Skip to content

fix(platform): zero cached platform-address balances absent from state#3855

Merged
QuantumExplorer merged 2 commits into
v3.1-devfrom
claude/strange-vaughan-678af1
Jun 12, 2026
Merged

fix(platform): zero cached platform-address balances absent from state#3855
QuantumExplorer merged 2 commits into
v3.1-devfrom
claude/strange-vaughan-678af1

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Jun 11, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

After the paloma devnet platform chain was reset, SwiftExampleApp kept displaying a stale platform-address balance (e.g. 294,627,247,940 credits / "2.94627248 DASH") from the old chain, and — worse — offered it as an identity-creation funding source that would fail at broadcast. Clearing Platform Sync Status and re-syncing against the new chain did not help: on the new chain getAddressInfo proves the address absent from state (entry without balance_and_nonce), but the balance refresh only overwrote cached rows on positive finds, so the stale balance was never zeroed.

The SDK sync engine already reports proven-absent addresses (AddressSyncResult.absent), but rs-platform-wallet ignored that signal in three places:

  1. PlatformAddressWallet::sync_balances built the persistence changeset only from result.found, so no zeroing entry ever reached the persister (→ FFI → SwiftData).
  2. PlatformPaymentAddressProvider::sync_finished merged scratch found entries but never removed proven-absent addresses from the committed found map, so current_balances() kept re-seeding the stale balance into every subsequent sync.
  3. on_address_absent never updated key-wallet's managed account, so the in-memory balance used by spend-path enumeration (identity-creation funding) kept the stale value.

What was done?

All changes are in rs-platform-wallet (per the project convention that the Swift layer only persists what the FFI hands it):

  • Extracted the sync diff into a pure helper compute_address_balance_diff(before, found, absent) in wallet/platform_addresses/sync.rs. It emits found-and-changed entries as before, plus a zeroed entry (balance 0, nonce 0) for every proven-absent address that previously carried non-default cached funds. An absent address has no balance and no nonce in state, so both are reset.
  • sync_finished in wallet/platform_addresses/provider.rs now removes every address proven absent this pass from the committed found map (extend-then-remove, so an absent proof wins in the impossible-by-contract overlap case).
  • on_address_absent now zeroes the managed account's credit balance via set_address_credit_balance(p2pkh, 0, None), mirroring on_address_found's wallet-manager write. The None key source is deliberate: gap-limit extension only fires on an unfunded→funded transition, which zeroing never is.

No Swift changes were needed: PlatformAddressChangeSet::is_empty, the FFI mapping in rs-platform-wallet-ffi, and Swift's PlatformWalletPersistenceHandler.persistAddressBalances all pass zero-balance entries through unconditionally, so the PersistentPlatformAddress row is updated to 0 end-to-end.

Note: the Platform Sync Status "Clear" button only resets in-memory display state and was left untouched — with this fix the stale balance is zeroed by the next sync pass itself, which is the layer that owns the decision.

How Has This Been Tested?

New unit tests in rs-platform-wallet:

  • sync::tests::absent_previously_funded_address_emits_zero_entry — the chain-reset case: cached balance + proven absent → single zeroed entry (balance 0, nonce 0)
  • sync::tests::absent_never_funded_address_emits_nothing
  • sync::tests::absent_zero_balance_nonzero_nonce_emits_zero_entry
  • sync::tests::absent_default_funds_emits_nothing
  • sync::tests::found_changed_emitted_unchanged_skipped
  • sync::tests::found_and_absent_combine
  • provider::tests::sync_finished_removes_absent_from_committed_found — absent address evicted from the committed found map and no longer yielded by current_balances()
  • provider::tests::sync_finished_keeps_found_drops_absent

Verification: cargo fmt --all; cargo check -p platform-wallet --all-features --tests; full cargo test -p platform-wallet suite passing (228 lib + 8 integration); iOS framework build (./build_ios.sh --target sim) and SwiftExampleApp simulator build both succeed.

Breaking Changes

None. Not consensus-breaking — client-side wallet sync/persistence behavior only.

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

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Fixed stale balance entries appearing in wallet after chain resets or when addresses are proven absent from the blockchain.
    • Enhanced address balance synchronization to properly clear and zero outdated cached balances.
    • Improved consistency between found and absent address tracking during wallet sync operations.

After a devnet chain reset, addresses proven absent from platform state
kept their cached balances: the sync diff only consumed result.found,
the provider never evicted absent addresses from its committed found
map, and the in-memory managed-account balance was never zeroed. The
stale balance survived re-sync and was offered as an identity-creation
funding source.

- sync_balances now emits a zeroed entry (balance 0, nonce 0) for every
  proven-absent address that previously carried cached funds, so the
  persister writes 0 through to SwiftData
- sync_finished removes absent addresses from the committed found map
  so current_balances() stops re-seeding the stale value
- on_address_absent zeroes the managed account's credit balance so
  spend-path enumeration no longer sees it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

More reviews will be available in 47 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more credits in the billing tab to continue.

⌛ How to resolve this issue?

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.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6f6adf42-869b-42f5-8196-9620bba6506e

📥 Commits

Reviewing files that changed from the base of the PR and between 8e47e2d and fc96e59.

📒 Files selected for processing (2)
  • packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs
  • packages/rs-platform-wallet/src/wallet/platform_addresses/sync.rs
📝 Walkthrough

Walkthrough

This PR introduces explicit handling for proven-absent platform addresses. When an address is detected as absent, the system now persists a zeroed balance and removes that address from committed funded snapshots to prevent stale state from reappearing in current balance queries after chain resets.

Changes

Absent Address Zeroing and Cleanup

Layer / File(s) Summary
Absent Address Diff Computation and Wiring
packages/rs-platform-wallet/src/wallet/platform_addresses/sync.rs
New pure helper compute_address_balance_diff() computes persistence entries for found addresses and emits explicit zeroing entries (balance and nonce = 0) for addresses proven absent when the pre-sync snapshot contained non-default funds. sync_balances is updated to call this helper for both found and absent address sets. Unit tests cover absent-address zeroing when previously funded, skipping unchanged entries, nonce reset behavior, and combined scenarios.
Provider Absent Address Manager Update
packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs
on_address_absent acquires a write lock on WalletManager, locates the wallet and platform-payment managed account, and calls set_address_credit_balance(p2pkh, 0, None) to zero the in-memory credit balance. Tokio integration tests verify that absent-proven addresses are recorded as absent and no longer appear in current_balances() output.
Committed State Cleanup on Sync Finish
packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs
sync_finished iterates over the per-pass absent set and removes those addresses from the committed account_state.found map before finalizing the commit, ensuring stale funded entries do not persist after chain-reset scenarios.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Suggested reviewers

  • thepastaclaw
  • lklimek

Poem

🐰 When chains reset and balances fade,
We zero the ghosts in the ledger made,
Absent proven, stale entries fall,
Pure functions compute it all! 🍃

🚥 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 PR title 'fix(platform): zero cached platform-address balances absent from state' directly and specifically describes the main bug fix: zeroing cached platform-address balances that are proven absent from chain state after a reset.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/strange-vaughan-678af1

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.

@thepastaclaw

thepastaclaw commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit fc96e59)

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs (1)

707-744: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear per_wallet_in_sync before each new sync pass.

sync_finished now removes every staged absent address from committed found, but prepare_for_sync() never resets per_wallet_in_sync after a failed pass. If one sync aborts after staging an address absent, the next successful pass will remove it here even when it was not absent in that pass, and compute_address_balance_diff() will not persist that removal because it only sees the current result.absent. That leaves in-memory and persisted balance state out of sync.

Suggested fix
 pub(crate) async fn prepare_for_sync(&mut self) -> Result<(), PlatformWalletError> {
+    self.per_wallet_in_sync.clear();
     let wallet_ids: Vec<WalletId> = self.per_wallet.keys().copied().collect();
🤖 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 `@packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs` around
lines 707 - 744, sync_finished applies staged absent removals from
per_wallet_in_sync even if the prior pass aborted, so ensure per_wallet_in_sync
is cleared at the start of a new sync pass to avoid applying stale absent
entries; add an explicit clear/reset of per_wallet_in_sync in prepare_for_sync()
(and any sync-abort/cleanup path) so that prepare_for_sync(), sync_finished(),
and compute_address_balance_diff() operate on the same per-pass state (use
per_wallet_in_sync.clear() or replace with a fresh default map), referencing the
per_wallet_in_sync field, the prepare_for_sync() entry point, and
sync_finished()/compute_address_balance_diff() to locate the relevant logic.
🧹 Nitpick comments (1)
packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs (1)

838-850: ⚡ Quick win

Add one test that calls on_address_absent end-to-end.

stage_absent() only mutates per_wallet_in_sync, so the new WalletManager write-through on Lines 685-704 never runs in this suite. A regression in the in-memory zeroing path would still pass these tests.

Also applies to: 856-933

🤖 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 `@packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs` around
lines 838 - 850, Tests currently call stage_absent which only mutates
PlatformPaymentAddressProvider::per_wallet_in_sync and thus never exercises the
WalletManager write-through performed by on_address_absent; add a single
end-to-end test that calls PlatformPaymentAddressProvider::on_address_absent
(not stage_absent) so the code path that invokes WalletManager (the
write-through path that persists absent addresses for WALLET/ACCOUNT) runs and
can catch regressions in the in-memory zeroing/persistence logic; ensure the
test sets up a real or test WalletManager instance, uses the same WALLET and
ACCOUNT keys, and asserts both the per_wallet_in_sync state and the
WalletManager-persisted state after calling on_address_absent.
🤖 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.

Outside diff comments:
In `@packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs`:
- Around line 707-744: sync_finished applies staged absent removals from
per_wallet_in_sync even if the prior pass aborted, so ensure per_wallet_in_sync
is cleared at the start of a new sync pass to avoid applying stale absent
entries; add an explicit clear/reset of per_wallet_in_sync in prepare_for_sync()
(and any sync-abort/cleanup path) so that prepare_for_sync(), sync_finished(),
and compute_address_balance_diff() operate on the same per-pass state (use
per_wallet_in_sync.clear() or replace with a fresh default map), referencing the
per_wallet_in_sync field, the prepare_for_sync() entry point, and
sync_finished()/compute_address_balance_diff() to locate the relevant logic.

---

Nitpick comments:
In `@packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs`:
- Around line 838-850: Tests currently call stage_absent which only mutates
PlatformPaymentAddressProvider::per_wallet_in_sync and thus never exercises the
WalletManager write-through performed by on_address_absent; add a single
end-to-end test that calls PlatformPaymentAddressProvider::on_address_absent
(not stage_absent) so the code path that invokes WalletManager (the
write-through path that persists absent addresses for WALLET/ACCOUNT) runs and
can catch regressions in the in-memory zeroing/persistence logic; ensure the
test sets up a real or test WalletManager instance, uses the same WALLET and
ACCOUNT keys, and asserts both the per_wallet_in_sync state and the
WalletManager-persisted state after calling on_address_absent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f7c6db68-3d10-42e9-91af-7e701f0cea2c

📥 Commits

Reviewing files that changed from the base of the PR and between 93b657d and 8e47e2d.

📒 Files selected for processing (2)
  • packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs
  • packages/rs-platform-wallet/src/wallet/platform_addresses/sync.rs

@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

The fix addresses the real chain-reset stale-balance bug, but the new sync_finished behavior assumes result.found and result.absent are disjoint per sync. The SDK's full-scan-then-incremental-catch-up flow can violate that: an address proven absent at the trunk checkpoint can be funded again before the tip and surface via on_address_found during catch-up. With the new extend-then-remove order, the legitimate new balance is removed from committed found and the diff helper also appends a zeroing persistence entry that wins last. The same exposure exists across syncs because per_wallet_in_sync is never cleared on a failed prior pass.

🔴 2 blocking | 🟡 1 suggestion(s)

2 additional finding(s) omitted (not in diff).

🤖 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.

In `packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs:707-744: Found/absent overlap within a sync silently wipes a freshly funded address
  The new `sync_finished` extends committed `found` with scratch `found` and then unconditionally removes every scratch `absent` address from committed `found`. The doc comment justifies extend-then-remove on the premise that the SDK proves an address either present or absent per pass and the two sets are disjoint, but that does not hold for the full-scan-plus-incremental-catch-up flow. In `packages/rs-sdk/src/platform/address_sync/mod.rs`, `process_trunk_result` / `process_branch_result` insert into `result.absent` and call `on_address_absent` when an address is missing at the trunk/branch checkpoint, while `incremental_catch_up`'s compacted (line 646) and recent (line 704) phases later call `on_address_found` with the new balance whenever a credit op for that address shows up between the checkpoint and tip. Neither catch-up path removes the address from `result.absent` or from the provider's scratch `absent` set. The order trunk-absent then catchup-found is exactly what happens when an address is empty at the checkpoint and gets funded shortly afterwards (very plausible right after a chain reset, which is the regression this PR targets). With the new extend-then-remove, the freshly inserted `found` balance is deleted from committed `found`, so subsequent `current_balances()` and `from_persisted` no longer see it.

  The symmetric defect is in `compute_address_balance_diff` (sync.rs lines 61-83): the absent loop checks only the pre-sync snapshot, so when `(tag, p2pkh)` is in both `result.found` and `result.absent` and the snapshot had non-default funds, the helper first emits the fresh balance entry and then appends a zero entry. `PlatformAddressChangeSet::addresses` is merged in order downstream, so the zero wins and the persister stores 0 for an address that is funded at the sync tip.

  Fix: filter `account_scratch.absent` against `account_scratch.found` before the removal loop, and skip the zero-emission branch in `compute_address_balance_diff` for any `(tag, p2pkh)` that is also in `found`. The contract for the rest of the persistence path should be that an address can never appear in both buckets simultaneously.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs:386-436: Clear `per_wallet_in_sync` at the start of each sync pass
  `per_wallet_in_sync` is per-pass scratch but `prepare_for_sync` only refreshes `self.pending` and clears the committed `account_state.absent`; it never touches the scratch map. The SDK calls `provider.sync_finished()` only on the success path (`packages/rs-sdk/src/platform/address_sync/mod.rs` line 415), so if a prior `sync_address_balances` returns an error after `on_address_absent` has staged an address, that scratch absent entry survives into the next sync. With this PR the next successful `sync_finished` will now remove that address from committed `found` even if the new pass never re-proved it absent — and if the new pass legitimately found it via catch-up, the freshly committed balance is wiped. Before this PR the leftover scratch was harmless because `sync_finished` only overwrote committed `absent` wholesale; the new destructive removal raises the cost of any aborted sync.

  Fix: `self.per_wallet_in_sync.clear();` at the top of `prepare_for_sync` before refreshing pending.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs:685-704: New wallet-manager zeroing in `on_address_absent` has no unit test
  The new `wallet_manager.write()` + `set_address_credit_balance(p2pkh, 0, None)` block in `on_address_absent` is the in-memory fix for the spend-path symptom (stale balance offered to identity-creation funding). None of the new tests exercise it: `sync_finished_removes_absent_from_committed_found` and `sync_finished_keeps_found_drops_absent` use `stage_absent` to write directly into `per_wallet_in_sync` precisely because the fixture has no wallet registered in the manager. Add a test that registers a `WalletManager` with a managed platform account, seeds a non-zero `address_balance`, calls `on_address_absent`, and asserts the account's `credit_balance` and `address_balances` map have been zeroed. Otherwise this mirror of `on_address_found` is unverified by the test suite.

Review follow-ups for the absent-address zeroing:

- An address proven absent at the full-scan checkpoint can be re-found
  by incremental catch-up in the same pass (the SDK inserts into
  result.found without retracting the absent proof). The catch-up find
  reflects the chain tip, so it now wins: compute_address_balance_diff
  skips zero-emission for addresses also in found, and sync_finished
  drops them from the scratch absent set before the destructive removal
- prepare_for_sync now clears per_wallet_in_sync so scratch staged by an
  aborted pass (sync_finished only runs on success) can't remove a
  committed balance the next pass never re-proved absent
- build the changeset via struct literal (clippy
  field_reassign_with_default broke CI under -D warnings)
- new tests: overlap at both layers, aborted-pass scratch clearing, and
  an end-to-end on_address_absent test against a real WalletManager
  asserting the managed account balance is zeroed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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

All three prior blocking/suggestion findings are fixed at fc96e59: sync_finished now dedupes found-over-absent before applying removals; compute_address_balance_diff skips zero-emission for addresses also in found; prepare_for_sync clears per-pass scratch up front; and a new test exercises on_address_absent against a real wallet manager. Six agents (claude+codex across general/rust-quality/ffi) converge on no new in-scope defects in either the latest delta or the cumulative PR. Approving.

@codecov

codecov Bot commented Jun 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 70.73%. Comparing base (93b657d) to head (fc96e59).
⚠️ Report is 13 commits behind head on v3.1-dev.

Additional details and impacted files
@@              Coverage Diff              @@
##           v3.1-dev    #3855       +/-   ##
=============================================
- Coverage     87.15%   70.73%   -16.43%     
=============================================
  Files          2641       20     -2621     
  Lines        327793     2788   -325005     
=============================================
- Hits         285701     1972   -283729     
+ Misses        42092      816    -41276     
Components Coverage Δ
dpp ∅ <ø> (∅)
drive ∅ <ø> (∅)
drive-abci ∅ <ø> (∅)
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value ∅ <ø> (∅)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier ∅ <ø> (∅)
🚀 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

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

✅ 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: "03a42d74cda9b7583ac620580d6513a8547d07680541f30916e37bde0dbfe99a"
)

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.

@QuantumExplorer
QuantumExplorer merged commit 9964908 into v3.1-dev Jun 12, 2026
29 of 32 checks passed
@QuantumExplorer
QuantumExplorer deleted the claude/strange-vaughan-678af1 branch June 12, 2026 04:22
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