Skip to content

fix(coinjoin): prevent multi-session input reuse race between DSCOMPLETE and DSTX delivery#7478

Open
PastaPastaPasta wants to merge 3 commits into
dashpay:developfrom
PastaPastaPasta:coinjoin-input-reuse-race
Open

fix(coinjoin): prevent multi-session input reuse race between DSCOMPLETE and DSTX delivery#7478
PastaPastaPasta wants to merge 3 commits 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, its inputs must remain unavailable to every other session until the wallet has observed the finalized transaction spending them. The old code released them on the coordinator's success message instead of on transaction observation.

What was done?

Client (defense in depth, main fix): on DSCOMPLETE(MSG_SUCCESS) the session's mixing inputs (the ones in vecEntries, i.e. exactly the inputs of the finalized transaction) are no longer unlocked. They move into a new per-wallet "pending observation" set in CCoinJoinClientManager and stay wallet-locked — persistently, via WalletBatch, so a restart cannot make them selectable again. They are released only when:

  • the wallet observes a mempool or confirmed transaction spending them (the normal case, checked every maintenance tick); or
  • a conservative terminal timeout (1 hour) expires and a fresh chain+mempool spentness check (findCoins) confirms the outpoint is still unspent everywhere we can see — this preserves wallet liveness if the finalized transaction never propagated, without reintroducing the race while it may still be in flight; or
  • the user manually unlocks the coin (e.g. lockunspent), which purges the pending entry.

The release check runs before the mixing gates in DoMaintenance, so pending inputs still unlock while mixing or CoinJoin itself is disabled. After a restart, persisted locks on denominated coins are reconciled: locks whose spend has already been observed are dropped, the rest are watched (spend-observation only, no terminal timeout — so a user's own persistent lock on a still-unspent denominated coin is never auto-released). Collateral inputs and all failure/DSCOMPLETE-error paths keep the previous immediate-unlock behavior, and keyHolderStorage.KeepAll() still runs on success. getcoinjoininfo gains a pending_inputs count.

Coordinator (ordering fix): 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 before the success notification. 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 old coordinators simply keep the previous behavior, which the client-side pending state now guards against.

How Has This Been Tested?

  • New unit test coinjoin_tests/coinjoin_pending_observation_tests covering: pending inputs stay locked and excluded from coin selection (AvailableCoins) while unrelated inputs remain selectable; an observed spending transaction releases the lock; an unspent input is released only after the terminal timeout; state reconstructed from persisted locks after a restart has no terminal timeout; a manual unlock purges the pending entry.
  • Full coinjoin_tests suite passes locally (./src/test/test_dash --run_test=coinjoin_tests, macOS arm64, --enable-debug).
  • test/lint/lint-whitespace.py and test/lint/lint-circular-dependencies.py pass.
  • 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; the unit tests exercise the client-side invariant directly instead.

Breaking Changes

None. No wire-protocol changes (DSTX push uses an existing message). One behavioral note: persistent wallet locks on denominated coins whose outpoint is already spent are cleaned up once at startup by the reconciliation pass; locks on unspent coins are never auto-released except by the pending-observation flow that created them.

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

PastaPastaPasta and others added 3 commits July 22, 2026 17:09
A client releases its session inputs as soon as it processes DSCOMPLETE(MSG_SUCCESS), but the finalized DSTX is relayed via delayed inventory trickling and can arrive much later. During that window the inputs are neither wallet-locked nor marked spent, so another session (especially with -coinjoinmultisession=1) can select and sign them again, producing two valid CoinJoin transactions spending the same outpoint. Conflicting InstantSend votes then leave both transactions without an ISLock, which can stall ChainLocks.

Instead of unlocking on success, move the session's mixing inputs into a per-wallet pending-observation set: they stay wallet-locked (persistently, via WalletBatch, so a restart cannot resurrect them) and are released only once the wallet observes a mempool or confirmed transaction spending them. If no spend is ever observed the input is released after a conservative timeout, but only after double-checking chain and mempool spentness. The release check runs before the mixing gates in DoMaintenance so pending inputs can still unlock while mixing or CoinJoin itself is disabled, and a manual user unlock purges the pending entry. Persisted locks on denominated coins are reconciled after restart. Collateral inputs and all failure paths keep the old immediate-unlock behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…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>
Cover the new pending-observation lifecycle: inputs stay locked and excluded from coin selection until a spending transaction is observed, unrelated inputs remain selectable, unspent inputs are released after the terminal timeout, pending state reconstructed from persisted locks after a restart has no terminal timeout, and a manual user unlock purges the pending entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

CoinJoin clients now keep relevant input locks until the finalized mixing transaction is observed, persist and restore pending observations, release locks after observation or timeout, and report pending counts through manager status and RPC output. Maintenance performs reconciliation before mixing gates return. CoinJoin servers directly relay signed final transactions to participants. Tests cover persistence, selection exclusion, observation, timeout, and manual unlock behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CoinJoinSession
  participant ClientManager
  participant Wallet
  participant Blockchain
  CoinJoinSession->>ClientManager: register successfully mixed inputs
  ClientManager->>Wallet: keep inputs locked
  ClientManager->>Blockchain: check finalized spend
  Blockchain-->>ClientManager: report observed spend
  ClientManager->>Wallet: release protective locks
Loading
sequenceDiagram
  participant CoinJoinServer
  participant Connman
  participant ParticipantNode
  CoinJoinServer->>Connman: send signed DSTX
  Connman->>ParticipantNode: deliver DSTX
  CoinJoinServer->>Connman: continue inventory relay
Loading

Suggested reviewers: knst

🚥 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
Title check ✅ Passed The title clearly summarizes the main CoinJoin race condition fix and its timing context.
Description check ✅ Passed The description is directly related and accurately explains the CoinJoin race fix and accompanying tests.
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.
✨ 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
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.

1 participant