Skip to content

feat(key-wallet): owner-tagged reservations to close the broadcast-release TOCTOU (platform#4185)#916

Open
bfoss765 wants to merge 3 commits into
dashpay:devfrom
bfoss765:feat/owner-tagged-reservations
Open

feat(key-wallet): owner-tagged reservations to close the broadcast-release TOCTOU (platform#4185)#916
bfoss765 wants to merge 3 commits into
dashpay:devfrom
bfoss765:feat/owner-tagged-reservations

Conversation

@bfoss765

@bfoss765 bfoss765 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Why this PR exists

This adds an owner-tagged reservation capability to key-wallet to 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: ReservationSet currently maps OutPoint → reserved-at-height with no per-reservation owner, and release removes by outpoint unconditionally. Platform's deferred-broadcast / asset-lock path reserves a transaction's inputs, .awaits the network broadcast, and on a Rejected result 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 any Arc::ptr_eq generation 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 the ReservationSet mutex. That's what this PR provides.

What changed

New public type ReservationToken (re-exported from crate root + managed_account): opaque Copy newtype over a private u64, with no public constructor — a token can only be obtained from reserve(), 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 (still pub(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.
  • Storage: HashMap<OutPoint, Reservation { reserved_at_height, owner }> + a next_token counter; TTL sweep preserved.

Public seam so consumers can adopt the fix (all additive):

  • TransactionBuilder::build_unsigned_reserved() / build_signed_reserved() → return the extra Option<ReservationToken>.
  • ManagedCoreFundsAccount::release_reservation_if_owner(&Transaction, ReservationToken) — the owner-guarded release a Rejected path should call instead of the unconditional release_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 to release_if_owner. The processed-spend release stays unconditional (the coin is genuinely spent).

Back-compat

  • build_unsigned / build_signed and release_reservation keep their existing signatures (they thin-delegate); the ~20 in-repo callers are untouched.
  • ReservationSet::reserve's changed return is pub(crate), zero external impact; all in-repo sites updated.
  • key-wallet-manager, dash-spv, key-wallet-ffi don't reference reservations; they still compile.

Validation

cargo build -p key-wallet -p key-wallet-manager -p dash-spv -p key-wallet-ffi clean; 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, assert release_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 call build_signed_reserved directly; happy to thread the token through those wrappers too if preferred.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added reservation ownership tracking for funding inputs using an optional token.
    • Asset-lock results now include an optional reservation token for guarded release on failures.
    • Reservation-aware transaction building now returns a reservation token to enable caller-managed ownership.
  • Bug Fixes

    • Prevented stale or failed transaction builds from releasing reservations owned by concurrent builds.
    • Improved safety for TTL sweep/re-reserve races by ensuring guarded releases only affect still-owned reservations.

…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>
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ec5ed4f7-5302-4cfb-ba35-ee33e287473e

📥 Commits

Reviewing files that changed from the base of the PR and between e99959c and b3c216a.

📒 Files selected for processing (2)
  • key-wallet/src/managed_account/managed_core_funds_account.rs
  • key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • key-wallet/src/managed_account/managed_core_funds_account.rs
  • key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs

📝 Walkthrough

Walkthrough

Reservation ownership tokens are introduced for UTXO reservations, propagated through reserved transaction and asset-lock builders, and exposed through managed account and crate APIs. Signing-failure cleanup now releases only reservations still owned by the originating build.

Changes

Reservation ownership flow

Layer / File(s) Summary
Reservation ownership model
key-wallet/src/managed_account/reservation.rs
Reservations now store per-build ReservationToken values, support owner-guarded release, and test token uniqueness, ownership checks, and sweep/re-reserve behavior.
Reservation-aware transaction building
key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs
Reserved unsigned and signed build paths return optional reservation tokens and use owner-guarded cleanup after signing failures.
Asset-lock and account integration
key-wallet/src/managed_account/..., key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs, key-wallet/src/lib.rs
Asset-lock results expose reservation tokens, managed accounts add guarded release, and ReservationToken is re-exported publicly.

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
Loading

Possibly related PRs

  • dashpay/rust-dashcore#812: Both changes modify key-wallet reservation handling across reservation, transaction-builder, and managed-account code.
  • dashpay/rust-dashcore#905: Both changes integrate managed account APIs with the key-wallet reservation ledger.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: owner-tagged reservations in key-wallet to prevent the broadcast-release TOCTOU.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 win

Reservation tokens are captured but never used to guard cleanup on downstream (non-signing) failures. Both asset-lock builders take a reservation via build_signed_reserved and get back reservation_token, but a failure in the code that runs after the successful signed build (credit-key derivation, or the signer round-trip/bookkeeping) returns Err without 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: in build_asset_lock, clone the funding account's ReservationSet before the credit-key derivation loop and call release_if_owner on the transaction's outpoints when resolve_funding_account/next_private_key fails, before returning Err.
  • key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs#L293-L357: in build_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

📥 Commits

Reviewing files that changed from the base of the PR and between 70d4bf8 and e99959c.

📒 Files selected for processing (6)
  • key-wallet/src/lib.rs
  • key-wallet/src/managed_account/managed_core_funds_account.rs
  • key-wallet/src/managed_account/mod.rs
  • key-wallet/src/managed_account/reservation.rs
  • key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs
  • key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs

@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.94083% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.44%. Comparing base (70d4bf8) to head (b3c216a).

Files with missing lines Patch % Lines
...c/wallet/managed_wallet_info/asset_lock_builder.rs 79.68% 13 Missing ⚠️
.../src/managed_account/managed_core_funds_account.rs 0.00% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #916      +/-   ##
==========================================
- Coverage   74.56%   74.44%   -0.12%     
==========================================
  Files         328      328              
  Lines       75711    75822     +111     
==========================================
- Hits        56451    56444       -7     
- Misses      19260    19378     +118     
Flag Coverage Δ
core 77.29% <ø> (ø)
ffi 49.50% <ø> (-1.05%) ⬇️
rpc 20.00% <ø> (ø)
spv 91.19% <ø> (+0.02%) ⬆️
wallet 74.48% <89.94%> (+0.06%) ⬆️
Files with missing lines Coverage Δ
key-wallet/src/managed_account/reservation.rs 100.00% <100.00%> (ø)
.../wallet/managed_wallet_info/transaction_builder.rs 87.93% <100.00%> (+0.34%) ⬆️
.../src/managed_account/managed_core_funds_account.rs 77.10% <0.00%> (-0.76%) ⬇️
...c/wallet/managed_wallet_info/asset_lock_builder.rs 86.56% <79.68%> (-1.58%) ⬇️

... and 22 files with indirect coverage changes

bfoss765 added a commit to bfoss765/platform that referenced this pull request Jul 23, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant