Skip to content

feat(platform-wallet): add full wallet rehydration from persistor + seed (skip-and-report)#3692

Draft
Claudius-Maginificent wants to merge 12 commits into
feat/platform-wallet-storage-secretsfrom
feat/platform-wallet-rehydration
Draft

feat(platform-wallet): add full wallet rehydration from persistor + seed (skip-and-report)#3692
Claudius-Maginificent wants to merge 12 commits into
feat/platform-wallet-storage-secretsfrom
feat/platform-wallet-rehydration

Conversation

@Claudius-Maginificent
Copy link
Copy Markdown
Collaborator

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


STACKED PR — review diff against feat/platform-wallet-storage-secrets (PR #3672), not against v3.1-dev.

Merge order: #3625 (feat/platform-wallet-sqlite-persistor) → #3672 (feat/platform-wallet-storage-secrets) → this PR.

PR #3672 (the secrets feature / SecretStore API) is a hard prerequisite. Do not merge this PR until #3672 lands.


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 Wallet from 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 call load_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.

SeedProvider port + SecretStore adapter + FFI ResolverSeedProvider

  • rs-platform-wallet::SeedProvider — a new Send + Sync trait defined in the manager crate (storage-agnostic, no import of storage types). Returns WalletSecret::Mnemonic(SecretString) | Seed(SecretBytes) or a typed SeedUnavailable error that carries no secret material.
  • rs-platform-wallet-storage::secrets::SeedProviderAdapter — implements SeedProvider over an Arc<dyn SecretStore>, gated behind the secrets feature. Looks up "mnemonic" first (→ Wallet::from_mnemonic), then "seed" (→ Wallet::from_seed_bytes); maps Ok(None) / SecretStoreError variants to SeedUnavailable without leaking any secret bytes or stringified sources.
  • rs-platform-wallet-ffi::ResolverSeedProvider — wraps the existing iOS MnemonicResolverHandle pattern so the FFI consumer (already adapted in this PR) feeds the same resolver interface into load_from_persistor.

Keyless PersistedWalletData DTO (persister never sees the seed)

SqlitePersister::load() now returns a PersistedWalletData struct — network, birth height, wallet ID, account manifest, core state, identity manager start state, and filtered asset locks — but deliberately no Wallet and 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 Wallet from the seed, load_from_persistor recomputes wallet.compute_wallet_id() (root-key-derived, mod.rs:118) and compares it against the persisted wallet_id, then cross-checks each seed-derived account xpub against the persisted account_registrations.account_xpub. Any mismatch raises WrongSeedForDatabase { expected_wallet_id, derived_wallet_id } — fail-closed, no partial state applied, no entry in LoadOutcome.skipped. This is a genuine cryptographic guard (A07/A08), not a tautology.

Skip-and-report contract

pub async fn load_from_persistor(
    &self,
    seeds: &dyn SeedProvider,
) -> Result<LoadOutcome, PlatformWalletError>

pub struct LoadOutcome {
    pub loaded: Vec<WalletId>,
    pub skipped: Vec<(WalletId, SkipReason)>,
}

A wallet whose seed is unavailable (store returns Ok(None), keyring locked, backend unavailable) is skipped — absent from wallet_manager and self.wallets, not degraded, not a locked placeholder. One unavailable seed never aborts the others. The caller receives Ok(LoadOutcome) (non-empty skipped is success, not an error) and a PlatformEvent::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).

SkipReason variants carry only non-secret structural kind enums (SecretStoreErrorKind) — no secret bytes, no label values, no stringified SecretStoreError sources (SECRETS.md SEC-REQ-2.0.1).

New schema readers

