Uh oh!
There was an error while loading. Please reload this page.
fix(platform-wallet): preserve persisted wallet state through seedless rehydration - #3980
Conversation
…s rehydration Review follow-ups for the watch-only rehydration PR: - Carry the FFI persister's fully-restored ManagedWalletInfo across load() as ClientWalletStartState::core_wallet_info (still keyless: no Wallet, no seed). The manager consumes the snapshot directly, so per-account UTXO/tx-record attribution, exact pool contents (derived-but-unused deep addresses stay in the SPV watch set), and per-index used flags survive a restart verbatim - and the second eager gap-window derivation on the launch path disappears. The core_state/used_core_addresses projection remains as the fallback for persisters that cannot reconstruct a snapshot (SQLite until #3968). - Run the rehydration mark<->refill loop to a fixpoint so a previously used address in the wedge zone (past the discovery horizon, within gap-refill reach) is re-marked used instead of coming back pool-visible as fresh (address-reuse leak; regression test added). - Hoist the load_from_persistor idempotency check ahead of per-wallet reconstruction so repeat loads skip the rebuild instead of discarding it at the WalletExists arm; validate a carried snapshot's wallet_id/network against its row and skip mismatches as corrupt. - Consume LoadOutcomeFFI in Swift loadFromPersistor: skipped wallets are logged, surfaced via lastLoadSkippedWallets, excluded from the get_wallet loop (no more spurious NotFound in lastError), and the outcome is freed via platform_wallet_load_outcome_free. - Drop RehydrateRowError (variant-for-variant mirror of CorruptKind); build_watch_only_wallet returns CorruptKind directly. - Reuse vec_to_ptr for the FFI skipped array. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ 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 51cd9c9) |
There was a problem hiding this comment.
Code Review
PR #3980 cleanly addresses the four seedless-rehydration defects from #3692 (attribution loss, watch-set shrinkage, wedge-zone address reuse, Swift discarding LoadOutcome). Snapshot path is guarded by wallet_id/network fail-closed, mark↔refill fixpoint is correctly bounded on a finite restored-address set, FFI ownership/free contract is symmetric with a zero-init on early returns, and Swift now consumes and frees the outcome. No blockers; a handful of non-blocking suggestions and nitpicks — mostly about type-safety of the snapshot-vs-projection union, the DecodeError reuse for identity-mismatch, and Swift/Rust constant coupling.
🟡 5 suggestion(s) | 💬 3 nitpick(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs:41-66: Snapshot vs projection fields form an implicit-invariant union
`ClientWalletStartState` now carries three overlapping sources of truth for core state: `core_wallet_info: Option<Box<ManagedWalletInfo>>`, `core_state: CoreChangeSet`, and `used_core_addresses: Vec<Address>`. The rule "if `core_wallet_info` is `Some`, ignore the other two" lives only in the load-loop match arm and a doc comment; a persister that populates both a snapshot and a projection would silently discard the projection with no compile-time signal. A tagged enum such as `enum CoreLoadState { Snapshot(Box<ManagedWalletInfo>), Projection { core_state: CoreChangeSet, used_core_addresses: Vec<Address> } }` would make the mutual exclusion type-level and remove the FFI persister's need to `Default::default()` the unused fields. Not a bug today but locks in the invariant the docs are pleading for, right before #3968 adds a second persister backend.
In `packages/rs-platform-wallet/src/manager/load.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/manager/load.rs:162-220: Snapshot path validates wallet_id/network but not manifest ↔ snapshot account consistency
When `core_wallet_info: Some(info)` is consumed, the manager stitches a watch-only `Wallet` built from `account_manifest` together with `ManagedWalletInfo.accounts` from the snapshot, but only `wallet_id`/`network` are cross-checked. If a future persister populates the manifest and the snapshot from divergent sources (extra CoinJoin account only in one, mis-ordered types, etc.) `Wallet.accounts` and `wallet_info.accounts` disagree silently: one drives scanning/derivation while the other drives balances/UTXOs. The current FFI persister derives both from the same in-memory state so this isn't user-triggerable today, but `PlatformWalletPersistence` is a public trait and #3968 will add a SQLite backend. Even a cheap structural check (assert the account-type sets match) — or at minimum a doc line making the assumption explicit — would extend the existing wallet_id/network fail-closed pattern to the account collection.
- [SUGGESTION] packages/rs-platform-wallet/src/manager/load.rs:175-190: Snapshot wallet_id/network mismatch reuses CorruptKind::DecodeError, obscuring the failure mode
A wallet_id/network mismatch on the persisted `core_wallet_info` snapshot is reported via `CorruptKind::DecodeError`, which the FFI maps to `LOAD_SKIP_REASON_DECODE_ERROR` (102) — the same code as a genuine bincode failure. These are semantically distinct: one means the persisted bytes are unreadable, the other means a persister returned a snapshot for the wrong row (a stronger data-integrity signal, e.g. persister row mixup). `CorruptKind` is `#[non_exhaustive]`, so a dedicated `SnapshotIdentityMismatch` variant (plus a new `LOAD_SKIP_REASON_*` code) is a low-cost, backward-compatible change that would make debug logs and Swift-side triage clearer.
In `packages/rs-platform-wallet/src/manager/rehydrate.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/manager/rehydrate.rs:462-478: New RehydrationPoolTypeMismatch runtime guard is not exercised by tests
The `debug_assert!(pool.pool_type == probe.pool_type)` was promoted to a runtime fail-closed `Err(RehydrationPoolTypeMismatch { .. })` in release builds — good hardening — but no test verifies that a mis-ordered probe/pool pair actually produces this error. Because `probes` is built from `address_pools()` and the real pools come from `address_pools_mut()` on the same account, the mismatch is only reachable via divergence between the two `key_wallet` accessors. A small structural-invariant test (or a `debug_assert_eq!` alongside the `Err`) would catch a future upstream regression. Same applies to the pre-existing `RehydrationPoolMismatch` length check.
In `packages/rs-platform-wallet/tests/rehydration_load.rs`:
- [SUGGESTION] packages/rs-platform-wallet/tests/rehydration_load.rs:1: No test combines snapshot-mismatch skip with a co-occurring successful load
`rt_snapshot_wallet_id_mismatch_is_skipped` verifies the mismatched row is skipped in isolation, and `rt_corrupt_row_skipped_and_other_loads` verifies a corrupt-manifest skip alongside a successful load in the same batch. There's no equivalent combined-batch test for the new snapshot-mismatch skip path (skipped wallet + a second healthy wallet loading successfully in the same `load_from_persistor` call). Extending the existing pattern would confirm the mismatch skip doesn't poison siblings in the batch.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Records the agreed model: platform-wallet is the single writer of wallet-state tables and the authority for state transitions; the persisted store is the durable history and the client's read model (display never blocks on load/unlock); the store schema is a versioned public contract; load() consumes the store verbatim (no re-derivation); persist errors are hard errors because local-only state (manifest, used-flags, metadata) is unrecoverable by rescan; load is seedless. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Latest push (e66fbf5..51cd9c9) is documentation-only — a 93-line 'Persistence architecture' section added to packages/rs-platform-wallet/README.md, with no changes to Rust or Swift source. All 8 prior findings remain valid at unchanged line ranges. One new suggestion from codex-general (projection-fallback path routes used addresses to only the first funds account) is included. No in-scope blockers.
🟡 8 suggestion(s) | 💬 5 nitpick(s)
Findings not posted inline (4)
These findings could not be anchored to the current diff, but they are still part of this review.
- [SUGGESTION]
packages/rs-platform-wallet/src/manager/rehydrate.rs:223-264: Projection fallback only restores used flags on the first funds account —used_pool_addressesis documented as a flattened snapshot across all accounts/pools, but both replay branches at lines 223-249 and 250-263 route the entire set throughwallet_info.accounts.all_funding_accounts_mut().into_iter().next()and callextend_pools_for_restored_addresseson only tha... - [SUGGESTION]
packages/rs-platform-wallet/src/manager/rehydrate.rs:462-478: New RehydrationPoolTypeMismatch runtime guard is not exercised by tests — STILL VALID (prior-4, code unchanged). Thedebug_assert!(pool.pool_type == probe.pool_type)was promoted to a runtime fail-closedErr(PlatformWalletError::RehydrationPoolTypeMismatch { .. })in release builds (good hardening), but no test verifies that a mis-ordered probe/pool pair actually p... - [NITPICK]
packages/rs-platform-wallet-ffi/src/manager.rs:384-407: Document that non-null out_outcome must be zero-initialised or previously freed — STILL VALID (prior-6, code unchanged).std::ptr::write(out_outcome, LoadOutcomeFFI { .., skipped: null_mut() })unconditionally overwrites the destination without inspecting or freeing any priorskippedarray —ptr::writedoes not run Drop. If a non-Swift caller reused the same `LoadOutcome... - [NITPICK]
packages/rs-platform-wallet-ffi/src/manager.rs:463-474: platform_wallet_load_outcome_free leaves loaded_count non-zero after freeing — STILL VALID (prior-8, code unchanged). The free function clearsskipped_count/skippedfor idempotence but leavesloaded_countuntouched. The doc string says the free is idempotent and nulls the pointer, so callers who inspect the struct post-free see a staleloaded_count. No memory-safety...
Carried-forward findings already raised (4)
These findings were not re-posted as new inline comments because an existing review thread already covers them.
- [SUGGESTION] (deduped existing open thread)
packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs:41-97: Snapshot vs projection fields form an implicit-invariant union — STILL VALID (prior-1, code unchanged).ClientWalletStartStatecarries three overlapping sources of truth for core state:core_wallet_info: Option<Box<ManagedWalletInfo>>(snapshot) andcore_state: CoreChangeSet+used_core_addresses: Vec<Address>(projection). The 'ifcore_wallet_infoi... - [SUGGESTION] (deduped existing open thread)
packages/rs-platform-wallet/src/manager/load.rs:162-220: Snapshot path validates wallet_id/network but not manifest↔snapshot account consistency — STILL VALID (prior-2, code unchanged). Whencore_wallet_info: Some(info)is consumed, the manager stitches a watch-onlyWalletbuilt fromaccount_manifesttogether withManagedWalletInfo.accountsfrom the snapshot, but onlywallet_id/networkare cross-checked. If a future persister po... - [SUGGESTION] (deduped existing open thread)
packages/rs-platform-wallet/src/manager/load.rs:175-190: Snapshot wallet_id/network mismatch reuses CorruptKind::DecodeError, obscuring the failure mode — STILL VALID (prior-3, code unchanged). A wallet_id/network mismatch on the persistedcore_wallet_infosnapshot is reported viaCorruptKind::DecodeError(format!(...)), which the FFI maps toLOAD_SKIP_REASON_DECODE_ERROR(102) — the same code as a genuine bincode failure. These are semantical... - [NITPICK] (deduped existing open thread)
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift:340-349: Swift reasonDescription hardcodes wire numbers instead of importing LOAD_SKIP_REASON_ constants* — STILL VALID (prior-7, code unchanged). Rust exportsLOAD_SKIP_REASON_MISSING_MANIFEST(100),LOAD_SKIP_REASON_MALFORMED_XPUB(101),LOAD_SKIP_REASON_DECODE_ERROR(102),LOAD_SKIP_REASON_CORRUPT_OTHER(199),LOAD_SKIP_REASON_OTHER(200) aspub const u32specifically so consumers can re...
🤖 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/manager/rehydrate.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/manager/rehydrate.rs:223-264: Projection fallback only restores used flags on the first funds account
`used_pool_addresses` is documented as a flattened snapshot across all accounts/pools, but both replay branches at lines 223-249 and 250-263 route the entire set through `wallet_info.accounts.all_funding_accounts_mut().into_iter().next()` and call `extend_pools_for_restored_addresses` on only that first funds account. A fallback persister that cannot provide `core_wallet_info` but does supply used addresses for a non-first account (e.g. CoinJoin, DashPay) will never probe those addresses against the matching account xpub, so they remain `used = false` and can be handed out again after seedless load — defeating the exact address-reuse guard this PR added via `used_pool_addresses`. Not user-triggerable today because the in-tree FFI persister always sets `core_wallet_info` and SQLite does not hydrate wallets yet (dashpay/platform#3968), but the public fallback contract is incorrect. Fix by grouping `addresses_to_mark` by their owning account (or dispatching per pool) before calling `extend_pools_for_restored_addresses`.
- [SUGGESTION] packages/rs-platform-wallet/src/manager/rehydrate.rs:462-478: New RehydrationPoolTypeMismatch runtime guard is not exercised by tests
STILL VALID (prior-4, code unchanged). The `debug_assert!(pool.pool_type == probe.pool_type)` was promoted to a runtime fail-closed `Err(PlatformWalletError::RehydrationPoolTypeMismatch { .. })` in release builds (good hardening), but no test verifies that a mis-ordered probe/pool pair actually produces this error. Because `probes` is built from `address_pools()` and the real pools come from `address_pools_mut()` on the same account, the mismatch is only reachable via divergence between the two `key_wallet` accessors. A small structural-invariant test would catch a future upstream regression. Same applies to the pre-existing `RehydrationPoolMismatch` length check at lines 456-461.
- [SUGGESTION] packages/rs-platform-wallet/src/manager/rehydrate.rs:223-264: Projection fallback only restores used flags on the first funds account
`used_pool_addresses` is documented as a flattened snapshot across all accounts/pools, but both replay branches at lines 223-249 and 250-263 route the entire set through `wallet_info.accounts.all_funding_accounts_mut().into_iter().next()` and call `extend_pools_for_restored_addresses` on only that first funds account. A fallback persister that cannot provide `core_wallet_info` but does supply used addresses for a non-first account (e.g. CoinJoin, DashPay) will never probe those addresses against the matching account xpub, so they remain `used = false` and can be handed out again after seedless load — defeating the exact address-reuse guard this PR added via `used_pool_addresses`. Not user-triggerable today because the in-tree FFI persister always sets `core_wallet_info` and SQLite does not hydrate wallets yet (dashpay/platform#3968), but the public fallback contract is incorrect. Fix by grouping `addresses_to_mark` by their owning account (or dispatching per pool) before calling `extend_pools_for_restored_addresses`.
- [SUGGESTION] packages/rs-platform-wallet/src/manager/rehydrate.rs:462-478: New RehydrationPoolTypeMismatch runtime guard is not exercised by tests
STILL VALID (prior-4, code unchanged). The `debug_assert!(pool.pool_type == probe.pool_type)` was promoted to a runtime fail-closed `Err(PlatformWalletError::RehydrationPoolTypeMismatch { .. })` in release builds (good hardening), but no test verifies that a mis-ordered probe/pool pair actually produces this error. Because `probes` is built from `address_pools()` and the real pools come from `address_pools_mut()` on the same account, the mismatch is only reachable via divergence between the two `key_wallet` accessors. A small structural-invariant test would catch a future upstream regression. Same applies to the pre-existing `RehydrationPoolMismatch` length check at lines 456-461.
In `packages/rs-platform-wallet/tests/rehydration_load.rs`:
- [SUGGESTION] packages/rs-platform-wallet/tests/rehydration_load.rs:546-587: No test combines snapshot-mismatch skip with a co-occurring successful load
STILL VALID (prior-5, code unchanged). `rt_snapshot_wallet_id_mismatch_is_skipped` verifies the mismatched row is skipped in isolation, and `rt_corrupt_row_skipped_and_other_loads` verifies a corrupt-manifest skip alongside a successful load in the same batch. There's no equivalent combined-batch test for the new snapshot-mismatch skip path (skipped wallet + a second healthy wallet loading successfully in the same `load_from_persistor` call). Extending the existing pattern would confirm the mismatch skip doesn't poison siblings in the batch.
In `packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs:41-97: Snapshot vs projection fields form an implicit-invariant union
STILL VALID (prior-1, code unchanged). `ClientWalletStartState` carries three overlapping sources of truth for core state: `core_wallet_info: Option<Box<ManagedWalletInfo>>` (snapshot) and `core_state: CoreChangeSet` + `used_core_addresses: Vec<Address>` (projection). The 'if `core_wallet_info` is Some, ignore the other two' rule lives only in a match arm in `manager/load.rs` and doc comments — a persister that populates both would silently discard the projection with no compile-time signal. The new README section documents these projection fields as 'a transitional fallback … slated for removal once every in-tree persister produces snapshots', which strengthens the case for a tagged enum `enum CoreLoadState { Snapshot(Box<ManagedWalletInfo>), Projection { core_state, used_core_addresses } }`: it would (a) make mutual exclusion type-level, (b) remove the FFI persister's need to `Default::default()` unused fields, and (c) shrink the deletion when #3968 lands to removing one variant.
In `packages/rs-platform-wallet/src/manager/load.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/manager/load.rs:162-220: Snapshot path validates wallet_id/network but not manifest↔snapshot account consistency
STILL VALID (prior-2, code unchanged). When `core_wallet_info: Some(info)` is consumed, the manager stitches a watch-only `Wallet` built from `account_manifest` together with `ManagedWalletInfo.accounts` from the snapshot, but only `wallet_id`/`network` are cross-checked. If a future persister populates the manifest and snapshot from divergent sources (extra CoinJoin account only in one, mis-ordered types, etc.), `Wallet.accounts` and `wallet_info.accounts` disagree silently: one drives scanning/derivation while the other drives balances/UTXOs. Not user-triggerable today since the FFI persister derives both from the same in-memory state, but `PlatformWalletPersistence` is a public trait and #3968 will add a SQLite backend. A cheap structural check comparing account-type sets — or at minimum an explicit doc line — would extend the wallet_id/network fail-closed pattern to the account collection.
- [SUGGESTION] packages/rs-platform-wallet/src/manager/load.rs:175-190: Snapshot wallet_id/network mismatch reuses CorruptKind::DecodeError, obscuring the failure mode
STILL VALID (prior-3, code unchanged). A wallet_id/network mismatch on the persisted `core_wallet_info` snapshot is reported via `CorruptKind::DecodeError(format!(...))`, which the FFI maps to `LOAD_SKIP_REASON_DECODE_ERROR` (102) — the same code as a genuine bincode failure. These are semantically distinct: unreadable bytes vs a persister returning a snapshot for the wrong row (a stronger data-integrity signal, e.g. persister row mixup, especially relevant once SQLite lands per #3968). `CorruptKind` is `#[non_exhaustive]`, so a dedicated `SnapshotIdentityMismatch` variant plus a new `LOAD_SKIP_REASON_*` code is a low-cost backward-compatible improvement for debug logs and Swift-side triage.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Issue being fixed or feature implemented
Suggested fixes for the findings of a code review of #3692 (see the review comment there for the full findings list with verification details). The confirmed defects all share one root cause: the keyless projection in the FFI persister flattens state the store already holds exactly — per-account UTXO attribution, pool contents with per-index
usedflags, tx records — and the manager-side replay cannot reconstruct what the flattening discarded. Concretely:rehydrate.rs):mark_usedran once beforemaintain_gap_limit, so a previously-used address past the discovery horizon but within gap-refill reach (e.g. used idx 45 with in-window used idx 20, gap 30) was derived after the mark pass and came backused = false— pool-visible as a fresh receive address, with the stale flag then persisted back over the store'sis_used = true.get_walletloop, landing a spurious NotFound in the@Published lastError, and the user was never told a wallet was skipped.What was done?
ClientWalletStartStategainscore_wallet_info: Option<Box<ManagedWalletInfo>>— still keyless by type (noWallet, no seed;ManagedWalletInfoholds balances/pools/UTXOs only). The FFI persister hands its fully-restoredwallet_infoacross; the manager validateswallet_id/networkagainst the row (mismatch ⇒ per-row corrupt skip) and consumes it directly. This fixes attribution, watch-set coverage, and used-flag fidelity in one move, restores the asset-lock funding-tx records to the in-memory map, deletes the projection block from the FFI crate (perpackages/swift-sdk/CLAUDE.md, stitching belongs in the library), and removes the second eager gap-window derivation from the launch path.core_state/used_core_addressesremain as the documented fallback for persisters that can't build a snapshot (SQLite until feat(platform-wallet-storage): embeddable SQLite persistence backend with seedless rehydration #3968).extend_pools_for_restored_addresses(still used by the fallback path): re-mark after every gap refill until nothing new resolves, so wedge-zone used addresses come backused.get_wallet), so a repeatload_from_persistorskips per-wallet reconstruction instead of discarding it at theWalletExistsarm (which stays as a race backstop).LoadOutcomeFFI: skipped wallets are logged with a decoded reason, surfaced via a new@Published lastLoadSkippedWallets: [SkippedWalletOnLoad], excluded from theget_walletloop (no morelastErrorpollution), and the outcome is freed viaplatform_wallet_load_outcome_free(deferred ahead of the throwingcheck()).packages/rs-platform-wallet/README.mdgains a normative "Persistence architecture" section codifying the agreed model (single writer, store-as-read-model, versioned schema contract, verbatimload(), hard persist errors, seedless load) — see the execution-plan comment on feat(platform-wallet): load watch-only wallet state from local storage at startup #3692.RehydrateRowError(variant-for-variant mirror ofCorruptKind+ identityFrom) deleted —build_watch_only_walletreturnsCorruptKinddirectly; the FFI skipped-array allocation reuses the crate'svec_to_ptrhelper.How Has This Been Tested?
cargo test -p platform-wallet— 217 lib + 8 + 7 integration, all passing. New tests:rehydration_wedge_zone_used_address_marked_after_refill(fails without the fixpoint fix),rt_snapshot_preserves_attribution_and_pools(CoinJoin-account UTXO stays attributed; derived-but-unused deep address survives reload),rt_snapshot_wallet_id_mismatch_is_skipped(snapshot/row mismatch ⇒ per-row skip, not a load).cargo test -p platform-wallet-ffi --features shielded— 111 + 26 + 5, all passing.cargo clippy -p platform-wallet -p platform-wallet-ffi --all-targets -- -D warningsclean;cargo fmt --all --checkclean;cargo check --all-targetsclean on the downstreamplatform-wallet-storageandrs-unified-sdk-fficrates.Breaking Changes
None beyond the base PR's own unreleased shapes (
ClientWalletStartStategains a field;build_watch_only_wallet's error type changes — both introduced by #3692 and unreleased).Checklist:
For repository code-owners and collaborators only
🤖 Generated with Claude Code