feat(key-wallet): owner-tagged reservations to close the broadcast-release TOCTOU (platform#4185) - #916
feat(key-wallet): owner-tagged reservations to close the broadcast-release TOCTOU (platform#4185)#916bfoss765 wants to merge 6 commits into
Conversation
…se the broadcast-release TOCTOU (platform#4185) ReservationSet previously mapped OutPoint -> reserved-at-height with no per-reservation owner, and released unconditionally by outpoint. The platform broadcast path (dashpay/platform#4185) reserves an asset-lock/deferred send's inputs, awaits the broadcast, and on a Rejected result releases them by outpoint. If key-wallet's TTL sweep reclaims that reservation mid-await and a different concurrent build re-reserves the same outpoint (same wallet generation, so any Arc::ptr_eq generation guard still passes), the rejected build's cleanup frees the OTHER build's reservation -> a double-spend window. The platform layer cannot detect this because the sweep is invisible inside key-wallet; the "release only if still mine" check must be atomic under the ReservationSet mutex. Stamp every reservation with a ReservationToken (a Copy newtype over a monotonic per-set counter, unique even across same-height reserves). reserve() returns the token it stamped; release_if_owner(outpoints, token) removes only outpoints still owned by that token, atomically under the mutex, and is a no-op for any swept-and-re-reserved outpoint. The unconditional release() is kept for the processed-spend path, where the coin is genuinely spent and must leave the set regardless of owner. Wiring: - assemble_unsigned returns the stamped token; build_signed's sign-failure path now releases owner-guarded (it too awaits, so it shares the hazard). - Additive token-returning builds: build_unsigned_reserved / build_signed_reserved. build_unsigned / build_signed keep their signatures. - Public seam for platform: ManagedCoreFundsAccount::release_reservation_if_owner and AssetLockResult::reservation_token; ReservationToken re-exported. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Important Review skippedNo new commits to review since the last review. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughReservation ownership tokens are introduced for UTXO reservations, propagated through reserved transaction and asset-lock builders, and exposed through managed account and crate APIs. A masternode voting-key lookup helper and tests are also added. ChangesReservation ownership flow
Masternode voting-key lookup
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant ManagedWalletInfo
participant TransactionBuilder
participant ReservationSet
participant Signer
ManagedWalletInfo->>TransactionBuilder: build_signed_reserved
TransactionBuilder->>ReservationSet: reserve selected outpoints
ReservationSet-->>TransactionBuilder: ReservationToken
TransactionBuilder->>Signer: sign transaction
Signer-->>TransactionBuilder: signed transaction or failure
TransactionBuilder->>ReservationSet: release_if_owner on failure
TransactionBuilder-->>ManagedWalletInfo: transaction, fee, token
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 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 |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs (1)
219-252: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winReservation tokens are captured but never used to guard cleanup on downstream (non-signing) failures. Both asset-lock builders take a reservation via
build_signed_reservedand get backreservation_token, but a failure in the code that runs after the successful signed build (credit-key derivation, or the signer round-trip/bookkeeping) returnsErrwithout releasing it — stranding the already-signed transaction's reserved inputs until the 24-block TTL sweep, since the caller never sees the token to release it itself.
key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs#L219-L252: inbuild_asset_lock, clone the funding account'sReservationSetbefore the credit-key derivation loop and callrelease_if_owneron the transaction's outpoints whenresolve_funding_account/next_private_keyfails, before returningErr.key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs#L293-L357: inbuild_asset_lock_with_signer, apply the same guarded-release pattern around the Phase 1–3 bookkeeping loop (resolve_funding_account,peek_next_path,signer.public_key(&path).await,mark_first_pool_index_used).🤖 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 `@key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs` around lines 219 - 252, Add guarded reservation cleanup in key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs:219-252 within build_asset_lock by cloning the funding account’s ReservationSet before credit-key derivation and calling release_if_owner on the signed transaction’s outpoints whenever resolve_funding_account or next_private_key fails. Apply the same pattern in key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs:293-357 within build_asset_lock_with_signer for failures during the Phase 1–3 bookkeeping operations, including resolve_funding_account, peek_next_path, signer.public_key, and mark_first_pool_index_used; preserve successful returns and only release reservations owned by this build.
🤖 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.
Outside diff comments:
In `@key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs`:
- Around line 219-252: Add guarded reservation cleanup in
key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs:219-252 within
build_asset_lock by cloning the funding account’s ReservationSet before
credit-key derivation and calling release_if_owner on the signed transaction’s
outpoints whenever resolve_funding_account or next_private_key fails. Apply the
same pattern in
key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs:293-357 within
build_asset_lock_with_signer for failures during the Phase 1–3 bookkeeping
operations, including resolve_funding_account, peek_next_path,
signer.public_key, and mark_first_pool_index_used; preserve successful returns
and only release reservations owned by this build.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 392d56f0-13b2-454e-aea8-f0de66b4c248
📒 Files selected for processing (6)
key-wallet/src/lib.rskey-wallet/src/managed_account/managed_core_funds_account.rskey-wallet/src/managed_account/mod.rskey-wallet/src/managed_account/reservation.rskey-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rskey-wallet/src/wallet/managed_wallet_info/transaction_builder.rs
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## dev #916 +/- ##
==========================================
+ Coverage 74.76% 74.79% +0.02%
==========================================
Files 328 328
Lines 76593 76765 +172
==========================================
+ Hits 57267 57415 +148
- Misses 19326 19350 +24
|
Point all rust-dashcore crates at bfoss765/rust-dashcore e99959ced0062159d629930f488374e29f63c42b (PR dashpay/rust-dashcore#916), which is v4.1-dev's rust-dashcore tip 70d4bf8 plus the additive owner-tagged reservation API: key_wallet::ReservationToken, ReservationSet::reserve/release_if_owner, TransactionBuilder::build_{unsigned,signed}_reserved, ManagedCoreFundsAccount::release_reservation_if_owner, and AssetLockResult.reservation_token. Additive over 70d4bf8, so it stays compatible with the rest of the v4.1-dev workspace. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tream failures Addresses the CodeRabbit Major (PR dashpay#916, asset_lock_builder.rs :219-252 / :293-357). Both asset-lock builders took a reservation via build_signed_reserved and captured its reservation_token, but a failure in the code that runs AFTER the successful signed build — credit-key derivation in build_asset_lock, and the Phase 1-3 signer/bookkeeping loop in build_asset_lock_with_signer — returned Err without releasing the reservation. The already-signed transaction's inputs then stranded until the 24-block TTL sweep, since the caller never received the token to release them itself. Clone the funding account's ReservationSet (a shared Arc view) before each loop re-borrows self.accounts, and on any failure release only THIS build's reservation with the owner-guarded release_if_owner(&reserved, token) — never the unconditional release, which could free a concurrent build's inputs that the TTL sweep + re-reserve handed over during this build (the very TOCTOU of dashpay/platform#4185 this PR closes). The success path is unchanged: a successful build consumes the reservation as designed, and the signer-failure release inside build_signed_reserved returns via `?` before this new code runs, so there is no double-release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
This PR has merge conflicts with the base branch. Please rebase or merge the base branch into your branch to resolve them. |
Adds a by-voting-key lookup (returns matching pro_reg_tx_hashes) so SDK consumers can resolve which masternodes an imported voting key controls without the legacy dashj masternode list — needed for contested-username voting after the dashj engine is held post-cutover. Data was already present on every entry (key_id_voting, pro_reg_tx_hash); this is the missing index/helper plus a unit test covering multi-match, single-match and no-match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
dash/src/sml/masternode_list/masternode_helpers.rs (1)
6-6: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy liftSplit the voting-key lookup from the reservation-token changes.
The supplied stack context shows no dependency between these cohorts. Split this public
MasternodeListfeature into a focused PR. After the split, use an allowedfeat:title prefix for this new API. The supplied context does not include the actual PR title, so verify its prefix separately.As per path instructions, “If a PR mixes unrelated concerns … suggest splitting it into separate focused PRs.”
🤖 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 `@dash/src/sml/masternode_list/masternode_helpers.rs` at line 6, Split the voting-key lookup changes from the reservation-token changes into separate focused PRs, keeping the public MasternodeList API work with the voting-key feature. For the resulting voting-key PR, verify and update the PR title to use the allowed feat: prefix.Source: Path instructions
🤖 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 `@dash/src/sml/masternode_list/masternode_helpers.rs`:
- Around line 116-121: Document in masternodes_by_voting_key that matching
masternodes are returned in ascending ProTxHash order, based on the underlying
sorted BTreeMap. Update the test to assert the returned order directly rather
than sorting matched before checking the expected seed values.
---
Nitpick comments:
In `@dash/src/sml/masternode_list/masternode_helpers.rs`:
- Line 6: Split the voting-key lookup changes from the reservation-token changes
into separate focused PRs, keeping the public MasternodeList API work with the
voting-key feature. For the resulting voting-key PR, verify and update the PR
title to use the allowed feat: prefix.
🪄 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: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: edd2b510-be53-47ae-bb49-57425fed6b0e
📒 Files selected for processing (1)
dash/src/sml/masternode_list/masternode_helpers.rs
All eight workspace rust-dashcore dependencies were redirected at bfoss765/rust-dashcore solely to pick up `MasternodeList::masternodes_by_voting_key`. That revision also carried ~528 lines of unrelated owner-tagged-reservation and asset-lock behaviour, and the upstream PR carrying the helper (dashpay/rust-dashcore#916) is CHANGES_REQUESTED, conflicting against dev, and has been asked to split — so the pin had no bounded lifetime. Revert the pin to the Dash-owned revision v4.2-dev already tracks (70d4bf8e36057c58e02d56769a6e9760f701dd06) and implement the filter locally in `spv/runtime.rs` as a private free function over the engine's current-tip MasternodeList. It reads only long-standing public SML fields (`key_id_voting`, `pro_reg_tx_hash`, both present on that revision), and folds in the `ProTxHash -> [u8; 32]` internal-byte-order conversion the caller previously did itself, so `masternodes_by_voting_key_blocking` gets shorter rather than longer. Four unit tests cover multi-match, single-match, no-match and the empty-list case. Swapping back to the upstream helper once dashpay#916 lands is tracked by dashpay#4262, cited in a doc comment at the duplication site. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Heads-up on a downstream cleanup that this PR unblocks, for whoever merges it. The last commit here — Because this PR is still open, platform#4258 could not pin to a revision carrying the helper without redirecting all eight of its workspace rust-dashcore dependencies at a personal fork — which would also have dragged in the owner-tagged-reservation and asset-lock changes from the other three commits here. That pin was rejected in review on #4258, and since this PR is So platform#4258 now stays on the Dash-owned rust-dashcore revision and carries a small local copy of the filter (14 lines over When |
Resolves the conflict flagged on PR dashpay#916 against the current `dev` (9cbe4e7). Only one file conflicted: `key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs`, in two places, both the same shape. `dev`'s dashpay#915 (CoinJoin funding account + drain mode) refactored both asset-lock builders from a single chained `TransactionBuilder::new()...` expression into a two-step `let mut builder = ...` so it can conditionally apply `set_selection_strategy(SelectionStrategy::All)` for drain mode. This branch had changed the same statement's destructuring to pick up the new third element, the `Option<ReservationToken>`. Resolution takes `dev`'s two-step builder form and moves this branch's three-element destructuring down onto the `let (transaction, fee, ...) = builder` line that consumes it, i.e. both changes are kept: let (transaction, fee, reservation_token) = builder .set_funding(...) .require_final_inputs() .build_signed_reserved(...) .await?; No semantic conflict underneath: `dev` did not touch `managed_account/reservation.rs`, where the owner-tagged reservation logic actually lives, and the owner-guarded release blocks added by b3c216a still sit after the build in both builders, unchanged. `managed_core_funds_account.rs` and `transaction_builder.rs` auto-merged. Validated after the merge: cargo build -p key-wallet -p key-wallet-manager -p dash-spv \ -p key-wallet-ffi -p dashcore -> clean cargo test -p key-wallet --lib -> 603 passed, 0 failed (incl. 23 reservation tests, TOCTOU regression among them) cargo test -p dashcore --lib -> 577 passed, 0 failed cargo test -p key-wallet-manager --lib -> 49 passed, 0 failed cargo test -p dash-spv --lib -> 494 passed, 0 failed cargo clippy -p key-wallet -p dashcore --all-targets -- -D warnings -> exit 0, 0 warnings Note for the platform#4185 re-pin: this branch's own public API (`ReservationToken`, `build_unsigned_reserved`/`build_signed_reserved`, `ManagedCoreFundsAccount::release_reservation_if_owner`, `AssetLockResult::reservation_token`) is unchanged by this merge. However `dev`'s dashpay#915 independently changed `build_asset_lock` / `build_asset_lock_with_signer` to take `AssetLockFundingAccount` instead of a bare `account_index`, plus a new `drain: bool`. That is a `dev`-side break that #4185 will hit on re-pin regardless of this PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ing_key Addresses the one unresolved CodeRabbit review thread on PR dashpay#916 (masternode_helpers.rs:116-121): the function returned matches in `BTreeMap` order but the public docs never promised an order, and the test hid that by sorting the result before asserting on it. Document the ordering as a guaranteed part of the API rather than an implementation accident, and say why it holds: `masternodes` is a `BTreeMap<ProTxHash, _>` (std guarantees ascending-key iteration) and `ProTxHash` derives `Ord` over its 32 internal bytes. Also note that because `ProTxHash` is `#[hash_newtype(forward)]`, the internal byte order is the same order its hex `Display` reads in — so the sequence is sorted the way it prints, which is the non-obvious part for a codebase that reverses byte order on several other hash types. Test changes: - `masternodes_by_voting_key_filters_and_collects` no longer sorts the result. It asserts the whole `Vec` at once, so it now pins ordering as well as contents. - New `masternodes_by_voting_key_returns_ascending_pro_tx_hash_order` seeds the list in DESCENDING proTxHash order, so a result that merely echoed insertion order would come back reversed and fail. It also asserts strict ascent through the public `Ord` impl, which is the comparison callers would actually use. The ordering claim is verified by the new test, not assumed. Also fixes a `cargo fmt` violation in this file's `list_from` test helper that was already present on the branch (`cargo fmt --all -- --check` was failing before this commit and passes after). cargo fmt --all -- --check -> clean cargo test -p dashcore --lib -> 577 passed, 0 failed Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Rebased onto Addressed the outstanding review items:
Note on priority: platform#4185 pins all eight crates to this branch, so it cannot merge until this lands upstream. @coderabbitai review |
|
✏️ Learnings added
✅ Action performedReview finished.
|
Why this PR exists
This adds an owner-tagged reservation capability to
key-walletto close a real time-of-check/time-of-use double-spend window that the platform layer (dashpay/platform#4185) cannot fix on its own.The bug it fixes:
ReservationSetcurrently mapsOutPoint → reserved-at-heightwith no per-reservation owner, andreleaseremoves by outpoint unconditionally. Platform's deferred-broadcast / asset-lock path reserves a transaction's inputs,.awaits the network broadcast, and on aRejectedresult releases those inputs by outpoint. If key-wallet's TTL sweep reclaims the reservation during that await and a different concurrent build re-reserves the same outpoint (same wallet generation, so anyArc::ptr_eqgeneration guard still passes), the rejected build's cleanup frees the other build's reservation — opening a double-spend window. Platform cannot detect this, because the sweep happens invisibly inside key-wallet; the "release only if it's still mine" check must be atomic under theReservationSetmutex. That's what this PR provides.What changed
New public type
ReservationToken(re-exported from crate root +managed_account): opaqueCopynewtype over a privateu64, with no public constructor — a token can only be obtained fromreserve(), so it is forge-proof and cheap to hold across an await. Height cannot serve as the identity (it collides across same-height reserves).ReservationSet(stillpub(crate)):reserve(&[OutPoint], height) -> ReservationToken— now returns a fresh per-call token stamping all outpoints; re-reserving transfers ownership to the new token.release_if_owner(&[OutPoint], ReservationToken)(new) — atomically removes only outpoints whose stored owner == the token; a no-op for swept / re-reserved / unreserved outpoints.release(...)(kept, unconditional) — doc now scopes it to the known-spent path only.HashMap<OutPoint, Reservation { reserved_at_height, owner }>+ anext_tokencounter; TTL sweep preserved.Public seam so consumers can adopt the fix (all additive):
TransactionBuilder::build_unsigned_reserved()/build_signed_reserved()→ return the extraOption<ReservationToken>.ManagedCoreFundsAccount::release_reservation_if_owner(&Transaction, ReservationToken)— the owner-guarded release a Rejected path should call instead of the unconditionalrelease_reservation.AssetLockResult.reservation_token: Option<ReservationToken>— carries the token out of the asset-lock build.The sign-failure path in
build_signed(which also awaits) was switched torelease_if_owner. The processed-spend release stays unconditional (the coin is genuinely spent).Back-compat
build_unsigned/build_signedandrelease_reservationkeep their existing signatures (they thin-delegate); the ~20 in-repo callers are untouched.ReservationSet::reserve's changed return ispub(crate), zero external impact; all in-repo sites updated.key-wallet-manager,dash-spv,key-wallet-ffidon't reference reservations; they still compile.Validation
cargo build -p key-wallet -p key-wallet-manager -p dash-spv -p key-wallet-fficlean;cargo test -p key-wallet --lib= 552 passed, 0 failed (13/13 reservation tests incl. the TOCTOU regression: reserve X→token A, simulate sweep + re-reserve X→token B, assertrelease_if_owner([X], A)does NOT free X); clippy + doc clean.Scope note
The asset-lock flow (the one platform#4185 cites) is wired end-to-end. The general manager send wrappers (
build_and_sign_transaction*) still return(Transaction, u64)and drop the token — platform's #4185 adoption can callbuild_signed_reserveddirectly; happy to thread the token through those wrappers too if preferred.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes