feat(key-wallet): reserve receive addresses on hand-out#818
Conversation
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.
📝 WalkthroughWalkthroughIntroduces ChangesAddress Reservation Lifecycle
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 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 |
Codecov Report❌ Patch coverage is
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
|
There was a problem hiding this comment.
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
📒 Files selected for processing (7)
key-wallet-ffi/src/address_pool.rskey-wallet-manager/src/events.rskey-wallet/src/managed_account/address_pool.rskey-wallet/src/managed_account/managed_core_funds_account.rskey-wallet/src/tests/address_pool_tests.rskey-wallet/src/tests/address_reservation_tests.rskey-wallet/src/tests/mod.rs
| let info = self | ||
| .addresses | ||
| .get_mut(&next_index) | ||
| .expect("generate_address_at_index(add_to_state=true) must insert at next_index"); |
There was a problem hiding this comment.
🩺 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.
| 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
| 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 | ||
| } |
There was a problem hiding this comment.
🎯 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.
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.
AddressStateenum (Available,Reserved { at },Used { at }) onAddressInfo, replacing the separateused/used_atfields. The states are mutually exclusive by construction, so the invariant "a used address is never reserved" holds structurally instead of being maintained by hand.next_unused_and_reserve,release_reservation, andsweep_expired_reservations(TTL backstop) onAddressPool; reserved addresses are excluded from hand-out, count against the gap limit, and are never pruned or aged out when clockless.next_receive_address_and_reserve,release_receive_reservation, andsweep_expired_receive_reservationsthroughManagedCoreFundsAccount, bumping the monitor revision on change.reserved_counttoPoolStats.Summary by CodeRabbit
Release Notes
New Features