fix(chat): order-tolerant memo pairing (survives the daemon output shuffle)

The full-node daemon unconditionally shuffles a transaction's shielded outputs
(transaction_builder.cpp ShuffleOutputs, to hide the change position), so the
two 0-value HushChat memo outputs land at random note positions — the header is
NOT guaranteed to precede its payload. The receive parser paired a header only
with the NEXT payload in position order, so a shuffled send failed to pair (the
message was silently lost) ~50%+ of the time.

groupHushChatMemoOutputs now holds an orphan payload (one seen before its header)
and pairs it with the header when it arrives, so header↔payload pair regardless
of on-chain order. A chat tx carries exactly one header + one payload (the harvest
already skips change outputs), so this is unambiguous.

This is the complete fix for the ObsidianDragon ecosystem: full-node and lite both
receive through this one C++ parser, so full-node→full-node and full-node→lite now
work despite the shuffle, with no daemon fork and no lite-backend change. Context:
SilentDragonXLite (which hardcodes payload position==1 and forced the daemon/backend
question) is being replaced by ObsidianDragonLite. Lite send never shuffles.

Tests: shuffled receive (payload-before-header, and change-interspersed) pairs +
decrypts; all existing ordered-input tests unchanged. Verified: Linux + Windows
build, ctest 100%, hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 16:46:57 -05:00
parent e191680782
commit 2e6188a36d
2 changed files with 85 additions and 19 deletions

View File

@@ -5974,6 +5974,46 @@ void testHushChatTransport()
EXPECT_EQ(bobSvc.store().conversation("cid-tx")[0].body, std::string("over the wire"));
}
// Phase 5: the full-node daemon SHUFFLES the two memo outputs, so the header may land at a HIGHER
// note position than its payload. The receive parser must pair them regardless of on-chain order.
void testHushChatShuffledReceive()
{
using namespace dragonx::chat;
ChatKeyPair alice, bob;
ChatIdentityResult ra = deriveChatIdentityFromSecret("shuf-alice", alice, true);
ChatIdentityResult rb = deriveChatIdentityFromSecret("shuf-bob", bob, true);
ChatService bobSvc;
bobSvc.setIdentity(bob);
// Payload BEFORE header (positions swapped, as a shuffle may produce).
OutgoingChatMemos memos;
EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex,
"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({1, memos.headerMemo}); // header at the HIGHER position
auto extracted = extractHushChatTransactionMetadata(tx, true);
EXPECT_EQ((int)extracted.metadata.size(), 1);
EXPECT_EQ(bobSvc.ingest(extracted.metadata, {}, 3), 1);
EXPECT_EQ(bobSvc.store().conversation("cid-shuf")[0].body, std::string("shuffled hi"));
// With a change output (empty memo) interspersed and the header last, still pairs.
OutgoingChatMemos memos2;
EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex,
"zs-bob", "cid-shuf2", "with change", memos2) == ChatComposeStatus::Ok);
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({2, memos2.headerMemo}); // header last
auto ex2 = extractHushChatTransactionMetadata(tx2, true);
EXPECT_EQ((int)ex2.metadata.size(), 1);
EXPECT_EQ(bobSvc.ingest(ex2.metadata, {}, 4), 1);
EXPECT_EQ(bobSvc.store().conversation("cid-shuf2")[0].body, std::string("with change"));
}
} // namespace
int main()
@@ -6070,6 +6110,7 @@ int main()
testHushChatDatabase();
testHushChatOutgoing();
testHushChatTransport();
testHushChatShuffledReceive();
testAddressChecksumValidation();
testLiteServerProbeLive();
testXmrigLiveInstall();