fix(platform-wallet): reconcile platform-address balances after top-up-from-addresses#3969
fix(platform-wallet): reconcile platform-address balances after top-up-from-addresses#3969shumkov wants to merge 3 commits into
Conversation
…p-from-addresses
Production report (2026-06-27): an identity top-up failed with "Insufficient
combined address balances: total available is less than required 1500000" even
though the wallet showed a Platform Balance of 1.19970434 DASH and the top-up
sheet showed "Available: 1.19970434 DASH". Downstream, DPNS registration also
failed ("Insufficient identity ... balance 35781660 required 42539440") because
the identity could never be topped up.
Root cause: IdentityWallet::top_up_from_addresses discarded the proof-attested
address_infos the SDK returns — the new on-chain balance + bumped nonce of every
platform address the top-up spent from. The local platform-address balances
therefore stayed frozen at their pre-top-up values: the wallet kept displaying
the stale "Platform Balance", and the next top-up's greedy input selection
(driven by those stale local balances) over-selected the now-drained addresses,
which Drive rejected.
The sibling fund_from_asset_lock path already does the right thing via
write_address_balances_changeset, with a comment describing this exact failure.
This wires the same reconciliation into the top-up path: write each spent
address's proof-attested post-spend balance back into its platform account (in
memory) and persist a PlatformAddressChangeSet, so the displayed balance and the
next selection both reflect on-chain reality. The shared entry-builder
build_transfer_persistence_entries is promoted to pub(crate) and re-exported as
build_platform_address_persistence_entries. Spent addresses are already funded,
so the None key source is safe — set_address_credit_balance only consults it on
a 0->funded transition.
Test would have caught this in CI: the regression test
top_up_records_post_spend_address_balance_not_stale is ✖ before the fix
(cannot find function build_platform_address_persistence_entries — there was no
reconciliation at all) and ✔ after.
Note: the unit test pins the reconciliation contract; that top_up_from_addresses
actually invokes it, and that Drive then accepts the follow-up top-up, are
network-gated and need a testnet e2e (top-up -> second top-up succeeds) to fully
close.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
✅ Review complete (commit c0d5ac7) |
📝 WalkthroughWalkthrough
ChangesPost-top-up address balance reconciliation
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ 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 |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs (1)
205-255: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy liftCover the actual
top_up_from_addressessuccess path.Line 232 tests the shared helper directly, so this regression would still pass if
top_up_from_addressesstopped reconciling or persistingaddress_infosat all. Given the reported bug was in that wiring, I'd add one method-level test with a fake persister/wallet manager to pin the end-to-end behavior.🤖 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/identity/network/top_up_from_addresses.rs` around lines 205 - 255, The current test only covers the shared persistence helper, so it would still pass even if `top_up_from_addresses` stopped reconciling or saving `address_infos` correctly. Add a method-level test for `top_up_from_addresses` itself using a fake wallet manager/persister to exercise the success path end-to-end, and assert that the post-top-up reconciliation persists the proof-backed balances and nonce for spent addresses rather than relying only on `build_platform_address_persistence_entries`.
🤖 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.
Nitpick comments:
In
`@packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs`:
- Around line 205-255: The current test only covers the shared persistence
helper, so it would still pass even if `top_up_from_addresses` stopped
reconciling or saving `address_infos` correctly. Add a method-level test for
`top_up_from_addresses` itself using a fake wallet manager/persister to exercise
the success path end-to-end, and assert that the post-top-up reconciliation
persists the proof-backed balances and nonce for spent addresses rather than
relying only on `build_platform_address_persistence_entries`.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 1291921a-6ce0-4e2f-a4fc-d96fa68d77fc
📒 Files selected for processing (3)
packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rspackages/rs-platform-wallet/src/wallet/platform_addresses/mod.rspackages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## v3.1-dev #3969 +/- ##
==========================================
Coverage 87.17% 87.18%
==========================================
Files 2629 2632 +3
Lines 327221 327563 +342
==========================================
+ Hits 285265 285592 +327
- Misses 41956 41971 +15
🚀 New features to boost your workflow:
|
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
The PR correctly starts using the SDK-returned proof-backed address info after identity top-up, but the new reconciliation still misses a restored-wallet state that this fix needs to cover. The main correctness issue is that ownership and derivation indexes are resolved only from the live derived address pool, while persisted platform-address balances can be restored separately.
🔴 1 blocking | 🟡 2 suggestion(s)
🤖 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/identity/network/top_up_from_addresses.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs:124-137: Reconciliation ignores restored balances outside the live address pool
The ownership lookup only scans `account.addresses.addresses`, so it only reconciles spent addresses that are currently present in the live derived-address pool. Restored platform-address state is broader than that: `initialize_from_persisted` rehydrates `account.address_balances` from persisted `found()` entries, and `PlatformPaymentAddressProvider::from_persisted` keeps the persisted `(address_index, address)` map even for addresses that are not currently in `managed.addresses.addresses`. If a top-up spends one of those restored cached rows, the SDK returns the proof-backed post-spend balance, but this loop leaves it out of `owned`; the in-memory balance and persisted row remain stale, which preserves the phantom balance this PR is intended to clear. Resolve spent wallet inputs from the persisted/indexed platform-address state as well as the live pool, or otherwise carry the stored derivation index for `address_balances` entries into this reconciliation.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs:163-164: Persist address reconciliation after releasing the wallet write lock
This persistence call runs while the `wallet_manager` write guard acquired earlier in the block is still live. The FFI persistence backend executes its callback sequence inline, and the transfer path already avoids this pattern by building the changeset under the lock, dropping the guard, and then calling `store`. Keeping the write lock during persistence can unnecessarily block other wallet operations and creates avoidable re-entry or lock-order risk for persistence implementations. Build the identity/address changesets while the guard is held, drop the guard, then call the persister.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs:232-237: Regression test does not cover the top-up reconciliation path
The regression was in `IdentityWallet::top_up_from_addresses` dropping or failing to persist the SDK-returned `address_infos`, but this test calls `build_platform_address_persistence_entries` directly. That proves the helper maps a provided owned address correctly, but it would still pass if the wallet method stopped invoking the helper, stopped storing the changeset, or failed the restored-address ownership lookup above. Add coverage around the wallet reconciliation path itself, or extract the reconciliation block into a unit-testable function that takes account state and returns the applied changeset.
… (cover restored) Addresses the code review on PR #3969: - BLOCKING: the reconciliation scanned only the live derived address pool (account.addresses.addresses), so a top-up that spent an address restored from disk — present in the persisted index bijection but not in the live pool, because initialize_from_persisted hydrates address_balances with a None key source and so never repopulates the pool — left its stale balance behind, preserving the phantom Platform Balance this PR targets. Resolution now goes through the address provider's per-account index<->address bijection (PlatformAddressWallet::apply_top_up_reconciliation), which covers restored addresses. The identity wallet returns the proof's AddressInfos; the FFI reconciles via the platform-address wallet (the side that owns the provider). - Persist after releasing locks: apply_top_up_reconciliation resolves entries under the provider read lock, applies the in-memory balances under the wallet-manager write lock, then persists with no locks held. - Test the reconciliation, not just a helper: extracted the resolution into the pure, unit-testable build_top_up_balance_entries. The new regression build_top_up_entries_resolves_restored_address_outside_live_pool pins that a spent address present only in the persisted bijection is resolved to its derivation index and recorded with the proof's post-spend balance — would fail with the prior live-pool-only lookup, passes now. Reverts the now-unused shared build_transfer_persistence_entries re-export. The FFI ABI is unchanged (Swift unaffected). platform-wallet: 203 tests pass; clippy + fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Prior reconciliation against 1bd68c3: #1 is FIXED by resolving spent addresses through the provider's persisted index/address bijection, #2 is FIXED because address reconciliation persistence now runs after provider and wallet locks are dropped, and #3 is STILL VALID and carried forward. I found no new latest-delta findings beyond the carried-forward test coverage gap. CodeRabbit provided no actionable inline findings, so there are no reactions.
🟡 1 suggestion(s)
🤖 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-ffi/src/identity_top_up.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/identity_top_up.rs:124-134: Top-up reconciliation integration remains untested
The current regression coverage exercises `build_top_up_balance_entries`, but it still does not pin the production contract that fixes the reported stale-balance bug: `IdentityWallet::top_up_from_addresses` returns proof-backed `AddressInfos`, the caller routes those infos into `PlatformAddressWallet::apply_top_up_reconciliation`, and that path applies and persists the resulting `PlatformAddressChangeSet`. A future change could remove or skip lines 132-134, or regress `apply_top_up_reconciliation` so it no longer persists entries, while the helper-level test continues to pass. Add focused coverage for the FFI/top-up boundary or for `apply_top_up_reconciliation` with a recording persister so the actual reconciliation contract is tested, not only the pure entry builder.
Addresses the carried-forward review finding (thepastaclaw #3): the prior regression coverage exercised only the pure entry builder (build_top_up_balance_entries), so a future change that stopped apply_top_up_reconciliation from persisting — the exact shape of the original stale-balance bug — would not be caught. Adds apply_top_up_reconciliation_persists_decremented_balance: a recording-persister integration test that injects a provider whose persisted bijection knows the spent address, runs the reconciliation, and asserts a PlatformAddressChangeSet carrying the proof's post-spend balance (decremented, bumped nonce) was actually persisted. Verified red→green: with the persist call in apply_top_up_reconciliation removed (simulating the original discard bug) the test fails ("a persisted platform-address entry for the spent address"); restored, it passes. 204 platform-wallet lib tests pass; fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs (1)
1323-1335: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winAssert all persisted entries for the spent address match the proof.
Using
.find(...)can miss a later conflicting/stale write for the same address. Collect matching entries and assert each one carries the proof-attested balance/nonce.Strengthen the regression assertion
- let entry = stored + let entries: Vec<_> = stored .iter() .filter_map(|cs| cs.platform_addresses.as_ref()) .flat_map(|pa| pa.addresses.iter()) - .find(|e| e.address == addr) - .expect("a persisted platform-address entry for the spent address"); - assert_eq!(entry.account_index, ACCOUNT); - assert_eq!( - entry.funds.balance, 5, - "persists the proof's post-spend balance, not the stale pre-spend value" - ); - assert_eq!(entry.funds.nonce, 4, "persists the bumped nonce"); + .filter(|e| e.address == addr) + .collect(); + assert!( + !entries.is_empty(), + "a persisted platform-address entry for the spent address" + ); + for entry in entries { + assert_eq!(entry.account_index, ACCOUNT); + assert_eq!( + entry.funds.balance, 5, + "persists the proof's post-spend balance, not the stale pre-spend value" + ); + assert_eq!(entry.funds.nonce, 4, "persists the bumped nonce"); + }🤖 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 1323 - 1335, The current assertion in the platform address persistence test only checks the first matching entry found via the `stored`/`entry` lookup, so it can miss later stale or conflicting writes for the same spent address. Update this test to collect all persisted `platform_addresses.addresses` entries matching `addr` from `recorder.stored` in `provider.rs`, then assert every matching entry has the expected `account_index`, `funds.balance`, and `funds.nonce` from the proof using the existing `ACCOUNT` and `entry.funds` expectations.
🤖 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.
Nitpick comments:
In `@packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs`:
- Around line 1323-1335: The current assertion in the platform address
persistence test only checks the first matching entry found via the
`stored`/`entry` lookup, so it can miss later stale or conflicting writes for
the same spent address. Update this test to collect all persisted
`platform_addresses.addresses` entries matching `addr` from `recorder.stored` in
`provider.rs`, then assert every matching entry has the expected
`account_index`, `funds.balance`, and `funds.nonce` from the proof using the
existing `ACCOUNT` and `entry.funds` expectations.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 5f610919-5993-4799-808c-2bbb6ae52e7f
📒 Files selected for processing (1)
packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Carried-forward prior findings: none. The prior test-coverage finding is fixed by the new apply_top_up_reconciliation_persists_decremented_balance test, which covers the recording-persister contract that the previous review explicitly accepted as an equivalent fix path. New latest-delta findings: none; the remaining FFI/live top-up coverage gap is useful follow-up work but is not an in-scope PR finding after the reconciliation/persistence contract is pinned.
Issue being fixed or feature implemented
A user reported (2026-06-27, screenshots) that an identity top-up failed with:
…even though the wallet displayed Platform Balance 1.19970434 DASH and the top-up sheet showed "Available: 1.19970434 DASH." As a downstream effect, DPNS registration also failed (
Insufficient identity … balance 35781660 required 42539440) because the identity could never be topped up — leaving the user stuck in a loop.With
creditsPerDash = 100_000_000_000, the displayed ~120 billion credits vs. the protocol's real< 1,500,000credits is an ~80,000× gap between the wallet's local platform-address balance and on-chain reality — a phantom balance.What was done?
Root cause:
IdentityWallet::top_up_from_addressesdiscarded the proof-attestedAddressInfosthe SDK returns — the new on-chain balance + bumped nonce of every platform address the top-up spent from. So the local platform-address balances stayed frozen at their pre-top-up values: the wallet kept showing the stale "Platform Balance", and the next top-up's input selection (driven by those stale balances) over-selected the now-drained addresses, which Drive rejected.The fix:
IdentityWallet::top_up_from_addressesnow returns the proof-attested(AddressInfos, Credits)instead of discarding the infos.PlatformAddressWallet::apply_top_up_reconciliation, which resolves each spent address through the address provider's persistedindex ↔ addressbijection (per_wallet_state). This covers addresses restored from disk that are no longer in the live derived pool — the exact cold/restored-wallet case the reported bug is about. It then applies the proof-attested post-spend balance in memory and persists aPlatformAddressChangeSet, so the displayed balance and the next input selection both reflect on-chain reality.build_top_up_balance_entries.This mirrors the sibling
fund_from_asset_lockpath, which already reconciles platform-address balances after spending (its comment documents this exact failure mode). Rust-only change inrs-platform-wallet+ the top-up FFI.How Has This Been Tested?
build_top_up_entries_resolves_restored_address_outside_live_pool— unit test pinning that a spent address present only in the persisted bijection (restored, not in the live pool) is resolved to the correct derivation index with the proof's post-spend balance/nonce. This is the load-bearing logic and the case the original bug hinges on.apply_top_up_reconciliation_persists_decremented_balance— recording-persister integration test pinning that the reconciliation actually persists aPlatformAddressChangeSetcarrying the decremented entry (not only that the pure builder maps it). Verified red→green: with the persist call removed (the original discard bug) it fails; restored, it passes.rs-platform-wallettest suite passes (204);cargo fmtclean; CITestsgreen.Not yet covered (network-gated): the FFI boundary actually routing the returned
AddressInfosintoapply_top_up_reconciliation, and Drive accepting a follow-up top-up, require a live testnet e2e (top-up → second top-up succeeds). Best confirmed by the affected user re-testing on their already-funded device.Breaking Changes
None.
IdentityWallet::top_up_from_addressesnow returns(AddressInfos, Credits)instead ofCredits; all in-tree callers are updated.Checklist:
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes