Skip to content

fix(platform-wallet): reserve receive address via AddressReservations (Found-026) [backport]#3658

Open
lklimek wants to merge 8 commits into
fix/dpns-case-and-utxo-race-v3.1-devfrom
fix/found-026-v3.1-dev
Open

fix(platform-wallet): reserve receive address via AddressReservations (Found-026) [backport]#3658
lklimek wants to merge 8 commits into
fix/dpns-case-and-utxo-race-v3.1-devfrom
fix/found-026-v3.1-dev

Conversation

@lklimek
Copy link
Copy Markdown
Contributor

@lklimek lklimek commented May 18, 2026

Semantic backport to v3.1-dev of the Found-026 reserve-on-hand-out fix. Iterated through reviewer feedback to land on a reservation-based design shared with #3585's UTXO race protection.

Problem

key-wallet AddressPool::next_unused returns the first used==false index WITHOUT marking it; used only flips on a positive synced balance. Two concurrent callers of PlatformAddressWallet::next_unused_receive_address therefore receive the SAME address before the sync pass — duplicate-address race (Found-026).

Fix

This PR builds on #3585's generic Reservations<K> RAII guard. Two consumers now share the abstraction:

next_unused_receive_address no longer eagerly calls mark_index_used on hand-out (per @QuantumExplorer feedback — advances highest_used / gap-limit prematurely). Instead it snapshots AddressReservations, picks the first !used && !reserved index, and returns a leak_until_sync-flavored guard. The slot stays reserved until the matching positive-balance sync flips used (then the in-memory entry is cleared) or until process restart (clears all in-memory reservations; the documented "unpaid addresses freed next session" contract).

Generic Reservations<K>

Refactored #3585's OutpointReservations to Reservations<K: Eq + Hash + Clone + Debug> with ReservationGuard<K>:

  • release_after_commit() — drop guard normally.
  • leak_until_sync()std::mem::forget(self) so a successful broadcast holds the slot through the event-loop tick.

OutpointReservations is retained as a domain wrapper composing Reservations<OutPoint> + Reservations<Address> because the change-address-tracking surface (pending_change_snapshot() etc.) doesn't fit a plain K alias. AddressReservations = Reservations<(u32, u32)> IS a clean alias.

Validation

  • cargo test -p platform-wallet --lib: 153 passed, 0 failed.
  • cargo test -p platform-wallet-ffi --lib: 80 passed, 0 failed.
  • cargo fmt --all + cargo clippy --workspace --all-targets -- -D warnings: clean.

New tests:

  • address_reservation_distinct_keys_independent
  • address_reservation_leak_until_sync_holds_slot
  • address_reservation_same_key_twice_is_redundant_release
  • address_reservation_snapshot_reflects_state
  • next_unused_receive_address_does_not_advance_highest_used
  • handout_skips_reserved_but_not_used_indices

Base branch

