Skip to content

key-wallet: native ephemeral Reserved address state + atomic next_unused_and_reserve in AddressPool #791

@Claudius-Maginificent

Description

@Claudius-Maginificent

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions