Skip to content

feat(key-wallet): atomic multi-account UTXO reservations (set_funding_multi)#912

Closed
bfoss765 wants to merge 3 commits into
dashpay:devfrom
bfoss765:feat/key-wallet-atomic-multi-account-reservations
Closed

feat(key-wallet): atomic multi-account UTXO reservations (set_funding_multi)#912
bfoss765 wants to merge 3 commits into
dashpay:devfrom
bfoss765:feat/key-wallet-atomic-multi-account-reservations

Conversation

@bfoss765

Copy link
Copy Markdown
Contributor

What

Adds TransactionBuilder::set_funding_multi, a funding entry that draws a union
of UTXOs from several accounts and reserves each selected input in the ledger
of the account that owns it
, rather than pooling every reservation into one
account's set.

Why

This is the upstream fix for the Dash Platform #4184 reservation blocker.
The platform asset-lock "fund from all accounts" builder seeds a primary BIP44
account via set_funding and appends other accounts' UTXOs via add_inputs.
Today the builder snapshots a single account's ReservationSet and reserves
all selected inputs there — so a CoinJoin/DashPay input gets reserved in the
primary account's ledger, and a later build issued through the owning
account can reselect that same coin before broadcast reconciliation releases it.
ReservationSet is pub(crate), so the platform crate cannot fix this itself;
the safe fix belongs here. Platform will adopt set_funding_multi (replacing
its set_funding(primary) + add_inputs(extra) composition) at the next
key-wallet pin bump.