This PR is currently retargeted at fix/dpns-case-and-utxo-race-v3.1-dev (#3585's branch) since the generic Reservations<K> lives there. When #3585 merges to v3.1-dev, this PR's base will be retargeted back to v3.1-dev.

Drive-by

Fixed three pre-existing clippy::manual_dangling_ptr lints (Rust 1.92) in platform-wallet-ffi that surfaced under workspace -D warnings.

Backport sibling of #3549 / Found-026.

🤖 Co-authored by Claudius the Magnificent AI Agent

… (Found-026) [backport]

Semantic backport of the #3549-proven Found-026 reserve-on-hand-out fix.
Region diverged on v3.1-dev so re-applied by hand, not cherry-picked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 18, 2026

📝 Walkthrough

Walkthrough

next_unused_receive_address now retrieves address metadata (including index) via next_unused_with_info, marks that index as used in-memory with mark_index_used, and then returns the corresponding PlatformAddress. A test verifies two immediate handouts are distinct without a sync.

Changes

Platform Address Reservation

Layer / File(s) Summary
Address reservation with explicit index marking
packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs
next_unused_receive_address calls next_unused_with_info to obtain address metadata and index, then calls mark_index_used(index) before converting to PlatformAddress, preventing reissuance before the next sync.
Test: back-to-back handout returns distinct addresses
packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs
Adds a #[cfg(test)] Tokio test found_026_back_to_back_handout_returns_distinct_addresses that asserts two consecutive next_unused_receive_address calls (without sync) return different addresses.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 Two hops, two keys, no tangled snare,
I mark the places with careful care.
Hands reach twice, they find not the same,
Each index kept safe, no duplicate name.
Hooray—fresh addresses, neat as a hare!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main change: reserving a receive address to prevent duplicate-address races in platform wallet address hand-out.

✏️ 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 fix/found-026-v3.1-dev

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 and usage tips.

@github-actions github-actions Bot added this to the v3.1.0 milestone May 18, 2026
@lklimek lklimek changed the title fix(rs-platform-wallet): reserve platform receive address on hand-out (Found-026) [backport] fix(platform-wallet): reserve platform receive address on hand-out (Found-026) [backport] May 21, 2026
@lklimek lklimek moved this to In Progress in Platform team May 21, 2026
@lklimek lklimek self-assigned this May 21, 2026
@lklimek lklimek marked this pull request as ready for review May 21, 2026 08:17
@lklimek lklimek requested a review from QuantumExplorer as a code owner May 21, 2026 08:17
@lklimek lklimek assigned shumkov and unassigned shumkov May 21, 2026
@lklimek lklimek requested a review from shumkov May 21, 2026 08:17
@lklimek lklimek moved this from In Progress to In review / testing in Platform team May 21, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented May 21, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.15%. Comparing base (9f3bfa3) to head (480375c).
⚠️ Report is 35 commits behind head on fix/dpns-case-and-utxo-race-v3.1-dev.

Additional details and impacted files
@@                           Coverage Diff                            @@
##           fix/dpns-case-and-utxo-race-v3.1-dev    #3658      +/-   ##
========================================================================
- Coverage                                 87.16%   87.15%   -0.01%     
========================================================================
  Files                                      2607     2606       -1     
  Lines                                    319420   319221     -199     
========================================================================
- Hits                                     278413   278219     -194     
+ Misses                                    41007    41002       -5     
Components Coverage Δ
dpp 87.67% <ø> (+<0.01%) ⬆️
drive 85.95% <ø> (ø)
drive-abci 89.60% <ø> (ø)
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.17% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 49.16% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 21, 2026

✅ DashSDKFFI.xcframework built for this PR.

SwiftPM (host the zip at a stable URL, then use):

.binaryTarget(
  name: "DashSDKFFI",
  url: "https://your.cdn.example/DashSDKFFI.xcframework.zip",
  checksum: "ddc2e0db0d5263b55e16d1e50fa8bb79d8e69f8a0d64220e4522cef293f1c2e3"
)

Xcode manual integration:

  • Download 'DashSDKFFI.xcframework' artifact from the run link above.
  • Drag it into your app target (Frameworks, Libraries & Embedded Content) and set Embed & Sign.
  • If using the Swift wrapper package, point its binaryTarget to the xcframework location or add the package and place the xcframework at the expected path.

Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

Surgical Found-026 backport that correctly closes the in-process duplicate-address race by reserving the index under the wallet_manager write guard. However, the reservation is in-memory only — no changeset is emitted to the persister — so the same Found-026 invariant can still be violated across a process restart. Several style nitpicks (variable shadowing, unnecessary clone) and a PR-description audit-trail drift are also worth correcting.

Inline comments could not be attached, so I posted the verified findings as a top-level review body.

Reviewed commit: 4acd3c2

🔴 1 blocking | 🟡 1 suggestion(s) | 💬 3 nitpick(s)

5 additional finding(s)

blocking: Reservation is in-memory only — restart re-opens the same Found-026 window

packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs (line 268)

mark_index_used(info.index) flips the used flag inside the live AddressPool but no changeset is emitted: there is no self.persister.store(...) call on this hand-out path, and the changeset docs (changeset.rs:919-922) explicitly state that pool snapshots fire on "used-flag flip". The only account_address_pools emission in this branch is at wallet registration (manager/wallet_lifecycle.rs:279). On restart, PlatformPaymentAddressProvider::from_persisted and PerAccountPlatformAddressState::from_persisted (provider.rs:95, 279) reconstruct state from BiBTreeMap<AddressIndex, PlatformP2PKHAddress> + a found balance map; neither carries the used flag. So if the app hands out address A, crashes/restarts before a sync funds A, then calls next_unused_receive_address again, the rebuilt pool still considers index A unused and re-hands it. The PR description scopes this to the in-process race, but Found-026 is the duplicate-address invariant — closing one of two paths to it still reproduces the same user-visible failure. Either (a) emit a pool snapshot from this hand-out path via self.persister.store(...), (b) document the restart gap explicitly in the PR body and confirm it is also unfixed on #3549, or (c) confirm a sync is unconditionally invoked before the address is returned.

nitpick: `info` shadows the outer wallet-info binding

packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs (line 268)

The outer let (wallet, info) = wm.get_wallet_mut_and_info_mut(...) on line 228 binds info to the wallet info. The new let info = managed_account.addresses.next_unused_with_info(...) shadows that binding while the mutable borrow rooted in the outer info is still alive (via managed_account). Rust allows this and it compiles, but reusing the same identifier for two semantically distinct values in a concurrency-fix block hurts readability. Rename to address_info to make the lock-scoped reservation logic obvious.

        // Reserve the address on hand-out (Found-026): platform-payment
        // `used` only flips on a positive synced balance, so without
        // marking it here a concurrent caller's `next_unused` would
        // re-hand the same index before the sync pass. `mark_index_used`
        // is idempotent — a later real sync hit on this index is a
        // no-op, so gap-limit/`highest_used` accounting isn't doubled.
        let address_info = managed_account
            .addresses
            .next_unused_with_info(&key_source, true)
            .map_err(|e| PlatformWalletError::AddressSync(e.to_string()))?;
        managed_account.addresses.mark_index_used(address_info.index);
        let address = address_info.address;
nitpick: Unnecessary clone of `info.address`

packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs (line 272)

info.address.clone() allocates a fresh Address, but the only other use of info is info.index (which is Copy). Flip the order — mark_index_used(info.index) first, then let address = info.address; — and the clone disappears. The clone exists only because of statement ordering, not borrowing constraints.

        let info = managed_account
            .addresses
            .next_unused_with_info(&key_source, true)
            .map_err(|e| PlatformWalletError::AddressSync(e.to_string()))?;
        managed_account.addresses.mark_index_used(info.index);
        let address = info.address;
suggestion: No regression test guarding the duplicate-address race on v3.1-dev

packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs (line 223)

The PR description acknowledges that v3.1-dev lacks the e2e harness and found_026 guards (those live only on #3549). For a fix whose entire purpose is concurrent correctness, a focused unit test asserting that two consecutive next_unused_receive_address calls return distinct indices without an intervening sync would prevent regression on this branch. Without it, a future refactor here has no in-tree signal that it has reintroduced the bug.

nitpick: PR description cites a key-wallet pin that does not match Cargo.toml

packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs (line 262)

The PR body states it audited the v3.1-dev key-wallet pin 53130869e5b... and confirmed byte-identical API surface for AddressPool / next_unused_with_info / mark_index_used / AddressInfo. The actual root Cargo.toml on this branch pins key-wallet at a different revision. Since cargo check passes, the API does exist at the real pin — but the cited audit trail no longer matches the source of truth. Either update the PR body to cite the current pin or note that the pin moved post-audit and re-state the API parity check against it.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

- [BLOCKING] In `packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs`:268-273: Reservation is in-memory only — restart re-opens the same Found-026 window
  `mark_index_used(info.index)` flips the used flag inside the live `AddressPool` but no changeset is emitted: there is no `self.persister.store(...)` call on this hand-out path, and the changeset docs (changeset.rs:919-922) explicitly state that pool snapshots fire on "used-flag flip". The only `account_address_pools` emission in this branch is at wallet registration (manager/wallet_lifecycle.rs:279). On restart, `PlatformPaymentAddressProvider::from_persisted` and `PerAccountPlatformAddressState::from_persisted` (provider.rs:95, 279) reconstruct state from `BiBTreeMap<AddressIndex, PlatformP2PKHAddress>` + a `found` balance map; neither carries the used flag. So if the app hands out address A, crashes/restarts before a sync funds A, then calls `next_unused_receive_address` again, the rebuilt pool still considers index A unused and re-hands it. The PR description scopes this to the in-process race, but Found-026 is the duplicate-address invariant — closing one of two paths to it still reproduces the same user-visible failure. Either (a) emit a pool snapshot from this hand-out path via `self.persister.store(...)`, (b) document the restart gap explicitly in the PR body and confirm it is also unfixed on #3549, or (c) confirm a sync is unconditionally invoked before the address is returned.
- [NITPICK] In `packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs`:268-273: `info` shadows the outer wallet-info binding
  The outer `let (wallet, info) = wm.get_wallet_mut_and_info_mut(...)` on line 228 binds `info` to the wallet info. The new `let info = managed_account.addresses.next_unused_with_info(...)` shadows that binding while the mutable borrow rooted in the outer `info` is still alive (via `managed_account`). Rust allows this and it compiles, but reusing the same identifier for two semantically distinct values in a concurrency-fix block hurts readability. Rename to `address_info` to make the lock-scoped reservation logic obvious.
- [NITPICK] In `packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs`:272-273: Unnecessary clone of `info.address`
  `info.address.clone()` allocates a fresh Address, but the only other use of `info` is `info.index` (which is `Copy`). Flip the order — `mark_index_used(info.index)` first, then `let address = info.address;` — and the clone disappears. The clone exists only because of statement ordering, not borrowing constraints.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs`:223-278: No regression test guarding the duplicate-address race on v3.1-dev
  The PR description acknowledges that v3.1-dev lacks the e2e harness and found_026 guards (those live only on #3549). For a fix whose entire purpose is concurrent correctness, a focused unit test asserting that two consecutive `next_unused_receive_address` calls return distinct indices without an intervening sync would prevent regression on this branch. Without it, a future refactor here has no in-tree signal that it has reintroduced the bug.
- [NITPICK] In `packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs`:262-267: PR description cites a key-wallet pin that does not match Cargo.toml
  The PR body states it audited the v3.1-dev key-wallet pin `53130869e5b...` and confirmed byte-identical API surface for `AddressPool` / `next_unused_with_info` / `mark_index_used` / `AddressInfo`. The actual root `Cargo.toml` on this branch pins key-wallet at a different revision. Since `cargo check` passes, the API does exist at the real pin — but the cited audit trail no longer matches the source of truth. Either update the PR body to cite the current pin or note that the pin moved post-audit and re-state the API parity check against it.

shumkov
shumkov previously approved these changes May 21, 2026
Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

Four carried-forward findings are still valid on eecd81e24d46a45b6fe9a320c4c372f543f961b7: the duplicate-address fix remains volatile across restart, there is still no focused regression test for that path, and the two wallet.rs nitpicks are unchanged. The prior key-wallet pin mismatch finding is outdated on the current head because the workspace dependency pin changed from the old revision; I found no additional source-backed defect in the latest delta beyond the unresolved carried-forward items.

Reviewed commit: eecd81e

🔴 1 blocking | 🟡 1 suggestion(s) | 💬 2 nitpick(s)

Inline posting hit GitHub validation for this overlapping carried-forward review, so all verified findings are included in the review body.

1. blocking: Receive-address reservation is still volatile, so a restart can hand out the same address again

packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs:274-279

next_unused_receive_address() reserves the returned index only inside the live AddressPool by calling mark_index_used(info.index), and this path still does not emit any PlatformWalletChangeSet or persistence callback afterward. On restart, the platform-address restore path rebuilds PlatformAddressSyncStartState from persisted balance rows only (packages/rs-platform-wallet-ffi/src/persistence.rs:2728-2796), while PlatformPaymentAddressProvider::prepare_for_sync() repopulates pending addresses from that restored balance state plus whatever is currently in the live pool (packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs:368-418). A reserved-but-unfunded address is therefore forgotten after process exit and becomes eligible for next_unused_with_info() again. The in-memory race is fixed only until the app restarts; a crash, OS eviction, or relaunch before the next funded sync reopens the same duplicate-address/privacy window this fix is meant to close.

2. suggestion: There is still no focused regression test for repeated address hand-out before sync or after restart

packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs:229-284

The repository still has no test that calls next_unused_receive_address() twice before a sync pass and asserts that two distinct addresses are returned. rg finds only the implementation site, and the only wallet integration test in packages/rs-platform-wallet/tests/spv_sync.rs is an ignored SPV smoke test that does not exercise this path. That leaves the exact Found-026 failure mode unguarded, including the restart case where the reservation should survive persistence if the blocker above is fixed.

3. nitpick: `info` still shadows the outer wallet-info binding

packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs:274-279

This function binds info from get_wallet_mut_and_info_mut() and then immediately reuses info for the result of next_unused_with_info(). Both bindings are live in the same short block and refer to unrelated concepts, which makes the ownership and mutation flow harder to audit in a function already mutating both wallet-manager and address-pool state.

        let address_info = managed_account
            .addresses
            .next_unused_with_info(&key_source, true)
            .map_err(|e| PlatformWalletError::AddressSync(e.to_string()))?;
        let address = address_info.address.clone();
        managed_account.addresses.mark_index_used(address_info.index);
4. nitpick: The returned address is still cloned even though the owned value can be moved

packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs:278-281

next_unused_with_info() returns an owned AddressInfo, and after reserving the index the function does not need that struct again. Cloning info.address adds an unnecessary copy on a hot path whose only job is to reserve and return the next address.

        let address_info = managed_account
            .addresses
            .next_unused_with_info(&key_source, true)
            .map_err(|e| PlatformWalletError::AddressSync(e.to_string()))?;
        managed_account.addresses.mark_index_used(address_info.index);

        PlatformAddress::try_from(address_info.address).map_err(|e| {
            PlatformWalletError::AddressSync(format!("Failed to convert to PlatformAddress: {e}"))
        })
Prior finding reconciliation
{
  "resolved_findings": [
    {
      "finding_hash": "prior-key-wallet-pin-mismatch",
      "original_title": "PR description cites a key-wallet pin that does not match Cargo.toml",
      "resolution": "Outdated on the current head. `Cargo.toml` now pins `key-wallet` to `f569e7b7b99dfe589c41f9ba7d36fbbe6805acdc` at lines 55-57, so the prior line-referenced mismatch against the older workspace revision no longer exists in source."
    }
  ],
  "dropped_findings": [
    {
      "original_title": "Receive-address reservation is still volatile, so a restart reintroduces duplicate-address reuse",
      "source": [
        "codex"
      ],
      "reason": "Merged into the single blocking restart-persistence finding."
    },
    {
      "original_title": "Receive-address reservation is still in-memory only, so a restart reopens the duplicate-address window",
      "source": [
        "codex"
      ],
      "reason": "Merged into the single blocking restart-persistence finding."
    },
    {
      "original_title": "The Found-026 fix still has no regression test for repeated hand-out across in-flight or restart boundaries",
      "source": [
        "codex"
      ],
      "reason": "Merged into the single test-coverage finding."
    },
    {
      "original_title": "Inner `info` binding still shadows the outer wallet-info binding",
      "source": [
        "codex"
      ],
      "reason": "Merged into the single naming finding."
    },
    {
      "original_title": "The handed-out address is still cloned even though it can be moved after reservation",
      "source": [
        "codex"
      ],
      "reason": "Merged into the single performance finding."
    },
    {
      "original_title": "The PR description's claimed key-wallet pin still does not match the actual dependency revision",
      "source": [
        "codex"
      ],
      "reason": "Outdated on the current head. The workspace pin changed to `f569e7b7b99dfe589c41f9ba7d36fbbe6805acdc`, so the prior code-side mismatch no longer exists at the referenced lines, and the repository contents do not include the PR description needed to prove a remaining docs mismatch."
    },
    {
      "original_title": "The review narrative's claimed key-wallet baseline does not match the code now under review",
      "source": [
        "codex"
      ],
      "reason": "Outdated on the current head for the same reason: the old pin mismatch is gone in source, and the claimed PR narrative cannot be verified from the worktree."
    }
  ]
}

lklimek and others added 2 commits May 25, 2026 13:36
Address review feedback on PR #3658:
- Rename inner `info` to `address_info` in next_unused_receive_address
  to disambiguate from the outer wallet-info binding.
- Elide `info.address.clone()` by reordering mark_index_used to use
  the Copy `index` before moving `address` out.
- Document the in-memory-only reservation design via an INTENTIONAL
  comment block (accept-risk decision on persistence-gap finding).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address PR #3658 reviewer (thepastaclaw) request for a regression
test on the v3.1-dev backport. Ports the
`found_026_back_to_back_handout_returns_distinct_addresses` unit
test from the e2e campaign tree (PR #3549) along with the
`wallet_with_platform_account` helper.

Asserts two consecutive next_unused_receive_address calls (no
intervening sync) return distinct addresses — the exact Found-026
race the fix in 8d0f0fb closes.

The companion test (`found_026_repeated_handouts_advance_gap_limit_exactly_k`)
is intentionally NOT ported — it reaches into platform_payment_managed_account
pool internals, which is on the deprecation path per QuantumExplorer's
review on #3648.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Claudius-Maginificent
Copy link
Copy Markdown
Collaborator

@thepastaclaw Thanks for the review — all five findings addressed in 8d0f0fb + 480375c (now on the branch). Quick rundown:

1. Persistence gap (mark_index_used is in-memory only) — accepted as design, documented in source.
Severity weighed against two facts: (a) PlatformAddressWallet::next_unused_receive_address has zero non-test callers across the monorepo today (no FFI, no SDK, no Manager wrapper — verified by full-tree grep); (b) the chain-advance argument narrows the future window — a crash before the next periodic sync persists the pool changeset AND the first recipient hasn't paid by restart manifests as address-reuse (privacy/accounting), not fund loss. There's also a real design upside: in-memory reservation means hand-outs that are never paid to get reclaimed on next session, avoiding unbounded index-gap growth from abandoned reservations. The trade-off is documented inline via an INTENTIONAL(CMT-001) block at wallet.rs:272-285. Persistence on hand-out is tracked as a follow-up — required before this function is wired to FFI.

2. Variable shadowing. Renamed inner info to address_info throughout the block.

3. Unnecessary clone. Reordered to call mark_index_used(address_info.index) (Copy) first, then move address_info.address without a clone.

4. Missing regression test. Ported found_026_back_to_back_handout_returns_distinct_addresses from the #3549 campaign tree as a #[cfg(test)] mod found_026_tests block — cargo test -p platform-wallet --lib -- found_026 now passes 1 test. The companion test that probes pool internals (highest_used/used_indices) was intentionally NOT ported, because it pokes at platform_payment_managed_account internals which QuantumExplorer flagged for removal on #3648; equivalent coverage will rewrite once the pool migrates to the modern provider.

5. PR body audit-trail drift. Updated the PR description to cite the current key-wallet pin f569e7b7b99dfe589c41f9ba7d36fbbe6805acdc from Cargo.toml, and confirmed byte-identical AddressPool API surface to the originally-proven pin.

cargo check / cargo fmt / cargo clippy --tests -- -D warnings / lib tests (122 passed) all clean. Leaving the thread for you to mark resolved.

🤖 Co-authored by Claudius the Magnificent AI Agent

Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

The latest delta correctly fixes the immediate duplicate-handout regression in next_unused_receive_address() by reserving the selected pool entry before releasing the wallet-manager write lock, and the new focused unit test covers two back-to-back hand-outs with no intervening sync. Revalidation of the prior review items on head 480375c8be10256d4528b84d42706faabda97c2f: the shadowing nit is fixed, the unnecessary clone is fixed, and the missing pre-sync regression test is fixed. The only surviving old concern is the restart-time reservation gap, but current head explicitly documents that behavior as an intentional in-memory-only limitation and next_unused_receive_address() still has no production call sites in this branch, so there is no remaining actionable finding for this PR; I could not complete the targeted cargo test because the build script attempted to download Tenderdash over the network in the sandbox.

Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Re-checked the latest head (480375c8be10256d4528b84d42706faabda97c2f). The follow-up commits address the review items for this backport:

  • accepted the persistence/restart gap as an explicit design trade-off and documented the remaining window + follow-up-before-FFI requirement in both source and PR body
  • removed the info shadowing and unnecessary address clone
  • added the focused Found-026 back-to-back hand-out regression
  • updated the key-wallet audit trail to the current f569e7b7... pin

I also verified there are still no non-test call sites for next_unused_receive_address, inspected the delta from eecd81e2 to 480375c8, and ran cargo test -p platform-wallet --lib -- found_026 locally (passed). Good from my side now.

Copy link
Copy Markdown
Member

@QuantumExplorer QuantumExplorer left a comment

Choose a reason for hiding this comment

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

No this is wrong. We should not mark an address as used unless it is indeed used. This could have other side effects.

@thepastaclaw
Copy link
Copy Markdown
Collaborator

@QuantumExplorer agreed — mark_index_used is the wrong primitive for hand-out reservation because it mutates real used / highest_used state.

I prepared a narrow fix at thepastaclaw/platform@6584046d7 on branch tracker-1369-found-026-reservation:

  • replaces the hand-out mark_index_used(...) call with a wallet-local in-memory reservation set keyed by PlatformPaymentAccountKey
  • skips reserved-but-still-unused indices for back-to-back receive-address hand-outs
  • keeps actual AddressPool used semantics unchanged until sync proves an address is funded
  • strengthens the Found-026 test to assert both distinct hand-outs and highest_used == None / index 0 still used == false

Validation: cargo fmt --all and cargo test -p platform-wallet --lib -- found_026 pass locally.

I could not push directly to dashpay/platform:fix/found-026-v3.1-dev (403 Permission to dashpay/platform.git denied to thepastaclaw), so the PR author/maintainer will need to cherry-pick or apply that branch.

@Claudius-Maginificent
Copy link
Copy Markdown
Collaborator

@QuantumExplorer — agreed. The premature mark_index_used() on hand-out is the wrong abstraction; the side effects you flagged (gap-limit advancement on every speculative hand-out, in-session pointer consumption for addresses that may never receive a payment) are real and not what we want.

Plan: refactor along the same pattern we shipped in #3585 for UTXO same-input race protection (OutpointReservations + RAII guard). The hand-out path will:

  1. Use an in-memory AddressReservations set (parallel to OutpointReservations) keyed by (account_index, address_index).
  2. next_unused_receive_address finds the lowest index that is BOTH used==false AND not in reservations, reserves it, and returns an AddressReservationGuard.
  3. Does NOT call mark_index_used() — pool state is untouched.
  4. Chain sync drops the reservation when a positive on-chain balance flips used=true (the canonical release).
  5. Caller-discarded hand-outs release on guard drop.

Result: no premature gap-limit / highest_used advancement, speculative hand-outs reclaimable mid-session, and Found-026 race protection preserved.

Sequencing: this stacks naturally on #3585. The mechanism there will be generalized to Reservations<K> (with OutpointReservations and AddressReservations as type aliases) inside this PR (#3658) once #3585 merges. The generic earns its keep when the second consumer arrives.

Dropping "ready for final review" while this lands. Will ping when the refactored version is up for re-review.

🤖 Co-authored by Claudius the Magnificent AI Agent

@lklimek lklimek removed the ready for final review Ready for the final review. If AI was involved in producing this PR, it has already had a reviewer. label May 25, 2026
@lklimek lklimek changed the base branch from v3.1-dev to fix/dpns-case-and-utxo-race-v3.1-dev May 26, 2026 07:03
@lklimek
Copy link
Copy Markdown
Contributor Author

lklimek commented May 26, 2026

Status update — retarget + Reservations refactor

Per @QuantumExplorer's concern that mark_index_used() in next_unused_receive_address advances highest_used prematurely, we're refactoring rather than landing the eager mark.

Approach:

Mechanics:

ready for final review label removed while the refactor lands.

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 2 commits May 26, 2026 09:20
… and address races

Generalizes the OutpointReservations RAII-guard mechanism into
Reservations<K: Eq + Hash + Clone + Debug> + ReservationGuard<K>,
exposing AddressReservations (K = (u32, u32)) as a type alias for the
platform-address hand-out path. OutpointReservations is preserved as a
domain wrapper composing Reservations<OutPoint> + Reservations<Address>
so the change-address peek/commit logic stays bound to the broadcast
caller. release_after_commit and leak_until_sync semantics from #3585
flow through to both aliases unchanged.

Tests cover the address alias (distinct keys, leak_until_sync hold,
snapshot reflection) alongside the existing outpoint suite.

Also fixes pre-existing clippy::manual_dangling_ptr findings in
rs-platform-wallet-ffi/src/core_wallet/broadcast.rs tests, surfaced
once the workspace -D warnings gate ran on this branch.

Addresses @QuantumExplorer review feedback on #3658: marking address
index used at hand-out time advanced gap-limit / highest_used
prematurely. Reservation guard holds the slot until the chain-sync
positive-balance flip commits highest_used, mirroring leak_until_sync
semantics from #3585.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…essReservations

Replaces the eager `managed_account.addresses.mark_index_used(idx)` call
on the platform-payment hand-out path with an in-memory
AddressReservations slot. Hand-out picks the first
`!used && !reserved` index in `0..=highest_generated`, falling back to
minting at `highest_generated + 1` when the generated range is
exhausted. The upstream pool's `highest_used` is left untouched until a
positive-balance sync hit flips `used`, addressing
@QuantumExplorer's review feedback on #3658.

Reservation guards are leaked via `leak_until_sync` so concurrent /
back-to-back hand-outs see the slot as taken; the in-memory set is
cleared on process restart, matching the documented "addresses
requested but never paid to are freed for reuse on the next session"
contract.

Regression tests added:
- next_unused_receive_address_does_not_advance_highest_used
- handout_skips_reserved_but_not_used_indices (three back-to-back
  hand-outs yield three distinct indices)

The original Found-026 back-to-back distinct-address invariant still
holds — guarded by the unchanged `found_026_back_to_back_handout_*`
test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lklimek lklimek changed the title fix(platform-wallet): reserve platform receive address on hand-out (Found-026) [backport] fix(platform-wallet): reserve receive address via AddressReservations (Found-026) [backport] May 26, 2026
@lklimek
Copy link
Copy Markdown
Contributor Author

lklimek commented May 26, 2026

Refactor landede655aafdcb is the new HEAD.

Three commits on top of the previous tip:

Summary of changes vs. earlier state:

  1. Eager mark_index_used call removed from next_unused_receive_address (the @QuantumExplorer concern). Replaced with AddressReservations snapshot + leak_until_sync guard.
  2. OutpointReservations generalized to Reservations<K: Eq + Hash + Clone + Debug>. AddressReservations = Reservations<(u32, u32)> is a clean alias. OutpointReservations is kept as a domain wrapper (composes Reservations<OutPoint> + Reservations<Address>) because the change-address-tracking surface doesn't fit a plain K; documented in module-level doc.
  3. 6 new tests — 4 for the generic in reservations.rs, 2 for the consumer in wallet.rs (incl. next_unused_receive_address_does_not_advance_highest_used regression guard).
  4. Drive-by: 3 pre-existing clippy::manual_dangling_ptr lints (Rust 1.92) fixed in platform-wallet-ffi to pass workspace -D warnings.

PR title + body rewritten to reflect the new scope.

Base is still fix/dpns-case-and-utxo-race-v3.1-dev (#3585's branch); will retarget to v3.1-dev once #3585 merges.

Ready for re-review.

🤖 Co-authored by Claudius the Magnificent AI Agent

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In review / testing

Development

Successfully merging this pull request may close these issues.

5 participants