fix(platform-wallet): atomic receive-address reservation (Reserved bridge for hand-out race)#3770
Draft
Claudius-Maginificent wants to merge 2 commits into
Conversation
…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>
Contributor
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue being fixed or feature implemented
Concurrent receive-address hand-out could hand the same platform payment address to two callers. The upstream
key-walletAddressPoolmodels only a two-state lifecycle (unused → used), andusedflips 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 asused == falseand both get the same address. This is the root cause behind the Found-026 race family.What was done?
Introduces an ephemeral Reserved tri-state (unused → reserved → used) for receive-address hand-out, exposed as a single atomic
next_unused_and_reserve(...):next_unused_receive_addressnow reserves the chosen index atomically under the wallet write lock, so concurrent callers can never be handed the same slot.on_address_foundpath.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 insidekey-wallet'sAddressPoolupstream. Tracked at dashpay/rust-dashcore#791. The signature ofnext_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-linepool.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 oldmark_index_usedidiom: hand-out advances the reserved frontier while the pool's used frontier does not advance until a sync hit flipsused.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).1 passed, 10k distinct in ~28s.cargo check -p platform-wallet-ffiandcargo 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:
For repository code-owners and collaborators only
Refs dashpay/rust-dashcore#791
🤖 Generated with Claude Code