Summary
Add a native, ephemeral Reserved tri-state to key-wallet::AddressPool —
Unused → Reserved → Used — together with an atomic
next_unused_and_reserve(&mut self, key_source, add_to_state) -> Result<Address>
that picks the first non-used, non-reserved index and marks it reserved
in one critical section.
Motivation
AddressPool currently models only two states per derivation index:
unused → used. The used flag flips only when a positive-balance sync proves
an address received funds, so next_unused returns the first index whose
used == false.
This has a hand-out race for downstream consumers: two concurrent callers each
ask for "the next unused receive address", both observe the same index as
used == false (no payment has landed yet — and won't until the user actually
receives funds), and both are handed the same address. Concurrent receive
flows then collide on one address.
Dash Platform's platform-wallet hit exactly this on its DIP-17 platform
payment receive-address path (next_unused_receive_address). We are currently
bridging the gap with a platform-local, process-global reservation table
(an in-memory Mutex<HashMap<(wallet, account), HashMap<index, reserved_at>>>)
consulted alongside the pool's used flag. It works (proven distinct under a
10k-task concurrency stress test), but it's a stopgap: global mutable state
decouples reservation lifetime from the wallet and duplicates state the pool
should own.
Requested API
impl AddressPool {
/// First index that is neither `used` nor `reserved`; marks it
/// `reserved` and returns its address, atomically. `add_to_state`
/// materializes the chosen index into the pool (so it counts toward
/// the gap-limit scan window).
pub fn next_unused_and_reserve(
&mut self,
key_source: &KeySource,
add_to_state: bool,
) -> Result<Address>;
/// Clear a reservation once the address is proven used (on a
/// positive-balance sync), completing Unused → Reserved → Used.
pub fn release_reservation(&mut self, index: u32);
// Optional: age-based reclaim so an abandoned hand-out frees its slot.
pub fn sweep_expired_reservations(&mut self, ttl: Duration) -> usize;
}
Requirements / semantics
- Ephemeral: the
reserved set must NOT be persisted/serialized — rebuilt
empty on load, so a reserved-but-never-paid index frees on restart rather
than pinning gap-limit headroom forever. (Distinct from used, which is
durable.)
- Atomic: pick-and-reserve must be a single critical section — no TOCTOU
gap between "find unused" and "mark reserved".
- Gap-limit: reserved-but-unused indices should count toward the scan
ceiling (e.g. via materializing the chosen index), so max(highest_used, highest_reserved) drives the window.
Downstream impact
Once this lands, platform-wallet removes its process-global bridge table and
collapses its single bridge function to a one-line delegation to
pool.next_unused_and_reserve(...) — the platform-side signature was kept
intentionally identical to make that swap trivial and touch no call sites.
Summary
Add a native, ephemeral Reserved tri-state to
key-wallet::AddressPool—Unused → Reserved → Used— together with an atomicnext_unused_and_reserve(&mut self, key_source, add_to_state) -> Result<Address>that picks the first non-
used, non-reservedindex and marks itreservedin one critical section.
Motivation
AddressPoolcurrently models only two states per derivation index:unused → used. Theusedflag flips only when a positive-balance sync provesan address received funds, so
next_unusedreturns the first index whoseused == false.This has a hand-out race for downstream consumers: two concurrent callers each
ask for "the next unused receive address", both observe the same index as
used == false(no payment has landed yet — and won't until the user actuallyreceives funds), and both are handed the same address. Concurrent receive
flows then collide on one address.
Dash Platform's
platform-wallethit exactly this on its DIP-17 platformpayment receive-address path (
next_unused_receive_address). We are currentlybridging the gap with a platform-local, process-global reservation table
(an in-memory
Mutex<HashMap<(wallet, account), HashMap<index, reserved_at>>>)consulted alongside the pool's
usedflag. It works (proven distinct under a10k-task concurrency stress test), but it's a stopgap: global mutable state
decouples reservation lifetime from the wallet and duplicates state the pool
should own.
Requested API
Requirements / semantics
reservedset must NOT be persisted/serialized — rebuiltempty on load, so a reserved-but-never-paid index frees on restart rather
than pinning gap-limit headroom forever. (Distinct from
used, which isdurable.)
gap between "find unused" and "mark reserved".
ceiling (e.g. via materializing the chosen index), so
max(highest_used, highest_reserved)drives the window.Downstream impact
Once this lands,
platform-walletremoves its process-global bridge table andcollapses its single bridge function to a one-line delegation to
pool.next_unused_and_reserve(...)— the platform-side signature was keptintentionally identical to make that swap trivial and touch no call sites.