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

@@ -177,11 +177,42 @@ HushChatMemoGroupingResult groupHushChatMemoOutputs(const std::vector<HushChatMe
HushChatMemoGroupingResult result;
std::optional<HushChatMemoOutput> pending_header_output;
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) {
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::vector<HushChatMe
pending_header_output = output;
pending_header = std::move(parsed.header);
tryPairHeldPayload(); // the payload may have arrived first (shuffled output order)
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;
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();
if (pending_payload_output) ++result.ignored_memo_count; // orphan payload, no header arrived
return result;
}