diff --git a/src/chat/chat_protocol.cpp b/src/chat/chat_protocol.cpp index bcda4af..c23d38b 100644 --- a/src/chat/chat_protocol.cpp +++ b/src/chat/chat_protocol.cpp @@ -177,11 +177,42 @@ HushChatMemoGroupingResult groupHushChatMemoOutputs(const std::vector pending_header_output; std::optional pending_header; + // A payload memo seen BEFORE its header. The full-node daemon shuffles the two 0-value memo + // outputs of a chat tx (transaction_builder ShuffleOutputs), so the header is NOT guaranteed to + // occupy the lower position. We hold an orphan payload until its header arrives and pair them + // regardless of on-chain order (a chat tx carries exactly one header + one payload). + std::optional pending_payload_output; auto addIssue = [&](HushChatMemoGroupingIssue issue, std::size_t position, std::string detail) { result.issues.push_back(HushChatMemoGroupingIssueInfo{issue, position, std::move(detail)}); }; + auto payloadMatchesHeader = [&](const std::string& memo) { + return pending_header->type == HushChatHeaderType::Message + ? isCiphertextPayloadCandidate(memo) + : isContactPayloadCandidate(memo); + }; + + auto emitPair = [&](const HushChatMemoOutput& payloadOutput) { + HushChatMemoPair pair; + pair.header = std::move(*pending_header); + pair.header_position = pending_header_output->position; + pair.payload_position = payloadOutput.position; + pair.payload_memo = payloadOutput.memo; + result.pairs.push_back(std::move(pair)); + pending_header_output.reset(); + pending_header.reset(); + }; + + // Pair a held (early) payload with the now-pending header, if it matches the header's type. + auto tryPairHeldPayload = [&]() { + if (!pending_header || !pending_payload_output) return; + if (payloadMatchesHeader(pending_payload_output->memo)) { + emitPair(*pending_payload_output); + pending_payload_output.reset(); + } + }; + auto clearPendingAsMissing = [&]() { if (!pending_header_output) return; addIssue(HushChatMemoGroupingIssue::MissingPayload, @@ -216,33 +247,27 @@ HushChatMemoGroupingResult groupHushChatMemoOutputs(const std::vectortype == HushChatHeaderType::Message - ? isCiphertextPayloadCandidate(output.memo) - : isContactPayloadCandidate(output.memo); - if (!payload_matches) { - ++result.ignored_memo_count; - continue; - } - - HushChatMemoPair pair; - pair.header = std::move(*pending_header); - pair.header_position = pending_header_output->position; - pair.payload_position = output.position; - pair.payload_memo = output.memo; - result.pairs.push_back(std::move(pair)); - pending_header_output.reset(); - pending_header.reset(); } clearPendingAsMissing(); + if (pending_payload_output) ++result.ignored_memo_count; // orphan payload, no header arrived return result; } diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 00c747a..188c756 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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();