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:
@@ -177,11 +177,42 @@ HushChatMemoGroupingResult groupHushChatMemoOutputs(const std::vector<HushChatMe
|
|||||||
HushChatMemoGroupingResult result;
|
HushChatMemoGroupingResult result;
|
||||||
std::optional<HushChatMemoOutput> pending_header_output;
|
std::optional<HushChatMemoOutput> pending_header_output;
|
||||||
std::optional<HushChatHeader> pending_header;
|
std::optional<HushChatHeader> 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<HushChatMemoOutput> pending_payload_output;
|
||||||
|
|
||||||
auto addIssue = [&](HushChatMemoGroupingIssue issue, std::size_t position, std::string detail) {
|
auto addIssue = [&](HushChatMemoGroupingIssue issue, std::size_t position, std::string detail) {
|
||||||
result.issues.push_back(HushChatMemoGroupingIssueInfo{issue, position, std::move(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 = [&]() {
|
auto clearPendingAsMissing = [&]() {
|
||||||
if (!pending_header_output) return;
|
if (!pending_header_output) return;
|
||||||
addIssue(HushChatMemoGroupingIssue::MissingPayload,
|
addIssue(HushChatMemoGroupingIssue::MissingPayload,
|
||||||
@@ -216,33 +247,27 @@ HushChatMemoGroupingResult groupHushChatMemoOutputs(const std::vector<HushChatMe
|
|||||||
|
|
||||||
pending_header_output = output;
|
pending_header_output = output;
|
||||||
pending_header = std::move(parsed.header);
|
pending_header = std::move(parsed.header);
|
||||||
|
tryPairHeldPayload(); // the payload may have arrived first (shuffled output order)
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pending_header_output || !pending_header) {
|
// Non-header memo.
|
||||||
|
if (pending_header_output && pending_header) {
|
||||||
|
if (payloadMatchesHeader(output.memo)) {
|
||||||
|
emitPair(output);
|
||||||
|
} else {
|
||||||
|
++result.ignored_memo_count;
|
||||||
|
}
|
||||||
|
} else if (!pending_payload_output && !output.memo.empty()) {
|
||||||
|
// No header yet — hold this as a candidate payload for a header still to come.
|
||||||
|
pending_payload_output = output;
|
||||||
|
} else {
|
||||||
++result.ignored_memo_count;
|
++result.ignored_memo_count;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const bool payload_matches = pending_header->type == 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();
|
clearPendingAsMissing();
|
||||||
|
if (pending_payload_output) ++result.ignored_memo_count; // orphan payload, no header arrived
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5974,6 +5974,46 @@ void testHushChatTransport()
|
|||||||
EXPECT_EQ(bobSvc.store().conversation("cid-tx")[0].body, std::string("over the wire"));
|
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
|
} // namespace
|
||||||
|
|
||||||
int main()
|
int main()
|
||||||
@@ -6070,6 +6110,7 @@ int main()
|
|||||||
testHushChatDatabase();
|
testHushChatDatabase();
|
||||||
testHushChatOutgoing();
|
testHushChatOutgoing();
|
||||||
testHushChatTransport();
|
testHushChatTransport();
|
||||||
|
testHushChatShuffledReceive();
|
||||||
testAddressChecksumValidation();
|
testAddressChecksumValidation();
|
||||||
testLiteServerProbeLive();
|
testLiteServerProbeLive();
|
||||||
testXmrigLiveInstall();
|
testXmrigLiveInstall();
|
||||||
|
|||||||
Reference in New Issue
Block a user