Skip to content

feat(platform-wallet): watch-only rehydration from persistor (seedless load)#3692

Open
Claudius-Maginificent wants to merge 14 commits into
v4.0-devfrom
feat/platform-wallet-rehydration
Open

feat(platform-wallet): watch-only rehydration from persistor (seedless load)#3692
Claudius-Maginificent wants to merge 14 commits into
v4.0-devfrom
feat/platform-wallet-rehydration

Conversation

@Claudius-Maginificent

@Claudius-Maginificent Claudius-Maginificent commented May 20, 2026

Copy link
Copy Markdown
Collaborator

Why this PR exists

  • Problem: after the SQLite persister landed, restarting the wallet rebuilt nothing from disk — load_from_persistor() was a stub, forcing a full re-scan from birth height on every launch.
  • What breaks without it: on iOS the Keychain is locked at launch with no seed in memory, so balances/history can't be shown until the user unlocks and re-scans.
  • Design constraint (validated against swift-sdk): launch is seedless and watch-only; signing unlocks the seed on-demand later.
  • Independence: self-contained on v3.1-dev — reshapes ClientWalletStartState and rewrites every in-tree consumer in the same PR. The storage-reader half is feat(platform-wallet): persistence readers + seedless load() wiring (split from #3692) #3968 (also independent); the two combine on the dash-evo-tool integration branch.

What was done (rs-platform-wallet + -ffi + swift)

PlatformWalletManager::load_from_persistor() reconstructs every persisted wallet from a keyless snapshot:

  • Each wallet returns as Wallet::new_watch_only(...) from stored account xpubs — no seed, no signing-key derivation on the load path.
  • Applies the persisted core-state projection (UTXOs, tx records, IS-locks, sync watermarks, balances) across any account topology, failing closed rather than reconstructing a silent-zero balance.
  • A structurally-corrupt row is skipped, not fatal: LoadOutcome.skipped + PlatformEvent::WalletSkippedOnLoad.
  • Defines the rehydration data model the feat(platform-wallet): persistence readers + seedless load() wiring (split from #3692) #3968 readers consume (keyless ClientWalletStartState, CoreChangeSet, load-result types).
  • Review follow-ups: slice-form warn_if_non_default_account, RehydrateRowErrorerror.rs, concrete on_wallet_skipped_on_load handler; carries last_applied_chain_lock through the seedless load.

Testing

cargo fmt --all --check; cargo clippy -p platform-wallet -p platform-wallet-ffi --all-targets -- -D warnings; cargo test -p platform-wallet (225 passed). Swift file needs a separate iOS build check.

Breaking changes

None against v3.1-dev (every change is additive or on an unreleased shape).

Checklist

  • Self-review performed
  • Tests added/updated
  • Docs updated where needed

🤖 Co-authored by Claudius the Magnificent AI Agent

Summary by CodeRabbit

  • New Features

    • Wallet loading now reports per-wallet results, including which wallets loaded and which were skipped.
    • Restored wallets are loaded as watch-only and can rebuild account, contact, and identity state on startup.
    • Wallet-load skip notifications are now available to platform handlers.
  • Bug Fixes

    • Loading now continues past corrupt wallet rows instead of failing the entire restore.
    • Improved handling of address reuse, UTXO restoration, and persisted core-state recovery.
    • Added safer validation for malformed seed/xpub data and wrong-seed restores.

@coderabbitai

coderabbitai Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8ae70161-68c4-408b-9d31-574058afd135

📥 Commits

Reviewing files that changed from the base of the PR and between 1f8d6b6 and 61c9fd7.

📒 Files selected for processing (5)
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet/src/changeset/client_start_state.rs
  • packages/rs-platform-wallet/src/events.rs
  • packages/rs-platform-wallet/src/lib.rs
  • packages/rs-platform-wallet/src/manager/rehydrate.rs
💤 Files with no reviewable changes (2)
  • packages/rs-platform-wallet/src/events.rs
  • packages/rs-platform-wallet/src/manager/rehydrate.rs
✅ Files skipped from review due to trivial changes (1)
  • packages/rs-platform-wallet/src/lib.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/rs-platform-wallet-ffi/src/persistence.rs

📝 Walkthrough

Walkthrough

Persisted wallet startup is redesigned around seedless watch-only reconstruction. ClientWalletStartState is reworked into a keyless manifest/core-state slice; load_from_persistor now returns a LoadOutcome with per-row skip tracking and transactional rollback. New rehydrate.rs implements watch-only wallet construction and address pool reconciliation. The FFI layer surfaces load outcomes via new C ABI structs. Contact/key replay is consolidated and non-default-account UTXO warnings are added.

Changes

Watch-only wallet rehydration and load outcome

Layer / File(s) Summary
Persisted state and public result types
packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs, packages/rs-platform-wallet/src/manager/load_outcome.rs, packages/rs-platform-wallet/src/error.rs, packages/rs-platform-wallet/src/changeset/client_start_state.rs, packages/rs-platform-wallet/src/lib.rs, packages/rs-platform-wallet/src/manager/mod.rs, packages/rs-platform-wallet/src/changeset/changeset.rs
ClientWalletStartState drops wallet/wallet_info fields and gains network, birth_height, account_manifest, core_state, contacts, identity_keys, and used_core_addresses. LoadOutcome, SkipReason, and CorruptKind types are defined. ClientStartState gains a skipped field and updated is_empty logic.
Skip event dispatch and module wiring
packages/rs-platform-wallet/src/events.rs, packages/rs-platform-wallet/src/manager/mod.rs
PlatformEventHandler and PlatformEventManager gain on_wallet_skipped_on_load. event_manager on PlatformWalletManager is unconditionally present (previously shielded-feature-gated).
Watch-only reconstruction and persisted core state
packages/rs-platform-wallet/src/manager/rehydrate.rs
New build_watch_only_wallet converts a manifest into a watch-only Wallet. New apply_persisted_core_state restores sync watermarks, UTXO set, chain lock, and address pool "used" state. extend_pools_for_restored_addresses performs bounded pool discovery with deep-scan warnings and gap-limit maintenance. Extensive unit tests cover roundtrips, topology guards, secret hygiene, and edge cases.
Load loop: outcome tracking, skip handling, rollback
packages/rs-platform-wallet/src/manager/load.rs, packages/rs-platform-wallet/src/wallet/platform_wallet.rs, packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs
load_from_persistor returns Result<LoadOutcome>, continues past per-row failures with SkipReason recording, treats WalletExists as idempotent, and rolls back only wallets inserted in the current call on hard failure.
FFI persister keyless projection and corruption classification
packages/rs-platform-wallet-ffi/src/persistence.rs
FFIPersister::load switches from abort-on-first-error to per-row skip continuation. build_wallet_start_state output is rewritten to the keyless shape. MALFORMED_XPUB_ERR_PREFIX and corrupt_kind_from_build_err classify decode failures.
C FFI outcome structs and free function
packages/rs-platform-wallet-ffi/src/manager.rs, packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
SkippedWalletFFI/LoadOutcomeFFI C structs and platform_wallet_load_outcome_free are added. platform_wallet_manager_load_from_persistor gains an out_outcome parameter. Swift call site passes nil.
Contact and identity-key replay consolidation
packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs, packages/rs-platform-wallet/src/wallet/apply.rs
Inline contact/key replay in apply_changeset is replaced by a single identity_manager.apply_contacts_and_keys call. The extracted method handles key upserts/removals and all contact routing with orphan-owner warnings.
Core bridge non-default-account UTXO warnings and tests
packages/rs-platform-wallet/src/changeset/core_bridge.rs
Private helpers emit tracing::warn! for non-default-account UTXOs persisted under account index 0. Warnings are added to TransactionDetected and BlockProcessed branches. Regression tests confirm UTXOs are still projected.
End-to-end load_from_persistor integration tests
packages/rs-platform-wallet/tests/rehydration_load.rs
FixedLoadPersister and RecordingHandler test doubles added. Five tests cover: watch-only roundtrip, idempotent repeat load, persister-supplied skips, corrupt-row skip with healthy wallet, and secret-hygiene of LoadOutcome/SkipReason renderings.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • dashpay/platform#3772: Updates the Swift loadFromPersistor FFI call to match the new platform_wallet_manager_load_from_persistor signature with the extra nil out-parameter introduced in this PR.
  • dashpay/platform#3828: Touches the same non-default-account UTXO projection and warning logic in core_bridge.rs that this PR extends with warning helpers and regression tests.

Suggested labels

ready for final review

Suggested reviewers

  • QuantumExplorer
  • llbartekll
  • thepastaclaw

🐇 From keyless vaults the wallets spring,
No seeds in state, just manifests ring.
Corrupt rows skipped, the rest march on,
The FFI counts each skip and gone.
Pools rehydrate with index care—
A watch-only world, beyond compare! 🪄

🚥 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 and concisely summarizes the main change: seedless watch-only rehydration from the persistor.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/platform-wallet-rehydration

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.

@github-actions

Copy link
Copy Markdown
Contributor

📖 Book Preview built successfully.

Download the preview from the workflow artifacts.
To view locally: download the artifact, unzip, and open index.html.

Updated at 2026-05-22T10:49:18.857Z

@Claudius-Maginificent

Copy link
Copy Markdown
Collaborator Author

🔁 Seedless-load rework landed — 34532e57d5..6f22c0e9f5

Per design validation against dashwallet-ios swift-sdk-integration: real iOS host loads watch-only at app launch (Keychain locked) and signs on-demand via the existing MnemonicResolverHandle vtable. The original seeded-load model (load takes &dyn SeedProvider, derives Wallet per persisted id, runs constant-time wrong-seed gate at load) did not match the real consumer. Pushed 7 commits flipping the model. Existing PR description above is stale — supersedes it for the deltas below.

What's new

  • load_from_persistor() is now seedlesspub async fn load_from_persistor(&self) -> Result<LoadOutcome, PlatformWalletError>. Manager reconstructs each persisted wallet via Wallet::new_watch_only(network, wallet_id, accounts), with accounts assembled from the keyless account_manifest (one Account::from_xpub(...) per registration entry). No seed touched on the load path.
  • Wrong-seed gate moved to the sign pathcrate::sign_gate::verify_seed_matches_wallet_id(root_pub, expected_wallet_id) -> bool (constant-time via subtle::ConstantTimeEq). Wired into all 4 resolver-fed FFI sign entrypoints:
    • sign_with_mnemonic_resolver.rs::dash_sdk_sign_with_mnemonic_resolver_and_path
    • identity_derive_and_persist.rs::dash_sdk_derive_and_persist_identity_keys
    • derive_identity_key_at_slot.rs::dash_sdk_derive_identity_key_at_slot_with_resolver
    • shielded_sync.rs::platform_wallet_manager_bind_shielded
  • SeedProvider trait + adapter + FFI shim deletedMnemonicResolverHandle (which already existed) is the sole on-demand signing contract. Files removed: rs-platform-wallet/src/seed_provider.rs, rs-platform-wallet-ffi/src/rehydration_seed_provider.rs, rs-platform-wallet-storage/src/secrets/seed_provider_adapter.rs (+ its test).
  • FFI signature: platform_wallet_manager_load_from_persistor(manager, persister, *mut LoadOutcomeFFI) — dropped the 3rd resolver arg (reverts b7508a0d47's 3-arg shape).
  • Swift PlatformWalletManager.swift aligns to the 2-arg + outparam C signature (passes nil for the outcome ptr).

ABI deltas (new in this PR, no v3.1-dev impact)

  • New result code ErrorWrongSeedForWallet = 13 in PlatformWalletFFIResultCode.
  • SkippedWalletFFI::reason_code family remapped to 100/101/102 for the new CorruptKind variants (MissingManifest, MalformedXpub, DecodeError). The old 0/1/2 (seed-availability) are gone — those skip-triggers don't exist anymore.

AR-7 hygiene

  • Load path eliminates AR-7 entirely — manager never constructs WalletType::Mnemonic|Seed; only WalletType::WatchOnly (no key material). AR-7's residual Debug concern was about derived Wallet values on the load path; that path no longer derives.
  • Sign path keeps AR-7 disciplineZeroizing + non_secure_erase() on the gate's throwaway master key on the mismatch path, before returning the error tag. No {:?} over any secret type.

Test reshape

  • tests/rehydration_load.rs — RT-WO, RT-Corrupt, RT-Z (watch-only construction, corrupt-row hard-fail, secret-hygiene). Wrong-seed scenarios removed from this file (no seed at load anymore).
  • Co-located gate tests in each of the 4 FFI sign entrypoints:
    • sign_with_mnemonic_resolver: happy + wrong + full-buffer-zero assertion
    • identity_derive_and_persist: wrong + persister-callback-never-fires assertion
    • derive_identity_key_at_slot: happy + wrong (asserts populated out_row on happy, zero/null fields on mismatch)
    • shielded_sync: wrong + null-resolver-handle-rejected (happy path requires full SDK + coordinator + commitment-tree — covered structurally by gate-fires-before-storage-touch assertion)
  • New sign_gate::tests unit module exercises CT helper directly.

What survived (unchanged by this rework)

  • Keyless PersistedWalletData / ClientWalletStartState (commit b9af9935)
  • A1/B/A2 schema readers (accounts::load_state, core_state::load_state, asset_locks::load_unconsumed)
  • F2 no-BIP44 silent-zero balance fix (commit 96a9aa90)
  • F4 dead post-insert wallet_id re-check removal (commit 62bd4754)
  • WrongSeedForDatabase typed error (re-homed to sign path)

Verification

  • cargo fmt --all --check: pass
  • cargo clippy -p platform-wallet -p platform-wallet-storage -p platform-wallet-ffi --all-targets -- -D warnings: pass
  • cargo check --workspace: pass
  • cargo test -p platform-wallet -p platform-wallet-storage -p platform-wallet-ffi: 414 passed, 0 failed, 7 ignored
  • cargo test --doc -p platform-wallet -p platform-wallet-storage: 3 passed, 1 ignored
  • cargo check --workspace --all-features: fails on pre-existing ShieldedChangeSet: Serialize/Deserialize gap at changeset.rs:914, verified reproducible on origin/feat/platform-wallet-rehydration@34532e57 — out of scope for this rework

🤖 Co-authored by Claudius the Magnificent AI Agent

@Claudius-Maginificent Claudius-Maginificent changed the title feat(platform-wallet): add full wallet rehydration from persistor + seed (skip-and-report) feat(platform-wallet): watch-only rehydration from persistor + wallet_id sign-gate (seedless load) May 25, 2026
@lklimek lklimek force-pushed the feat/platform-wallet-rehydration branch from 0e92cb4 to f57b117 Compare May 25, 2026 11:21
@Claudius-Maginificent Claudius-Maginificent changed the title feat(platform-wallet): watch-only rehydration from persistor + wallet_id sign-gate (seedless load) feat(platform-wallet): watch-only rehydration from persistor (seedless load) May 25, 2026
Base automatically changed from feat/platform-wallet-storage-secrets to feat/platform-wallet-sqlite-persistor May 29, 2026 11:28
@lklimek lklimek added this to the v4.0.0 milestone Jun 1, 2026
Base automatically changed from feat/platform-wallet-sqlite-persistor to v3.1-dev June 9, 2026 08:13
@codecov

codecov Bot commented Jun 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.18%. Comparing base (48f0cc3) to head (61c9fd7).
⚠️ Report is 2 commits behind head on v3.1-dev.

Additional details and impacted files
@@            Coverage Diff             @@
##           v3.1-dev    #3692    +/-   ##
==========================================
  Coverage     87.17%   87.18%            
==========================================
  Files          2629     2632     +3     
  Lines        327221   327563   +342     
==========================================
+ Hits         285265   285592   +327     
- Misses        41956    41971    +15     
Components Coverage Δ
dpp 87.70% <ø> (ø)
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.

@lklimek

lklimek commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

@coderabbitai review all

@thepastaclaw

thepastaclaw commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit 61c9fd7)

@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

@lklimek On it — I’ll re-review the full PR.

✅ Action performed

Full review finished.

@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

I verified the reported wallet rehydration findings against the PR head and surrounding call paths. Three correctness issues are in scope for this restore/storage work: the SQLite account-registration key is lossy, repeated loads collide with already-registered wallets, and the FFI load path still turns malformed account xpub rows into whole-load failures before the manager can skip them per wallet.

🔴 6 blocking | 🟡 1 suggestion(s)

Findings not posted inline (3)

These findings could not be anchored to the current diff, but they are still part of this review.

  • [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:107-111: Account registration primary key collapses distinct account variants — The writer upserts account_registrations on (wallet_id, account_type, account_index), and the migration declares the same primary key. That key is narrower than key_wallet's account identity: PlatformPayment is keyed by (account, key_class), and DashPay receiving/external accounts are k...
  • [BLOCKING] packages/rs-platform-wallet/src/manager/load.rs:159-168: Repeated restore fails on wallets already in the managerload_from_persistor always calls wm.insert_wallet(wallet, platform_info) for every persisted wallet. The underlying key-wallet-manager implementation returns WalletExists when the wallet id is already registered, and this code maps that to a hard WalletCreation error that aborts the loa...
  • [BLOCKING] packages/rs-platform-wallet-ffi/src/persistence.rs:1543-1545: Malformed FFI account xpub aborts the whole restore — The FFI persister builds each WalletRestoreEntryFFI with build_wallet_start_state(entry)? before returning ClientStartState to PlatformWalletManager::load_from_persistor. Inside that helper, malformed account_xpub_bytes returns PersistenceError while decoding the account xpub, so one...
🤖 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-storage/src/sqlite/schema/accounts.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:107-111: Account registration primary key collapses distinct account variants
  The writer upserts `account_registrations` on `(wallet_id, account_type, account_index)`, and the migration declares the same primary key. That key is narrower than `key_wallet`'s account identity: `PlatformPayment` is keyed by `(account, key_class)`, and DashPay receiving/external accounts are keyed by `(index, user_identity_id, friend_identity_id)`. The blob stores the full `AccountRegistrationEntry`, but inserting two such variants with the same label and numeric index overwrites the first row before `load_state` can reconstruct the manifest. A restored watch-only wallet can therefore lose accounts and their address/platform state. Persist enough discriminator columns, or an unambiguous serialized account-type key, and include it in the conflict key/order.
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs:107-111: Account registration primary key collapses distinct account variants
  The writer upserts `account_registrations` on `(wallet_id, account_type, account_index)`, and the migration declares the same primary key. That key is narrower than `key_wallet`'s account identity: `PlatformPayment` is keyed by `(account, key_class)`, and DashPay receiving/external accounts are keyed by `(index, user_identity_id, friend_identity_id)`. The blob stores the full `AccountRegistrationEntry`, but inserting two such variants with the same label and numeric index overwrites the first row before `load_state` can reconstruct the manifest. A restored watch-only wallet can therefore lose accounts and their address/platform state. Persist enough discriminator columns, or an unambiguous serialized account-type key, and include it in the conflict key/order.

In `packages/rs-platform-wallet/src/manager/load.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/load.rs:159-168: Repeated restore fails on wallets already in the manager
  `load_from_persistor` always calls `wm.insert_wallet(wallet, platform_info)` for every persisted wallet. The underlying `key-wallet-manager` implementation returns `WalletExists` when the wallet id is already registered, and this code maps that to a hard `WalletCreation` error that aborts the load batch. A second `load_from_persistor` call on the same manager, or a restore after the same wallet has already been registered in memory, fails instead of treating the persisted wallet as already satisfied. Handle `WalletExists` or pre-check the existing wallet id so repeated restore is idempotent and still proceeds to the remaining persisted wallets.
- [BLOCKING] packages/rs-platform-wallet/src/manager/load.rs:159-168: Repeated restore fails on wallets already in the manager
  `load_from_persistor` always calls `wm.insert_wallet(wallet, platform_info)` for every persisted wallet. The underlying `key-wallet-manager` implementation returns `WalletExists` when the wallet id is already registered, and this code maps that to a hard `WalletCreation` error that aborts the load batch. A second `load_from_persistor` call on the same manager, or a restore after the same wallet has already been registered in memory, fails instead of treating the persisted wallet as already satisfied. Handle `WalletExists` or pre-check the existing wallet id so repeated restore is idempotent and still proceeds to the remaining persisted wallets.

In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/persistence.rs:1543-1545: Malformed FFI account xpub aborts the whole restore
  The FFI persister builds each `WalletRestoreEntryFFI` with `build_wallet_start_state(entry)?` before returning `ClientStartState` to `PlatformWalletManager::load_from_persistor`. Inside that helper, malformed `account_xpub_bytes` returns `PersistenceError` while decoding the account xpub, so one corrupt SwiftData account row makes the entire load callback fail. That bypasses the new per-wallet skip contract documented on the manager and FFI surfaces, and prevents otherwise valid persisted wallets from loading. Convert per-entry account decode failures into a skipped wallet entry or an equivalent row-local corruption marker that the manager can report through `LoadOutcome::skipped`.
- [BLOCKING] packages/rs-platform-wallet-ffi/src/persistence.rs:1543-1545: Malformed FFI account xpub aborts the whole restore
  The FFI persister builds each `WalletRestoreEntryFFI` with `build_wallet_start_state(entry)?` before returning `ClientStartState` to `PlatformWalletManager::load_from_persistor`. Inside that helper, malformed `account_xpub_bytes` returns `PersistenceError` while decoding the account xpub, so one corrupt SwiftData account row makes the entire load callback fail. That bypasses the new per-wallet skip contract documented on the manager and FFI surfaces, and prevents otherwise valid persisted wallets from loading. Convert per-entry account decode failures into a skipped wallet entry or an equivalent row-local corruption marker that the manager can report through `LoadOutcome::skipped`.

In `packages/rs-platform-wallet/src/manager/mod.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/manager/mod.rs:8: Rehydration internals are exposed as public API
  `pub mod rehydrate` exposes `manager::rehydrate::apply_persisted_core_state` to downstream crates even though it mutates `ManagedWalletInfo` according to load-path-specific assumptions such as first-funds-account UTXO routing and bounded address-pool probing. The only external uses are storage integration tests, so making this part of the public SDK API hardens internal restore details that should be free to change. Keep the module/function crate-private and move those cross-crate assertions behind an internal test feature or into `rs-platform-wallet` tests.

Comment thread packages/rs-platform-wallet/src/manager/mod.rs
Comment thread packages/rs-platform-wallet-ffi/src/persistence.rs
Comment thread packages/rs-platform-wallet/src/changeset/core_bridge.rs
Comment thread packages/rs-platform-wallet/src/manager/platform_address_sync.rs Outdated
Comment thread packages/rs-platform-wallet/src/manager/rehydrate.rs Outdated
Comment thread packages/rs-platform-wallet/src/events.rs Outdated
…persistor [#3692, clean on v3.1-dev]

Squashed net-diff of feat/platform-wallet-rehydration onto v3.1-dev base
(1653b89). Includes all merged commits:
  • changeset: CoreChangeSet, ClientWalletStartState, addresses_derived wiring
  • rehydrate: seedless watch-only wallet rebuild + apply_persisted_core_state
  • load_outcome: LoadOutcome / SkipReason / CorruptKind
  • manager/load: load_from_persistor implementation
  • manager/mod: PlatformWalletManager wiring
  • events: PlatformEvent + on_wallet_skipped_on_load concrete handler
  • error: RehydrateRowError relocated from manager::rehydrate
  • core_bridge: warn_if_non_default_account generalised to &[T] slice
  • FFI: persistence + manager bindings
  • Swift: PlatformWalletManager load() bridging
  • tests: rehydration_load integration suite
  • misc: .cargo/audit.toml, .gitignore

fmt + clippy (-D warnings) + cargo test: all pass.
Tree verified byte-for-byte identical to feat/platform-wallet-rehydration HEAD.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@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 (2)
packages/rs-platform-wallet/src/manager/identity_sync.rs (1)

148-208: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Do not let an exiting old loop erase the new loop’s cancel token.

Line 422 can execute after stop() has taken the old token and a subsequent start() has stored a new one. Clearing the slot unconditionally makes the live identity-sync loop unobservable/uncancellable and can allow duplicate background loops.

Proposed fix
 pub struct IdentitySyncManager<P>
 where
     P: PlatformWalletPersistence + 'static,
 {
@@
     /// Cancel token for the background loop, if running.
     background_cancel: StdMutex<Option<CancellationToken>>,
+    background_generation: AtomicU64,
     interval_secs: AtomicU64,
@@
             sdk,
             persister,
             background_cancel: StdMutex::new(None),
+            background_generation: AtomicU64::new(0),
             interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS),
@@
         }
         let cancel = CancellationToken::new();
+        let my_gen = self
+            .background_generation
+            .fetch_add(1, Ordering::AcqRel)
+            + 1;
         *guard = Some(cancel.clone());
@@
-                    if let Ok(mut guard) = this.background_cancel.lock() {
-                        *guard = None;
+                    if let Ok(mut guard) = this.background_cancel.lock() {
+                        if this.background_generation.load(Ordering::Acquire) == my_gen {
+                            *guard = None;
+                        }
                     }

Also applies to: 392-422

🤖 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/manager/identity_sync.rs` around lines 148 -
208, The background loop teardown in IdentitySyncManager::start/stop is clearing
the shared cancel-token slot unconditionally, which can wipe out a newer token
stored by a subsequent start(). Update the loop-exit cleanup near the stop
handling so it only clears background_cancel when it still matches the token
owned by that loop, leaving a newer loop’s token intact. Use the existing
stop/start/background_cancel/CancellationToken flow in IdentitySyncManager to
guard the compare-and-clear logic.
packages/rs-platform-wallet/src/manager/shielded_sync.rs (1)

132-169: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Keep cleanup from clearing a newer background loop’s cancel token.

Line 251 can run after stop() returns and a later start() has already stored a fresh token. Unconditionally setting background_cancel = None then loses the new token, so later stop() cannot cancel the running loop and another start() can spawn duplicates. Restore a generation/token ownership guard and check it while holding the mutex.

Proposed fix
 pub struct ShieldedSyncManager {
@@
     /// Cancel token for the background loop, if running.
     background_cancel: StdMutex<Option<CancellationToken>>,
+    background_generation: AtomicU64,
     interval_secs: AtomicU64,
@@
             event_manager,
             coordinator_slot,
             background_cancel: StdMutex::new(None),
+            background_generation: AtomicU64::new(0),
             interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS),
@@
         }
         let cancel = CancellationToken::new();
+        let my_gen = self
+            .background_generation
+            .fetch_add(1, Ordering::AcqRel)
+            + 1;
         *guard = Some(cancel.clone());
@@
-                    if let Ok(mut guard) = this.background_cancel.lock() {
-                        *guard = None;
+                    if let Ok(mut guard) = this.background_cancel.lock() {
+                        if this.background_generation.load(Ordering::Acquire) == my_gen {
+                            *guard = None;
+                        }
                     }

Also applies to: 215-252

🤖 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/manager/shielded_sync.rs` around lines 132 -
169, The background-loop cleanup in `ShieldedSyncManager::stop`/`cleanup` is
clearing `background_cancel` even when a newer `start` has already installed a
fresh `CancellationToken`. Update the token lifecycle so cleanup only removes
the token it created, using a generation or token-identity guard while holding
the `StdMutex` around `background_cancel`. Ensure the `start`, `stop`, and
cleanup paths all compare ownership before setting the slot to `None`, so a
later loop’s token is preserved and can still be canceled.
🧹 Nitpick comments (1)
packages/rs-platform-wallet/src/manager/rehydrate.rs (1)

174-178: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Pre-index spent outpoints before filtering.

This is currently new_utxos × spent_utxos; persisted wallets with larger histories can make startup restore unnecessarily quadratic. Build a set of spent outpoints once, then filter against it.

🤖 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/manager/rehydrate.rs` around lines 174 - 178,
The unspent UTXO selection in rehydrate_wallet currently scans core.new_utxos
against core.spent_utxos for each item, making startup restore quadratic for
large histories. Update the filtering logic in rehydrate_wallet to precompute a
set of spent outpoints once, then use that set when building the unspent
Vec<&key_wallet::Utxo> so the lookup is O(1) per UTXO.
🤖 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-platform-wallet/src/manager/rehydrate.rs`:
- Around line 8-10: The module docs in rehydrate.rs still describe wrong-seed
validation as deferred to separate FFI work, which is now outdated. Update the
comment near the top of the file to match the current contract by stating that
wrong-seed validation is handled in resolver-backed signing entrypoints, while
keeping the note that load/rehydration itself does not touch the seed; use the
existing rehydrate module documentation text as the place to align this wording.

---

Outside diff comments:
In `@packages/rs-platform-wallet/src/manager/identity_sync.rs`:
- Around line 148-208: The background loop teardown in
IdentitySyncManager::start/stop is clearing the shared cancel-token slot
unconditionally, which can wipe out a newer token stored by a subsequent
start(). Update the loop-exit cleanup near the stop handling so it only clears
background_cancel when it still matches the token owned by that loop, leaving a
newer loop’s token intact. Use the existing
stop/start/background_cancel/CancellationToken flow in IdentitySyncManager to
guard the compare-and-clear logic.

In `@packages/rs-platform-wallet/src/manager/shielded_sync.rs`:
- Around line 132-169: The background-loop cleanup in
`ShieldedSyncManager::stop`/`cleanup` is clearing `background_cancel` even when
a newer `start` has already installed a fresh `CancellationToken`. Update the
token lifecycle so cleanup only removes the token it created, using a generation
or token-identity guard while holding the `StdMutex` around `background_cancel`.
Ensure the `start`, `stop`, and cleanup paths all compare ownership before
setting the slot to `None`, so a later loop’s token is preserved and can still
be canceled.

---

Nitpick comments:
In `@packages/rs-platform-wallet/src/manager/rehydrate.rs`:
- Around line 174-178: The unspent UTXO selection in rehydrate_wallet currently
scans core.new_utxos against core.spent_utxos for each item, making startup
restore quadratic for large histories. Update the filtering logic in
rehydrate_wallet to precompute a set of spent outpoints once, then use that set
when building the unspent Vec<&key_wallet::Utxo> so the lookup is O(1) per UTXO.
🪄 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: 6ad8195d-696b-4fae-83cc-2844ae7e9047

📥 Commits

Reviewing files that changed from the base of the PR and between f6dd0df and 83f7d4f.

📒 Files selected for processing (20)
  • .cargo/audit.toml
  • .gitignore
  • packages/rs-platform-wallet-ffi/src/manager.rs
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet/src/changeset/changeset.rs
  • packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs
  • packages/rs-platform-wallet/src/changeset/core_bridge.rs
  • packages/rs-platform-wallet/src/error.rs
  • packages/rs-platform-wallet/src/events.rs
  • packages/rs-platform-wallet/src/lib.rs
  • packages/rs-platform-wallet/src/manager/identity_sync.rs
  • packages/rs-platform-wallet/src/manager/load.rs
  • packages/rs-platform-wallet/src/manager/load_outcome.rs
  • packages/rs-platform-wallet/src/manager/mod.rs
  • packages/rs-platform-wallet/src/manager/rehydrate.rs
  • packages/rs-platform-wallet/src/manager/shielded_sync.rs
  • packages/rs-platform-wallet/src/wallet/apply.rs
  • packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs
  • packages/rs-platform-wallet/tests/rehydration_load.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
✅ Files skipped from review due to trivial changes (3)
  • .cargo/audit.toml
  • .gitignore
  • packages/rs-platform-wallet/src/changeset/changeset.rs
🚧 Files skipped from review as they are similar to previous changes (13)
  • packages/rs-platform-wallet/src/lib.rs
  • packages/rs-platform-wallet/src/manager/load_outcome.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
  • packages/rs-platform-wallet/src/events.rs
  • packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs
  • packages/rs-platform-wallet/src/wallet/apply.rs
  • packages/rs-platform-wallet/src/manager/mod.rs
  • packages/rs-platform-wallet/tests/rehydration_load.rs
  • packages/rs-platform-wallet/src/manager/load.rs
  • packages/rs-platform-wallet/src/changeset/core_bridge.rs
  • packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet-ffi/src/manager.rs

Comment thread packages/rs-platform-wallet/src/manager/rehydrate.rs Outdated

@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

Carried-forward prior findings: all three prior author-facing findings are STILL VALID at 83f7d4f: duplicate restore still hard-fails, malformed FFI account xpubs still abort the whole load before skipped-wallet handling, and manager::rehydrate remains public. New in-scope finding in the latest cumulative verification: the keyless FFI projection now drops restored core address-pool snapshots, so used spent addresses and derived empty gap-window state are not rehydrated. One additional agent finding about a missing wallet-list free callback is a real pre-existing hardening issue, but it is out of scope for this PR because Swift wires both callbacks and the current delta did not introduce or worsen that partial-configuration path.

🔴 5 blocking | 🟡 1 suggestion(s)

Findings not posted inline (2)

These findings could not be anchored to the current diff, but they are still part of this review.

  • [BLOCKING] packages/rs-platform-wallet/src/manager/load.rs:155-164: Repeated restore fails on wallets already in the managerload_from_persistor still calls wm.insert_wallet(wallet, platform_info) for every persisted wallet and converts every insertion error into a hard WalletCreation failure. The normal registration path maps key_wallet_manager::WalletError::WalletExists(_) specially, and Swift documents launc...
  • [BLOCKING] packages/rs-platform-wallet-ffi/src/persistence.rs:1543-1545: Malformed FFI account xpub aborts the whole restoreFFIPersister::load still builds each Swift-provided restore entry with build_wallet_start_state(entry)? before returning ClientStartState to the manager. Inside that helper, malformed account_xpub_bytes is decoded with ?, so one corrupt SwiftData account row becomes a whole-load `Persis...
Carried-forward findings already raised (1)

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/manager/mod.rs:8: Rehydration internals are exposed as public APIpub mod rehydrate exposes load-path helpers such as manager::rehydrate::apply_persisted_core_state to downstream crates. These functions mutate ManagedWalletInfo under restore-specific assumptions and should remain behind PlatformWalletManager::load_from_persistor; otherwise the SDK freez...
🤖 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/load.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/load.rs:155-164: Repeated restore fails on wallets already in the manager
  `load_from_persistor` still calls `wm.insert_wallet(wallet, platform_info)` for every persisted wallet and converts every insertion error into a hard `WalletCreation` failure. The normal registration path maps `key_wallet_manager::WalletError::WalletExists(_)` specially, and Swift documents launch restore as idempotent, but this load path does not handle a wallet that is already registered. Calling restore twice on the same manager, or restoring when one persisted wallet is already loaded, aborts the batch instead of treating that wallet as already present and continuing with the remaining rows.
- [BLOCKING] packages/rs-platform-wallet/src/manager/load.rs:155-164: Repeated restore fails on wallets already in the manager
  `load_from_persistor` still calls `wm.insert_wallet(wallet, platform_info)` for every persisted wallet and converts every insertion error into a hard `WalletCreation` failure. The normal registration path maps `key_wallet_manager::WalletError::WalletExists(_)` specially, and Swift documents launch restore as idempotent, but this load path does not handle a wallet that is already registered. Calling restore twice on the same manager, or restoring when one persisted wallet is already loaded, aborts the batch instead of treating that wallet as already present and continuing with the remaining rows.

In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/persistence.rs:1543-1545: Malformed FFI account xpub aborts the whole restore
  `FFIPersister::load` still builds each Swift-provided restore entry with `build_wallet_start_state(entry)?` before returning `ClientStartState` to the manager. Inside that helper, malformed `account_xpub_bytes` is decoded with `?`, so one corrupt SwiftData account row becomes a whole-load `PersistenceError` before `PlatformWalletManager::load_from_persistor` can classify the wallet as `LoadOutcome::skipped`. This violates the PR's per-wallet corrupt-row contract and prevents otherwise valid wallets from loading.
- [BLOCKING] packages/rs-platform-wallet-ffi/src/persistence.rs:3400-3415: Core address-pool restore is dropped by the keyless projection
  `build_wallet_start_state` restores `entry.core_address_pools` into the temporary `wallet_info`, including used flags and derived addresses, but the returned `ClientWalletStartState` only forwards `new_utxos`, height watermarks, and `last_applied_chain_lock` through `CoreChangeSet`. The manager then rebuilds a fresh watch-only wallet and `apply_persisted_core_state` only marks addresses that currently have restored UTXOs. Any previously used address whose funds were spent, or any derived gap-window address without an unspent UTXO, comes back as unused after seedless launch and can be handed out again.
- [BLOCKING] packages/rs-platform-wallet-ffi/src/persistence.rs:1543-1545: Malformed FFI account xpub aborts the whole restore
  `FFIPersister::load` still builds each Swift-provided restore entry with `build_wallet_start_state(entry)?` before returning `ClientStartState` to the manager. Inside that helper, malformed `account_xpub_bytes` is decoded with `?`, so one corrupt SwiftData account row becomes a whole-load `PersistenceError` before `PlatformWalletManager::load_from_persistor` can classify the wallet as `LoadOutcome::skipped`. This violates the PR's per-wallet corrupt-row contract and prevents otherwise valid wallets from loading.

In `packages/rs-platform-wallet/src/manager/mod.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/manager/mod.rs:8: Rehydration internals are exposed as public API
  `pub mod rehydrate` exposes load-path helpers such as `manager::rehydrate::apply_persisted_core_state` to downstream crates. These functions mutate `ManagedWalletInfo` under restore-specific assumptions and should remain behind `PlatformWalletManager::load_from_persistor`; otherwise the SDK freezes reconstruction details as public API instead of keeping them internal to the load boundary.

Comment thread packages/rs-platform-wallet-ffi/src/persistence.rs
@lklimek

lklimek commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

@coderabbitai review all

@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

@lklimek On it — I’ll re-review the full PR, with attention to the changes still in scope for #3692 after the split.

✅ Action performed

Full review finished.

lklimek and others added 9 commits June 29, 2026 14:39
…ncrete handlers only (#3692 review)

Remove `PlatformEventHandler::on_platform_event` (the generic backward-compat
escape hatch) and `PlatformEventManager::on_platform_event` entirely.
`on_wallet_skipped_on_load` now has a plain no-op default, matching the
pattern used by every other concrete handler on the trait.

`PlatformEvent` is kept: it is `pub`, re-exported from `lib.rs`, and
not present in the FFI or Swift layer — no dead-code warning applies to
public items, and removing it would be a needless churn of the public API.

Not a breaking change vs v3.1-dev: `on_platform_event` was only ever on
this branch (absent from origin/v3.1-dev).

Doc comments in manager/load.rs and manager/mod.rs updated to point to
`on_wallet_skipped_on_load` instead of the removed method/event wrapper.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…g-seed gate (#3692 review)

The rehydrate module header and the rehydration_load test header both
claimed the wrong-seed gate was "deferred to separate FFI work and is
not part of this path." That gate now exists on the resolver-backed
signing entrypoints (sign_with_mnemonic_resolver + the FFI resolver sign
path). Reword to say wrong-seed validation lives there; the seedless
load path never sees the seed. Docs-only, no behaviour change.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…nt HashSet (#3692 review)

apply_persisted_core_state filtered new_utxos against spent_utxos with a
nested `any()`, making the unspent projection O(new × spent). Collect the
spent outpoints into a HashSet once and do O(1) membership lookups —
behaviour is identical (Copy OutPoint, Hash + Eq), just linear.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ehydrate (#3692 review)

The FFI build_wallet_start_state decoded the persisted core address pools
(used flags + derived addresses) into a temp wallet_info, but the keyless
ClientWalletStartState forwarded only the account manifest + UTXO/height
projection — the pool used-state was dropped. apply_persisted_core_state
then marked addresses used ONLY from currently-unspent UTXOs, so a
previously-used address whose funds were since spent came back marked
unused and could be handed out again as a fresh receive address: an
address-reuse privacy leak.

Carry the used-state through:
- Add ClientWalletStartState::used_core_addresses (Vec<Address>, empty
  default) — a flat snapshot of every pool-marked-used address.
- Populate it in the FFI projection from the already-decoded pools.
- apply_persisted_core_state now marks used the UNION of unspent-UTXO
  addresses + used_core_addresses (new param), deriving deep slots via the
  existing horizon walk. Renamed extend_pools_for_restored_utxos ->
  extend_pools_for_restored_addresses since it now resolves both sources.

Empty used_core_addresses preserves the prior unspent-only behaviour, so
the native/SQLite path is unchanged until #3968 wires its
pool readers to populate this field (cross-PR follow-up; no regression).

Also fixes the O(new x spent) unspent filter via an outpoint HashSet.

Test: rehydration_restores_persisted_used_state_for_spent_out_address
asserts an in-window and a deep spent-out address come back used, and that
the empty-snapshot baseline does NOT mark them.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…t wallet on load (#3692 review)

load_from_persistor mapped EVERY insert_wallet error (including
key_wallet_manager::WalletError::WalletExists) to a fatal WalletCreation +
'load break + full rollback. So a second load_from_persistor — or a load
run while the wallet is already in memory — aborted the whole batch
instead of being a no-op.

Match WalletExists specifically and treat it as already-satisfied: record
the wallet as loaded and `continue` to the next row. It was not inserted
by this pass, so it stays out of the rollback set and a later hard-fail
never evicts the pre-existing wallet. Mirrors the create-path idempotent
handling in wallet_lifecycle.

Test: rt_idempotent_repeat_restore loads the same persister twice and
asserts the second call returns Ok with the wallet still present.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ting the batch (#3692 review)

FFIPersister::load looped `build_wallet_start_state(entry)?`, so ONE
corrupt SwiftData row (e.g. a malformed account_xpub that aborts decode)
failed the ENTIRE load() — every wallet, every launch. The manager
already documents per-wallet skip (LoadOutcome::skipped +
on_wallet_skipped_on_load, returns Ok), but the FFI never reached it.

Make the FFI loop per-entry resilient: on a per-row build failure record
the wallet as skipped and continue. Errors from build_wallet_start_state
are inherently per-row (decode/projection of THAT entry), so this never
swallows a whole-load failure. The skip travels to the manager through a
new ClientStartState::skipped channel (Vec<(WalletId, SkipReason)>, empty
default); load_from_persistor folds it into LoadOutcome::skipped and fires
on_wallet_skipped_on_load. Reason is CorruptPersistedRow{DecodeError} —
PersistenceError's Display is structural (no row bytes / key material).

Cross-PR: ClientStartState derives Default so #3968's `::default()` build
still compiles; a destructure there needs `skipped: _` (follow-up).

Test: rt_persister_skipped_folds_into_outcome asserts a persister-rejected
row surfaces in LoadOutcome::skipped + fires the event while the healthy
wallet still loads and the call returns Ok.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…stive (#3692 review)

Semver hygiene for the new, unreleased load surface so future variants
don't break downstream matches: add #[non_exhaustive] to SkipReason,
CorruptKind, LoadOutcome (load_outcome.rs) and PlatformEvent (events.rs).

Consequence: the FFI skip_reason_code match (a downstream crate) is no
longer exhaustive over the now-non_exhaustive SkipReason/CorruptKind, so
add catch-all arms mapping future variants to generic codes (199 corrupt
kind, 200 skip reason) until the mapping is extended. matches!() in tests
is unaffected (it carries an implicit wildcard).

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…dex pool extension on rehydrate (#3692 review)

QA flagged that the existing real-manager rehydration test defaults the
address-pool payload and is structurally blind to #1, and that the
pool-DEPTH fix (dash-evo-tool#829 Bug 2 / PR #830) had no regression guard.
Add two distinct, focused tests through apply_persisted_core_state:

- rehydration_used_state_survives_spent_utxo (#1, address-reuse): builds a
  ClientWalletStartState whose in-window address received funds that were
  then SPENT (new_utxos cancelled by spent_utxos → zero balance) and routes
  used_core_addresses through the field. Asserts the in-window + a deep
  (idx 30) address come back marked USED even with zero balance, and that
  the empty-snapshot baseline does NOT mark them. Replaces the weaker
  no-UTXO variant so the used flag is proven independent of a live UTXO.

- rt_deep_index_utxos_extend_pools_on_rehydration (DEPTH): unspent UTXOs on
  walkable ladders past the eager 0..=gap_limit window (external -> idx 84,
  internal -> idx 90). Asserts the deep slots are derived into their pools
  and Sum(per-address visible) == balance.total == Sum(persisted) — no
  deep-index undercount. Test-only; the production fix already exists.

Also: drop the stale "(from wallet_metadata)" table reference on the
ClientWalletStartState::network doc (backend-agnostic now).

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
 review)

Remove rt_deep_index_utxos_extend_pools_on_rehydration: the deep-index
pool-extension scenario is already guarded by the pre-existing
rehydration_extends_pools_to_cover_deep_index_utxos and
rehydration_coinjoin_single_pool_deep_index. The existing 30->60 horizon
extension already exercises the recursive walk, so a deeper ladder added no
new code path — pure duplication. Keeps the #1 address-reuse test
(rehydration_used_state_survives_spent_utxo).

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Claudius-Maginificent

Copy link
Copy Markdown
Collaborator Author

Review-fix summary (pushed in 1f8d6b6029)

Thanks for the thorough pass — dispositions for the blocking / review-body findings:

Fixed in this push:

  • FFI load aborts the whole batch on one corrupt row2c6e3e8d35: the FFI load loop now skips a corrupt entry as CorruptPersistedRow, folds it into LoadOutcome::skipped + on_wallet_skipped_on_load, and continues — honoring the documented per-wallet skip contract.
  • Repeated restore hard-fails on an already-present wallet8832f6f18f: WalletExists is now idempotent (skip + continue, kept out of the rollback set).
  • Keyless projection dropped address-pool used-state (address reuse) → 2e710276a7 (+ regression test in 1f8d6b6029): persisted pool used-flags now carry through ClientWalletStartState::used_core_addresses and are applied (FFI/iOS path). The native/SQLite half is tracked for feat(platform-wallet): persistence readers + seedless load() wiring (split from #3692) #3968.
  • Nitpicks: stale wrong-seed doc (a5b7032b73), O(n) UTXO spent-filter (22ff11c801), #[non_exhaustive] on the load enums (3876d0eeed), and the wallet_metadatawallets doc.

By design, not changed here:

Routed to #3968 (storage crate, outside this PR's diff): account-registration key collapse, restore_from_inner open-path check, blob-vs-typed-column trust, cross-wallet identity ON CONFLICT, secrets atomic-replace + envelope size-limit, and the native-path address-pool persistence.

🤖 Co-authored by Claudius the Magnificent AI Agent

…eview)

After the on_platform_event removal, the `PlatformEvent` enum had zero
references repo-wide — events flow through the concrete
`PlatformEventHandler` methods (`on_wallet_skipped_on_load`, etc.), not a
dispatched enum. Remove the enum (and the `#[non_exhaustive]` just added
to it) plus its `lib.rs` re-export. Its only variant, `WalletSkippedOnLoad`,
went with it; the `on_wallet_skipped_on_load(wallet_id, &SkipReason)`
handler and `SkipReason` itself stay. No imports orphaned — `SkipReason`
and `WalletId` are still used by `PlatformEventHandler` /
`PlatformEventManager`.

Verified: `git grep PlatformEvent` over rs-platform-wallet, -ffi and
swift-sdk is empty (only `PlatformEventHandler` / `PlatformEventManager`
remain).

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@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/src/manager/rehydrate.rs (1)

204-244: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Preserve account attribution during core-state rehydration.

This always restores UTXOs and used-address state into the first funds-bearing account. For wallets with multiple funds accounts, rows belonging to later accounts are derived/marked against the wrong xpub, so their pool visibility and used flags can be lost. Carry account identity in the keyless start state and apply each UTXO/used address to its matching account; or fail closed for multi-funds-account snapshots until that mapping exists.

🤖 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/manager/rehydrate.rs` around lines 204 - 244,
The rehydration logic in the core-state restore path is always applying UTXOs
and used-address markings to the first funding account, which breaks
multi-funds-account wallets. Update the `rehydrate` flow to preserve account
identity from the snapshot/start state and route each restored UTXO and marked
address to the matching account instead of using
`all_funding_accounts_mut().next()`. If that mapping is not available yet, make
`rehydrate` fail closed for multi-account snapshots rather than silently
restoring into the wrong account.
🧹 Nitpick comments (1)
packages/rs-platform-wallet/tests/rehydration_load.rs (1)

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

Rename new tests to start with should_.

These test names are descriptive, but the current test guideline requires names to start with “should …”. As per coding guidelines, "**/tests/**/*.{js,jsx,ts,tsx,rs}: Name tests descriptively, starting with 'should …'".

Also applies to: 223-223, 270-270, 339-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/tests/rehydration_load.rs` at line 182, Rename
the newly added tests so they follow the test naming guideline by starting with
should_, updating the async test functions in rehydration_load.rs such as
rt_idempotent_repeat_restore and the other mentioned test cases to more
descriptive names that begin with should; keep the rest of each test body
unchanged and ensure the renamed symbols remain consistent wherever they are
referenced.

Source: Coding guidelines

🤖 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-platform-wallet/src/changeset/client_start_state.rs`:
- Around line 36-42: The `ClientStartState::is_empty()` logic is missing the
`skipped` field, so a state with only persister-rejected rows is treated as
empty even though `skipped` still needs to be reported. Update `is_empty()` in
`ClientStartState` to consider `skipped` alongside the other load-result
collections, using the `skipped: Vec<(WalletId, SkipReason)>` field as part of
the emptiness check. Also make the corresponding documentation/comment and any
related `LoadOutcome` handling consistent so a skipped-only result is never
collapsed into an empty load.

---

Outside diff comments:
In `@packages/rs-platform-wallet/src/manager/rehydrate.rs`:
- Around line 204-244: The rehydration logic in the core-state restore path is
always applying UTXOs and used-address markings to the first funding account,
which breaks multi-funds-account wallets. Update the `rehydrate` flow to
preserve account identity from the snapshot/start state and route each restored
UTXO and marked address to the matching account instead of using
`all_funding_accounts_mut().next()`. If that mapping is not available yet, make
`rehydrate` fail closed for multi-account snapshots rather than silently
restoring into the wrong account.

---

Nitpick comments:
In `@packages/rs-platform-wallet/tests/rehydration_load.rs`:
- Line 182: Rename the newly added tests so they follow the test naming
guideline by starting with should_, updating the async test functions in
rehydration_load.rs such as rt_idempotent_repeat_restore and the other mentioned
test cases to more descriptive names that begin with should; keep the rest of
each test body unchanged and ensure the renamed symbols remain consistent
wherever they are referenced.
🪄 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: 3241630a-897e-4cdf-b079-e2698f300be0

📥 Commits

Reviewing files that changed from the base of the PR and between 83f7d4f and 1f8d6b6.

📒 Files selected for processing (12)
  • packages/rs-platform-wallet-ffi/src/manager.rs
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet/src/changeset/client_start_state.rs
  • packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs
  • packages/rs-platform-wallet/src/events.rs
  • packages/rs-platform-wallet/src/manager/load.rs
  • packages/rs-platform-wallet/src/manager/load_outcome.rs
  • packages/rs-platform-wallet/src/manager/mod.rs
  • packages/rs-platform-wallet/src/manager/rehydrate.rs
  • packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs
  • packages/rs-platform-wallet/src/wallet/platform_wallet.rs
  • packages/rs-platform-wallet/tests/rehydration_load.rs
✅ Files skipped from review due to trivial changes (1)
  • packages/rs-platform-wallet/src/wallet/platform_wallet.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs
  • packages/rs-platform-wallet/src/manager/load_outcome.rs
  • packages/rs-platform-wallet/src/manager/mod.rs
  • packages/rs-platform-wallet/src/manager/load.rs
  • packages/rs-platform-wallet-ffi/src/manager.rs

Comment thread packages/rs-platform-wallet/src/changeset/client_start_state.rs
@lklimek lklimek requested a review from thepastaclaw June 29, 2026 15:55

@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

Carried-forward prior findings: prior-1, prior-2, and prior-3 are fixed in the current source; prior-4 remains valid because manager::rehydrate is still exported as public API. New findings in the latest delta: the PR removes the generation guard from both identity and shielded background sync loops, reintroducing a stale-thread cleanup race; the FFI malformed-xpub row is now skipped correctly but is still surfaced with the generic reason code despite the ABI documenting a dedicated malformed-xpub code.

🔴 2 blocking | 🟡 2 suggestion(s)

Carried-forward findings already raised (1)

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/manager/mod.rs:8: Rehydration internals are exposed as public APIpub mod rehydrate exposes restore-path helpers such as manager::rehydrate::apply_persisted_core_state to downstream crates even though the only in-tree non-test caller is the manager load path. These helpers mutate ManagedWalletInfo under seedless reconstruction assumptions and should stay...
🤖 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/identity_sync.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/identity_sync.rs:421-423: Old identity-sync thread can clear a newer cancellation token
  The base branch used `background_generation` so an exiting background thread only cleared `background_cancel` if it was still the active generation. This PR removes that guard and now clears the slot unconditionally. A `stop()` followed quickly by `start()` can install a new token while the old thread is still unwinding; when the old thread reaches this cleanup block, it erases the new token. After that `is_running()` reports false, `stop()`/`quiesce()` cannot cancel the still-live new loop, and shutdown can return while the loop later calls the FFI-backed persister through host-owned callback context.

In `packages/rs-platform-wallet/src/manager/shielded_sync.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/shielded_sync.rs:251-253: Old shielded-sync thread can make a new loop untrackable
  The base branch guarded this cleanup with a generation check, but the current code clears `background_cancel` unconditionally when any shielded-sync thread exits. A tight `stop()` -> `start()` overlap lets the old thread erase the new loop's cancellation token. The FFI stop path relies on `quiesce()` as the barrier before clear/unregister/rebind operations, but once the token is lost `quiesce()` can return while the replacement loop is still alive and can later persist shielded state or dispatch callbacks after the host believes sync is stopped.

In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/persistence.rs:1568-1572: Malformed Swift xpub rows use the generic skip code
  `SkippedWalletFFI.reason_code` documents `101` for malformed account xpubs, and `skip_reason_code` only emits that value for `CorruptKind::MalformedXpub`. The FFI loader now correctly skips a bad row instead of aborting the whole restore, but every `build_wallet_start_state` error, including `failed to decode account xpub`, is converted to `CorruptKind::DecodeError`, so Swift receives reason code `102`. That makes the newly exposed outcome API unable to distinguish malformed xpub rows from unrelated structural decode failures despite advertising a dedicated code.

In `packages/rs-platform-wallet/src/manager/mod.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/manager/mod.rs:8: Rehydration internals are exposed as public API
  `pub mod rehydrate` exposes restore-path helpers such as `manager::rehydrate::apply_persisted_core_state` to downstream crates even though the only in-tree non-test caller is the manager load path. These helpers mutate `ManagedWalletInfo` under seedless reconstruction assumptions and should stay behind `PlatformWalletManager::load_from_persistor`; leaving the module public freezes internal recovery mechanics as SDK API.

Comment thread packages/rs-platform-wallet/src/manager/identity_sync.rs
Comment thread packages/rs-platform-wallet/src/manager/shielded_sync.rs Outdated
Comment thread packages/rs-platform-wallet-ffi/src/persistence.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

Carried-forward prior findings: prior-1, prior-2, prior-3, and prior-4 are still valid in the current source at 8d88d49. New findings in the latest delta: none beyond the carried-forward issues; the latest three-file delta does not address the existing cancellation-token races, FFI skip-code mismatch, or public rehydration module exposure.

🔴 2 blocking | 🟡 2 suggestion(s)

Carried-forward findings already raised (4)

These findings were not re-posted as new inline comments because an existing review thread already covers them.

  • [BLOCKING] (deduped existing open thread) packages/rs-platform-wallet/src/manager/identity_sync.rs:421-423: Old identity-sync thread can clear a newer cancellation token — This PR removed the background_generation guard that previously let an exiting thread clear background_cancel only when its token was still current. The cleanup now unconditionally writes None, so a stop() followed quickly by start() can install a replacement token while the old OS thre...
  • [BLOCKING] (deduped existing open thread) packages/rs-platform-wallet/src/manager/shielded_sync.rs:251-253: Old shielded-sync thread can make a new loop untrackable — This cleanup has the same stale-thread race: the PR removed background_generation, and any exiting shielded-sync thread now clears background_cancel unconditionally. A tight stop() or quiesce() followed by start() can leave the previous thread racing with the new start, causing the prev...
  • [SUGGESTION] (deduped existing open thread) packages/rs-platform-wallet-ffi/src/persistence.rs:1568-1572: Malformed Swift xpub rows use the generic skip codeSkippedWalletFFI.reason_code documents 101 for malformed account xpubs, and skip_reason_code only returns that value for CorruptKind::MalformedXpub. However, FFIPersister::load maps every build_wallet_start_state failure to CorruptKind::DecodeError, including the explicit `failed to...
  • [SUGGESTION] (deduped existing open thread) packages/rs-platform-wallet/src/manager/mod.rs:8: Rehydration internals are exposed as public APIpub mod rehydrate exports restore-path internals such as manager::rehydrate::apply_persisted_core_state to downstream crates, while the only in-tree non-test caller is the manager load path. These helpers mutate ManagedWalletInfo under the PR's seedless reconstruction assumptions and should...
🤖 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/identity_sync.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/identity_sync.rs:421-423: Old identity-sync thread can clear a newer cancellation token
  This PR removed the `background_generation` guard that previously let an exiting thread clear `background_cancel` only when its token was still current. The cleanup now unconditionally writes `None`, so a `stop()` followed quickly by `start()` can install a replacement token while the old OS thread is still unwinding, then have the old thread erase that new token. Once that happens, `is_running()` reports false and later `stop()` or `shutdown()` cannot cancel the still-live replacement loop; after `quiesce()` reopens its gate, that loop can run again and call the FFI-backed persister through host-owned callback context after the host believes shutdown is complete.

In `packages/rs-platform-wallet/src/manager/shielded_sync.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/shielded_sync.rs:251-253: Old shielded-sync thread can make a new loop untrackable
  This cleanup has the same stale-thread race: the PR removed `background_generation`, and any exiting shielded-sync thread now clears `background_cancel` unconditionally. A tight `stop()` or `quiesce()` followed by `start()` can leave the previous thread racing with the new start, causing the previous thread to erase the new loop's cancellation token. The FFI stop, clear, and destroy paths rely on quiescence before the host clears or releases Swift-owned persistence and event callback contexts; after the token is lost, a later quiesce can return while the replacement loop is still alive and able to persist shielded state or dispatch callbacks.

In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/persistence.rs:1568-1572: Malformed Swift xpub rows use the generic skip code
  `SkippedWalletFFI.reason_code` documents `101` for malformed account xpubs, and `skip_reason_code` only returns that value for `CorruptKind::MalformedXpub`. However, `FFIPersister::load` maps every `build_wallet_start_state` failure to `CorruptKind::DecodeError`, including the explicit `failed to decode account xpub` error from decoding `AccountSpecFFI.account_xpub_bytes`. Restore now correctly skips the corrupt row instead of aborting the batch, but Swift receives reason code `102`, so the newly exposed outcome ABI cannot distinguish malformed xpub rows from unrelated structural decode failures despite advertising a dedicated code.

In `packages/rs-platform-wallet/src/manager/mod.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/manager/mod.rs:8: Rehydration internals are exposed as public API
  `pub mod rehydrate` exports restore-path internals such as `manager::rehydrate::apply_persisted_core_state` to downstream crates, while the only in-tree non-test caller is the manager load path. These helpers mutate `ManagedWalletInfo` under the PR's seedless reconstruction assumptions and should remain behind `PlatformWalletManager::load_from_persistor`; keeping the module public freezes the recovery mechanics as SDK API without an external contract.

@thepastaclaw

Copy link
Copy Markdown
Collaborator

Correction from PastaClaw: the earlier review-bot triage note at #3692 (comment) was generated by PastaClaw during heartbeat handling, but it was posted through the wrong GitHub identity. Please treat it as agent-authored review-bot triage, not as Pasta speaking.

Reposting under the correct account for attribution:

Processed the CodeRabbit batch against current head 8d88d494.

Most of the older inline CodeRabbit threads are already resolved. Two current CodeRabbit items still look live and are not covered by my latest CHANGES_REQUESTED review:

  • packages/rs-platform-wallet/src/changeset/client_start_state.rs: ClientStartState::is_empty() still ignores skipped, so a load result containing only persister-skipped rows can be treated as empty even though LoadOutcome::skipped and skip notifications still need to surface.
  • packages/rs-platform-wallet/src/manager/rehydrate.rs: apply_persisted_core_state() still restores UTXOs / used-address state through the first funds-bearing account. The current docs describe this as a re-warm-on-sync limitation, but the CodeRabbit outside-diff point is still worth an explicit decision: either preserve account attribution/fail closed for multi-funds-account snapshots, or make the accepted limitation very clear in the PR before merge.

Separately, my current blocking review still stands on the identity/shielded sync cancellation-token cleanup races.

thepastaclaw pushed a commit to thepastaclaw/platform that referenced this pull request Jun 29, 2026
… join [dashpay#3954, independent on v3.1-dev]

Introduce a shared `ThreadRegistry<WalletWorker>` lifecycle engine in
`dash-async` (weight-ordered graceful shutdown: lower weights drain first,
equal weights concurrently; reap-or-park orphan handling; cancellation-safe
`RefcountedFlagGuard`). Migrate the three periodic sync coordinators
(identity / platform-address / shielded) onto a shared `CoordinatorLifecycle`
that drives them through the registry and gates passes via an
`is_syncing` / `quiescing` handshake.

`PlatformWalletManager::shutdown()` now holds every coordinator's quiescing
gate across the whole teardown, quiesces the weight-0 coordinators
concurrently, then drains the weight-10 wallet-event adapter and any parked
orphans, returning a per-worker `ShutdownReport`. `clear_shielded` refuses
(leaving the commitment-tree store intact) when the in-flight pass does not
drain cleanly, surfaced as `ShieldedShutdownIncomplete` /
`ErrorShutdownIncomplete = 19`. FFI `destroy` reports the incomplete-shutdown
code so the Swift host defers freeing its callback context.

Reconstructed independently on a clean v3.1-dev base: only the graceful-
shutdown contribution is included; the drifted dashpay#3692 rehydration/storage
work merged into the source branch is excluded.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
lklimek and others added 3 commits June 30, 2026 07:33
…d on rehydrate (#3692 review)

#3692 inadvertently deleted the per-manager `background_generation`
guard from `identity_sync` and `shielded_sync` (the change was curated
from the stacked branch where it was "superseded by #3954's
ThreadRegistry"). On the *independent* #3692 there is no ThreadRegistry,
so the deletion reintroduces the race thepastaclaw flagged: a tight
stop() -> start() lets an exiting old thread's cleanup unconditionally
clear `background_cancel`, wiping the *new* loop's token. is_running()
then reports false and stop()/quiesce() can no longer cancel the
still-live new loop.

Fix: revert both files to v3.1-dev base, so #3692 no longer touches the
sync managers at all and the base guard stands. The centralized
lifecycle replacement (ThreadRegistry / CoordinatorLifecycle) remains
#3954's scope; in the integration branch #3954's version supersedes
this.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
#3692 review)

`is_empty()` omitted the `skipped` set, so a load that reconstructed no
wallets but rejected one or more rows reported empty — which would let a
skipped-only result short-circuit `LoadOutcome::skipped` and the
`on_wallet_skipped_on_load` notifications. Count `skipped` so a
skipped-only state is non-empty. Adds a regression test.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ount xpub (#3692 review)

The FFI load path mapped every `build_wallet_start_state` failure to
`CorruptKind::DecodeError` (reason_code 102), so the dedicated
`MalformedXpub` family (code 101 — already wired in `skip_reason_code`
and documented on `WalletSkipInfoFFI.reason_code`) was dead: Swift could
never distinguish an unrecoverable malformed-xpub row from any other
decode failure.

Classify the error via `corrupt_kind_from_build_err`, keyed off the
producer's stable `MALFORMED_XPUB_ERR_PREFIX` constant (shared between
the decode site and the classifier so the two can't drift). Adds a
regression test.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Opus 4.6 <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 latest push fixed the identity-sync generation cleanup and the malformed-xpub skip-code classification, including a regression test for the latter. One blocking shielded-sync race remains because the generation check is still performed before acquiring the cancel-token mutex, and the rehydration helper module is still exposed as public Rust API despite only being used internally by the manager load path.

🔴 2 blocking | 🟡 1 suggestion(s)

Findings not posted inline (1)

These findings could not be anchored to the current diff, but they are still part of this review.

  • [BLOCKING] packages/rs-platform-wallet/src/manager/shielded_sync.rs:270-273: Shielded sync can still clear a newer cancellation token — The cleanup checks background_generation before locking background_cancel. An old loop can read its own generation, then a stop/start sequence can install a new token and increment the generation while holding the mutex; when the old loop later acquires the mutex, it still executes the alread...
Carried-forward findings already raised (1)

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/manager/mod.rs:8: Rehydration internals are exposed as public APIpub mod rehydrate makes restore internals such as manager::rehydrate::apply_persisted_core_state available to downstream crates, while the only in-tree non-test caller is PlatformWalletManager::load_from_persistor. These helpers mutate ManagedWalletInfo under seedless reconstruction assum...
🤖 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/shielded_sync.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/shielded_sync.rs:270-273: Shielded sync can still clear a newer cancellation token
  The cleanup checks `background_generation` before locking `background_cancel`. An old loop can read its own generation, then a stop/start sequence can install a new token and increment the generation while holding the mutex; when the old loop later acquires the mutex, it still executes the already-passed branch and clears the new token. After that `is_running()`, `stop()`, `quiesce()`, and shutdown lose track of the replacement loop, so the Rust background thread can continue using persistence/event callback contexts after Swift believes the loop has stopped or the manager has been destroyed.
- [BLOCKING] packages/rs-platform-wallet/src/manager/shielded_sync.rs:270-273: Shielded sync can still clear a newer cancellation token
  The cleanup checks `background_generation` before locking `background_cancel`. An old loop can read its own generation, then a stop/start sequence can install a new token and increment the generation while holding the mutex; when the old loop later acquires the mutex, it still executes the already-passed branch and clears the new token. After that `is_running()`, `stop()`, `quiesce()`, and shutdown lose track of the replacement loop, so the Rust background thread can continue using persistence/event callback contexts after Swift believes the loop has stopped or the manager has been destroyed.

In `packages/rs-platform-wallet/src/manager/mod.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/manager/mod.rs:8: Rehydration internals are exposed as public API
  `pub mod rehydrate` makes restore internals such as `manager::rehydrate::apply_persisted_core_state` available to downstream crates, while the only in-tree non-test caller is `PlatformWalletManager::load_from_persistor`. These helpers mutate `ManagedWalletInfo` under seedless reconstruction assumptions and do not define a stable external contract, so exposing the module freezes implementation details as SDK API unnecessarily.

@Claudius-Maginificent

Copy link
Copy Markdown
Collaborator Author

Re: thepastaclaw's 2026-06-30 re-review — confirming the two resolved items (identity-sync generation cleanup + malformed-xpub skip-code 101 with its regression test), and dispositioning the remaining body-only finding.

🔴 shielded_sync.rs generation-check-before-lock — dispositioned as pre-existing base, out of scope for this PR.

🟡 pub mod rehydrate — unchanged by design: load-bearing across the crate boundary for the feature-gated rehydration e2e (see the resolved thread). WONTFIX-with-rationale.

🤖 Co-authored by Claudius the Magnificent AI Agent

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants