feat(chat): tier-3 reassembly accumulator in the chat service

Second half of tier-3 larger messages: the receive side. ChatService now buffers
the slices of a multi-transaction message (keyed by the shared message id) as
they arrive — across transactions and across scans — and reassembles the whole
ciphertext once every chunk is present, then decrypts and surfaces it as one
message. The buffer is NOT persisted: a rescan re-sends every slice, so it
re-derives from chain exactly like the control reducer, and survives a restart
mid-receive (the next full scan re-feeds the siblings and completes it).

Ingest's per-message build/decrypt/guard logic is refactored into one shared
lambda so a reassembled tier-3 message passes the identical block / on-chain-hide
/ sensitive-vault / tombstone checks as a single-tx message. A completed group is
retired after it surfaces (deduped across scans by its stable canonical txid —
the smallest sibling txid); a group withheld only because the sensitive vault is
locked is kept so a later unlock + scan completes it. Our own slices (sender ==
us) are skipped — the local echo records the outgoing message. The pending map is
bounded (256 groups) against a flood of never-completing partials.

Unit-tested (testHushChatTier3Reassembly): a ~9000-char message's first slice
alone surfaces nothing; once every slice has arrived it reassembles, decrypts to
the original and surfaces once; a re-scan dedups; our own slices never ingest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 12:31:42 -05:00
co-authored by Claude Opus 4.8
parent cc08854237
commit 5e33bf2c41
3 changed files with 166 additions and 24 deletions
+60
View File
@@ -15827,6 +15827,65 @@ void testHushChatLargeMessageTier3()
cid, tooHuge, over) == ChatComposeStatus::TooLong);
}
// Tier 3 through the ChatService: slices arriving across separate ingests (separate scans) accumulate
// until the message is whole, then it surfaces once; a re-scan of every slice dedups it.
void testHushChatTier3Reassembly()
{
using namespace dragonx::chat;
ChatKeyPair alice, bob;
ChatIdentityResult ra = deriveChatIdentityFromSecret("t3svc-alice", alice, true);
ChatIdentityResult rb = deriveChatIdentityFromSecret("t3svc-bob", bob, true);
EXPECT_TRUE(ra.status == ChatIdentityStatus::Ready && rb.status == ChatIdentityStatus::Ready);
const std::string cid = "conv-t3svc";
std::string big;
for (int i = 0; i < 9000; ++i) big += static_cast<char>('a' + (i % 26));
std::vector<OutgoingChatMemos> txs;
EXPECT_TRUE(splitOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex, "zs-bob",
cid, big, txs) == ChatComposeStatus::Ok);
EXPECT_TRUE((int)txs.size() >= 3);
// Extract each transaction into partial metadata (outputs reversed to mimic the daemon shuffle).
std::vector<HushChatTransactionMetadata> all;
for (std::size_t t = 0; t < txs.size(); ++t) {
std::vector<std::string> wire{ txs[t].headerMemo };
for (const auto& pm : txs[t].payloadMemos) wire.push_back(pm);
std::reverse(wire.begin(), wire.end());
HushChatTransactionInput tx; tx.txid = "t3svc-" + std::to_string(t);
for (std::size_t i = 0; i < wire.size(); ++i) tx.outputs.push_back({i, wire[i]});
auto md = extractHushChatTransactionMetadata(tx, true).metadata;
for (auto& m : md) all.push_back(std::move(m));
}
EXPECT_TRUE(all.size() == txs.size()); // one partial metadata per transaction
ChatService svc;
svc.setIdentity(bob);
// Stage 1: only the FIRST transaction's slice has arrived — the message is incomplete, nothing surfaces.
std::vector<HushChatTransactionMetadata> firstOnly(all.begin(), all.begin() + 1);
EXPECT_EQ(svc.ingest(firstOnly, {}, 1000), 0);
EXPECT_EQ((int)svc.store().conversation(cid).size(), 0);
// Stage 2: the whole history re-arrives (every slice) — reassembled, decrypted and surfaced once.
EXPECT_EQ(svc.ingest(all, {}, 1000), 1);
std::vector<ChatMessage> conv = svc.store().conversation(cid);
EXPECT_EQ((int)conv.size(), 1);
EXPECT_EQ(conv[0].body, big);
EXPECT_TRUE(conv[0].direction == ChatDirection::Incoming);
EXPECT_EQ(conv[0].peer_public_key_hex, ra.public_key_hex);
// A re-scan of the same slices does not duplicate the message.
EXPECT_EQ(svc.ingest(all, {}, 1000), 0);
EXPECT_EQ((int)svc.store().conversation(cid).size(), 1);
// Our OWN tier-3 slices (sender == us) are never ingested as incoming — the echo records them.
ChatService selfSvc;
selfSvc.setIdentity(alice);
EXPECT_EQ(selfSvc.ingest(all, {}, 1000), 0);
EXPECT_EQ((int)selfSvc.store().conversation(cid).size(), 0);
}
void testHushChatOutgoing()
{
using namespace dragonx::chat;
@@ -20438,6 +20497,7 @@ int main()
testHushChatOutgoing();
testHushChatLargeMessage();
testHushChatLargeMessageTier3();
testHushChatTier3Reassembly();
testHushChatTransport();
testHushChatShuffledReceive();
testWalletFileProbe();