Skip to content

feat(platform-wallet-storage): embeddable SQLite persistence backend with seedless rehydration - #3968

Open
Claudius-Maginificent wants to merge 249 commits into
v4.2-devfrom
feat/platform-wallet-storage-rehydration
Open

feat(platform-wallet-storage): embeddable SQLite persistence backend with seedless rehydration#3968
Claudius-Maginificent wants to merge 249 commits into
v4.2-devfrom
feat/platform-wallet-storage-rehydration

Conversation

@Claudius-Maginificent

@Claudius-Maginificent Claudius-Maginificent commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

TL;DR: Adds a durable, embeddable SQLite storage backend for Dash Platform wallet state, so identities, contacts, keys, and balances survive an app restart instead of being lost or re-derived from scratch.

User story

As a Dash Platform wallet developer, I want my wallet's Platform data (identities, contacts, keys, balances, core sync progress) to survive an app/node restart, so that users don't lose data or sit through a full re-sync every time the app starts.

Scenario

Base flow

A Dash Platform wallet app registers identities and contacts, tracks balances and asset locks, and derives Platform payment addresses as the user interacts with it.

Actual behavior

platform-wallet defines the persistence trait (PlatformWalletPersistence) and the manager-side load_from_persistor() entry point, but ships no production storage backend. Restart the app and its Platform identities and contacts are gone until re-derived, the address-reuse guard resets, and core sync restarts from scratch — there is no on-disk durability, no backup/restore, and no schema-migration path for wallet state.

Expected behavior

That state is durably persisted to a local SQLite database (one .db file can hold many wallets), with online backup/restore and automatic schema migration. Restarting the app restores everything seedlessly and signing works immediately post-load — and no private-key material is ever written to the database (signing material stays in the OS keyring or an encrypted vault).

Detailed discussion

What was done

Originally split from #3692; rebased directly on v4.1-dev, which already carries the shared rehydration scaffolding (ClientStartState, load_from_persistor, the WalletType::ExternalSignable model) — this PR is the storage half, the net change against v4.1-dev is almost entirely the new crate.

Adds rs-platform-wallet-storage — a self-contained, embeddable SQLite persistence backend implementing PlatformWalletPersistence.

Persister & seedless rehydration

  • SqlitePersister (usable as Arc<dyn PlatformWalletPersistence>, Send + Sync, object-safe) with configurable journal / synchronous / flush modes, a retention policy, and auto-backup.
  • Auto-backup filenames (pre-migration-*, pre-restore-*) embed a sanitized stem of the source database's own filename ahead of the timestamp, so two sibling databases sharing one backup directory can no longer collide on a backup filename when they migrate or restore within the same timestamp second (pre-delete-* already keys on wallet id and is unaffected). The stem is allowlist-sanitized — alphanumerics, -, _ kept, everything else mapped to _, capped at 32 characters, an empty stem falls back to db — so it can't smuggle a path separator or traversal sequence into the backup directory, and it precedes the timestamp so the timestamp stays the token the filename parser reads back.
  • Seedless load() reconstructs each wallet external-signable from its persisted account manifest (Wallet::new_external_signable, no seed required), then layers the persisted core-state projection — UTXOs, sync watermarks, chainlock, used-address pool depth — via apply_persisted_core_state. Prekeyed identity/contact joins mean signing works immediately post-load with no key re-sync.
  • Per-domain readers back the load path: accounts, asset locks, core state, core address pool, identities (prekeyed), identity keys, platform addresses, and version watermarks.

Schema & migrations (refinery; additive; version-pinned with golden schema-freeze fingerprints)

  • V001 __initial — per-wallet tables keyed by wallet_id, native FOREIGN KEY … ON DELETE CASCADE; identity-owned tables cascade via identities.wallet_id. identity_keys is keyed (identity_id, key_id) — matching the domain model, since identities keys on identity_id alone and an identity has exactly one owning wallet — and is bound to its owning identity by a compound (wallet_id, identity_id) → identities(wallet_id, identity_id) foreign key, so a key can only be filed under the wallet that actually owns the identity it names. identity_keys.wallet_id is nullable, mirroring identities.wallet_id: a wallet-less identity can hold keys, with BEFORE INSERT/BEFORE UPDATE triggers standing in for the co-ownership check where the foreign key goes dormant (SQLite skips FK enforcement once any child-key column is NULL). See "Trust boundary & robustness" below for the write-path and load-path detail.
  • V002 __address_height_pin — adds platform_addresses.as_of_height (address double-count fix).
  • V003 __unified (additive, sequenced after V002) — core_address_pool (first-class per-index address-pool rows with a used flag, giving real per-account UTXO attribution in place of an account-0 approximation), meta_data_versions (per-(wallet_id, domain) monotonic sequence for cache invalidation), and meta_store_generation (restore-regenerated store token).
  • V004 __invitations (additive, from the v4.1-dev merge) — adds the invitations table for DIP-13 DashPay invitations (inviter-side records; no key material stored, the voucher key is HD-re-derivable from funding_index).
  • V005 __pool_public_key (additive) — adds nullable public_key/key_type columns to core_address_pool, closing the gap where pre-derived platform-node Ed25519 keys were silently dropped on SQLite round-trips (closes rs-platform-wallet-storage: persist provider_key_account_registrations (BLS/EdDSA provider-key accounts) #4113).
  • V006 __pool_reserved_at (additive) — adds a nullable reserved_at column to core_address_pool, persisting AddressState::Reserved's timestamp (rust-dashcore PR fix(rs-dpp): Remove overflows #818) instead of silently collapsing it to the same row shape as Available. Write-only for now — the ON CONFLICT clears it whenever a row is/becomes used, so a stale Reserved snapshot can never resurrect a reservation on an already-used address. The read/restore path deliberately isn't wired to consume it yet; see "Deferred" below.
  • The identity_keys key/co-ownership/nullability changes above were made by editing V001 in place rather than layering a new migration version — the crate is pre-release with no product consumers yet, so there is no deployed schema to migrate away from. The golden schema-freeze fingerprints and the committed populated_v001.db fixture were regenerated to match; the migration-set identity fingerprint is unchanged.

Trust boundary & robustness (the .db is untrusted input at load)

  • Fail-hard load(): any row that fails to decode, or an out-of-range wallet_id, aborts the whole call with a typed WalletStorageError — no silent per-row skip, no partial Ok.
  • Layered size limits: a 16 MiB (SIZE_LIMIT_BYTES) cap on KV values and BLOB decode, per-column length() pre-read gates before materialization, a bincode decode bounded by a Limit config that rejects trailing bytes, and a 32 MiB SQLITE_LIMIT_LENGTH connection backstop.
  • Typed columns are cross-checked against the decoded BLOB on every reader (outpoint / account / identity / key / wallet id); any mismatch hard-errors.
  • Key/identity co-ownership is enforced structurally, not just checked after the fact: identity_keys' compound foreign key rejects a key filed under a wallet that doesn't own the identity it names, surfacing as a typed IdentityKeyWalletMismatch rather than a bare driver constraint error. The upsert explicitly restates the incoming wallet scope on conflict (wallet_id = excluded.wallet_id) — load-bearing, because without it a cross-wallet write for an already-existing key resolves to an UPDATE that never touches wallet_id, so the foreign key never fires and the write would silently overwrite the resident row instead of being rejected.
  • A wallet-less identity (one belonging to no wallet) can hold keys: identity_keys.wallet_id is nullable, the same spelling identities.wallet_id already uses for "owned by no wallet". Because SQLite skips foreign-key enforcement entirely once any child-key column is NULL, BEFORE INSERT/BEFORE UPDATE triggers stand in for the dormant foreign key on those rows, rejecting a NULL-scoped key whose identity is in fact wallet-owned; violations map to the same typed error as above. A dedicated SqlitePersister::load_unowned_identities() accessor reads these identities back with their persisted keys already merged in. It is deliberately kept separate from load(): surfacing wallet-less identities through a wallet-scoped manager would stamp them with the loading wallet's id, and the next ordinary flush would silently claim them for that wallet — the same orphan-to-owned promotion path that legitimately exists for discovery. A caller that wants these identities calls the accessor explicitly and writes any changes back through a wallet-less-scoped persister.

Secrets

  • Encrypted-vault + OS-keyring secret store (SecretStore / EncryptedFileStore: Argon2id KDF + XChaCha20-Poly1305 AEAD envelope, zeroized, over keyring-core), so signing material never touches the wallet .db.

Changes outside the storage crate (kept as narrow as the underlying fixes allow — the design intent is still to leave platform-wallet untouched wherever the storage crate alone can carry a change)

  • platform-wallet: three real fixes. Two are forced by shared logic the storage crate calls into rather than owning itself — insert_platform_node_pool_entry (the helper SQLite-restore, FFI-restore, and plain key registration all share) now correctly restores used_indices/highest_used pool bookkeeping for an already-used platform-node key on rehydration (previously only a regression test existed for this; the production code was unfixed — closes a real PR fix(platform-wallet-storage): persist provider key accounts and platform-node public keys (closes #4113) #4117 review finding), and next_unused_receive_address now reserves the address it hands out (AddressPool::next_unused_and_reserve) instead of leaving it unreserved, closing a hand-out race now that upstream key_wallet supports address reservation. The third is independent of the storage crate but feeds directly into what it persists: derive_spent_utxos (changeset/core_bridge.rs) previously recorded every spent-UTXO entry with an empty locking script, discarding the address already available on the same input detail two lines below — TxOut.script_pubkey has no encoding for "unknown" and an empty script is itself a legitimate on-chain value, so the placeholder was indistinguishable from a real observation. It now derives the script from that address instead; the reconstruction is exact because every production path derives a UTXO's address from its script, and Address::from_script round-trips byte-for-byte back through script_pubkey() for P2PKH, P2SH, and witness programs alike. The fix applies to newly-derived records only — rows already on disk keep their empty scripts. Otherwise: doc-comment only (new_watch_onlynew_external_signable).
  • swift-sdk: the Platform-wallet load FFI consumer reconciled to the shipped 1-arg platform_wallet_manager_load_from_persistor contract; one real fix in SendTransactionView (stop funding core-to-core sends from a Platform-Payment index); the rest are doc-comment renames.
  • Infra: rust-dashcore pinned to a dash-evo-tool integration branch (dashpay/rust-dashcore) combining PR chore(release): update changelog and bump version to 0.24.0-dev.18 #909 (out-of-order UTXO spend fix, feat(wasm-dpp): implement asset lock proof bindings #649 — supersedes the previously-referenced feat(rs-dpp): migrate fees from js-dpp v0.24 #851 draft, which stalled) with PR fix(rs-dpp): Remove overflows #818 (address reservation on hand-out, the API next_unused_receive_address above now uses). Still an interim state: neither upstream PR has merged to dev yet, so the pin points at a branch (now tracked via branch = "dash-evo-tool" rather than a fixed rev, so it floats to the branch tip on the next cargo update), not a merged/tagged commit — re-pin to dev directly once both land. See "Fourth dependency refresh" below for the latest tip bump and its fallout. Root .cargo/audit.toml acknowledges RUSTSEC-2025-0141 (bincode unmaintained — an informational advisory, mitigated by the size caps + fail-hard load() above).
  • Synced with v4.1-dev (this PR's base) via a merge commit, not a rebase — routine, recurring maintenance on a long-lived branch. Two real conflicts: platform-wallet-ffi's FFI result codes (the persister transient/fatal split above) gained a new discriminant, 28, for v4.1-dev's broadcast-rejection variant — renumbered to avoid colliding with persister codes 26/27, mirrored in Swift; and rs-platform-wallet's SPV acceptance handling took v4.1-dev's fuller authoritative-acceptance API in place of a narrower classifier written for the pin refresh above, confirmed compatible with the retained dash-evo-tool pin (strictly ahead of v4.1-dev's own dev-tracking pin). All 9 crates consuming the bumped dependency pass targeted clippy --no-deps -D warnings and tests post-merge; the pre-existing, unrelated platform-wallet-storage feature-unification failure noted elsewhere reproduces identically before and after.
  • Forward-ported the full outstanding PR fix(platform-wallet-storage): persist provider key accounts and platform-node public keys (closes #4113) #4117 review-feedback round (provider-key account persistence, typed pool-key validation, conflicting/untyped pool-key write rejection) — fix(platform-wallet-storage): persist provider key accounts and platform-node public keys (closes #4113) #4117 had been squash-merged before its last fix round landed on its source branch, so those commits needed merging forward by hand.

Review remediation (independent grumpy-review: 6 specialist passes, 31 raw findings consolidated to 22 — 0 CRITICAL, 1 HIGH, 5 MEDIUM, 16 LOW)

  • Fixed 17 findings across the three touched crates:
    • platform-wallet-storage: parent-directory permission gate (rejects a group/other-writable parent dir) shared by the SQLite persister and the encrypted secret file store; a shared id32() decode helper replacing 7 duplicated inline 32-byte-ID decodes; restored AddressPoolType/PublicKeyType integer-discriminant parity tests; SCHEMA.md/README.md doc-drift corrections (table count, migration-log rows, rekey fault-domain note).
    • platform-wallet: load_from_persistor now retries a transient persister failure during startup instead of failing rehydration outright; insert_platform_node_pool_entry clears stale used_indices/highest_used bookkeeping when a pool entry downgrades from used; populate_platform_node_pool propagates a typed error instead of stringly-typed; a hand-rolled timestamp computation replaced with the shared now_secs() helper.
    • platform-wallet-ffi: dedicated FFI result codes distinguishing transient vs. fatal persister errors (mirrored in the Swift PlatformWalletError enum); a 16 MiB size gate on asset-lock proof bytes before bincode decodes them.
    • Each fix independently re-verified (not just trusted from the fixing agent): cargo fmt/clippy -D warnings/full test suite re-run per crate, specific new test names confirmed passing in the raw log.
  • The remaining 2 MEDIUM findings (the rust-dashcore branch pin, the address-reservation lifecycle gap) are already covered above — see "Infra" and "Deferred" — and intentionally left as documented follow-up rather than duplicated here.

Deferred (TODO-marked, no regression)

  • Manifest authentication (a MAC binding the persisted manifest to its wallet_id) — tracked in feat(platform-wallet): manifest integrity checksum (Risk-6/R12.5 follow-up) #3992.
  • Long-term recovery for orphaned wallet rows (a crash between wallet-row creation and first-account registration leaves a permanently-empty manifest; it rehydrates harmlessly as an empty external-signable wallet today, but there is no eviction / re-registration path) — an open product decision, TODO left at the exact branch.
  • Address-reservation lifecycle: V006 (above) persists reserved_at correctly, but nothing releases or sweeps a stale reservation, and nothing threads the restored value back into the live wallet (that needs a signature change in rs-platform-wallet's insert_platform_node_pool_entry, deliberately kept out of this storage-only PR). rust-dashcore already ships the release/sweep API end to end (AddressPool/ManagedCoreFundsAccount/ManagedWalletInfo), it's just not called from anywhere yet. Tracked in Address-reservation lifecycle is half-wired: no release/sweep, no FFI representation #4188.

Second remediation round (independent grumpy-review of this PR's full diff: 4 domains × 3 specialist roles, 65 raw findings consolidated to 62 — 0 CRITICAL/HIGH, 11 MEDIUM, 50 LOW, 1 INFO) — 12 commits closing every blocking-classified finding plus a broad discretionary-low sweep:

  • Blockers closed: pre-read size gate on the two identity BLOB columns (user_identity_id/friend_identity_id) that had skipped the gate every other variable-width column already had; connection-mutex poisoning made permanent-and-observable instead of silently dropping future changesets forever (store()/flush()/commit_writes() now short-circuit once poisoned, buffer discarded on detection); a corrupted vault header (Tier-1, not Tier-2) disclosed in SECRETS.md as indistinguishable from a wrong passphrase, matching the existing OS-arm disclosure; parent-directory permission gate now walks the full ancestor chain (lexical and canonicalized symlink target) through /, checking ownership as well as write bits, applied to the restore destination too (previously skipped there entirely).
    • Not closed, explicitly deferred: bumping past the SQLite WAL-reset corruption bug (fixed upstream in 3.51.3) is blocked on dashpay/grovedb — the pinned v5.0.1 (already its latest tag) hard-requires rusqlite ^0.38, and no 0.38.x patch bundles a newer SQLite. Left as-is; the bug needs 2+ connections writing/checkpointing the same file at the same instant, a narrow window, not the default single-writer path.
  • Mediums closed (schema, migration V007): core_utxos.account_index and .spent_in_txid dropped — both were write-only columns with zero production readers (rehydration already resolves owning-account at read time via owning_account_for_script; spent_in_txid was never populated by any write path) that could silently drift from what production actually used. The one remaining schema-touching MEDIUM (rust-dashcore pin) is intentional — see "Infra" above, unchanged.
  • Discretionary lows (~14 items): CLI exit-code classification corrected (missing --db → usage error 2; restore source-validation failures → validation error 3, not runtime error 1); restore_from's reader-exclusion hardened with SQLite locking_mode=EXCLUSIVE so WAL readers back off too, not just writers; auto-backup directory now created at explicit 0700 instead of umask-default; vault passphrase minimum raised from 1 to 8 bytes; the V001-mutability policy contradiction across three docs resolved (every migration stays editable pre-release, not just V001); five ephemeral review-finding-ID references stripped from committed comments; the account-zero-fallback regression test now drives the real load_state/apply_persisted_core_state path instead of a raw-column test helper.
  • Every fix independently re-verified by the coordinator (not trusted from the fixing pass): fresh cargo test/clippy -D warnings runs per commit, specific test names confirmed passing in the log, diffs read before staging.

Native shielded viewing-key persistence + delete_wallet contract (closes #4200)

  • SqlitePersister previously advertised no support for PersistenceCapabilities::SHIELDED_VIEWING_KEYS and silently dropped ShieldedChangeSet::viewing_keys on both store() and load(), despite the changeset/capability plumbing already existing — found via a dash-evo-tool consumer having to bolt on its own wrapper to fill the gap. Added a shielded_viewing_keys table (migration V008, ON DELETE CASCADE like every sibling table), wired behind the crate's existing shielded feature. A decode/width/identifier failure on one wallet's row degrades that one subwallet to absent (logged) instead of aborting rehydration for every wallet in the store.
  • PlatformWalletPersistence had no delete_wallet method at all (SqlitePersister had one only as an inherent method). Added it to the trait with a default implementation returning a new PersistenceError::UnsupportedOperation, so the ~30 existing implementers across rs-platform-wallet (mostly test mocks) compile unchanged; SqlitePersister overrides it, delegating to its existing cascade-delete implementation.

Test-only fast KDF for SecretStore (closes #4111)

  • Downstream integration suites (e.g. dash-evo-tool's wallet_backend tests) drove real SecretStore::{set,get,set_secret,get_secret,reprotect} flows and paid a production-strength Argon2id derivation (64 MiB) on every call — individually 5-23s, with no way to opt out.
  • Added SecretStore::file_mock / EncryptedFileStore::open_mock, gated behind #[cfg(any(test, feature = "test-util"))] (new test-util Cargo feature). A mock-constructed store derives at the enforced floor params instead of the shipped default — the fastest configuration enforce_bounds still accepts — for both the vault-unlock derivation and the per-secret Tier-2 wrap, with no change to any public method signature. A caller swaps filefile_mock at construction; every subsequent call is transparently fast.
  • No KDF parameter type is exposed outside the crate (KdfParams stays pub(crate)); the fix is entirely crate-side so no downstream consumer implements its own fast-KDF logic. A single shared KdfParams::floor_target() helper is the crate's only definition of "fastest legal Argon2id params" — the three previously-duplicated private #[cfg(test)] copies were deleted and their ~31 call sites rewritten against it.
  • A cfg!(debug_assertions)-based runtime guard (a runtime value, present in every profile — unlike debug_assert!) lives on floor_target() itself, the single choke point every caller (mock constructors and internal tests alike) goes through. If test-util ever leaks into a release build via feature unification, the guard panics loudly instead of silently handing back weak crypto; open_mock evaluates it before the passphrase check so a blank passphrase can't mask the panic.
  • Perf follow-through: argon2 is the only workspace crate consumer of the argon2 dependency, so added it to the root Cargo.toml's existing [profile.dev.package.*] opt-level = 3 list (alongside halo2_proofs/orchard/pasta_curves/etc.) — narrow in practice despite being a workspace-level stanza, since nothing else in the tree compiles argon2. Composes multiplicatively with the mock-KDF floor params: measured 149.19s → 13.51s (~11x) on platform-wallet-storage's 266 KDF-heavy lib unit tests.

Fourth dependency refresh — dash-evo-tool branch tip bump + AssetLockFundingAccount/drain API follow-through

  • Re-pinned rust-dashcore's dash-evo-tool branch to its current tip (34f0921e), which additionally folds in PR feat(rs-drive-abci)!: commit and vote extensions signature verification #915 (CoinJoin funding account + drain mode for asset-lock builds), feat: masternode identities #918 (branch-and-bound coin-selection rewrite), feat: update masternode identities #914, chore(release): update changelog and bump version to 0.24.0-dev.18 #917, feat: update masternode identities #908.
  • feat(rs-drive-abci)!: commit and vote extensions signature verification #915 changed ManagedWalletInfo::build_asset_lock_with_signer's signature (account_index: u32 → an AssetLockFundingAccount enum, plus a new drain: bool param inserted before signer) — the one platform-wallet call site wasn't updated, breaking the build (Cargo resolved the new pin cleanly; the break only showed up as a downstream E0061 compile error). Fixed by wrapping the existing index in AssetLockFundingAccount::Bip44 { account_index } and passing drain: false, preserving prior UTXO-selection behavior exactly — CoinJoin funding accounts and drain mode aren't exposed by platform-wallet's public API yet.
  • Also cleared a clippy::await_holding_lock hit in a platform-wallet test (built_resume_rebroadcasts_original_and_typed_failures_do_not_broadcast): a std::sync::MutexGuard was released via an explicit drop() a few lines before the next .await, which clippy still flagged as held across it. Scoped the guard to a block instead — the pattern already used everywhere else in the same test — clearing the lint with no behavior change.
  • Verified: clippy -p platform-wallet -p platform-wallet-ffi --all-features --all-targets -- -D warnings clean; platform-wallet's asset-lock unit tests (31/31) pass.

Fifth dependency refresh — dash-evo-tool branch tip realignment (no source change)

  • Re-pinned rust-dashcore's dash-evo-tool branch from 34f0921e to its current tip f8a5cc89. The two revisions resolve to the identical source tree (8961652a…), so this moves no bytes: 34f0921e was itself the merge of fix/bnb-coin-selection-918, and the tip merges dev after that same branch-and-bound rewrite re-landed there via PR chore(release): update changelog and bump version to 0.24.0-dev.19 #919. The bump reconciles branch history only.
  • Verified accordingly rather than assumed: platform-wallet-storage 674/0/2 and platform-wallet 517/0/1 both match their pre-bump baselines, platform-wallet-ffi checks clean, and every workspace member naming dashcore or key-wallet checks clean.

Load-time integrity — a malformed row fails loudly, by design

  • Spent-UTXO records are now written with the genuine locking script (see the platform-wallet derive_spent_utxos fix above), so the empty-script rows that previously made an entire wallet file unloadable are no longer produced.
  • The read path deliberately does not tolerate a row that fails to decode. core_state::load_used_addresses and core_pool::load_used_addresses propagate a typed AddressDecode error rather than skipping the row with a warning, and core_state::load_state and sqlite/util/wallet.rs are fail-hard for the same reason. A skip would return Ok while silently dropping an address from the reuse guard — a wrong answer presented as a correct one. Regression tests pin the propagation so a later refactor cannot soften it.
  • This is the deliberate division of responsibility: the producer stops writing rows that cannot be read back, and the storage layer refuses to paper over one if it ever appears. Rows written by earlier code are not rewritten, so an existing database carrying an empty script still fails loudly until the offending row is corrected.

Testing

  • cargo clippy --package platform-wallet --package platform-wallet-storage --package platform-wallet-ffi --package rs-unified-sdk-ffi --all-features --locked -- --no-deps -D warnings — clean (matches this repo's CI invocation exactly).

  • cargo test --package platform-wallet --package platform-wallet-storage --package platform-wallet-ffi --all-features1597 passed / 0 failed / 9 ignored, current as of the key/identity co-ownership and nullable-key work below.

  • Swift compilation is verified by CI (Swift SDK build) — FFI symbols were matched by hand against the Rust extern "C" surface.

  • Independently reviewed via a 14-agent panel (security/structural/QA passes across the storage core, secrets subsystem, tests+docs, and cross-cutting integration, plus an independent Codex Sol pass over the full diff): 0 CRITICAL/HIGH, 17 MEDIUM findings after deduplication — see review threads for detail. Top items worth a look before merge: the rust-dashcore pin above, and two data-durability edge cases (identities table missing a uniqueness constraint on (wallet_id, identity_index), and load_from_persistor's failure path not being recoverable by the shipped Swift reference caller).

  • Second remediation round (12 commits) and the platform-wallet-storage: SqlitePersister doesn't persist shielded viewing keys; no delete_wallet contract #4200 shielded-viewing-key + delete_wallet work (2 commits) each independently verified: cargo test/clippy --all-features -- -D warnings clean for platform-wallet-storage, and additionally for platform-wallet (the delete_wallet trait change) — all ~30 existing PlatformWalletPersistence implementers across rs-platform-wallet compiled unchanged.

  • Load-resilience fix verified independently at crate scope: cargo test -p platform-wallet-storage674 passed / 0 failed / 2 ignored across 63 binaries, and cargo clippy -p platform-wallet-storage --lib --test sqlite_used_core_addresses -- -D warnings clean. Five new regression tests (three unit, one per read path, one end-to-end through the public SqlitePersister API), each proven to fail before the fix for the correct reason. Current as of the key/identity co-ownership, nullable-key, and unowned-identity accessor work above.

Breaking changes

None in this PR's net diff. The storage crate is purely additive; the platform-wallet touches are two bug fixes (pool-bookkeeping restore, address-reservation hand-out — see above) plus doc-comment renames, none changing a public signature; swift-sdk is comment renames plus one localized send-funding fix. (The WalletType::ExternalSignable model this crate consumes already lives in v4.1-dev.)

Checklist

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas (trust-boundary and migration paths)
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes — n/a, no breaking changes
  • I have made corresponding changes to the documentation (README / SCHEMA / SECRETS)

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

Prior work

Attribution

🤖 Co-authored by Claudius the Magnificent AI Agent

Summary by CodeRabbit

  • New Features

    • Added support for a safer, more explicit secret-storage flow, including unprotected vaults, password-protected secrets, and stricter size limits.
    • Wallet data loading now restores more account, identity, contact, and balance information automatically.
  • Bug Fixes

    • Improved database consistency during wallet delete, backup, restore, and migration operations.
    • Fixed several read/load paths to reject corrupted, oversized, or mismatched data instead of failing silently.
    • Strengthened key and secret handling to better prevent data leakage and invalid input issues.

@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds a Tier-2 secret-envelope format and hardens secret storage, while renaming the SQLite wallet root to wallets, adding typed per-area rehydration readers, and wiring SqlitePersister::load() to rebuild keyless wallets from persisted state.

Changes

Secrets

Layer / File(s) Summary
Wire format and AAD types
src/secrets/wire/*.rs
Adds the envelope, typed AAD structs, KDF wire encoding, and bincode wrap/unwrap logic.
Secret error taxonomy
src/secrets/error.rs
Adds tier-2 error variants and updates keyring SPI projection.
Store API and file-vault hardening
src/secrets/store.rs, src/secrets/file/*
Adds file_unprotected, refactors read/write flows, and hardens blank-passphrase, size, fsync, and permission handling.
Secrets docs, keyring docs, and config
SECRETS.md, src/secrets/mod.rs, src/secrets/keyring.rs, Cargo.toml, tests/secrets_*
Updates docs, feature flags, compile-time checks, and secrets-focused integration tests.

SQLite

Layer / File(s) Summary
Migrations, blob sealing, and shared helpers
migrations/V001__initial.rs, migrations/V003__unified.rs, src/sqlite/schema/blob.rs, src/sqlite/schema/wallets.rs, src/kv.rs, src/lib.rs
Re-roots wallet FKs to wallets, adds address-pool and metadata-version tables, seals blob persistence, and updates shared size/cast helpers.
Per-area readers and load_state wiring
src/sqlite/schema/*.rs
Adds typed readers for accounts, identities, keys, contacts, asset locks, core state, pools, and platform addresses.
Persister open/load/delete/backup/restore
src/sqlite/persister.rs, src/sqlite/error.rs, src/sqlite/backup.rs, src/sqlite/conn.rs, src/sqlite/migrations.rs, src/sqlite/util/wallet.rs
Adds open-path guarding, schema/application checks, keyless load/rebuild, and hardened persistence flows.
Tests and fixtures
tests/*.rs
Extensive coverage updates for the new schema, load path, and rehydration behavior.
Docs
SCHEMA.md, README.md
Updates the schema and load() documentation to match the new root anchor and keyless rehydration model.

Estimated code review effort: 5 (Critical) | ~150 minutes

Possibly related issues

Possibly related PRs

Suggested labels: Client Only

Suggested reviewers: shumkov, thepastaclaw

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the new embeddable SQLite persistence backend and seedless wallet rehydration, which are the main changes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/platform-wallet-storage-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.

@lklimek lklimek changed the title feat(platform-wallet-storage): persistence readers + seedless load() wiring (split from #3692) feat(platform-wallet): persistence readers + seedless load() wiring (split from #3692) Jun 29, 2026
lklimek and others added 2 commits June 29, 2026 12:55
…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>
…3968, independent on v3.1-dev]

Storage-crate half of the rehydration work, rebuilt to stand alone on
v3.1-dev: SqlitePersister::load() wiring + per-area readers (accounts,
core_state, identities, asset_locks, contacts, identity_keys) that
reconstruct the keyless ClientWalletStartState.

Independence on v3.1-dev required two deliberate stubs — the reshaped
ClientWalletStartState drops wallet/wallet_info, breaking two base
consumers; both are resolved by #3692 in the dash-evo-tool integration:
  - manager/load.rs: whole-body todo!("keyless rehydration lands in #3692")
  - ffi/persistence.rs: tail-only todo!("seeded FFI restore path lands in
    #3692") — keeps the 8 builder helpers live (no dead_code under
    -D warnings) and minimizes the #3692 merge conflict

Cross-crate manager-apply e2e tests in sqlite_core_state_reader.rs are
gated behind a new off-by-default `rehydration-apply` feature (enabled in
the integrated stack); storage-level load_state assertions run standalone.

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

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
lklimek and others added 10 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>
…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>
@Claudius-Maginificent

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@thepastaclaw

thepastaclaw commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Sonnet deferred (commit 5931df7)
Canonical validated blockers: 6

@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 PR adds the storage-side keyless load readers, but it also replaces two externally reachable restore paths with unconditional panics. The new rehydration readers are mostly wired, but several fail-hard corruption checks are missing where typed SQLite columns can disagree with decoded blobs.

🔴 2 blocking | 🟡 6 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.

  • [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs:143-150: Identity reader trusts blob identity over the row keyload_state() selects identity_id but discards it, then decodes entry_blob and routes the restored identity using entry.id. The writer rejects IdentityEntry values whose blob ID disagrees with the typed column, but a restored or corrupted SQLite row can bypass the writer. The reader shou...
  • [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs:245-266: Contact reader does not validate request IDs against row keys — The contacts reader keys pending rows from (owner_id, contact_id) but stores the decoded ContactRequest without checking its sender and recipient IDs. During apply, sent requests are inserted under entry.request.recipient_id and incoming requests under entry.request.sender_id, so a row wh...
🤖 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:13-15: Public manager restore API now panics
  `load_from_persistor()` is a public restore entry point returning `Result<(), PlatformWalletError>`, but this PR replaces the previous implementation with `todo!()`. The exported C ABI function `platform_wallet_manager_load_from_persistor` calls this method directly, and the Swift `loadFromPersistor()` wrapper calls that exported function, so any app invoking persisted wallet restore aborts instead of receiving a typed error. If this branch intentionally defers keyless manager rehydration to #3692, the public API still needs to fail closed with an error rather than panic across the FFI boundary.

In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/persistence.rs:3389-3390: FFI persister load panics after receiving restore rows
  `FFIPersister::load()` calls `build_wallet_start_state()` for every wallet returned by the Swift `on_load_wallet_list_fn` callback, and this function now reaches an unconditional `todo!()` after partially reconstructing the entry. This path is externally reachable through restore and shielded binding flows that call `persister.load()`. A panic here can unwind toward `extern "C"` callers and abort the process instead of returning the existing `PersistenceError`/`PlatformWalletFFIResult` failure path.

In `packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs:143-150: Identity reader trusts blob identity over the row key
  `load_state()` selects `identity_id` but discards it, then decodes `entry_blob` and routes the restored identity using `entry.id`. The writer rejects `IdentityEntry` values whose blob ID disagrees with the typed column, but a restored or corrupted SQLite row can bypass the writer. The reader should enforce the same column-vs-blob check, including wallet scope when `entry.wallet_id` is set, so semantic corruption fails the load instead of hydrating the wrong identity.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs:143-150: Identity reader trusts blob identity over the row key
  `load_state()` selects `identity_id` but discards it, then decodes `entry_blob` and routes the restored identity using `entry.id`. The writer rejects `IdentityEntry` values whose blob ID disagrees with the typed column, but a restored or corrupted SQLite row can bypass the writer. The reader should enforce the same column-vs-blob check, including wallet scope when `entry.wallet_id` is set, so semantic corruption fails the load instead of hydrating the wrong identity.

In `packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs:168-169: Identity-key reader does not verify decoded entries match row columns
  `load_state()` reconstructs `(identity_id, key_id)` from the SQL row, decodes `public_key_blob`, and inserts the decoded entry without checking that the blob carries the same identity, key id, wallet id, or public-key hash. The apply path later ignores the changeset map key and routes by fields from the decoded `IdentityKeyEntry`, so a semantically inconsistent row can attach a public key to the wrong identity or carry a hash that disagrees with the indexed column. Mirror the writer-side consistency checks on read before inserting into the changeset.

In `packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs:245-266: Contact reader does not validate request IDs against row keys
  The contacts reader keys pending rows from `(owner_id, contact_id)` but stores the decoded `ContactRequest` without checking its sender and recipient IDs. During apply, sent requests are inserted under `entry.request.recipient_id` and incoming requests under `entry.request.sender_id`, so a row whose blob disagrees with the typed columns rehydrates under a different counterparty and later tombstones for the row key will not clear it. Established rows should also verify their outgoing and incoming requests match the same `(owner, contact)` relationship before accepting the row.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs:245-266: Contact reader does not validate request IDs against row keys
  The contacts reader keys pending rows from `(owner_id, contact_id)` but stores the decoded `ContactRequest` without checking its sender and recipient IDs. During apply, sent requests are inserted under `entry.request.recipient_id` and incoming requests under `entry.request.sender_id`, so a row whose blob disagrees with the typed columns rehydrates under a different counterparty and later tombstones for the row key will not clear it. Established rows should also verify their outgoing and incoming requests match the same `(owner, contact)` relationship before accepting the row.

In `packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs:316-325: Oversized BLOB rows are materialized before the size cap runs
  The new load readers fetch BLOB columns directly into `Vec<u8>` and only then call `blob::decode()`, whose 16 MiB cap runs after rusqlite has already allocated and copied the value. A restored or locally modified SQLite DB can therefore store a huge `record_blob` or other `*_blob` value that passes SQLite integrity checks and forces large process allocations on startup before returning `BlobTooLarge`. Use a shared bounded read helper or select `length(blob_column)` first, as the KV path already does, before materializing BLOB contents.

Comment thread packages/rs-platform-wallet/src/manager/load.rs
Comment thread packages/rs-platform-wallet-ffi/src/persistence.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs Outdated

@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: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (7)
packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs (1)

165-180: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Count identity_keys by wallet_id now that the table is wallet-scoped.

identity_keys moved onto (wallet_id, identity_id, key_id), but this smoke test still routes it through the via_identity path. That means the assertion would still pass if the row were written with the wrong wallet_id as long as identity_id matched, so the new schema contract is not actually being exercised here.

Suggested fix
     let via_identity = [
-        "identity_keys",
         "token_balances",
         "dashpay_profiles",
         "dashpay_payments_overlay",
     ];
🤖 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-storage/tests/sqlite_migrations.rs` around lines
165 - 180, The smoke test still treats identity_keys as identity-scoped, but the
schema now scopes it by wallet_id. Update the test logic in sqlite_migrations.rs
so identity_keys uses the wallet_id COUNT query path instead of the via_identity
branch, while keeping the other tables that still depend on identities routed
through identity_id. Use the existing via_identity handling in the loop over
cases to locate and adjust the count_sql selection.
packages/rs-platform-wallet-storage/SCHEMA.md (1)

507-513: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The soft-cascade note overstates cleanup for identity-scoped metadata.

meta_identity and meta_token do not carry wallet_id, so a wallet delete only reaches them through existing identities rows. If metadata was written before an identities row ever existed, that cleanup path never fires; the orphan-metadata section above already documents exactly that case.

Suggested wording
-`wallets` row fires a wallet-rooted `AFTER DELETE` trigger that
-brooms the wallet-scoped tables (`meta_wallet`, `meta_contact`,
-`meta_platform_address`) by `wallet_id`, and the FK cascade through
-`identities` fires a per-identity trigger that brooms `meta_identity` +
-`meta_token` by `identity_id`. Both legs key on the id alone, so a wallet
-delete cleans its metadata transitively whether or not the typed parent
-was ever written and regardless of any contact's lifecycle state.
+`wallets` row fires a wallet-rooted `AFTER DELETE` trigger that
+brooms the wallet-scoped tables (`meta_wallet`, `meta_contact`,
+`meta_platform_address`) by `wallet_id`, and the FK cascade through
+existing `identities` rows fires a per-identity trigger that brooms
+`meta_identity` + `meta_token` by `identity_id`. That means wallet-scoped
+metadata is cleaned regardless of typed-parent existence, while
+identity-scoped metadata still requires an `identities` row to exist.
🤖 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-storage/SCHEMA.md` around lines 507 - 513, The
soft-cascade description in SCHEMA.md overstates what a wallet delete cleans up
for identity-scoped metadata. Update the note near the wallet/identity trigger
flow to say that `wallets` deletion only reaches `meta_identity` and
`meta_token` through existing `identities` rows and that orphan metadata written
before an `identities` row exists is not covered; align the wording with the
existing orphan-metadata section and reference the `wallets` trigger and the
`identities` FK cascade path.
packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs (1)

27-36: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail closed on corrupted platform-payment registration rows.

This helper trusts the typed account_index column but never verifies that the decoded AccountRegistrationEntry is actually a PlatformPayment entry for that same index. all_platform_payment_registrations() feeds platform_addrs::load_all(), so a tampered row will currently rehydrate under the typed index with the blob's xpub instead of tripping AccountRegistrationEntryMismatch.

Suggested fix
 fn decode_platform_payment_row(
     account_index: i64,
     xpub_bytes: &[u8],
 ) -> Result<PlatformPaymentRegistration, WalletStorageError> {
     let account_index = crate::sqlite::util::safe_cast::i64_to_u32(
         "account_registrations.account_index",
         account_index,
     )?;
     let entry: AccountRegistrationEntry = blob::decode(xpub_bytes)?;
+    if account_type_db_label(&entry.account_type) != "platform_payment"
+        || account_index(&entry.account_type) != account_index
+    {
+        return Err(WalletStorageError::AccountRegistrationEntryMismatch);
+    }
     Ok((account_index, entry.account_xpub))
 }
🤖 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-storage/src/sqlite/schema/accounts.rs` around
lines 27 - 36, `decode_platform_payment_row` currently decodes the blob and
returns the typed `account_index` without checking that the
`AccountRegistrationEntry` is a `PlatformPayment` for that same index. Update
this helper to validate the decoded `AccountRegistrationEntry` matches the
expected `PlatformPayment` variant and index, and return
`AccountRegistrationEntryMismatch` if it does not. Keep the existing
`safe_cast::i64_to_u32` conversion, but make
`all_platform_payment_registrations()` fail closed by rejecting any corrupted or
mismatched row instead of rehydrating it.
packages/rs-platform-wallet-storage/src/sqlite/backup.rs (2)

243-263: 🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Do not delete WAL/SHM before the replacement is guaranteed.

If sibling removal succeeds and tmp.persist(dest_db_path) then fails, the original main DB remains but its WAL/SHM may be gone, losing committed WAL-mode state. The restore path needs a rollback-safe swap strategy or a SQLite-native restore that does not destructively unlink siblings before the main replacement succeeds.

🤖 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-storage/src/sqlite/backup.rs` around lines 243 -
263, The restore flow in `backup.rs` removes `-wal`/`-shm` siblings before
`tmp.persist(dest_db_path)`, which can leave the original DB intact but its
WAL-mode state lost if persist fails. Change the `restore` logic to use a
rollback-safe replacement strategy: do not unlink siblings until the destination
swap is guaranteed, or replace the whole SQLite set atomically via a
SQLite-native restore path. Keep the fix localized around the sibling cleanup
and `tmp.persist` sequence so the operation remains all-or-nothing.

361-374: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Apply keep_last_n as a floor, not a ceiling.

With both keep_last_n and max_age set, line 373 still requires pass_count, so backups beyond the newest N are deleted even when they are within max_age. That contradicts the new floor semantics.

Proposed fix
-        let pass_count = match policy.keep_last_n {
-            Some(n) => idx < n,
-            None => true,
-        };
         let pass_age = match policy.max_age {
             Some(max) => now.duration_since(ts).map(|d| d <= max).unwrap_or(true),
-            None => true,
+            None => policy.keep_last_n.is_none(),
         };
-        if within_floor || (pass_count && pass_age) {
+        if within_floor || pass_age {
🤖 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-storage/src/sqlite/backup.rs` around lines 361 -
374, In backup pruning logic in the `retain_backups` flow, `keep_last_n` is
still being treated like a ceiling because the deletion condition requires
`pass_count` even when `max_age` is also set. Update the condition around
`within_floor`, `pass_count`, and `pass_age` so that the newest N backups are
always kept as a floor and any backup within the age limit is also retained,
using the existing `policy.keep_last_n` and `policy.max_age` checks in this
block.
packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs (1)

143-150: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate typed identity columns against the blob during load.

load_state ignores the selected identity_id, so a corrupted row whose typed column and entry_blob.id diverge is silently rehydrated under the blob value. Also reject a blob wallet_id that disagrees with the scoped wallet.

Proposed fix
-        let _identity_id: Vec<u8> = row.get(0)?;
+        let identity_id: Vec<u8> = row.get(0)?;
         let payload: Vec<u8> = row.get(1)?;
         let tombstoned: i64 = row.get(2)?;
         if tombstoned != 0 {
             continue;
         }
+        let typed_id = <[u8; 32]>::try_from(identity_id.as_slice())
+            .map_err(|_| WalletStorageError::blob_decode("identities.identity_id is not 32 bytes"))?;
         let entry: IdentityEntry = blob::decode(&payload)?;
+        if entry.id.as_bytes() != &typed_id {
+            return Err(WalletStorageError::IdentityEntryIdMismatch);
+        }
+        if let Some(entry_wallet_id) = entry.wallet_id {
+            if entry_wallet_id != *wallet_id {
+                return Err(WalletStorageError::WalletIdMismatch {
+                    expected: *wallet_id,
+                    found: entry_wallet_id,
+                });
+            }
+        }
         let managed = managed_identity_from_entry(&entry, wallet_id);
🤖 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-storage/src/sqlite/schema/identities.rs` around
lines 143 - 150, The load path in load_state is trusting the blob too much and
currently ignores the selected identity_id, so mismatched typed columns can be
silently rehydrated under the blob value. Update the row handling in load_state
to validate that the typed identity_id matches entry_blob.id before decoding
into IdentityEntry, and also verify the blob wallet_id matches the wallet_id
scope passed into managed_identity_from_entry. If either check fails, reject the
row instead of continuing.
packages/rs-platform-wallet-storage/src/sqlite/persister.rs (1)

299-326: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Enforce the open-path registry before restore.

restore_from_inner can replace dest_db_path while a live SqlitePersister in this process still owns the same DB. Check the registry up front and return AlreadyOpen; otherwise the live handle/buffer can diverge from the restored file.

Proposed fix outline
+        let registered_path = dest_db_path
+            .canonicalize()
+            .unwrap_or_else(|_| dest_db_path.to_path_buf());
+        if open_path_registry()
+            .lock()
+            .unwrap_or_else(|p| p.into_inner())
+            .contains(&registered_path)
+        {
+            return Err(WalletStorageError::AlreadyOpen {
+                path: registered_path,
+            });
+        }
+
         if !skip_backup && dest_db_path.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-storage/src/sqlite/persister.rs` around lines 299
- 326, restore_from_inner currently restores the database without checking
whether the destination path is already owned by a live SqlitePersister, which
can leave an in-memory handle out of sync with the replaced file. Add an upfront
registry lookup in restore_from_inner for dest_db_path and return
WalletStorageError::AlreadyOpen when the path is already registered, before any
backup or restore work begins. Keep the change localized around
restore_from_inner and the open-path registry used by SqlitePersister so
existing live handles are protected from restore-time replacement.
🧹 Nitpick comments (4)
packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs (1)

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

Assert synced_height as well as last_processed_height.

This test writes both fields, but only validates one of them. If load() stops wiring synced_height, the round-trip still passes.

Suggested assertion
     assert_eq!(slice.core_state.new_utxos.len(), 1);
     assert_eq!(slice.core_state.new_utxos[0].value(), 777_000);
+    assert_eq!(slice.core_state.synced_height, Some(50));
     assert_eq!(slice.core_state.last_processed_height, Some(50));
🤖 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-storage/tests/sqlite_load_wiring.rs` around lines
91 - 127, The round-trip test in `sqlite_load_wiring.rs` only verifies
`last_processed_height` from `state.wallets.get(&w).core_state` even though
`synced_height` is also written into `CoreChangeSet`; update the existing load
assertions to check both fields after `p2.load()` so `load()` wiring regressions
for `synced_height` are caught alongside `last_processed_height`.
packages/rs-platform-wallet-storage/tests/sqlite_dashpay_overlay_contract.rs (1)

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

Assert that the overlay stays out of the rehydrated identity.

This currently proves only that load() still returns the wallet's core state. If a regression starts merging dashpay_profiles into the loaded identity payload, this test still passes. Please also assert that the seeded identity is present after load() and that its DashPay profile remains absent for the overlay-only write case.

🤖 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-storage/tests/sqlite_dashpay_overlay_contract.rs`
around lines 93 - 108, The current test around persister.load() only verifies
wallet.core_state, so it can miss regressions where dashpay_profiles gets merged
into the rehydrated identity. Update the sqlite_dashpay_overlay_contract test to
also inspect the loaded identity payload for the seeded wallet after load() and
assert that the identity is still present while its DashPay profile remains
absent in this overlay-only write scenario. Use the existing persister.load(),
wallets.get(&w), and any identity fields already available in the loaded state
to make the check explicit.
packages/rs-platform-wallet-storage/tests/sqlite_delete_real_apply_failure.rs (1)

67-72: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Also assert that the failed pre-flush left nothing durable.

Restoring the buffer is only half of the contract here. If apply_changeset_to_tx ever leaks the wallets insert before the core_sync_state failure, this test still passes and leaves duplicate-on-retry state behind.

Suggested assertion block
     assert!(
         persister.buffer_has_changeset_for_test(&w),
         "buffered changeset must be restored after a real pre-flush apply failure"
     );
+
+    let conn = persister.lock_conn_for_test();
+    let wallets_rows: i64 = conn
+        .query_row(
+            "SELECT COUNT(*) FROM wallets WHERE wallet_id = ?1",
+            rusqlite::params![w.as_slice()],
+            |row| row.get(0),
+        )
+        .unwrap();
+    let core_rows: i64 = conn
+        .query_row(
+            "SELECT COUNT(*) FROM core_sync_state WHERE wallet_id = ?1",
+            rusqlite::params![w.as_slice()],
+            |row| row.get(0),
+        )
+        .unwrap();
+    assert_eq!(wallets_rows, 0, "failed pre-flush must not durably create the wallet row");
+    assert_eq!(core_rows, 0, "failed pre-flush must not durably create child rows");
🤖 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-storage/tests/sqlite_delete_real_apply_failure.rs`
around lines 67 - 72, The test currently only verifies the buffered changeset is
restored, but it should also verify that a failed pre-flush did not persist any
durable state. In sqlite_delete_real_apply_failure.rs, extend the existing
scenario around the failed delete so it checks the database/transaction state
after the apply failure and confirms no `wallets` insert or other durable side
effects remain from `apply_changeset_to_tx`. Keep the existing
`persister.buffer_has_changeset_for_test(&w)` assertion, and add a second
assertion in the same test that validates the storage is clean after the failure
so retry does not see duplicate-on-retry state.
packages/rs-platform-wallet-storage/src/sqlite/persister.rs (1)

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

Fix the query-budget documentation.

load() currently performs multiple reader calls inside the for wallet_id in wallet_ids loop, so the query count grows with wallet count. Reword this to avoid promising constant query budget.

🤖 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-storage/src/sqlite/persister.rs` around lines 813
- 814, Update the query-budget comment in the load path so it no longer claims
constant cost with wallet count; the current load() flow iterates over
wallet_ids and performs multiple reader calls per wallet, so reword the
documentation to describe that it has per-wallet read/query work rather than a
fixed query budget. Keep the note near the wallet_ids loop/load() implementation
and make sure the wording matches the actual behavior of the reader calls.
🤖 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-ffi/src/persistence.rs`:
- Around line 3389-3390: The temporary restore stub in the persistence restore
flow should not panic via todo!(); replace it with a recoverable typed error so
callers receive a PersistenceError instead of crashing. Update the restore-path
branch that currently ignores identity_manager and unused_asset_locks to return
an appropriate PersistenceError variant (or equivalent error conversion) from
the same function/method, keeping the signature consistent and preserving the
existing error handling path.

In `@packages/rs-platform-wallet-storage/README.md`:
- Around line 165-168: The README wording around the manager-side rehydration
flow is too strong for this PR because the manager/FFI load path is still
stubbed. Update the description near the watch-only rebuild note to clearly mark
the manager-side `load_from_persistor`/`Wallet::new_watch_only` application as
pending or follow-up work, and keep the current text scoped to the storage-side
behavior only.

In `@packages/rs-platform-wallet-storage/src/kv.rs`:
- Around line 62-65: The key-length validation in validate_key currently assumes
Rust chars().count() matches SQLite length() for all strings, but embedded NULs
break that equivalence. Update the key precheck to explicitly reject keys
containing \0 before comparing length, or adjust the validation/comment so it no
longer claims the same key set; keep the logic aligned with the SQL CHECK
constraint in kv.rs.

In `@packages/rs-platform-wallet-storage/src/secrets/error.rs`:
- Around line 3-5: The file-level non-leakage docs in error.rs are too broad for
the current Io behavior: they claim variants never carry a stringified source,
but Io::fmt/rendering still exposes the underlying source text. Update the docs
to carve out the Io exception, or change Io’s display implementation/tests so it
no longer includes the source string, keeping the wording aligned with the
actual Error and Io rendering behavior.
- Around line 88-91: The UnsupportedEnvelopeVersion error currently truncates
the envelope version to u8, so update the error variant in error.rs to store the
full u32 version value instead. Then adjust the envelope parsing call site that
constructs UnsupportedEnvelopeVersion to pass the original Envelope.version
without narrowing, keeping the reported version accurate in the error message.

In `@packages/rs-platform-wallet-storage/src/secrets/file/format.rs`:
- Around line 21-22: The docs for the nested BTreeMap format currently imply
duplicate (wallet_id, label) pairs are prevented entirely, but the read path
still accepts duplicate JSON keys and serde collapses them. Update the
documentation near the format description to state that uniqueness is only
guaranteed by serialization, or change the deserialization logic in the file
format/parser code to explicitly reject duplicate keys, and make the behavior
match the tests and the intended API.

In `@packages/rs-platform-wallet-storage/src/secrets/file/mod.rs`:
- Around line 628-654: The post-persist Unix handling in the vault write path is
swallowing parent-directory fsync failures and returning success, which makes
`put`/`delete`/`rekey` report a durable commit when only the rename succeeded.
Update the flow around the `persist()`/`sync_all()` block to surface a distinct
“committed but not durable” result or otherwise keep the in-memory commit behind
the durability boundary, and make sure the caller can tell when
`fs::File::open(parent)` or `sync_all()` fails instead of only logging via
`tracing::warn!`.

In `@packages/rs-platform-wallet-storage/src/secrets/store.rs`:
- Around line 255-266: The reprotect method in SecretStore currently does a
non-atomic read-then-write using get_secret followed by set_secret, which can
overwrite concurrent updates with stale plaintext. Update reprotect to use an
atomic backend-specific reprotect/CAS path, or add a version check so the write
only succeeds if the entry has not changed since get_secret; reference
SecretStore::reprotect, get_secret, and set_secret when wiring the fix.

In `@packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs`:
- Around line 136-141: The scheme-0 plaintext path in the envelope handling
still leaves temporary Vec<u8> buffers unwiped, including the
Unprotected(plaintext.to_vec()) branch and the ExpectedProtectedButUnsealed arm.
Update the envelope logic in the encode/decode flow around the Envelope and
Payload handling to use zeroizing storage for these plaintext temporaries or
explicitly wipe them before drop, while keeping SecretBytes::new only for the
final encoded blob.

In `@packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:
- Around line 179-199: `persist`/`open` currently treats `has_schema_history()`
as the only brand-new-vs-existing check, so a pre-existing non-wallet SQLite
file with no `refinery_schema_history` can still be migrated. Add an explicit
guard in the `had_schema_history` decision path to reject existing SQLite files
that lack wallet schema history, using the same `conn`/`has_schema_history` flow
and returning a typed wallet storage error before any backup, integrity check,
or `migrations::run()` work begins.

In `@packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs`:
- Around line 143-154: The sync-state write path in core_state should treat
last_applied_chain_lock monotonically, not as a blind overwrite. Update the
CoreChangeSet-to-DB flow around upsert_sync_state so the stored chain-lock is
max-merged with the existing row (using the same chain-lock height comparison
logic as the height watermarks) before persisting. Apply this behavior wherever
last_applied_chain_lock is written in the affected core_state update functions
so the persisted chain-lock cannot regress.
- Around line 40-41: The `decode_from_slice` handling in
`last_applied_chain_lock` is too permissive because it accepts a valid prefix
and ignores any appended data. Update this decoding path in `core_state.rs` to
mirror the other blob decoders: after calling `bincode::decode_from_slice` for
`ChainLock`, verify the returned consumed length matches `bytes.len()` and treat
any mismatch as corruption by returning `None` instead of loading the state.

In `@packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs`:
- Around line 151-169: Mirror the writer-side validation in load_state by
checking that each decoded public_key_blob matches the row’s typed columns
before inserting into cs.upserts. After decode_entry(&payload), verify the
entry’s identity_id, key_id, wallet_id, and public_key_hash against the values
from the identity_keys query, and return a WalletStorageError if any mismatch is
found. Keep the checks local to load_state and use the existing decode_entry,
Identifier::from, and KeyID::try_from flow so inconsistent rows are rejected
instead of loaded silently.

In `@packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs`:
- Around line 46-82: The sqlite_accounts_reader test is too weak because both
AccountRegistrationEntry fixtures use the same xpub and the assertions only
check set membership, so row reordering or xpub/row mixups can still pass.
Update the test to use distinct xpub fixtures for each entry and assert the
loaded manifest in the expected order, using the accounts::load_state result and
the existing AccountType variants to verify each row maps to the correct xpub.

In `@packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs`:
- Line 33: The doc comment on the wallet start state field still references the
old wallet_metadata table. Update the comment in client_wallet_start_state.rs to
point to the renamed wallets table instead, keeping the wording aligned with the
field’s source of truth and using the existing comment near the network field to
locate it.

In `@packages/rs-platform-wallet/src/manager/load.rs`:
- Around line 8-14: The public rehydration entry point
PlatformWalletManager::load_from_persistor currently panics via todo!, which
turns a caller error into a runtime abort. Replace the todo! with a recoverable
Result path by returning an explicit PlatformWalletError for the unsupported
stub state, or otherwise gate/remove this API until keyless rehydration in
PlatformWalletManager is implemented. Ensure callers receive an error instead of
a panic.

---

Outside diff comments:
In `@packages/rs-platform-wallet-storage/SCHEMA.md`:
- Around line 507-513: The soft-cascade description in SCHEMA.md overstates what
a wallet delete cleans up for identity-scoped metadata. Update the note near the
wallet/identity trigger flow to say that `wallets` deletion only reaches
`meta_identity` and `meta_token` through existing `identities` rows and that
orphan metadata written before an `identities` row exists is not covered; align
the wording with the existing orphan-metadata section and reference the
`wallets` trigger and the `identities` FK cascade path.

In `@packages/rs-platform-wallet-storage/src/sqlite/backup.rs`:
- Around line 243-263: The restore flow in `backup.rs` removes `-wal`/`-shm`
siblings before `tmp.persist(dest_db_path)`, which can leave the original DB
intact but its WAL-mode state lost if persist fails. Change the `restore` logic
to use a rollback-safe replacement strategy: do not unlink siblings until the
destination swap is guaranteed, or replace the whole SQLite set atomically via a
SQLite-native restore path. Keep the fix localized around the sibling cleanup
and `tmp.persist` sequence so the operation remains all-or-nothing.
- Around line 361-374: In backup pruning logic in the `retain_backups` flow,
`keep_last_n` is still being treated like a ceiling because the deletion
condition requires `pass_count` even when `max_age` is also set. Update the
condition around `within_floor`, `pass_count`, and `pass_age` so that the newest
N backups are always kept as a floor and any backup within the age limit is also
retained, using the existing `policy.keep_last_n` and `policy.max_age` checks in
this block.

In `@packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:
- Around line 299-326: restore_from_inner currently restores the database
without checking whether the destination path is already owned by a live
SqlitePersister, which can leave an in-memory handle out of sync with the
replaced file. Add an upfront registry lookup in restore_from_inner for
dest_db_path and return WalletStorageError::AlreadyOpen when the path is already
registered, before any backup or restore work begins. Keep the change localized
around restore_from_inner and the open-path registry used by SqlitePersister so
existing live handles are protected from restore-time replacement.

In `@packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs`:
- Around line 27-36: `decode_platform_payment_row` currently decodes the blob
and returns the typed `account_index` without checking that the
`AccountRegistrationEntry` is a `PlatformPayment` for that same index. Update
this helper to validate the decoded `AccountRegistrationEntry` matches the
expected `PlatformPayment` variant and index, and return
`AccountRegistrationEntryMismatch` if it does not. Keep the existing
`safe_cast::i64_to_u32` conversion, but make
`all_platform_payment_registrations()` fail closed by rejecting any corrupted or
mismatched row instead of rehydrating it.

In `@packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs`:
- Around line 143-150: The load path in load_state is trusting the blob too much
and currently ignores the selected identity_id, so mismatched typed columns can
be silently rehydrated under the blob value. Update the row handling in
load_state to validate that the typed identity_id matches entry_blob.id before
decoding into IdentityEntry, and also verify the blob wallet_id matches the
wallet_id scope passed into managed_identity_from_entry. If either check fails,
reject the row instead of continuing.

In `@packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs`:
- Around line 165-180: The smoke test still treats identity_keys as
identity-scoped, but the schema now scopes it by wallet_id. Update the test
logic in sqlite_migrations.rs so identity_keys uses the wallet_id COUNT query
path instead of the via_identity branch, while keeping the other tables that
still depend on identities routed through identity_id. Use the existing
via_identity handling in the loop over cases to locate and adjust the count_sql
selection.

---

Nitpick comments:
In `@packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:
- Around line 813-814: Update the query-budget comment in the load path so it no
longer claims constant cost with wallet count; the current load() flow iterates
over wallet_ids and performs multiple reader calls per wallet, so reword the
documentation to describe that it has per-wallet read/query work rather than a
fixed query budget. Keep the note near the wallet_ids loop/load() implementation
and make sure the wording matches the actual behavior of the reader calls.

In
`@packages/rs-platform-wallet-storage/tests/sqlite_dashpay_overlay_contract.rs`:
- Around line 93-108: The current test around persister.load() only verifies
wallet.core_state, so it can miss regressions where dashpay_profiles gets merged
into the rehydrated identity. Update the sqlite_dashpay_overlay_contract test to
also inspect the loaded identity payload for the seeded wallet after load() and
assert that the identity is still present while its DashPay profile remains
absent in this overlay-only write scenario. Use the existing persister.load(),
wallets.get(&w), and any identity fields already available in the loaded state
to make the check explicit.

In
`@packages/rs-platform-wallet-storage/tests/sqlite_delete_real_apply_failure.rs`:
- Around line 67-72: The test currently only verifies the buffered changeset is
restored, but it should also verify that a failed pre-flush did not persist any
durable state. In sqlite_delete_real_apply_failure.rs, extend the existing
scenario around the failed delete so it checks the database/transaction state
after the apply failure and confirms no `wallets` insert or other durable side
effects remain from `apply_changeset_to_tx`. Keep the existing
`persister.buffer_has_changeset_for_test(&w)` assertion, and add a second
assertion in the same test that validates the storage is clean after the failure
so retry does not see duplicate-on-retry state.

In `@packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs`:
- Around line 91-127: The round-trip test in `sqlite_load_wiring.rs` only
verifies `last_processed_height` from `state.wallets.get(&w).core_state` even
though `synced_height` is also written into `CoreChangeSet`; update the existing
load assertions to check both fields after `p2.load()` so `load()` wiring
regressions for `synced_height` are caught alongside `last_processed_height`.
🪄 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: edc85543-e83f-4a54-88ef-17800859c720

📥 Commits

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

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (79)
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet-storage/.cargo/audit.toml
  • packages/rs-platform-wallet-storage/Cargo.toml
  • packages/rs-platform-wallet-storage/README.md
  • packages/rs-platform-wallet-storage/SCHEMA.md
  • packages/rs-platform-wallet-storage/SECRETS.md
  • packages/rs-platform-wallet-storage/migrations/V001__initial.rs
  • packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs
  • packages/rs-platform-wallet-storage/src/kv.rs
  • packages/rs-platform-wallet-storage/src/lib.rs
  • packages/rs-platform-wallet-storage/src/secrets/error.rs
  • packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs
  • packages/rs-platform-wallet-storage/src/secrets/file/format.rs
  • packages/rs-platform-wallet-storage/src/secrets/file/mod.rs
  • packages/rs-platform-wallet-storage/src/secrets/keyring.rs
  • packages/rs-platform-wallet-storage/src/secrets/mod.rs
  • packages/rs-platform-wallet-storage/src/secrets/secret.rs
  • packages/rs-platform-wallet-storage/src/secrets/store.rs
  • packages/rs-platform-wallet-storage/src/secrets/wire/aad.rs
  • packages/rs-platform-wallet-storage/src/secrets/wire/config.rs
  • packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs
  • packages/rs-platform-wallet-storage/src/secrets/wire/kdf.rs
  • packages/rs-platform-wallet-storage/src/secrets/wire/mod.rs
  • packages/rs-platform-wallet-storage/src/sqlite/backup.rs
  • packages/rs-platform-wallet-storage/src/sqlite/config.rs
  • packages/rs-platform-wallet-storage/src/sqlite/conn.rs
  • packages/rs-platform-wallet-storage/src/sqlite/error.rs
  • packages/rs-platform-wallet-storage/src/sqlite/kv.rs
  • packages/rs-platform-wallet-storage/src/sqlite/migrations.rs
  • packages/rs-platform-wallet-storage/src/sqlite/persister.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/token_balances.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/wallets.rs
  • packages/rs-platform-wallet-storage/src/sqlite/util/safe_cast.rs
  • packages/rs-platform-wallet-storage/tests/common/mod.rs
  • packages/rs-platform-wallet-storage/tests/secrets_api.rs
  • packages/rs-platform-wallet-storage/tests/secrets_default_on_compiles.rs
  • packages/rs-platform-wallet-storage/tests/secrets_scan.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_account_zero_attribution.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_asset_locks_filter.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_commit_writes_lock_poison_shortcircuit.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_contacts_keys_rehydration.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_dashpay_overlay_contract.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_delete_buffer_reconcile.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_delete_cross_process_exclusion.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_delete_partial_commit_window.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_delete_real_apply_failure.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_delete_wallet.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_fk_changeset_ordering.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_identity_keys_reader.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_money_column_overflow_on_read.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_open_integrity_check.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_qa_identity_tombstone.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_second_open_guard.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_wallet_db_identity.rs
  • packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs
  • packages/rs-platform-wallet/src/manager/load.rs

Comment thread packages/rs-platform-wallet-ffi/src/persistence.rs Outdated
Comment thread packages/rs-platform-wallet-storage/README.md Outdated
Comment thread packages/rs-platform-wallet-storage/src/kv.rs
Comment thread packages/rs-platform-wallet-storage/src/secrets/error.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/error.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs
Comment thread packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs Outdated
Comment thread packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs Outdated
Comment thread packages/rs-platform-wallet/src/manager/load.rs Outdated
…riminators to stop distinct-variant collapse (#3968 review)

account_registrations keyed on (wallet_id, account_type, account_index) only. PlatformPayment key classes and DashPay (user, friend) identity pairs share that key across genuinely distinct accounts, so the ON CONFLICT DO UPDATE silently overwrote one with another — a restored wallet lost accounts (data loss).

Chose option (a) widen-PK over fail-loud: a wallet legitimately holds multiple DashpayReceivingFunds accounts (one per contact) at the same index, so failing the collision would reject valid multi-contact wallets. Add key_class, user_identity_id, friend_identity_id as NOT NULL columns with sentinel defaults (0 / zeroblob) so non-discriminated variants still dedup on re-persist, and widen the PK to include them. The reader cross-checks every typed PK column against the decoded blob and orders deterministically. V001 edited in place (on-disk format unshipped).

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

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
@github-actions github-actions Bot modified the milestones: v4.1.0, v4.2.0 Jul 24, 2026

@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.

Preliminary review — Codex only

Carried-forward prior findings: five of the six prior issues remain in scope—the retained-manager shutdown, duplicate identity indices, C ABI renumbering, unconstrained account labels, and post-restore WAL cleanup—while callback retry classification is explicitly deferred in the current source. New findings in latest delta: the transaction point lookup can return a record for a different txid after a soft repair; the proposed height-row memory-exhaustion blocker was not confirmed as a specific correctness defect. Four blocking issues remain, so this preliminary review requests changes.

Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 4 blocking

5 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 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/core_state.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs:574-578: Do not return a different transaction from a txid lookup
  `get_tx_record` is queried for one txid, but when the decoded record contains another txid this branch logs the mismatch, attempts a best-effort primary-key repair, and still returns the mismatching record. The current regression test explicitly demonstrates a lookup for the typed txid returning the blob's different txid. In production, `reconcile_sent_payments` can use transaction B's finality to confirm payment A, and the asset-lock flow can use B's chain-lock context while constructing a proof for A's outpoint; if B's key already exists, the repair also fails and repeated lookups keep returning the wrong record. Return `CoreTransactionEntryMismatch` for a txid mismatch; any repair must be a separate maintenance operation that does not violate the lookup contract.

Comment on lines +574 to +578
if let Err(mismatch) = ensure_transaction_record_matches_columns(txid, height, &record) {
warn_transaction_record_mismatch(txid, height, &record, &mismatch);
repair_transaction_columns_soft(conn, wallet_id, txid, &record.txid, record.height());
}
Ok(Some(record))

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.

🔴 Blocking: Do not return a different transaction from a txid lookup

get_tx_record is queried for one txid, but when the decoded record contains another txid this branch logs the mismatch, attempts a best-effort primary-key repair, and still returns the mismatching record. The current regression test explicitly demonstrates a lookup for the typed txid returning the blob's different txid. In production, reconcile_sent_payments can use transaction B's finality to confirm payment A, and the asset-lock flow can use B's chain-lock context while constructing a proof for A's outpoint; if B's key already exists, the repair also fails and repeated lookups keep returning the wrong record. Return CoreTransactionEntryMismatch for a txid mismatch; any repair must be a separate maintenance operation that does not violate the lookup contract.

source: ['codex']

lklimek added a commit to dashpay/dash-evo-tool that referenced this pull request Jul 28, 2026
…ation (#940)

* chore(deps): track platform PR #3968 branch for wallet-storage rehydration

Switches dash-sdk, rs-sdk-trusted-context-provider, platform-wallet, and
platform-wallet-storage from a pinned rev to branch tracking of
dashpay/platform#3968 (feat/platform-wallet-storage-rehydration), so
Cargo resolves its current HEAD instead of a fixed commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(deps): bump platform PR #3968 pin to latest branch HEAD

feat/platform-wallet-storage-rehydration moved 0ed8b4d1 -> debf67bd
since this branch was last synced.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
orchardpaytl pushed a commit to orchardpaytl/orchardpay that referenced this pull request Jul 28, 2026
…ation (dashpay#940)

* chore(deps): track platform PR #3968 branch for wallet-storage rehydration

Switches dash-sdk, rs-sdk-trusted-context-provider, platform-wallet, and
platform-wallet-storage from a pinned rev to branch tracking of
dashpay/platform#3968 (feat/platform-wallet-storage-rehydration), so
Cargo resolves its current HEAD instead of a fixed commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(deps): bump platform PR #3968 pin to latest branch HEAD

feat/platform-wallet-storage-rehydration moved 0ed8b4d1 -> debf67bd
since this branch was last synced.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 3e05d5f)

@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.

Preliminary review — Codex only

Source: reviewers=general:gpt-5.6-sol (claude-code/api-gateway), rust-quality:gpt-5.6-sol (claude-code/api-gateway); verifier=gpt-5.6-sol (claude-code/api-gateway).

Carried-forward prior findings: all six remain valid at the current head, including four blocking defects involving destructive manager shutdown after load failure, duplicate identity-index replacement, C ABI renumbering, and txid lookups returning the wrong transaction. New findings in latest delta: none; the latest delta does not modify any of the six finding sites or introduce another confirmed wallet-storage defect. The four blockers require changes before merge.

Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — rust-quality (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 4 blocking

6 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

lklimek and others added 4 commits July 30, 2026 09:07
…_asset_lock_with_signer

The rust-dashcore `dash-evo-tool` branch bump (PR #915, CoinJoin funding
accounts + drain mode for asset-lock builds) changed
`build_asset_lock_with_signer`'s signature from a plain `account_index: u32`
to an `AssetLockFundingAccount` enum plus a new `drain: bool` param. The one
call site in platform-wallet was left on the old signature, breaking the
build. Wrap the existing account index in `AssetLockFundingAccount::Bip44`
and pass `drain: false` to preserve prior UTXO-selection behavior —
CoinJoin funding and drain mode aren't exposed by platform-wallet yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…y::await_holding_lock

built_resume_rebroadcasts_original_and_typed_failures_do_not_broadcast held
a std::sync::MutexGuard past several .await points, only releasing it via
an explicit drop() further down. clippy::await_holding_lock still flagged
it as held across those awaits. Scope the guard to a block instead so it
releases at block exit — the pattern already used everywhere else in this
test — clearing the lint with no behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… wallet file

A single `core_utxos` row with an empty script made an entire persisted
wallet file unloadable — every wallet in it, funded ones included. A real
user DB hit this: 1 fabricated row out of 668 locked 12 wallets holding
1,162,971,366 duffs.

Two halves had to meet. `derive_spent_utxos` builds each spent `Utxo` with
`ScriptBuf::default()` because the script belongs to the previous
transaction, and `core_state::apply` materialized that as a real row when
the spend found no existing outpoint to mark. Harmless until this branch
added `core_state::load_used_addresses`, which scans every stored script
— spent included — and fed each one to `Address::from_script` with a bare
`?`.

Stop manufacturing the row: a spent-only placeholder is only ever read by
the address-reuse guard, which needs a decodable script, so an empty one
buys nothing and costs the whole file. Placeholders carrying a genuine
script still persist — the skip is keyed on emptiness, not on being a
placeholder.

Make both reuse-guard readers skip-and-warn per row instead of failing the
load. The warn carries wallet id and script (plus account for the pool
reader) so the next occurrence is greppable rather than invisible.

`load_state` stays fail-hard, deliberately. It reads only `spent = 0`
rows, it is the balance source, and it runs before the guard — so a
corrupt balance-bearing row still aborts loudly instead of silently
under-reporting a balance. `load_state_fails_hard_on_undecodable_unspent_script`
pins that invariant; it is what makes the skip safe. The skip's worst case
is one address missing from the reuse guard, an address-reuse (privacy)
regression that re-warms on the next full sync.

No schema change and no data migration: the read-path skip de-fangs rows
already on disk without rewriting anyone's wallet.

Tests: apply_skips_spent_only_row_with_empty_script,
load_used_addresses_skips_undecodable_script_rows (core_state + core_pool),
load_state_fails_hard_on_undecodable_unspent_script,
spent_only_utxo_with_empty_script_does_not_block_load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lklimek and others added 13 commits July 31, 2026 20:04
… of failing the wallet load

Two identities persisted at the same (wallet_id, identity_index) made
load_from_persistor reject the entire wallet. The identities table has no
uniqueness constraint on that pair, and load_state used a plain map insert,
so the second row silently displaced the first. merge_contacts_and_keys then
could not resolve the displaced identity's keys or contacts and returned
OrphanedIdentityEntry, failing the whole load.

The fatal error was only a symptom detector. load_state already dropped the
displaced identity unconditionally; the merge errored only when that identity
happened to own keys or contacts, so the same collision either bricked a
wallet or lost an identity in silence. Fix the displacement itself.

wallet_identities is keyed by index and can hold one identity per slot, so
the lower identity_id keeps the slot and the other is parked in
out_of_wallet_identities, retaining its wallet_id and identity_index. Both
identities, their keys, and their contacts survive the load, and a later
flush rewrites the same on-disk row. Selecting by id rather than by row
arrival makes the outcome identical on every load; the underlying SELECT is
unordered, so the previous behaviour let SQLite's rowid order decide which
identity a wallet showed.

Orphaned entries with no such explanation still hard-error, so the corruption
detector keeps its full strength.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…bucket pairing

ManagedIdentity documented two bucket cases, pairing an out-of-wallet
placement with wallet_id == None and identity_index == None. A collision
between two identities claiming one HD index leaves a third case: the
displaced identity is parked out-of-wallet with both fields still Some(_) so
its keys, contacts, and on-disk row survive.

Record that the fields are provenance rather than the authority on placement,
and that IdentityManager::identity_index and the location index behind it are
what decide the bucket, and so whether an identity can sign. Both correctly
report a parked identity as out-of-wallet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… instead of failing the wallet load"

This reverts commit 65bdbb1.
derive_spent_utxos filled script_pubkey with ScriptBuf::default(), emitting
a spent UTXO whose locking script was never observed on chain. TxOut has no
encoding for "unknown", so the default is a claim rather than an absence, and
consumers reading the stored script get an unusable row.

Rebuild the script from the address the input detail already carries. Height
and the confirmation flags describe the previous transaction and genuinely
aren't recoverable here, so they stay defaulted.

Ports the fix from #4257, whose test already covers this exact change against
a byte-identical core_bridge.rs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tabase

Sibling databases in one directory share the default auto-backup dir
(`<db_dir>/backups/auto/`), and the pre-migration and pre-restore filenames
carried nothing but a second-resolution timestamp. A schema-bump boot that
migrated two of them within the same second produced identical filenames;
`persist_noclobber` correctly refused to overwrite, so no backup was ever
lost, but the losing `SqlitePersister::open` failed with
`BackupDestinationExists` and surfaced as a wallet-backend wiring error.

`BackupKind::PreMigration` and `BackupKind::PreRestore` now carry the source
database's sanitized filename stem, placed before the timestamp so the
timestamp stays the last `-`-delimited token `backup_timestamp` parses back.
`PreDelete` is unchanged — its wallet id already discriminates it.

`sanitize_db_stem` keeps `[A-Za-z0-9_-]`, maps every other character to `_`,
bounds the result at 32 characters, and falls back to `db` for a path with no
stem, so a path-derived string can never escape the backup directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e schema level

An identity_keys row could name an identity parented to a different
wallet. Every reader is wallet-scoped, so such a row is unresolvable:
the per-wallet loader finds keys whose owner it cannot see and returns
OrphanedIdentityEntry, failing the entire wallet load long after the
bad write, with nothing pointing back at the writer.

Make the co-ownership requirement structural. identity_keys' FK becomes
compound — (wallet_id, identity_id) references identities(wallet_id,
identity_id) — so a key can only be filed under the wallet that owns
the identity. The unique parent index this requires adds no restriction
of its own: identity_id is already PRIMARY KEY.

A violation now surfaces as IdentityKeyWalletMismatch naming the wallet
and identity, discriminated numerically on SQLITE_CONSTRAINT_FOREIGNKEY
rather than by matching driver message text, and keeping the driver
error as a walkable source.

The wallet_id -> wallets FK is retained. The compound FK already implies
it, since the matched identities row carries the same non-NULL wallet_id
and is itself FK'd to wallets; it stays as an explicit statement of
intent and a second cascade path.

The load-time orphan check stays: it still guards rows written before
this constraint existed, and any future path that bypasses the writer.

Regenerates tests/fixtures/populated_v001.db, whose applied-V001
checksum must track V001's shape or refinery's divergent-version guard
trips the migration-execution suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d, key_id)

`identities` keys on `identity_id` ALONE, so an identity has exactly one
row and exactly one owning wallet. `identity_keys.wallet_id` was therefore
a denormalised copy carrying no discriminating power, and the
`(wallet_id, identity_id, key_id)` primary key was strictly wider than the
domain key (`IdentityKeysChangeSet` is keyed `(identity_id, key_id)`).

That gap was the enabling condition for the duplicate-row corruption: it
let one key exist twice under two scopes — a state the domain cannot
express. Narrowing the key makes the row pair unrepresentable rather than
merely rejected.

Narrowing alone silently weakens the compound FK landed in 4b92230.
Under the new key a foreign wallet's write for an existing key no longer
arrives as an INSERT — it collides and resolves to `DO UPDATE`, and an
UPDATE that leaves `wallet_id` untouched violates no foreign key. The
write then returns `Ok` and the foreign wallet's key material silently
replaces the owner's under the owner's own scope. The upsert therefore
assigns `wallet_id = excluded.wallet_id`, which restates the incoming
scope so the mismatch still trips the FK.

`identity_key_upsert_cannot_overwrite_another_wallets_key` pins exactly
that: it fails — Ok plus a swapped blob — with the assignment removed.
The DELETE keeps `wallet_id = ?1` deliberately; it is no longer part of
the key but remains a scope guard, mirroring the `identities` tombstone,
so one wallet's `removed` set cannot delete another wallet's key.

Fallout: the migration SQL fingerprint and `populated_v001.db` are
regenerated. The migration-set identity fingerprint is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…arded by triggers

NULL is the canonical "owned by no wallet", already the spelling used by
`identities.wallet_id`. `identity_keys.wallet_id` becomes nullable so an
unowned identity can hold keys.

Nullability disarms the constraints that made the scope safe. SQLite's
default MATCH SIMPLE skips foreign-key enforcement entirely once ANY
column of the child key is NULL, so for a NULL-scoped row BOTH the
`wallets` FK and the compound `identities(wallet_id, identity_id)` FK are
dormant, and nothing at the FK level stops an unowned key from naming a
wallet-OWNED identity — re-opening, through the NULL door, exactly the
unresolvable row the compound FK was added to stop.

A trigger pair replaces that dormancy: a key filed as unowned must name
an identity that is itself unowned. The BEFORE UPDATE twin is not a copy
of the BEFORE INSERT one — the primary key is `(identity_id, key_id)`, so
the writer's upsert resolves an existing key to DO UPDATE, and an UPDATE
never fires an insert trigger. Without the twin the guard is bypassed by
the ordinary re-save path. The converse case (a wallet-scoped key naming
an unowned identity) needs no trigger: that child key is fully non-NULL,
so the compound FK is live.

`RAISE(ABORT)` surfaces as SQLITE_CONSTRAINT_TRIGGER (1811) rather than
_FOREIGNKEY (787), so `map_fk_violation` accepts both and folds them into
the existing `IdentityKeyWalletMismatch`. Both codes mean "this key does
not belong under this scope"; splitting one invariant across two errors
would only give an operator two things to learn to treat identically.

Writer and deleter are made NULL-correct. `wallet_id_to_param` moves to
`schema::mod` and is now used by `identity_keys` too, so both tables
spell the unowned scope the same way — writing the raw all-zero sentinel
would store 32 zero bytes, a value matching no wallet, no reader and no
guard. The delete keeps its scope guard but matches with the NULL-safe
`IS`: `wallet_id = NULL` is never true, so a plain `=` could never erase
an unowned key and would report success having removed nothing.

Tests cover acceptance (unowned key on an unowned identity, including the
DO UPDATE re-save), rejection via BOTH the INSERT and UPDATE trigger
paths, and deletion. The rejection test asserts extended code 1811, so it
proves the trigger fired rather than inferring it from some other guard
rejecting first.

Fallout: migration SQL fingerprint and `populated_v001.db` regenerated;
migration-set identity fingerprint unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Identities that belong to no wallet had no way back out of storage:
`load()` enumerates registered wallets, so nothing it returns can reach
them. `SqlitePersister::load_unowned_identities` is that door, returning
each already carrying its persisted public keys — an accessor handing
back identities without their keys would be a trap.

Inherent rather than part of `PlatformWalletPersistence`, and
deliberately not folded into any wallet's bucket. Routing unowned
identities through a wallet's `out_of_wallet_identities` would look
correct on a load-and-assert test and be wrong on load-save-reload: the
loader stamps the reading wallet's id onto them, and the `identities`
upsert's COALESCE promotion then claims the row on that wallet's next
flush. Keeping them out of wallet-scoped changesets removes the
opportunity rather than special-casing the promotion, which the user
wants to keep working for discovery.

The three scope-aware readers switch from `wallet_id = ?1` to the
NULL-safe `wallet_id IS ?1` with the shared `wallet_id_to_param`, making
reads the mirror of writes: a real wallet id behaves exactly as `=` did,
and the all-zero sentinel maps to NULL and reads the unowned bucket. This
includes `load_tombstoned_ids`, so a tombstoned unowned identity is
recognised as tombstoned and its leftover key rows are skipped rather
than hard-erroring the read as inexplicable orphans.

`managed_identity_from_entry` no longer falls back to the reading scope
when that scope is the sentinel: an identity read from the unowned bucket
comes back with `wallet_id: None`, never `Some([0; 32])`. The sentinel is
a storage spelling of "no wallet", not a wallet id, and handing it out
would put an owner-shaped value on an identity that has none.

Tests: a full round trip (write at sentinel scope, reopen, read back)
asserting the keys are present, the returned identity is still unowned,
and both `identities.wallet_id` and every `identity_keys.wallet_id` are
still SQL NULL; plus a test pinning the documented limitation that
`load()` does not deliver these while the accessor does, with a
registered wallet present so the store is not trivially empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three assumptions this branch relies on had no test that would catch
their regression.

A tombstoned UNOWNED identity. `load_tombstoned_ids` is scoped by
wallet_id, and scoped with `=` it cannot match a NULL: an unowned
tombstone goes unrecognised, its surviving key rows look like owners that
vanished for no reason, and the read fails with `OrphanedIdentityEntry` —
the same brick this branch started from, reached through the unowned
door. Demonstrated rather than asserted: reverting the predicate to
`= ?1` fails the new test with `OrphanedIdentityEntry { owner: [156;
32] }`, naming the test's own identity, and restoring it leaves the file
byte-identical.

A wallet-scoped key naming an unowned identity. This was argued to need
no third trigger, because the child key is fully non-NULL so MATCH SIMPLE
leaves the compound FK live. Now shown: the write is rejected, and the
extended code is asserted to be 787, so the FK is provably what rejected
it rather than a trigger or one of the earlier in-Rust guards standing in
for an FK that never fires.

The DELETE's scope guard. With `(identity_id, key_id)` as the primary
key, `wallet_id` in the DELETE's WHERE no longer selects the row, which
makes removing it look like a tidy-up. It is the only thing stopping one
wallet's `removed` set from deleting another wallet's key. The test
covers both directions: the foreign delete is a no-op, the owner's still
works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
34f0921e -> f8a5cc89, tracking the `dash-evo-tool` branch tip across all
12 rust-dashcore packages. Manifest pins already follow the branch, so
only `Cargo.lock` moves.

Upstream range:
  9cbe4e75  fix(key-wallet): rewrite branch-and-bound coin selection (#918) (#919)
  f8a5cc89  Merge remote-tracking branch 'origin/dev' into dash-evo-tool

No source change. Both revs resolve to the identical tree
8961652a3f2be35743d4df5368474a7c368e8c00, so `git diff 34f0921e
f8a5cc89` is empty. The coin-selection rewrite was already present at
34f0921e, which is itself the merge of `fix/bnb-coin-selection-918`
(7a5a07fc, PR #918); 9cbe4e75 is that same work re-landed on `dev` via
PR #919, and coin_selection.rs is blob e30477116 at both revs. This bump
reconciles history, it does not change behaviour.

Verified: platform-wallet-storage 674 passed / 0 failed / 2 ignored;
platform-wallet 517 passed / 0 failed / 1 ignored; cargo check clean for
platform-wallet-ffi and every other dashcore/key-wallet dependent (dpp,
platform-encryption, dash-sdk, rs-dapi, rs-sdk-ffi, rs-unified-sdk-ffi,
rs-unified-sdk-jni). `cargo check --workspace` fails, but identically at
the old rev — see the commit discussion; those breakages are unrelated
and pre-existing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

6 participants