Skip to content

fix(platform-wallet): atomic multi-pool address reservations (receive + core BIP44 receive/change) + change-loop refactor#3770

Draft
Claudius-Maginificent wants to merge 8 commits into
fix/dpns-case-and-utxo-race-v3.1-devfrom
fix/platform-wallet-address-reserve-bridge
Draft

fix(platform-wallet): atomic multi-pool address reservations (receive + core BIP44 receive/change) + change-loop refactor#3770
Claudius-Maginificent wants to merge 8 commits into
fix/dpns-case-and-utxo-race-v3.1-devfrom
fix/platform-wallet-address-reserve-bridge

Conversation

@Claudius-Maginificent
Copy link
Copy Markdown
Collaborator

@Claudius-Maginificent Claudius-Maginificent commented May 29, 2026

Issue being fixed or feature implemented

HD address hand-out in platform-wallet suffers a two-state race. The upstream key-wallet AddressPool models only unused → used, and used flips only when a positive-balance sync proves the address received funds. So next_unused-style hand-out returns the same derivation index to two concurrent callers until a payment lands — address reuse (a privacy + accounting hazard).

This PR closes that race across every affected pool, generalizing the receive-address "Reserved" bridge to a multi-pool model:

  • DIP-17 platform-payment receive (the original bridge).
  • Core BIP-44 external (receive)CW-001.
  • Core BIP-44 internal (change)CW-002, which additionally collides with the in-flight send_to_addresses change-address peeks tracked in pending_change.

It also extracts the duplicated change-address selection loop (Phase 3) and folds in validated review feedback.

Note

Stacked on #3585 (base branch fix/dpns-case-and-utxo-race-v3.1-dev). Review/merge after #3585. The receive-address + multi-pool reservation work that earlier drafts of #3585 carried was moved here.

PA-001 (asset-lock funding) — verified PHANTOM, no change

The suspected asset-lock funding-address reuse race does not exist. build_asset_lock_transaction holds wallet_manager.write().await across the entire build — the funding-address peek (peek_next_funding_addressnext_address(.., false)) and the builder's used-flip (next_private_keymark_index_used) run under the same write guard. Two concurrent builds are fully serialized: the second peeks only after the first has marked its index used. No funding-pool reservation is required; asset_lock/build.rs is untouched.

What was done?

