fix: close same-UTXO concurrent-selection race in send_to_addresses#3622
Conversation
PR #3585 closes broadcast-failure atomicity on `send_to_addresses` but leaves the headline race open: two callers acquiring the wallet write lock independently still snapshot the same `spendable` set, both select the same outpoint, both broadcast, and the loser gets an opaque network rejection — never `ConcurrentSpendConflict`. Add a per-wallet `OutpointReservations` set (RAII guard, panic-safe via poisoned-mutex recovery). Coin selection consults the set under the write lock; selected outpoints are reserved before the lock drops, so a second concurrent caller sees them filtered out of its spendable snapshot and short-circuits with the new typed `NoSpendableInputs` error *before* hitting the network. Successful broadcasts transition inputs from "reserved" to "spent" via `check_core_transaction(Mempool)` under the second write lock — no observable gap. Failed broadcasts and panics release the reservation through `Drop`, so the next call picks up the released UTXOs. Adds three reservation-set unit tests plus two race-property tests: - concurrent same-UTXO sends → exactly one Ok, one `NoSpendableInputs` (the loser must NOT reach the broadcaster — that's the bug being closed) - broadcast failure releases the reservation so the same UTXO can be retried `tokio::sync::RwLock` is already in use; DET's original `!Send` blocker that forced the weaker asset-lock pattern does not apply here, so we take Option B (reservation set) over Option A (lock-held mark-spent + Err-rollback) — sidesteps building a rollback primitive in `key-wallet` that `check_core_transaction` does not expose today. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ 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 |
Review GateCommit:
|
Drop obvious comments, cap non-obvious ones at 3 lines per reviewer feedback. No behavior change. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…napshot (#3622 review) - Replace sleep-based race coordination with Notify handshake. - Assert broadcaster called exactly once across concurrent senders. - Snapshot reservation set once for spendable filter (drop per-UTXO lock). - Pin BuilderError error-text regression test; flag typed-wrapper TODO.
Summary
Stacks on top of #3585 to close the same-UTXO concurrent-selection race in
CoreWallet::send_to_addresses.#3585 (now reframed) closes broadcast-failure atomicity — UTXOs and the change-address index are not mutated when a broadcast errors. That is a real fix, but it does not prevent two concurrent callers from both selecting the same UTXO X, both broadcasting, and the loser receiving an opaque network rejection. This PR closes that race in-process.
The race, before this PR
send_to_addressesacquires the wallet write lock, snapshots the spendable UTXO set, builds the transaction, drops the lock, then awaits broadcast. Two callers can interleave like this:check_core_transaction(... update_state=true)yet. B also selects {X}, drops the lock.TransactionBroadcasterror.Local state on the loser may or may not be corrupt depending on broadcast-failure handling — that part is what #3585 fixed. The selection race itself is untouched by #3585.
Approach: per-wallet outpoint reservations
Every
CoreWalletgets anOutpointReservationsfield — aMutex<HashSet<OutPoint>>of in-flight outpoints. Coin selection consults the reservation set in addition to the spendable set, and excludes any outpoint that is already reserved.The fix flow:
check_core_transaction(... update_state=true)to mark UTXOs spent normally, remove outpoints from reservations.A small RAII guard (
ReservationGuard) ensures reservations are always released, including on panic in steps 5–7.After this PR, the same interleaving above plays out as:
NoSpendableInputserror before broadcasting.B never broadcasts a transaction that double-spends X. That is the property #3585 does not provide.
Design rationale — why a reservation set, not lock-held mark-spent
The textbook alternative is to call
check_core_transaction(... update_state=true)inside the write lock, before broadcast — marking UTXOs spent up front and rolling back on broadcast failure. We considered it and rejected it for one practical reason:check_core_transactiondoes not currently expose an "unspend" / rollback primitive, so adopting that approach would require building rollback support in the upstreamkey-walletcrate. The reservation-set approach sidesteps that work entirely — there is nothing to undo on failure, so no rollback primitive is needed.A reservation set has one tradeoff worth flagging: it closes the race in-process only. Two separate processes operating the same wallet can still race at the network layer. Production wallets are single-process per wallet, so this is acceptable; multi-process scenarios were never closed by #3585 either.
Files changed
packages/rs-platform-wallet/src/wallet/core/reservations.rsOutpointReservations+ RAIIReservationGuardpackages/rs-platform-wallet/src/wallet/core/broadcast.rspackages/rs-platform-wallet/src/wallet/core/wallet.rsCoreWallet.reservations: OutpointReservationsfieldpackages/rs-platform-wallet/src/wallet/core/mod.rspackages/rs-platform-wallet/src/error.rsNoSpendableInputs { context: String }variantPublic API surface change
PlatformWalletError::NoSpendableInputs { context: String }— the typed error a race-loser sees instead of a generic broadcast failure.send_to_addressessignature unchanged.OutpointReservationsispub(crate)— not exported beyond the crate.Test plan
cargo fmt --checkgreencargo build -p platform-walletgreencargo clippy -p platform-wallet --lib --tests -- -D warningscleancargo test -p platform-wallet --lib— 128 passed, 0 failed (fix: case-insensitive .dash + atomic state on broadcast failure #3585 baseline 123 + 5 new):wallet::core::reservations::tests::reserve_then_drop_releaseswallet::core::reservations::tests::second_reservation_is_disjointwallet::core::reservations::tests::poisoned_mutex_still_releaseswallet::core::broadcast::tests::concurrent_same_utxo_sends_resolve_via_reservation_set— headline race-property test:tokio::join!of two concurrentsend_to_addressesagainst the same UTXO, with a gated broadcaster; loser short-circuits without reaching the network.wallet::core::broadcast::tests::broadcast_failure_releases_reservation_for_retry— pins the Drop-release contract.NoSpendableInputsrather than a broadcast-layer rejection (deferred — needs running node)Out of scope (filed for follow-up)
wallet/identity/network/payments.rs::send_paymenthas the same race shape. The sameOutpointReservationsinstance onCoreWalletcould be threaded intoIdentityWalletto close that path. Tracked but not addressed here.Stacking note
This PR targets
fix/dpns-case-and-utxo-race-v3.1-dev(#3585's branch). Merge order: #3585 first, then this. If #3585 retargets to v3.1-dev directly before merging, this PR auto-rebases.🤖 Co-authored by Claudius the Magnificent AI Agent