feat(platform-wallet-storage): embeddable SQLite persistence backend with seedless rehydration#3968
feat(platform-wallet-storage): embeddable SQLite persistence backend with seedless rehydration#3968Claudius-Maginificent wants to merge 223 commits into
Conversation
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis PR adds a Tier-2 secret-envelope format and hardens secret storage, while renaming the SQLite wallet root to ChangesSecrets
SQLite
Estimated code review effort: 5 (Critical) | ~150 minutes Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…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>
52cdad9 to
83f7d4f
Compare
3d57f73 to
2f2a74a
Compare
…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>
|
@coderabbitai full review |
✅ Action performedFull review finished. |
|
🕓 Ready for review — 71 ahead in queue (commit e75f259) |
thepastaclaw
left a comment
There was a problem hiding this comment.
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 key —load_state()selectsidentity_idbut discards it, then decodesentry_bloband routes the restored identity usingentry.id. The writer rejectsIdentityEntryvalues 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 decodedContactRequestwithout checking its sender and recipient IDs. During apply, sent requests are inserted underentry.request.recipient_idand incoming requests underentry.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.
There was a problem hiding this comment.
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 winCount
identity_keysbywallet_idnow that the table is wallet-scoped.
identity_keysmoved onto(wallet_id, identity_id, key_id), but this smoke test still routes it through thevia_identitypath. That means the assertion would still pass if the row were written with the wrongwallet_idas long asidentity_idmatched, 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 winThe soft-cascade note overstates cleanup for identity-scoped metadata.
meta_identityandmeta_tokendo not carrywallet_id, so a wallet delete only reaches them through existingidentitiesrows. If metadata was written before anidentitiesrow 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 winFail closed on corrupted platform-payment registration rows.
This helper trusts the typed
account_indexcolumn but never verifies that the decodedAccountRegistrationEntryis actually aPlatformPaymententry for that same index.all_platform_payment_registrations()feedsplatform_addrs::load_all(), so a tampered row will currently rehydrate under the typed index with the blob's xpub instead of trippingAccountRegistrationEntryMismatch.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 liftDo 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 winApply
keep_last_nas a floor, not a ceiling.With both
keep_last_nandmax_ageset, line 373 still requirespass_count, so backups beyond the newest N are deleted even when they are withinmax_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 winValidate typed identity columns against the blob during load.
load_stateignores the selectedidentity_id, so a corrupted row whose typed column andentry_blob.iddiverge is silently rehydrated under the blob value. Also reject a blobwallet_idthat 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 winEnforce the open-path registry before restore.
restore_from_innercan replacedest_db_pathwhile a liveSqlitePersisterin this process still owns the same DB. Check the registry up front and returnAlreadyOpen; 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(®istered_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 winAssert
synced_heightas well aslast_processed_height.This test writes both fields, but only validates one of them. If
load()stops wiringsynced_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 winAssert 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 mergingdashpay_profilesinto the loaded identity payload, this test still passes. Please also assert that the seeded identity is present afterload()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 winAlso assert that the failed pre-flush left nothing durable.
Restoring the buffer is only half of the contract here. If
apply_changeset_to_txever leaks thewalletsinsert before thecore_sync_statefailure, 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 winFix the query-budget documentation.
load()currently performs multiple reader calls inside thefor wallet_id in wallet_idsloop, 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
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (79)
packages/rs-platform-wallet-ffi/src/persistence.rspackages/rs-platform-wallet-storage/.cargo/audit.tomlpackages/rs-platform-wallet-storage/Cargo.tomlpackages/rs-platform-wallet-storage/README.mdpackages/rs-platform-wallet-storage/SCHEMA.mdpackages/rs-platform-wallet-storage/SECRETS.mdpackages/rs-platform-wallet-storage/migrations/V001__initial.rspackages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rspackages/rs-platform-wallet-storage/src/kv.rspackages/rs-platform-wallet-storage/src/lib.rspackages/rs-platform-wallet-storage/src/secrets/error.rspackages/rs-platform-wallet-storage/src/secrets/file/crypto.rspackages/rs-platform-wallet-storage/src/secrets/file/format.rspackages/rs-platform-wallet-storage/src/secrets/file/mod.rspackages/rs-platform-wallet-storage/src/secrets/keyring.rspackages/rs-platform-wallet-storage/src/secrets/mod.rspackages/rs-platform-wallet-storage/src/secrets/secret.rspackages/rs-platform-wallet-storage/src/secrets/store.rspackages/rs-platform-wallet-storage/src/secrets/wire/aad.rspackages/rs-platform-wallet-storage/src/secrets/wire/config.rspackages/rs-platform-wallet-storage/src/secrets/wire/envelope.rspackages/rs-platform-wallet-storage/src/secrets/wire/kdf.rspackages/rs-platform-wallet-storage/src/secrets/wire/mod.rspackages/rs-platform-wallet-storage/src/sqlite/backup.rspackages/rs-platform-wallet-storage/src/sqlite/config.rspackages/rs-platform-wallet-storage/src/sqlite/conn.rspackages/rs-platform-wallet-storage/src/sqlite/error.rspackages/rs-platform-wallet-storage/src/sqlite/kv.rspackages/rs-platform-wallet-storage/src/sqlite/migrations.rspackages/rs-platform-wallet-storage/src/sqlite/persister.rspackages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rspackages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rspackages/rs-platform-wallet-storage/src/sqlite/schema/blob.rspackages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rspackages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rspackages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rspackages/rs-platform-wallet-storage/src/sqlite/schema/identities.rspackages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rspackages/rs-platform-wallet-storage/src/sqlite/schema/mod.rspackages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rspackages/rs-platform-wallet-storage/src/sqlite/schema/token_balances.rspackages/rs-platform-wallet-storage/src/sqlite/schema/wallets.rspackages/rs-platform-wallet-storage/src/sqlite/util/safe_cast.rspackages/rs-platform-wallet-storage/tests/common/mod.rspackages/rs-platform-wallet-storage/tests/secrets_api.rspackages/rs-platform-wallet-storage/tests/secrets_default_on_compiles.rspackages/rs-platform-wallet-storage/tests/secrets_scan.rspackages/rs-platform-wallet-storage/tests/sqlite_account_zero_attribution.rspackages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rspackages/rs-platform-wallet-storage/tests/sqlite_asset_locks_filter.rspackages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rspackages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rspackages/rs-platform-wallet-storage/tests/sqlite_commit_writes_lock_poison_shortcircuit.rspackages/rs-platform-wallet-storage/tests/sqlite_compile_time.rspackages/rs-platform-wallet-storage/tests/sqlite_contacts_keys_rehydration.rspackages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rspackages/rs-platform-wallet-storage/tests/sqlite_dashpay_overlay_contract.rspackages/rs-platform-wallet-storage/tests/sqlite_delete_buffer_reconcile.rspackages/rs-platform-wallet-storage/tests/sqlite_delete_cross_process_exclusion.rspackages/rs-platform-wallet-storage/tests/sqlite_delete_partial_commit_window.rspackages/rs-platform-wallet-storage/tests/sqlite_delete_real_apply_failure.rspackages/rs-platform-wallet-storage/tests/sqlite_delete_wallet.rspackages/rs-platform-wallet-storage/tests/sqlite_error_classification.rspackages/rs-platform-wallet-storage/tests/sqlite_fk_changeset_ordering.rspackages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rspackages/rs-platform-wallet-storage/tests/sqlite_identity_keys_reader.rspackages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rspackages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rspackages/rs-platform-wallet-storage/tests/sqlite_migrations.rspackages/rs-platform-wallet-storage/tests/sqlite_money_column_overflow_on_read.rspackages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rspackages/rs-platform-wallet-storage/tests/sqlite_open_integrity_check.rspackages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rspackages/rs-platform-wallet-storage/tests/sqlite_qa_identity_tombstone.rspackages/rs-platform-wallet-storage/tests/sqlite_second_open_guard.rspackages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rspackages/rs-platform-wallet-storage/tests/sqlite_wallet_db_identity.rspackages/rs-platform-wallet/src/changeset/client_wallet_start_state.rspackages/rs-platform-wallet/src/manager/load.rs
…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>
AddressInfo::is_used() collapsed Reserved{at} and Available to the same
`used=0` row, silently discarding the reservation timestamp on every
write. Add a nullable reserved_at column and persist it, clearing it
whenever a row is (or becomes) used so a stale Reserved snapshot can
never resurrect a reservation on an already-used address.
The read/restore path is intentionally left untouched: threading the
restored value into the live wallet requires widening
insert_platform_node_pool_entry's signature in rs-platform-wallet,
tracked separately in #4188 to keep this change scoped to
platform-wallet-storage.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…okkeeping on downgrade Two proven correctness gaps from PR #3968's review: - load_from_persistor (the sole app-launch rehydration entry point) never retried a transient persister error, unlike the sibling registration path which wraps load_persisted() in retry_transient with the documented rationale that a load is an idempotent read. A single transient blip at launch aborted rehydration for every wallet. - insert_platform_node_pool_entry left stale used_indices/highest_used when an index transitioned used=true -> used=false across two calls, violating the same invariant its sibling used->used fix enforces. Also: typed PlatformNodePoolError now propagates through PlatformWalletError instead of being flattened to a string (with matching #Errors doc update), rebuild_provider_key_account gained direct BLS/EdDSA and mismatch-rejection tests, and a duplicate inline now_secs() computation was consolidated to the existing helper. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…er errors, gate asset-lock proof size The FFI boundary collapsed every persister error (transient or fatal) to a single generic code, discarding exactly the retry-classification this PR's PersistenceErrorKind introduces -- so a production Swift/iOS host had no signal to build its own retry loop around a transient rehydration blip. Add ErrorPersisterTransient/ErrorPersisterFatal FFI codes routed through PersistenceError::is_transient(), recursing through PersisterRestore's nested wrapping, and mirror both new codes on the Swift side. Also gate the AssetLockProof bincode decode in asset_lock_manager_recover behind an explicit 16 MiB length check plus a matching decode-budget limit, closing the one bincode decode boundary the RUSTSEC-2025-0141 suppression rationale didn't actually cover -- and correct that rationale to describe the real guarded surface. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
… decode, doc corrections - SEC-002: SqlitePersister::open_inner now shares the same parent-directory permission check the secrets vault already enforced (rejects a group/other-writable parent before precreate_secure), closing the one asymmetry between the two on-disk artifacts. - CODE-002: introduce schema::id32() as the single "stored column -> [u8;32]" decoder, carrying both the column name and actual length in one structured WalletStorageError::InvalidWalletIdLength variant, and route every reader through it instead of eight inline, inconsistently-erroring copies. - CODE-003: restore INTEGER-discriminant parity tests for AddressPoolType and PublicKeyType so an added variant fails a test instead of silently hitting a frozen runtime CHECK rejection; migration SQL is untouched. - SEC-004 / PROJ-004: document the vault's shared rekey fault domain and why the version/generation read accessors stay test-only. - PROJ-001/002/003/005: SCHEMA.md and README.md corrected for the real V001 table count (23, including pending_contact_crypto/ignored_senders and the fifth CHECK domain), the V005/V006 migration log entries, the actual load_prekeyed reconstruction path, and the full V001-V006 migration set. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…review-findings # Conflicts: # packages/rs-platform-wallet/src/changeset/traits.rs # packages/rs-platform-wallet/src/wallet/identity/network/payments.rs
…ialization owning_account_for_script and load_used_addresses (core_pool.rs), plus the account-registration readers in accounts.rs, materialized user_identity_id/friend_identity_id/wallet_id as a full Vec<u8> before validating width — the only load-path BLOB columns missing the pre-read length() gate every other variable-width column already has. A crafted .db could force an oversized allocation per row before the post-hoc 32-byte check rejected it. Select length(<col>) alongside each row and gate via blob::check_fixed_width before materializing, mirroring the existing txid handling in core_state::load_state. Bounded pre-fix by the connection-level 32MiB SQLITE_LIMIT_LENGTH backstop, so this closes an allocation-amplification gap rather than an unbounded one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Codex Sol <noreply@openai.com>
…uity derive_and_verify maps any AEAD failure on the vault header's verify_ct/verify_nonce to WrongPassphrase, including a bit-flipped header opened with the correct passphrase — cryptographically indistinguishable from an actually wrong passphrase, since the header carries no independent (non-passphrase-keyed) integrity check. SECRETS.md documents this exact ambiguity class for the OS-keyring arm but claimed the file arm was unambiguous; that claim held only for Tier-2 entries; the Tier-1 header had the same gap, undisclosed. Extend the disclosure to state that a file-arm WrongPassphrase means "wrong passphrase or corrupted header" until a header-level integrity check exists. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Codex Sol <noreply@openai.com>
…nt and observable, harden parent-permission gate A poisoned connection Mutex previously left every subsequent store()/ flush()/commit_writes() call free to keep operating on stale buffered state, and Manual-mode store() kept returning Ok(()) while silently discarding writes with no signal to the caller. conn() now discards every buffered changeset the moment poisoning is detected, and ensure_connection_usable() short-circuits store()/flush()/ commit_writes() up front so a poisoned instance fails loudly and permanently instead of quietly eating data. README documents that a LockPoisoned result means the SqlitePersister instance is unusable and the caller must drop it and reopen. check_parent_perms only inspected the immediate parent directory's mode bits, not ancestors or ownership, and restore_from_inner skipped the gate entirely on its destination. The gate now walks both the lexical path and its canonicalized symlink target through the filesystem root, rejecting any writable-without-sticky-bit or not-owned-by-the-current-user-or-root component, and applies to the restore destination as well as open(). The filesystem root's own owner is treated as a root identity so the check degrades sanely under UID-namespaced environments. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Codex Sol <noreply@openai.com>
… the real rehydration path The account-zero-fallback regression test asserted against core_state::list_unspent_utxos, a test-only helper reading a raw column directly — never exercising load_state() + apply_persisted_core_state, the path a real wallet restart actually uses. A regression in the production resolver could pass this test undetected. Rewrite it to store, close, reopen, drive the real rehydration pipeline, and assert the UTXO lands in the first funds account with its exact balance. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Codex Sol <noreply@openai.com>
… and spent_in_txid, resolve owning-account at read time core_utxos.account_index was written once at UPSERT time but had no production reader: rehydration (apply_persisted_core_state) resolves owning-account exclusively via owning_account_for_script against whatever core_address_pool rows exist at load time. A script with no pool row at write time stayed frozen at its fallback value forever in the column while the read-time resolver picked up a later-added pool row correctly — the two silently disagreed with nothing to catch it. spent_in_txid had the same shape: documented as tracking the spending transaction, never populated by any write path. Drop both columns (migration V007) along with the now-dead account_index_for_script helper and the obsolete FK-cleanup trigger. The test-only list_unspent_utxos helper now resolves ownership through owning_account_for_script, matching what production actually does, instead of trusting a column nothing else reads. Also corrects core_pool.rs's doc comment, which claimed owning_account_for_script and load_used_addresses "attribute a shared script identically" — they intentionally don't: UTXO ownership considers every pool row, the reuse guard only rows marked used. Fixed the documentation to describe the actual, intentional distinct-source contract instead of a false identical-behavior claim. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Codex Sol <noreply@openai.com>
…se restore trust model persistence_capabilities()'s WALLET_RESTORE rationale cited wallets as a LOAD_UNIMPLEMENTED member — stale; the actual gap is that token balances and the DashPay overlay have no load readers, so a full restore stays lossy. Restated the comment to match. restore_from validates SQLite integrity, wallet application identity, and schema compatibility, but never authenticates the backup's provenance. Documented this as an accepted residual: a restore trusts its source file as much as the live database, so the backup directory itself needs the same protection. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Codex Sol <noreply@openai.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Codex Sol <noreply@openai.com>
…ard wording Follow-up polish to the weak-KDF test-path documentation and the minimum-passphrase-length change: clarifies that the Argon2id floor applies to fresh-vault creation and per-secret Tier-2 wrapping, while an existing vault keeps the parameters recorded in its own header, and that the blank-passphrase guard is superseded by minimum-length enforcement. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Codex Sol <noreply@openai.com>
…across migration docs README, the V003 migration docstring, and a migrations.rs test comment each stated a different policy on whether V001 stays byte-identical or is edited in place. This crate has no deployed stores yet, so every migration (V001 included) remains editable until the first release, after which the set becomes append-only. Restated the V003 docstring and the migrations.rs comment to say so consistently instead of singling out V001 as frozen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Codex Sol <noreply@openai.com>
Missing --db returned exit 1 (runtime error) instead of the documented usage-error exit 2, inconsistent with the sibling --yes/ --keep-last checks. Restore's source-validation rejections (NotAWalletDb, SchemaVersionUnsupported, SchemaHistoryMalformed, SourceOpenFailed) returned exit 1 instead of the documented validation-failure exit 3, and the missing-schema-history message misattributed itself to the integrity check. Runtime failures (RestoreDestinationLocked, Io, AutoBackupDisabled) keep exit 1. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Codex Sol <noreply@openai.com>
…m source comments Five committed comments referenced transient review-finding IDs from a prior triage round — dead references once the generating report is regenerated, per coding-best-practices. Replaced each with the underlying rationale in prose. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Codex Sol <noreply@openai.com>
…arden auto-backup directory permissions restore_from's documented reader-exclusion only interlocked writers: BEGIN EXCLUSIVE alone doesn't stop a WAL-mode reader from observing the destination mid-restore. Adds SQLite's own locking_mode=EXCLUSIVE pragma alongside it so readers back off for the restore's duration too. When the destination doesn't exist yet, an owner-only (0600) placeholder is created atomically before exclusion is acquired so a peer can't race to create the path during staging; the placeholder is cleaned up on a pre-lock-release failure and left alone afterward, since a peer may have legitimately claimed the path once the lock is gone. The auto-backup directory (and any missing parent components) is now created explicitly at mode 0700 instead of umask-default, matching this crate's existing 0600 file discipline, and is checked through the ancestor-walking parent-permission gate before use. Also documents restore's trust model (structural validation, not provenance authentication — protect the backup directory) and adds a README Testing section noting the --all-features requirement for the two #3968 end-to-end regression tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Codex Sol <noreply@openai.com>
ShieldedChangeSet::viewing_keys and ClientStartState::shielded.viewing_keys already existed as the write/read contract, and PersistenceCapabilities:: SHIELDED_VIEWING_KEYS already existed as a flag, but SqlitePersister silently dropped viewing keys on both store() and load() and never attested the capability. Add a shielded_viewing_keys table (migration V008), scoped by (wallet_id, account_index) with the standard ON DELETE CASCADE every sibling table already uses, so delete_wallet reaps it for free. Wire apply()/load() to persist and restore the 96-byte FVK per subwallet, gated behind this crate's existing shielded feature, and attest SHIELDED_VIEWING_KEYS when that feature is compiled in. A decode, width, or identifier failure on one wallet's viewing-key row degrades that one SubwalletId to absent and logs a warning instead of aborting load() for every wallet in the store — the corrupt-row blast-radius gap called out in #4200's due-diligence review. Genuine backend/IO failures remain fatal. Closes #4200. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Codex Sol <noreply@openai.com>
… with a default unsupported implementation PlatformWalletPersistence had no delete_wallet contract at all — its own comment deferred it pending "a cross-backend contract... agreed." SqlitePersister already has a mature inherent delete_wallet (cascade delete, auto-backup, buffer-drain-and-restore-on-failure), but nothing exposed it through the trait, so every consumer needing wallet removal had to bolt on its own adapter. Add delete_wallet to the trait with a default implementation returning the new PersistenceError::UnsupportedOperation, so the ~30 existing implementers across this crate (almost all #[cfg(test)] mocks) keep compiling unchanged. SqlitePersister overrides it, delegating to its existing inherent method; the FK ON DELETE CASCADE already covering every wallet-scoped table (including the new shielded_viewing_keys table) reaps everything automatically. Related to #4200. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Codex Sol <noreply@openai.com>
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-walletdefines the persistence trait (PlatformWalletPersistence) and the manager-sideload_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
.dbfile 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, theWalletType::ExternalSignablemodel) — this PR is the storage half, the net change againstv4.1-devis almost entirely the new crate.Adds
rs-platform-wallet-storage— a self-contained, embeddable SQLite persistence backend implementingPlatformWalletPersistence.Persister & seedless rehydration
SqlitePersister(usable asArc<dyn PlatformWalletPersistence>,Send + Sync, object-safe) with configurable journal / synchronous / flush modes, a retention policy, and auto-backup.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 — viaapply_persisted_core_state. Prekeyed identity/contact joins mean signing works immediately post-load with no key re-sync.Schema & migrations (refinery; additive; version-pinned with golden schema-freeze fingerprints)
__initial— per-wallet tables keyed bywallet_id, nativeFOREIGN KEY … ON DELETE CASCADE; identity-owned tables cascade viaidentities.wallet_id.__address_height_pin— addsplatform_addresses.as_of_height(address double-count fix).__unified(additive, sequenced after V002) —core_address_pool(first-class per-index address-pool rows with ausedflag, 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), andmeta_store_generation(restore-regenerated store token).__invitations(additive, from thev4.1-devmerge) — adds theinvitationstable for DIP-13 DashPay invitations (inviter-side records; no key material stored, the voucher key is HD-re-derivable fromfunding_index).__pool_public_key(additive) — adds nullablepublic_key/key_typecolumns tocore_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).__pool_reserved_at(additive) — adds a nullablereserved_atcolumn tocore_address_pool, persistingAddressState::Reserved's timestamp (rust-dashcore PR fix(rs-dpp): Remove overflows #818) instead of silently collapsing it to the same row shape asAvailable. Write-only for now — theON CONFLICTclears it whenever a row is/becomesused, so a staleReservedsnapshot 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.Trust boundary & robustness (the
.dbis untrusted input at load)load(): any row that fails to decode, or an out-of-rangewallet_id, aborts the whole call with a typedWalletStorageError— no silent per-row skip, no partialOk.SIZE_LIMIT_BYTES) cap on KV values and BLOB decode, per-columnlength()pre-read gates before materialization, a bincode decode bounded by aLimitconfig that rejects trailing bytes, and a 32 MiBSQLITE_LIMIT_LENGTHconnection backstop.Secrets
SecretStore/EncryptedFileStore: Argon2id KDF + XChaCha20-Poly1305 AEAD envelope,zeroized, overkeyring-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-walletuntouched wherever the storage crate alone can carry a change)platform-wallet: two real fixes, both 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 restoresused_indices/highest_usedpool 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), andnext_unused_receive_addressnow reserves the address it hands out (AddressPool::next_unused_and_reserve) instead of leaving it unreserved, closing a hand-out race now that upstreamkey_walletsupports address reservation. Otherwise: doc-comment only (new_watch_only→new_external_signable).swift-sdk: the Platform-wallet load FFI consumer reconciled to the shipped 1-argplatform_wallet_manager_load_from_persistorcontract; one real fix inSendTransactionView(stop funding core-to-core sends from a Platform-Payment index); the rest are doc-comment renames.rust-dashcorepinned to adash-evo-toolintegration branch (dashpay/rust-dashcore, commit0091c4a9) 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 APInext_unused_receive_addressabove now uses). Still an interim state: neither upstream PR has merged todevyet, so the pin points at a branch, not a merged/tagged commit — re-pin todevdirectly once both land. Root.cargo/audit.tomlacknowledgesRUSTSEC-2025-0141(bincode unmaintained — an informational advisory, mitigated by the size caps + fail-hardload()above).Review remediation (independent grumpy-review: 6 specialist passes, 31 raw findings consolidated to 22 — 0 CRITICAL, 1 HIGH, 5 MEDIUM, 16 LOW)
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 sharedid32()decode helper replacing 7 duplicated inline 32-byte-ID decodes; restoredAddressPoolType/PublicKeyTypeinteger-discriminant parity tests;SCHEMA.md/README.mddoc-drift corrections (table count, migration-log rows, rekey fault-domain note).platform-wallet:load_from_persistornow retries a transient persister failure during startup instead of failing rehydration outright;insert_platform_node_pool_entryclears staleused_indices/highest_usedbookkeeping when a pool entry downgrades from used;populate_platform_node_poolpropagates a typed error instead of stringly-typed; a hand-rolled timestamp computation replaced with the sharednow_secs()helper.platform-wallet-ffi: dedicated FFI result codes distinguishing transient vs. fatal persister errors (mirrored in the SwiftPlatformWalletErrorenum); a 16 MiB size gate on asset-lock proof bytes before bincode decodes them.cargo fmt/clippy -D warnings/full test suite re-run per crate, specific new test names confirmed passing in the raw log.rust-dashcorebranch 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)
wallet_id) — tracked in feat(platform-wallet): manifest integrity checksum (Risk-6/R12.5 follow-up) #3992.reserved_atcorrectly, but nothing releases or sweeps a stale reservation, and nothing threads the restored value back into the live wallet (that needs a signature change inrs-platform-wallet'sinsert_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: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 inSECRETS.mdas 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).dashpay/grovedb— the pinnedv5.0.1(already its latest tag) hard-requiresrusqlite ^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.core_utxos.account_indexand.spent_in_txiddropped — both were write-only columns with zero production readers (rehydration already resolves owning-account at read time viaowning_account_for_script;spent_in_txidwas 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.--db→ usage error 2; restore source-validation failures → validation error 3, not runtime error 1);restore_from's reader-exclusion hardened with SQLitelocking_mode=EXCLUSIVEso WAL readers back off too, not just writers; auto-backup directory now created at explicit0700instead 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 realload_state/apply_persisted_core_statepath instead of a raw-column test helper.cargo test/clippy -D warningsruns per commit, specific test names confirmed passing in the log, diffs read before staging.Native shielded viewing-key persistence +
delete_walletcontract (closes #4200)SqlitePersisterpreviously advertised no support forPersistenceCapabilities::SHIELDED_VIEWING_KEYSand silently droppedShieldedChangeSet::viewing_keyson bothstore()andload(), despite the changeset/capability plumbing already existing — found via adash-evo-toolconsumer having to bolt on its own wrapper to fill the gap. Added ashielded_viewing_keystable (migration V008,ON DELETE CASCADElike every sibling table), wired behind the crate's existingshieldedfeature. 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.PlatformWalletPersistencehad nodelete_walletmethod at all (SqlitePersisterhad one only as an inherent method). Added it to the trait with a default implementation returning a newPersistenceError::UnsupportedOperation, so the ~30 existing implementers acrossrs-platform-wallet(mostly test mocks) compile unchanged;SqlitePersisteroverrides it, delegating to its existing cascade-delete implementation.Test-only fast KDF for
SecretStore(closes #4111)dash-evo-tool'swallet_backendtests) drove realSecretStore::{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.SecretStore::file_mock/EncryptedFileStore::open_mock, gated behind#[cfg(any(test, feature = "test-util"))](newtest-utilCargo feature). A mock-constructed store derives at the enforced floor params instead of the shipped default — the fastest configurationenforce_boundsstill accepts — for both the vault-unlock derivation and the per-secret Tier-2 wrap, with no change to any public method signature. A caller swapsfile→file_mockat construction; every subsequent call is transparently fast.KdfParamsstayspub(crate)); the fix is entirely crate-side so no downstream consumer implements its own fast-KDF logic. A single sharedKdfParams::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.cfg!(debug_assertions)-based runtime guard (a runtime value, present in every profile — unlikedebug_assert!) lives onfloor_target()itself, the single choke point every caller (mock constructors and internal tests alike) goes through. Iftest-utilever leaks into a release build via feature unification, the guard panics loudly instead of silently handing back weak crypto;open_mockevaluates it before the passphrase check so a blank passphrase can't mask the panic.argon2is the only workspace crate consumer of theargon2dependency, so added it to the rootCargo.toml's existing[profile.dev.package.*] opt-level = 3list (alongsidehalo2_proofs/orchard/pasta_curves/etc.) — narrow in practice despite being a workspace-level stanza, since nothing else in the tree compilesargon2. Composes multiplicatively with the mock-KDF floor params: measured 149.19s → 13.51s (~11x) onplatform-wallet-storage's 266 KDF-heavy lib unit tests.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-features— 1513 passed / 0 failed, current as of the latestv4.1-devmerge and the review-remediation fixes above.Swift compilation is verified by CI (
Swift SDK build) — FFI symbols were matched by hand against the Rustextern "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-dashcorepin above, and two data-durability edge cases (identitiestable missing a uniqueness constraint on(wallet_id, identity_index), andload_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_walletwork (2 commits) each independently verified:cargo test/clippy --all-features -- -D warningsclean forplatform-wallet-storage, and additionally forplatform-wallet(thedelete_wallettrait change) — all ~30 existingPlatformWalletPersistenceimplementers acrossrs-platform-walletcompiled unchanged.Breaking changes
None in this PR's net diff. The storage crate is purely additive; the
platform-wallettouches are two bug fixes (pool-bookkeeping restore, address-reservation hand-out — see above) plus doc-comment renames, none changing a public signature;swift-sdkis comment renames plus one localized send-funding fix. (TheWalletType::ExternalSignablemodel this crate consumes already lives inv4.1-dev.)Checklist
For repository code-owners and collaborators only
Prior work
key-walletPRs (UTXO-spend fix, address reservation) this PR's interimdash-evo-toolpin combines.reserved_atfix (V006) deliberately leaves for later.delete_wallettrait contract; closed by the second remediation round above.Attribution
🤖 Co-authored by Claudius the Magnificent AI Agent
Summary by CodeRabbit
New Features
Bug Fixes