Skip to content

fix(platform-wallet): prevent duplicate HD address hand-out with an ephemeral reservation bridge#3770

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

fix(platform-wallet): prevent duplicate HD address hand-out with an ephemeral reservation bridge#3770
Claudius-Maginificent wants to merge 13 commits into
fix/dpns-case-and-utxo-race-v3.1-devfrom
fix/platform-wallet-address-reserve-bridge

Conversation

@Claudius-Maginificent

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

Copy link
Copy Markdown
Collaborator

Issue being fixed or feature implemented

What was done?

An ephemeral reservation layer that makes concurrent HD address hand-out mutually exclusive:

  • Ephemeral "Reserved" bridge (wallet/platform_addresses/address_reserve.rs): adds an intermediate state (unused → reserved → used) in a process-global, mutex-guarded table reclaimed by a 5-minute TTL sweep. Concurrent hand-out skips reserved indices; reservations that are never consumed expire and are swept. This is a deliberate stopgap until key-wallet gains a native Reserved state upstream (tracked at dashpay/rust-dashcore#791).
  • Three pools covered:
  • De-duplicated change selection: the change-address selection loop is extracted into a shared helper (wallet/core/change_address.rs) used by the core broadcast path (wallet/core/wallet.rs, wallet/core/broadcast.rs).

How Has This Been Tested?

  • cargo build -p dash-sdk -p platform-wallet -p platform-wallet-ffi — clean.
  • Unit tests cover reserve / skip / expiry and the TTL sweep in address_reserve.rs (14 tests), receive-pool reservation in provider.rs (5 tests), and the change-pool skip of indices with in-flight reserved outpoints.

Breaking Changes

None. The reservation layer is an additive in-memory mechanism; no public call signatures change.

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

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

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: 5a822dd1-71bc-476f-9762-37685e51701d

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
lklimek and others added 5 commits June 15, 2026 20:34
# Conflicts:
#	packages/rs-platform-wallet/src/wallet/core/broadcast.rs
…committed follow-up

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ning reserve

After merging the reserve-exclusivity change, OutpointReservations::reserve
returns Option. Unwrap it at the change-handout test's pending-mark site so
the live guard is held for the duration of the assertion, matching the other
reserve call sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings in the DPNS .dash resolution revert so this branch no longer
carries the DPNS change (now in #3914); scope stays the address
reservation bridge stacked on the UTXO-spend race guard.
@Claudius-Maginificent Claudius-Maginificent changed the title fix(platform-wallet): atomic multi-pool address reservations (receive + core BIP44 receive/change) + change-loop refactor fix(platform-wallet): prevent duplicate HD address hand-out with an ephemeral reservation bridge Jun 16, 2026
@lklimek

lklimek commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

We have decided to implement this on rust-dashcore.

@lklimek lklimek closed this Jun 17, 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