feat(platform-wallet): add full wallet rehydration from persistor + seed (skip-and-report)#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 |
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 there was no code to reconstitute a signing
Walletfrom it. This PR closes that gap: given the user's seed at unlock time, the platform wallet manager now restores full signing wallets from the local DB without re-scanning.The user story: imagine you are a mobile wallet app. The user opens the app, provides their mnemonic (or seed bytes), and the OS keyring hands you a
SecretBytes. You callload_from_persistor(&seed_provider). Moments later, every wallet the user had before restart is back — with balances, UTXOs, identities, and asset-lock state — ready to sign. No re-scan. No birth-height replay. No degraded mode.What was done?
The implementation follows the design in the rehydration plan (internal). 29 files changed, ~2,850 lines net.
SeedProviderport +SecretStoreadapter + FFIResolverSeedProviderrs-platform-wallet::SeedProvider— a newSend + Synctrait defined in the manager crate (storage-agnostic, no import of storage types). ReturnsWalletSecret::Mnemonic(SecretString) | Seed(SecretBytes)or a typedSeedUnavailableerror that carries no secret material.rs-platform-wallet-storage::secrets::SeedProviderAdapter— implementsSeedProviderover anArc<dyn SecretStore>, gated behind thesecretsfeature. Looks up"mnemonic"first (→Wallet::from_mnemonic), then"seed"(→Wallet::from_seed_bytes); mapsOk(None)/SecretStoreErrorvariants toSeedUnavailablewithout leaking any secret bytes or stringified sources.rs-platform-wallet-ffi::ResolverSeedProvider— wraps the existing iOSMnemonicResolverHandlepattern so the FFI consumer (already adapted in this PR) feeds the same resolver interface intoload_from_persistor.Keyless
PersistedWalletDataDTO (persister never sees the seed)SqlitePersister::load()now returns aPersistedWalletDatastruct — network, birth height, wallet ID, account manifest, core state, identity manager start state, and filtered asset locks — but deliberately noWalletand no seed field. The SECRETS.md boundary is enforced by type, not convention: the return type structurally cannot hold a signing wallet or any key material.Wrong-seed gate — constant-time, fail-closed (
WrongSeedForDatabase)After deriving the
Walletfrom the seed,load_from_persistorrecomputeswallet.compute_wallet_id()(root-key-derived,mod.rs:118) and compares it against the persistedwallet_id, then cross-checks each seed-derived account xpub against the persistedaccount_registrations.account_xpub. Any mismatch raisesWrongSeedForDatabase { expected_wallet_id, derived_wallet_id }— fail-closed, no partial state applied, no entry inLoadOutcome.skipped. This is a genuine cryptographic guard (A07/A08), not a tautology.Skip-and-report contract
A wallet whose seed is unavailable (store returns
Ok(None), keyring locked, backend unavailable) is skipped — absent fromwallet_managerandself.wallets, not degraded, not a locked placeholder. One unavailable seed never aborts the others. The caller receivesOk(LoadOutcome)(non-emptyskippedis success, not an error) and aPlatformEvent::WalletSkippedOnLoad { wallet_id, reason }per skip, reusing the established fan-out event channel.Skip is distinct from wrong-seed:
SkipReason= seed absent or store locked (recoverable, retry after unlock);WrongSeedForDatabase= seed present but cryptographically wrong (security event, hard fail for that wallet).SkipReasonvariants carry only non-secret structural kind enums (SecretStoreErrorKind) — no secret bytes, no label values, no stringifiedSecretStoreErrorsources (SECRETS.md SEC-REQ-2.0.1).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 the SQL level (WHERE status NOT IN ('consumed')); fixes the stalelist_activedoc-comment that claimed consumed locks leave viaremoved(they do not — they are upserted and remain on disk)Wiring (
SqlitePersister::load+manager/rehydrate.rs)manager/rehydrate.rs(new file, 381 lines) assembles the full reconstruction sequence per wallet: persister reads keyless DTO → manager fetches seed fromSeedProvider→ derivesWallet→ wrong-seed gate → assemblesClientWalletStartState(wallet + wallet_info from B + identities from existingidentities::load_state+ filtered asset-locks from A2) → enters the existingmanager/load.rsloop unchanged. Real tracing counts replace the previous placeholder zeros;LOAD_UNIMPLEMENTEDshrinks to["contacts", "identity_keys"](deferred to PR-3).FFI adaptation
rs-platform-wallet-ffiadapted to the newload_from_persistor(&dyn SeedProvider)signature.LoadOutcomeFFIC-ABI struct surfacesloaded_countandskipped_countto callers. The existing iOS mnemonic resolver is wired asResolverSeedProvider.No V002 migration
Every column required for this phase is in V001. No SQL migration is added. Contacts and identity-keys rehydration (schema readers exist; changeset shape gap) are deferred to PR-3.
How Has This Been Tested?
Static verification
Rehydration test suite (RT-*)
Coverage spans: seed round-trip (sign after reload), wrong-seed hard-fail (distinct from skip), skip-and-report with recoverability, secret hygiene across tracing / Display / Debug / SQLite file, asset-lock Consumed filter correctness, no-BIP44 UTXO routing, corrupt-blob fail-hard, manager integration.
Note: CI has not yet been run on this branch. The test suite ran locally against the
feat/platform-wallet-storage-secretsbase. Green CI is a merge gate, not a pre-review gate for a stacked draft.This PR also received internal multi-agent review (security, QA, architecture consistency) prior to opening.
Breaking Changes
load_from_persistorgains a&dyn SeedProviderparameter and returnsLoadOutcomeinstead of():Before (on base):
After:
This is an additive capability change on an unreleased API (no semver-versioned release of
rs-platform-walletexists yet). The sole in-tree FFI consumer (rs-platform-wallet-ffi) has been adapted in commitb573fcad0ca854b9b27001184f0de1ff3e20b750. External callers (none known) would need to supply aSeedProviderimplementation — the existingSeedProviderAdapter(storage crate,secretsfeature) orResolverSeedProvider(FFI crate) are the ready-made implementations.ClientWalletStartStateno longer carries aWalletfield — it is now assembled in the manager after seed derivation. This is an internal changeset type not part of any public ABI.Known limitations and accepted risks
AR-7 — upstream
key-wallet::WalletTypederivedDebugcan render key material. TheWalletType::Mnemonic { mnemonic, root_extended_private_key }variants deriveDebugupstream. Our code never calls{:?}on aWalletand theWrongSeedForDatabaseerror carries only two 32-byte wallet IDs (no key fields).LoadOutcome,SkipReason, and all tracing spans are confirmed key-material-free (RT-Z). The upstreamcompute_wallet_idinternalDebughygiene is a tracked follow-up owed to the key-wallet crate maintainers; it does not affect the security boundary of this PR.Per-account UTXO attribution and
last_applied_chain_lockre-warm. After rehydration, per-account balance is exact (RT-2 covers this).last_applied_chain_lockis restored from the persisted core blob. The asset-lock proof-resume metadata fallback fires correctly on the first post-load sync. Confirmed in test.Contacts and identity-keys not yet wired.
ClientWalletStartStatehas no contacts slot; wiring requires a changeset-shape change touching thers-platform-walletpublic API. Deferred to PR-3.LOAD_UNIMPLEMENTEDis updated to reflect exactly this remaining scope.Checklist:
For repository code-owners and collaborators only
🤖 Co-authored by Claudius the Magnificent AI Agent
Rebase note (2026-05-20): Rebased onto new #3672 tip (
3eefec2174); commit S amended to usekeyring_core::CredentialStoreApiadapter (customSecretStoreSPI retired); 11 commits preserved.