Item Reader Notes
A1 schema::accounts::load_state Reads account_registrations + pools; decodes AccountRegistrationEntry; no Wallet built
B schema::core_state::load_state Bulk reconstructs ManagedWalletInfo — 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 balance
A2 schema::asset_locks::load_unconsumed Status-predicate reader excluding terminal Consumed rows at the SQL level (WHERE status NOT IN ('consumed')); fixes the stale list_active doc-comment that claimed consumed locks leave via removed (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 from SeedProvider → derives Wallet → wrong-seed gate → assembles ClientWalletStartState (wallet + wallet_info from B + identities from existing identities::load_state + filtered asset-locks from A2) → enters the existing manager/load.rs loop unchanged. Real tracing counts replace the previous placeholder zeros; LOAD_UNIMPLEMENTED shrinks to ["contacts", "identity_keys"] (deferred to PR-3).

FFI adaptation

rs-platform-wallet-ffi adapted to the new load_from_persistor(&dyn SeedProvider) signature. LoadOutcomeFFI C-ABI struct surfaces loaded_count and skipped_count to callers. The existing iOS mnemonic resolver is wired as ResolverSeedProvider.

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

# Check compiles across the workspace
cargo check --workspace
cargo check --workspace --all-features

# With and without the secrets feature
cargo check -p rs-platform-wallet-storage
cargo check -p rs-platform-wallet-storage --features secrets

# Doctests (includes the persister.rs:626-658 load() doctest)
cargo test --doc -p rs-platform-wallet-storage
cargo test --doc -p rs-platform-wallet

# Secret-boundary audit hooks (SECRETS.md)
cargo test -p rs-platform-wallet-storage --test secrets_scan
cargo test -p rs-platform-wallet-storage --test sqlite_compile_time -- secrets_guard

Rehydration test suite (RT-*)

# Full RT suite
cargo test -p rs-platform-wallet-storage --features secrets
cargo test -p rs-platform-wallet --features secrets

# Individual gates (merge-blocking: RT-W, RT-Z)
cargo test -p rs-platform-wallet --test rehydration_load -- rt_w_wrong_seed_hard_fail
cargo test -p rs-platform-wallet --test rehydration_load -- rt_z_secret_hygiene

# Core state round-trip (RT-2, balance + UTXO set)
cargo test -p rs-platform-wallet-storage --test sqlite_core_state_reader

# Asset-lock filter gate (RT-4 — must fail unfiltered, pass filtered)
cargo test -p rs-platform-wallet-storage --test sqlite_asset_locks_filter

# Skip path (RT-S — absent wallet, LoadOutcome.skipped, event, recoverable reload)
cargo test -p rs-platform-wallet --test rehydration_load -- rt_s_skip_seed_absent
cargo test -p rs-platform-wallet --test rehydration_load -- rt_s_skip_store_locked

# No-BIP44 topology (RT-4 companion, no silent zero balance)
cargo test -p rs-platform-wallet --test rehydration_load -- rt_no_bip44_topology

# Wiring smoke test
cargo test -p rs-platform-wallet-storage --test sqlite_load_wiring

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-secrets base. 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_persistor gains a &dyn SeedProvider parameter and returns LoadOutcome instead of ():

Before (on base):

pub async fn load_from_persistor(&self) -> Result<(), PlatformWalletError>

After:

pub async fn load_from_persistor(
    &self,
    seeds: &dyn SeedProvider,
) -> Result<LoadOutcome, PlatformWalletError>

This is an additive capability change on an unreleased API (no semver-versioned release of rs-platform-wallet exists yet). The sole in-tree FFI consumer (rs-platform-wallet-ffi) has been adapted in commit b573fcad0ca854b9b27001184f0de1ff3e20b750. External callers (none known) would need to supply a SeedProvider implementation — the existing SeedProviderAdapter (storage crate, secrets feature) or ResolverSeedProvider (FFI crate) are the ready-made implementations.

ClientWalletStartState no longer carries a Wallet field — 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::WalletType derived Debug can render key material. The WalletType::Mnemonic { mnemonic, root_extended_private_key } variants derive Debug upstream. Our code never calls {:?} on a Wallet and the WrongSeedForDatabase error carries only two 32-byte wallet IDs (no key fields). LoadOutcome, SkipReason, and all tracing spans are confirmed key-material-free (RT-Z). The upstream compute_wallet_id internal Debug hygiene 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_lock re-warm. After rehydration, per-account balance is exact (RT-2 covers this). last_applied_chain_lock is 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. ClientWalletStartState has no contacts slot; wiring requires a changeset-shape change touching the rs-platform-wallet public API. Deferred to PR-3. LOAD_UNIMPLEMENTED is updated to reflect exactly this remaining scope.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

Note on the checklist item above: no ! in the title because this is an additive capability on an unreleased API, not a semver breaking change of a shipped release. The signature change is documented explicitly in Breaking Changes above.

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Co-authored by Claudius the Magnificent AI Agent


Rebase note (2026-05-20): Rebased onto new #3672 tip (3eefec2174); commit S amended to use keyring_core::CredentialStoreApi adapter (custom SecretStore SPI retired); 11 commits preserved.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 20, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a59a9062-b472-4758-960e-0961fc997e0a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 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 and usage tips.

lklimek and others added 11 commits May 20, 2026 17:10
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>
…m-wallet-rehydration

# Conflicts:
#	packages/rs-platform-wallet-ffi/src/manager.rs
@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

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

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants