feat(chat): larger messages — multi-output chunking in one transaction (tier 2)

A message whose ciphertext exceeds one 512-byte memo is now split across up to
kHushChatMaxMessageParts (16) payload outputs in a SINGLE transaction, lifting
the ~239-byte cap to a few KB while staying atomic, instant (0-conf), and
one-fee. The crypto is untouched (one secretstream push/pull); chunking is a
transport concern:
- Header gains an optional part count `n` (emitted only when >1, so a 1-part
  message stays byte-identical on the wire); bounded in the parser.
- Each chunk is a 2-hex part-index prefix + its ciphertext slice, so the
  receiver reorders them after the daemon's output shuffle.
- OutgoingChatMemos carries a payload vector; chatSendOutputs emits header + N
  payloads (both broadcast call sites already iterate the result).
- The grouper gains a multi-part pre-scan: a lone Message header with n>1 →
  collect the N indexed chunks, sort, strip, concat → one pair; anything else
  falls through to the classic single-pair path (older clients mis-pair and
  drop — graceful). A body beyond the cap is refused as TooLong (tier 3 later).
Round-trip (shuffled outputs), 1-part byte-identical, and cap all unit-tested.

Larger-messages tier 2; tier 3 (multi-tx) + the denser raw-memo encoding remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 11:16:22 -05:00
co-authored by Claude Opus 4.8
parent 19e9b62632
commit 6b93a8ab60
5 changed files with 207 additions and 31 deletions
+64 -11
View File
@@ -15098,7 +15098,7 @@ void testHushChatControlMemo()
EXPECT_EQ(cm.recipientZaddr, std::string("zs-self"));
HushChatTransactionInput rt; rt.txid = "txid-ctrl-rt";
rt.outputs.push_back({0, cm.headerMemo});
rt.outputs.push_back({1, cm.payloadMemo});
rt.outputs.push_back({1, cm.payloadMemos[0]});
auto rx = extractHushChatTransactionMetadata(rt, true);
EXPECT_EQ((int)rx.metadata.size(), 1);
EXPECT_TRUE(rx.metadata[0].type == HushChatHeaderType::Control);
@@ -15132,7 +15132,7 @@ void testHushChatControlReducer()
auto feed = [&](const OutgoingChatMemos& m, const std::string& txid, std::int64_t ts) {
HushChatTransactionInput tx; tx.txid = txid;
tx.outputs.push_back({0, m.headerMemo});
tx.outputs.push_back({1, m.payloadMemo});
tx.outputs.push_back({1, m.payloadMemos[0]});
auto ex = extractHushChatTransactionMetadata(tx, true);
return svc.ingest(ex.metadata, {{txid, ts}}, ts);
};
@@ -15409,7 +15409,7 @@ void testHushChatSensitiveService()
EXPECT_TRUE(buildOutgoingMessage(peer, rp.public_key_hex, "zs-peer", rm.public_key_hex, "zs-me",
cid, body, m) == ChatComposeStatus::Ok);
HushChatTransactionInput tx; tx.txid = txid;
tx.outputs.push_back({0, m.headerMemo}); tx.outputs.push_back({1, m.payloadMemo});
tx.outputs.push_back({0, m.headerMemo}); tx.outputs.push_back({1, m.payloadMemos[0]});
auto ex = extractHushChatTransactionMetadata(tx, true);
const std::int64_t t = static_cast<std::int64_t>(std::time(nullptr));
return svc.ingest(ex.metadata, {{txid, t}}, t);
@@ -15694,6 +15694,58 @@ void testHushChatDeleteConversation()
// Phase 4: outgoing memo construction round-trips through the receive parser + decrypt, and
// ChatService compose/recordOutgoing echoes into the store.
// Larger messages (tier 2): a long message is chunked across N index-prefixed payload outputs in ONE
// tx and reassembled on receipt regardless of on-chain output order; the cap refuses an over-long body.
void testHushChatLargeMessage()
{
using namespace dragonx::chat;
ChatKeyPair alice, bob;
ChatIdentityResult ra = deriveChatIdentityFromSecret("lm-alice", alice, true);
ChatIdentityResult rb = deriveChatIdentityFromSecret("lm-bob", bob, true);
EXPECT_TRUE(ra.status == ChatIdentityStatus::Ready && rb.status == ChatIdentityStatus::Ready);
const std::string cid = "conv-lm";
std::string big; // well beyond one memo (~239 bytes)
for (int i = 0; i < 1500; ++i) big += static_cast<char>('A' + (i % 26));
OutgoingChatMemos memos;
EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex, "zs-bob",
cid, big, memos) == ChatComposeStatus::Ok);
EXPECT_TRUE((int)memos.payloadMemos.size() > 1); // actually chunked
EXPECT_TRUE(memos.headerMemo.find("\"n\":") != std::string::npos); // carries the part count
// Build the tx (header + all payloads) and REVERSE the output order to mimic the daemon's
// ShuffleOutputs — payloads before the header, indices descending. Reassembly must still work.
std::vector<std::string> wire;
wire.push_back(memos.headerMemo);
for (const auto& p : memos.payloadMemos) wire.push_back(p);
std::reverse(wire.begin(), wire.end());
HushChatTransactionInput tx; tx.txid = "lm1";
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);
EXPECT_TRUE(ext.metadata[0].type == HushChatHeaderType::Message);
std::string plain;
EXPECT_TRUE(decryptIncoming(bob, ext.metadata[0].sender_public_key_hex,
ext.metadata[0].secretstream_header_hex,
ext.metadata[0].payload_memo, plain) == ChatCryptoStatus::Ok);
EXPECT_EQ(plain, big);
// A short message stays single-payload and byte-identical (no "n" field, no index prefix).
OutgoingChatMemos small;
EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex, "zs-bob",
cid, "hi", small) == ChatComposeStatus::Ok);
EXPECT_EQ((int)small.payloadMemos.size(), 1);
EXPECT_TRUE(small.headerMemo.find("\"n\":") == std::string::npos);
// Cap: a body needing more than kHushChatMaxMessageParts chunks is refused.
std::string huge(6000, 'x');
OutgoingChatMemos tooBig;
EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex, "zs-bob",
cid, huge, tooBig) == ChatComposeStatus::TooLong);
}
void testHushChatOutgoing()
{
using namespace dragonx::chat;
@@ -15717,7 +15769,7 @@ void testHushChatOutgoing()
HushChatTransactionInput tx;
tx.txid = "txout1";
tx.outputs.push_back({0, memos.headerMemo}); // header at the lower position
tx.outputs.push_back({1, memos.payloadMemo});
tx.outputs.push_back({1, memos.payloadMemos[0]});
auto extracted = extractHushChatTransactionMetadata(tx, true);
EXPECT_EQ((int)extracted.metadata.size(), 1);
@@ -15742,7 +15794,7 @@ void testHushChatOutgoing()
HushChatTransactionInput tx2;
tx2.txid = "txout2";
tx2.outputs.push_back({0, creq.headerMemo});
tx2.outputs.push_back({1, creq.payloadMemo});
tx2.outputs.push_back({1, creq.payloadMemos[0]});
auto ex2 = extractHushChatTransactionMetadata(tx2, true);
EXPECT_EQ((int)ex2.metadata.size(), 1);
EXPECT_TRUE(ex2.metadata[0].type == HushChatHeaderType::ContactRequest);
@@ -15817,18 +15869,18 @@ void testHushChatTransport()
"zs-bob", "cid-tx", "over the wire", memos) == ChatComposeStatus::Ok);
// Full-node: header at index 0, both to the peer, each "utf8:"-prefixed.
std::array<ChatSendOutput, 2> fn = chatSendOutputs(memos, /*utf8Prefix=*/true);
auto fn = chatSendOutputs(memos, /*utf8Prefix=*/true);
EXPECT_EQ(fn[0].address, std::string("zs-bob"));
EXPECT_EQ(fn[1].address, std::string("zs-bob"));
EXPECT_TRUE(fn[0].memo.rfind("utf8:", 0) == 0);
EXPECT_TRUE(fn[1].memo.rfind("utf8:", 0) == 0);
EXPECT_EQ(fn[0].memo, std::string("utf8:") + memos.headerMemo);
EXPECT_EQ(fn[1].memo, std::string("utf8:") + memos.payloadMemo);
EXPECT_EQ(fn[1].memo, std::string("utf8:") + memos.payloadMemos[0]);
// Lite: raw, no prefix.
std::array<ChatSendOutput, 2> lt = chatSendOutputs(memos, /*utf8Prefix=*/false);
auto lt = chatSendOutputs(memos, /*utf8Prefix=*/false);
EXPECT_EQ(lt[0].memo, memos.headerMemo);
EXPECT_EQ(lt[1].memo, memos.payloadMemo);
EXPECT_EQ(lt[1].memo, memos.payloadMemos[0]);
// On-chain form round-trips: the daemon stores the UTF-8 text (the string after "utf8:"), which
// is exactly what the harvest parser reads back (header first / lower position).
@@ -15863,7 +15915,7 @@ void testHushChatShuffledReceive()
"zs-bob", "cid-shuf", "shuffled hi", memos) == ChatComposeStatus::Ok);
HushChatTransactionInput tx;
tx.txid = "txshuf";
tx.outputs.push_back({0, memos.payloadMemo}); // payload at the LOWER position
tx.outputs.push_back({0, memos.payloadMemos[0]}); // payload at the LOWER position
tx.outputs.push_back({1, memos.headerMemo}); // header at the HIGHER position
auto extracted = extractHushChatTransactionMetadata(tx, true);
EXPECT_EQ((int)extracted.metadata.size(), 1);
@@ -15877,7 +15929,7 @@ void testHushChatShuffledReceive()
HushChatTransactionInput tx2;
tx2.txid = "txshuf2";
tx2.outputs.push_back({0, std::string()}); // change output: empty memo (ignored)
tx2.outputs.push_back({1, memos2.payloadMemo}); // payload
tx2.outputs.push_back({1, memos2.payloadMemos[0]}); // payload
tx2.outputs.push_back({2, memos2.headerMemo}); // header last
auto ex2 = extractHushChatTransactionMetadata(tx2, true);
EXPECT_EQ((int)ex2.metadata.size(), 1);
@@ -20303,6 +20355,7 @@ int main()
testChatStoreTxidIndex();
testChatStoreLatestAndMove();
testHushChatOutgoing();
testHushChatLargeMessage();
testHushChatTransport();
testHushChatShuffledReceive();
testWalletFileProbe();