fix(platform-wallet): data-integrity follow-ups from the #3990 sync review#4008
Conversation
…eview Addresses the CodeRabbit data-integrity findings on the v4.0-dev→v4.1-dev sync (#3990). #3991 tried to fix them but was orphaned when its base (the sync branch) merged, and its diff no longer applied — the reconcile seam grew a `credited_outputs` arg (ADDR-09, #4004/#4005) since. Each fix here is re-derived against current v4.1-dev. - file_store purge: `purge_wallet` / `purge_all_subwallets` now delete the durable `shielded_pending_spends` rows too (they were leaving stale redrive rows that rehydrate ghost reservations after a Clear / unregister). `reset_commitment_tree` deliberately does NOT touch them — a redrive is broadcast state, not tree state. - `SubwalletState::mark_spent` now resolves the reservation + redrive whenever the nullifier is KNOWN, not only on the first unspent→spent transition, so a note restored already-spent still clears its rehydrated redrive. Returns `MarkSpentOutcome { newly_spent, dropped_redrives }` so the file store mirrors the exact SQLite deletion even on the already-spent path (targeted by activity id — zero SQLite work on the common no-redrive path). - `reconcile_address_infos` gains a `_with_persistence` variant reporting whether the changeset was durably stored; `fund_from_asset_lock` gates `consume_asset_lock` on it, so an irreversible Consumed lock is never paired with balance rows that failed to persist (which would under-budget the next spend after a restart). The public signature is unchanged (thin discarding wrapper) so the other seven callers don't churn. - `commit_reconciliation` index conflict: a CREDIT whose derivation index is already paired to a different address is now dropped outright (committing it would evict the pairing or persist a non-round-trippable seed); a REMOVAL still zeroes `found` and is emitted (so the durable zero-out lands and a stale balance can't resurrect) but skips the bijection merge. - `reset_sync_state` holds the provider write lock across BOTH the provider reset and the managed-account balance clear (wallet-manager write nested inside, matching the seam's lock order), so the reset is atomic against a concurrent sync / reconciliation instead of leaving a window between two separate lock scopes. - `register_from_addresses` treats the post-acceptance local `add_identity` as best-effort (warn, don't propagate), so a local persistence failure no longer suppresses the `(identity, address_infos)` return the composite needs to reconcile spent funding addresses. - Swift Clear races: `PlatformBalanceSyncService` gains a `balanceSnapshotGeneration` token (a snapshot in flight when a Clear runs is dropped instead of republishing pre-clear balances) and an `isClearing` flag; `CoreContentView` gates Clear + Sync Now on it. Verified: `cargo test -p platform-wallet` 244 (default) + 362 (shielded, incl. 5 new regression tests: purge-clears-redrive, mark_spent-on-restored-spent-note, index-conflict removal-emit + credit-drop); `cargo fmt --all --check` and `cargo clippy` clean. The Swift changes are app-level (no FFI signature change) and are compiled by the Swift SDK CI check. Original review by CodeRabbit on #3990; fix approach cross-referenced with @thepastaclaw's #3991. Drops the anchor-probe skip-vs-break finding (an earlier adversarial verify on #3977 refuted the harmful variant) and its collateral FFI error code 20. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 8 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (8)
✨ Finishing Touches🧪 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 |
|
✅ Review complete (commit 153e51c) |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
PR delivers narrow data-integrity follow-ups (durable redrive lifecycle, persistence-gated asset-lock consume, address-reconciliation index-conflict handling, Swift Clear/Sync race gating). Verification against 7ce9fae confirms invariants match the extensive rationale comments; no blocking issues. Two suggestion-level polish items on the shielded-store purge ordering and the fund_from_asset_lock non-persisted return signal are worth flagging.
🟡 2 suggestion(s)
Source: reviewers claude-general/security-auditor/rust-quality/ffi-engineer (opus); codex-general/security-auditor/rust-quality/ffi-engineer (gpt-5.5[high], failed/unparseable); verifier claude (opus).
🤖 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/shielded/file_store.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/file_store.rs:638-665: Durable redrive DELETE runs after the in-memory purge, so a SQL failure leaves memory and disk inconsistent
`purge_wallet` and `purge_all_subwallets` clear `self.subwallets` before running `DELETE FROM shielded_pending_spends`. If the SQL DELETE fails, both functions return `Err`, but the in-memory purge has already committed — the exact memory/disk divergence this PR is fixing on the `mark_spent` path. Two consistent options: (a) run the SQL DELETE first, then purge memory, so an `Err` return leaves both stores unchanged (fail-atomic); or (b) match the `mark_spent` / `clear_pending` error posture in this file — `warn!` the SQL failure and return `Ok(())` because the in-memory purge is already authoritative. Either is preferable to the current shape, where callers see `Err` and cannot tell whether the memory purge landed.
In `packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs:320-338: `fund_from_asset_lock` returns `Ok(cs)` identically on the durable-success and skip-consume paths
When `persisted == false`, the function returns `Ok(cs)` — the same shape as the fully-successful path — after logging at `error!` and skipping `consume_asset_lock`. The rationale (Platform will deterministically reject a Resume with 'lock already consumed') is correct, but the return signal collapses two very different outcomes: 'top-up landed and is durable' vs. 'top-up applied in memory only, lock stays Resumable, balance may resurface stale after restart'. Downstream UI aggregation and telemetry can't distinguish the two. A distinct variant or a boolean on the returned changeset would let callers surface the discrepancy rather than treat it as full success. Non-blocking — current behavior is correct, just opaque.
There was a problem hiding this comment.
Code Review
Latest push (7ce9fae → abf952e) is a pure cargo fmt on two test assertions in platform_addresses/provider.rs — no behavioral change. Two prior suggestion-level findings from the shielded-store purge and fund_from_asset_lock persistence-gated paths still apply at the current head. No new findings from the delta; no blocking issues.
🟡 2 carried-forward suggestion(s) | 0 new latest-delta findings
Prior finding reconciliation
- STILL VALID: packages/rs-platform-wallet/src/wallet/shielded/file_store.rs:638-665 — purge_wallet / purge_all_subwallets mutate memory before the durable DELETE, so a SQL failure returns Err with memory already purged
- STILL VALID: packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs:320-338 — fund_from_asset_lock returns Ok(cs) identically on the durable-success and skip-consume paths
Latest delta
7ce9faeb..abf952ea only changes packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs formatting/test assertion layout. No new latest-delta findings.
Source: reviewers claude-general/security-auditor/rust-quality/ffi-engineer (opus, ok); codex-general/security-auditor/rust-quality/ffi-engineer (gpt-5.5[high], failed/unparseable: ACP advertises gpt-5.5 not gpt-5.5[high]); verifier claude (opus).
🤖 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.
Carried-forward prior findings still valid at abf952ea:
In `packages/rs-platform-wallet/src/wallet/shielded/file_store.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/file_store.rs:638-665: purge_wallet / purge_all_subwallets mutate memory before the durable DELETE, so a SQL failure returns Err with memory already purged
`purge_wallet` clears `self.subwallets` at line 642 before executing `DELETE FROM shielded_pending_spends WHERE wallet_id = ?1` at line 649; `purge_all_subwallets` does the same at line 658 before the unscoped DELETE at line 662. If the SQL execute fails, both functions map the error and return `Err(...)` — but the in-memory purge has already committed. Callers cannot distinguish 'nothing happened' from 'memory purged, disk unchanged', which is exactly the memory/disk divergence this PR is otherwise fixing on the mark_spent path. Two consistent fixes: (a) run the SQL DELETE first, then purge memory only on success (fail-atomic); or (b) match the log-don't-abort posture used earlier in this same file for `mark_spent` and `clear_pending` — `warn!` the SQL failure and return `Ok(())` because the in-memory purge is already authoritative and the durable rows will be reconciled on the next open.
In `packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs:320-338: fund_from_asset_lock returns Ok(cs) identically on the durable-success and skip-consume paths
When `persisted == false`, the function returns `Ok(cs)` at line 337 after logging at `error!` and skipping `consume_asset_lock` — the same return shape as the fully-successful path further down. The persistence-gate itself is the correct invariant (a Consumed lock over non-durable balances would under-budget after restart), and Platform will deterministically reject a Resume with 'lock already consumed', but the collapsed return signal makes 'top-up landed and is durable' indistinguishable from 'top-up applied in memory only, lock stays Resumable, balance may resurface stale after restart'. Downstream UI aggregation and telemetry cannot detect the degraded outcome. A distinct variant on the returned changeset — or a `persisted: bool` companion mirroring the `_with_persistence` reconciliation seam this already calls — would let callers surface the discrepancy rather than treat it as full success. Non-blocking; behavior is correct, just opaque.
…taclaw - purge_wallet/purge_all_subwallets: run the SQLite redrive DELETE BEFORE the in-memory purge, so an Err leaves both stores untouched (fail-atomic) instead of a memory purge the caller can't observe. - clear_redrive: delegate the durable delete to delete_redrive_row instead of carrying a second copy of the same SQL. - reset_sync_state: fix the stale doc paragraph that still claimed the two locks are taken sequentially — the wallet-manager write is now nested inside the provider write. - PlatformBalanceSyncService: bump balanceSnapshotGeneration in reset()/configure() too — a snapshot in flight across a wallet or network switch strongly captured the old wallet handle and would publish its balances over the new configuration. - performSync(): guard on isClearing at the service level — pull-to- refresh and the post-submit resyncs bypass CoreContentView's button gating and could start a sync mid-Clear. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Self-review results (8 finder angles × adversarial verification)Ran a full multi-angle review of this PR. Fixed in
Deferred (tracked separately, both pre-existing):
Verified-refuted (for the record): the vacuous 🤖 Generated with Claude Code |
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Latest push (abf952ea → 153e51c6) fixes one of the two carried-forward findings and leaves the other still valid. purge_wallet / purge_all_subwallets now delete durable redrive rows before mutating memory, so the prior fail-atomicity concern is fixed. fund_from_asset_lock still returns the same Ok(cs) for durable success and the persistence-failure skip-consume path, so that suggestion carries forward. No new latest-delta findings and no blockers.
🟡 1 carried-forward suggestion(s) | 0 new latest-delta findings
Prior finding reconciliation
- FIXED:
packages/rs-platform-wallet/src/wallet/shielded/file_store.rs:638-665— purge ordering now runs SQL before memory in both purge paths. - STILL VALID:
packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs:320-338—fund_from_asset_lockstill returnsOk(cs)identically on durable-success and skip-consume paths.
Latest delta
abf952ea..153e51c6 touches wallet.rs, shielded/file_store.rs, and PlatformBalanceSyncService.swift. It fixes the shielded purge ordering, updates the reset-sync lock-order doc, and adds Swift-side snapshot/clear-sync hardening. No new latest-delta findings.
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).
🤖 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.
Carried-forward prior findings still valid at 153e51c6:
In `packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs:320-338: fund_from_asset_lock returns Ok(cs) identically on the durable-success and skip-consume paths
Carried forward from prior-2; unchanged at 153e51c6 (git diff of this file across abf952ea..HEAD is empty). When `persisted == false`, the function logs at `error!`, skips `consume_asset_lock`, and returns `Ok(cs)` at line 337 — the same return shape as the fully-successful path further down. The persistence-gate itself is the correct invariant (a Consumed lock over non-durable balances would under-budget after restart, and Platform will deterministically reject a Resume with 'lock already consumed'), but the collapsed return signal makes 'top-up landed and is durable' indistinguishable from 'top-up applied in memory only, lock stays Resumable, balance may resurface stale after restart'. The signal loss propagates through the C FFI (`rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs:116-119` converts to `PlatformWalletFFIResult::ok()`) and through Swift (`ManagedPlatformAddressWallet.swift:614-617` decodes as a successful `[UpdatedBalance]`), so downstream UI aggregation and telemetry cannot detect the degraded outcome. A distinct variant on the returned changeset — or a `persisted: bool` companion mirroring the `_with_persistence` reconciliation seam this already calls — would let callers surface the discrepancy rather than treat it as full success. Consistent with the fail-atomic reordering just applied to `purge_wallet` / `purge_all_subwallets`, this is the last place in the data-integrity series where the caller can't distinguish partial-success from full-success. Non-blocking; behavior is correct, just opaque.
Issue being fixed or feature implemented
Addresses the CodeRabbit data-integrity findings raised on the v4.0-dev → v4.1-dev sync (#3990). Those findings are against content that is now on
v4.1-dev; they were never fixed — #3991 prepared fixes but was orphaned/closed when its base (the sync branch) merged, and its diff no longer applies because the reconcile seam grew acredited_outputsargument in the ADDR-09 work (#4004/#4005) since. Each fix here is re-derived against currentv4.1-devand independently verified, not cherry-picked.What was done?
Shielded store (durable redrive lifecycle — gaps from #3988):
purge_wallet/purge_all_subwalletsnow also delete the durableshielded_pending_spendsrows. They were only clearing in-memory state, so a Clear / unregister left stale redrive rows that rehydrate ghost reservations on the next open.reset_commitment_treedeliberately does not touch them — a redrive is broadcast state, not tree state.SubwalletState::mark_spentresolves the reservation + redrive whenever the nullifier is known, not only on the firstunspent→spenttransition — so a note restored already spent still clears its rehydrated redrive. It returnsMarkSpentOutcome { newly_spent, dropped_redrives }so the file store mirrors the exact SQLite deletion on the already-spent path too (targeted by activity id, so the common no-redrive path issues zero SQLite work).Address reconciliation seam:
reconcile_address_infosgains a_with_persistencevariant that reports whether the changeset was durably stored.fund_from_asset_lockgatesconsume_asset_lockon it: an irreversibleConsumedlock is never paired with balance rows that failed to persist (which would under-budget the next spend after a restart). The public signature is unchanged (thin discarding wrapper), so the other seven callers don't churn.commit_reconciliationindex conflict: a credit whose derivation index already maps to a different address is dropped outright (committing it would evict the pairing viaBiBTreeMap::insert, orphaning another address'sfound, or persist a seedcurrent_balancescan't round-trip); a removal still zeroesfoundand is emitted (so the durable zero-out lands and a stale balance can't resurrect) but skips the bijection merge.reset_sync_stateholds the provider write lock across both the provider reset and the managed-account balance clear (wallet-manager write nested inside, matching the seam's provider→wallet-manager lock order), so the reset is atomic against a concurrent sync / reconciliation rather than leaving a window between two separate lock scopes.Identity registration:
register_from_addressestreats the post-acceptance localadd_identityas best-effort (warn, don't propagate) — mirroring the transfer / top-up flows — so a local persistence failure no longer suppresses the(identity, address_infos)return the composite needs to reconcile spent funding addresses.SwiftExampleApp Clear races:
PlatformBalanceSyncServicegains abalanceSnapshotGenerationtoken: arefreshBalanceSnapshotthat was in flight when a Clear runs is dropped instead of republishing pre-clear balances over the cleared UI. It also gains anisClearingflag, andCoreContentViewgates both Clear and Sync Now on it (Clear is fire-and-forget, so it could otherwise interleave with the Rust reset + SwiftData wipe).How Has This Been Tested?
cargo test -p platform-wallet --lib→ 244 passed (default features);--features shielded→ 362 passed.purge_clears_durable_redrive_rows_but_tree_reset_does_not,mark_spent_on_restored_spent_note_clears_durable_redrive,commit_reconciliation_index_conflict_still_emits_removal,commit_reconciliation_index_conflict_drops_credit.cargo fmt --all -- --checkclean;cargo clippy -p platform-wallet --features shieldedclean (only pre-existing workspace-profile notes).Not covered (deferred / reasoned): the lock-ordering (
reset_sync_state) and best-effort (register_from_addresses) fixes are correctness-by-construction and covered by the existing integration suites rather than dedicated unit tests; the asset-lock consume gate's persist-failure branch needs an erroring-persister harness and is covered by reasoning + the plain-path integration test.Breaking Changes
None.
MarkSpentOutcomeis apub(super)internal type; the publicreconcile_address_infossignature is unchanged.Notes for reviewers
Original findings by CodeRabbit on #3990; fix approach cross-referenced with @thepastaclaw's #3991. I dropped the anchor-probe "skip-vs-break" finding — an earlier adversarial verification on #3977 refuted the harmful variant (deeper probes fail identically; the "fix" would make every routine mid-block spend probe all 100 depths) — and with it the collateral
ErrorShieldedMerkleWitnessUnavailable = 20FFI code, since not adding an ABI code we don't need is strictly better.Checklist:
🤖 Generated with Claude Code