fix(coinjoin): prevent multi-session input reuse race between DSCOMPLETE and DSTX delivery#7478
Conversation
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>
WalkthroughCoinJoin 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
sequenceDiagram
participant CoinJoinServer
participant Connman
participant ParticipantNode
CoinJoinServer->>Connman: send signed DSTX
Connman->>ParticipantNode: deliver DSTX
CoinJoinServer->>Connman: continue inventory relay
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
src/coinjoin/server.cpp (1)
391-397: 🎯 Functional Correctness | 🔵 Trivial | 🏗️ Heavy liftAdd 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
DSTXbeforeDSCOMPLETE.🤖 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
📒 Files selected for processing (6)
src/coinjoin/client.cppsrc/coinjoin/client.hsrc/coinjoin/server.cppsrc/coinjoin/server.hsrc/rpc/coinjoin.cppsrc/wallet/test/coinjoin_tests.cpp
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 viaPeerRelayInv(delayed inventory trickling) whileDSCOMPLETEis pushed directly, so the completion message can overtake theINV/GETDATA/DSTXexchange. 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 invecEntries, i.e. exactly the inputs of the finalized transaction) are no longer unlocked. They move into a new per-wallet "pending observation" set inCCoinJoinClientManagerand stay wallet-locked — persistently, viaWalletBatch, so a restart cannot make them selectable again. They are released only when: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; orlockunspent), 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, andkeyHolderStorage.KeepAll()still runs on success.getcoinjoininfogains apending_inputscount.Coordinator (ordering fix):
CommitFinalTransactionnow pushes the fully signed DSTX directly to every mixing participant before sendingDSCOMPLETE, 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.DSTXis an existing message, so no protocol change: old clients accept the direct push as-is (unsolicitedTX/DSTXis explicitly non-punishable innet_processing), and old coordinators simply keep the previous behavior, which the client-side pending state now guards against.How Has This Been Tested?
coinjoin_tests/coinjoin_pending_observation_testscovering: 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.coinjoin_testssuite passes locally (./src/test/test_dash --run_test=coinjoin_tests, macOS arm64,--enable-debug).test/lint/lint-whitespace.pyandtest/lint/lint-circular-dependencies.pypass.Breaking Changes
None. No wire-protocol changes (
DSTXpush 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:
🤖 Generated with Claude Code