Skip to content

fix(platform-wallet): index-conflicting removal no longer orphans a restored address balance#4013

Merged
QuantumExplorer merged 1 commit into
v4.1-devfrom
claude/fix-removal-index-persist-roundtrip
Jul 5, 2026
Merged

fix(platform-wallet): index-conflicting removal no longer orphans a restored address balance#4013
QuantumExplorer merged 1 commit into
v4.1-devfrom
claude/fix-removal-index-persist-roundtrip

Conversation

@QuantumExplorer

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

A reconcile removal (a fully consumed input) can resolve to a pool
address_index that collides with a different, funded address's true
derivation index. commit_reconciliation correctly leaves the in-memory
(index → address) bijection untouched in that case, but it still emits
the zero-funds removal (so a stale balance can't resurrect after a
restart). That emitted entry carried the conflicting address_index all
the way to durable storage:

  • The FFI hands the entry to Swift's
    PlatformWalletPersistenceHandler.persistAddressBalances, which
    unconditionally overwrote the row's addressIndex with the
    conflicting value — even though that field is authoritative derivation
    metadata owned by the address-emit path, and this callback is
    documented to refresh only the volatile balance/nonce/isUsed fields.
  • Result: two durable PersistentPlatformAddress rows end up claiming
    the same (accountIndex, addressIndex).
  • On the next app start, the restore rebuild
    (PerAccountPlatformAddressState::insert_persisted_entry) does a
    BiBTreeMap insert per row. A colliding insert evicts the prior
    pairing, so — depending on SwiftData fetch order — the funded
    address's (index → address) pairing can be evicted, orphaning its
    balance from current_balances (the address silently loses its
    restored credits).

This was surfaced by the #3990 sync review and is closed here at the
persister seam.

What was done?

The persister contract has two sides; both are hardened.

Write side — root cause (swift-sdk).
persistAddressBalances no longer writes accountIndex / addressIndex.
A row's derivation index is fixed the moment its address is derived and
is owned by the address-emit path (persistPlatformPaymentAddresses);
the balance-update callback now touches balance / nonce / isUsed only —
exactly what its own doc comment already promised. A conflicting removal
therefore zeroes the removed address's balance without ever rebinding its
index, so the durable store stays a bijection.

Read side — defense in depth (platform-wallet).
PerAccountPlatformAddressState::insert_persisted_entry now guards the
bijection. A zero-balance/zero-nonce row is indistinguishable from a
legitimate freshly-derived, never-funded address, so it still seeds
found exactly as before; but it extends the bijection via
insert_no_overwrite, so it can never evict an incumbent (index → address) pairing, while a funded row keeps overwrite semantics and wins
its slot. For the common case of unique indices this is byte-identical to
the previous unconditional insert — it only changes behaviour on an index
collision, keeping the funded pairing regardless of restore order. This
protects any already-persisted store and — because both the SwiftData/FFI
loader and the SQLite loader (platform_addrs::build_per_account) route
through this one method — covers both backends.

No FFI struct / ABI change; the entry still carries the indices, the
persister just stops trusting the removal's copy of them.

How Has This Been Tested?

  • Rust (cargo test -p platform-wallet, -p platform-wallet-storage):
    • insert_persisted_entry_removal_remnant_never_orphans_funded_pairing
      round-trips the conflict through restore in both fetch orders and
      asserts that the intersection of found and the bijection (i.e. what
      current_balances yields) is exactly the funded balance — the
      remnant's inert zero row is never paired.
    • insert_persisted_entry_unfunded_derived_address_restores_normally
      pins that a real never-funded ({0, 0}) address still restores into
      both found and the bijection, so the guard can't swallow it.
    • All existing commit_reconciliation / platform_addresses tests and
      the storage crate's SQLite load_state reconstruction tests stay
      green.
  • Swift (swift test, SwiftDashSDKTests): new
    AddressBalancePersistTests round-trips through persist + reload —
    seeds a funded row at index 5 and a row at index 2, applies a removal
    for the index-2 address carrying the conflicting index 5, and asserts
    the removed row keeps index 2 (zeroed) while the funded row keeps index
    5, so no two durable rows share an index.
  • cargo fmt --all; cargo check -p platform-wallet --all-targets.

Breaking Changes

None. No consensus-affecting changes; no FFI ABI change; pre-release, so
no SwiftData migration concern.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

@github-actions github-actions Bot added this to the v4.1.0 milestone Jul 5, 2026
@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@QuantumExplorer, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7dd252c8-de6a-4423-8af9-1720b69b899d

📥 Commits

Reviewing files that changed from the base of the PR and between b2b30d5 and 542490d.

📒 Files selected for processing (3)
  • packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
  • packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AddressBalancePersistTests.swift
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/fix-removal-index-persist-roundtrip

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.

@thepastaclaw

thepastaclaw commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit 542490d)

…estored address balance

A reconcile removal (fully consumed input) can resolve to a pool index
that collides with a different, funded address's true derivation index.
commit_reconciliation leaves the in-memory bijection untouched in that
case but still emits the zero-funds removal so the balance can't
resurrect. That emitted entry carried the conflicting address_index all
the way to durable storage:

- Swift persistAddressBalances unconditionally overwrote the row's
  addressIndex with the conflicting value, even though the derivation
  index is authoritative from the address-emit path and this callback is
  documented to refresh only balance/nonce/isUsed. Two durable rows then
  claim one (accountIndex, addressIndex).
- On restart insert_persisted_entry rebuilds the bijection with a
  BiBTreeMap insert per row; a colliding insert evicts the funded
  pairing, orphaning its balance from current_balances depending on
  fetch order.

Fix both sides of the persister seam:
- Swift: persistAddressBalances no longer writes accountIndex/addressIndex
  (volatile balance/nonce/isUsed only), so a removal can't rebind an
  index and the durable store stays a bijection.
- Rust: insert_persisted_entry guards the bijection only. A zero row is
  indistinguishable from a legitimate never-funded derived address, so it
  still seeds found as before; it inserts into the bijection via
  insert_no_overwrite so it can never evict a funded pairing, while a
  funded row keeps overwrite semantics and wins its slot. For unique
  indices this is identical to the previous unconditional insert. Covers
  both the SwiftData/FFI and SQLite restore paths, which share this method.

Tests: Rust round-trips the conflict through restore in both fetch orders
(insert_persisted_entry_removal_remnant_never_orphans_funded_pairing),
pins that a never-funded zero row still restores normally
(insert_persisted_entry_unfunded_derived_address_restores_normally), and
Swift round-trips through persist + reload (AddressBalancePersistTests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer QuantumExplorer force-pushed the claude/fix-removal-index-persist-roundtrip branch from a125539 to 542490d Compare July 5, 2026 13:20

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Prior reviewed SHA a125539 had zero findings, so no findings were carried forward. Both agents (Claude and Codex, all sub-reviewers) reported zero findings on both the latest delta and cumulative head. Verified against worktree at 542490d: the fix hardens the platform-wallet restore path via a bijection guard in insert_persisted_entry (uses insert_no_overwrite only for zero-balance/zero-nonce rows) and drops non-authoritative derivation index writes on the Swift persistAddressBalances callback. No in-scope defects identified.

Source: reviewers claude/opus and codex/gpt-5.5 for general + security-auditor + ffi-engineer; verifier claude/opus.

Prior reconciliation: the previous automated review at a125539e found no issues, so there are no carried-forward prior findings to keep or resolve.

Latest delta: reviewed with git diff a125539e..542490da plus cumulative current-head context.

@QuantumExplorer QuantumExplorer merged commit 050bab1 into v4.1-dev Jul 5, 2026
16 checks passed
@QuantumExplorer QuantumExplorer deleted the claude/fix-removal-index-persist-roundtrip branch July 5, 2026 13:56
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.

2 participants