feat(platform-wallet): watch-only rehydration from persistor (seedless load)#3692
Conversation
|
Important Review skippedDraft detected. 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 |
schema::accounts::load_state reads account_registrations rows back into a deterministic Vec<AccountRegistrationEntry> manifest — the account-set oracle and per-account xpub cross-check source for rehydration. Mints no Wallet, fail-hard on a corrupt blob. RT: sqlite_accounts_reader (3 tests). Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…wrong-seed gate (S) - platform-wallet: storage-agnostic SeedProvider trait with zeroizing, Debug-redacted SecretPhrase/SecretSeed newtypes (M-DONT-LEAK-TYPES); SeedUnavailable/SecretStoreErrorKind structural projections. - manager::rehydrate::rehydrate_wallet: fail-closed, constant-time wrong-seed gate (compute_wallet_id recompute + per-account xpub cross-check via subtle) yielding typed WrongSeedForDatabase that carries only the two 32-byte ids. AR-7 noted at the call site. - manager::rehydrate::apply_persisted_core_state: keyless CoreChangeSet → ManagedWalletInfo apply (balance no-silent-zero contract). - load_from_persistor signature → (&dyn SeedProvider) -> LoadOutcome; seed-unavailable ⇒ skip (continue before insert, LoadOutcome.skipped, PlatformEvent::WalletSkippedOnLoad); wrong seed ⇒ hard-fail. - ClientWalletStartState made keyless by type (no Wallet/seed field). - platform-wallet-storage: secrets-gated CredentialStoreSeedProvider adapter over `keyring_core::api::CredentialStoreApi` (mnemonic→seed label order, no secret in logs/errors). File-backend WrongPassphrase is recovered via `downcast_failure` on the cross-SPI marker so the operator-actionable case survives the seam. RT: seed_provider (4) + rehydrate (3) unit tests, secrets_seed_provider _adapter (10). secrets_scan/secrets_guard still green. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
core_state::load_state rebuilds the keyless CoreChangeSet projection (unspent UTXOs with address recovered from script+network, tx records, IS-locks, sync watermarks) for one wallet — the safety-critical balance source. spent rows excluded; fail-hard on a corrupt blob. Documents the reconstructed-vs-deferred split: last_applied_chain_lock / per-account-attribution / coinbase flags re-warm on first post-load sync (the no-V001-column deviation from dev-plan §5 is recorded inline). RT-2 (sqlite_core_state_reader): a non-zero balance survives store→drop→reopen→load→apply, reconstructed exact in the confirmed bucket — the no-silent-zero contract proven end-to-end. 4 tests. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…on reader (A2) asset_locks::load_unconsumed excludes terminal 'consumed' rows at the SQL level so a spent one-shot lock never resurrects as actionable on rehydration (A04/A08); historical rows stay on disk via load_state. Corrects the factually-wrong list_active doc-comment (consumed locks do NOT leave via AssetLockChangeSet::removed — they upsert and persist). RT-4 (sqlite_asset_locks_filter): mix incl. terminal Consumed — row still on disk, absent from filtered feed, non-terminal survive. 2 tests. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…d (C) SqlitePersister::load() now populates ClientStartState.wallets with the keyless per-wallet payload (network, birth_height, account_manifest, core_state, identity_manager, Consumed-filtered unused_asset_locks) via the A1/B/A2 readers + identities::load_state. Return type carries no Wallet/seed by construction. Real wallets_rehydrated tracing count; LOAD_UNIMPLEMENTED shrunk to the genuinely-deferred set (contacts/identity_keys/last_applied_chain_lock); load() rustdoc corrected. RT (sqlite_load_wiring): keyless payload round-trips, empty DB stays empty, metadata-only wallet still present. 3 tests. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
rehydration_load: load_from_persistor through a real PlatformWalletManager (mock SDK, in-memory keyless persister, test SeedProvider) — - seed round-trip: wallet registered + signing-capable by construction; - RT-W: present-but-wrong seed ⇒ WrongSeedForDatabase, NOT in skipped, NO WalletSkippedOnLoad event, wallet absent; - RT-S: seed absent ⇒ skip (other wallets load, skipped wallet ABSENT from manager, LoadOutcome.skipped + exactly one WalletSkippedOnLoad event, Ok), then recoverable on a fresh targeted re-load; - RT-S(ii): KeyringLocked ⇒ StoreUnavailable skip; - RT-Z: no seed byte leaks into LoadOutcome / SkipReason / WrongSeedForDatabase Display+Debug. 5 tests. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…tion (F) sqlite_load_reconstruction: header rewritten (no longer 'blocked on upstream Wallet::from_persisted'); tc_p4_006/tc_p4_007 now assert wallets_rehydrated=N / pending=0 and a populated wallets payload; tc_p4_012 asserts O(1)-per-wallet + small constant shared overhead (no brittle magic-number pin) instead of the old fixed-2. All 13 green. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
… SELECTs (F) The full-rehydration readers (accounts/core_state load_state) use prepare() for one-shot SELECTs by design; add them to READ_ONLY_PREPARE_ALLOWED so tc_p1_003 (writers must use prepare_cached) stays green without weakening the writer-side rule. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
… doc (F2,F3) F2 (MEDIUM): apply_persisted_core_state previously routed persisted UTXOs only into the first BIP44 account, silently dropping ALL UTXOs (→ Ok + balance 0) for CoinJoin-only / non-BIP44 topologies. Now route into the wallet's first funds-bearing account of ANY topology (BIP44/ BIP32/CoinJoin/DashPay) via all_funding_accounts_mut(); the wallet total stays exact (it is a sum). A wallet with persisted UTXOs but no funds account at all fails closed with the new typed PlatformWalletError::RehydrationTopologyUnsupported (wallet_id + utxo_count, no key material) instead of a silent zero. Signature is now Result<(), PlatformWalletError>. F3 (LOW): moved the last_applied_chain_lock bullet from the 'Reconstructed' to the 'Deferred' rustdoc section (it is always None from disk — no V001 column). RT: f2_no_bip44_wallet_nonzero_balance_survives_reopen (CoinJoin-only, 9_000_000 duffs) fail→pass; RT-2 + B-2/B-3/B-4 still green. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
F4 (LOW): the plain '!=' wallet_id re-check after insert_wallet was shadowed-dead — the constant-time rehydrate_wallet gate already proves compute_wallet_id() == expected_wallet_id pre-insert and a mismatch is the typed fail-closed WrongSeedForDatabase. The legacy check only emitted a weaker untyped WalletCreation error and confused readers; removed. Also wires the F2 apply_persisted_core_state Result into the hard-fail/rollback path. RT-W still passes (typed WrongSeedForDatabase from the real gate unaffected). Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…PI (F1) F1 (HIGH): workspace no longer compiled against the new load_from_persistor signature / keyless ClientWalletStartState. - New ResolverSeedProvider wraps the existing Swift MnemonicResolver- Handle vtable (same mechanism as sign_with_mnemonic_resolver) as a SeedProvider — minimal correct seed source, no second secret path, no mnemonic round-tripping. Chosen over SecretStoreSeedProvider because the iOS host already owns the resolver, not a SecretStore. - build_wallet_start_state now projects its reconstructed wallet + wallet_info into the keyless ClientWalletStartState shape (account_manifest from accounts, core_state CoreChangeSet from the restored UTXO set + sync watermarks); the local Wallet is dropped (manager re-derives from the resolver seed + runs the wrong-seed gate). - platform_wallet_manager_load_from_persistor gains a resolver param and an optional *mut LoadOutcomeFFI out-param: the LoadOutcome is no longer silently discarded — every skipped (wallet_id, reason) is logged AND surfaced (loaded_count/skipped_count/skipped[]) so the host can retry the skipped set. New platform_wallet_load_outcome_free releases the heap array. Acceptance: cargo check --workspace AND --all-features both exit 0. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
b573fca to
b7508a0
Compare
…m-wallet-rehydration # Conflicts: # packages/rs-platform-wallet-ffi/src/manager.rs
|
📖 Book Preview built successfully. Download the preview from the workflow artifacts. Updated at 2026-05-22T10:49:18.857Z |
🔁 Seedless-load rework landed —
|
…atch_only
load_from_persistor now rebuilds every persisted wallet watch-only from
its keyless AccountRegistrationEntry manifest (Wallet::new_watch_only)
and applies the keyless core-state projection on top. No seed material
is touched on the load path: signing keys are derived on demand later
through the MnemonicResolverHandle sign entrypoints, which carry the
fail-closed wrong-seed gate themselves.
Drops the SeedProvider port + WalletSecret/SecretPhrase/SecretSeed
payloads (and the storage CredentialStoreSeedProvider adapter that fed
them) — load no longer needs the abstraction. WrongSeedForDatabase
stays on PlatformWalletError for the sign-path gate. RT suite reshapes
to RT-WO (watch-only round-trip) + RT-Corrupt (per-row decode skip with
SkipReason::CorruptPersistedRow{kind: CorruptKind::MissingManifest}) +
RT-Z (no key material in any LoadOutcome / SkipReason surface).
apply_persisted_core_state and its F2/F3/F4 fixes are unchanged.
AR-7 residual risk on the load path is eliminated (no Wallet of a
signing type is constructed during load, so its Debug-leak surface is
gone from this path).
Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…stor platform_wallet_manager_load_from_persistor is now a 2-arg call (manager_handle, out_outcome). The Swift host never passed a real resolver at load time anyway — load is watch-only, signing keys are derived later on demand through the same MnemonicResolverHandle vtable the per-call sign entrypoints already use (next commit lands the wallet_id gate there). Drops the MnemonicResolverHandle → platform_wallet::SeedProvider adapter (rehydration_seed_provider.rs); no consumer left. LoadOutcomeFFI.SkippedWalletFFI.reason_code reshapes to the new CorruptKind family (100/101/102) — ABI-bump for #3692 since the seed- availability codes (0/1/2) it previously carried are gone with the seedless load path. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…edless FFI platform_wallet_manager_load_from_persistor is now (handle, out_outcome) — the resolver argument is gone with the seedless-load rework. Pass nil for out_outcome (Swift doesn't surface skipped wallets yet; corrupt rows are logged on the Rust side). Doc string refreshed to reflect Wallet::new_watch_only as the underlying load primitive and the on-demand-signing + wrong-seed-gate contract on the resolver-fed sign entrypoints. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
… sign-gate split The wrong-seed detection moves off the load path and onto the resolver-fed FFI sign entrypoints. That gate + its coverage now ships in PR #3735 (security patch against v3.1-dev), not here. Drop the dangling reference to the never-existed `sign_wrong_seed_gate.rs` file and point readers at PR #3735 instead. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
0e92cb4 to
f57b117
Compare
…m-wallet-rehydration
Issue being fixed or feature implemented
After the SQLite persister landed (#3625), restarting the wallet app required a full re-scan from birth height — the DB held all the data but nothing reconstituted live wallets from it. This PR closes that gap.
The user story matches how the real iOS host works. The app launches with the Keychain locked. There is no seed in memory. The wallet UI needs to come back instantly with all balances, UTXOs, identities, and asset-lock state — without prompting the user to unlock — so they can see their funds, scroll their history, and decide whether to act. Only when they do act (sign a transaction, register an identity key) does the Keychain unlock and the seed arrive, gated to that one operation. This was validated against
dashwallet-ios(swift-sdk-integrationbranch):loadFromPersistor()is zero-arg, called at app launch with locked Keychain; signing flows take theMnemonicResolverHandlevtable on demand.The implementation reflects that: load is seedless and watch-only. Every persisted wallet comes back as
Wallet::new_watch_only(...)— no key material derived, no signing capability, no seed touched. Wrong-seed detection moves to the sign path — covered by the companion security PR #3735 againstv3.1-dev.What was done?
Seedless watch-only load (
rs-platform-wallet)PlatformWalletManager::load_from_persistor()reconstructs each persisted wallet from the keylessClientWalletStartState:For each wallet in the persisted
walletsmap, the manager:AccountCollectionfrom theaccount_manifest: oneAccount::from_xpub(parent_wallet_id, account_type, account_xpub, network)perAccountRegistrationEntry.Wallet::new_watch_only(network, wallet_id, accounts)—key_wallet::WalletType::WatchOnlyvariant, noMnemonic/Seedvariant, no key bytes anywhere.CoreChangeSet(UTXOs, tx records, IS-locks, sync watermarks) into the wallet via the existingapply_persisted_core_state(...)path, which correctly handles non-BIP44 topologies (CoinJoin-only / DashPay) viaall_funding_accounts_mut()— the F2 silent-zero balance fix carries through.A wallet whose persisted rows fail to decode is skipped, not silently mis-loaded.
LoadOutcome.skippedcarries(WalletId, SkipReason::CorruptPersistedRow { kind: CorruptKind })whereCorruptKindisMissingManifest | MalformedXpub | DecodeError(String). APlatformEvent::WalletSkippedOnLoad { wallet_id, reason }fires per skip. One corrupt row never aborts the rest. The caller receivesOk(LoadOutcome)(non-emptyskippedis success, not an error).New schema readers
schema::accounts::load_stateaccount_registrations+ pools; decodesAccountRegistrationEntry; noWalletbuiltschema::core_state::load_stateManagedWalletInfo— UTXOs, tx records, IS-locks, derived-address flags, sync watermarks,last_applied_chain_lock; routes UTXOs to the first funds-bearing account of any topology (no BIP44 assumption); no silent zero balanceschema::asset_locks::load_unconsumedConsumedrows at SQL level (WHERE status NOT IN ('consumed'))FFI
The resolver arg is gone — load is purely watch-only.
LoadOutcomeFFIsurfacesloaded_count/skipped_count/skipped[]so the host can retry skipped wallets after a corruption-fix flow.Swift wrapper
PlatformWalletManager.swift::loadFromPersistor()aligns to the new 2-arg + outparam C signature (passesnilfor the outcome ptr — the iOS host doesn't surface skip reasons to the UI today).No V002 migration
Every column required for this phase is in V001. No SQL migration is added.
Not in this PR
ClientWalletStartStatehas no contacts slot; wiring requires a changeset-shape change. Deferred to PR-3 (feat(platform-wallet): add contacts and identity-key rehydration (item G) #3693).v3.1-devdirectly). Once merged + merge-up, the gate ships with this PR's lineage too.How Has This Been Tested?
Result: 410 tests passed, 0 failed, 8 ignored. Doctests: 3 passed, 0 failed, 1 ignored.
Targeted suite (
packages/rs-platform-wallet/tests/rehydration_load.rs):load_from_persistor(); assert every wallet comes back asWallet::WatchOnlywith correctwallet_id, accounts, balances. No seed ever touched.LoadOutcome.skippedwithCorruptPersistedRow, the other wallets load cleanly, exactly onePlatformEvent::WalletSkippedOnLoadfires.LoadOutcome,SkipReason,WalletSkippedOnLoadpayloads carry no key material inDisplayorDebug.Persister-side readers:
cargo test -p platform-wallet-storage --test sqlite_accounts_reader \ --test sqlite_core_state_reader \ --test sqlite_asset_locks_filter \ --test sqlite_load_wiring \ --test sqlite_load_reconstruction13/13
tc_p4_*passes including corruption-is-hard-error variants.Breaking Changes
This PR rewrites a load path that was added in earlier commits of this same PR (and has never shipped). There are no breaking changes against
v3.1-dev. For reviewers tracking the in-PR evolution:PlatformWalletManager::load_from_persistor()no longer takes a&dyn SeedProvider(the trait itself was deleted —MnemonicResolverHandleis the on-demand contract).ClientWalletStartStateno longer carries aWalletfield (assembled in the manager viaWallet::new_watch_only).platform_wallet_manager_load_from_persistor.No
!in the title because this is additive capability on an unreleased API —v3.1-devcarries none of the previous PR-internal shapes.AR-7 hygiene
Load path eliminates AR-7 entirely — the manager never constructs
WalletType::Mnemonic|Seed, onlyWalletType::WatchOnly(no key material). AR-7's residualDebugconcern was about derivedWalletvalues on the load path; that path no longer derives.Sign path keeps AR-7 discipline (
Zeroizing+non_secure_erase()); the sign-time wallet_id gate that enforces it ships in PR #3735.Checklist:
For repository code-owners and collaborators only
🤖 Co-authored by Claudius the Magnificent AI Agent
Rebuild note (2026-05-25): History rewritten to remove the sign-gate code that was extracted to PR #3735. The 5-commit minimal rework on top of the original PR-1 rehydration work yields a focused diff: watch-only load via
Wallet::new_watch_only, FFI resolver-arg drop, Swift wrapper align. The sign-time wallet_id gate ships via #3735 againstv3.1-dev.