Ephemeral "Reserved" bridge, generalized to multiple pools (platform_addresses/address_reserve.rs)

  • A process-global, Mutex-guarded reservation table supplies the missing reserved layer between unused and used, consulted/updated atomically while the caller holds the wallet write lock.
  • Keyed by (wallet_id, PoolKind, account), where PoolKind ∈ {PlatformReceive, CoreReceive, CoreChange} keeps each pool's index space disjoint.
  • Reservations are in-memory only (never serialized — no feat(platform-wallet)!: add platform-wallet-storage crate (sqlite persister) #3625 schema impact) and reclaimed by a 5-minute TTL sweep.

CW-001 — core BIP-44 receive + change reservation (core/wallet.rs, platform_addresses/{mod,provider,wallet}.rs)

  • next_receive_address_for_account / next_change_address_for_account (+ blocking twins, behind FFI core_wallet_next_receive_address / core_wallet_next_change_address) route through CoreWallet::reserve_bip44_address, reaching the external/internal pool via managed_account_type_mut().address_pools_mut()[0|1] and picking through the bridge.

CW-002 — change consults in-flight send reservations (core/wallet.rs)

  • The change pool is shared with the broadcast loop, which peeks change addresses into OutpointReservations.pending_change before a send confirms. The standalone change hand-out now consults that snapshot and retries; each rejected index stays bridge-reserved, so it converges to the first address that is neither bridge-reserved nor pending. The receive path uses an empty avoid-set.

Phase 3 — readability refactor (core/change_address.rs new, core/broadcast.rs, identity/network/payments.rs)

  • The duplicated change-address peek/retry/commit loop in send_to_addresses and send_payment is extracted into one core::change_address::pick_and_reserve_change_address helper. Behavior is identical (same pending_change consultation, peek/commit ordering, retry, error mapping, lock scope).

Review-feedback fixes

  • SEC-001 — drive the TTL sweep: sweep_expired(RESERVATION_TTL) is now called from PlatformPaymentAddressProvider::sync_finished (the natural per-completed-sync seam), reclaiming abandoned hand-outs across all pool kinds. RESERVATION_TTL (300s) shared from address_reserve.
  • SEC-002 — observe the leak: the success-but-unreconciled broadcast branch already warned via tracing; it now reports leaked_reservations (count of slots pinned until restart) via new reserved_count() guard accessors.
  • PROJ-001 — cross-reference the two reservation models (core/reservations RAII drop-release ⇄ platform_addresses/address_reserve global TTL-sweep) in each module header.

Upstream migration

This whole bridge is a deliberate stopgap. The proper home for the tri-state unused → reserved → used lifecycle is inside key-wallet's AddressPool — tracked at dashpay/rust-dashcore#791. The next_unused_and_reserve signature is intentionally shaped so the eventual swap is a one-liner with no call-site changes.

How Has This Been Tested?

  • cargo test -p platform-wallet --all-features: 219 lib tests pass, 0 failed, 3 ignored (the 3 are pre-existing #[ignore] heavy variants).
  • Concurrency: the always-on 1k-task stress test concurrent_reserve_no_duplicates (+ a #[ignore]d 10k variant) proves distinct hand-outs under parallel load; concurrent_same_utxo_sends_resolve_via_reservation_set proves the loser short-circuits with NoSpendableInputs.
  • New unit tests: distinct_pool_kinds_do_not_collide (pool-kind index-space disjointness), next_change_address_skips_pending_change_reservation (CW-002), plus the existing distinct/reserved-skip/release/TTL suite re-parameterized over PoolKind.
  • Behavior-preservation for the refactor: send_to_addresses_* and broadcast_and_reconcile_* pass unchanged.
  • cargo check -p platform-wallet-ffi and cargo check -p dash-sdk: rc=0. cargo clippy -p platform-wallet --all-features --tests: 0 warnings. cargo fmt --check: clean.
  • Downstream e2e pins (pa_005 / pa_008b / id_002 / id_005 / al_001) live on test(platform-wallet): e2e framework + full test suite — triage pins, Found-*/PA-* guards, fail-closed persist, Stage-2 merge #3549 / test(platform-wallet): shielded (Orchard) e2e suite — spec + Wave H harness #3727.

Breaking Changes

None. No public API or serialized/persisted form changed; reservations are ephemeral (in-memory, rebuilt empty on restart).

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 2 commits May 29, 2026 15:10
…ss hand-out race

next_unused_receive_address called the upstream two-state pool
(AddressPool::next_unused, Unused -> Used). used only flips on a
positive-balance sync, so two concurrent callers both saw the same index
as unused and were handed the SAME address.

Add a platform-local Reserved layer as a thin, self-contained bridge
behind ONE function, next_unused_and_reserve, whose signature mirrors the
intended upstream AddressPool::next_unused_and_reserve so the future swap
is a one-liner with no call-site churn.

- Reserved indices live in a process-global Mutex-guarded side table,
  keyed by (wallet_id, account). Ephemeral: never persisted, rebuilt
  empty on restart, absent from every serde/bincode form.
- Pick-and-reserve is one critical section under the table Mutex (and the
  wallet write lock at the call site) -- no TOCTOU gap.
- The chosen index is materialized via the pool's public generator, so
  reserved-but-unused indices count toward the gap-limit scan window
  (they become pending addresses the BLAST sync covers); the ceiling is
  computed upstream from the materialized pool, so no local
  max(highest_used, highest_reserved) patch is needed.
- on_address_found releases the reservation once an address is proven
  used, completing Unused -> Reserved -> Used.
- TTL reclaim: sweep_expired + PlatformAddressWallet::sweep_expired_reservations
  (default 1h) free stale reservations in long-lived processes.

Tests (Found-026 adapted + mandatory concurrency stress):
- back-to-back hand-outs distinct; reserved index skipped while pool
  still reports unused; on-use clears reservation; hand-out advances
  highest_reserved while highest_used does NOT advance until a sync hit.
- 1000-task always-on concurrency test + 10_000-task #[ignore] variant:
  every task gets a pairwise-distinct address (verified 10k -> 10k).
- persisted-form invariant: first hand-out picks the same index as
  upstream next_unused, so the serialized pool is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rust-dashcore#791); set reservation TTL to 5m

The process-global reserved table is a deliberate bridge until key-wallet
gains a native Reserved tri-state in its AddressPool. Document that intent
inline and at fn table(), referencing the upstream feature request
dashpay/rust-dashcore#791 so the future swap to
a one-line pool.next_unused_and_reserve(..) delegation is obvious.

Tighten RESERVATION_TTL from 1h to 5m so abandoned receive-address
hand-outs reclaim their gap-limit slot faster. The set stays in-memory
only (rebuilt empty on restart, never serialized), so this only bounds
leakage within a single long-lived process.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 29, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 88af1ba9-db52-4179-9a93-c9836ceb0fc7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/platform-wallet-address-reserve-bridge

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 and usage tips.

lklimek and others added 6 commits May 30, 2026 01:10
…ation (CW-001)

Core BIP44 address hand-out (`next_receive_address_for_account`,
`next_change_address_for_account`, their blocking twins, and FFI
`core_wallet_next_receive_address` / `core_wallet_next_change_address`)
called `next_receive_address(.., true)` / `next_change_address(.., true)`,
which delegate to `AddressPool::next_unused`. That only flips `used` on a
positive-balance sync, so two concurrent callers were handed the SAME
index -- the same two-state race the platform-address bridge already fixes.

Route both pools through the ephemeral Reserved bridge, keyed by a new
`PoolKind` discriminant so the BIP44 external and internal pools each keep
a disjoint index space from the DIP-17 platform-payment pool and from each
other. Reservations are in-memory only and released by the 5-minute TTL
sweep; once an index is actually paid the pool's own `used` flag keeps it
out of future hand-out, so a lingering reservation is harmless until swept.

- Generalize `address_reserve` to key by `(wallet_id, PoolKind, account)`;
  `PlatformReceive` preserves the existing platform-address behavior.
- Add `CoreWallet::reserve_bip44_address` reaching the external/internal
  pool via `managed_account_type_mut().address_pools_mut()[0|1]`.
- BRIDGE/TODO(upstream) -> rust-dashcore#791; ephemeral, no serialized form
  changed (no #3625 schema impact).

Tests: pool-kind disjointness, plus the existing distinct/reserved-skip/
release/TTL/concurrency suite now parameterized over `PoolKind` (13 ok).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…adability (no behavior change)

The change-address peek/retry/commit loop was duplicated verbatim in
`core/broadcast.rs::send_to_addresses` and
`identity/network/payments.rs::send_payment`. Extract it into one
`core::change_address::pick_and_reserve_change_address` helper both call
sites use.

Behavior is identical: same `pending_change` snapshot consultation, same
peek(advance=false) / commit(advance=true) ordering, same
retry-on-pending, same `TransactionBuild` error mapping, same
wallet-write-lock scope (the caller still holds it and still records the
returned address into `pending_change` via the outpoint reservation). The
advance commits exactly once per iteration -- the prior code did the same
in both its break and burn branches, just written twice.

Proof: existing `concurrent_callers_get_no_spendable_inputs`,
`send_to_addresses_reserves_change_address`,
`send_to_addresses_burns_change_index_when_reserved`, and the
change-reservation tests pass unchanged (283 passed, 0 failed -- same
count as before the refactor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the `pub(crate) mod change_address;` declaration that the readability
refactor's extracted helper needs. Folds into the preceding refactor
commit logically; kept as a follow-up because history rewriting is
disabled in this environment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `next_change_address_skips_pending_change_reservation`: peek the
change address an in-flight `send_to_addresses` would choose, mark it
pending in `OutpointReservations.pending_change`, then assert a
standalone `next_change_address_for_account` hand-out returns a
DIFFERENT address. Locks in the CW-002 avoid-loop behaviour.

The CW-002 production change (the `pending_change` avoid-loop in
`reserve_bip44_address`) shipped folded into the CW-001 commit because
history rewrite is disabled in this environment; this commit adds the
dedicated regression test for it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…TL sweep, observe leak_until_sync, cross-ref reservation impls

SEC-001: the ephemeral address-reservation TTL sweep was implemented and
tested but nothing drove it at runtime, so abandoned receive/change/CW
reservations pinned gap-limit slots until process restart. Drive
`sweep_expired(RESERVATION_TTL)` from the address-sync `sync_finished`
seam — a natural per-completed-sync periodic point that already runs in
the provider — reclaiming stale hand-outs across ALL pool kinds. Addresses
proven used are still released eagerly in `on_address_found`; this only
frees never-paid hand-outs. `RESERVATION_TTL` moves to `address_reserve`
(next to `sweep_expired`) so both the provider driver and the existing
host-facing `sweep_expired_reservations` share one constant.

SEC-002: the success-but-unreconciled broadcast branch leaks the outpoint
/change reservation via `leak_until_sync` (no in-process reclaim). Both
leak `tracing::warn!` events already explained why; add a
`leaked_reservations` count (new `reserved_count()` on the guards) so the
leak is observable — how many slots are pinned until restart.

PROJ-001: cross-reference the two reservation models — `core/reservations`
(RAII drop-release) and `platform_addresses/address_reserve` (global
TTL-sweep) — in each module header so a future change to one reclaim model
isn't forgotten in the other.

No behavior change beyond the now-driven sweep; TTL value (300s) and all
reservation semantics unchanged. 219 tests pass; clippy/fmt clean;
platform-wallet-ffi + dash-sdk check rc=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…L const, reserved_count, sweep driver)

The preceding review-feedback commit shipped partial — several edits did
not land, leaving the tree non-compiling (unresolved RESERVATION_TTL
import, missing ReservationGuard::reserved_count). Complete the wiring:

- SEC-001: add `pub(crate) const RESERVATION_TTL` to `address_reserve`
  (the shared home `wallet.rs` already imports) and drive
  `sweep_expired(RESERVATION_TTL)` from
  `PlatformPaymentAddressProvider::sync_finished` — the natural
  per-completed-sync periodic seam — reclaiming stale hand-outs across all
  pool kinds.
- SEC-002: add `ReservationGuard::reserved_count` so the broadcast leak
  warn! can report `leaked_reservations` (how many slots are pinned until
  restart).
- PROJ-001: add the `address_reserve` side of the two-models cross-ref.

Restores green: platform-wallet 219 passed/0 failed/3 ignored; clippy
--all-features --tests 0 warnings; fmt clean; platform-wallet-ffi +
dash-sdk check rc=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Claudius-Maginificent Claudius-Maginificent changed the title fix(platform-wallet): atomic receive-address reservation (Reserved bridge for hand-out race) fix(platform-wallet): atomic multi-pool address reservations (receive + core BIP44 receive/change) + change-loop refactor May 30, 2026
@QuantumExplorer QuantumExplorer added this to the v4.2.0 milestone Jun 1, 2026
@QuantumExplorer QuantumExplorer removed this from the v4.2.0 milestone Jun 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants