Skip to content

fix(platform-wallet): height-pin address balances so delta replay cannot double-count (ADDR-09)#4019

Merged
QuantumExplorer merged 7 commits into
v4.1-devfrom
claude/xenodochial-mcnulty-b5c470
Jul 6, 2026
Merged

fix(platform-wallet): height-pin address balances so delta replay cannot double-count (ADDR-09)#4019
QuantumExplorer merged 7 commits into
v4.1-devfrom
claude/xenodochial-mcnulty-b5c470

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Jul 6, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

ADDR-09 behavioral QA (2026-07-06) showed the top-up double-count is not fixed by the watermark approach (#4004/#4005): after "Top Up from Core", the topped-up platform address showed exactly 2× its proof-verified on-chain balance, durable across automatic BLAST syncs, manual Clear+resync, and app restart. Ground truth: on-chain (via getWithProof) held the single credit; the client double-counted.

Root cause: two sources of truth — proof-attested absolutes (ST results, trunk/branch scan elements) and the recent/compacted delta stream — were reconciled through global height cursors. Any path that threaded the wrong height re-applied a delta that was already baked into an absolute. #4004/#4005 forced a full rescan via watermark invalidation, but the rescan itself replays the recent window and re-added the same delta.

What was done?

Every absolute balance now carries a height pinAddressFunds::as_of_height, the Platform block height the balance is current as of:

  • Delta gating (rs-sdk address_sync): a delta at block B applies only when B > pin; applying advances the pin. Recent loop gates per entry; compacted loop gates per operation (block-aware ops carry (height, credits), so a pin inside a compacted range drops exactly the already-included ops).
  • Scan pinning: trunk/branch absolutes pinned at the scan checkpoint_height; absence-zeroing entries too.
  • Proof heights surfaced: BroadcastStateTransition gains *_with_metadata variants exposing the quorum-authenticated metadata.height (previously verified then discarded); all address/identity transition helpers return it alongside AddressInfos.
  • Reconcile authority (rs-platform-wallet): reconcile_address_infos(infos, as_of_height, ctx) pins ST-attested absolutes; freshness = pin ordering (nonce breaks same-block ties only). A later pin wins even when it revises the balance downward — protecting against stale-node scans and self-healing rows poisoned by the old bug. The now-redundant invalidate_sync_watermark / credited_outputs machinery is deleted.
  • Persistence round-trip: sqlite V002 migration adds platform_addresses.as_of_height; AddressBalanceEntryFFI.as_of_height; Swift stores it in PersistentPlatformAddress.lastSeenHeight (previously a dead column). Pin 0 = "unknown provenance" (legacy rows): deltas apply as before and any pinned absolute supersedes them.

How was it tested?

  • ~835 unit tests green across dash-sdk, platform-wallet, platform-wallet-storage, platform-wallet-ffi, rs-sdk-ffi, including new commit_reconciliation_later_pin_revises_balance_downward (the healing property) and commit_reconciliation_drops_stale_height.
  • On-device (testnet sim): fresh 0.05 DASH top-up reflected exactly once and stable through multiple automatic BLAST syncs, manual Clear + full rescan, and app restart; all four wallet addresses matched proof-verified on-chain balances exactly.
  • Live race win: the post-Clear rescan hit a node whose snapshot (height 379728) was older than the funding proof (379731) — pin ordering preserved the newer balance instead of letting the stale scan clobber it.

Breaking changes

None consensus-wise. SDK API: address/identity transition helpers now return the proof height (tuple extension); reconcile_address_infos takes as_of_height instead of credited_outputs.

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

  • New Features

    • Wallet and balance-sync state now includes an “as-of” block height pin to improve cross-session reconciliation and restoration.
    • Several wallet actions now surface the confirmed proof block height alongside their usual results.
  • Bug Fixes

    • Prevented balance double-counting by making reconciliation height-pinned and dropping stale balance/absence updates.
    • Improved persistence and reload behavior so restored balances retain the correct as-of height watermark, keeping devices in sync.

…not double-count

ADDR-09: after "Top Up from Core", the topped-up platform address showed
exactly 2x its on-chain balance, durable across automatic syncs, manual
Clear+resync, and app restart. The watermark-invalidation fixes (#4004 /
#4005) forced a full rescan but could not stop the rescan itself from
replaying the on-chain AddToCredits delta on top of an absolute that
already included it.

Root cause: two sources of truth for an address balance — proof-attested
ABSOLUTES (ST results, trunk/branch scan elements) and the
recent/compacted balance-change DELTA stream — were reconciled through
global height cursors, and any path that threaded the wrong height
re-applied deltas already baked into an absolute.

Fix: every absolute now carries its height pin. `AddressFunds` gains
`as_of_height` — the Platform block height the balance is current AS OF:

- a delta recorded at block B applies only when `B > pin` (otherwise it
  is already included); applying advances the pin. The recent loop gates
  per entry; the compacted loop gates PER OPERATION (block-aware ops
  carry their own heights, so a pin inside a compacted range drops
  exactly the ops the absolute already includes).
- trunk/branch scan absolutes are pinned at the scan checkpoint height;
  absence-zeroing entries too.
- `BroadcastStateTransition` gains `*_with_metadata` variants exposing
  the quorum-authenticated `metadata.height` (previously verified and
  discarded), and every address/identity transition helper returns the
  proof height alongside its `AddressInfos`.
- `reconcile_address_infos(infos, as_of_height, ctx)` pins ST-attested
  absolutes; freshness is decided by pin ordering (nonce only breaks
  same-block ties) — a later pin wins even when it revises the balance
  DOWNWARD, which both protects against stale-node scans and self-heals
  rows poisoned by the old bug. The now-redundant
  `invalidate_sync_watermark` / `credited_outputs` machinery is removed.
- the pin round-trips through persistence: sqlite V002 migration adds
  `platform_addresses.as_of_height`; the FFI `AddressBalanceEntryFFI`
  carries it; Swift stores it in `PersistentPlatformAddress
  .lastSeenHeight` (previously a dead column). Pin 0 = "unknown
  provenance" (legacy rows): deltas apply as before and any pinned
  absolute supersedes them.

Verified on testnet simulator: fresh 0.05 DASH top-up reflected exactly
once and stable through automatic syncs, Clear+full-rescan, and restart;
all four wallet addresses match proof-verified on-chain balances
exactly; the funding pin (379731) survived a rescan whose snapshot was
older (379728) — the stale-scan race the pin is designed to win.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added this to the v4.1.0 milestone Jul 6, 2026
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 44 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: 325a57a3-2c7f-473f-98fe-cf9335e1949e

📥 Commits

Reviewing files that changed from the base of the PR and between 78a16ce and 4dd2098.

📒 Files selected for processing (13)
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet-ffi/src/platform_address_types.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs
  • packages/rs-platform-wallet/src/changeset/changeset.rs
  • packages/rs-platform-wallet/src/wallet/apply.rs
  • packages/rs-platform-wallet/src/wallet/platform_wallet.rs
  • packages/rs-sdk/src/platform/address_sync/types.rs
  • packages/rs-sdk/src/platform/transition/top_up_address.rs
  • packages/rs-sdk/src/platform/transition/transfer_to_addresses.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
  • packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md
  • packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AddressBalancePersistTests.swift
📝 Walkthrough

Walkthrough

This PR threads a proof-committed block height through platform address transitions and persists it as as_of_height on balance records. Reconciliation, sync, FFI, sqlite storage, and Swift persistence now carry the pin value instead of relying on credited-output watermark invalidation.

Changes

Address balance height pin

Layer / File(s) Summary
AddressFunds contract
packages/rs-sdk/src/platform/address_sync/types.rs, .../changeset/serde_adapters.rs, .../changeset/changeset.rs
Adds as_of_height: u64 to AddressFunds with defaulted serde compatibility and updates a changeset test to populate it.
Address scan/catch-up pinning
packages/rs-sdk/src/platform/address_sync/mod.rs
Pins scan results to checkpoint height and gates replay/apply paths by comparing operation height against the stored pin.
Proof-height SDK transitions
packages/rs-sdk/src/platform/transition/broadcast.rs, put_identity.rs, top_up_address.rs, top_up_identity_from_addresses.rs, transfer_address_funds.rs, transfer_to_addresses.rs, address_credit_withdrawal.rs
Broadcast helpers return response metadata; transition APIs now return the proof-committed height alongside address results.
Platform-wallet reconciliation
packages/rs-platform-wallet/src/wallet/platform_addresses/{provider,wallet,transfer,withdrawal,fund_from_asset_lock,sync}.rs, .../platform_wallet.rs, .../wallet/identity/network/*.rs, .../wallet/apply.rs, .../wallet/platform_addresses/mod.rs
Replaces credited-output reconciliation with proof_height/as_of_height pinning across wallet flows and provider freshness checks.
FFI and WASM bridging
packages/rs-platform-wallet-ffi/src/{persistence,platform_address_types,platform_addresses/wallet}.rs, packages/rs-sdk-ffi/src/{address_sync/mod,identity/*,address/transitions/*}.rs, packages/wasm-sdk/src/state_transitions/addresses.rs
Carries as_of_height through the FFI structs and persistence callbacks, while call sites discard the newly returned proof height where it is not surfaced.
Sqlite and Swift persistence
packages/rs-platform-wallet-storage/migrations/V002__address_height_pin.rs, .../src/sqlite/schema/platform_addrs.rs, .../tests/*.rs, packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPlatformAddress.swift, .../PlatformWallet/*.swift, SwiftExampleApp/*
Adds the database column, persists and reloads the pin, and widens Swift-side cached balance handling to store and restore the height.
Regression/test updates
packages/rs-platform-wallet/src/{changeset,wallet/apply.rs}, packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs, .../sync.rs, packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md
Updates tests and the ADDR-09 test-plan narrative to assert the new pin-based behavior.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related PRs

  • dashpay/platform#3637: Shares the AddressFunds serialization/persistence surface that this PR extends with as_of_height.
  • dashpay/platform#3855: Touches the same platform-address reconciliation and zeroing paths now updated to use height pins.
  • dashpay/platform#4004: Implements the older ADDR-09 double-credit fix that this PR replaces with proof_height/as_of_height pinning.

Suggested labels: Client Only

Suggested reviewers: shumkov, lklimek, llbartekll, ZocoLini

🚥 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 title clearly matches the main change: adding height pins to platform-wallet address balances to prevent double-counting.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/xenodochial-mcnulty-b5c470

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 6, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit 4dd2098)

@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.21%. Comparing base (f55b1ce) to head (4dd2098).
⚠️ Report is 2 commits behind head on v4.1-dev.

Additional details and impacted files
@@              Coverage Diff              @@
##           v4.1-dev    #4019       +/-   ##
=============================================
+ Coverage     58.79%   87.21%   +28.41%     
=============================================
  Files            15     2642     +2627     
  Lines          1966   328287   +326321     
=============================================
+ Hits           1156   286330   +285174     
- Misses          810    41957    +41147     
Components Coverage Δ
dpp 87.72% <ø> (∅)
drive 86.14% <ø> (∅)
drive-abci 89.45% <ø> (∅)
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.20% <ø> (∅)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 49.55% <ø> (∅)
🚀 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.

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

Actionable comments posted: 1

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-storage/tests/sqlite_persist_roundtrip.rs (1)

295-310: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Round-trip test doesn't assert as_of_height survives persistence.

The fixtures set as_of_height: 111 / as_of_height: 222, but the post-reload assertions never check rows[0].funds.as_of_height / rows[1].funds.as_of_height. Since this test exists specifically to validate the new height-pin persistence, a regression in the column read/write path could pass silently.

✅ Proposed fix
     assert_eq!(rows[0].address, addr1);
     assert_eq!(rows[0].funds.balance, 500);
     assert_eq!(rows[0].funds.nonce, 1);
+    assert_eq!(rows[0].funds.as_of_height, 111);
     assert_eq!(rows[1].address, addr2);
     assert_eq!(rows[1].funds.balance, 1500);
+    assert_eq!(rows[1].funds.nonce, 2);
+    assert_eq!(rows[1].funds.as_of_height, 222);

Also applies to: 333-339

🤖 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-storage/tests/sqlite_persist_roundtrip.rs` around
lines 295 - 310, The sqlite_persist_roundtrip.rs round-trip test is not
verifying that as_of_height survives persistence. Update the assertions in the
existing reload check for the PlatformAddressBalanceEntry rows so they
explicitly validate rows[0].funds.as_of_height and rows[1].funds.as_of_height
match the fixture values set in the test, using the same round-trip assertion
block that already checks nonce and balance.
🧹 Nitpick comments (2)
packages/rs-platform-wallet/src/wallet/platform_addresses/sync.rs (1)

200-354: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test gap: no coverage for a non-zero absent_as_of_height landing in an emitted entry.

Every test passes 0 for absent_as_of_height, so the pinning behavior added in this PR (lines 86-93) is only exercised with the "legacy" value. A test asserting entries[0].funds.as_of_height == <non-zero checkpoint> for the absent-zeroing path would directly cover the new field.

✅ Suggested additional test
#[test]
fn absent_previously_funded_address_pins_to_checkpoint_height() {
    let mut before = BTreeMap::new();
    before.insert(tag(0), funds(294_627_247_940, 5));

    let found = BTreeMap::new();
    let mut absent = BTreeSet::new();
    absent.insert((tag(0), p2pkh(1)));

    let entries = compute_address_balance_diff(&before, &found, &absent, 12_345);

    assert_eq!(entries.len(), 1);
    assert_eq!(entries[0].funds.as_of_height, 12_345);
}
🤖 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/sync.rs` around
lines 200 - 354, Add coverage for the new absent-entry height pinning in
compute_address_balance_diff by extending the absent-zeroing path test around
absent_previously_funded_address_emits_zero_entry (or adding a nearby test) to
pass a non-zero absent_as_of_height and assert the emitted entry’s
AddressFunds.as_of_height matches that checkpoint. This should exercise the
branch that creates the zeroed AddressFunds for an address in the absent set,
ensuring the new field is preserved in the emitted diff.
packages/rs-sdk-ffi/src/identity/transfer_to_addresses.rs (1)

184-196: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider a brief comment documenting why _proof_height is dropped.

Sibling call sites (transfer.rs, top_up_from_asset_lock.rs) explain that this raw path only renders proof entries without persisting, so the height pin doesn't matter here. This site lacks that context, which could confuse a future reader into thinking the height was overlooked rather than intentionally discarded.

✏️ Suggested comment
         wrapper.runtime.block_on(async {
+            // The `_proof_height` pin matters to callers that PERSIST these
+            // absolutes (the platform-wallet reconcile seam); this raw FFI
+            // path only renders the transfer result, so it drops the height.
             let (address_infos, identity_balance, _proof_height) = identity
🤖 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-sdk-ffi/src/identity/transfer_to_addresses.rs` around lines 184 -
196, Add a brief inline comment at the transfer path in
transfer_to_addresses::transfer_credits_to_addresses explaining that
_proof_height is intentionally discarded because this raw FFI path only renders
proof entries and does not persist them, so the height pin is unnecessary.
Mirror the context used in the sibling call sites transfer.rs and
top_up_from_asset_lock.rs near the destructuring of the
transfer_credits_to_addresses result.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/rs-sdk/src/platform/transition/transfer_to_addresses.rs`:
- Line 35: The return-tuple doc for transfer_to_addresses is outdated: it still
describes only AddressInfos and Credits, while the signature now returns a third
u64 from metadata.height. Update the doc block above transfer_to_addresses so
the third bullet clearly documents this proof-committed height and how callers
use it as the AddressFunds::as_of_height pin, instead of repeating the address
infos description.

---

Outside diff comments:
In `@packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs`:
- Around line 295-310: The sqlite_persist_roundtrip.rs round-trip test is not
verifying that as_of_height survives persistence. Update the assertions in the
existing reload check for the PlatformAddressBalanceEntry rows so they
explicitly validate rows[0].funds.as_of_height and rows[1].funds.as_of_height
match the fixture values set in the test, using the same round-trip assertion
block that already checks nonce and balance.

---

Nitpick comments:
In `@packages/rs-platform-wallet/src/wallet/platform_addresses/sync.rs`:
- Around line 200-354: Add coverage for the new absent-entry height pinning in
compute_address_balance_diff by extending the absent-zeroing path test around
absent_previously_funded_address_emits_zero_entry (or adding a nearby test) to
pass a non-zero absent_as_of_height and assert the emitted entry’s
AddressFunds.as_of_height matches that checkpoint. This should exercise the
branch that creates the zeroed AddressFunds for an address in the absent set,
ensuring the new field is preserved in the emitted diff.

In `@packages/rs-sdk-ffi/src/identity/transfer_to_addresses.rs`:
- Around line 184-196: Add a brief inline comment at the transfer path in
transfer_to_addresses::transfer_credits_to_addresses explaining that
_proof_height is intentionally discarded because this raw FFI path only renders
proof entries and does not persist them, so the height pin is unnecessary.
Mirror the context used in the sibling call sites transfer.rs and
top_up_from_asset_lock.rs near the destructuring of the
transfer_credits_to_addresses result.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d718998a-7f7f-4e13-b225-f9f6c3268ba5

📥 Commits

Reviewing files that changed from the base of the PR and between 0877460 and d295598.

📒 Files selected for processing (43)
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet-ffi/src/platform_address_types.rs
  • packages/rs-platform-wallet-ffi/src/platform_addresses/wallet.rs
  • packages/rs-platform-wallet-storage/migrations/V002__address_height_pin.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs
  • packages/rs-platform-wallet/src/changeset/changeset.rs
  • packages/rs-platform-wallet/src/changeset/serde_adapters.rs
  • packages/rs-platform-wallet/src/wallet/apply.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/register_from_addresses.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/transfer_to_addresses.rs
  • packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs
  • packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs
  • packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs
  • packages/rs-platform-wallet/src/wallet/platform_addresses/sync.rs
  • packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs
  • packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs
  • packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs
  • packages/rs-platform-wallet/src/wallet/platform_wallet.rs
  • packages/rs-sdk-ffi/src/address/transitions/top_up_from_asset_lock.rs
  • packages/rs-sdk-ffi/src/address/transitions/transfer.rs
  • packages/rs-sdk-ffi/src/address/transitions/withdraw.rs
  • packages/rs-sdk-ffi/src/address_sync/mod.rs
  • packages/rs-sdk-ffi/src/identity/create_from_addresses.rs
  • packages/rs-sdk-ffi/src/identity/top_up_from_addresses.rs
  • packages/rs-sdk-ffi/src/identity/transfer_to_addresses.rs
  • packages/rs-sdk/src/platform/address_sync/mod.rs
  • packages/rs-sdk/src/platform/address_sync/types.rs
  • packages/rs-sdk/src/platform/transition/address_credit_withdrawal.rs
  • packages/rs-sdk/src/platform/transition/broadcast.rs
  • packages/rs-sdk/src/platform/transition/put_identity.rs
  • packages/rs-sdk/src/platform/transition/top_up_address.rs
  • packages/rs-sdk/src/platform/transition/top_up_identity_from_addresses.rs
  • packages/rs-sdk/src/platform/transition/transfer_address_funds.rs
  • packages/rs-sdk/src/platform/transition/transfer_to_addresses.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPlatformAddress.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/PlatformBalanceSyncService.swift
  • packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md

Comment thread packages/rs-sdk/src/platform/transition/transfer_to_addresses.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

Solid, well-scoped fix for ADDR-09. The per-address height pin is threaded end-to-end (SDK → provider → SQLite → Swift) with strong test coverage and clear rationale. One public-API serde gap should be closed so legacy blobs still deserialize; other agent findings are either false positives (the raw FFI debug path documents dropping the height on purpose) or speculative nits not caused by this PR.

Source: reviewers claude/opus + codex/gpt-5.5 for general, security-auditor, rust-quality, and ffi-engineer; verifier claude/opus; policy gate review_policy.enforce_backport_prereq_policy.

🟡 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-sdk/src/platform/address_sync/types.rs`:
- [SUGGESTION] packages/rs-sdk/src/platform/address_sync/types.rs:78-80: `AddressFunds::as_of_height` breaks serde compatibility for legacy blobs
  `AddressFunds` derives `Serialize`/`Deserialize` behind the `serde` feature, and this PR adds `as_of_height` without `#[serde(default)]`. Any external consumer using `dash-sdk/serde` to persist `AddressFunds` directly will now fail to deserialize pre-PR blobs, even though the field's own doc-comment explicitly promises legacy-safe semantics (`0` = "unknown provenance"). The in-tree `changeset/serde_adapters.rs` correctly uses `#[serde(default)]` on its `Wire` mirror, but the public type does not — so anything not routed through that adapter breaks. Add `#[serde(default)]` to align the derive with the documented "pin = 0 for legacy" contract.

Comment on lines +78 to +80
/// `0` means "unknown provenance" (legacy rows persisted before the
/// pin existed): every delta applies, matching pre-pin behavior.
pub as_of_height: u64,

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.

🟡 Suggestion: AddressFunds::as_of_height breaks serde compatibility for legacy blobs

AddressFunds derives Serialize/Deserialize behind the serde feature, and this PR adds as_of_height without #[serde(default)]. Any external consumer using dash-sdk/serde to persist AddressFunds directly will now fail to deserialize pre-PR blobs, even though the field's own doc-comment explicitly promises legacy-safe semantics (0 = "unknown provenance"). The in-tree changeset/serde_adapters.rs correctly uses #[serde(default)] on its Wire mirror, but the public type does not — so anything not routed through that adapter breaks. Add #[serde(default)] to align the derive with the documented "pin = 0 for legacy" contract.

Suggested change
/// `0` means "unknown provenance" (legacy rows persisted before the
/// pin existed): every delta applies, matching pre-pin behavior.
pub as_of_height: u64,
/// `0` means "unknown provenance" (legacy rows persisted before the
/// pin existed): every delta applies, matching pre-pin behavior.
#[cfg_attr(feature = "serde", serde(default))]
pub as_of_height: u64,

source: ['claude']

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.

Resolved in this update — AddressFunds::as_of_height breaks serde compatibility for legacy blobs no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

…elpers

The address/identity transition helpers now return the proof's committed
block height alongside AddressInfos (the balance height pin). The wasm
paths only render the proof entries, so the height is intentionally
unused here. Fixes the macOS workspace-tests E0308s CI caught — verified
this time with a full `cargo check --workspace --all-targets`.

Co-Authored-By: Claude Opus 4.8 <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

Source: reviewers = claude ffi-engineer opus; claude general opus; claude rust-quality opus; claude security-auditor opus; codex ffi-engineer gpt-5.5; codex general gpt-5.5; codex rust-quality gpt-5.5; codex security-auditor gpt-5.5; verifier = claude opus; specialists = security-auditor, rust-quality, ffi-engineer; policy gate = review_policy.enforce_backport_prereq_policy.

Prior reconciliation: prior-1 STILL VALID at packages/rs-sdk/src/platform/address_sync/types.rs:80 — file unchanged in latest delta. Carried-forward: AddressFunds::as_of_height still lacks #[serde(default)]. New in latest delta (wasm-sdk destructuring of proof height): nothing to flag — the wasm surface is render-only. Cumulative: one moderate-confidence single-source suggestion from codex-security-auditor about the sync-path emit lacking pin-freshness enforcement, which would let a stale full-scan clobber a fresher commit_reconciliation row.

🟡 1 suggestion(s)

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

1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 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/sync.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/platform_addresses/sync.rs:49-61: Sync-path diff emits without checking pin freshness against `before`
  `compute_address_balance_diff` only skips a `found` entry when `before.get(&tag) == Some(&funds)` — an exact `AddressFunds` equality check. This means a `found` entry whose `as_of_height` is *below* the snapshot's pin still emits (the pin field differs, so the funds compare unequal) and gets persisted. The freshness guard the PR introduces in `commit_reconciliation` (provider.rs, per its doc block) is therefore not mirrored here. If the address trunk/branch scan returns a stale-but-valid proof — the SDK path disables metadata freshness checks and the recent catch-up query is best-effort — the resulting older-pinned funds will clobber a fresher `commit_reconciliation` row on disk. The lock-order argument in the comment at lines 169–174 protects against interleaved writes, not against stale inputs to the sync itself. Applying the same pin-freshness rule (`funds.as_of_height >= before.get(&tag).map(|f| f.as_of_height).unwrap_or(0)`) before emitting closes the gap and keeps the mitigation symmetrical across both writers. Single-source finding (codex-security-auditor only, moderate confidence); defense-in-depth rather than a proven exploit path.

Resolves the planned overlap with #3650 (Found-025): the height-pin
delta gate now lives inside the extracted `apply_op` / `apply_change`
seam, so the forward pass AND the end-of-pass unknown-address replay
gate identically, and the gate is unit-testable without an Sdk.

- `apply_op(op, current: AddressFunds, op_height)` gates per the pin:
  recent changes at or below it are already inside the pinned absolute;
  compacted operations gate per-op on their own heights. Applying
  advances the pin.
- the call sites pass the height a change is recorded AS OF (recent:
  the entry's block height; compacted: the range end) instead of the
  pagination cursor — the cursor-based compacted filter is subsumed by
  per-address pins, which also fixes the fresh-address undercount the
  cursor filter caused on replay (a just-derived address's true base is
  0 from genesis, so its older ops must apply).
- `seed_base_balances` (#3650) becomes safe by construction: the seeded
  `current_balances()` funds now carry their pins, so the recent replay
  can no longer double-count a reconciled absolute routed through the
  defensive seed.
- #3650's tests adapted from cursor to pin semantics (real block
  heights instead of 0-as-no-filter); new
  `apply_block_changes_drops_recent_delta_already_inside_pinned_absolute`
  covers the exact ADDR-09 shape on the pure seam.

Co-Authored-By: Claude Opus 4.8 <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

Incremental + cumulative review at cfa400c2. Both prior findings from b8eedda4 remain STILL VALID and are carried forward. The latest delta rewrites the SDK address-sync apply path; I found two additional suggestion-level issues there/at the adjacent standalone FFI surface, but no blockers.

Source: reviewers = claude general opus (ok); codex general gpt-5.5 (ok); claude security-auditor opus (ok); codex security-auditor gpt-5.5 (ok); claude rust-quality opus (ok); codex rust-quality gpt-5.5 (ok); claude ffi-engineer opus (ok); codex ffi-engineer gpt-5.5 (ok); verifier = claude opus (ok); specialists = security-auditor, rust-quality, ffi-engineer; policy gate = review_policy.enforce_backport_prereq_policy.

🟡 4 suggestion(s)

Prior Reconciliation

  • prior-1 STILL VALID: Verified at packages/rs-sdk/src/platform/address_sync/types.rs:52,80 — struct still derives Serialize/Deserialize behind the serde feature and as_of_height still lacks #[serde(default)].
  • prior-2 STILL VALID: Verified at packages/rs-platform-wallet/src/wallet/platform_addresses/sync.rs:49-96 — the found loop skips only when before.get(&tag) == Some(&funds) (exact equality) and the absent-zeroing loop only checks that before had non-default funds; neither branch compares the emitted pin to the snapshot pin.

Carried-Forward Prior Findings

  • [SUGGESTION] packages/rs-sdk/src/platform/address_sync/types.rs:78-80: AddressFunds::as_of_height breaks serde compatibility for legacy blobs
    AddressFunds derives Serialize/Deserialize behind the serde feature (line 52) but the newly added as_of_height field has no #[serde(default)]. The doc-comment on lines 78–79 explicitly promises legacy-safe semantics (pin 0 = "unknown provenance", every delta applies), but any external consumer of dash-sdk/serde that persists AddressFunds directly (JS/Swift bindings via serde_json, downstream Rust caches, on-disk snapshots written pre-PR) will fail to deserialize with a missing field "as_of_height" error rather than loading with the documented pin-0 legacy contract. The in-tree changeset/serde_adapters.rs::Wire mirror already uses #[serde(default)], so the wallet-changeset path is fine — but the public SDK type is not routed through that adapter. Adding #[serde(default)] to the field aligns the derive with the documented contract.
  • [SUGGESTION] packages/rs-platform-wallet/src/wallet/platform_addresses/sync.rs:48-96: compute_address_balance_diff can persist stale-pinned scan results over fresher rows
    The found loop at lines 49-61 skips only when before.get(&tag) == Some(&funds) — a byte-for-byte AddressFunds equality that includes as_of_height. A found entry whose as_of_height is older than the snapshot pin still compares unequal and is emitted, so a stale-but-valid trunk/branch scan proof can overwrite a fresher commit_reconciliation row on disk. The absent-zeroing loop at lines 65-96 has the same shape — it emits a zero pinned at absent_as_of_height without checking that the scan checkpoint is newer than the snapshot pin. commit_reconciliation in provider.rs already applies the height-freshness rule (existing.as_of_height > entry.funds.as_of_height ⇒ stale); mirroring that rule here keeps both writers into the same rows consistent. The lock ordering at sync.rs:169-174 serializes concurrent writers but does not filter stale inputs to this writer. Skip the emit when funds.as_of_height < before.get(&tag).map(|f| f.as_of_height).unwrap_or(0) on the found branch, and when absent_as_of_height < … on the absent branch. Defense-in-depth against DAPI node lag / eclipse routing — the PR description itself calls out that a scan at height 379728 can arrive alongside a funding proof pinned at 379731 in the wild.

New Findings In Latest Delta / Current Head

  • [SUGGESTION] packages/rs-sdk/src/platform/address_sync/mod.rs:958-984: pending_unknown accumulates foreign-wallet changes without a hard cap on shared chains
    The new unknown-address path clones every unmatched recent/compacted change into pending_unknown and the in-code comment explicitly leaves the buffer unbounded, relying on a one-shot warn at PENDING_UNKNOWN_WARN_THRESHOLD (1000). On a shared/busy chain the incoming stream is global: most changes seen during pagination belong to other wallets and remain buffered until the end-of-pass refresh, where they get discarded at the unknown_keys intersection. The comment correctly notes that a bounded-memory fix would need to re-query, not just stash keys, so replacing the design isn't trivial — but for mobile/embedded consumers, letting one wallet sync retain unbounded foreign owned-changes across a long catch-up window is a real memory-pressure risk (OwnedBalanceOp::Compacted carries a Vec of arbitrary length). Consider either (a) tightening to a hard byte-count cap that triggers a mid-pass drain + refresh, or (b) draining pending_unknown after each pagination page rather than at end-of-pass so foreign changes are discarded promptly. The current tradeoff is defensible for well-behaved networks, so this is a hardening suggestion rather than a blocker — but the warn hook alone doesn't prevent the exhaustion it detects.
  • [SUGGESTION] packages/rs-sdk-ffi/src/address_sync/types.rs:93-108: Standalone Swift batch sync (DashSDKFoundAddress) drops the new as_of_height pin
    dash_sdk_sync_addresses_batch_with_result returns DashSDKFoundAddress over the C boundary and Swift feeds those values back as knownBalances on later calls. The C struct exposes only index/key/nonce/balance — the per-address as_of_height that this PR introduces on AddressFunds is not carried across. When the Rust side rebuilds AddressFunds from FFI inputs it fills as_of_height: 0, which per the field's own doc-comment means "unknown provenance ⇒ every delta applies". Standalone Swift consumers of this batch API therefore get pre-PR delta-replay semantics across persistence/restart, even though the platform-wallet FFI path (AddressBalanceEntryFFI) does round-trip the pin. Add a as_of_height: u64 field to DashSDKFoundAddress (mirror it on the Swift Codable) so ADDR-09 replay protection extends to that surface too.
🤖 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-sdk/src/platform/address_sync/types.rs`:
- [SUGGESTION] packages/rs-sdk/src/platform/address_sync/types.rs:78-80: AddressFunds::as_of_height breaks serde compatibility for legacy blobs
  AddressFunds derives Serialize/Deserialize behind the `serde` feature (line 52) but the newly added `as_of_height` field has no `#[serde(default)]`. The doc-comment on lines 78–79 explicitly promises legacy-safe semantics (pin `0` = "unknown provenance", every delta applies), but any external consumer of `dash-sdk/serde` that persists AddressFunds directly (JS/Swift bindings via serde_json, downstream Rust caches, on-disk snapshots written pre-PR) will fail to deserialize with a `missing field "as_of_height"` error rather than loading with the documented pin-0 legacy contract. The in-tree `changeset/serde_adapters.rs::Wire` mirror already uses `#[serde(default)]`, so the wallet-changeset path is fine — but the public SDK type is not routed through that adapter. Adding `#[serde(default)]` to the field aligns the derive with the documented contract.

In `packages/rs-platform-wallet/src/wallet/platform_addresses/sync.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/platform_addresses/sync.rs:48-96: compute_address_balance_diff can persist stale-pinned scan results over fresher rows
  The found loop at lines 49-61 skips only when `before.get(&tag) == Some(&funds)` — a byte-for-byte AddressFunds equality that includes `as_of_height`. A found entry whose `as_of_height` is *older* than the snapshot pin still compares unequal and is emitted, so a stale-but-valid trunk/branch scan proof can overwrite a fresher `commit_reconciliation` row on disk. The absent-zeroing loop at lines 65-96 has the same shape — it emits a zero pinned at `absent_as_of_height` without checking that the scan checkpoint is newer than the snapshot pin. `commit_reconciliation` in provider.rs already applies the height-freshness rule (`existing.as_of_height > entry.funds.as_of_height ⇒ stale`); mirroring that rule here keeps both writers into the same rows consistent. The lock ordering at sync.rs:169-174 serializes concurrent writers but does not filter stale inputs to this writer. Skip the emit when `funds.as_of_height < before.get(&tag).map(|f| f.as_of_height).unwrap_or(0)` on the found branch, and when `absent_as_of_height < …` on the absent branch. Defense-in-depth against DAPI node lag / eclipse routing — the PR description itself calls out that a scan at height 379728 can arrive alongside a funding proof pinned at 379731 in the wild.

In `packages/rs-sdk/src/platform/address_sync/mod.rs`:
- [SUGGESTION] packages/rs-sdk/src/platform/address_sync/mod.rs:958-984: pending_unknown accumulates foreign-wallet changes without a hard cap on shared chains
  The new unknown-address path clones every unmatched recent/compacted change into `pending_unknown` and the in-code comment explicitly leaves the buffer unbounded, relying on a one-shot warn at PENDING_UNKNOWN_WARN_THRESHOLD (1000). On a shared/busy chain the incoming stream is *global*: most changes seen during pagination belong to other wallets and remain buffered until the end-of-pass refresh, where they get discarded at the `unknown_keys` intersection. The comment correctly notes that a bounded-memory fix would need to re-query, not just stash keys, so replacing the design isn't trivial — but for mobile/embedded consumers, letting one wallet sync retain unbounded foreign owned-changes across a long catch-up window is a real memory-pressure risk (`OwnedBalanceOp::Compacted` carries a Vec of arbitrary length). Consider either (a) tightening to a hard byte-count cap that triggers a mid-pass drain + refresh, or (b) draining pending_unknown after each pagination page rather than at end-of-pass so foreign changes are discarded promptly. The current tradeoff is defensible for well-behaved networks, so this is a hardening suggestion rather than a blocker — but the warn hook alone doesn't prevent the exhaustion it detects.

In `packages/rs-sdk-ffi/src/address_sync/types.rs`:
- [SUGGESTION] packages/rs-sdk-ffi/src/address_sync/types.rs:93-108: Standalone Swift batch sync (DashSDKFoundAddress) drops the new as_of_height pin
  `dash_sdk_sync_addresses_batch_with_result` returns `DashSDKFoundAddress` over the C boundary and Swift feeds those values back as `knownBalances` on later calls. The C struct exposes only index/key/nonce/balance — the per-address `as_of_height` that this PR introduces on AddressFunds is not carried across. When the Rust side rebuilds AddressFunds from FFI inputs it fills `as_of_height: 0`, which per the field's own doc-comment means "unknown provenance ⇒ every delta applies". Standalone Swift consumers of this batch API therefore get pre-PR delta-replay semantics across persistence/restart, even though the platform-wallet FFI path (AddressBalanceEntryFFI) does round-trip the pin. Add a `as_of_height: u64` field to DashSDKFoundAddress (mirror it on the Swift Codable) so ADDR-09 replay protection extends to that surface too.

Comment on lines 83 to 96
account_index,
address_index,
address: p2pkh,
// Absent in state ⇒ no balance and no nonce. Reset both.
// Absent in state ⇒ no balance and no nonce. Reset both. The
// absence was proven by the tree scan, so the zero is pinned
// at the scan's checkpoint height (`absent_as_of_height`) —
// a later re-credit carries a higher pin and wins.
funds: AddressFunds {
balance: 0,
nonce: 0,
as_of_height: absent_as_of_height,
},
});
}

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.

🟡 Suggestion: compute_address_balance_diff can persist stale-pinned scan results over fresher rows

The found loop at lines 49-61 skips only when before.get(&tag) == Some(&funds) — a byte-for-byte AddressFunds equality that includes as_of_height. A found entry whose as_of_height is older than the snapshot pin still compares unequal and is emitted, so a stale-but-valid trunk/branch scan proof can overwrite a fresher commit_reconciliation row on disk. The absent-zeroing loop at lines 65-96 has the same shape — it emits a zero pinned at absent_as_of_height without checking that the scan checkpoint is newer than the snapshot pin. commit_reconciliation in provider.rs already applies the height-freshness rule (existing.as_of_height > entry.funds.as_of_height ⇒ stale); mirroring that rule here keeps both writers into the same rows consistent. The lock ordering at sync.rs:169-174 serializes concurrent writers but does not filter stale inputs to this writer. Skip the emit when funds.as_of_height < before.get(&tag).map(|f| f.as_of_height).unwrap_or(0) on the found branch, and when absent_as_of_height < … on the absent branch. Defense-in-depth against DAPI node lag / eclipse routing — the PR description itself calls out that a scan at height 379728 can arrive alongside a funding proof pinned at 379731 in the wild.

Suggested change
account_index,
address_index,
address: p2pkh,
// Absent in state ⇒ no balance and no nonce. Reset both.
// Absent in state ⇒ no balance and no nonce. Reset both. The
// absence was proven by the tree scan, so the zero is pinned
// at the scan's checkpoint height (`absent_as_of_height`) —
// a later re-credit carries a higher pin and wins.
funds: AddressFunds {
balance: 0,
nonce: 0,
as_of_height: absent_as_of_height,
},
});
}
// 1. Found-and-changed.
for (&(tag, p2pkh), &funds) in found {
if let Some(existing) = before.get(&tag) {
// Skip when unchanged, or when the newly scanned pin is
// older than the already committed pin — a stale-but-valid
// proof must not clobber a fresher row.
if existing == &funds || existing.as_of_height > funds.as_of_height {
continue;
}
}
let (wallet_id, account_index, address_index) = tag;
entries.push(PlatformAddressBalanceEntry {
wallet_id,
account_index,
address_index,
address: p2pkh,
funds,
});
}
// 2. Absent-but-previously-funded — zero out cached balances that the
// new chain no longer knows about.
for &(tag, p2pkh) in absent {
// Absent at the checkpoint but re-found during incremental
// catch-up — the find is newer (tip), so never zero it.
if found.contains_key(&(tag, p2pkh)) {
continue;
}
let Some(existing) = before.get(&tag) else {
continue;
};
// Only emit when we actually had non-default cached funds for the
// address. An address that was already empty (or never cached)
// doesn't need a zeroing write.
if existing.balance == 0 && existing.nonce == 0 {
continue;
}
// A stale absence proof must not clobber a row pinned by a newer
// proof or transition reconciliation.
if existing.as_of_height > absent_as_of_height {
continue;
}
let (wallet_id, account_index, address_index) = tag;
entries.push(PlatformAddressBalanceEntry {
wallet_id,
account_index,
address_index,
address: p2pkh,
// Absent in state ⇒ no balance and no nonce. Reset both. The
// absence was proven by the tree scan, so the zero is pinned
// at the scan's checkpoint height (`absent_as_of_height`) —
// a later re-credit carries a higher pin and wins.
funds: AddressFunds {
balance: 0,
nonce: 0,
as_of_height: absent_as_of_height,
},
});
}

source: ['claude', 'codex', 'prior']

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch — taken in 78a16ce, with one extension: the suggestion as written guards only the persist diff, which would leave sync_finished's scratch merge (found.extend(...)) regressing the in-memory seed while disk holds the fresher row — a disk-vs-memory divergence in exactly the protected scenario. So the height-freshness rule now lands symmetrically in both writers:

  • compute_address_balance_diff: your two skips (older-pinned found entry; absence zeroing whose scan checkpoint is older than the committed pin), essentially as suggested.
  • sync_finished: per-entry scratch merge keeping the committed entry when its pin is fresher.

Absent-removal stays unguarded in memory (absence carries no per-entry height at the provider layer); the diff guard protects the durable row, and the next pass reconstructs the in-memory entry from a full replay (base 0, pin 0 → every delta applies).

Worth noting the blast radius even unguarded was transient: the regressed row keeps its old pin, so the next pass on a healthy node re-applies the missing delta and self-heals within one cycle — but no reason to allow the window at all. Three new tests cover the stale-pin found skip, the stale-checkpoint absence skip, and the merge freshness rule (plus newer-pin paths still landing).

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.

Resolved in this update — compute_address_balance_diff can persist stale-pinned scan results over fresher rows no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

QuantumExplorer and others added 3 commits July 6, 2026 14:27
…node scans

Review follow-up (thepastaclaw on #4019): a full scan against a lagging
node can produce a stale-but-valid absolute (older pin) for a row a
fresher reconcile already committed — and both of the sync's writers
would take it: `compute_address_balance_diff` emitted it to the durable
rows, and `sync_finished`'s scratch merge overwrote the in-memory seed.
The regression self-heals within one pass (the regressed row keeps its
old pin, so the next replay re-applies the missing delta), but there is
no reason to let it happen at all.

Apply `commit_reconciliation`'s height-freshness rule to BOTH writers,
symmetrically — guarding only the persist diff (as suggested) would
leave memory regressing while disk holds the fresher row, i.e. a
disk-vs-memory divergence in exactly the protected scenario:

- `compute_address_balance_diff`: skip a found entry whose pin is older
  than the committed row's, and skip an absence zeroing whose scan
  checkpoint is older (on a lagging node an address funded seconds ago
  legitimately does not exist yet at the checkpoint).
- `sync_finished`: merge scratch `found` per entry, keeping the
  committed entry when its pin is fresher. Absent-removal stays
  unguarded in memory (absence carries no per-entry height); the diff
  guard protects the durable row and the next pass reconstructs the
  in-memory entry from a full replay.

Tests: stale-pin found skip, stale-checkpoint absence skip, and the
scratch-merge freshness rule (plus the newer-pin paths still landing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review follow-up: the `transfer_credits_to_addresses` return doc still
listed the old two elements (with a duplicated third bullet) after the
signature grew the proof's committed block height. Document the third
element as the balance height pin (`AddressFunds::as_of_height`) and
what persisting callers must do with it; same staleness fixed on
`TopUpAddress::top_up`, whose "returns AddressInfos" line predated the
`(AddressInfos, u64)` tuple. Also qualify the pre-existing unresolved
`AddressProvider::last_known_recent_block_height` intra-doc link in
address_sync/types.rs (module path was missing, so rustdoc could not
resolve it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI caught AddressBalancePersistTests still using the pre-pin 6-tuple
shapes of persistAddressBalances / loadCachedBalances. Update the
entries and the loadedRow helper to the 7-tuple, give the fixtures
realistic proof heights, and add an assertion that the pin round-trips
into PersistentPlatformAddress.lastSeenHeight — so the new field's
Swift persistence has direct coverage instead of just compiling.

Co-Authored-By: Claude Opus 4.8 <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

The delta from 78a16ce..a93f7ca is documentation-only in Rust (doc-comment clarifications on top_up_address / transfer_to_addresses and one intra-doc link fix on address_sync/types.rs) plus a Swift persist-test update to the 7-tuple shape asserting the pin round-trips into lastSeenHeight. No new Rust logic or FFI signatures changed. Prior-1 (AddressFunds::as_of_height lacks #[serde(default)]) and prior-3 (pending_unknown unbounded, intentionally deferred by author) remain STILL VALID and are carried forward. No new blockers.

Source: reviewers = claude general opus (ok); codex general gpt-5.5 (ok); claude security-auditor opus (ok); codex security-auditor gpt-5.5 (ok); claude rust-quality opus (ok); codex rust-quality gpt-5.5 (ok); claude ffi-engineer opus (ok); codex ffi-engineer gpt-5.5 (ok); verifier = claude opus (ok); specialists = security-auditor, rust-quality, ffi-engineer; policy gate = review_policy.enforce_backport_prereq_policy.

🟡 2 suggestion(s)

Prior Reconciliation

  • prior-1 STILL VALID: verified at packages/rs-sdk/src/platform/address_sync/types.rs:52,80; AddressFunds still derives serde when the feature is enabled, and as_of_height still lacks #[serde(default)].
  • prior-3 STILL VALID: verified at packages/rs-sdk/src/platform/address_sync/mod.rs:958-984; pending_unknown still appends unmatched global changes into an intentionally unbounded buffer with only the one-shot threshold warning.

Carried-Forward Prior Findings

  • [SUGGESTION] packages/rs-sdk/src/platform/address_sync/types.rs:78-80: AddressFunds::as_of_height breaks serde compatibility for legacy blobs
    AddressFunds derives Serialize/Deserialize behind the serde feature (line 52), but the new as_of_height field on line 80 has no #[serde(default)]. The field's own doc-comment on lines 78–79 explicitly promises legacy-safe semantics — pin 0 = 'unknown provenance', every delta applies, matching pre-pin behavior. Without the serde default, any external dash-sdk/serde consumer decoding a pre-PR persisted blob (JS/Swift wrappers, downstream Rust caches, on-disk snapshots) fails with missing field "as_of_height" instead of loading the documented pin-0 fallback. The in-tree wallet-changeset path is safe because changeset/serde_adapters.rs::Wire uses #[serde(default)], but the public SDK type is not routed through that adapter. Adding the attribute aligns the derive with the documented contract. Carried forward from prior review; the latest delta touched this file only for an intra-doc link and did not address this.
  • [SUGGESTION] packages/rs-sdk/src/platform/address_sync/mod.rs:958-984: pending_unknown accumulates foreign-wallet changes without a hard cap
    The unknown-address branch clones every unmatched recent/compacted balance change into pending_unknown and the in-line comment (964–976) intentionally leaves the buffer unbounded, relying on a one-shot warn at PENDING_UNKNOWN_WARN_THRESHOLD (1000). The recent/compacted streams are global to the chain, so on a busy shared chain most entries belong to other wallets and remain buffered until the end-of-pass refresh intersects them with unknown_keys. OwnedBalanceOp::Compacted carries a Vec of arbitrary length, so for mobile/embedded consumers on a long catch-up window this is a real memory-pressure surface; a stale/eclipsing DAPI route could amplify it. The author's comment correctly notes that a bounded-memory fix must re-query rather than merely stash keys, so this remains a hardening suggestion — but the warn hook only observes the condition, it does not prevent exhaustion. Consider a hard byte-count cap that triggers a mid-pass drain + refresh, or draining pending_unknown after each pagination page rather than end-of-pass.

New Findings In Latest Delta

  • None. The latest delta from 78a16cea to a93f7caf only updates Rust docs/intra-doc links and Swift persistence tests for the height-pin tuple.
🤖 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-sdk/src/platform/address_sync/types.rs`:
- [SUGGESTION] packages/rs-sdk/src/platform/address_sync/types.rs:78-80: AddressFunds::as_of_height breaks serde compatibility for legacy blobs
  AddressFunds derives Serialize/Deserialize behind the `serde` feature (line 52), but the new `as_of_height` field on line 80 has no `#[serde(default)]`. The field's own doc-comment on lines 78–79 explicitly promises legacy-safe semantics — pin `0` = 'unknown provenance', every delta applies, matching pre-pin behavior. Without the serde default, any external `dash-sdk/serde` consumer decoding a pre-PR persisted blob (JS/Swift wrappers, downstream Rust caches, on-disk snapshots) fails with `missing field "as_of_height"` instead of loading the documented pin-0 fallback. The in-tree wallet-changeset path is safe because `changeset/serde_adapters.rs::Wire` uses `#[serde(default)]`, but the public SDK type is not routed through that adapter. Adding the attribute aligns the derive with the documented contract. Carried forward from prior review; the latest delta touched this file only for an intra-doc link and did not address this.

In `packages/rs-sdk/src/platform/address_sync/mod.rs`:
- [SUGGESTION] packages/rs-sdk/src/platform/address_sync/mod.rs:958-984: pending_unknown accumulates foreign-wallet changes without a hard cap
  The unknown-address branch clones every unmatched recent/compacted balance change into `pending_unknown` and the in-line comment (964–976) intentionally leaves the buffer unbounded, relying on a one-shot warn at PENDING_UNKNOWN_WARN_THRESHOLD (1000). The recent/compacted streams are global to the chain, so on a busy shared chain most entries belong to other wallets and remain buffered until the end-of-pass refresh intersects them with `unknown_keys`. `OwnedBalanceOp::Compacted` carries a `Vec` of arbitrary length, so for mobile/embedded consumers on a long catch-up window this is a real memory-pressure surface; a stale/eclipsing DAPI route could amplify it. The author's comment correctly notes that a bounded-memory fix must re-query rather than merely stash keys, so this remains a hardening suggestion — but the warn hook only observes the condition, it does not prevent exhaustion. Consider a hard byte-count cap that triggers a mid-pass drain + refresh, or draining `pending_unknown` after each pagination page rather than end-of-pass.

@QuantumExplorer QuantumExplorer merged commit a7df9d6 into v4.1-dev Jul 6, 2026
23 of 24 checks passed
@QuantumExplorer QuantumExplorer deleted the claude/xenodochial-mcnulty-b5c470 branch July 6, 2026 09:29

@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

Incremental + cumulative review at 4dd20980. The latest delta a93f7caf..4dd20980 is a broad v4.1-dev merge and does not change the ADDR-09 height-pin files. Both prior findings remain STILL VALID and are carried forward; the verifier also kept one additional cumulative current-head suggestion around stale absence clearing in the in-memory provider state.

Source: reviewers = claude general opus (ok); codex general gpt-5.5 (ok); claude security-auditor opus (ok); codex security-auditor gpt-5.5 (ok); claude rust-quality opus (ok); codex rust-quality gpt-5.5 (ok); claude ffi-engineer opus (ok); codex ffi-engineer gpt-5.5 (ok); verifier = claude opus (ok); specialists = security-auditor, rust-quality, ffi-engineer; policy gate = review_policy.enforce_backport_prereq_policy.

3 suggestion(s)

Prior Reconciliation

  • prior-1 STILL VALID: packages/rs-sdk/src/platform/address_sync/types.rs:52 still derives serde behind the serde feature, and as_of_height at line 80 still lacks #[serde(default)]; the latest delta did not modify this file.
  • prior-2 STILL VALID: packages/rs-sdk/src/platform/address_sync/mod.rs:958-984 still appends unmatched global changes into pending_unknown with only the one-shot warning threshold and no hard cap; the latest delta did not modify this file.

Carried-Forward Prior Findings

  • [SUGGESTION] packages/rs-sdk/src/platform/address_sync/types.rs:78-80: AddressFunds::as_of_height breaks serde compatibility for legacy blobs
    AddressFunds derives Serialize/Deserialize behind the serde feature, but the new as_of_height: u64 field has no #[serde(default)]. The field docs define pin 0 as the legacy/unknown-provenance fallback, so external dash-sdk/serde consumers decoding pre-PR blobs can fail with missing field "as_of_height" instead of loading the documented pin-0 behavior. The in-tree wallet changeset adapter is safe, but the public SDK type is not routed through that adapter.
  • [SUGGESTION] packages/rs-sdk/src/platform/address_sync/mod.rs:958-984: pending_unknown accumulates foreign-wallet changes without a hard cap
    The unknown-address branch clones every unmatched recent/compacted balance change into pending_unknown until the end-of-pass refresh. Recent/compacted streams are global to the chain, so busy shared-chain catch-up can buffer mostly foreign-wallet changes; OwnedBalanceOp::Compacted can also carry an arbitrary-length Vec. The existing warning observes the condition but does not bound memory. A byte-count cap with mid-pass drain/refresh, or per-page draining, would close the hardening gap.

New Findings In Latest Delta

  • None. The latest delta is a merge from v4.1-dev; it does not touch the two carried-forward ADDR-09 files.

Additional Cumulative Current-Head Finding

  • [SUGGESTION] packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs:840-847: Stale absence pass still clears in-memory found entries even when durable rows are pin-fresher
    In sync_finished, the found merge enforces height-pin freshness, but the following absent-removal is not pin-guarded: every address in account_scratch.absent is removed from account_state.found, and on_address_absent has already zeroed the managed-account balance cache. In the stale-node race this PR targets, disk keeps the fresher pinned row, but the active in-memory session can lose the funded address until reload or a later repair pass. The code comment documents this tradeoff, so this remains a suggestion rather than a blocker; consider skipping removal when the committed pin is fresher than the pass, mirroring the found-merge rule.
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-sdk/src/platform/address_sync/types.rs`:
- [SUGGESTION] packages/rs-sdk/src/platform/address_sync/types.rs:78-80: AddressFunds::as_of_height breaks serde compatibility for legacy blobs
  `AddressFunds` derives `Serialize`/`Deserialize` behind the `serde` feature (types.rs:52), but the new `as_of_height: u64` field at line 80 has no `#[serde(default)]`. The field's own doc-comment on lines 78-79 documents pin `0` as "unknown provenance" -- the legacy value for rows persisted before the pin existed. Without the serde default, any external `dash-sdk/serde` consumer decoding a pre-PR persisted blob fails instead of loading at the documented pin-0 fallback.

In `packages/rs-sdk/src/platform/address_sync/mod.rs`:
- [SUGGESTION] packages/rs-sdk/src/platform/address_sync/mod.rs:958-984: pending_unknown accumulates foreign-wallet changes without a hard cap
  The unknown-address branch clones every unmatched recent/compacted balance change into `pending_unknown` until end-of-pass refresh. The recent/compacted streams are global to the chain, so on a busy shared chain most entries belong to foreign wallets and remain buffered indefinitely. The one-shot warning observes but does not bound memory.

In `packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs:840-847: Stale absence pass still clears in-memory found entries even when durable rows are pin-fresher
  The found merge enforces height-pin freshness, but the absent-removal path is not pin-guarded. A stale-node absence can remove the in-memory found entry and zero the managed-account balance cache even though durable storage keeps the fresher pinned row.

Comment on lines +840 to 847
// Absence carries no per-entry height, so a stale pass's
// removal is NOT pin-guarded here; the persist diff skips
// the durable zero for fresher-pinned rows, and the next
// pass reconstructs the in-memory entry from a full
// replay (base 0, pin 0 → every delta applies).
for absent_addr in &account_scratch.absent {
account_state.found.remove(absent_addr);
}

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.

🟡 Suggestion: Stale absence pass still clears in-memory found entries even when durable rows are pin-fresher

In sync_finished, the found merge at lines 832–838 correctly enforces height-pin freshness (the fresher committed entry wins). The following absent-removal at lines 845–847 is NOT pin-guarded: every address in account_scratch.absent is removed from account_state.found, and on_address_absent (line 788) has already zeroed the managed-account balance cache. In the stale-node race this PR is designed to handle, disk keeps the fresher pinned row (the persist diff at compute_address_balance_diff guards it), but the in-memory session loses the funded address. Spending/selection reads current_balances() from that same in-memory found map, so a valid funded address can disappear until reload or a later repair pass. The author's comment at lines 840–844 documents this as intentional — noting the next pass will reconstruct from full replay — so this is a hardening suggestion rather than a blocker; consider skipping the removal for entries whose committed pin is fresher than the pass, mirroring the found-merge rule.

source: ['codex']

bezibalazs added a commit that referenced this pull request Jul 6, 2026
…(SH-06 credit)

Root cause of the on-device SH-06 gap (a confirmed unshield credit never
reflected app-side): build_wallet_restore_entry hardcoded
platform_address_balances to null/0, so persisted platform-address
balances were never rehydrated into the Rust provider on load. After a
restart the BLAST loop trusts its watermark but has an empty per-account
balance map; the tree-scan re-pins each address at checkpoint_height and
the #4019 ADDR-09 gate (op_height <= as_of_height) then discards the
credit delta — the balance stays 0 forever. iOS is unaffected (its Swift
loadCachedBalances feeds (balance, as_of_height) back), which is why the
fix belongs in the Android load path only, not core.

- PlatformWalletPersistenceHandler.buildWalletRestoreData now reads the
  wallet's platform_addresses Room rows into a new
  platformAddressBalances restore list (asOfHeight = lastSeenHeight).
- NativePersistenceBridge gains PlatformAddressBalanceRestoreData +ing
  the WalletRestoreData field; JNI build_wallet_restore_entry marshals
  it into WalletRestoreEntryFFI.platform_address_balances via the
  staging-vecs discipline (AddressBalanceEntryFFI is Copy POD; sealed +
  freed like ignored_senders).
- as_of_height round-trips verbatim, so the height-pin still prevents the
  #4019 double-count; a genuine 0 ("unknown provenance") self-heals to
  the first pinned absolute on the next sync. Round-trip unit test added.

Not a distinct pre-restart bug: the persisted-balance map is the backstop
that keeps a tracked address in the sync pending-set once it falls out of
the live gap window; restoring it fixes both the post-restart and the
out-of-window paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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