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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user