Skip to content

fix(coinjoin): push finalized DSTX directly to participants before DSCOMPLETE#7478

Open
PastaPastaPasta wants to merge 1 commit into
dashpay:developfrom
PastaPastaPasta:coinjoin-input-reuse-race
Open

fix(coinjoin): push finalized DSTX directly to participants before DSCOMPLETE#7478
PastaPastaPasta wants to merge 1 commit into
dashpay:developfrom
PastaPastaPasta:coinjoin-input-reuse-race

Conversation

@PastaPastaPasta

@PastaPastaPasta PastaPastaPasta commented Jul 22, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

A CoinJoin participant releases its selected inputs as soon as it processes DSCOMPLETE(MSG_SUCCESS). However, the coordinator announces the finalized mixing transaction only via PeerRelayInv (delayed inventory trickling) while DSCOMPLETE is pushed directly, so the completion message can overtake the INV/GETDATA/DSTX exchange. During that window the session inputs are neither wallet-locked nor marked spent, so with -coinjoinmultisession=1 (where the one-block cooldown is bypassed) another session can select and sign the very same inputs, producing two valid CoinJoin transactions spending the same outpoint. One will later be rejected.

The broken invariant: after a successful session, a participant must observe the finalized transaction spending its inputs before it releases them for reuse. The old ordering allowed the release to happen first.

What was done?

CommitFinalTransaction now pushes the fully signed DSTX directly to every mixing participant before sending DSCOMPLETE, so with in-order message processing participants observe the spend of their inputs (via mempool acceptance) before the success notification unlocks them. The inv-based relay to the rest of the network is unchanged. DSTX is an existing message, so no protocol change: old clients accept the direct push as-is (unsolicited TX/DSTX is explicitly non-punishable in net_processing), and the push is best-effort — a participant that misses it still gets the transaction via the regular inv relay, i.e. the previous behavior.

Note: since the fix is coordinator-side, the race is closed as masternodes upgrade; a client mixing through an un-upgraded coordinator retains the previous behavior.

How Has This Been Tested?

  • coinjoin_tests suite passes locally.
  • Not covered: an end-to-end functional test that delays the DSTX to one participant of a real multi-party mixing session — there is currently no functional-test harness for actual mixing sessions.

Breaking Changes

None. No wire-protocol changes (DSTX push uses an existing message).

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation
  • I have assigned this pull request to a milestone (for repository code-owners and collaborators only)

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

CCoinJoinServer::CommitFinalTransaction now retrieves the fully signed DSTX and directly relays it to current mixing participants before continuing the existing inventory-based relay. The new lock-protected helper sends NetMsgType::DSTX messages through connman.ForNode; individual delivery failures are logged without aborting the process.

Estimated code review effort: 2 (Simple) | ~10 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CCoinJoinServer
  participant Connman
  participant ParticipantNode
  CCoinJoinServer->>CCoinJoinServer: create fully signed DSTX
  CCoinJoinServer->>Connman: send DSTX to participant address
  Connman->>ParticipantNode: deliver signed DSTX
Loading

Suggested reviewers: udjinm6

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main change: directly relaying the finalized DSTX to participants before DS completion.
Description check ✅ Passed The description matches the code change and explains the race fix and relay order.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/coinjoin/server.cpp (1)

391-397: 🎯 Functional Correctness | 🔵 Trivial | 🏗️ Heavy lift

Add a functional test for DSTX-before-DSCOMPLETE ordering.

The new behavior depends on per-participant message ordering; a delayed-DSTX scenario should assert that the participant receives DSTX before DSCOMPLETE.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/coinjoin/server.cpp` around lines 391 - 397, Add a functional test
covering the delayed-DSTX path around RelayDSTXToParticipants and DSCOMPLETE
handling. Configure the scenario so DSTX delivery is delayed, then assert each
mixing participant receives DSTX before DSCOMPLETE, preserving the intended
per-participant message ordering.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/coinjoin/client.cpp`:
- Around line 702-705: Update CheckPendingObservations so it releases
m_wallet->cs_wallet before calling m_wallet->chain().findCoins(coins), then
reacquires cs_wallet before evaluating or updating the pending observation
result. Preserve the existing timeout loop and IsSpent handling while ensuring
all wallet state access after the query occurs under the re-acquired lock.

---

Nitpick comments:
In `@src/coinjoin/server.cpp`:
- Around line 391-397: Add a functional test covering the delayed-DSTX path
around RelayDSTXToParticipants and DSCOMPLETE handling. Configure the scenario
so DSTX delivery is delayed, then assert each mixing participant receives DSTX
before DSCOMPLETE, preserving the intended per-participant message ordering.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 70343333-a0d7-4cb9-8ae5-e4d22853ddbd

📥 Commits

Reviewing files that changed from the base of the PR and between 87692f7 and e18b458.

📒 Files selected for processing (6)
  • src/coinjoin/client.cpp
  • src/coinjoin/client.h
  • src/coinjoin/server.cpp
  • src/coinjoin/server.h
  • src/rpc/coinjoin.cpp
  • src/wallet/test/coinjoin_tests.cpp

Comment thread src/coinjoin/client.cpp Outdated
@thepastaclaw

thepastaclaw commented Jul 23, 2026

Copy link
Copy Markdown

⛔ Blockers found — Sonnet deferred (commit 6b178c5)
Canonical validated blockers: 1

@thephez thephez added the RPC Some notable changes to RPC params/behaviour/descriptions label Jul 23, 2026
…COMPLETE

The coordinator announced the finalized mixing transaction only via PeerRelayInv, which queues an inventory item subject to trickle delays, while DSCOMPLETE is pushed directly. A participant could therefore process the success notification long before receiving the transaction that spends its inputs. Push the fully signed DSTX directly to every mixing participant before sending DSCOMPLETE so that, with in-order message processing, participants observe the spend of their inputs before the completion message. The inv-based relay to the rest of the network is unchanged, and DSTX is an existing message so older clients handle the direct push as-is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@PastaPastaPasta
PastaPastaPasta force-pushed the coinjoin-input-reuse-race branch from e18b458 to 6b178c5 Compare July 23, 2026 15:41
@PastaPastaPasta PastaPastaPasta changed the title fix(coinjoin): prevent multi-session input reuse race between DSCOMPLETE and DSTX delivery fix(coinjoin): push finalized DSTX directly to participants before DSCOMPLETE Jul 23, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6b178c5098

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/coinjoin/server.cpp
Comment on lines +395 to +396
if (const auto dstx = m_dstxman.GetDSTX(hashTx); dstx) {
WITH_LOCK(cs_coinjoin, RelayDSTXToParticipants(dstx));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep session inputs locked until the wallet observes the DSTX

When a participant receives this direct DSTX, ProcessTransaction only enqueues the wallet's TransactionAddedToMempool callback; it does not synchronously mark the wallet input spent. The following directly queued DSCOMPLETE is handled immediately and CompletedTransaction() calls UnlockCoins(), so if the validation callback queue has not drained (a normal scheduling race, or a backlog), a -coinjoinmultisession=1 session can still select and sign the same input. Thus sending the messages in socket order does not establish the claimed wallet-observation ordering; retain the lock until the wallet-side callback/observed spend is processed.

AGENTS.md reference: AGENTS.md:L164-L164

Useful? React with 👍 / 👎.

@PastaPastaPasta PastaPastaPasta removed the RPC Some notable changes to RPC params/behaviour/descriptions label Jul 23, 2026

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preliminary review — Codex only

The change guarantees DSTX-before-DSCOMPLETE ordering on the wire, but it does not guarantee that the wallet records the spend before completion unlocks the inputs. Because mempool notifications are asynchronous, the input-reuse race remains reachable when multi-session CoinJoin is enabled.

Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — dash-core-commit-history (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 1 blocking

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/coinjoin/server.cpp`:
- [BLOCKING] src/coinjoin/server.cpp:391-396: Direct DSTX ordering does not prevent wallet input reuse
  Processing DSTX first only adds the transaction to the mempool and enqueues `TransactionAddedToMempool`; `validationinterface.cpp` dispatches that notification asynchronously, so `CWallet::transactionAddedToMempool()` may not yet have called `SyncTransaction()` and `AddToSpends()` when the next DSCOMPLETE is processed. DSCOMPLETE then calls `CompletedTransaction()` and unlocks the session inputs, while `AvailableCoins()` still sees them as unspent because it relies on the wallet's lock and `mapTxSpends` state. This is reachable with `-coinjoinmultisession=1`: if the scheduler dequeues a due CoinJoin maintenance task before the newly queued validation callback and blocks on `cs_wallet_manager_map` while DSCOMPLETE holds it, the maintenance task resumes immediately after completion unlocks the inputs and can select them before the same scheduler thread processes the wallet callback. Synchronize preceding validation callbacks before dispatching a validated matching DSCOMPLETE without holding CoinJoin locks, or retain the input locks until wallet observation is confirmed. Add a regression test that delays the validation callback and verifies that successful completion cannot make the spent inputs selectable.

Comment thread src/coinjoin/server.cpp
Comment on lines +391 to +396
// Push the fully signed transaction directly to every mixing participant first:
// the inv-based relay below is subject to delayed trickling, so without this a
// participant could process DSCOMPLETE (which is sent directly) and release its
// inputs for reuse before ever seeing the transaction that spends them.
if (const auto dstx = m_dstxman.GetDSTX(hashTx); dstx) {
WITH_LOCK(cs_coinjoin, RelayDSTXToParticipants(dstx));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Direct DSTX ordering does not prevent wallet input reuse

Processing DSTX first only adds the transaction to the mempool and enqueues TransactionAddedToMempool; validationinterface.cpp dispatches that notification asynchronously, so CWallet::transactionAddedToMempool() may not yet have called SyncTransaction() and AddToSpends() when the next DSCOMPLETE is processed. DSCOMPLETE then calls CompletedTransaction() and unlocks the session inputs, while AvailableCoins() still sees them as unspent because it relies on the wallet's lock and mapTxSpends state. This is reachable with -coinjoinmultisession=1: if the scheduler dequeues a due CoinJoin maintenance task before the newly queued validation callback and blocks on cs_wallet_manager_map while DSCOMPLETE holds it, the maintenance task resumes immediately after completion unlocks the inputs and can select them before the same scheduler thread processes the wallet callback. Synchronize preceding validation callbacks before dispatching a validated matching DSCOMPLETE without holding CoinJoin locks, or retain the input locks until wallet observation is confirmed. Add a regression test that delays the validation callback and verifies that successful completion cannot make the spent inputs selectable.

source: ['codex']

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree; I'll work on another PR. I want this one just doing the send ordering fix

@PastaPastaPasta
PastaPastaPasta requested review from UdjinM6 and knst July 24, 2026 23:29

@UdjinM6 UdjinM6 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking

  1. Don't land RelayDSTXToParticipants as-is — it leaks the participant set.
    The participant relays the DSTX onward unconditionally on ATMP success (net_processing.cpp:4844), with no suppression anywhere. Today it learns of the tx via the coordinator's inbound trickle (NextInvToInbounds, mean 5s) plus a getdata round trip, so it announces well behind the crowd. The direct push moves it to t0 + RTT, announcing concurrently with the coordinator's own outbound trickle (mean 2s). A sybil observer then sees the coordinator (self-identifying — it signs the DSTX) plus its N participants as the earliest announcers, resolving that round's anonymity set to IPs. Coordinator-side deployment means un-upgraded clients get this with no opt-out.

  2. Fix it client-side instead.
    In CCoinJoinClientSession::CompletedTransaction (client.cpp:575), on MSG_SUCCESS hold vecOutPointLocked until the wallet observes the spend — the client already has finalMutableTransaction, it signed it — with a timeout fallback so locks can't leak if the tx never lands. Deterministic, no propagation change, and the node bearing the risk deploys the fix.

  3. Document the MN-only-publication rationale in the code. It isn't written down anywhere, which is plausibly how this approach got proposed.

If the coordinator-side approach is pursued anyway

  1. Correct the ordering claim.
    "Participants observe the spend before DSCOMPLETE unlocks" is true at the mempool level only. CMainSignals::TransactionAddedToMempool enqueues to the scheduler (validationinterface.cpp:215), so the wallet's mapTxSpends updates asynchronously. It works in practice because coin selection runs on that same single-threaded scheduler via DoMaintenance, but the invariant rests on scheduler FIFO ordering, not P2P message ordering — say so in the comment, or a future refactor reopens the race silently.

  2. Keep the CCoinJoinBroadcastTx instead of re-querying.
    server.cpp:381-394 looks the map up twice; if the second GetDSTX misses, the fix is skipped with no log: CCoinJoinBroadcastTx dstx = m_dstxman.GetDSTX(hashTx);
    if (!dstx) { dstx = CCoinJoinBroadcastTx(...); dstx.vchSig = ...; m_dstxman.AddDSTX(dstx); }

  3. Consider folding the push into RelayCompletedTransaction's existing loop (server.cpp:906) — one ForNode per peer instead of two, drops a near-copy of RelayFinalTransaction, and makes the DSTX→DSCOMPLETE adjacency structural rather than a property of
    two distant call sites.

  4. Note the redundant inv. PeerRelayInv still announces to participants who just got the push; they answer AlreadyHave and never getdata. Harmless, but comment it so it doesn't read as an oversight.

  5. Add a test for the invariant. coinjoin_tests passing is a regression check, not evidence for the fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants