Skip to content

feat(key-wallet): reserve receive addresses on hand-out#818

Open
xdustinface wants to merge 1 commit into
devfrom
feat/address-reservation
Open

feat(key-wallet): reserve receive addresses on hand-out#818
xdustinface wants to merge 1 commit into
devfrom
feat/address-reservation

Conversation

@xdustinface

@xdustinface xdustinface commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Adds a reservation lifecycle to addresses so a receive address handed out to a caller is not re-issued before it is funded or explicitly released. This closes the hand-out race where two sequential requests returned the same address.

  • Model address lifecycle as an AddressState enum (Available, Reserved { at }, Used { at }) on AddressInfo, replacing the separate used/used_at fields. The states are mutually exclusive by construction, so the invariant "a used address is never reserved" holds structurally instead of being maintained by hand.
  • Add next_unused_and_reserve, release_reservation, and sweep_expired_reservations (TTL backstop) on AddressPool; reserved addresses are excluded from hand-out, count against the gap limit, and are never pruned or aged out when clockless.
  • Wire next_receive_address_and_reserve, release_receive_reservation, and sweep_expired_receive_reservations through ManagedCoreFundsAccount, bumping the monitor revision on change.
  • Add reserved_count to PoolStats.
  • Cover reserve/release/sweep, serde and bincode round-trips, gap-limit and prune interaction, and end-to-end promotion on funding.

Summary by CodeRabbit

Release Notes

New Features

  • Introduced address reservation system enabling temporary holds on receive addresses with configurable time-to-live
  • Added ability to release reserved addresses back to the available pool
  • Automatic cleanup mechanism for expired reservations
  • Enhanced address state tracking with distinct states for available, reserved, and used addresses

Adds a reservation lifecycle to addresses so a receive address handed out to a caller is not re-issued before it is funded or explicitly released. This closes the hand-out race where two sequential requests returned the same address.

- Model address lifecycle as an `AddressState` enum (`Available`, `Reserved { at }`, `Used { at }`) on `AddressInfo`, replacing the separate `used`/`used_at` fields. The states are mutually exclusive by construction, so the invariant "a used address is never reserved" holds structurally instead of being maintained by hand.
- Add `next_unused_and_reserve`, `release_reservation`, and `sweep_expired_reservations` (TTL backstop) on `AddressPool`; reserved addresses are excluded from hand-out, count against the gap limit, and are never pruned or aged out when clockless.
- Wire `next_receive_address_and_reserve`, `release_receive_reservation`, and `sweep_expired_receive_reservations` through `ManagedCoreFundsAccount`, bumping the monitor revision on change.
- Add `reserved_count` to `PoolStats`.
- Cover reserve/release/sweep, serde and bincode round-trips, gap-limit and prune interaction, and end-to-end promotion on funding.
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Introduces AddressState (Available, Reserved, Used) to replace the flat used/used_at fields on AddressInfo. AddressPool gains next_unused_and_reserve, release_reservation, and sweep_expired_reservations. ManagedCoreFundsAccount exposes matching Standard-only wrappers. FFI adapters and downstream test fixtures are updated throughout.

Changes

Address Reservation Lifecycle

Layer / File(s) Summary
AddressState enum and AddressInfo contract
key-wallet/src/managed_account/address_pool.rs
Defines AddressState (Available, Reserved { at }, Used { at }) with serde/bincode support. Refactors AddressInfo to carry state instead of used/used_at. Updates both constructors and rewrites all lifecycle predicates (is_available, is_reserved, is_used, used_at, reserved_at, mark_used).
AddressPool selection, marking, and stats
key-wallet/src/managed_account/address_pool.rs
Updates next_unused*, next_unused_multiple*, unused_addresses_count, unused_addresses, mark_used, mark_index_used, scan_for_usage, needs_more_addresses, stats (adds reserved_count), reset_usage, prune_unused, PoolStats, and Display to use is_available()/is_reserved()/is_used().
New reservation methods on AddressPool
key-wallet/src/managed_account/address_pool.rs
Implements next_unused_and_reserve, release_reservation, and sweep_expired_reservations with defined edge-case semantics for now==0, ttl==0, and at==0.
ManagedCoreFundsAccount reservation wrappers
key-wallet/src/managed_account/managed_core_funds_account.rs
Adds next_receive_address_and_reserve, release_receive_reservation, and sweep_expired_receive_reservations as Standard-only methods, each bumping the monitor revision on observable state changes.
FFI adapter and downstream fixture updates
key-wallet-ffi/src/address_pool.rs, key-wallet-manager/src/events.rs
Updates address_info_to_ffi to call is_used()/used_at(). Fixes AddressInfo construction in FFI and manager test fixtures to use state: AddressState::Available.
Tests: pool units, batch selection, and end-to-end reservation
key-wallet/src/managed_account/address_pool.rs, key-wallet/src/tests/address_pool_tests.rs, key-wallet/src/tests/address_reservation_tests.rs, key-wallet/src/tests/mod.rs
Adds/reworks pool unit tests for reservation handout, release, idempotency, serde round-trip, sweep, reset, prune, and headroom. Updates batch-selection tests to assert reserved addresses are skipped. Adds address_reservation_tests module with async end-to-end, failure-mode, and TTL sweep boundary tests.

Sequence Diagram(s)

sequenceDiagram
    participant Caller
    participant ManagedCoreFundsAccount
    participant AddressPool
    participant AddressInfo

    Caller->>ManagedCoreFundsAccount: next_receive_address_and_reserve(xpub, now)
    ManagedCoreFundsAccount->>AddressPool: next_unused_and_reserve(key_source, now)
    AddressPool->>AddressInfo: find Available entry or derive new
    AddressInfo-->>AddressPool: state = Reserved { at: now }
    AddressPool-->>ManagedCoreFundsAccount: Address
    ManagedCoreFundsAccount-->>Caller: Ok(Address) + bump monitor_revision

    Caller->>ManagedCoreFundsAccount: release_receive_reservation(&address)
    ManagedCoreFundsAccount->>AddressPool: release_reservation(index)
    AddressPool->>AddressInfo: if Reserved → state = Available
    AddressPool-->>ManagedCoreFundsAccount: bool (was_reserved)
    ManagedCoreFundsAccount-->>Caller: bool + bump monitor_revision if true

    Caller->>ManagedCoreFundsAccount: sweep_expired_receive_reservations(now, ttl)
    ManagedCoreFundsAccount->>AddressPool: sweep_expired_reservations(now, ttl)
    AddressPool->>AddressInfo: Reserved { at } where (now - at) > ttl → Available
    AddressPool-->>ManagedCoreFundsAccount: usize (reclaimed)
    ManagedCoreFundsAccount-->>Caller: usize + bump monitor_revision if > 0
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

  • dashpay/rust-dashcore#781: Directly overlaps on AddressInfo/AddressPool in key-wallet/src/managed_account/address_pool.rs, as both PRs modify the same core types and data model layer.

Suggested labels

ready-for-review

Suggested reviewers

  • ZocoLini
  • llbartekll

Poem

🐇 Hop, hop — no more address race!
Three states now keep each coin in place:
Available, Reserved, or Used with care,
The bunny stamps a timestamp there.
Sweep the stale ones, free the rest —
A tidy pool is always best! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(key-wallet): reserve receive addresses on hand-out' is directly related to the main change: implementing a reservation lifecycle for receive addresses to prevent race conditions when handing out addresses.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/address-reservation

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.

@codecov

codecov Bot commented Jun 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.52239% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.19%. Comparing base (0b056c2) to head (b4a2439).
⚠️ Report is 3 commits behind head on dev.

Files with missing lines Patch % Lines
key-wallet/src/managed_account/address_pool.rs 95.50% 13 Missing ⚠️
.../src/managed_account/managed_core_funds_account.rs 95.23% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #818      +/-   ##
==========================================
- Coverage   73.38%   73.19%   -0.20%     
==========================================
  Files         323      323              
  Lines       72288    72324      +36     
==========================================
- Hits        53048    52936     -112     
- Misses      19240    19388     +148     
Flag Coverage Δ
core 76.74% <ø> (ø)
ffi 47.35% <100.00%> (-1.53%) ⬇️
rpc 20.00% <ø> (-13.05%) ⬇️
spv 90.18% <ø> (-0.08%) ⬇️
wallet 72.50% <95.48%> (+0.85%) ⬆️
Files with missing lines Coverage Δ
key-wallet-ffi/src/address_pool.rs 36.72% <100.00%> (-3.11%) ⬇️
key-wallet-manager/src/events.rs 67.98% <100.00%> (-0.16%) ⬇️
.../src/managed_account/managed_core_funds_account.rs 78.52% <95.23%> (+1.79%) ⬆️
key-wallet/src/managed_account/address_pool.rs 79.35% <95.50%> (+13.50%) ⬆️

... and 23 files with indirect coverage changes

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@key-wallet/src/managed_account/address_pool.rs`:
- Around line 1053-1058: The needs_more_addresses() method now evaluates
available addresses using the is_available() predicate to determine if more
addresses are needed, but the maintain_gap_limit() function only checks against
highest_used when deciding whether to generate new addresses. This creates a
mismatch where needs_more_addresses() can return true for reserved-but-unused
pools while maintain_gap_limit() generates no new addresses. Update the
maintain_gap_limit() function to use the same availability predicate that counts
is_available() addresses when deciding whether to replenish the pool, ensuring
both methods use consistent logic for determining when the gap limit has been
breached.
- Around line 676-679: Replace the expect() call on the get_mut(&next_index)
operation with proper error handling using ok_or() to convert the Option into a
Result, then propagate this error through the return type of the containing
function instead of panicking. This ensures that missing pool entries result in
a returned error rather than a process crash, which is appropriate for library
code.
- Around line 250-251: The state field on AddressState lacks backward
compatibility support for deserialization of existing wallet payloads. Since the
legacy used and used_at fields have been removed, older serialized wallets will
fail to deserialize. Add the #[serde(default)] attribute to the state field
declaration to allow missing values during deserialization, or implement a
custom Deserialize handler or deserialize_with function that maps the old used
and used_at fields to the appropriate AddressState enum variant. Verify the fix
works by adding a test that attempts to deserialize a wallet payload with the
legacy field structure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 53d08b96-0bb9-4ded-afe8-c70aa70862c6

📥 Commits

Reviewing files that changed from the base of the PR and between 95a3c8f and b4a2439.

📒 Files selected for processing (7)
  • key-wallet-ffi/src/address_pool.rs
  • key-wallet-manager/src/events.rs
  • key-wallet/src/managed_account/address_pool.rs
  • key-wallet/src/managed_account/managed_core_funds_account.rs
  • key-wallet/src/tests/address_pool_tests.rs
  • key-wallet/src/tests/address_reservation_tests.rs
  • key-wallet/src/tests/mod.rs

Comment thread key-wallet/src/managed_account/address_pool.rs
Comment on lines +676 to +679
let info = self
.addresses
.get_mut(&next_index)
.expect("generate_address_at_index(add_to_state=true) must insert at next_index");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Return an error instead of panicking on a pool invariant miss.

This is library code, so an unexpected missing entry should propagate through Result rather than crash the process with expect(). As per coding guidelines, “Avoid unwrap() and expect() in library code; use proper error types.”

Proposed fix
         let info = self
             .addresses
             .get_mut(&next_index)
-            .expect("generate_address_at_index(add_to_state=true) must insert at next_index");
+            .ok_or_else(|| {
+                Error::InvalidParameter(
+                    "generate_address_at_index(add_to_state=true) did not insert at next_index"
+                        .into(),
+                )
+            })?;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let info = self
.addresses
.get_mut(&next_index)
.expect("generate_address_at_index(add_to_state=true) must insert at next_index");
let info = self
.addresses
.get_mut(&next_index)
.ok_or_else(|| {
Error::InvalidParameter(
"generate_address_at_index(add_to_state=true) did not insert at next_index"
.into(),
)
})?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@key-wallet/src/managed_account/address_pool.rs` around lines 676 - 679,
Replace the expect() call on the get_mut(&next_index) operation with proper
error handling using ok_or() to convert the Option into a Result, then propagate
this error through the return type of the containing function instead of
panicking. This ensures that missing pool entries result in a returned error
rather than a process crash, which is appropriate for library code.

Source: Coding guidelines

Comment on lines 1053 to 1058
pub fn needs_more_addresses(&self) -> bool {
let unused_count = self.addresses.values().filter(|info| !info.used).count() as u32;
let available_count =
self.addresses.values().filter(|info| info.is_available()).count() as u32;

unused_count < self.gap_limit
available_count < self.gap_limit
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep needs_more_addresses() aligned with maintain_gap_limit().

This now reports true when reservations reduce available headroom, but maintain_gap_limit() still only keys off highest_used, so a reserved-only pool can need more addresses while generating none. Update replenishment to use the same availability predicate, or this predicate becomes misleading.

Suggested direction
-        while self.highest_generated.unwrap_or(0) < target {
+        while self.needs_more_addresses() || self.highest_generated.unwrap_or(0) < target {
             let next_index = self.highest_generated.map(|h| h + 1).unwrap_or(0);
             self.generate_address_at_index(next_index, key_source, true)?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@key-wallet/src/managed_account/address_pool.rs` around lines 1053 - 1058, The
needs_more_addresses() method now evaluates available addresses using the
is_available() predicate to determine if more addresses are needed, but the
maintain_gap_limit() function only checks against highest_used when deciding
whether to generate new addresses. This creates a mismatch where
needs_more_addresses() can return true for reserved-but-unused pools while
maintain_gap_limit() generates no new addresses. Update the maintain_gap_limit()
function to use the same availability predicate that counts is_available()
addresses when deciding whether to replenish the pool, ensuring both methods use
consistent logic for determining when the gap limit has been breached.

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.

1 participant