Skip to content

fix(platform-wallet): atomic receive-address reservation (Reserved bridge for hand-out race)#3770

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

fix(platform-wallet): atomic receive-address reservation (Reserved bridge for hand-out race)#3770
Claudius-Maginificent wants to merge 2 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

Issue being fixed or feature implemented

Concurrent receive-address hand-out could hand the same platform payment address to two callers. The upstream key-wallet AddressPool models only a two-state lifecycle (unusedused), and used flips only when a positive-balance sync proves an address received funds. So two callers asking for "the next unused receive address" before any payment lands both observe the same index as used == false and both get the same address. This is the root cause behind the Found-026 race family.

Stacked PR. Base branch is fix/dpns-case-and-utxo-race-v3.1-dev (the slimmed #3585 branch), not v3.1-dev. This keeps the diff to just the Reserved-bridge delta. #3585 must merge first; this PR merges after it.

What was done?

Introduces an ephemeral Reserved tri-state (unusedreservedused) for receive-address hand-out, exposed as a single atomic next_unused_and_reserve(...):

  • next_unused_receive_address now reserves the chosen index atomically under the wallet write lock, so concurrent callers can never be handed the same slot.
  • A reserved index is materialized into the pool (counts toward the gap-limit scan window) and released on confirmed use via the balance-sync on_address_found path.
  • A TTL sweep (sweep_expired_reservations, 5 min) reclaims slots that were handed out but never paid, so a caller that reserves an address and walks away doesn't pin gap-limit headroom for the process lifetime.

Design — deliberate platform-local BRIDGE

The Reserved state lives in a process-global, Mutex-guarded reservation table keyed by (wallet_id, account). This is a documented stopgap: the correct home for the tri-state is inside key-wallet's AddressPool upstream. Tracked at dashpay/rust-dashcore#791. The signature of next_unused_and_reserve(...) is intentionally shaped to match the future upstream method, so when upstream lands native support this whole module collapses to a one-line pool.next_unused_and_reserve(..) delegation with no call-site changes.

Ephemeral — no persistence, no wire/schema change

The reserved set is never persisted: it lives in memory only and is rebuilt empty on every process start. It does not appear in any serde/bincode form, so there is no #3625 schema or wire change. Reclaim is bounded by the 5-minute TTL plus release-on-use.

How Has This Been Tested?

14 unit tests covering the pure selection logic, the atomic critical section, release-on-use, TTL reclaim, and the behavioral shift, plus concurrency stress:

  • handout_advances_reserved_not_used — documents the behavioral shift vs. the old mark_index_used idiom: hand-out advances the reserved frontier while the pool's used frontier does not advance until a sync hit flips used.
  • concurrent_reserve_no_duplicates — always-on 1k-task parallel reserve, proves zero duplicates in CI.
  • concurrent_reserve_no_duplicates_10k#[ignore]-gated full-scale stress: 10,000 concurrent tasks → 10,000 pairwise-distinct indices, 0 duplicates (run explicitly with --ignored).

Verification pass, all green:

  • cargo test -p platform-wallet --all-features → 217 passed, 0 failed (1 ignored = the 10k stress).
  • 10k stress run explicitly → 1 passed, 10k distinct in ~28s.
  • cargo check -p platform-wallet-ffi and cargo check -p dash-sdk → rc=0.
  • cargo clippy -p platform-wallet --all-features --tests → 0 warnings, 0 errors.
  • cargo fmt -p platform-wallet --check → clean.

Downstream e2e suites this is expected to keep green: pa_005, pa_008b, id_002, id_005, found_026.

Breaking Changes

None. The reserved state is in-memory only and never serialized — no schema, wire, or persisted-state change. The first hand-out from a fresh account still picks exactly the index the upstream two-state pool would have, so the serialized pool is bit-identical for a single call; the reservation layer only changes subsequent concurrent hand-outs.

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 made corresponding changes to the documentation

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

Refs dashpay/rust-dashcore#791

🤖 Generated with Claude Code

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: f03bf884-21da-4e3f-bbf3-ae595ac4f7b0

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.

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