feat(chat): tier-3 larger messages — protocol + split + cross-tx reassembly core

Foundation for messages too large for a single transaction (tier 2's 16-output
ceiling, ~4000 chars). A long message is now split across SEVERAL transactions
that share a random message id; each carries a slice of the one ciphertext with
GLOBAL chunk indices, and the receiver reassembles across the siblings.

Protocol: the Message header gains an optional "mid" (message id); "n" is reused
as the TOTAL chunk count and its bound raised to kHushChatMaxMessageChunks (64 ⇒
up to ~16 KB / 4 txs, tunable, ≤256 for the 2-hex index). The grouper, when a
tx's header carries "mid", emits a PARTIAL (its global-indexed chunks + message
id + total) instead of reassembling in-place; HushChatTransactionMetadata
carries those partials through. Crypto is untouched — one secretstream session
for the whole message, chunking is transport-only.

Compose: splitOutgoingMessage() returns one OutgoingChatMemos per transaction
(one element = the classic tier-1/2 single tx, byte-identical; several = tier 3).
buildOutgoingMessage (single-tx) is unchanged. Old clients bound "n" to 16 so a
tier-3 header fails to parse and its memos are dropped — graceful degradation.

Unit-tested (testHushChatLargeMessageTier3): a ~9000-char message splits into 3
txs, every output shuffled, slices gathered across siblings, reassembled and
decrypted back to the original; short message stays single-tx (no mid); the
total-chunk cap is enforced. The stateful accumulator (persisted, confirm-gated),
sequential broadcast, and progressive/incomplete UI follow in later commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 12:20:11 -05:00
co-authored by Claude Opus 4.8
parent 1729f7dfad
commit cc08854237
5 changed files with 263 additions and 6 deletions
+82
View File
@@ -15746,6 +15746,87 @@ void testHushChatLargeMessage()
cid, huge, tooBig) == ChatComposeStatus::TooLong);
}
// Larger messages (tier 3): a message too big for one tx is split across SEVERAL transactions that
// share a message id; each carries a slice of the one ciphertext with GLOBAL chunk indices. The
// receiver accumulates slices across the sibling txs (shuffled outputs, any tx order) and reassembles.
void testHushChatLargeMessageTier3()
{
using namespace dragonx::chat;
ChatKeyPair alice, bob;
ChatIdentityResult ra = deriveChatIdentityFromSecret("lm3-alice", alice, true);
ChatIdentityResult rb = deriveChatIdentityFromSecret("lm3-bob", bob, true);
EXPECT_TRUE(ra.status == ChatIdentityStatus::Ready && rb.status == ChatIdentityStatus::Ready);
const std::string cid = "conv-lm3";
// ~9000 chars → ~36 chunks → 3 transactions (16 + 16 + 4), well past the single-tx cap of 16.
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() > 1); // actually multi-transaction
// Every sibling header carries the SAME message id and the SAME total chunk count.
std::string midField;
for (const auto& m : txs) {
EXPECT_TRUE(m.headerMemo.find("\"mid\":") != std::string::npos);
EXPECT_TRUE(m.headerMemo.find("\"n\":") != std::string::npos);
}
// Extract each transaction (with its outputs REVERSED to mimic the daemon's per-tx ShuffleOutputs),
// gather every slice, and confirm they reassemble into the original plaintext.
std::unordered_map<int, std::string> chunks; // global index -> ciphertext-hex chunk
std::string mid, e, p;
int total = 0;
for (std::size_t t = 0; t < txs.size(); ++t) {
std::vector<std::string> wire;
wire.push_back(txs[t].headerMemo);
for (const auto& pm : txs[t].payloadMemos) wire.push_back(pm);
std::reverse(wire.begin(), wire.end());
HushChatTransactionInput tx; tx.txid = "lm3-" + std::to_string(t);
for (std::size_t i = 0; i < wire.size(); ++i) tx.outputs.push_back({i, wire[i]});
auto ext = extractHushChatTransactionMetadata(tx, true);
EXPECT_EQ((int)ext.metadata.size(), 1);
const auto& md = ext.metadata[0];
EXPECT_TRUE(md.type == HushChatHeaderType::Message);
EXPECT_TRUE(!md.message_id.empty());
EXPECT_TRUE(md.payload_memo.empty()); // a slice carries chunks, not a whole payload
EXPECT_TRUE(!md.partial_chunks.empty());
if (mid.empty()) { mid = md.message_id; e = md.secretstream_header_hex; p = md.sender_public_key_hex; total = md.total_parts; }
EXPECT_EQ(md.message_id, mid); // siblings agree
EXPECT_EQ(md.total_parts, total);
EXPECT_EQ(md.secretstream_header_hex, e);
for (const auto& c : md.partial_chunks) {
EXPECT_TRUE(chunks.find(c.first) == chunks.end()); // no duplicate index
chunks[c.first] = c.second;
}
}
EXPECT_EQ((int)chunks.size(), total); // every chunk arrived exactly once
std::string full;
for (int i = 0; i < total; ++i) {
EXPECT_TRUE(chunks.find(i) != chunks.end()); // contiguous 0..total-1
full += chunks[i];
}
std::string plain;
EXPECT_TRUE(decryptIncoming(bob, p, e, full, plain) == ChatCryptoStatus::Ok);
EXPECT_EQ(plain, big);
// A short message still yields a single tier-1/2 transaction with no message id.
std::vector<OutgoingChatMemos> one;
EXPECT_TRUE(splitOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex, "zs-bob",
cid, "hi there", one) == ChatComposeStatus::Ok);
EXPECT_EQ((int)one.size(), 1);
EXPECT_TRUE(one[0].headerMemo.find("\"mid\":") == std::string::npos);
// Cap: beyond kHushChatMaxMessageChunks total chunks it is refused even across transactions.
std::string tooHuge(20000, 'z');
std::vector<OutgoingChatMemos> over;
EXPECT_TRUE(splitOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex, "zs-bob",
cid, tooHuge, over) == ChatComposeStatus::TooLong);
}
void testHushChatOutgoing()
{
using namespace dragonx::chat;
@@ -20356,6 +20437,7 @@ int main()
testChatStoreLatestAndMove();
testHushChatOutgoing();
testHushChatLargeMessage();
testHushChatLargeMessageTier3();
testHushChatTransport();
testHushChatShuffledReceive();
testWalletFileProbe();