How

  • The builder's reservations field becomes a private Reservations enum
    (None / Single / Multi). Single is the existing set_funding path,
    byte-for-byte unchanged.
  • Multi carries one ledger per funding account ({ ReservationSet, owned outpoints }). At assembly the selected inputs are grouped by owner and
    committed in one synchronous, infallible step — only after coin selection has
    fully succeeded.
  • Signature: primary is &mut (its change address is derived here — the sole
    mutation); extra is an IntoIterator of shared &ManagedCoreFundsAccount,
    since extras are only read. Shared refs keep adoption a clean single call: the
    platform site holds the primary by &mut and passes the rest by & straight
    out of its all_funding_accounts() borrow, rather than partitioning that
    collection into N exclusive borrows that each also exclude the primary. The
    documented "primary must not appear in extra" precondition is enforced by
    an identity check (std::ptr::eq) that skips a duplicate so it can't be
    reserved twice, with a debug_assert to catch the caller bug in debug builds
    (a Result return was avoided so the fluent builder chain is preserved).
  • Atomicity: ReservationSet::reserve cannot fail and the commit yields to no
    other task, so the union reserves entirely or (on an earlier selection error)
    not at all. A signing failure releases every group, so no owning account is
    left with a stranded reservation.
  • Per-account release and the TTL backstop need no new machinery: each
    input lives in its owning account's existing set, so the release in
    update_utxos (adjacent to the spent-outpoint tracking from fix(key-wallet): out-of-order UTXO spend recorded nowhere — phantom unspent balance (#649) #909) and the
    TTL sweep already reclaim it in the right ledger.

Deliberately not ported from the internal design note: a public
ReservationTxn/stage/commit/ReservationConflict staging API. Since
reserve is infallible and cross-account atomicity is already provided by the
wallet-manager lock the single-account path documents, that surface is
unnecessary — this keeps the new public API to a single method.

Known limitation (parity with the single-account path)

If the caller panics during signing (rather than returning an Err), the
already-committed reservations are not released and the coins stay reserved
until the 24-block (RESERVATION_TTL_BLOCKS) TTL backstop reclaims them. This
is identical to the existing single-account set_funding path — no Drop-based
panic guard is added here, so behaviour stays on par with it.

Tests

New: atomic commit across 2–3 accounts, group filter, insufficient-funds
reserves-nothing, signing-failure rollback across all ledgers, per-account
release + TTL, a threaded no-double-select race, and a duplicate-primary guard
check. Existing single-account builder tests unchanged and green.
cargo test/clippy/fmt clean for key-wallet, key-wallet-ffi,
key-wallet-manager.

bfoss765 and others added 3 commits July 21, 2026 16:09
Add `TransactionBuilder::set_funding_multi`, a funding entry that draws a union
of UTXOs from several accounts and reserves each selected input in the ledger of
the account that *owns* it — not in one shared set. This fixes the reservation
defect behind the platform asset-lock union builder, where extra-account inputs
were reserved in the primary account's ledger, letting a build issued through
the owning account reselect the same coin before broadcast reconciliation.

Internals: the builder's `reservations` field becomes a private `Reservations`
enum (`None` / `Single` / `Multi`). The single-account `set_funding` path is
byte-for-byte unchanged (`Single`: every selected input reserved in the one
set). `Multi` groups the selected inputs by owning account and commits them all
in one synchronous, infallible step at assembly time, only after coin selection
has fully succeeded. Because `ReservationSet::reserve` cannot fail and the
commit yields to no other task, the union either reserves entirely or (on an
earlier selection error) not at all — no window in which a subset is reserved
and a concurrent selector grabs the rest. A signing failure after the commit
releases every group, so no owning account is left with a stranded reservation.

Per-account release and TTL sweep need no new machinery: each input lives in its
owning account's existing set, so `update_utxos` / `release_reservation` / the
TTL backstop already reclaim it in the right ledger.

Deviates from the design note's `ReservationTxn`/`stage`/`ReservationConflict`
sketch: since `reserve` is infallible and cross-account atomicity is already
provided by the wallet-manager lock the single-account path documents and
relies on, that staging/rollback surface is unnecessary. This keeps the new
public API to a single method.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add tests for `set_funding_multi`:
- each selected input is reserved in its owning account's ledger, across a
  three-account union drain;
- an account whose coin is not selected reserves nothing (group filter);
- an under-funded union reserves nothing anywhere (fail before commit);
- a signing failure rolls back every account's group;
- per-account `release_reservation` and the TTL backstop each act only on the
  owning ledger, needing no shared machinery;
- two builds racing over the same union under a shared lock never double-select
  (Arc<Mutex> + thread::spawn, matching the crate's concurrency-test idiom).

Expose `RESERVATION_TTL_BLOCKS` as `pub(crate)` so the TTL test can assert the
exact reclaim boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uard duplicate primary

Round-2 review follow-ups on set_funding_multi:

- extras are only read (utxos/reservations, both &self); the only mutation is
  primary.next_change_address. Change `extra` from
  `IntoIterator<Item = &mut ManagedCoreFundsAccount>` to
  `Item = &ManagedCoreFundsAccount`. The platform call site clones/borrows
  extras under an immutable all_funding_accounts() borrow and takes &mut only on
  the primary; shared refs make adoption a clean single-call swap instead of
  forcing N simultaneous exclusive borrows that also exclude the primary.

- guard the documented "primary must not appear in extra" precondition: with
  shared-ref extras, compare identity via std::ptr::eq and skip the duplicate so
  it cannot be pushed and reserved twice; debug_assert!(false, ...) plus a
  tracing::warn! matches the crate's existing "shouldn't happen" idiom (the
  builder is fluent/returns Self, so a Result variant would break the chain).
  Add set_funding_multi_rejects_primary_listed_in_extra (should_panic).

Update the doc comment and every test call site. Full suites green:
key-wallet lib 555, key-wallet-ffi 233, key-wallet-manager 48+7+5; clippy+fmt clean.

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

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@bfoss765, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: be94aeb9-1763-4672-b42c-6b285641894b

📥 Commits

Reviewing files that changed from the base of the PR and between 19690d3 and 70c677b.

📒 Files selected for processing (2)
  • key-wallet/src/managed_account/reservation.rs
  • key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs
✨ 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.

@QuantumExplorer

Copy link
Copy Markdown
Member

I don't think we would ever want this, as it goes against how SPV wallet accounts should work. Generally you want a barrier against accounts being linked together.

@QuantumExplorer QuantumExplorer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking this.

@bfoss765 bfoss765 closed this Jul 22, 2026
@bfoss765

Copy link
Copy Markdown
Contributor Author

Closing in favor of the single-account approach.

#912 added atomic multi-account UTXO reservations (set_funding_multi) specifically to reserve across multiple funds accounts in a single transaction. We've since decided to keep asset-lock / shielded funding single-source-account: the re-scoped platform PR #4184 takes a single optional derivation path (default = unmixed BIP44), and each funding transaction draws from exactly one source account. That removes the requirement this PR existed to satisfy — there is no longer a multi-account atomic-reservation scenario.

No consumer depends on set_funding_multi. Happy to reopen if the multi-account requirement returns.

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.

2 participants