feat(platform-wallet): add platform-wallet-storage crate (sqlite persister)#3625
feat(platform-wallet): add platform-wallet-storage crate (sqlite persister)#3625Claudius-Maginificent wants to merge 78 commits into
Conversation
New workspace crate `platform-wallet-sqlite` implementing the
`PlatformWalletPersistence` trait against a bundled SQLite backend, plus
a `platform-wallet-sqlite` maintenance CLI.
Highlights
- Per-wallet in-memory buffer with `Merge`-respecting `store` + atomic
per-wallet `flush` (one SQLite transaction per call).
- `FlushMode::{Immediate, Manual}` with `commit_writes` aggregating
dirty wallets in deterministic order.
- Online backup via `rusqlite::backup::Backup::run_to_completion`,
source-validating `restore_from`, `prune_backups` retention with
AND-semantics, automatic pre-migration and pre-delete backups (with
typed `AutoBackupDisabled` refusal when `auto_backup_dir = None`).
- Refinery-driven barrel migrations under `migrations/`; FK enforcement
emulated with triggers because barrel's column builder doesn't emit
composite-key `FK` clauses portably on SQLite.
- `delete_wallet` cascade with `DeleteWalletReport`; `inspect_counts`
surface for the CLI.
- CLI: `migrate`, `backup`, `restore`, `prune`, `inspect`,
`delete-wallet` with `--yes` destructive-op guards, humantime
retention parsing, and stdout/stderr/exit-code conventions matching
the spec.
- 52 tests across 8 files plus compile-time assertions cover every
FR/NFR except the ones blocked on upstream `serde`/`bincode`
derives or a `Wallet::from_persisted` constructor (tracked in
TODOs in `persister.rs::load` and the test modules' module-docs).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…o.toml Phase 2.2 fix wave — addresses Adams' BLOCK findings. - PROJ-001: add `platform-wallet-sqlite` to both `--package` lists in `tests-rs-workspace.yml` (coverage run and the Ubuntu 4-shard fallback) so CI actually executes the crate's tests. - PROJ-002: append `packages/rs-platform-wallet-sqlite` to every enumerated `COPY --parents` block in the Dockerfile (the chef prepare stage, the artifact-build stage, and the rs-dapi stage). Workspace `Cargo.toml` already lists the member; chef would fail with "directory not found" without these copies. - PROJ-003: allow `wallet-sqlite` in the PR-title conventional- scopes list (matches the existing `feat(wallet-sqlite): …` commit). - PROJ-004: align `dash-sdk` feature flags with sibling `rs-platform-wallet` (`dashpay-contract`, `dpns-contract`); document why `dpp`, `dash-sdk`, and `bincode` are direct deps (they're actually used — Adams' "unused" claim was wrong for all three); drop the redundant `serde` feature from bincode. - PROJ-005: gate `lock_conn_for_test` and `config_for_test` behind `cfg(any(test, feature = "test-helpers"))` plus a new `test-helpers` dev feature; the crate's own `[dev-dependencies]` self-include now activates it for integration tests, so downstream consumers cannot reach the helpers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2.2 fix wave — addresses Diziet, Marvin, Smythe, Trillian BLOCKs.
Library
- D-01: new `SqlitePersister::delete_wallet_skip_backup(wallet_id)`
entry point that intentionally skips the auto-backup. The CLI's
`--no-auto-backup` now uses it instead of mutating
`auto_backup_dir` to `None` (which collided with the
`AutoBackupDisabled` refusal path and silently broke the flag).
- D-02: `delete_wallet` checks `wallet_metadata` existence BEFORE
running the auto-backup. Refusing on an unknown wallet id no
longer leaves an orphaned `.db` in the auto-backup directory.
- D-03: `restore_from` try-acquires an exclusive file lock on the
destination via `fs2::FileExt::try_lock_exclusive` and raises
`RestoreDestinationLocked` if the file is held. Falls through on
filesystems without advisory locking.
- D-04: `restore_from` reads the source DB's max
`refinery_schema_history.version` and raises
`SchemaVersionUnsupported { found, expected_range }` when it
exceeds the highest embedded migration version.
- SEC-001: `restore_from` stages via
`tempfile::NamedTempFile::new_in(parent)` plus `persist`. The
previous predictable `<dest>.db.restore-tmp` filename was a
symlink-plant TOCTOU window.
- DOC-007 / DOC-008: rustdoc on `RetentionPolicy` explains the
AND-semantics; `DeleteWalletReport.backup_path` documents that
`None` ONLY happens via the new skip-backup entry point.
CLI
- D-05: `-v`/`-vv`/`-vvv`/`-q` wired to a `tracing_subscriber::fmt`
subscriber that writes to stderr with an `EnvFilter` defaulted
from the flag count (`warn` / `info` / `debug` / `trace`); `-q`
forces `error`.
- `delete-wallet --no-auto-backup` now routes through
`delete_wallet_skip_backup` and prints empty stdout (no backup
path) with the `warning: auto-backup skipped (--no-auto-backup)`
line on stderr.
Tests
- QA-001: new TC-023 in `tests/buffer_semantics.rs` — registers a
`commit_hook` on the write connection (rusqlite `hooks` feature),
then drives a flush whose changeset touches `core_sync_state`,
`wallet_metadata`, and `token_balances`. The hook MUST fire
exactly once. Atomicity is now empirically verified.
- QA-008: `tests/load_reconstruction.rs::tc043_*` rewritten to
store non-empty `ContactChangeSet` and `TokenBalanceChangeSet`
payloads (the previous Defaults were `is_empty()` and got
skipped by the buffer). The test now reopens the persister,
directly SQL-queries `contacts_sent` and `token_balances` rows,
and asserts `ClientStartState.platform_addresses` stays empty.
- SEC-006: new `tests/secrets_scan.rs` greps every file under
`src/schema/` and `migrations/` for the substrings `private`,
`mnemonic`, `seed`, `xpriv`, `secret`. A small allow-list lets
doc comments mention the boundary while catching genuine slips.
Docs
- DOC-002: README CLI synopsis adds an explicit sentence about
`--yes` being REQUIRED for destructive subcommands, plus a
logging-flag blurb.
- DOC-016: new per-crate `CHANGELOG.md` with `[Unreleased]` section
enumerating the additions and security fixes from this fix wave
(the workspace CHANGELOG is generated from Conventional Commits).
- SECRETS.md audit-hooks section updated to point at
`tests/secrets_scan.rs` and the TC-082 lint test by file:line.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
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:
📝 WalkthroughWalkthroughAdds a new Rust crate implementing SQLite-backed wallet storage with migrations, buffering, backup/restore/prune, schema writers/readers, a maintenance CLI, extensive tests, and integrates it into workspace, CI, and Docker. Also updates platform-wallet error model and serde adapters. ChangesSQLite Wallet Storage
Sequence Diagram(s)sequenceDiagram
rect rgba(200, 230, 255, 0.5)
participant Dev as Developer CLI
participant P as SqlitePersister
participant DB as SQLite DB
end
Dev->>P: migrate
P->>DB: open + run embedded migrations
Dev->>P: backup --out <path>
P->>DB: online backup (page copy)
Dev->>P: restore --from <backup>
P->>DB: staged validate + atomic replace
Dev->>P: prune --keep-last N
P-->>Dev: PruneReport / errors (kind-tagged)
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related issues
Possibly related PRs
Suggested labels
Suggested reviewers
Poem
✨ Finishing Touches🧪 Generate unit tests (beta)
|
Add a new `serde` Cargo feature on `platform-wallet`. When enabled, every type carried in a `PlatformWalletChangeSet` gains `serde::Serialize` / `serde::Deserialize` derives via `#[cfg_attr(feature = "serde", derive(...))]`: - `CoreChangeSet`, `IdentityChangeSet`, `IdentityEntry`, `IdentityKeysChangeSet`, `IdentityKeyEntry`, `IdentityKeyDerivationIndices`, `ContactChangeSet`, `ContactRequestEntry`, `SentContactRequestKey`, `ReceivedContactRequestKey`, `PlatformAddressChangeSet`, `PlatformAddressBalanceEntry`, `AssetLockChangeSet`, `AssetLockEntry`, `TokenBalanceChangeSet`, `WalletMetadataEntry`, `AccountRegistrationEntry`, `AccountAddressPoolEntry`, and the top-level `PlatformWalletChangeSet`. - Per-identity / DashPay leaf types referenced inside those changesets: `BlockTime`, `IdentityStatus`, `DpnsNameInfo`, `DashPayProfile`, `ContactRequest`, `EstablishedContact`, `PaymentEntry`, `PaymentDirection`, `PaymentStatus`, `AssetLockStatus`. The feature activates `key-wallet/serde` (which transitively flips `dashcore/serde` and `dash-network/serde`) so every upstream leaf type already wired with `#[cfg_attr(feature = "serde", ...)]` (TransactionRecord, Utxo, InstantLock, AccountType, AddressInfo, AddressPoolType, ExtendedPubKey, Network) round-trips cleanly. Two upstream types lack their own serde feature and use `#[serde(with = ...)]` adapters in the new `src/changeset/serde_adapters.rs` module: - `AssetLockFundingType` (key-wallet, no `serde` derive) — encoded as a stable u8 tag matching the prior hand-rolled blob layout. - `AddressFunds` (dash-sdk re-export, no serde derive) — encoded as a `(nonce, balance)` shadow struct. One field is marked `#[serde(skip)]`: - `CoreChangeSet::addresses_derived` carries `key_wallet_manager::DerivedAddress`, which has no serde derive AND no `key-wallet-manager/serde` feature to activate. The breadcrumb is written to a typed table by persisters, not via a changeset blob, so skipping costs nothing. `cargo build -p platform-wallet` (no features) and `cargo build -p platform-wallet --features serde` both build clean. `cargo test -p platform-wallet` passes (8 lib tests, 121 integration tests) with and without the new feature. The change is opt-in; the default-feature build is byte-identical to its prior shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…allet-storage and restructure for future secrets submodule PURE rename + restructure — no functional code changes. Carves out a spot for a future `SecretStore` (sketched in `SECRETS.md`) to land as a `secrets` submodule inside the same crate, rather than a separate `platform-wallet-secrets` crate. Crate metadata - Cargo package name: `platform-wallet-sqlite` → `platform-wallet-storage`. - Crate directory: `packages/rs-platform-wallet-sqlite/` → `packages/rs-platform-wallet-storage/`. - Binary name: `platform-wallet-sqlite` → `platform-wallet-storage`. Module layout - Everything SQLite-related is now under `src/sqlite/`: `mod.rs` (new — re-exports the submodules), `persister.rs`, `buffer.rs`, `config.rs`, `error.rs`, `migrations.rs`, `backup.rs`, and `schema/`. The `migrations/` Rust-file directory stays at the crate root because `refinery::embed_migrations!` resolves its path relative to `Cargo.toml`. - `src/lib.rs` exposes `pub mod sqlite;` plus root re-exports of the common types (`SqlitePersister`, `SqlitePersisterConfig`, `FlushMode`, `SqlitePersisterError`, `RetentionPolicy`, `PruneReport`, `DeleteWalletReport`, `AutoBackupOperation`, `JournalMode`, `Synchronous`) so most consumer imports stay identical — only the crate name in `Cargo.toml` changes for them. A `// pub mod secrets;` marker reserves the future module slot. Cargo features - `sqlite` (default) — enables the SQLite persister + every backend- specific optional dep (`rusqlite`, `refinery`, `barrel`, `dpp`, `dash-sdk`, `key-wallet`, `key-wallet-manager`, `dashcore`, `bincode`, `fs2`, `tempfile`, `chrono`, `sha2`). - `cli` (default) — enables the maintenance binary; implies `sqlite`. - `secrets` — reserved, no code yet. - `test-helpers` — crate-private accessors (unchanged semantics); now implies `sqlite`. - `cargo build -p platform-wallet-storage --no-default-features` builds the bare crate cleanly (verified). Tests - Renamed `tests/<name>.rs` → `tests/sqlite_<name>.rs` (9 files) so the future `secrets_<name>.rs` files won't collide. `secrets_scan.rs` and `tests/common/` keep their names. - `secrets_scan.rs` updated to scan `src/sqlite/schema/` (the new location of the schema writers) and `migrations/`. Carved out `src/secrets/` from the scan up front — that future submodule WILL legitimately contain the words `private`, `mnemonic`, `seed`. Workspace integration - `Cargo.toml` workspace `members` entry renamed. - `Dockerfile`: three `COPY --parents` blocks updated. - `.github/workflows/tests-rs-workspace.yml`: two `--package` lines updated. - `.github/workflows/pr.yml`: added `wallet-storage` alongside the existing `wallet-sqlite` allow-list entry (both coexist so PRs pending against either name pass). Gate output - `cargo fmt --all -- --check` clean. - `cargo build -p platform-wallet-storage` clean. - `cargo build -p platform-wallet-storage --no-default-features` clean. - `cargo build -p platform-wallet-storage --bin platform-wallet-storage` clean. - `cargo test -p platform-wallet-storage` — 54 tests, 0 failures. - `cargo clippy -p platform-wallet-storage --all-targets -- -D warnings` clean. - `cargo check --workspace --offline` clean. - `cargo metadata` no longer exposes the old `platform-wallet-sqlite` package name. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hand-rolled encoder Replace the hand-rolled `BlobWriter` / `BlobReader` plumbing under `src/sqlite/schema/` with a single `bincode::serde::encode_to_vec` call per row, acting on the serde-derived changeset types in `platform-wallet` (enabled via that crate's `serde` feature, added in the preceding commit). The encoder swap is the technical-debt cleanup the workflow-feature plan called for. Wire format - Every `_blob` column now starts with a 1-byte schema-revision tag (`blob::BLOB_REV = 1`) followed by the bincode-serde body. The tag lets future migrations swap encoders without losing existing rows; unknown revisions surface as `SqlitePersisterError::Serialization`. - `blob::encode<T: Serialize>` and `blob::decode<T: DeserializeOwned>` are the only public entry points; the previous per-field `u8/u32/u64/bytes/opt_*/str` walker is gone. - The outpoint helpers (`encode_outpoint` / `decode_outpoint`) stay in `blob.rs` because outpoints serve as primary-key fragments — they were never `_blob` payloads to begin with. Per-schema-file delta - `accounts.rs`: dropped the manual `BlobWriter` for both `AccountRegistrationEntry` and `AccountAddressPoolEntry`; each row now encodes the full entry via `blob::encode`. Schema-stable typed columns (`account_type`, `account_index`, `pool_type`) still mirror the entry for direct SQL lookups. - `asset_locks.rs`: collapsed the funding-type-tag / tx-consensus / proof-bincode three-part hand-rolled blob into a single `blob::encode(&AssetLockEntry)` call. `funding_type` rides through the new `platform_wallet::changeset::serde_adapters::asset_lock_funding_type` adapter; `Transaction` and `AssetLockProof` round-trip via their own serde derives. ~30 LOC removed. - `contacts.rs`: each `_blob` cell now stores the `ContactRequestEntry` / `EstablishedContact` directly. - `core_state.rs`: `core_transactions.record_blob` now encodes the full `TransactionRecord`; `core_instant_locks.islock_blob` encodes the `InstantLock` via dashcore's serde derive (which was always there, gated on `dashcore/serde` — flipped on by `platform-wallet/ serde`). The placeholder-record decoder gymnastics in `get_tx_record` collapse into a one-line `blob::decode` call. - `dashpay.rs`: `dashpay_profiles.profile_blob` encodes the whole `DashPayProfile`; `dashpay_payments_overlay.overlay_blob` encodes each `PaymentEntry`. - `identities.rs`: `entry_blob` encodes the full `IdentityEntry`; new `fetch` helper for tests. - `identity_keys.rs`: dpp's `IdentityPublicKey` uses `serde(tag = "$formatVersion")` which bincode-serde's `deserialize_any` requirement can't navigate. Solution: an in-crate wire shape (`IdentityKeyWire`) pre-encodes that one field via dpp's native `bincode::Encode/Decode` derives while everything else stays on bincode-serde. Same "one blob per row" property; one layer of indirection for the offending field. Unblocked tests (Marvin's previously-deferred TC-002..TC-014) - TC-007 — `IdentityKeyEntry` round-trip including the public key, hash, and DIP-9 derivation breadcrumbs; plus an inline NFR-10 substring scan that asserts the blob contains no `private`/`mnemonic`/`seed`/`xpriv` ASCII. - TC-009 — `PlatformAddressBalanceEntry` round-trip including the `AddressFunds` (via the `address_funds` serde adapter). - TC-010 — `AssetLockEntry` round-trip including the embedded `Transaction`, `AssetLockFundingType` (via the `asset_lock_funding_type` adapter), and `AssetLockStatus`. - TC-012 — `DashPayProfile` + `PaymentEntry` round-trip through the dashpay tables. - TC-014 — `AccountRegistrationEntry` round-trip including the full `ExtendedPubKey` (via key-wallet's serde derive). Gate output - `cargo fmt --all -- --check` clean. - `cargo build -p platform-wallet-storage` clean. - `cargo build -p platform-wallet-storage --no-default-features` clean. - `cargo build -p platform-wallet-storage --bin platform-wallet-storage` clean. - `cargo test -p platform-wallet-storage` — 60 tests, 0 failures (up from 54 before this commit; +5 new TCs in `sqlite_persist_roundtrip.rs` plus +1 in the blob.rs lib-test suite). - `cargo clippy -p platform-wallet-storage --all-targets -- -D warnings` clean. - `cargo check --workspace --offline` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tion version for forward-compat The refinery migration version on the database already gates schema evolution at the right granularity — every row in every `_blob` column is written by code at the same revision, so a per-blob revision byte was redundant. Changes - `src/sqlite/schema/blob.rs`: remove the `BLOB_REV` constant and its prepend / strip logic. `encode<T>` is now a one-line wrapper over `bincode::serde::encode_to_vec`; `decode<T>` is the matching pair over `decode_from_slice`. Net: ~30 LOC dropped from the module. - Drop the two unit tests (`decode_rejects_unknown_rev`, `decode_rejects_empty_blob`) that exercised the rev-tag logic exclusively — the behaviour they covered no longer exists. The `encode_decode_roundtrip` and `outpoint_roundtrip` tests stay. - `src/sqlite/schema/mod.rs`: update the module-level encoding-policy doc to drop the "1-byte schema-rev tag" framing and explain that schema evolution is gated by the refinery migration version instead. - `src/sqlite/schema/asset_locks.rs`: drop the analogous comment about the rev tag in that module's header. `encode_outpoint` / `decode_outpoint` are untouched — they're a separate concern (typed-column primary-key encoding, fixed layout for indexed lookups, never blob payloads). Migration concern: NONE. The crate is unreleased; no existing on-disk `.db` files carry the BLOB_REV byte. Anyone with a wallet-storage test database between the previous commit and this one needs to delete it — flagged in the workspace CHANGELOG. Gate - `cargo fmt --all -- --check` clean. - `cargo build -p platform-wallet-storage` clean. - `cargo build -p platform-wallet-storage --bin platform-wallet-storage` clean. - `cargo test -p platform-wallet-storage` — 58 tests, 0 failures (down from 60: the two dropped tests were rev-tag-specific). - `cargo clippy -p platform-wallet-storage --all-targets -- -D warnings` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `prune` subcommand returns to the unconditional shape: walk the
backup directory, apply the retention policy, unlink, print removed
paths to stdout. Operators who want a preview can list the directory
themselves before running.
Changes
- `src/bin/platform-wallet-storage.rs`: drop the `dry_run: bool`
field on `PruneArgs`, the `if args.dry_run { ... }` branch in
`run_prune`, and the `list_backup_dir_for_dry_run` helper (only
caller was the dry-run branch).
- `README.md`: trim `[--dry-run]` from the `prune` synopsis line.
- `CHANGELOG.md`: note the flag removal in `[Unreleased]`.
No CLI smoke test referenced `--dry-run`, so the 58-test count is
unchanged. Gate is clean: fmt / build / bin build / 58 tests / clippy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…allet-storage rename PROJ-002: `CoreChangeSet.addresses_derived` doc block referenced `rs-platform-wallet-sqlite::schema::core_state`, the path the crate had before `8e0830626d` renamed it to `rs-platform-wallet-storage` and regrouped the module layout under `sqlite/`. The rename swept every import + Cargo.toml + workflow file but missed this single doc-string in the sister crate, which a grep-driven reader would follow to a dead path. Replace with the current canonical path: `platform_wallet_storage::sqlite::schema::core_state`. No code change. No test change. Independently cherry-pickable into the future upstream PR alongside `e26945cfdf` (the original serde-feature commit). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Error, atomic variants, propagate SQL errors
Atomic-variant error type per the dash-evo-tool error pattern
(`~/git/dash-evo-tool/CLAUDE.md` §Error messages): every variant
carries the upstream error via `#[source]` (or `#[from]` when the
conversion is the only thing the trait does), never via a
stringified copy. Variants do not contain user-facing-prose
`String` fields — the `#[error("...")]` attribute provides the
renderable `Display` form, the typed fields carry diagnostics.
Resolves CODE-002, SEC-002, PROJ-001, CODE-004, CODE-008 (partial),
SEC-001 (library half — CLI half in Commit D). Annotates CODE-001
with INTENTIONAL per triage decision.
Error type
- `SqlitePersisterError` → `WalletStorageError`. The old name lives
as a `#[deprecated]` type alias so existing callers compile during
the migration; tests in this crate already use the new name.
- Split `Sqlite` callers into `IntegrityCheckRunFailed`,
`SourceOpenFailed`, and the generic `Sqlite { source }`. The
`IntegrityCheckFailed { check_output: String }` variant becomes
`IntegrityCheckFailed { report: String }` — the SQLite-returned
diagnostic text is not a user-facing message; the rename
clarifies that.
- `Serialization(String)` (a stringified bincode error) split into
`BincodeEncode { source: bincode::error::EncodeError }`,
`BincodeDecode { source: bincode::error::DecodeError }`, and
`BlobDecode { reason: &'static str }` for typed-column structural
errors. `&'static str` is acceptable per the policy — it's a
compile-time identifier, not a user message.
- `InvalidWalletId(String)` split into `InvalidWalletIdHex { source:
hex::FromHexError }` and `InvalidWalletIdLength { actual: usize }`.
- `ConfigInvalid(&'static str)` → `ConfigInvalid { reason: &'static str }`.
- `SchemaVersionUnsupported { found: i64, expected_range: String }`
→ `SchemaVersionUnsupported { found: i64, max_supported: i64 }`.
- New variants: `HashDecode { source: dashcore::hashes::Error }`,
`ConsensusCodec { source: dashcore::consensus::encode::Error }`,
`IntegerOverflow { field: &'static str, value: u64, target:
SafeCastTarget }`, `LoadIncomplete { unimplemented: &'static
[&'static str] }`.
- `From` impls added for every typed source so `?`-style propagation
works at every writer / reader boundary.
- `From<WalletStorageError> for PersistenceError` renders the full
`#[source]` chain via a private `DisplayChain` helper instead of
losing the inner-error context to a single `Display` call.
Safe-cast helper (SEC-002)
- New module `src/sqlite/util/safe_cast.rs` with `u64_to_i64(field:
&'static str, value: u64) -> Result<i64, WalletStorageError>` and
the inverse. Every durable-boundary cast in writers/readers now
routes through these — schema/platform_addrs (balance, sync_height,
sync_timestamp, last_known_recent_block, nonce, account_index,
address_index), schema/asset_locks (amount_duffs, account_index),
schema/token_balances (balance), schema/core_state (utxo.value,
utxo.height, account_index), schema/identities (no u64 columns —
identity_index is u32, uses `i64::from`).
- Lossless `u32 → i64` casts swapped to `i64::from(...)` so static
conversions stay clearly distinct from fallible-cast sites.
Error propagation (CODE-002)
- Every `query_row(...).unwrap_or(default)` that previously
swallowed real SQL errors (busy-timeout, corrupt, decode) now
uses `.optional()?.unwrap_or(default)` — `optional()?` collapses
ONLY the genuine "no rows returned" case into `None`; every other
error propagates as `WalletStorageError::Sqlite`.
- `current_schema_version` and `count_pending` now return
`Result<_, WalletStorageError>` instead of swallowing into
`Option`. Migrate / open paths surface those errors instead of
silently re-running every migration on a corrupt schema-history.
- `delete_wallet_inner` existence check + per-table row-count
queries use `.optional()?` so a corrupt child table fails loudly
instead of reporting 0 rows removed.
Auto-backup dedup (CODE-004)
- `run_auto_backup` extracted as a standalone function in
`persister.rs`. Both the open-time (`PreMigration`) and library-
time (`PreDelete`, new `PreRestore`) paths call it. The previous
`unreachable!("OpenMigration not callable via run_auto_backup")`
branch is gone — there is no longer a closed-over self that
prevents the open path from reusing the helper.
- `BackupKind::PreRestore` variant added; `is_backup_file` /
retention recognise the `pre-restore-` prefix.
LoadIncomplete (PROJ-001)
- `LOAD_UNIMPLEMENTED: &[&str]` pub-const lists the
`ClientStartState` field paths the persister does not yet
reconstruct (`["ClientStartState::wallets"]` today).
- Trait-impl `load()` rustdoc explicitly documents the partial-
reconstruction caveat at the top, points at `LOAD_UNIMPLEMENTED`,
and emits a `tracing::warn!` on every call until the upstream
`Wallet::from_persisted` lands.
- New `WalletStorageError::LoadIncomplete` variant exists for
callers that want to surface the gap as a typed value (not
returned from `load` itself per the trait contract — see rustdoc).
restore_from auto-backup (SEC-001 library half)
- `SqlitePersister::restore_from(dest, src, auto_backup_dir)` —
takes a pre-restore auto-backup of the live destination before
staging the source over it. Refuses with
`AutoBackupDisabled { operation: Restore }` when `auto_backup_dir`
is `None`. New `SqlitePersister::restore_from_skip_backup(dest,
src)` for the CLI's `--no-auto-backup` flag (added to RestoreArgs
here for the corresponding CLI surface).
- `backup::restore_from` keeps the source-validation +
destination-lock + staged-tempfile + atomic-persist shape; the
pre-restore backup is taken by the persister's `_inner` before
calling into `backup::restore_from`. (SEC-004 — staged-tempfile
integrity recheck + chmod 600 — also lands in this commit.)
Write probe (CODE-008)
- `ensure_dir`'s predictable `.platform-wallet-storage-write-probe`
filename replaced by `tempfile::NamedTempFile::new_in(dir)` —
unguessable name per probe, no race against concurrent persister
opens.
CODE-001 INTENTIONAL annotation
- Inline comment on the `Mutex<Connection>` declaration documents
the accept-risk decision: single connection serializes reads
through the write lock, acceptable for current per-wallet
workload, revisit if read contention becomes measurable.
Test sweep
- Every `tests/sqlite_*.rs` file migrated from `SqlitePersisterError`
to `WalletStorageError`. The deprecated alias still resolves but
emits `#[deprecated]` warnings under `-D deprecated`; live code
uses the new name. Restore tests call
`SqlitePersister::restore_from_skip_backup` to avoid threading an
`auto_backup_dir` through fixture helpers.
Gate
- `cargo fmt --all -- --check` clean.
- `cargo build -p platform-wallet-storage` clean (default features).
- `cargo build -p platform-wallet-storage --bin platform-wallet-storage` clean.
- `cargo test -p platform-wallet-storage` — 62 tests, 0 failures
(+4 from new safe_cast unit tests).
- `cargo clippy -p platform-wallet-storage --all-targets -- -D warnings` clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… migration tracking SEC-003: V001 emulates FK INSERT parent-existence + AFTER-DELETE cascade via triggers but doesn't cover `UPDATE wallet_id` on `wallet_metadata` or `UPDATE identity_id` on `identity_keys` / `dashpay_profiles`. The persister's own writers never mutate those columns, but if a future migration accidentally introduces such an UPDATE the result is silent orphaning of child rows. New migration `V002__defensive_update_triggers.rs` installs `BEFORE UPDATE OF <id>` triggers on each that raise the canonical `RAISE(ABORT, 'FOREIGN KEY constraint failed')` — same idiom V001 uses for the parent-existence check, so downstream string matching stays stable. V001 stays untouched per the append-only migration policy. Also: `build.rs` emits `cargo:rerun-if-changed` for each file under `migrations/`. `refinery::embed_migrations!` is a proc-macro evaluated at compile time; Cargo doesn't track file-system reads inside proc macros, so without this build-script directive, adding/editing a migration file fails to trigger a rebuild of the embedded list. Discovered while wiring V002 — `tc025` failed against a stale cache until `migrations.rs` was manually touched. The build-script closes that gap. Gate - `cargo fmt --all -- --check` clean. - `cargo build -p platform-wallet-storage` clean. - `cargo test -p platform-wallet-storage` — 62 tests, 0 failures. - `cargo clippy -p platform-wallet-storage --all-targets -- -D warnings` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…caping, scope allow-list, stable enum labels, docs)
Closes the cleanup batch from the Phase-2.8 triage report:
PROJ-003, PROJ-004, SEC-005, SEC-006, CODE-003, DOC-002, DOC-005,
plus a related DOC-001 correction (FK README claim).
PROJ-003 — Remove `wallet-sqlite` from `.github/workflows/pr.yml`.
The three historical commits using that scope are already on the
branch; future commits in this crate use `wallet-storage`. No
reason to keep a deprecated name in the allow-list.
PROJ-004 — Delete `packages/rs-platform-wallet-storage/CHANGELOG.md`.
The user explicitly stated we don't maintain per-crate CHANGELOGs;
the workspace-level CHANGELOG.md is generated from Conventional
Commits and remains the single source of truth.
SEC-005 — Delete the substring-scan block in
`tests/sqlite_persist_roundtrip.rs::tc007_identity_key_entry_roundtrip`.
bincode wire bytes carry no field names, so the substring scan
against `public_key_blob` conveyed intent but enforced nothing.
The load-bearing NFR-10 check is `tests/secrets_scan.rs`, which
greps schema source files. Comment in tc007 redirects readers
there.
SEC-006 — Replace hand-rolled JSON in `run_inspect --format json`
with `serde_json::json!`. `serde_json` added as an optional dep
gated by the `cli` feature. Today's input is safe (table names are
compile-time identifiers; wallet ids are hex), but any future
addition that flows user-controlled bytes into the printer would
break the previous escape-less `print!`.
CODE-003 — `format!("{:?}", entry.account_type)` /
`format!("{:?}", entry.pool_type)` replaced with new pub(crate)
helpers `account_type_db_label(&AccountType) -> &'static str` and
`pool_type_db_label(&AddressPoolType) -> &'static str` in
`schema/accounts.rs`. Both are exhaustive `match` expressions —
adding a variant upstream fails to compile here, forcing an
explicit label decision rather than silent `Debug`-format drift.
`schema/core_state.rs` (derived-addresses writer) uses the same
helpers.
DOC-002 — `tests/secrets_scan.rs` docstring updated: scan path is
`src/sqlite/schema/` not `src/schema/`. Explicitly carves out files
in `src/sqlite/` outside `schema/` plus the future `src/secrets/`
slot as out-of-scope.
DOC-005 — README `--no-default-features` paragraph rewritten:
factual description of what the bare crate provides today (nothing
public), no future-feature framing per user's "no future
placeholders" rule.
DOC-001 (bonus correction) — README schema section updated to
reflect V002's defensive UPDATE triggers. The previous "identical
to native FKs" claim was false on UPDATE before V002; with V002
landed the claim becomes accurate and the section explicitly cites
both migrations.
INTENTIONAL annotations already in place from Commits B/C —
CODE-001 (single connection serialises reads) at
`src/sqlite/persister.rs:78-84`; CODE-007 (prune fails-fast) at
`src/sqlite/backup.rs:200-204`. PROJ-005's accept-risk rationale
is captured inline above the `lock_conn_for_test` accessor at
`src/sqlite/persister.rs:299-307`.
Gate
- `cargo fmt --all -- --check` clean.
- `cargo build -p platform-wallet-storage` clean.
- `cargo build -p platform-wallet-storage --no-default-features` clean.
- `cargo build -p platform-wallet-storage --bin platform-wallet-storage` clean.
- `cargo test -p platform-wallet-storage` — 62 tests, 0 failures.
- `cargo clippy -p platform-wallet-storage --all-targets -- -D warnings` clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@coderabbitai review all |
|
✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Actionable comments posted: 15
🧹 Nitpick comments (11)
packages/rs-platform-wallet-storage/src/sqlite/config.rs (1)
111-118: 💤 Low valueMinor: Simplify empty parent check.
The
.filter(|p| !p.as_os_str().is_empty())check at line 114 is defensive, butPath::parent()should not return an emptyOsStrin practice for well-formed paths. The fallback to"."is safe regardless.Consider simplifying to:
let parent = db_path.parent().unwrap_or_else(|| Path::new(".")); parent.join("backups").join("auto")However, the current implementation is safe and may handle edge cases in path handling, so this is optional.
🤖 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/config.rs` around lines 111 - 118, The function default_auto_backup_dir contains an overly defensive check when computing parent from db_path; simplify by using db_path.parent().unwrap_or_else(|| Path::new(".")) to get a &Path fallback and then join "backups"/"auto" off that parent. Update the local variable used (currently named parent) to hold the &Path from parent() rather than mapping to a PathBuf, and then call parent.join("backups").join("auto") to produce the final PathBuf.packages/rs-platform-wallet-storage/tests/common/mod.rs (1)
47-56: 💤 Low valueHardcoded
'testnet'may collide with tests asserting onnetwork.
ensure_wallet_metaalways writesnetwork = 'testnet'. Tests that later assert on wallet metadata or persist aWalletMetadataEntrywith a different network (e.g.,tc023_one_flush_is_one_transactionwritesNetwork::Testnetand would silently match, but other tests writingMainnetwould observe a stale row from this helper). Consider takingnetworkas a parameter (defaulting to"testnet") so call sites can match their changeset's expectations.🤖 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/common/mod.rs` around lines 47 - 56, The helper ensure_wallet_meta currently hardcodes network = 'testnet', which can collide with tests expecting other networks; change the signature of ensure_wallet_meta(persister: &SqlitePersister, wallet_id: &WalletId) to accept a network parameter (e.g., network: &str) with callers passing "testnet" where appropriate, update the INSERT SQL call in ensure_wallet_meta to bind the network parameter instead of the literal 'testnet', and update all test call sites (or add a convenience wrapper) to pass the correct network values (e.g., "mainnet" or "testnet") so tests see consistent wallet_metadata rows.packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs (2)
188-231: 💤 Low valueTwo
if let Cmd::Migratebranches — fold them.The
Migratecommand is special-cased twice in succession (lines 219–231 to tweakconfig/ validate flags, then lines 235–244 to actually run it). Folding both into a singleif let Cmd::Migrate(m) = &cli.cmd { ... return ... }block (or arun_migratehelper) would keep all of the migrate-specific logic in one place and remove theunreachable!()arm on line 247.🤖 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/bin/platform-wallet-storage.rs` around lines 188 - 231, The migrate handling is split across two places; fold the two separate `if let Cmd::Migrate` blocks into one contiguous block so all migrate-specific validation and invocation live together. Locate the existing `if let Cmd::Migrate(m) = &cli.cmd {` usage, move the checks that validate `auto_backup_dir` and the `m.no_auto_backup` mutation of `config` (the use of `SqlitePersisterConfig::new(&db)` and `config.with_auto_backup_dir(...)`) into the same block where the migrate command is executed (or replace both with a single `run_migrate` helper that accepts `db`, `m`, `config`, and `auto_backup_dir`), then perform the `run_migrate` call and return its Result immediately; finally remove the now-unnecessary second `if let Cmd::Migrate` and the `unreachable!()` arm.
122-134: 💤 Low valueReject uppercase / odd-length hex consistently.
parse_wallet_idcheckss.len() == 64then defers tohex::decode, which accepts both upper- and lower-case but not mixed-case-with-non-hex obviously — that's fine. However, the error message on line 124 leaks the raw user-supplied string back into stderr; if this binary is ever invoked from a context that pipes secrets-like opaque IDs, echoing them is undesirable. Consider dropping the trailing(`{}`)from the message and just naming the length, matching thewallet id is not valid hexstyle on line 130.🤖 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/bin/platform-wallet-storage.rs` around lines 122 - 134, In parse_wallet_id, stop echoing the raw input in the length error and validate/normalize hex consistently: when s.len() != 64 return an Err that only mentions the expected length (do not include `s`), and replace relying solely on hex::decode with an explicit check that all chars are lowercase hex (0-9,a-f) so uppercase is rejected; keep the existing "wallet id is not valid hex" error for decoding failures but ensure messages follow the same non-leaking style.packages/rs-platform-wallet-storage/tests/sqlite_buffer_semantics.rs (2)
273-278: 💤 Low valueRemove redundant
_unused_btreemapdead-code shim.
BTreeMapis now actually used intc023_one_flush_is_one_transaction(see line 308), so the#[allow(dead_code)] fn _unused_btreemapworkaround can be deleted along with its comment.♻️ Suggested removal
-// Mark the unused `BTreeMap` import as used in case future expansion of -// this test file needs it. -#[allow(dead_code)] -fn _unused_btreemap() -> BTreeMap<u32, u32> { - BTreeMap::new() -} -🤖 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_buffer_semantics.rs` around lines 273 - 278, Remove the redundant dead-code shim `_unused_btreemap` and its explanatory comment: delete the `#[allow(dead_code)] fn _unused_btreemap() -> BTreeMap<u32, u32> { BTreeMap::new() }` block since `BTreeMap` is now actually used in the test `tc023_one_flush_is_one_transaction`, making the shim unnecessary; ensure imports remain and run tests to confirm nothing else depends on that helper.
154-180: 💤 Low valueProptest opens a fresh SQLite DB per case — consider reusing one persister.
fresh_persister()runs once per proptest case (×64), each migrating a brand-new on-disk SQLite DB. That makes this test materially slower and more fragile on tmpfs/CI than necessary. Since the property is purely about monotonic-max merge oncore_sync_statefor a single wallet, you could hoist persister creation out of the proptest closure and just use a freshwidper case (or reset the row), keeping the same DB across cases.🤖 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_buffer_semantics.rs` around lines 154 - 180, Test currently calls fresh_persister() inside the proptest closure causing a new on-disk SQLite DB per case; move persister creation out of the proptest! Create the persister once before proptest (call fresh_persister() once to get persister/_tmp/_path), then inside the proptest closure use a new wallet id (wid) per case or clear/reset the core_sync_state row (use ensure_wallet_meta and persister.store with core_with_height) so each case reuses the same DB connection and only varies the wallet or row content; update references to fresh_persister, wid, ensure_wallet_meta and persister accordingly.packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs (1)
72-102: 💤 Low value
ensure_existswrites outside the buffer/flush transaction boundary.
ensure_existstakes a&Connectionand runs an immediateINSERT OR IGNORErather than participating in the in-memory merge buffer + per-flush transaction model used byapply. This is fine for the documented use case (tests poking the DB before exercisingidentity_keys), but worth a doc note so production callers don't reach for it and accidentally couple stub writes to a different durability boundary than the rest of a changeset flush.🤖 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 72 - 102, ensure_exists currently performs an immediate INSERT OR IGNORE on a plain &Connection, which bypasses the in-memory merge buffer and per-flush transaction used by apply and can cause durability/ordering mismatches; update ensure_exists to participate in the same flush boundary by either accepting and using the existing merge buffer/flush transaction or a transactional handle (e.g., a &Transaction or buffer API used by apply) and executing the INSERT within that transaction, or if intentionally intended only for tests, add a clear doc comment on ensure_exists describing that it writes immediately to the DB and must not be used by production code that relies on the merge-buffer + per-flush transaction model (reference ensure_exists, apply, and the in-memory merge buffer/flush transaction behavior in your comment).packages/rs-platform-wallet-storage/src/sqlite/util/safe_cast.rs (1)
20-26: ⚡ Quick winConsider widening
SafeCastTargetto coveru32-bound writes.Several call sites (e.g.,
asset_locks::list_active,core_state::list_unspent_utxos) need to fail when ani64won't fit inu32and currently reportSafeCastTarget::U64, which is wrong. Adding aU32variant here (and a smalli64_to_u32helper) would let those readers go through this module with an accurate target label and consistent error shape.🤖 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/util/safe_cast.rs` around lines 20 - 26, The SafeCastTarget enum currently only lists I64 and U64 but callers need a U32 target; add a new variant SafeCastTarget::U32 and implement a small helper function i64_to_u32 that mirrors existing i64_to_u64/i64_to_i64 behavior (validate range, return Result<u32, SafeCastError> and report SafeCastTarget::U32 on failure). Update any match/log paths in this module that construct errors to use the new U32 variant so callers like asset_locks::list_active and core_state::list_unspent_utxos can call the helper and produce the correct error shape/message.packages/rs-platform-wallet-storage/migrations/V001__initial.rs (2)
185-191: 💤 Low valueComment references
inject_custombut the code appends raw DDL.The comment claims constraints/indexes are layered "via
inject_custom", but the implementation just concatenates DDL afterm.make::<Sqlite>(). Either switch to barrel'sinject_customAPI or update the comment to reflect what's actually happening so future maintainers don't go looking for aninject_customcall site.🤖 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/migrations/V001__initial.rs` around lines 185 - 191, The comment mentions layering constraints/indexes "via `inject_custom`" but the code builds raw DDL by concatenating into `tail` after calling `m.make::<Sqlite>()`; either (A) change the implementation to use Barrel's `inject_custom` API to append the constraints/indexes instead of manual string concatenation (replace the `tail` concatenation and use `m.inject_custom(...)` at the appropriate spot), or (B) update the comment to accurately describe the current behavior (mention that raw DDL is appended to `tail` after `m.make::<Sqlite>()` and that `inject_custom` is not used) so future maintainers are not misled; locate references to `tail`, `m.make::<Sqlite>()`, and any subsequent emission of the SQL to modify accordingly.
281-326: 💤 Low valueUnused
_colsparameter inparent_checkclosure.The closure takes
_cols: &[&str]but never uses it; every call passes&["wallet_id"]. Consider removing the parameter, or actually using it to parameterize the FK column name so the closure can support FK relationships keyed on something other thanwallet_id(which would let you fold the bespokeidentity_keys/dashpay_profilestriggers below into the same helper).🤖 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/migrations/V001__initial.rs` around lines 281 - 326, The closure parent_check currently accepts an unused parameter _cols: &[&str]; either remove that parameter from parent_check and all its callers (keep it as parent_check(child: &str) and continue using wallet_id inside the generated trigger SQL), or update parent_check to accept cols: &[&str] and use cols[0] (or join cols) in place of the hard-coded wallet_id everywhere (including the WHEN clauses and DELETE WHERE clause) so the same helper can generate triggers for identities, identity_keys, dashpay_profiles, etc.; ensure you update the for loop callers accordingly to pass either no cols (if removed) or the appropriate slice like &["wallet_id"] or &["identity_id"] where needed.packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs (1)
102-107: ⚡ Quick win
SafeCastTarget::U64is misleading for au32overflow.The cast here is
i64 → u32, but the surfacedtargetisSafeCastTarget::U64. Operators reading the error will see"u64"and assume the value didn't fit inu64, when in fact it didn't fit inu32. Consider adding aU32variant toSafeCastTarget(and a correspondingi64_to_u32helper insafe_cast) so the error type accurately describes the target, and to avoid repeating thistry_frompattern at every reader. This same issue recurs incore_state.rsforcore_utxos.heightandcore_utxos.account_index.🤖 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/asset_locks.rs` around lines 102 - 107, The IntegerOverflow error currently reports SafeCastTarget::U64 for an i64→u32 conversion in asset_locks.rs (see the u32::try_from usage producing WalletStorageError::IntegerOverflow), which is misleading; add a U32 variant to crate::sqlite::util::safe_cast::SafeCastTarget and implement a dedicated i64_to_u32 helper in the safe_cast module that performs the conversion and returns WalletStorageError::IntegerOverflow with target = SafeCastTarget::U32 on failure, then replace the manual u32::try_from usages (e.g., the account_index conversion in asset_locks.rs and the similar conversions in core_state.rs for core_utxos.height and core_utxos.account_index) to call the new helper so errors accurately report the intended target type.
🤖 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-storage/src/bin/platform-wallet-storage.rs`:
- Around line 235-244: The current migrate branch uses peek_schema_version which
swallows all errors into None causing applied to be unreliable; change
peek_schema_version to return Result<Option<i64>, E> (i.e., Result<Option<i64>,
Box<dyn Error> or a concrete error type) and update the call site in the
Cmd::Migrate block: keep using the old behavior for pre_version (call
peek_schema_version before opening the DB if you must tolerate missing table),
but after SqlitePersister::open(config.clone()) call the new peek_schema_version
and propagate or error out on Err so the post_version probe cannot be silently
treated as 0; compute applied only when post_version is Ok(Some/_), and map
errors to the CLI error flow (use map_open_err_for_cli or similar) instead of
unwrap_or(0) so the printed "applied: N" is trustworthy.
- Around line 288-300: The CLI-level pre-check args.out.is_file() in run_backup
is redundant and does not prevent the TOCTOU race because backup_to() itself
checks exists() and ultimately calls run_to() -> Connection::open(dest) which
can be raced; either remove the args.out.is_file() guard from run_backup, or
(preferred) harden the persistence path by modifying backup_to()/run_to() to
perform atomic file creation (e.g., use OpenOptions::create_new() or equivalent)
when opening the destination instead of relying on exists()/Connection::open,
ensuring the creation fails if the file was created concurrently.
In `@packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:
- Around line 517-520: The code that rebuilds state in load() uses
schema::platform_addrs::count_per_wallet and then inserts addrs into
state.platform_addresses only if count > 0 || addrs.sync_height > 0 ||
addrs.sync_timestamp > 0, which drops wallets whose only persisted platform
state is addrs.last_known_recent_block; update the reconstruction condition to
also check addrs.last_known_recent_block (e.g., include ||
addrs.last_known_recent_block > 0) so that entries with only
last_known_recent_block are preserved when inserting into
state.platform_addresses.
- Around line 412-468: flush_inner currently calls self.buffer.drain(wallet_id)
and discards the changeset (cs) before opening the DB transaction, so any
failure after draining (e.g., during schema::...::apply or tx.commit()) loses
the buffered changes; change the logic so the buffer is only removed after
tx.commit() succeeds: either fetch/clone/peek the changeset (use a
non-destructive read API instead of buffer.drain) into cs, execute the schema
apply calls and tx.commit(), and only then call buffer.drain(wallet_id) or
buffer.remove(wallet_id) to clear it; if you must drain early, ensure every
error path re-inserts/requeues the drained cs back into the buffer (e.g.,
buffer.insert(wallet_id, cs)) before returning the PersistenceError, referencing
flush_inner, self.buffer.drain(wallet_id), cs, and tx.commit() to locate the
edits.
- Around line 269-326: Before checking existence/backing up/deleting in
delete_wallet_inner, reconcile in-memory buffered writes for the target wallet:
call the component that persists or discards pending buffer entries (e.g.,
flush/commit_writes or discard_buffered_writes) for wallet_id before the initial
conn() existence check and before run_auto_backup; propagate any error from that
operation so the delete aborts on flush failures. Locate delete_wallet_inner and
insert the flush/discard call at the top (prior to the SELECT 1 and
run_auto_backup usages) and ensure the rest of the logic (PER_WALLET_TABLES
counting, schema::wallet_meta::delete, tx.commit) operates on the now-consistent
persisted state.
In `@packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs`:
- Around line 26-28: The decode function currently ignores the bytes-consumed
result from bincode::serde::decode_from_slice, allowing trailing garbage to pass
as valid; update the blob::decode implementation (the decode function that calls
bincode::serde::decode_from_slice) to check the second tuple element
(bytes_consumed) against blob.len() and return a WalletStorageError (e.g.,
Err(WalletStorageError::CorruptedBlob) or similar existing error variant) when
bytes_consumed != blob.len(), otherwise return the decoded value; ensure the
error path uses the same error type returned on bincode failures to keep the
function signature consistent.
In `@packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs`:
- Around line 108-136: The upsert_utxo function currently writes account_index
as 0 into core_utxos causing list_unspent_utxos to misassign UTXOs; fix by
populating account_index before inserting: either (a) perform a lookup/join
against core_derived_addresses inside upsert_utxo (match wallet_id +
script/address) to derive the correct account_index and use that value instead
of 0, or (b) modify the Utxo/CoreChangeSet pipeline so the writer that calls
upsert_utxo receives and passes the owning account_index, or (c) add a
deterministic backfill writer that runs after writes but before any
list_unspent_utxos calls to update core_utxos.account_index from
core_derived_addresses; update upsert_utxo (and any callers of
upsert_utxo/CoreChangeSet) to ensure account_index is never left as the
hardcoded 0.
- Around line 138-174: The read in upsert_sync_state currently uses lp.map(|x| x
as u32) / sy.map(|x| x as u32) which silently truncates out-of-range i64 values;
change the rusqlite row mapping to return Option<i64> for both heights (keep the
closure in query_row returning (Option<i64>, Option<i64>)), then after the
query_row.optional()? unwrap_or((None,None)) convert each Option<i64> to
Option<u32> using a checked helper (reuse i64_to_u64 + u32::try_from or add
i64_to_u32) and map conversion errors to WalletStorageError::IntegerOverflow so
the function returns an error rather than silently wrapping before using lp and
sy in the INSERT/ON CONFLICT params.
- Around line 27-45: The code iterates cs.spent_utxos and calls upsert_utxo(tx,
wallet_id, utxo, true) when the outpoint is missing, which will materialize a
synthetic UTXO with account_index = 0; document this intent at the call site or
add an explicit guard flag to make the exceptional behavior obvious. Update the
block in the loop that handles cs.spent_utxos to either (a) add a clear comment
above the else branch referencing upsert_utxo and why creating a spent-only
placeholder with account_index = 0 is safe and will be corrected later, or (b)
wrap the else branch in a condition/flag (e.g., only_insert_spent_placeholders)
and pass that flag through to upsert_utxo so callers must opt into creating
synthetic rows; reference cs.spent_utxos, upsert_utxo, and the
core_utxos/account_index = 0 behavior in the comment or the new guard.
In `@packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs`:
- Around line 42-64: fetch currently only selects entry_blob and discards the
tombstoned column referenced in the doc; change fetch to SELECT entry_blob,
tombstoned from identities and return the tombstone flag alongside the decoded
IdentityEntry (e.g. update the signature to Result<Option<(IdentityEntry,
bool)>, WalletStorageError> or a small wrapper type), decode payload with
blob::decode for the entry, map the tombstoned SQL value to a bool, and update
the doc comment to reflect the new return shape (ensure you still use
rusqlite::OptionalExtension and propagate errors as before).
In `@packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs`:
- Around line 72-89: The upsert loop writes typed key columns from the loop key
((identity_id, key_id), wallet_id) but serializes the payload from entry (via
IdentityKeyWire::from_entry), which can produce mismatches; ensure the
serialized wire and the SQL key are consistent by either normalizing the wire
values to the loop key or rejecting mismatches before persistence: construct or
overwrite IdentityKeyWire fields using the loop's wallet_id/identity_id/key_id
(from cs.upserts key and wallet_id variable) prior to blob::encode, or validate
that entry.identity_id, entry.key_id and entry.wallet_id exactly match the loop
key and return an error if they differ, then proceed to tx.execute.
In `@packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs`:
- Around line 19-37: The loop over cs.addresses currently ignores each entry's
own wallet_id and always writes using the outer wallet_id; add a fast-fail check
at the top of the loop that compares entry.wallet_id to the outer wallet_id (the
variables named entry and wallet_id in the cs.addresses loop) and return an
error if they differ (include a descriptive message mentioning the mismatched
wallet ids and the PlatformAddressBalanceEntry), before executing the INSERT;
this prevents silently writing mixed-wallet entries.
In `@packages/rs-platform-wallet-storage/src/sqlite/schema/wallet_meta.rs`:
- Around line 45-51: The row mapper in the wallet_metadata reader must reject
malformed rows instead of coercing them: in the stmt.query_map closure that
reads wallet_id (currently into bytes and wid) and birth_height (converted with
as u32), validate that wallet_id.bytes.len() == 32 and that birth_height is
within u32 bounds before converting; if either check fails return a real error
(e.g., a StorageError::CorruptedRow or map a descriptive
rusqlite::Error::InvalidParameterName/Other) from the closure so the query fails
instead of producing a zeroed wallet id or truncated height. Locate the closure
that does row.get(0) / copy_from_slice and the code that casts birth_height to
u32 and replace the coercion with explicit checks and an early Err(...) return
with a typed, descriptive error.
In `@packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs`:
- Around line 87-106: The test's assertion that delete_wallet returns
AutoBackupDirUnwritable is flaky when run as root because chmod 0o500 doesn't
block root; modify the test in sqlite_auto_backup.rs (the block that creates
unwritable dir, calls SqlitePersisterConfig::with_auto_backup_dir,
SqlitePersister::open, and persister.delete_wallet) to detect running as root
(e.g., check geteuid()==0 on Unix) and in that case either skip the
exact-variant assertion or assert only that an error occurred, otherwise keep
the existing matches!(... AutoBackupDirUnwritable { .. }) check; this ensures
deterministic behavior without changing SqlitePersister/delete_wallet logic.
---
Nitpick comments:
In `@packages/rs-platform-wallet-storage/migrations/V001__initial.rs`:
- Around line 185-191: The comment mentions layering constraints/indexes "via
`inject_custom`" but the code builds raw DDL by concatenating into `tail` after
calling `m.make::<Sqlite>()`; either (A) change the implementation to use
Barrel's `inject_custom` API to append the constraints/indexes instead of manual
string concatenation (replace the `tail` concatenation and use
`m.inject_custom(...)` at the appropriate spot), or (B) update the comment to
accurately describe the current behavior (mention that raw DDL is appended to
`tail` after `m.make::<Sqlite>()` and that `inject_custom` is not used) so
future maintainers are not misled; locate references to `tail`,
`m.make::<Sqlite>()`, and any subsequent emission of the SQL to modify
accordingly.
- Around line 281-326: The closure parent_check currently accepts an unused
parameter _cols: &[&str]; either remove that parameter from parent_check and all
its callers (keep it as parent_check(child: &str) and continue using wallet_id
inside the generated trigger SQL), or update parent_check to accept cols:
&[&str] and use cols[0] (or join cols) in place of the hard-coded wallet_id
everywhere (including the WHEN clauses and DELETE WHERE clause) so the same
helper can generate triggers for identities, identity_keys, dashpay_profiles,
etc.; ensure you update the for loop callers accordingly to pass either no cols
(if removed) or the appropriate slice like &["wallet_id"] or &["identity_id"]
where needed.
In `@packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs`:
- Around line 188-231: The migrate handling is split across two places; fold the
two separate `if let Cmd::Migrate` blocks into one contiguous block so all
migrate-specific validation and invocation live together. Locate the existing
`if let Cmd::Migrate(m) = &cli.cmd {` usage, move the checks that validate
`auto_backup_dir` and the `m.no_auto_backup` mutation of `config` (the use of
`SqlitePersisterConfig::new(&db)` and `config.with_auto_backup_dir(...)`) into
the same block where the migrate command is executed (or replace both with a
single `run_migrate` helper that accepts `db`, `m`, `config`, and
`auto_backup_dir`), then perform the `run_migrate` call and return its Result
immediately; finally remove the now-unnecessary second `if let Cmd::Migrate` and
the `unreachable!()` arm.
- Around line 122-134: In parse_wallet_id, stop echoing the raw input in the
length error and validate/normalize hex consistently: when s.len() != 64 return
an Err that only mentions the expected length (do not include `s`), and replace
relying solely on hex::decode with an explicit check that all chars are
lowercase hex (0-9,a-f) so uppercase is rejected; keep the existing "wallet id
is not valid hex" error for decoding failures but ensure messages follow the
same non-leaking style.
In `@packages/rs-platform-wallet-storage/src/sqlite/config.rs`:
- Around line 111-118: The function default_auto_backup_dir contains an overly
defensive check when computing parent from db_path; simplify by using
db_path.parent().unwrap_or_else(|| Path::new(".")) to get a &Path fallback and
then join "backups"/"auto" off that parent. Update the local variable used
(currently named parent) to hold the &Path from parent() rather than mapping to
a PathBuf, and then call parent.join("backups").join("auto") to produce the
final PathBuf.
In `@packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs`:
- Around line 102-107: The IntegerOverflow error currently reports
SafeCastTarget::U64 for an i64→u32 conversion in asset_locks.rs (see the
u32::try_from usage producing WalletStorageError::IntegerOverflow), which is
misleading; add a U32 variant to crate::sqlite::util::safe_cast::SafeCastTarget
and implement a dedicated i64_to_u32 helper in the safe_cast module that
performs the conversion and returns WalletStorageError::IntegerOverflow with
target = SafeCastTarget::U32 on failure, then replace the manual u32::try_from
usages (e.g., the account_index conversion in asset_locks.rs and the similar
conversions in core_state.rs for core_utxos.height and core_utxos.account_index)
to call the new helper so errors accurately report the intended target type.
In `@packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs`:
- Around line 72-102: ensure_exists currently performs an immediate INSERT OR
IGNORE on a plain &Connection, which bypasses the in-memory merge buffer and
per-flush transaction used by apply and can cause durability/ordering
mismatches; update ensure_exists to participate in the same flush boundary by
either accepting and using the existing merge buffer/flush transaction or a
transactional handle (e.g., a &Transaction or buffer API used by apply) and
executing the INSERT within that transaction, or if intentionally intended only
for tests, add a clear doc comment on ensure_exists describing that it writes
immediately to the DB and must not be used by production code that relies on the
merge-buffer + per-flush transaction model (reference ensure_exists, apply, and
the in-memory merge buffer/flush transaction behavior in your comment).
In `@packages/rs-platform-wallet-storage/src/sqlite/util/safe_cast.rs`:
- Around line 20-26: The SafeCastTarget enum currently only lists I64 and U64
but callers need a U32 target; add a new variant SafeCastTarget::U32 and
implement a small helper function i64_to_u32 that mirrors existing
i64_to_u64/i64_to_i64 behavior (validate range, return Result<u32,
SafeCastError> and report SafeCastTarget::U32 on failure). Update any match/log
paths in this module that construct errors to use the new U32 variant so callers
like asset_locks::list_active and core_state::list_unspent_utxos can call the
helper and produce the correct error shape/message.
In `@packages/rs-platform-wallet-storage/tests/common/mod.rs`:
- Around line 47-56: The helper ensure_wallet_meta currently hardcodes network =
'testnet', which can collide with tests expecting other networks; change the
signature of ensure_wallet_meta(persister: &SqlitePersister, wallet_id:
&WalletId) to accept a network parameter (e.g., network: &str) with callers
passing "testnet" where appropriate, update the INSERT SQL call in
ensure_wallet_meta to bind the network parameter instead of the literal
'testnet', and update all test call sites (or add a convenience wrapper) to pass
the correct network values (e.g., "mainnet" or "testnet") so tests see
consistent wallet_metadata rows.
In `@packages/rs-platform-wallet-storage/tests/sqlite_buffer_semantics.rs`:
- Around line 273-278: Remove the redundant dead-code shim `_unused_btreemap`
and its explanatory comment: delete the `#[allow(dead_code)] fn
_unused_btreemap() -> BTreeMap<u32, u32> { BTreeMap::new() }` block since
`BTreeMap` is now actually used in the test
`tc023_one_flush_is_one_transaction`, making the shim unnecessary; ensure
imports remain and run tests to confirm nothing else depends on that helper.
- Around line 154-180: Test currently calls fresh_persister() inside the
proptest closure causing a new on-disk SQLite DB per case; move persister
creation out of the proptest! Create the persister once before proptest (call
fresh_persister() once to get persister/_tmp/_path), then inside the proptest
closure use a new wallet id (wid) per case or clear/reset the core_sync_state
row (use ensure_wallet_meta and persister.store with core_with_height) so each
case reuses the same DB connection and only varies the wallet or row content;
update references to fresh_persister, wid, ensure_wallet_meta and persister
accordingly.
🪄 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: 78dea89d-cb94-44d0-8590-02ed68920ad8
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (55)
.github/workflows/pr.yml.github/workflows/tests-rs-workspace.ymlCargo.tomlDockerfilepackages/rs-platform-wallet-storage/Cargo.tomlpackages/rs-platform-wallet-storage/README.mdpackages/rs-platform-wallet-storage/SECRETS.mdpackages/rs-platform-wallet-storage/build.rspackages/rs-platform-wallet-storage/migrations/V001__initial.rspackages/rs-platform-wallet-storage/migrations/V002__defensive_update_triggers.rspackages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rspackages/rs-platform-wallet-storage/src/lib.rspackages/rs-platform-wallet-storage/src/sqlite/backup.rspackages/rs-platform-wallet-storage/src/sqlite/buffer.rspackages/rs-platform-wallet-storage/src/sqlite/config.rspackages/rs-platform-wallet-storage/src/sqlite/error.rspackages/rs-platform-wallet-storage/src/sqlite/migrations.rspackages/rs-platform-wallet-storage/src/sqlite/mod.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/wallet_meta.rspackages/rs-platform-wallet-storage/src/sqlite/util/mod.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_scan.rspackages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rspackages/rs-platform-wallet-storage/tests/sqlite_backup_restore.rspackages/rs-platform-wallet-storage/tests/sqlite_buffer_semantics.rspackages/rs-platform-wallet-storage/tests/sqlite_cli_smoke.rspackages/rs-platform-wallet-storage/tests/sqlite_compile_time.rspackages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rspackages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rspackages/rs-platform-wallet-storage/tests/sqlite_migrations.rspackages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rspackages/rs-platform-wallet/Cargo.tomlpackages/rs-platform-wallet/src/changeset/changeset.rspackages/rs-platform-wallet/src/changeset/mod.rspackages/rs-platform-wallet/src/changeset/serde_adapters.rspackages/rs-platform-wallet/src/wallet/asset_lock/tracked.rspackages/rs-platform-wallet/src/wallet/identity/types/block_time.rspackages/rs-platform-wallet/src/wallet/identity/types/dashpay/contact_request.rspackages/rs-platform-wallet/src/wallet/identity/types/dashpay/established_contact.rspackages/rs-platform-wallet/src/wallet/identity/types/dashpay/payment.rspackages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rspackages/rs-platform-wallet/src/wallet/identity/types/key_storage.rs
Routine forward-integration. Cargo.lock reconciliation only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment-tightening pass per claudius:coding-best-practices, scoped to PR #3625's own additions: - sqlite_buffer_semantics.rs: drop `_unused_btreemap` placeholder + its "future expansion" comment. `BTreeMap` is genuinely used elsewhere in the file (line 301 — `balances` map), so the import stays. Removes a speculative-future-state comment and an empty helper that exists only to silence a phantom lint. - sqlite_load_reconstruction.rs: fix stale cross-reference. Module doc said "tracked in a TODO in persister.rs::load", but the actual signal is the `LOAD_UNIMPLEMENTED` constant + tracing::warn. Replace with the accurate present-state pointer. Plus a single rustfmt fix in `packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs` that fell out of the v3.1-dev merge — the textual auto-merge produced a 3-arg call spread across 5 lines that rustfmt collapses to one line. Not a logic change. Rules driving the changes: - present-state, not history (sqlite_load_reconstruction.rs) - comment only when meaningful — dropping speculative placeholders (sqlite_buffer_semantics.rs) Quality gates: `cargo fmt --all` clean, `cargo check --workspace` green, `cargo clippy -p platform-wallet -p platform-wallet-storage --tests --no-deps -- -D warnings` green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…reation SEC-011 (Smythe audit, MEDIUM): the restore path already applied `chmod 0o600` after writing the SQLite file (`backup.rs::restore_from`), but the initial-create path in `SqlitePersister::open` and the backup-create path in `backup::run_to` did not. Both relied on the process umask, which can leave a newly created DB world- or group-readable. Extracts the existing inline `#[cfg(unix)]` + `Permissions::from_mode(0o600)` block into a small helper `sqlite::util::permissions::apply_secure_permissions` (no-op on non-Unix) and calls it at all three sites. The restore path keeps its existing semantics — it just delegates to the helper now — so the file mode no longer depends on the process umask anywhere a SQLite file is created or replaced by this crate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SEC-011 fix landed (
|
…let-sqlite-persistor
…e_cached writers, functional load() (#3643) Co-authored-by: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…3633) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: QuantumExplorer <quantum@dash.org>
…let-sqlite-persistor # Conflicts: # packages/rs-platform-wallet/Cargo.toml
There was a problem hiding this comment.
Code Review
I re-checked the prior findings against head 54621bcc691e46c73de947158cddee93c6e913a9. STILL VALID and carried forward: restore_from still relies on a non-SQLite advisory lock and can run unlocked, destructive auto-backups are still taken before any cross-process exclusion, the two FFI callback/free-pair contracts still accept host-owned allocations without requiring their free callbacks, and the new ErrorNoSelectableInputs path is still unreachable from real wallet auto-selection failures; FIXED/OUTDATED: the buffered-only pre-delete-backup report is intentionally documented/tested behavior. There are no separate new latest-delta defects beyond the carried-forward ErrorNoSelectableInputs issue; the storage findings remain unresolved from earlier PR scope and still warrant changes.
_Note: Inline posting failed (command failed (1): python3 scripts/review_poster.py dashpay/platform 3625 54621bc
STDOUT:
STDERR:
Traceback (most recent call last):
File "/Users/claw/.openclaw/workspace/scripts/review_poster.py", line 163, in
result = post_review(repo, pr_number, h), so I posted the same verified findings as a top-level review body._
Reviewed commit: 54621bc
🔴 1 blocking | 🟡 4 suggestion(s)
Verified findings
blocking: Carried-forward prior finding (STILL VALID): `restore_from` still does not serialize against concurrent SQLite writers
packages/rs-platform-wallet-storage/src/sqlite/backup.rs (line 125)
restore_from() still documents the _lock as the mechanism that prevents a racing opener from recreating -wal/-shm during restore, but the implementation only takes an advisory fs2::FileExt::try_lock_exclusive() lock on the database file. Ordinary SQLite readers and writers do not participate in that flock, so they can continue using the database while this code removes siblings and atomically swaps the main file. The fallback branch at lines 192-198 also downgrades any non-WouldBlock lock failure to a warning and proceeds unlocked. In other words, the restore path still cannot enforce the serialization invariant it claims, so a concurrent process can race the restore and observe or create inconsistent wallet state.
suggestion: Carried-forward prior finding (STILL VALID): destructive auto-backups are still taken before any cross-process exclusion
packages/rs-platform-wallet-storage/src/sqlite/persister.rs (line 297)
Both destructive rollback snapshots are still created before anything excludes other SQLite processes. restore_from_inner() takes the pre-restore backup at lines 297-313 and only then calls backup::restore_from(), while delete_wallet_inner() takes the pre-delete backup at lines 420-428 before starting the delete transaction at line 430. run_auto_backup() delegates to backup::run_to(), whose own contract says it is an online page-stepping backup that does not block writers (backup.rs:51-53). A concurrent process can therefore commit rows after the snapshot is taken but before the restore/delete lands, which means the advertised rollback backup can already be stale when the destructive operation succeeds.
suggestion: Carried-forward prior finding (STILL VALID): `ErrorNoSelectableInputs` is still unreachable from real auto-selection failures
packages/rs-platform-wallet-ffi/src/error.rs (line 170)
This PR now maps PlatformWalletError::{NoSpendableInputs, OnlyOutputAddressesFunded, OnlyDustInputs} to ErrorNoSelectableInputs, but those wallet error variants are still never constructed anywhere in the tree. The only in-repo references are the enum definition and the new FFI tests. The real platform-address auto-selection failures still return generic AddressOperation errors in packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs:235-238 and packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs:209-231, so FFI and Swift callers still fall through to ErrorUnknown for the exact cases this new ABI surface is meant to distinguish.
suggestion: Carried-forward prior finding (STILL VALID): `load()` still accepts a host-owned wallet-list allocation without the paired free callback
packages/rs-platform-wallet-ffi/src/persistence.rs (line 1284)
FFIPersister::load() still only requires on_load_wallet_list_fn; it constructs LoadGuard with self.callbacks.on_load_wallet_list_free_fn even when that free callback is None. If the host callback returns a non-null entries_ptr/count but the paired free callback was not configured, Rust proceeds to read the host-owned allocation and the guard has no way to return it. That violates the callback ownership contract and leaks/strands the wallet-list allocation and nested buffers on every successful load.
suggestion: Carried-forward prior finding (STILL VALID): `on_get_core_tx_record_fn` can still return owned tx bytes without the paired free callback
packages/rs-platform-wallet-ffi/src/persistence.rs (line 1572)
The tx-record path still accepts tx_bytes_ptr from on_get_core_tx_record_fn and builds TxBytesGuard with self.callbacks.on_get_core_tx_record_free_fn, but that field remains optional. When the host supplies a non-null tx-bytes buffer and forgets the matching free callback, the guard's Drop branch cannot call anything, so the allocation is never returned to the host allocator. The FFI contract should reject this configuration before accepting host-owned bytes, or otherwise require the paired free callback whenever the producer callback is installed.
🤖 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.
- [BLOCKING] In `packages/rs-platform-wallet-storage/src/sqlite/backup.rs`:125-199: Carried-forward prior finding (STILL VALID): `restore_from` still does not serialize against concurrent SQLite writers
`restore_from()` still documents the `_lock` as the mechanism that prevents a racing opener from recreating `-wal`/`-shm` during restore, but the implementation only takes an advisory `fs2::FileExt::try_lock_exclusive()` lock on the database file. Ordinary SQLite readers and writers do not participate in that flock, so they can continue using the database while this code removes siblings and atomically swaps the main file. The fallback branch at lines 192-198 also downgrades any non-`WouldBlock` lock failure to a warning and proceeds unlocked. In other words, the restore path still cannot enforce the serialization invariant it claims, so a concurrent process can race the restore and observe or create inconsistent wallet state.
- [SUGGESTION] In `packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:297-430: Carried-forward prior finding (STILL VALID): destructive auto-backups are still taken before any cross-process exclusion
Both destructive rollback snapshots are still created before anything excludes other SQLite processes. `restore_from_inner()` takes the pre-restore backup at lines 297-313 and only then calls `backup::restore_from()`, while `delete_wallet_inner()` takes the pre-delete backup at lines 420-428 before starting the delete transaction at line 430. `run_auto_backup()` delegates to `backup::run_to()`, whose own contract says it is an online page-stepping backup that does not block writers (`backup.rs:51-53`). A concurrent process can therefore commit rows after the snapshot is taken but before the restore/delete lands, which means the advertised rollback backup can already be stale when the destructive operation succeeds.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/error.rs`:170-185: Carried-forward prior finding (STILL VALID): `ErrorNoSelectableInputs` is still unreachable from real auto-selection failures
This PR now maps `PlatformWalletError::{NoSpendableInputs, OnlyOutputAddressesFunded, OnlyDustInputs}` to `ErrorNoSelectableInputs`, but those wallet error variants are still never constructed anywhere in the tree. The only in-repo references are the enum definition and the new FFI tests. The real platform-address auto-selection failures still return generic `AddressOperation` errors in `packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs:235-238` and `packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs:209-231`, so FFI and Swift callers still fall through to `ErrorUnknown` for the exact cases this new ABI surface is meant to distinguish.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/persistence.rs`:1284-1303: Carried-forward prior finding (STILL VALID): `load()` still accepts a host-owned wallet-list allocation without the paired free callback
`FFIPersister::load()` still only requires `on_load_wallet_list_fn`; it constructs `LoadGuard` with `self.callbacks.on_load_wallet_list_free_fn` even when that free callback is `None`. If the host callback returns a non-null `entries_ptr`/`count` but the paired free callback was not configured, Rust proceeds to read the host-owned allocation and the guard has no way to return it. That violates the callback ownership contract and leaks/strands the wallet-list allocation and nested buffers on every successful load.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/persistence.rs`:1572-1605: Carried-forward prior finding (STILL VALID): `on_get_core_tx_record_fn` can still return owned tx bytes without the paired free callback
The tx-record path still accepts `tx_bytes_ptr` from `on_get_core_tx_record_fn` and builds `TxBytesGuard` with `self.callbacks.on_get_core_tx_record_free_fn`, but that field remains optional. When the host supplies a non-null tx-bytes buffer and forgets the matching free callback, the guard's `Drop` branch cannot call anything, so the allocation is never returned to the host allocator. The FFI contract should reject this configuration before accepting host-owned bytes, or otherwise require the paired free callback whenever the producer callback is installed.
…let-sqlite-persistor
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Cumulative incremental review of PR #3625 at head cf5f87a. Latest delta (54621bc..cf5f87a) is a v3.1-dev merge that does not touch wallet-storage. Prior-finding reconciliation: the three CMT-001/002/003 prior findings (delete-buffer reconcile, restore staged-validation, blob trailing-bytes) are FIXED with regression tests. The advisory-lock and destructive-backup concurrency concerns are INTENTIONALLY DEFERRED — the current head documents the trade-off inline with a structured warn rather than silent-skip; reclassified as suggestions rather than blockers. The NoSpendableInputs unreachability claim is OUT OF SCOPE — those variants entered via merged PR #3651, not this PR. The load()/per_account rehydration gap flagged by codex is OUT OF SCOPE — the PR description explicitly enumerates this as deferred work blocked on upstream Wallet::from_persisted. One legitimate latest-cumulative suggestion remains: PruneReport.kept undercounts failed removals.
🟡 3 suggestion(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-storage/src/sqlite/backup.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/backup.rs:350-356: PruneReport.kept undercounts files that remain after failed removals
`PruneReport.kept` is documented in `persister.rs:35` as 'Number of files that remain in the directory after pruning', but the loop only increments `kept` when the retention policy passes. When `remove_file()` fails the file remains on disk and is pushed onto `failed_removals`, but `kept` is not bumped. As a result `kept + removed.len() + failed_removals.len()` can exceed the documented invariant in the success direction (a file shows up in both surviving on disk and being missing from `kept`), so callers and tests that use `kept` to assert post-prune retention will misread the filesystem state under partial failure. Either bump `kept` when `remove_file` fails or amend the rustdoc to say `kept` is files retained by policy, distinct from files surviving due to deletion failure.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/backup.rs:181-203: restore_from advisory lock does not exclude concurrent SQLite writers; fallback proceeds unlocked
`restore_from()` relies on `fs2::FileExt::try_lock_exclusive()` as the guard that makes WAL/SHM cleanup and atomic rename safe. That lock only serializes peers that voluntarily take the same fs2 lock — ordinary SQLite clients (other platform-wallet processes, external sqlite3 tools) use POSIX byte-range / OFD locks on their own connections and do not participate in fs2's flock, so they can keep writing through the restore window. Additionally, lines 192-199 downgrade non-WouldBlock lock-acquire errors to a tracing::warn! and proceed with `_lock = None`, so on filesystems where flock returns EINVAL/ENOTSUP (some NFS/FUSE backends) the restore runs entirely unguarded. The current head explicitly documents this trade-off in the comment at lines 168-180, which is why this is a suggestion and not a blocker: either (a) use SQLite's own exclusive locking (BEGIN EXCLUSIVE on the destination held across the rename, or rusqlite's backup API directly into a fresh connection that holds the write lock), or (b) expose a typed `WalletStorageError::AdvisoryLockUnavailable` and require callers to opt in to an unlocked restore via an explicit sibling (mirroring `delete_wallet_skip_backup`) so the safety bypass is at the call site, not buried in a warn line.
In `packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/persister.rs:297-430: Pre-restore and pre-delete auto-backups are taken before any cross-process exclusion
Both destructive rollback snapshots are produced via `run_auto_backup` → `rusqlite::backup::Backup::run_to_completion`, which is a page-stepping online backup that does not block writers. In `restore_from_inner` (lines 297-315) the pre-restore backup is taken before `backup::restore_from` even attempts its advisory lock. In `delete_wallet_inner` (lines 420-429) the pre-delete backup is taken under the in-process connection mutex, but that mutex only excludes other threads inside this `SqlitePersister` instance — a second process opening the same `.db` file can commit a transaction between the backup's last copied page and the start of the delete transaction at line 430. The 'rollback safety net' that destructive operations advertise therefore does not necessarily capture state at destructive-op start time, only at backup-start time. Either acquire a SQLite-level exclusive lock (BEGIN EXCLUSIVE on the same connection) before invoking `run_auto_backup` for destructive ops, or tighten the rustdoc to state explicitly that the rollback snapshot reflects state at backup-start, not state at the moment the destructive op's transaction begins, and that single-process access is a precondition of the rollback guarantee.
Out-of-scope follow-up suggestions (8)
These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.
- ClientStartState.wallets is not repopulated by load(); per_account map dropped —
SqlitePersister::load()(persister.rs:892-920) leavesstate.walletsempty andplatform_addrs::load_state()(platform_addrs.rs:166-192) reconstructsPlatformAddressSyncStartStatewithper_account: Default::default(). So a restart driven solely through the new persister cannot fully rehydrate wallets or per-account address state. However, the PR description explicitly enumerates this under 'Deferred (with rationale, follow-up PRs)': 'ClientStartState.walletsreconstruction inload()— blocked on a future upstreamWallet::from_persisted. All data IS persisted; only the wallet rehydration step is gated. TODO in persister.rs::load.' TheLOAD_UNIMPLEMENTEDconstant and the inline comment inload_statedocument the same deferral. Not introduced by this PR's bug, but tracked explicitly for follow-up.- Follow-up: Open the planned follow-up PR to add upstream
Wallet::from_persistedand wire it throughSqlitePersister::load(), including a per-account rehydration path that fillsPlatformAddressSyncStartState.per_accountfrom the persistedplatform_addressesrows. Add an end-to-end restart test that proves balances/known addresses survive process restart.
- Follow-up: Open the planned follow-up PR to add upstream
- ErrorNoSelectableInputs FFI variant has no real producer —
PlatformWalletError::{NoSpendableInputs, OnlyOutputAddressesFunded, OnlyDustInputs}exist only in their own definitions, the FFI classifier inpackages/rs-platform-wallet-ffi/src/error.rs:178-180, and the FFI unit tests at:418-427. Real auto-select failure sites intransfer.rs,withdrawal.rs, andpayments.rsstill return genericAddressOperationstrings, so FFI consumers will continue to seeErrorUnknownfor these cases. However, these variants and the FFI mapping were introduced via PR #3651 (commit 31e8af2) and merged into this branch via the v3.1-dev sync; they are not part of PR #3625's diff against v3.1-dev, so this should not block or expand the wallet-storage PR.- Follow-up: Open a separate maintainer-tracked PR (or follow-up to #3651) that either wires the auto-select sites to construct the typed variants or drops the unused FFI surface so the contract does not over-promise.
- Workspace-wide bincode 2.0.1 unmaintained advisory (RUSTSEC-2025-0141) — Acknowledged in the PR description as a workspace concern. Cannot be addressed surgically inside platform-wallet-storage without coordinated re-validation of every persistence/protocol codec consumer in the workspace.
- Follow-up: Track separately as a workspace dependency-policy PR.
- key_wallet::Wallet::WalletType::Mnemonic does not zeroize seed material on drop — Flagged in the PR description as out of scope for this storage crate. Upstream concern; relevant because platform-wallet-storage holds wallet handles that ultimately contain seeds in memory.
- Follow-up: File an upstream issue against key_wallet to implement Zeroize/ZeroizeOnDrop for the Mnemonic seed bytes.
- Latest-delta v3.1-dev merge brings in unrelated DAPI/JS cleanup —
git diff 54621bcc..cf5f87a3consists exclusively of a merge from origin/v3.1-dev: workspace version bumps, Cargo.lock churn, CHANGELOG entries, and js-dapi-client cleanup. These have been reviewed in their own upstream PRs (e.g. #3679, #3746).- Follow-up: No action — verify the merge commit is clean and proceed.
- Carried-forward: ErrorNoSelectableInputs ABI surface unreachable from real auto-selection failures — Out of scope for this PR. The three NoSpendableInputs/OnlyOutputAddressesFunded/OnlyDustInputs variants and the FFI classifier were introduced via PR #3651 (commit 31e8af2) and merged into this branch through the v3.1-dev sync. Not part of PR #3625's diff against v3.1-dev. Moved to out_of_scope_findings.
- Follow-up: Consider creating a separate issue or author/maintainer-requested PR for this.
- Cumulative: load() never repopulates ClientStartState.wallets — Out of scope. The PR description explicitly lists this under 'Deferred (with rationale, follow-up PRs)' — gated on a future upstream Wallet::from_persisted. The LOAD_UNIMPLEMENTED constant and tracing field document the deferral in-tree. Moved to out_of_scope_findings.
- Follow-up: Consider creating a separate issue or author/maintainer-requested PR for this.
- Cumulative: platform-address restore drops persisted address/balance map — Out of scope. Same deferred restart-rehydration scope; the inline comment at platform_addrs.rs:162-165 documents that 'the load-side wiring upstream is the consumer of this struct' and is awaiting upstream changes. Moved to out_of_scope_findings.
- Follow-up: Consider creating a separate issue or author/maintainer-requested PR for this.
| if pass_count && pass_age { | ||
| kept += 1; | ||
| } else { | ||
| match std::fs::remove_file(&path) { | ||
| Ok(()) => removed.push(path), | ||
| Err(e) => failed_removals.push((path, e)), | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: PruneReport.kept undercounts files that remain after failed removals
PruneReport.kept is documented in persister.rs:35 as 'Number of files that remain in the directory after pruning', but the loop only increments kept when the retention policy passes. When remove_file() fails the file remains on disk and is pushed onto failed_removals, but kept is not bumped. As a result kept + removed.len() + failed_removals.len() can exceed the documented invariant in the success direction (a file shows up in both surviving on disk and being missing from kept), so callers and tests that use kept to assert post-prune retention will misread the filesystem state under partial failure. Either bump kept when remove_file fails or amend the rustdoc to say kept is files retained by policy, distinct from files surviving due to deletion failure.
source: ['codex']
| let _lock: Option<std::fs::File> = if dest_db_path.exists() { | ||
| use fs2::FileExt; | ||
| let f = std::fs::OpenOptions::new() | ||
| .read(true) | ||
| .write(true) | ||
| .open(dest_db_path)?; | ||
| match f.try_lock_exclusive() { | ||
| Ok(()) => Some(f), | ||
| Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { | ||
| return Err(WalletStorageError::RestoreDestinationLocked); | ||
| } | ||
| Err(_) => { | ||
| tracing::warn!( | ||
| target: "platform_wallet_storage", | ||
| dest = %dest_db_path.display(), | ||
| "advisory lock unsupported on this filesystem; concurrent-writer race possible" | ||
| ); | ||
| None | ||
| } | ||
| } | ||
| } else { | ||
| None | ||
| }; |
There was a problem hiding this comment.
🟡 Suggestion: restore_from advisory lock does not exclude concurrent SQLite writers; fallback proceeds unlocked
restore_from() relies on fs2::FileExt::try_lock_exclusive() as the guard that makes WAL/SHM cleanup and atomic rename safe. That lock only serializes peers that voluntarily take the same fs2 lock — ordinary SQLite clients (other platform-wallet processes, external sqlite3 tools) use POSIX byte-range / OFD locks on their own connections and do not participate in fs2's flock, so they can keep writing through the restore window. Additionally, lines 192-199 downgrade non-WouldBlock lock-acquire errors to a tracing::warn! and proceed with _lock = None, so on filesystems where flock returns EINVAL/ENOTSUP (some NFS/FUSE backends) the restore runs entirely unguarded. The current head explicitly documents this trade-off in the comment at lines 168-180, which is why this is a suggestion and not a blocker: either (a) use SQLite's own exclusive locking (BEGIN EXCLUSIVE on the destination held across the rename, or rusqlite's backup API directly into a fresh connection that holds the write lock), or (b) expose a typed WalletStorageError::AdvisoryLockUnavailable and require callers to opt in to an unlocked restore via an explicit sibling (mirroring delete_wallet_skip_backup) so the safety bypass is at the call site, not buried in a warn line.
source: ['claude', 'codex']
| if !skip_backup && dest_db_path.exists() { | ||
| let dir = auto_backup_dir.ok_or(WalletStorageError::AutoBackupDisabled { | ||
| operation: AutoBackupOperation::Restore, | ||
| })?; | ||
| // Open the destination read-only just long enough to | ||
| // page-stream a snapshot to disk under auto_backup_dir. | ||
| let dest_conn = crate::sqlite::conn::open_conn( | ||
| dest_db_path, | ||
| crate::sqlite::conn::Access::ReadOnly, | ||
| )?; | ||
| run_auto_backup( | ||
| &dest_conn, | ||
| Some(dir), | ||
| BackupKind::PreRestore, | ||
| AutoBackupOperation::Restore, | ||
| )?; | ||
| drop(dest_conn); | ||
| } | ||
| backup::restore_from(dest_db_path, src_backup) | ||
| } | ||
|
|
||
| /// Apply retention to a directory of `wallet-*.db` (and/or | ||
| /// `pre-*-*.db`) files. | ||
| pub fn prune_backups( | ||
| &self, | ||
| dir: &Path, | ||
| policy: RetentionPolicy, | ||
| ) -> Result<PruneReport, WalletStorageError> { | ||
| backup::prune(dir, policy) | ||
| } | ||
|
|
||
| /// Cascade-delete every row owned by `wallet_id`. Takes a | ||
| /// pre-delete auto-backup before the cascade and refuses if | ||
| /// `auto_backup_dir` is `None` (FR-18). For the library-API, | ||
| /// safe-by-default route. | ||
| /// | ||
| /// To skip the auto-backup explicitly — wired up by the CLI's | ||
| /// `--no-auto-backup` — call | ||
| /// [`delete_wallet_skip_backup`](Self::delete_wallet_skip_backup). | ||
| /// | ||
| /// # Racing stores | ||
| /// | ||
| /// N-4: calls to `store(wallet_id, ...)` for the same wallet while | ||
| /// `delete_wallet` is in progress will be **discarded** after the | ||
| /// delete commits. The store call may return `Ok(())` (in | ||
| /// `FlushMode::Manual` it lands in the buffer), but its data does | ||
| /// not survive the delete — the post-commit re-drain inside | ||
| /// `delete_wallet` removes any buffered changeset that arrived | ||
| /// during the delete window. Synchronize at the caller layer if | ||
| /// you need different semantics. | ||
| pub fn delete_wallet( | ||
| &self, | ||
| wallet_id: WalletId, | ||
| ) -> Result<DeleteWalletReport, WalletStorageError> { | ||
| self.delete_wallet_inner(wallet_id, false) | ||
| } | ||
|
|
||
| /// Cascade-delete every row owned by `wallet_id` WITHOUT taking | ||
| /// an auto-backup. | ||
| /// | ||
| /// Library consumers should prefer [`delete_wallet`](Self::delete_wallet) | ||
| /// — it's safe by default. This entry point exists so the CLI's | ||
| /// `--no-auto-backup` flag can deliver on its name regardless of | ||
| /// `auto_backup_dir`. Returns `DeleteWalletReport.backup_path = | ||
| /// None` to signal the backup was intentionally skipped. | ||
| pub fn delete_wallet_skip_backup( | ||
| &self, | ||
| wallet_id: WalletId, | ||
| ) -> Result<DeleteWalletReport, WalletStorageError> { | ||
| self.delete_wallet_inner(wallet_id, true) | ||
| } | ||
|
|
||
| fn delete_wallet_inner( | ||
| &self, | ||
| wallet_id: WalletId, | ||
| skip_backup: bool, | ||
| ) -> Result<DeleteWalletReport, WalletStorageError> { | ||
| // CMT-008: acquire the connection mutex FIRST and hold it | ||
| // across drain → existence-check → backup → delete-transaction | ||
| // → post-commit buffer wipe. Concurrent `store()` calls in | ||
| // Immediate mode block on this guard (their flush takes conn); | ||
| // Manual-mode stores can still buffer, so we re-drain after | ||
| // commit to discard any racing writes (the wallet is going | ||
| // away — those writes are intentionally void). | ||
| let mut conn = self.conn()?; | ||
|
|
||
| // Drain the buffered changeset so a later flush can't | ||
| // resurrect the wallet, and so the wallet counts as existing | ||
| // even when its only state is buffered. Hold the drained value | ||
| // in `drained_slot` and only consume it AFTER tx.commit(). | ||
| let drained = self.buffer.take_for_flush(&wallet_id)?; | ||
| let had_buffered = drained.is_some(); | ||
| let drained_slot: std::cell::Cell<Option<PlatformWalletChangeSet>> = | ||
| std::cell::Cell::new(drained); | ||
|
|
||
| // Helper: any pre-commit failure must restore the changeset so | ||
| // we don't lose pending writes on a delete that didn't happen. | ||
| let restore_buffer = |slot: &std::cell::Cell<Option<PlatformWalletChangeSet>>| { | ||
| if let Some(cs) = slot.take() { | ||
| if let Err(e) = self.buffer.restore(wallet_id, cs) { | ||
| tracing::error!( | ||
| wallet_id = %hex::encode(wallet_id), | ||
| error_kind = e.error_kind_str(), | ||
| "buffer restore failed during delete_wallet error path — changeset lost" | ||
| ); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| let result: Result<DeleteWalletReport, WalletStorageError> = (|| { | ||
| // A wallet exists iff it was buffered OR persisted. Refusing | ||
| // on a truly-unknown wallet must not waste a backup file. | ||
| let exists_in_db = conn | ||
| .query_row( | ||
| "SELECT 1 FROM wallet_metadata WHERE wallet_id = ?1", | ||
| rusqlite::params![wallet_id.as_slice()], | ||
| |_| Ok(()), | ||
| ) | ||
| .optional()? | ||
| .is_some(); | ||
| if !had_buffered && !exists_in_db { | ||
| return Err(WalletStorageError::WalletNotFound { wallet_id }); | ||
| } | ||
| let backup_path = if skip_backup { | ||
| None | ||
| } else { | ||
| run_auto_backup( | ||
| &conn, | ||
| self.config.auto_backup_dir.as_deref(), | ||
| BackupKind::PreDelete { wallet_id }, | ||
| AutoBackupOperation::DeleteWallet, | ||
| )? | ||
| }; | ||
| let tx = conn.transaction()?; |
There was a problem hiding this comment.
🟡 Suggestion: Pre-restore and pre-delete auto-backups are taken before any cross-process exclusion
Both destructive rollback snapshots are produced via run_auto_backup → rusqlite::backup::Backup::run_to_completion, which is a page-stepping online backup that does not block writers. In restore_from_inner (lines 297-315) the pre-restore backup is taken before backup::restore_from even attempts its advisory lock. In delete_wallet_inner (lines 420-429) the pre-delete backup is taken under the in-process connection mutex, but that mutex only excludes other threads inside this SqlitePersister instance — a second process opening the same .db file can commit a transaction between the backup's last copied page and the start of the delete transaction at line 430. The 'rollback safety net' that destructive operations advertise therefore does not necessarily capture state at destructive-op start time, only at backup-start time. Either acquire a SQLite-level exclusive lock (BEGIN EXCLUSIVE on the same connection) before invoking run_auto_backup for destructive ops, or tighten the rustdoc to state explicitly that the rollback snapshot reflects state at backup-start, not state at the moment the destructive op's transaction begins, and that single-process access is a precondition of the rollback guarantee.
source: ['claude']
…ister) + trait surface (#3743) Co-authored-by: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Co-authored-by: Claudius the Magnificent (1M context) <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Cumulative incremental verification at head 64a85a8. Prior-finding reconciliation: PruneReport.kept undercount, restore_from advisory-lock silent degrade, pre-delete auto-backup snapshot skew, buffered-delete reconciliation, staged-restore validation, blob trailing-byte decode, and all FFI prior findings are FIXED or OUTDATED. STILL VALID and carried forward: (a) the pre-restore auto-backup is taken before SQLite-native EXCLUSIVE is acquired (asymmetric to the now-defended delete path), and (b) the rs-platform-wallet NoSpendableInputs/OnlyOutputAddressesFunded/OnlyDustInputs variants are still dead surface. NEW in the latest cf5f87a..64a85a8 delta: the post-EXCLUSIVE wallet_footprint re-check on the delete path compares only (table, row_count), so an in-place UPDATE by a peer can pass the check while the pre-delete backup is already stale. Codex agents elevated two restore-path issues (pre-restore backup race, lock-release-before-rename) to blocking; verified — the lock-release window is explicitly an intentional documented trade-off with a bounded threat model and is treated as out-of-scope; the pre-restore race is kept as a suggestion. The block_on_worker .expect panic-across-FFI concern is a pre-existing workspace pattern not introduced by this PR. No in-scope blocking defects.
🟡 2 suggestion(s) | 💬 1 nitpick(s)
1 additional finding(s) omitted (not in diff).
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/persister.rs:271-296: Carried-forward (STILL VALID): pre-restore auto-backup is taken before any SQLite-native exclusion on the destination
`restore_from_inner` opens the destination read-only and page-streams the `PreRestore` auto-backup at persister.rs:283-293 before `backup::restore_from` acquires its destination `BEGIN EXCLUSIVE` (backup.rs:244-270). A cross-process peer (another rusqlite Connection / sibling `SqlitePersister`) can commit between the backup's last copied page and the EXCLUSIVE acquisition; those bytes appear in neither the rollback snapshot nor the post-restore state, and the rename silently drops them. The delete path already closes the equivalent gap with a pre/post-EXCLUSIVE `wallet_footprint` re-check that surfaces `ConcurrentMutationDuringDelete`. Restore has no analogous guard — the asymmetry is not load-bearing. Either snapshot under EXCLUSIVE (mirror the delete pattern: open a short-lived writer conn, `BEGIN EXCLUSIVE`, then drive the rusqlite Backup API while holding the lock) or expand the rustdoc to say plainly that the pre-restore snapshot reflects state at backup-start, not at restore-tx-start, and that callers must quiesce peers. Note: the lock-release-before-rename window further down is a separate, deliberately documented trade-off (see out-of-scope) — this finding is specifically about the backup-before-EXCLUSIVE gap.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/persister.rs:458-514: New latest-delta finding: post-EXCLUSIVE wallet-footprint re-check compares only row counts, so in-place UPDATE by a peer evades it
The new guard at persister.rs:458-514 snapshots `wallet_footprint` before `run_auto_backup`, then re-snapshots under `BEGIN EXCLUSIVE` and aborts with `ConcurrentMutationDuringDelete` on mismatch. But `wallet_footprint` (persister.rs:1288-1305) and `wallet_footprint_tx` (:1309-1326) only collect `(table, row_count)` via `count_rows_for_wallet_sql`. Several per-wallet tables are single-row or upsert-style (`wallet_metadata`, `core_sync_state`, `platform_address_sync`), so a peer `UPDATE` that mutates existing-row contents without changing counts passes the check. In that case the cascade proceeds, but the pre-delete backup the operator is told they can roll back to is already stale relative to what was destroyed — defeating the new safe-by-default contract. Tighten the footprint to also fingerprint row contents (e.g., `SELECT COUNT(*), COALESCE(SUM(LENGTH(...)), 0)` or a per-table content hash) so an UPDATE-only peer also surfaces `ConcurrentMutationDuringDelete`.
Out-of-scope follow-up suggestions (7)
These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.
- restore_from microsecond lock-release-to-rename window (intentional, documented) — Codex agents flagged the EXCLUSIVE-released-before-persist at backup.rs:336-352 as a blocking write-loss bug. Verified: this is an explicit, rustdoc-documented trade-off (backup.rs:191-214). SQLite keeps a kernel handle on the destination's old inode for as long as the lock conn is alive; holding it across the rename would leave a dangling handle on the unlinked inode and (on some filesystems) make the rename fail. A peer that re-acquires a SQLite lock in the microsecond window writes to the soon-to-be-unlinked inode; non-malicious peers lose at most a transient miss, malicious peers cannot escalate beyond losing their own write, and callers serialize restore intent at the app layer. Acceptable under the current threat model.
- Follow-up: Optional follow-up to investigate per-filesystem rename-with-open-handle semantics (ext4/APFS/ZFS) and conditionally keep the lock across persist where safe. Not blocking for this PR.
block_on_workerpanic-unwind acrossextern "C"boundary (pre-existing workspace-wide pattern) — Codex-ffi flagged the newplatform_address_wallet_fund_from_asset_lock_signer/..._resume_...externs as creating a new panic-across-FFI exposure viaruntime.rs:55's.expect("tokio worker panicked"). Verified: the same panic-cross-FFI shape exists for every async extern inrs-platform-wallet-ffi(and the prior synchronousruntime().block_on(...)form had the same exposure). This PR did not introduce the pattern.- Follow-up: Open a separate hardening PR to wrap every
extern "C"entry inrs-platform-wallet-ffi(and considerrs-sdk-ffi) withstd::panic::catch_unwind, mapping caught panics to a typedErrorInternalresult code. Workspace-wide change — not for this PR.
- Follow-up: Open a separate hardening PR to wrap every
- ClientStartState.wallets rehydration deferred on
load()—SqlitePersister::load()intentionally reconstructs onlyplatform_addressesand logswalletsas unimplemented (LOAD_UNIMPLEMENTEDdocuments the surface). Per PR description, gated on upstreamWallet::from_persisted.- Follow-up: Open the planned follow-up once
Wallet::from_persistedlands upstream.
- Follow-up: Open the planned follow-up once
- PR #3743 / PR #3671 merge content pulled in by latest delta — The cf5f87a..64a85a8 delta is a v3.1-dev merge that brings in PR #3743 (wallet-storage crate — already independently reviewed and merged upstream) and PR #3671 (platform-address funding from asset-lock proofs — ~1.5k LOC across DPP / rs-drive-abci / rs-platform-wallet / SwiftExampleApp). These were reviewed on their own PRs and should not be re-litigated here. The two upstream-merged versions of the wallet-storage crate are now converged.
- Workspace-wide
bincode 2.0.1unmaintained advisory (RUSTSEC-2025-0141) — Now the BLOB encoder forplatform-wallet-storage, but the advisory is workspace-wide and cannot be remediated surgically inside this PR.- Follow-up: Track separately as a workspace dependency-policy PR with coordinated codec re-validation across every bincode consumer.
key_wallet::Wallet::WalletType::Mnemonicdoes not zeroize seed material on drop — Out of scope for the storage crate; relevant becauseplatform-wallet-storageultimately holds handles whose seeds live in memory.- Follow-up: File an upstream issue against
key_walletto implementZeroizeOnDropfor the Mnemonic seed bytes.
- Follow-up: File an upstream issue against
submit_with_cl_height_retrytrusts unauthenticated DAPI 10506 rejection — The CL-height retry helper inwallet/asset_lock/orchestration.rs:225-243bumpsuser_fee_increasebased on Platform's self-reportedcurrent_core_chain_locked_heightwith no quorum attestation. A malicious DAPI node can grief a user with bounded retry waste; cannot extract value. Trust boundary and bounded budget are explicitly documented at the call site.- Follow-up: Track an SDK-level follow-up to add quorum-attested consensus errors so client retry decisions don't depend on single-node honesty. Not blocking for this PR.
| fn restore_from_inner( | ||
| dest_db_path: &Path, | ||
| src_backup: &Path, | ||
| auto_backup_dir: Option<&Path>, | ||
| skip_backup: bool, | ||
| ) -> Result<(), WalletStorageError> { | ||
| if !skip_backup && dest_db_path.exists() { | ||
| let dir = auto_backup_dir.ok_or(WalletStorageError::AutoBackupDisabled { | ||
| operation: AutoBackupOperation::Restore, | ||
| })?; | ||
| // Open the destination read-only just long enough to | ||
| // page-stream a snapshot to disk under auto_backup_dir. | ||
| let dest_conn = crate::sqlite::conn::open_conn( | ||
| dest_db_path, | ||
| crate::sqlite::conn::Access::ReadOnly, | ||
| )?; | ||
| run_auto_backup( | ||
| &dest_conn, | ||
| Some(dir), | ||
| BackupKind::PreRestore, | ||
| AutoBackupOperation::Restore, | ||
| )?; | ||
| drop(dest_conn); | ||
| } | ||
| backup::restore_from(dest_db_path, src_backup) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Carried-forward (STILL VALID): pre-restore auto-backup is taken before any SQLite-native exclusion on the destination
restore_from_inner opens the destination read-only and page-streams the PreRestore auto-backup at persister.rs:283-293 before backup::restore_from acquires its destination BEGIN EXCLUSIVE (backup.rs:244-270). A cross-process peer (another rusqlite Connection / sibling SqlitePersister) can commit between the backup's last copied page and the EXCLUSIVE acquisition; those bytes appear in neither the rollback snapshot nor the post-restore state, and the rename silently drops them. The delete path already closes the equivalent gap with a pre/post-EXCLUSIVE wallet_footprint re-check that surfaces ConcurrentMutationDuringDelete. Restore has no analogous guard — the asymmetry is not load-bearing. Either snapshot under EXCLUSIVE (mirror the delete pattern: open a short-lived writer conn, BEGIN EXCLUSIVE, then drive the rusqlite Backup API while holding the lock) or expand the rustdoc to say plainly that the pre-restore snapshot reflects state at backup-start, not at restore-tx-start, and that callers must quiesce peers. Note: the lock-release-before-rename window further down is a separate, deliberately documented trade-off (see out-of-scope) — this finding is specifically about the backup-before-EXCLUSIVE gap.
source: ['claude', 'codex']
| // Snapshot the wallet's footprint BEFORE auto_backup so | ||
| // the post-EXCLUSIVE re-check has a baseline to compare | ||
| // against. `wallet_footprint` queries every PER_WALLET_TABLES | ||
| // row count; mismatches between pre-backup and post-lock | ||
| // mean a peer mutated the wallet inside the lock-free | ||
| // window the rusqlite Backup API forces us to leave open. | ||
| let pre_backup_footprint = wallet_footprint(&conn, &wallet_id)?; | ||
|
|
||
| let backup_path = if skip_backup { | ||
| None | ||
| } else { | ||
| run_auto_backup( | ||
| &conn, | ||
| self.config.auto_backup_dir.as_deref(), | ||
| BackupKind::PreDelete { wallet_id }, | ||
| AutoBackupOperation::DeleteWallet, | ||
| )? | ||
| }; | ||
|
|
||
| // Test-only hook: fires between the backup snapshot and | ||
| // the cascade EXCLUSIVE so TC-CODE-006-3 can simulate a | ||
| // cross-process peer that mutates `wallet_metadata` in | ||
| // the gap rusqlite's Backup API forces us to leave open. | ||
| #[cfg(any(test, feature = "__test-helpers"))] | ||
| self.consume_post_backup_hook(); | ||
|
|
||
| // SQLite-native EXCLUSIVE for the cascade window. Excludes | ||
| // cross-process peers (other rusqlite Connections, sibling | ||
| // `SqlitePersister`s) that would otherwise commit rows for | ||
| // `wallet_id` between the backup snapshot and the cascade. | ||
| // The in-process mutex on `conn` alone never gave that | ||
| // guarantee. Peers waiting on the lock back off via | ||
| // SQLite's `busy_timeout`. | ||
| let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Exclusive)?; | ||
|
|
||
| // Re-snapshot the wallet's footprint under EXCLUSIVE and | ||
| // compare against the pre-backup snapshot. Any change means | ||
| // a peer mutated the wallet between the backup and the lock | ||
| // acquisition — the backup we just took is now inconsistent | ||
| // with the live state, so rollback-from-backup would | ||
| // silently lose those writes. Abort with the typed | ||
| // `ConcurrentMutationDuringDelete` so the operator can | ||
| // retry after quiescing the peer. | ||
| let post_lock_footprint = wallet_footprint_tx(&tx, &wallet_id)?; | ||
| if post_lock_footprint != pre_backup_footprint { | ||
| tracing::warn!( | ||
| wallet_id = %hex::encode(wallet_id), | ||
| pre_backup = ?pre_backup_footprint, | ||
| post_lock = ?post_lock_footprint, | ||
| "delete_wallet aborted: peer mutated wallet between auto-backup and EXCLUSIVE" | ||
| ); | ||
| // Roll back the empty EXCLUSIVE — no destructive work | ||
| // has happened yet inside this tx, so drop is enough, | ||
| // but be explicit. | ||
| let _ = tx.rollback(); | ||
| return Err(WalletStorageError::ConcurrentMutationDuringDelete { wallet_id }); | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: New latest-delta finding: post-EXCLUSIVE wallet-footprint re-check compares only row counts, so in-place UPDATE by a peer evades it
The new guard at persister.rs:458-514 snapshots wallet_footprint before run_auto_backup, then re-snapshots under BEGIN EXCLUSIVE and aborts with ConcurrentMutationDuringDelete on mismatch. But wallet_footprint (persister.rs:1288-1305) and wallet_footprint_tx (:1309-1326) only collect (table, row_count) via count_rows_for_wallet_sql. Several per-wallet tables are single-row or upsert-style (wallet_metadata, core_sync_state, platform_address_sync), so a peer UPDATE that mutates existing-row contents without changing counts passes the check. In that case the cascade proceeds, but the pre-delete backup the operator is told they can roll back to is already stale relative to what was destroyed — defeating the new safe-by-default contract. Tighten the footprint to also fingerprint row contents (e.g., SELECT COUNT(*), COALESCE(SUM(LENGTH(...)), 0) or a per-table content hash) so an UPDATE-only peer also surfaces ConcurrentMutationDuringDelete.
source: ['codex']
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Cumulative re-review at head 016987c. Latest delta (64a85a8..016987c) is a clean merge of v3.1-dev that brings in unrelated rs-sdk-ffi work (PR #3751, platform_version field) — no platform-wallet-storage changes. Prior CMT-001..010 findings (buffer drain on delete, staged-bytes re-validation in restore_from, blob trailing-byte rejection, advisory-lock fallback removal, FFI error mapping, identity-manager enumeration, AddressFunds serde) are all FIXED at this head. Remaining in-scope items are non-blocking: a convergent architectural finding about three error variants that have no real producer, plus two cumulative race-detection refinements (pre-restore snapshot window, footprint row-count vs. content comparison) and three minor nitpicks. The codex-rust-quality 'blocking' finding about load_state dropping per_account state is documented as intentional pending upstream Wallet::from_persisted and is moved to out-of-scope follow-ups along with several other documented deferrals.
🟡 3 suggestion(s) | 💬 3 nitpick(s)
1 additional finding(s) omitted (not in diff).
🤖 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/error.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/error.rs:66-99: Dead error variants: NoSpendableInputs / OnlyOutputAddressesFunded / OnlyDustInputs have no production producer
Carried-forward prior finding (STILL VALID at 016987c7). Repo-wide grep for these three variants returns only two files: the definition in packages/rs-platform-wallet/src/error.rs and the FFI classifier in packages/rs-platform-wallet-ffi/src/error.rs (match arm + its own unit tests). The actual auto-selection failure sites — wallet/platform_addresses/transfer.rs, wallet/platform_addresses/withdrawal.rs, wallet/identity/network/payments.rs — still collapse selection failures into stringly-typed AddressOperation(...) / TransactionBuild(...) errors. The FFI carves out a distinct ErrorNoSelectableInputs code that only its own tests can ever trip, which undermines the exhaustiveness contract the classifier advertises. Either wire the real selection sites to construct these variants (the shapes — funded_outputs, sub_min_count, sub_min_aggregate, min_input_amount — are already correct), or delete the variants and the FFI arm until a real producer exists.
In `packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/persister.rs:458-514: delete_wallet concurrent-mutation guard compares only row counts, not content
Carried-forward prior finding (STILL VALID at 016987c7). wallet_footprint() / wallet_footprint_tx() (persister.rs:1284-1326) reduce each PER_WALLET_TABLES entry to (table_name, row_count). An in-place UPDATE that touches single-row or upsert-style tables — wallet_metadata, core_sync_state, platform_address_sync — will not change the row count, so the pre-backup and post-EXCLUSIVE snapshots compare equal even when the underlying content was overwritten by a racing writer. The cascade then proceeds and the advertised rollback backup is already stale relative to the state being destroyed. Strengthening the fingerprint to include a content hash (or a per-row monotonically updating revision column) closes the gap; alternatively, accept the limitation in rustdoc so callers understand the guarantee is row-count not row-content.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/persister.rs:277-295: Pre-restore auto-backup snapshot is taken before destination EXCLUSIVE lock
Carried-forward prior finding (STILL VALID at 016987c7). restore_from_inner opens the destination read-only and runs run_auto_backup() to page-stream a snapshot to auto_backup_dir BEFORE backup::restore_from acquires the destination-side BEGIN EXCLUSIVE. A cross-process writer that commits during the page-step window contributes to neither the rollback snapshot nor the post-restore database. The delete path already defends the analogous gap with a footprint snapshot + post-lock re-check; restore has no equivalent guard. Either move the pre-restore snapshot inside the EXCLUSIVE-locked window, or document the restore-snapshot precondition explicitly in rustdoc so operators understand 'rollback to pre-restore snapshot' means 'rollback to the point the snapshot completed' rather than 'rollback to the instant restore was invoked'.
Out-of-scope follow-up suggestions (6)
These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.
- load_state leaves PlatformAddressSyncStartState.per_account empty (intentionally deferred) — Verified at packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs:166-192 — load_state explicitly sets per_account: Default::default(). The doc comment at lines 161-165 documents this as intentional pending upstream wiring that round-trips xpubs into the live provider, and persister.rs::LOAD_UNIMPLEMENTED enumerates the load-side gaps. The codex-rust-quality agent classified this as blocking, but the deferral is documented in source. Without the upstream Wallet::from_persisted constructor and xpub round-trip, populating per_account from this PR alone is not actionable. Worth tracking as a follow-up so a future PR re-hydrates per_account once the upstream constructor lands.
- Follow-up: Track a follow-up PR (paired with upstream key_wallet::Wallet::from_persisted) to round-trip xpubs into platform_addresses and populate PlatformAddressSyncStartState::per_account from load_state. Extend tc040_load_platform_addresses to assert at least one restored account entry and one restored funded address.
- Workspace-wide bincode 2.0.1 unmaintained advisory (RUSTSEC-2025-0141) — Acknowledged in PR description as workspace-wide and not surgically resolvable inside this PR. The new crate's BLOB encoder uses the same bincode 2.0.1 as the rest of the workspace.
- Follow-up: Open a dedicated workspace dependency-bump PR/issue to migrate or replace bincode 2.0.1 once upstream maintenance is resolved.
- key_wallet::Wallet::WalletType::Mnemonic does not zeroize seed material on drop — Out-of-scope upstream concern. platform-wallet-storage holds wallet handles that ultimately contain seed bytes in memory; post-drop memory may retain seed material harvestable via core dumps or swap.
- Follow-up: File an upstream issue against key_wallet to implement Zeroize/ZeroizeOnDrop for Mnemonic seed bytes.
- submit_with_cl_height_retry trusts unauthenticated DAPI 10506 rejection — CL-height retry helper bumps user_fee_increase based on Platform's self-reported current_core_chain_locked_height with no cryptographic attestation. Bounded griefing budget documented at orchestration.rs:225-243; would need quorum-attested consensus errors to fix properly.
- Follow-up: Track an SDK-level follow-up for quorum-attested consensus errors so retry decisions don't depend on single-node honesty.
- restore_from lock-release-to-rename microsecond window — Deliberate trade-off documented at backup.rs:191-214: SQLite-native EXCLUSIVE is released before rename so SQLite can drop its file handle on the old inode. A racing peer that re-acquires its own lock in that window can lose its own write when the rename completes; a malicious same-host peer cannot escalate beyond that. Acceptable for current threat model.
- Follow-up: Optionally investigate per-FS rename-with-open-handle behaviour (ext4, APFS, ZFS) and keep the lock conn alive across persist on filesystems that permit it.
- DashSDKConfig FFI layout change from merged PR #3751 — The v3.1-dev merge appended platform_version: u32 to #[repr(C)] DashSDKConfig (rs-sdk-ffi/src/types.rs). The Rust apply_version helper validates 0 → default and forwards non-zero to PlatformVersion::get → SdkBuilder::with_version, so the new path is type-safe. However, this is an ABI-breaking append: raw FFI consumers (Objective-C, other-language binders) compiled against the prior layout will read four bytes past the original struct boundary unless they recompile. The Swift wrapper defaults the new param so source-level Swift callers are unaffected. Belongs to PR #3751's review thread, not this PR.
- Follow-up: Confirm the FFI layout change is captured in the swift-sdk/FFI changelog and downstream consumers have been notified to recompile. Consider whether a versioned DashSDKConfigV2 + dash_sdk_create_v2 or an explicit struct_size guard is warranted before further fields are appended.
| // Snapshot the wallet's footprint BEFORE auto_backup so | ||
| // the post-EXCLUSIVE re-check has a baseline to compare | ||
| // against. `wallet_footprint` queries every PER_WALLET_TABLES | ||
| // row count; mismatches between pre-backup and post-lock | ||
| // mean a peer mutated the wallet inside the lock-free | ||
| // window the rusqlite Backup API forces us to leave open. | ||
| let pre_backup_footprint = wallet_footprint(&conn, &wallet_id)?; | ||
|
|
||
| let backup_path = if skip_backup { | ||
| None | ||
| } else { | ||
| run_auto_backup( | ||
| &conn, | ||
| self.config.auto_backup_dir.as_deref(), | ||
| BackupKind::PreDelete { wallet_id }, | ||
| AutoBackupOperation::DeleteWallet, | ||
| )? | ||
| }; | ||
|
|
||
| // Test-only hook: fires between the backup snapshot and | ||
| // the cascade EXCLUSIVE so TC-CODE-006-3 can simulate a | ||
| // cross-process peer that mutates `wallet_metadata` in | ||
| // the gap rusqlite's Backup API forces us to leave open. | ||
| #[cfg(any(test, feature = "__test-helpers"))] | ||
| self.consume_post_backup_hook(); | ||
|
|
||
| // SQLite-native EXCLUSIVE for the cascade window. Excludes | ||
| // cross-process peers (other rusqlite Connections, sibling | ||
| // `SqlitePersister`s) that would otherwise commit rows for | ||
| // `wallet_id` between the backup snapshot and the cascade. | ||
| // The in-process mutex on `conn` alone never gave that | ||
| // guarantee. Peers waiting on the lock back off via | ||
| // SQLite's `busy_timeout`. | ||
| let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Exclusive)?; | ||
|
|
||
| // Re-snapshot the wallet's footprint under EXCLUSIVE and | ||
| // compare against the pre-backup snapshot. Any change means | ||
| // a peer mutated the wallet between the backup and the lock | ||
| // acquisition — the backup we just took is now inconsistent | ||
| // with the live state, so rollback-from-backup would | ||
| // silently lose those writes. Abort with the typed | ||
| // `ConcurrentMutationDuringDelete` so the operator can | ||
| // retry after quiescing the peer. | ||
| let post_lock_footprint = wallet_footprint_tx(&tx, &wallet_id)?; | ||
| if post_lock_footprint != pre_backup_footprint { | ||
| tracing::warn!( | ||
| wallet_id = %hex::encode(wallet_id), | ||
| pre_backup = ?pre_backup_footprint, | ||
| post_lock = ?post_lock_footprint, | ||
| "delete_wallet aborted: peer mutated wallet between auto-backup and EXCLUSIVE" | ||
| ); | ||
| // Roll back the empty EXCLUSIVE — no destructive work | ||
| // has happened yet inside this tx, so drop is enough, | ||
| // but be explicit. | ||
| let _ = tx.rollback(); | ||
| return Err(WalletStorageError::ConcurrentMutationDuringDelete { wallet_id }); | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: delete_wallet concurrent-mutation guard compares only row counts, not content
Carried-forward prior finding (STILL VALID at 016987c). wallet_footprint() / wallet_footprint_tx() (persister.rs:1284-1326) reduce each PER_WALLET_TABLES entry to (table_name, row_count). An in-place UPDATE that touches single-row or upsert-style tables — wallet_metadata, core_sync_state, platform_address_sync — will not change the row count, so the pre-backup and post-EXCLUSIVE snapshots compare equal even when the underlying content was overwritten by a racing writer. The cascade then proceeds and the advertised rollback backup is already stale relative to the state being destroyed. Strengthening the fingerprint to include a content hash (or a per-row monotonically updating revision column) closes the gap; alternatively, accept the limitation in rustdoc so callers understand the guarantee is row-count not row-content.
source: ['codex']
| if !skip_backup && dest_db_path.exists() { | ||
| let dir = auto_backup_dir.ok_or(WalletStorageError::AutoBackupDisabled { | ||
| operation: AutoBackupOperation::Restore, | ||
| })?; | ||
| // Open the destination read-only just long enough to | ||
| // page-stream a snapshot to disk under auto_backup_dir. | ||
| let dest_conn = crate::sqlite::conn::open_conn( | ||
| dest_db_path, | ||
| crate::sqlite::conn::Access::ReadOnly, | ||
| )?; | ||
| run_auto_backup( | ||
| &dest_conn, | ||
| Some(dir), | ||
| BackupKind::PreRestore, | ||
| AutoBackupOperation::Restore, | ||
| )?; | ||
| drop(dest_conn); | ||
| } | ||
| backup::restore_from(dest_db_path, src_backup) |
There was a problem hiding this comment.
🟡 Suggestion: Pre-restore auto-backup snapshot is taken before destination EXCLUSIVE lock
Carried-forward prior finding (STILL VALID at 016987c). restore_from_inner opens the destination read-only and runs run_auto_backup() to page-stream a snapshot to auto_backup_dir BEFORE backup::restore_from acquires the destination-side BEGIN EXCLUSIVE. A cross-process writer that commits during the page-step window contributes to neither the rollback snapshot nor the post-restore database. The delete path already defends the analogous gap with a footprint snapshot + post-lock re-check; restore has no equivalent guard. Either move the pre-restore snapshot inside the EXCLUSIVE-locked window, or document the restore-snapshot precondition explicitly in rustdoc so operators understand 'rollback to pre-restore snapshot' means 'rollback to the point the snapshot completed' rather than 'rollback to the instant restore was invoked'.
source: ['codex']
| let pre_version = peek_schema_version(&db).map_err(|e| CliError::runtime(e.to_string()))?; | ||
| let _persister = SqlitePersister::open(config.clone()).map_err(map_open_err_for_cli)?; | ||
| let post_version = | ||
| peek_schema_version(&db).map_err(|e| CliError::runtime(e.to_string()))?; | ||
| let applied = post_version | ||
| .unwrap_or(0) | ||
| .saturating_sub(pre_version.unwrap_or(0)) as usize; | ||
| println!("applied: {applied}"); |
There was a problem hiding this comment.
💬 Nitpick: peek_schema_version uses rusqlite::Connection::open and can create an empty file at the user-supplied path
peek_schema_version (peek path of the migrate subcommand) opens the destination through rusqlite::Connection::open(db)?, which creates a zero-byte SQLite file at db if it does not exist. It runs BEFORE SqlitePersister::open in the migrate arm. For the typical 'migrate --db /new/path/wallets.db' case the side-effect is harmless because SqlitePersister::open immediately migrates the freshly-created file. But (a) it bypasses the parent-directory existence guard the persister provides (NFR-6 / WalletStorageError::Io(NotFound)) and surfaces a raw rusqlite CANTOPEN instead, and (b) if SqlitePersister::open subsequently errors (e.g. apply_pragmas rejects journal_mode), an empty pre-migration .db file is left behind with default-umask permissions (no apply_secure_permissions via this path). Routing the pre-version probe through crate::sqlite::conn::open_conn(_, Access::ReadOnly) opens with SQLITE_OPEN_READ_ONLY and will not create.
source: ['claude']
| CREATE TABLE identity_keys ( | ||
| identity_id BLOB NOT NULL, | ||
| key_id INTEGER NOT NULL, | ||
| public_key_blob BLOB NOT NULL, | ||
| public_key_hash BLOB NOT NULL, | ||
| derivation_blob BLOB, | ||
| PRIMARY KEY (identity_id, key_id), | ||
| FOREIGN KEY (identity_id) REFERENCES identities(identity_id) ON DELETE CASCADE | ||
| ); |
There was a problem hiding this comment.
💬 Nitpick: identity_keys.derivation_blob column is declared but never populated
identity_keys.derivation_blob BLOB is declared in V001__initial.rs but the writer in schema/identity_keys.rs::apply always inserts NULL for that column and stuffs the entire IdentityKeyWire (including derivation_indices) into public_key_blob; decode_entry reads the wire from public_key_blob only. The column is dead schema. Not a correctness bug, but future readers may select the wrong column. Either populate the column or drop it via a follow-up migration (under append-only NFR-8 it would remain a documented dead column).
source: ['claude']
| let mut dest_lock_conn: Option<rusqlite::Connection> = if dest_db_path.exists() { | ||
| let conn = | ||
| crate::sqlite::conn::open_conn(dest_db_path, crate::sqlite::conn::Access::ReadWrite)?; | ||
| // Reuse a sensible busy_timeout so peers don't immediately | ||
| // surface BUSY without a backoff window. The destination DB | ||
| // may not have a persister attached yet (the persister is the | ||
| // CALLER), so this conn applies its own. | ||
| conn.busy_timeout(std::time::Duration::from_secs(5))?; | ||
| // Take EXCLUSIVE up-front by promoting an immediate tx. If a | ||
| // peer holds the DB, SQLite waits for busy_timeout then | ||
| // returns BUSY — we surface that as `RestoreDestinationLocked` | ||
| // so callers keep their existing branch. | ||
| match conn.execute_batch("BEGIN EXCLUSIVE") { | ||
| Ok(()) => Some(conn), | ||
| Err(rusqlite::Error::SqliteFailure(err, _)) | ||
| if matches!( | ||
| err.code, | ||
| rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked | ||
| ) => | ||
| { | ||
| return Err(WalletStorageError::RestoreDestinationLocked); | ||
| } | ||
| Err(other) => return Err(WalletStorageError::Sqlite(other)), | ||
| } | ||
| } else { | ||
| None | ||
| }; |
There was a problem hiding this comment.
💬 Nitpick: restore_from unlinks WAL/SHM siblings while the destination lock connection is still alive
In restore_from, the WAL/SHM sibling unlink runs BEFORE dest_lock_conn is dropped. On Unix the unlinked inodes remain reachable through the still-open fds, so the open BEGIN EXCLUSIVE tx (and the subsequent ROLLBACK) operate against orphaned inodes — fine in practice but leaves SQLite holding handles for unlinked files. On Windows, remove_file against a file held open by another process returns PermissionDenied (the rest of the crate is broadly Unix-targeted — chmod is #[cfg(unix)] — so portability may already be out of scope). Moving the sibling-unlink between drop(lock_conn) and persist() closes the ordering wart. Low confidence because the comment at lines 191-214 indicates the ordering was deliberate; flagging in case the trade-off was for the LIVE conn's WAL handles rather than the lock conn's.
source: ['claude']
…let-sqlite-persistor
…3.1.0-dev.7 Workspace bumped to 3.1.0-dev.7 in the v3.1-dev merge (release commit ebdbb0b). platform-wallet-storage inherits version.workspace = true, but its Cargo.lock entry still pointed at dev.6 — fixed by re-running cargo check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
Correction to the initial body for commit 187a026: prior finding reconciliation carries forward STILL VALID prior findings #2, #3, #4, #5, and #6; prior finding #1 is OUTDATED/FIXED as originally stated because the merged platform-address transfer work now produces OnlyOutputAddressesFunded/OnlyDustInputs. No latest-delta storage-crate changes were introduced by 016987c..187a026, so these are carried-forward cumulative findings, not new delta findings.
GitHub's live PR head had already moved to eadae31 when this queue item ran; this review covers the assigned commit 187a026 exactly.
🟡 2 suggestion(s) | 💬 3 nitpick(s)
Carried-forward prior findings
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:
- [SUGGESTION] lines 458-514: delete_wallet concurrent-mutation guard compares only row counts, not content
Carried-forward prior finding (STILL VALID at 016987c7). wallet_footprint() / wallet_footprint_tx() (persister.rs:1284-1326) reduce each PER_WALLET_TABLES entry to (table_name, row_count). An in-place UPDATE that touches single-row or upsert-style tables — wallet_metadata, core_sync_state, platform_address_sync — will not change the row count, so the pre-backup and post-EXCLUSIVE snapshots compare equal even when the underlying content was overwritten by a racing writer. The cascade then proceeds and the advertised rollback backup is already stale relative to the state being destroyed. Strengthening the fingerprint to include a content hash (or a per-row monotonically updating revision column) closes the gap; alternatively, accept the limitation in rustdoc so callers understand the guarantee is row-count not row-content.
In `packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:
- [SUGGESTION] lines 271-296: Pre-restore auto-backup snapshot is taken before the destination EXCLUSIVE lock
`restore_from_inner` opens the destination read-only and runs `run_auto_backup()` to page-stream a snapshot to `auto_backup_dir` BEFORE `backup::restore_from` acquires the destination-side `BEGIN EXCLUSIVE`. A cross-process writer that commits during the page-step window contributes to neither the rollback snapshot (already drifting) nor the post-restore database (about to be wholesale replaced). The delete path already defends the analogous gap with a footprint snapshot + post-lock re-check (lines 458-514); restore has no equivalent guard. Either move the pre-restore snapshot inside the EXCLUSIVE-locked window, or document the precondition explicitly in rustdoc on `restore_from_backup` so operators understand 'rollback to pre-restore snapshot' means 'rollback to the point the snapshot completed' rather than 'rollback to the instant restore was invoked'.
In `packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs`:
- [NITPICK] lines 257-282: peek_schema_version uses rusqlite::Connection::open and can create a zero-byte file at the user-supplied path
`peek_schema_version` opens the destination through `rusqlite::Connection::open(db)?` (line 259), which silently creates a zero-byte SQLite file at `db` if the path does not yet exist. It runs BEFORE `SqlitePersister::open` in the migrate arm (line 215). For the typical `migrate --db /new/path/wallets.db` case this is benign because the persister immediately migrates the freshly-created file. But (a) it bypasses the parent-directory existence guard the persister provides (NFR-6 / `WalletStorageError::Io(NotFound)`) and surfaces a raw rusqlite `CANTOPEN` instead, and (b) if `SqlitePersister::open` subsequently errors (e.g. `apply_pragmas` rejects journal_mode, or the auto-backup-disabled gate fires), an empty pre-migration `.db` file is left behind with default-umask permissions — `apply_secure_permissions` (chmod 600) is never reached on this path. Routing the pre-version probe through `crate::sqlite::conn::open_conn(_, Access::ReadOnly)` opens with `SQLITE_OPEN_READ_ONLY` and will not create the file.
In `packages/rs-platform-wallet-storage/migrations/V001__initial.rs`:
- [NITPICK] lines 140-148: identity_keys.derivation_blob column is declared but never populated
identity_keys.derivation_blob BLOB is declared in V001__initial.rs but the writer in schema/identity_keys.rs::apply always inserts NULL for that column and stuffs the entire IdentityKeyWire (including derivation_indices) into public_key_blob; decode_entry reads the wire from public_key_blob only. The column is dead schema. Not a correctness bug, but future readers may select the wrong column. Either populate the column or drop it via a follow-up migration (under append-only NFR-8 it would remain a documented dead column).
In `packages/rs-platform-wallet-storage/src/sqlite/backup.rs`:
- [NITPICK] lines 311-348: restore_from unlinks WAL/SHM siblings while the destination lock connection is still alive
The WAL/SHM sibling unlink at backup.rs:311-322 runs BEFORE `dest_lock_conn` is dropped at lines 342-348. On Unix the unlinked inodes remain reachable through the still-open fds, so the open `BEGIN EXCLUSIVE` tx (and the subsequent ROLLBACK) operate against orphaned inodes — functionally fine, but leaves SQLite holding handles for unlinked files. On Windows, `remove_file` against a file held open by another process returns `PermissionDenied`, which would surface as `WalletStorageError::Io`. The comment at lines 336-341 documents that lock release must happen before `persist` for inode-handle correctness — moving the sibling unlink to between `drop(lock_conn)` and `persist()` keeps both invariants. Low confidence because the current ordering may have been deliberate so step-5 sibling cleanup is atomic with the staged-file validation gate; flagging in case the trade-off was for the LIVE conn's WAL handles rather than the lock conn's.
Prior finding reconciliation
- Prior #1 — OUTDATED: Dead error variants: NoSpendableInputs / OnlyOutputAddressesFunded / OnlyDustInputs have no production producer — Current head now has production producers for OnlyOutputAddressesFunded/OnlyDustInputs via the merged platform-address transfer work. The residual NoSpendableInputs/InputSumOverflow discussion is outside this PR delta, so the original three-variant finding is no longer carried forward as-is.
- Prior #2 — STILL VALID: delete_wallet concurrent-mutation guard compares only row counts, not content — wallet_footprint()/wallet_footprint_tx() still collect (table_name, COUNT(*)) snapshots and delete_wallet still compares pre-backup vs post-lock footprints, so content changes with unchanged row counts remain undetected.
- Prior #3 — STILL VALID: Pre-restore auto-backup snapshot is taken before destination EXCLUSIVE lock — restore_from_inner still runs run_auto_backup() before entering backup::restore_from, where the destination BEGIN EXCLUSIVE lock is acquired.
- Prior #4 — STILL VALID: peek_schema_version uses rusqlite::Connection::open and can create an empty file at the user-supplied path — peek_schema_version still calls rusqlite::Connection::open(db), so the pre-migration probe can create the user-supplied path.
- Prior #5 — STILL VALID: identity_keys.derivation_blob column is declared but never populated — V001 still declares identity_keys.derivation_blob, while identity_keys::apply sets derivation_blob = NULL and decode_entry reads IdentityKeyWire from public_key_blob only.
- Prior #6 — STILL VALID: restore_from unlinks WAL/SHM siblings while the destination lock connection is still alive — backup::restore_from still removes WAL/SHM siblings before dest_lock_conn is dropped/rolled back.
Reviewed commit: 187a0264
| fn restore_from_inner( | ||
| dest_db_path: &Path, | ||
| src_backup: &Path, | ||
| auto_backup_dir: Option<&Path>, | ||
| skip_backup: bool, | ||
| ) -> Result<(), WalletStorageError> { | ||
| if !skip_backup && dest_db_path.exists() { | ||
| let dir = auto_backup_dir.ok_or(WalletStorageError::AutoBackupDisabled { | ||
| operation: AutoBackupOperation::Restore, | ||
| })?; | ||
| // Open the destination read-only just long enough to | ||
| // page-stream a snapshot to disk under auto_backup_dir. | ||
| let dest_conn = crate::sqlite::conn::open_conn( | ||
| dest_db_path, | ||
| crate::sqlite::conn::Access::ReadOnly, | ||
| )?; | ||
| run_auto_backup( | ||
| &dest_conn, | ||
| Some(dir), | ||
| BackupKind::PreRestore, | ||
| AutoBackupOperation::Restore, | ||
| )?; | ||
| drop(dest_conn); | ||
| } | ||
| backup::restore_from(dest_db_path, src_backup) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Pre-restore auto-backup snapshot is taken before the destination EXCLUSIVE lock
restore_from_inner opens the destination read-only and runs run_auto_backup() to page-stream a snapshot to auto_backup_dir BEFORE backup::restore_from acquires the destination-side BEGIN EXCLUSIVE. A cross-process writer that commits during the page-step window contributes to neither the rollback snapshot (already drifting) nor the post-restore database (about to be wholesale replaced). The delete path already defends the analogous gap with a footprint snapshot + post-lock re-check (lines 458-514); restore has no equivalent guard. Either move the pre-restore snapshot inside the EXCLUSIVE-locked window, or document the precondition explicitly in rustdoc on restore_from_backup so operators understand 'rollback to pre-restore snapshot' means 'rollback to the point the snapshot completed' rather than 'rollback to the instant restore was invoked'.
source: ['claude', 'codex']
| fn peek_schema_version(db: &Path) -> Result<Option<i64>, rusqlite::Error> { | ||
| use rusqlite::OptionalExtension; | ||
| let conn = rusqlite::Connection::open(db)?; | ||
| // Pre-migration the history table may not exist yet — that is a | ||
| // legitimate "no version" answer, not a failure. | ||
| let has_history = conn | ||
| .query_row( | ||
| "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'refinery_schema_history'", | ||
| [], | ||
| |_| Ok(()), | ||
| ) | ||
| .optional()? | ||
| .is_some(); | ||
| if !has_history { | ||
| return Ok(None); | ||
| } | ||
| let v = conn | ||
| .query_row( | ||
| "SELECT MAX(version) FROM refinery_schema_history", | ||
| [], | ||
| |row| row.get::<_, Option<i64>>(0), | ||
| ) | ||
| .optional()? | ||
| .flatten(); | ||
| Ok(v) | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: peek_schema_version uses rusqlite::Connection::open and can create a zero-byte file at the user-supplied path
peek_schema_version opens the destination through rusqlite::Connection::open(db)? (line 259), which silently creates a zero-byte SQLite file at db if the path does not yet exist. It runs BEFORE SqlitePersister::open in the migrate arm (line 215). For the typical migrate --db /new/path/wallets.db case this is benign because the persister immediately migrates the freshly-created file. But (a) it bypasses the parent-directory existence guard the persister provides (NFR-6 / WalletStorageError::Io(NotFound)) and surfaces a raw rusqlite CANTOPEN instead, and (b) if SqlitePersister::open subsequently errors (e.g. apply_pragmas rejects journal_mode, or the auto-backup-disabled gate fires), an empty pre-migration .db file is left behind with default-umask permissions — apply_secure_permissions (chmod 600) is never reached on this path. Routing the pre-version probe through crate::sqlite::conn::open_conn(_, Access::ReadOnly) opens with SQLITE_OPEN_READ_ONLY and will not create the file.
source: ['claude', 'codex']
| if let Some(file_name) = dest_db_path.file_name() { | ||
| for ext in ["-wal", "-shm"] { | ||
| let mut sibling_name = file_name.to_os_string(); | ||
| sibling_name.push(ext); | ||
| let sibling = dest_db_path.with_file_name(sibling_name); | ||
| match std::fs::remove_file(&sibling) { | ||
| Ok(()) => {} | ||
| Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} | ||
| Err(e) => return Err(WalletStorageError::Io(e)), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // 6. ATOM-010 (A-5): chmod 600 on the temp BEFORE persist so the | ||
| // destination inherits owner-only mode via the atomic rename. | ||
| // Pre-A-5 the chmod ran post-persist — a rare chmod failure | ||
| // returned Err while leaving the new DB live at the destination | ||
| // (caller thought restore rolled back, reality was mixed). | ||
| #[cfg(unix)] | ||
| { | ||
| use std::os::unix::fs::PermissionsExt; | ||
| tmp.as_file() | ||
| .set_permissions(std::fs::Permissions::from_mode(0o600))?; | ||
| } | ||
|
|
||
| // 7. Release the SQLite-native EXCLUSIVE lock BEFORE the rename. | ||
| // Dropping `dest_lock_conn` causes SQLite to close its file | ||
| // handle on the old inode; if we kept it alive across `persist` | ||
| // the handle would point at the unlinked old inode while the | ||
| // new DB took its place — peers reopening would race the rename | ||
| // and (on some filesystems) the rename itself could fail. | ||
| if let Some(conn) = dest_lock_conn.take() { | ||
| // Best-effort rollback of the empty EXCLUSIVE tx; an error here | ||
| // means SQLite is already in trouble and `drop(conn)` covers | ||
| // the rest. Silent because the conn is about to drop anyway. | ||
| let _ = conn.execute_batch("ROLLBACK"); | ||
| drop(conn); | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: restore_from unlinks WAL/SHM siblings while the destination lock connection is still alive
The WAL/SHM sibling unlink at backup.rs:311-322 runs BEFORE dest_lock_conn is dropped at lines 342-348. On Unix the unlinked inodes remain reachable through the still-open fds, so the open BEGIN EXCLUSIVE tx (and the subsequent ROLLBACK) operate against orphaned inodes — functionally fine, but leaves SQLite holding handles for unlinked files. On Windows, remove_file against a file held open by another process returns PermissionDenied, which would surface as WalletStorageError::Io. The comment at lines 336-341 documents that lock release must happen before persist for inode-handle correctness — moving the sibling unlink to between drop(lock_conn) and persist() keeps both invariants. Low confidence because the current ordering may have been deliberate so step-5 sibling cleanup is atomic with the staged-file validation gate; flagging in case the trade-off was for the LIVE conn's WAL handles rather than the lock conn's.
source: ['claude', 'codex']
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Latest delta (187a026..eadae31) is exclusively a single-line Cargo.lock version bump for platform-wallet-storage (3.1.0-dev.6 → 3.1.0-dev.7); zero source files changed. All five carried-forward prior findings against the storage crate remain STILL VALID at the current head (verified on disk: persister.rs:271-296, persister.rs:458-514, bin/platform-wallet-storage.rs:257-282, V001__initial.rs:140-148, sqlite/backup.rs:311-348). Codex flagged one additional cumulative correctness issue in delete_wallet's pre-flush-before-backup ordering that is real but milder than its 'blocking' rating suggests. No new latest-delta findings. The three FFI/error-classifier findings (ErrorNoSelectableInputs/InputSumOverflow/on_get_core_tx_record leak) and the NoSpendableInputs dead-variant finding live in rs-platform-wallet / rs-platform-wallet-ffi, not the storage crate this PR introduces, and are recorded as out-of-scope for separate follow-up. Review action is COMMENT — suggestions/nitpicks only, no in-scope blockers.
🟡 1 suggestion(s)
5 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/persister.rs:425-475: delete_wallet pre-flushes buffered-only state before auto_backup, so a backup failure leaves the wallet materialized on disk instead of buffered-only
When delete_wallet is called on a wallet that exists only in the in-memory buffer, the pre-flush at persister.rs:425-441 commits the drained changeset to disk BEFORE run_auto_backup runs at persister.rs:469-474. If run_auto_backup then fails (AutoBackupDisabled, AutoBackupDirUnwritable, IO error mid-step), the function returns Err and restore_buffer at persister.rs:569 is a no-op because the drained_slot is already empty (the cs was consumed by the successful pre-flush commit). End state: the wallet that was previously buffered-only is now durable on disk, the buffer is empty, and the caller observes a failed delete. The wallet's logical existence is preserved (a retry after fixing the backup config will succeed), but the side-effect of forced materialization on a failing destructive op is surprising and is not covered by the existing delete-buffer regression tests. The flush-before-backup ordering is intentional (rusqlite Backup API requires no active write tx on the source — see the comment at persister.rs:417-424), so the right fix is either (a) take the auto_backup attempt BEFORE the pre-flush for buffered-only wallets and snapshot the post-flush state under EXCLUSIVE, (b) on auto_backup failure, open a new EXCLUSIVE tx and DELETE FROM PER_WALLET_TABLES for wallet_id to undo the pre-flush, or (c) document the materialization-on-failure semantics in the rustdoc on delete_wallet so callers don't assume Err means 'no on-disk change'. Codex rated this blocking at 0.92; downgraded to suggestion here because logical state is preserved and retries self-correct.
| if let Some(cs) = drained_slot.take() { | ||
| #[cfg(any(test, feature = "__test-helpers"))] | ||
| if let Some(primed) = primed_pre_flush_error { | ||
| drained_slot.set(Some(cs)); | ||
| return Err(primed); | ||
| } | ||
| let pre_flush_tx = | ||
| conn.transaction_with_behavior(rusqlite::TransactionBehavior::Exclusive)?; | ||
| if let Err(e) = apply_changeset_to_tx(&pre_flush_tx, &wallet_id, &cs) { | ||
| let _ = pre_flush_tx.rollback(); | ||
| drained_slot.set(Some(cs)); | ||
| return Err(e); | ||
| } | ||
| if let Err(e) = pre_flush_tx.commit() { | ||
| drained_slot.set(Some(cs)); | ||
| return Err(WalletStorageError::Sqlite(e)); | ||
| } | ||
| } | ||
|
|
||
| // Re-evaluate existence after the pre-flush: a buffered- | ||
| // only wallet now has rows on disk. | ||
| let exists_in_db = if exists_pre_flush { | ||
| true | ||
| } else { | ||
| conn.query_row( | ||
| "SELECT 1 FROM wallet_metadata WHERE wallet_id = ?1", | ||
| rusqlite::params![wallet_id.as_slice()], | ||
| |_| Ok(()), | ||
| ) | ||
| .optional()? | ||
| .is_some() | ||
| }; | ||
|
|
||
| // Snapshot the wallet's footprint BEFORE auto_backup so | ||
| // the post-EXCLUSIVE re-check has a baseline to compare | ||
| // against. `wallet_footprint` queries every PER_WALLET_TABLES | ||
| // row count; mismatches between pre-backup and post-lock | ||
| // mean a peer mutated the wallet inside the lock-free | ||
| // window the rusqlite Backup API forces us to leave open. | ||
| let pre_backup_footprint = wallet_footprint(&conn, &wallet_id)?; | ||
|
|
||
| let backup_path = if skip_backup { | ||
| None | ||
| } else { | ||
| run_auto_backup( | ||
| &conn, | ||
| self.config.auto_backup_dir.as_deref(), | ||
| BackupKind::PreDelete { wallet_id }, | ||
| AutoBackupOperation::DeleteWallet, | ||
| )? | ||
| }; |
There was a problem hiding this comment.
🟡 Suggestion: delete_wallet pre-flushes buffered-only state before auto_backup, so a backup failure leaves the wallet materialized on disk instead of buffered-only
When delete_wallet is called on a wallet that exists only in the in-memory buffer, the pre-flush at persister.rs:425-441 commits the drained changeset to disk BEFORE run_auto_backup runs at persister.rs:469-474. If run_auto_backup then fails (AutoBackupDisabled, AutoBackupDirUnwritable, IO error mid-step), the function returns Err and restore_buffer at persister.rs:569 is a no-op because the drained_slot is already empty (the cs was consumed by the successful pre-flush commit). End state: the wallet that was previously buffered-only is now durable on disk, the buffer is empty, and the caller observes a failed delete. The wallet's logical existence is preserved (a retry after fixing the backup config will succeed), but the side-effect of forced materialization on a failing destructive op is surprising and is not covered by the existing delete-buffer regression tests. The flush-before-backup ordering is intentional (rusqlite Backup API requires no active write tx on the source — see the comment at persister.rs:417-424), so the right fix is either (a) take the auto_backup attempt BEFORE the pre-flush for buffered-only wallets and snapshot the post-flush state under EXCLUSIVE, (b) on auto_backup failure, open a new EXCLUSIVE tx and DELETE FROM PER_WALLET_TABLES for wallet_id to undo the pre-flush, or (c) document the materialization-on-failure semantics in the rustdoc on delete_wallet so callers don't assume Err means 'no on-disk change'. Codex rated this blocking at 0.92; downgraded to suggestion here because logical state is preserved and retries self-correct.
source: ['codex']
|
@coderabbitai review all |
|
✅ Actions performedFull review triggered. |
| /// Retry classification for [`PersistenceError::Backend`]. | ||
| /// | ||
| /// The kind carries the persistor's `is_transient()` contract across | ||
| /// the trait boundary so consumers can decide whether to retry, undo | ||
| /// in-memory state, or surface the failure to the user without | ||
| /// guessing from a string message. | ||
| /// | ||
| /// The enum is intentionally NOT `#[non_exhaustive]`: adding a new | ||
| /// kind MUST force every consumer match to update explicitly. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] | ||
| pub enum PersistenceErrorKind { | ||
| /// The persistor reports the write was not committed and the | ||
| /// buffered state is preserved (e.g. `SQLITE_BUSY`, `SQLITE_FULL`, | ||
| /// `SQLITE_IOERR`, `SQLITE_NOMEM`). Callers MAY retry with | ||
| /// exponential backoff. | ||
| Transient, | ||
| /// The persistor reports an unrecoverable failure (schema | ||
| /// corruption, logic bug, I/O error not covered by the transient | ||
| /// class). Callers MUST NOT retry — the buffered changeset is | ||
| /// gone and the same call will keep failing. | ||
| Fatal, |
| #[error("persistence backend error ({kind:?}): {source}")] | ||
| Backend { | ||
| kind: PersistenceErrorKind, | ||
| source: Box<dyn StdError + Send + Sync>, | ||
| }, |
| /// SQL fragment for counting rows of `table` belonging to a single | ||
| /// wallet. `scope` selects the predicate flavour. The fragment includes | ||
| /// the leading `SELECT COUNT(*) FROM` so the call site can format it | ||
| /// directly and bind a single `?1` parameter (the wallet id bytes). | ||
| pub fn count_rows_for_wallet_sql(table: &str, scope: WalletScope) -> String { | ||
| match scope { | ||
| WalletScope::DirectColumn => { | ||
| format!("SELECT COUNT(*) FROM {table} WHERE wallet_id = ?1") | ||
| } | ||
| WalletScope::ViaIdentity => format!( | ||
| "SELECT COUNT(*) FROM {table} \ | ||
| WHERE identity_id IN (SELECT identity_id FROM identities WHERE wallet_id = ?1)" | ||
| ), | ||
| } | ||
| } |
| /// Construct a [`Self::Backend`] with an explicit kind. Use this | ||
| /// at the persistor boundary where the kind is known (e.g. | ||
| /// `From<WalletStorageError>` checks `is_transient()` and the | ||
| /// constraint codes before calling this). |
…d path The Phase 1 v3.1-dev merge into feat/platform-wallet-sqlite-persistor brought in the new platform-wallet PersistenceError API: the legacy `From<String> for PersistenceError` blanket impl is gone, so the four `String.into()` / `format!(...).into()` conversions in the shielded note & sync-state FFI load callbacks no longer compile. Switch them to the canonical `PersistenceError::backend(...)` constructor, matching the pattern already used everywhere else in this same file (see the wallet-load and account-decode call sites). Behaviour is identical: `backend()` boxes the message under `PersistenceErrorKind::Fatal`, which is what the legacy `From<String>` impl produced. Failed CI: https://github.com/dashpay/platform/actions/runs/26565892285 Failed job: 78262769905 (Rust workspace tests / Tests (macOS))
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (4)
packages/rs-platform-wallet-storage/tests/feature_flag_build.rs (1)
31-52: ⚡ Quick winAvoid exact multi-line string matching for manifest assertions.
On Line 37 and Line 41, the checks are whitespace/order-sensitive, so harmless formatting changes can fail this test. Prefer parsing
Cargo.tomlsemantically (or at least matching smaller invariant fragments) to keep this guard stable.🤖 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/feature_flag_build.rs` around lines 31 - 52, The test tc_code_020_1_wallet_and_serde_deps_are_optional is brittle because it matches exact multi-line slices of Cargo.toml; instead parse the manifest or assert smaller invariant fragments: load manifest into a toml::Value (or use regex) and assert that a dependency table for "platform-wallet" exists with optional = true and that its features array contains "serde", assert that "serde" dependency exists with version "1" and optional = true, and still assert the "sqlite" feature contains "dep:platform-wallet" and "dep:serde" entries; update references to the manifest variable and the assert checks in this function accordingly.packages/rs-platform-wallet-storage/tests/sqlite_backup_restore.rs (1)
354-421: 💤 Low valueInconsistent assertion:
kept + removedcannot equal 5 when all removals fail.When all
remove_filecalls fail,report.removedis empty (len=0) and all 5 files should be infailed_removals. The assertion at lines 416-420 checksremoved.len() + kept == 5, but sinceremoved.len()is 0 andkeptshould equal 5, this effectively assertskept == 5. However, line 410-414 assertskept == totalwheretotal = removed.len() + failed_removals.len(). Ifremoved.len()=0andfailed_removals.len()=5, thenkept=5, so both assertions pass.The comment at line 355 says "counted in BOTH
keptandfailed_removals" which seems to indicatekeptcounts files that weren't successfully removed (including failures). If that's the semantics, then the assertion at 416-420 is redundant and potentially confusing sinceremoved.len()will always be 0 in this failure scenario.Consider clarifying or simplifying the assertion:
- assert_eq!( - report.removed.len() + report.kept, - 5, - "kept + removed must equal total eligible (5)" - ); + assert_eq!( + report.kept, + 5, + "all 5 files must be kept when removal fails" + );🤖 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_backup_restore.rs` around lines 354 - 421, The test tc_code_019_a_failed_removal_counts_in_kept has a redundant/confusing assertion: after asserting report.kept == report.removed.len() + report.failed_removals.len(), the subsequent assert (report.removed.len() + report.kept == 5) is either redundant or misleading when removals all fail; update the test by removing that second assertion or replace it with an explicit clarity check (e.g., assert_eq!(report.kept, 5) and/or assert_eq!(report.failed_removals.len(), 5) and assert!(report.removed.is_empty())) so the semantics of kept, removed and failed_removals (as used in prune_backups) are unambiguous.packages/rs-platform-wallet-storage/tests/sqlite_trait_dispatch.rs (1)
153-157: ⚡ Quick winAssert wallet identity, not only count, in
report.succeeded.The current check can pass even if the wrong wallet IDs are reported as successful.
Suggested assertion tightening
let report = PlatformWalletPersistence::commit_writes(&persister) .expect("trait commit_writes must succeed"); assert!(report.is_ok(), "report={report:?}"); assert_eq!(report.succeeded.len(), 2); + assert!(report.succeeded.contains(&a), "wallet A should be marked succeeded"); + assert!(report.succeeded.contains(&b), "wallet B should be marked succeeded");🤖 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_trait_dispatch.rs` around lines 153 - 157, The test currently only checks the number of successful wallets after calling PlatformWalletPersistence::commit_writes(&persister) via the report variable; update the assertion to verify the actual wallet identities inside report.succeeded (not just its length) by comparing it against the expected wallet IDs created earlier in the test (e.g., the two wallet IDs you intended to succeed). Locate the call to PlatformWalletPersistence::commit_writes and the report variable, then replace or augment the assert_eq!(report.succeeded.len(), 2) with an assertion that the set/vec report.succeeded contains exactly the expected wallet IDs (order-insensitive comparison if necessary), ensuring the test fails if wrong IDs are reported.packages/rs-platform-wallet/src/changeset/serde_adapters.rs (1)
22-55: ⚡ Quick winAdd adapter wire-compat tests to lock serialization contracts.
These adapters encode persistence-critical wire shapes (especially the fixed tag map). A small test module here would guard against accidental remapping.
Suggested tests
+#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn asset_lock_funding_type_tags_are_stable() { + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType::*; + let cases = [ + (IdentityRegistration, 0u8), + (IdentityTopUp, 1u8), + (IdentityTopUpNotBound, 2u8), + (IdentityInvitation, 3u8), + (AssetLockAddressTopUp, 4u8), + (AssetLockShieldedAddressTopUp, 5u8), + ]; + + for (variant, expected_tag) in cases { + let bytes = bincode::serde::encode_to_vec( + &variant, + bincode::config::standard(), + ).unwrap(); + let decoded: u8 = bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).unwrap().0; + assert_eq!(decoded, expected_tag); + } + } +}Also applies to: 59-88
🤖 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/src/changeset/serde_adapters.rs` around lines 22 - 55, Add a small test module that locks the wire-format tag mapping used by the serialize and deserialize functions for AssetLockFundingType: write unit tests that (1) assert serialize produces the exact u8 tag for each AssetLockFundingType variant (IdentityRegistration, IdentityTopUp, IdentityTopUpNotBound, IdentityInvitation, AssetLockAddressTopUp, AssetLockShieldedAddressTopUp), (2) assert deserialize maps each exact tag back to the correct variant, (3) assert round-trip serialize->deserialize yields the same variant, and (4) assert deserialize returns an error for an out-of-range/unknown tag; place tests alongside the serialize and deserialize functions so future refactors will fail if the fixed tag map changes.
🤖 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-storage/src/bin/platform-wallet-storage.rs`:
- Around line 178-187: The run function currently forces extraction of cli.db
into db before handling subcommands, causing the Cmd::Prune branch to fail when
--db is not provided; modify run so that the Cmd::Prune early-return (the if let
Cmd::Prune(args) = &cli.cmd { return run_prune(args); }) happens before
attempting to read/unwrap cli.db (the db = cli.db.ok_or_else(...) call), or
alternatively only require/extract db inside the branches that need it (e.g.,
after checking cli.cmd for non-Prune cases), leaving run_prune able to run
without --db.
In `@packages/rs-platform-wallet-storage/tests/secrets_scan.rs`:
- Around line 51-54: The test currently swallows read_dir errors in scan_dir by
using a silent early return on the Result from std::fs::read_dir, which allows
the test to pass when scan roots are missing or unreadable; change scan_dir to
fail fast by unwrap-ing or propagating the error (e.g., expect/panic) when
std::fs::read_dir(dir) returns Err so the test fails loudly if a scan root like
src/sqlite/schema or migrations is missing or unreadable; apply the same
fail-fast change to the other read_dir usage found around the second occurrence
(the lines near 101-102) so both scan entry reads cause test failure on error.
In `@packages/rs-platform-wallet-storage/tests/sqlite_open_integrity_check.rs`:
- Around line 89-93: The test currently asserts len > 8192 but then seeks and
reads two full pages at offsets 4096 and 8192 which will panic if the file is
only two pages long; update the guard on the variable len to require at least
three full pages (12288 bytes)—for example replace the assert!(len > 8192, ...)
with assert!(len >= 12288, "expected at least three full pages")—so that the
subsequent f.seek(SeekFrom::Start(page_start)) and f.read_exact(&mut buf) calls
for page_start values 4096 and 8192 are safe.
In
`@packages/rs-platform-wallet-storage/tests/sqlite_restore_cross_process_exclusion.rs`:
- Around line 80-84: The comment about busy_timeout is misleading: PRAGMA
busy_timeout set on the test peer connection (via ro_conn_rw and
peer.pragma_update(..., "busy_timeout", 50i64)) is connection-local and only
affects how that peer acquires EXCLUSIVE; restore_from_skip_backup /
backup::restore_from open a separate destination lock connection and set their
own busy_timeout (5s) before issuing BEGIN EXCLUSIVE. Update the test comment to
state that the 50ms busy_timeout only governs the peer's acquisition behavior
and does not control the restore path's waiting behavior (which is controlled by
the restore code's own busy_timeout and BEGIN EXCLUSIVE).
---
Nitpick comments:
In `@packages/rs-platform-wallet-storage/tests/feature_flag_build.rs`:
- Around line 31-52: The test tc_code_020_1_wallet_and_serde_deps_are_optional
is brittle because it matches exact multi-line slices of Cargo.toml; instead
parse the manifest or assert smaller invariant fragments: load manifest into a
toml::Value (or use regex) and assert that a dependency table for
"platform-wallet" exists with optional = true and that its features array
contains "serde", assert that "serde" dependency exists with version "1" and
optional = true, and still assert the "sqlite" feature contains
"dep:platform-wallet" and "dep:serde" entries; update references to the manifest
variable and the assert checks in this function accordingly.
In `@packages/rs-platform-wallet-storage/tests/sqlite_backup_restore.rs`:
- Around line 354-421: The test tc_code_019_a_failed_removal_counts_in_kept has
a redundant/confusing assertion: after asserting report.kept ==
report.removed.len() + report.failed_removals.len(), the subsequent assert
(report.removed.len() + report.kept == 5) is either redundant or misleading when
removals all fail; update the test by removing that second assertion or replace
it with an explicit clarity check (e.g., assert_eq!(report.kept, 5) and/or
assert_eq!(report.failed_removals.len(), 5) and
assert!(report.removed.is_empty())) so the semantics of kept, removed and
failed_removals (as used in prune_backups) are unambiguous.
In `@packages/rs-platform-wallet-storage/tests/sqlite_trait_dispatch.rs`:
- Around line 153-157: The test currently only checks the number of successful
wallets after calling PlatformWalletPersistence::commit_writes(&persister) via
the report variable; update the assertion to verify the actual wallet identities
inside report.succeeded (not just its length) by comparing it against the
expected wallet IDs created earlier in the test (e.g., the two wallet IDs you
intended to succeed). Locate the call to
PlatformWalletPersistence::commit_writes and the report variable, then replace
or augment the assert_eq!(report.succeeded.len(), 2) with an assertion that the
set/vec report.succeeded contains exactly the expected wallet IDs
(order-insensitive comparison if necessary), ensuring the test fails if wrong
IDs are reported.
In `@packages/rs-platform-wallet/src/changeset/serde_adapters.rs`:
- Around line 22-55: Add a small test module that locks the wire-format tag
mapping used by the serialize and deserialize functions for
AssetLockFundingType: write unit tests that (1) assert serialize produces the
exact u8 tag for each AssetLockFundingType variant (IdentityRegistration,
IdentityTopUp, IdentityTopUpNotBound, IdentityInvitation, AssetLockAddressTopUp,
AssetLockShieldedAddressTopUp), (2) assert deserialize maps each exact tag back
to the correct variant, (3) assert round-trip serialize->deserialize yields the
same variant, and (4) assert deserialize returns an error for an
out-of-range/unknown tag; place tests alongside the serialize and deserialize
functions so future refactors will fail if the fixed tag map changes.
🪄 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: 036f066a-923b-41a8-976c-6f684659d612
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (65)
.github/workflows/pr.yml.github/workflows/tests-rs-workspace.ymlCargo.tomlDockerfilepackages/rs-platform-wallet-ffi/src/persistence.rspackages/rs-platform-wallet-storage/Cargo.tomlpackages/rs-platform-wallet-storage/README.mdpackages/rs-platform-wallet-storage/SECRETS.mdpackages/rs-platform-wallet-storage/build.rspackages/rs-platform-wallet-storage/migrations/V001__initial.rspackages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rspackages/rs-platform-wallet-storage/src/lib.rspackages/rs-platform-wallet-storage/src/sqlite/backup.rspackages/rs-platform-wallet-storage/src/sqlite/buffer.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/migrations.rspackages/rs-platform-wallet-storage/src/sqlite/mod.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/wallet_meta.rspackages/rs-platform-wallet-storage/src/sqlite/util/mod.rspackages/rs-platform-wallet-storage/src/sqlite/util/permissions.rspackages/rs-platform-wallet-storage/src/sqlite/util/safe_cast.rspackages/rs-platform-wallet-storage/tests/common/mod.rspackages/rs-platform-wallet-storage/tests/feature_flag_build.rspackages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rspackages/rs-platform-wallet-storage/tests/secrets_scan.rspackages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rspackages/rs-platform-wallet-storage/tests/sqlite_backup_restore.rspackages/rs-platform-wallet-storage/tests/sqlite_buffer_semantics.rspackages/rs-platform-wallet-storage/tests/sqlite_cli_smoke.rspackages/rs-platform-wallet-storage/tests/sqlite_compile_time.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_wallet.rspackages/rs-platform-wallet-storage/tests/sqlite_error_classification.rspackages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rspackages/rs-platform-wallet-storage/tests/sqlite_hardening_3625.rspackages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rspackages/rs-platform-wallet-storage/tests/sqlite_migrations.rspackages/rs-platform-wallet-storage/tests/sqlite_open_integrity_check.rspackages/rs-platform-wallet-storage/tests/sqlite_open_version_gate.rspackages/rs-platform-wallet-storage/tests/sqlite_permissions.rspackages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rspackages/rs-platform-wallet-storage/tests/sqlite_restore_cross_process_exclusion.rspackages/rs-platform-wallet-storage/tests/sqlite_restore_staged_validation.rspackages/rs-platform-wallet-storage/tests/sqlite_trait_dispatch.rspackages/rs-platform-wallet/Cargo.tomlpackages/rs-platform-wallet/src/changeset/changeset.rspackages/rs-platform-wallet/src/changeset/mod.rspackages/rs-platform-wallet/src/changeset/serde_adapters.rspackages/rs-platform-wallet/src/changeset/traits.rspackages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs
| fn run(cli: Cli) -> Result<ExitCode, CliError> { | ||
| let db = cli | ||
| .db | ||
| .ok_or_else(|| CliError::runtime("--db is required"))?; | ||
| let auto_backup_dir: Option<PathBuf> = cli.auto_backup_dir; | ||
|
|
||
| // For `prune`, we don't open a persister — pure filesystem op. | ||
| if let Cmd::Prune(args) = &cli.cmd { | ||
| return run_prune(args); | ||
| } |
There was a problem hiding this comment.
Allow prune to run without --db.
At Line 179, --db is enforced before the Cmd::Prune branch, so prune fails even though it’s a pure filesystem operation. Move DB validation after the prune early-return (or gate it by subcommand).
💡 Suggested patch
fn run(cli: Cli) -> Result<ExitCode, CliError> {
- let db = cli
- .db
- .ok_or_else(|| CliError::runtime("--db is required"))?;
- let auto_backup_dir: Option<PathBuf> = cli.auto_backup_dir;
-
// For `prune`, we don't open a persister — pure filesystem op.
if let Cmd::Prune(args) = &cli.cmd {
return run_prune(args);
}
+
+ let db = cli
+ .db
+ .ok_or_else(|| CliError::runtime("--db is required"))?;
+ let auto_backup_dir: Option<PathBuf> = cli.auto_backup_dir;🤖 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/bin/platform-wallet-storage.rs`
around lines 178 - 187, The run function currently forces extraction of cli.db
into db before handling subcommands, causing the Cmd::Prune branch to fail when
--db is not provided; modify run so that the Cmd::Prune early-return (the if let
Cmd::Prune(args) = &cli.cmd { return run_prune(args); }) happens before
attempting to read/unwrap cli.db (the db = cli.db.ok_or_else(...) call), or
alternatively only require/extract db inside the branches that need it (e.g.,
after checking cli.cmd for non-Prune cases), leaving run_prune able to run
without --db.
| fn scan_dir(dir: &Path, offenders: &mut Vec<String>) { | ||
| let Ok(entries) = std::fs::read_dir(dir) else { | ||
| return; | ||
| }; |
There was a problem hiding this comment.
Fail fast when scan roots cannot be read.
Line 52 silently returns on read_dir failure, so this test can pass even when src/sqlite/schema or migrations is missing/unreadable. That weakens the security guardrail.
Suggested fix
-fn scan_dir(dir: &Path, offenders: &mut Vec<String>) {
- let Ok(entries) = std::fs::read_dir(dir) else {
- return;
- };
+fn scan_dir(dir: &Path, offenders: &mut Vec<String>) -> std::io::Result<()> {
+ let entries = std::fs::read_dir(dir)?;
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
- scan_dir(&p, offenders);
+ scan_dir(&p, offenders)?;
continue;
}
@@
for (idx, line) in body.lines().enumerate() {
@@
}
}
+ Ok(())
}
@@
- scan_dir(&manifest.join("src/sqlite/schema"), &mut offenders);
- scan_dir(&manifest.join("migrations"), &mut offenders);
+ scan_dir(&manifest.join("src/sqlite/schema"), &mut offenders)
+ .expect("must be able to read src/sqlite/schema");
+ scan_dir(&manifest.join("migrations"), &mut offenders)
+ .expect("must be able to read migrations");Also applies to: 101-102
🤖 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/secrets_scan.rs` around lines 51 -
54, The test currently swallows read_dir errors in scan_dir by using a silent
early return on the Result from std::fs::read_dir, which allows the test to pass
when scan roots are missing or unreadable; change scan_dir to fail fast by
unwrap-ing or propagating the error (e.g., expect/panic) when
std::fs::read_dir(dir) returns Err so the test fails loudly if a scan root like
src/sqlite/schema or migrations is missing or unreadable; apply the same
fail-fast change to the other read_dir usage found around the second occurrence
(the lines near 101-102) so both scan entry reads cause test failure on error.
| assert!(len > 8192, "expected at least two full pages"); | ||
| for page_start in [4096u64, 8192] { | ||
| f.seek(SeekFrom::Start(page_start)).unwrap(); | ||
| let mut buf = vec![0u8; 4096]; | ||
| f.read_exact(&mut buf).unwrap(); |
There was a problem hiding this comment.
Fix insufficient file-length guard before reading page 3.
Line 89 allows lengths that still make read_exact at Line 93 panic. Require at least 3 full pages (12288 bytes) before reading offsets 4096 and 8192.
🐛 Proposed fix
- assert!(len > 8192, "expected at least two full pages");
+ assert!(
+ len >= 12_288,
+ "expected at least three full pages to corrupt pages 2 and 3; got {len} bytes"
+ );📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| assert!(len > 8192, "expected at least two full pages"); | |
| for page_start in [4096u64, 8192] { | |
| f.seek(SeekFrom::Start(page_start)).unwrap(); | |
| let mut buf = vec![0u8; 4096]; | |
| f.read_exact(&mut buf).unwrap(); | |
| assert!( | |
| len >= 12_288, | |
| "expected at least three full pages to corrupt pages 2 and 3; got {len} bytes" | |
| ); | |
| for page_start in [4096u64, 8192] { | |
| f.seek(SeekFrom::Start(page_start)).unwrap(); | |
| let mut buf = vec![0u8; 4096]; | |
| f.read_exact(&mut buf).unwrap(); |
🤖 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_open_integrity_check.rs`
around lines 89 - 93, The test currently asserts len > 8192 but then seeks and
reads two full pages at offsets 4096 and 8192 which will panic if the file is
only two pages long; update the guard on the variable len to require at least
three full pages (12288 bytes)—for example replace the assert!(len > 8192, ...)
with assert!(len >= 12288, "expected at least three full pages")—so that the
subsequent f.seek(SeekFrom::Start(page_start)) and f.read_exact(&mut buf) calls
for page_start values 4096 and 8192 are safe.
| // Peer opens a writer conn, sets a SHORT busy_timeout so we don't | ||
| // wedge the test on a wedge — then takes EXCLUSIVE and holds it. | ||
| let mut peer = ro_conn_rw(&db_path); | ||
| peer.pragma_update(None, "busy_timeout", 50i64).unwrap(); | ||
| let tx = peer |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In SQLite/rusqlite, is PRAGMA busy_timeout scoped per connection or shared across all connections to the same database file?
💡 Result:
In SQLite and rusqlite, the PRAGMA busy_timeout setting is scoped per database connection, not shared across all connections to the same database file [1][2][3]. Each database connection maintains its own individual busy handler [1][3]. When you execute PRAGMA busy_timeout = N or call the equivalent rusqlite method, it sets the configuration specifically for that single connection instance [1][2][3]. Consequently, if your application opens multiple connections to the same database file, you must configure the busy timeout for each connection individually to ensure the desired behavior across all of them [2]. In rusqlite, the Connection::busy_timeout method (which wraps the underlying sqlite3_busy_timeout C API) applies the timeout to that specific connection instance [3]. Note that rusqlite connections are sometimes initialized with a default busy timeout (often 5000ms), but this remains a connection-specific setting [3][4].
Citations:
- 1: https://sqlite.org/pragma.html
- 2: https://www.php.net/manual/en/sqlite3.busytimeout.php
- 3: https://docs.rs/rusqlite/latest/src/rusqlite/busy.rs.html
- 4: https://www.reddit.com/r/rust/comments/6z7gs2/using_rusqlite_from_multiple_threads/
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate the test file
ls -la packages/rs-platform-wallet-storage/tests/sqlite_restore_cross_process_exclusion.rs
# Extract the relevant section around lines ~70-100
sed -n '60,110p' packages/rs-platform-wallet-storage/tests/sqlite_restore_cross_process_exclusion.rs | nl -ba
# Find the implementation of restore_from_skip_backup
rg -n "restore_from_skip_backup" -S packages/rs-platform-wallet-storage | head -n 50
# Print the implementation area (and any connection creation inside)
# (Use -n and limit output size)
impl_file="$(rg -n "fn\s+restore_from_skip_backup" -S packages/rs-platform-wallet-storage | head -n 1 | cut -d: -f1)"
echo "Implementation file: $impl_file"
sed -n '1,220p' "$impl_file" | nl -baRepository: dashpay/platform
Length of output: 254
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="packages/rs-platform-wallet-storage/tests/sqlite_restore_cross_process_exclusion.rs"
echo "---- test file: first 140 lines (with line numbers) ----"
python3 - <<PY
from pathlib import Path
p=Path("$FILE")
lines=p.read_text().splitlines()
for i,l in enumerate(lines[:140], start=1):
print(f"{i:4d} {l}")
PY
echo
echo "---- locate ro_conn_rw and pragma_update usage ----"
rg -n "ro_conn_rw|pragma_update|busy_timeout|restore_from_skip_backup" -S packages/rs-platform-wallet-storage/tests/sqlite_restore_cross_process_exclusion.rs
echo
echo "---- locate restore_from_skip_backup implementation in repo ----"
rg -n "fn\s+restore_from_skip_backup" -S packages/rs-platform-wallet-storage | head -n 20
IMPL="$(rg -n "fn\s+restore_from_skip_backup" -S packages/rs-platform-wallet-storage | head -n 1 | cut -d: -f1)"
echo "Implementation file: $IMPL"
echo
echo "---- implementation snippet around restore_from_skip_backup (with line numbers) ----"
python3 - <<PY
from pathlib import Path
p=Path("$IMPL")
lines=p.read_text().splitlines()
# print a window around the function definition
start=1
for idx,l in enumerate(lines):
if "fn restore_from_skip_backup" in l:
start=max(0, idx-20)
end=min(len(lines), idx+220)
break
else:
start=0
end=min(len(lines), 260)
for i in range(start, end):
print(f"{i+1:4d} {lines[i]}")
PY
echo
echo "---- scan for SQLite connection creation inside restore_from_skip_backup ----"
rg -n "Connection::open|rusqlite::Connection::open|sqlite3_open|open\(|new\(" -S "$IMPL" | head -n 80Repository: dashpay/platform
Length of output: 20018
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Show ro_conn_rw helper (already hinted around line 152 in test file)
rg -n "fn ro_conn_rw" -S packages/rs-platform-wallet-storage/tests/sqlite_restore_cross_process_exclusion.rs
python3 - <<'PY'
from pathlib import Path
p=Path("packages/rs-platform-wallet-storage/tests/sqlite_restore_cross_process_exclusion.rs")
lines=p.read_text().splitlines()
start=0
for i,l in enumerate(lines):
if "fn ro_conn_rw" in l:
start=i
break
else:
raise SystemExit("not found")
end=min(len(lines), start+40)
for j in range(start, end):
print(f"{j+1:4d} {lines[j]}")
PY
# Find backup::restore_from implementation
rg -n "pub\s+fn\s+restore_from\s*\(" -S packages/rs-platform-wallet-storage/src/sqlite
BACKUP_IMPL=$(rg -n "pub\s+fn\s+restore_from\s*\(" -S packages/rs-platform-wallet-storage/src/sqlite | head -n1 | cut -d: -f1)
echo "Backup restore_from impl line: $BACKUP_IMPL"
# Print a chunk around the backup restore_from function
python3 - <<'PY'
from pathlib import Path
import re,sys
root=Path("packages/rs-platform-wallet-storage/src/sqlite")
# find file containing restore_from
target=None
for f in root.rglob("*.rs"):
txt=f.read_text(errors="ignore")
if re.search(r"pub\s+fn\s+restore_from\s*\(", txt):
target=f
break
if not target:
raise SystemExit("restore_from not found")
lines=target.read_text().splitlines()
start=0
for i,l in enumerate(lines):
if "pub fn restore_from" in l:
start=i
break
end=min(len(lines), start+220)
print(f"---- file: {target} ----")
for j in range(start, end):
print(f"{j+1:4d} {lines[j]}")
PY
# Inspect open_conn helper for busy_timeout behavior
rg -n "fn\s+open_conn|busy_timeout|pragma_update\(None,\s*\"busy_timeout\"" -S packages/rs-platform-wallet-storage/src/sqlite | head -n 50
# Print sqlite/conn.rs or similar where open_conn defined
CONN_FILE=$(rg -n "fn\s+open_conn" -S packages/rs-platform-wallet-storage/src/sqlite | head -n1 | cut -d: -f1)
echo "open_conn impl line: $CONN_FILE"
python3 - <<'PY'
from pathlib import Path
import re
# Locate the file containing fn open_conn
for f in Path("packages/rs-platform-wallet-storage/src/sqlite").rglob("*.rs"):
txt=f.read_text(errors="ignore")
if re.search(r"fn\s+open_conn\s*\(", txt):
target=f; break
else:
raise SystemExit("open_conn file not found")
lines=target.read_text().splitlines()
start=0
for i,l in enumerate(lines):
if "fn open_conn" in l:
start=i
break
end=min(len(lines), start+220)
print(f"---- file: {target} ----")
for j in range(start, end):
print(f"{j+1:4d} {lines[j]}")
PYRepository: dashpay/platform
Length of output: 16546
Clarify busy_timeout intent in this test (it’s connection-local and restore configures its own)
PRAGMA busy_timeout is per-connection, and restore_from_skip_backup/backup::restore_from opens a separate destination lock connection where it sets its own busy_timeout (5s) before BEGIN EXCLUSIVE. So line 83’s busy_timeout only affects how the peer acquires EXCLUSIVE (matching the code comment), not the restore wait; the comment/review should be updated for this scope/intent.
🤖 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_restore_cross_process_exclusion.rs`
around lines 80 - 84, The comment about busy_timeout is misleading: PRAGMA
busy_timeout set on the test peer connection (via ro_conn_rw and
peer.pragma_update(..., "busy_timeout", 50i64)) is connection-local and only
affects how that peer acquires EXCLUSIVE; restore_from_skip_backup /
backup::restore_from open a separate destination lock connection and set their
own busy_timeout (5s) before issuing BEGIN EXCLUSIVE. Update the test comment to
state that the 50ms busy_timeout only governs the peer's acquisition behavior
and does not control the restore path's waiting behavior (which is controlled by
the restore code's own busy_timeout and BEGIN EXCLUSIVE).
…iagram Adds a schema reference document covering all 19 tables introduced by the V001 migration. Includes a Mermaid erDiagram that renders inline on GitHub (https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/creating-diagrams), per-table column lists with PK/FK/type annotations, foreign-key conventions, and the migration version table. No secrets are documented here — SECRETS.md covers the secret-bearing backends.
There was a problem hiding this comment.
Code Review
Correction to the initial body for commit 436c196: all six prior findings from the eadae31 review are STILL VALID at the current head and are carried forward. The latest delta eadae31..436c196 only changes packages/rs-platform-wallet-ffi/src/persistence.rs to use explicit PersistenceError::backend(...) constructors; no new latest-delta defects were found. This correction fixes the reconciliation text only; it does not post a duplicate review.
🟡 3 suggestion(s) | 💬 3 nitpick(s)
Carried-forward prior findings
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:
- [SUGGESTION] lines 396-475: delete_wallet pre-flushes buffered-only state before auto_backup, so a backup failure leaves the wallet materialized on disk instead of buffered-only
Carried-forward prior finding (STILL VALID at 436c1968). delete_wallet_inner still drains the in-memory buffer, applies it in a separate pre_flush_tx, commits that transaction, and only then calls run_auto_backup. If run_auto_backup fails (disabled/missing backup dir, IO, destination conflict, etc.), the outer error path restores the drained changeset to the buffer, but it does not undo the already-committed pre-flush rows. A wallet that existed only in memory before the failed delete therefore becomes materialized on disk even though delete_wallet returns an error. Either defer the pre-flush until the destructive operation can complete atomically, or explicitly roll back/remove the pre-flushed rows when the subsequent backup/delete setup fails.
In `packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:
- [SUGGESTION] lines 1288-1329: delete_wallet concurrent-mutation guard compares only row counts, not row content
Carried-forward prior finding (STILL VALID at 436c1968). wallet_footprint()/wallet_footprint_tx() still reduce each PER_WALLET_TABLES entry to (table_name, COUNT(*)), and delete_wallet_inner compares the pre-backup and post-EXCLUSIVE snapshots. That detects inserted/deleted rows, but not an in-place UPDATE or same-cardinality replacement in tables such as wallet_metadata, core_sync_state, or platform_address_sync. A peer can therefore change wallet content in the backup-to-lock window while preserving row counts; the delete proceeds even though the rollback backup no longer represents the state being destroyed. Include a content hash/revision in the fingerprint, or document the guarantee as row-count-only.
In `packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:
- [SUGGESTION] lines 271-296: Pre-restore auto-backup snapshot is taken before backup::restore_from acquires the destination EXCLUSIVE lock
Carried-forward prior finding (STILL VALID at 436c1968). restore_from_inner opens dest_db_path read-only, page-streams a snapshot via run_auto_backup, drops the read-only handle, and only then calls backup::restore_from, where the destination-side BEGIN EXCLUSIVE is acquired. A cross-process writer can commit during that snapshot-to-lock window; those writes are present in neither the rollback snapshot nor the post-restore database.
In `packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs`:
- [NITPICK] lines 257-282: peek_schema_version uses rusqlite::Connection::open and silently creates a zero-byte file at the user-supplied --db path
Carried-forward prior finding (STILL VALID at 436c1968). peek_schema_version still calls rusqlite::Connection::open(db) before SqlitePersister::open in the migrate path. Connection::open uses create semantics, so a typo or missing DB path can leave a default-permission empty SQLite file behind before the persister owns permissions/error handling.
In `packages/rs-platform-wallet-storage/migrations/V001__initial.rs`:
- [NITPICK] lines 140-148: identity_keys.derivation_blob column is declared but never populated
Carried-forward prior finding (STILL VALID at 436c1968). V001 declares identity_keys.derivation_blob, but schema/identity_keys.rs inserts NULL and updates derivation_blob = NULL, while the decode path reconstructs IdentityKeyWire including derivation_indices from public_key_blob. The column is dead schema from the initial migration.
In `packages/rs-platform-wallet-storage/src/sqlite/backup.rs`:
- [NITPICK] lines 311-348: restore_from unlinks -wal / -shm siblings while the destination EXCLUSIVE-holding connection is still alive
Carried-forward prior finding (STILL VALID at 436c1968). restore_from removes dest_db_path -wal/-shm siblings before step 7 rolls back and drops dest_lock_conn. On Unix the unlinked inodes remain reachable through the live connection handles; on stricter filesystems the remove can fail while SQLite handles are open. Moving sibling cleanup until after drop(conn), still before tmp.persist, preserves the atomicity intent without overlapping a live handle.
Prior finding reconciliation
- Prior #1 — STILL VALID: delete_wallet pre-flushes buffered-only state before auto_backup, so a backup failure leaves the wallet materialized on disk instead of buffered-only — delete_wallet_inner still commits drained buffered state in pre_flush_tx before run_auto_backup, and the backup-failure path restores the buffer but does not undo those committed rows.
- Prior #2 — STILL VALID: delete_wallet concurrent-mutation guard compares only row counts, not row content — wallet_footprint()/wallet_footprint_tx() still use COUNT(*) row counts only; delete_wallet_inner still compares those snapshots.
- Prior #3 — STILL VALID: Pre-restore auto-backup snapshot is taken before backup::restore_from acquires the destination EXCLUSIVE lock — restore_from_inner still runs run_auto_backup() before entering backup::restore_from / BEGIN EXCLUSIVE.
- Prior #4 — STILL VALID: peek_schema_version uses rusqlite::Connection::open and silently creates a zero-byte file at the user-supplied --db path — peek_schema_version still calls rusqlite::Connection::open(db).
- Prior #5 — STILL VALID: identity_keys.derivation_blob column is declared but never populated — V001 still declares derivation_blob while identity_keys::apply writes NULL and decode reads from public_key_blob.
- Prior #6 — STILL VALID: restore_from unlinks -wal / -shm siblings while the destination EXCLUSIVE-holding connection is still alive — backup::restore_from still removes WAL/SHM siblings before dest_lock_conn is dropped/rolled back.
Reviewed commit: 436c1968
| fn restore_from_inner( | ||
| dest_db_path: &Path, | ||
| src_backup: &Path, | ||
| auto_backup_dir: Option<&Path>, | ||
| skip_backup: bool, | ||
| ) -> Result<(), WalletStorageError> { | ||
| if !skip_backup && dest_db_path.exists() { | ||
| let dir = auto_backup_dir.ok_or(WalletStorageError::AutoBackupDisabled { | ||
| operation: AutoBackupOperation::Restore, | ||
| })?; | ||
| // Open the destination read-only just long enough to | ||
| // page-stream a snapshot to disk under auto_backup_dir. | ||
| let dest_conn = crate::sqlite::conn::open_conn( | ||
| dest_db_path, | ||
| crate::sqlite::conn::Access::ReadOnly, | ||
| )?; | ||
| run_auto_backup( | ||
| &dest_conn, | ||
| Some(dir), | ||
| BackupKind::PreRestore, | ||
| AutoBackupOperation::Restore, | ||
| )?; | ||
| drop(dest_conn); | ||
| } | ||
| backup::restore_from(dest_db_path, src_backup) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Pre-restore auto-backup snapshot is taken before backup::restore_from acquires the destination EXCLUSIVE lock
restore_from_inner (lines 271-296) opens dest_db_path read-only at 283-286, page-streams a snapshot via run_auto_backup at 287-292, drops the read-only handle at 293, and only then calls backup::restore_from at line 295 — which is where the destination-side BEGIN EXCLUSIVE is acquired (backup.rs:256). A cross-process writer can commit one or more transactions to the destination during the snapshot-finalize-to-EXCLUSIVE window; those writes are present in neither the rollback snapshot (already finalized) nor the post-restore database (overwritten by the staged source bytes during the atomic rename). The operator who later trusts the pre-restore auto-backup as a non-destructive rollback point silently loses those interleaved peer writes. Acquire the destination EXCLUSIVE first (or an equivalent file lock), then run the snapshot under that lock, then proceed into the staged restore — closing the window without changing the restore_from contract.
source: ['claude', 'codex']
| fn peek_schema_version(db: &Path) -> Result<Option<i64>, rusqlite::Error> { | ||
| use rusqlite::OptionalExtension; | ||
| let conn = rusqlite::Connection::open(db)?; | ||
| // Pre-migration the history table may not exist yet — that is a | ||
| // legitimate "no version" answer, not a failure. | ||
| let has_history = conn | ||
| .query_row( | ||
| "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'refinery_schema_history'", | ||
| [], | ||
| |_| Ok(()), | ||
| ) | ||
| .optional()? | ||
| .is_some(); | ||
| if !has_history { | ||
| return Ok(None); | ||
| } | ||
| let v = conn | ||
| .query_row( | ||
| "SELECT MAX(version) FROM refinery_schema_history", | ||
| [], | ||
| |row| row.get::<_, Option<i64>>(0), | ||
| ) | ||
| .optional()? | ||
| .flatten(); | ||
| Ok(v) | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: peek_schema_version uses rusqlite::Connection::open and silently creates a zero-byte file at the user-supplied --db path
peek_schema_version at line 259 calls rusqlite::Connection::open(db)?, which opens with SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE and therefore creates a fresh zero-byte SQLite file at db if the path didn't exist. It is called from the migrate path at lines 215 and 218 — both before and after SqlitePersister::open — so a typo'd --db argument leaves a default-permission empty file behind even when the persister's own no-create / chmod-600 logic would later refuse. The crate already has crate::sqlite::conn::open_conn(.., Access::ReadOnly) for exactly this read-only probe case. Switch to Connection::open_with_flags(db, SQLITE_OPEN_READ_ONLY | SQLITE_OPEN_URI) (or the existing helpers) so a missing destination is a typed error instead of a silent file-creation side effect.
| fn peek_schema_version(db: &Path) -> Result<Option<i64>, rusqlite::Error> { | |
| use rusqlite::OptionalExtension; | |
| let conn = rusqlite::Connection::open(db)?; | |
| // Pre-migration the history table may not exist yet — that is a | |
| // legitimate "no version" answer, not a failure. | |
| let has_history = conn | |
| .query_row( | |
| "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'refinery_schema_history'", | |
| [], | |
| |_| Ok(()), | |
| ) | |
| .optional()? | |
| .is_some(); | |
| if !has_history { | |
| return Ok(None); | |
| } | |
| let v = conn | |
| .query_row( | |
| "SELECT MAX(version) FROM refinery_schema_history", | |
| [], | |
| |row| row.get::<_, Option<i64>>(0), | |
| ) | |
| .optional()? | |
| .flatten(); | |
| Ok(v) | |
| } | |
| fn peek_schema_version(db: &Path) -> Result<Option<i64>, rusqlite::Error> { | |
| use rusqlite::{OpenFlags, OptionalExtension}; | |
| let conn = rusqlite::Connection::open_with_flags( | |
| db, | |
| OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, | |
| )?; | |
| let has_history = conn | |
| .query_row( | |
| "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'refinery_schema_history'", | |
| [], | |
| |_| Ok(()), | |
| ) | |
| .optional()? | |
| .is_some(); | |
| if !has_history { | |
| return Ok(None); | |
| } | |
| let v = conn | |
| .query_row( | |
| "SELECT MAX(version) FROM refinery_schema_history", | |
| [], | |
| |row| row.get::<_, Option<i64>>(0), | |
| ) | |
| .optional()? | |
| .flatten(); | |
| Ok(v) | |
| } |
source: ['claude', 'codex']
| CREATE TABLE identity_keys ( | ||
| identity_id BLOB NOT NULL, | ||
| key_id INTEGER NOT NULL, | ||
| public_key_blob BLOB NOT NULL, | ||
| public_key_hash BLOB NOT NULL, | ||
| derivation_blob BLOB, | ||
| PRIMARY KEY (identity_id, key_id), | ||
| FOREIGN KEY (identity_id) REFERENCES identities(identity_id) ON DELETE CASCADE | ||
| ); |
There was a problem hiding this comment.
💬 Nitpick: identity_keys.derivation_blob column is declared but never populated
V001 declares identity_keys.derivation_blob BLOB, but the writer in schema/identity_keys.rs always inserts NULL and the ON CONFLICT UPDATE also forces derivation_blob = NULL. decode_entry reconstructs the entire IdentityKeyWire (including derivation_indices) from public_key_blob, and no reader path touches derivation_blob. The column is dead schema from day one: it consumes a column slot in every row, complicates any future migration that wants to use the same name, and may mislead operators or external readers into assuming it carries authoritative data. Either populate it with the derivation-index sub-projection of the wire (and update decode_entry to consume it) or drop it in a follow-up migration — easiest to fix now before V001 ships as the migration baseline.
source: ['claude', 'codex']
| if let Some(file_name) = dest_db_path.file_name() { | ||
| for ext in ["-wal", "-shm"] { | ||
| let mut sibling_name = file_name.to_os_string(); | ||
| sibling_name.push(ext); | ||
| let sibling = dest_db_path.with_file_name(sibling_name); | ||
| match std::fs::remove_file(&sibling) { | ||
| Ok(()) => {} | ||
| Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} | ||
| Err(e) => return Err(WalletStorageError::Io(e)), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // 6. ATOM-010 (A-5): chmod 600 on the temp BEFORE persist so the | ||
| // destination inherits owner-only mode via the atomic rename. | ||
| // Pre-A-5 the chmod ran post-persist — a rare chmod failure | ||
| // returned Err while leaving the new DB live at the destination | ||
| // (caller thought restore rolled back, reality was mixed). | ||
| #[cfg(unix)] | ||
| { | ||
| use std::os::unix::fs::PermissionsExt; | ||
| tmp.as_file() | ||
| .set_permissions(std::fs::Permissions::from_mode(0o600))?; | ||
| } | ||
|
|
||
| // 7. Release the SQLite-native EXCLUSIVE lock BEFORE the rename. | ||
| // Dropping `dest_lock_conn` causes SQLite to close its file | ||
| // handle on the old inode; if we kept it alive across `persist` | ||
| // the handle would point at the unlinked old inode while the | ||
| // new DB took its place — peers reopening would race the rename | ||
| // and (on some filesystems) the rename itself could fail. | ||
| if let Some(conn) = dest_lock_conn.take() { | ||
| // Best-effort rollback of the empty EXCLUSIVE tx; an error here | ||
| // means SQLite is already in trouble and `drop(conn)` covers | ||
| // the rest. Silent because the conn is about to drop anyway. | ||
| let _ = conn.execute_batch("ROLLBACK"); | ||
| drop(conn); | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: restore_from unlinks -wal / -shm siblings while the destination EXCLUSIVE-holding connection is still alive
Step 5 (lines 311-322) calls std::fs::remove_file on dest_db_path's -wal and -shm siblings before step 7 (lines 342-348) rolls back and drops dest_lock_conn. On Unix the unlinked inodes remain reachable through the live connection's file descriptors until the conn is dropped; on stricter filesystems (some Windows / network filesystems) the remove can fail outright while SQLite handles are open. The atomicity rationale in the comment is correct (clear siblings before persist so either both swap or neither does), but holding the lock-bearing connection across the unlink is an ordering wart. Moving sibling cleanup until after drop(conn) (still before tmp.persist) preserves atomicity without overlapping a live handle on the unlinked inodes.
source: ['claude', 'codex']
…agrams, caption trigger-edge Replace the single 19-table erDiagram with four domain-scoped diagrams: - Diagram 1: Core / L1 (accounts, transactions, UTXOs, instant locks, derived addresses, sync state) - Diagram 2: Identities + DashPay (identities, identity_keys, token_balances, dashpay_profiles, dashpay_payments_overlay) - Diagram 3: Contacts (contacts_sent, contacts_recv, contacts_established) - Diagram 4: Platform addresses + asset locks Each diagram repeats WALLET_METADATA as its anchor. Diagram 1 carries a blockquote caption explaining that the CORE_TRANSACTIONS → CORE_UTXOS edge is enforced by the setnull_core_utxos_on_tx_delete trigger, not a declared FOREIGN KEY. Diagram 3 adds a note clarifying that the owner/sender/recipient id columns are application-enforced, not FK- constrained. The per-table prose reference section is unchanged.
There was a problem hiding this comment.
Code Review
Prior finding reconciliation for 03fd3dd: carried forward STILL VALID prior findings: #1:pre-restore-auto-backup-before-exclusive-lock, #2:peek-schema-version-open-creates-file, #4:restore-from-unlinks-wal-shm-while-lock-alive; fixed/outdated prior findings: #3:identity-keys-derivation-blob-dead-schema; intentionally deferred prior findings: none. Latest-delta findings from 436c196..03fd3dd are listed separately when present.
Latest delta (436c196..03fd3dd) is documentation-only (SCHEMA.md); no code changed in that delta. Three prior findings remain STILL VALID at 03fd3dd and are carried forward; the identity_keys.derivation_blob dead-schema prior finding is fixed/outdated on this head. Codex flagged a load() rehydration gap as blocking, but it is explicitly an intentionally-deferred upstream dependency (documented via LOAD_UNIMPLEMENTED, README, structured wallets_pending_rehydration log, dedicated tests) — downgraded to an out-of-scope follow-up. Two additional in-scope suggestions: cumulative delete_wallet footprint check only compares row counts (misses in-place mutations), and two FFI restore decoders accept bincode payloads with trailing bytes.
🟡 4 suggestion(s) | 💬 2 nitpick(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/persister.rs:271-296: Pre-restore auto-backup is taken before backup::restore_from acquires the destination EXCLUSIVE lock
STILL VALID at 03fd3dd3 (carried forward from prior review). `restore_from_inner` opens `dest_db_path` read-only, page-streams a snapshot via `run_auto_backup`, drops the read-only handle, and only then calls `backup::restore_from`, where `BEGIN EXCLUSIVE` is acquired internally. A cross-process writer (sibling process, FFI consumer, another `SqlitePersister`) can commit during the snapshot-drop → EXCLUSIVE window; those writes are neither captured in the rollback snapshot nor preserved after the restore overwrites the destination, silently breaking the safe-by-default contract the docstring advertises. The companion `delete_wallet` path already added a post-EXCLUSIVE footprint re-check to detect this exact race (persister.rs:493-514); restore has no equivalent guard. Either take the snapshot under the same EXCLUSIVE-held connection `backup::restore_from` owns, or add a footprint/hash re-check after EXCLUSIVE and abort with a typed `ConcurrentMutationDuringRestore` if it diverges.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/persister.rs:458-514: delete_wallet's ConcurrentMutationDuringDelete detector only compares row counts, so in-place updates evade the safety check
The new delete hardening snapshots `wallet_footprint` before `run_auto_backup` and re-checks it under EXCLUSIVE (lines 464, 501-514). But `wallet_footprint` / `wallet_footprint_tx` (persister.rs:1288-1325) only record `(table_name, row_count)` per `PER_WALLET_TABLES` row. A peer that updates existing rows in place during the snapshot→EXCLUSIVE window — changing balances, sync heights, blob payloads, nonces — leaves counts unchanged, so the delete proceeds even though the auto-backup is now stale and rollback-from-backup would silently lose those committed updates. The typed `ConcurrentMutationDuringDelete` shield therefore only catches cardinality changes, not content drift. Extend the footprint to a per-table content checksum (e.g. row-hashed `MD5`/`SHA-1` aggregate over the ordered serialized rows) so any peer write during the gap aborts the delete.
In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/persistence.rs:2266-2271: Swift→Rust wallet restore accepts account_xpub_bytes with trailing garbage
`build_wallet_start_state` decodes `WalletRestoreEntryFFI.accounts[*].account_xpub_bytes` with `bincode::decode_from_slice(...)` but discards the returned `consumed` length, so a host row containing a valid `ExtendedPubKey` prefix followed by extra bytes is silently accepted. That weakens corruption/version-drift detection at the host-storage→Rust boundary: Rust reconstructs an account from bytes that are not an exact encoding of the expected payload instead of failing the load and surfacing the mismatch. Reject any decode where `consumed != xpub_bytes.len()` with a typed `PersistenceError::backend("trailing N bytes")`.
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/persistence.rs:2956-2970: Swift→Rust asset-lock restore accepts proof_bytes with trailing garbage
`UnresolvedAssetLockTxRecordFFI.proof_bytes` is decoded with `dpp::bincode::decode_from_slice::<AssetLockProof, _>(...)` and again ignores the returned consumed-byte count, so a malformed or forward-versioned host row carrying a valid `AssetLockProof` prefix plus trailing bytes is treated as a successful restore. Because these rows resume in-flight asset-lock workflows after restart, the boundary should reject non-exact payloads. Mirror the xpub fix: reject when `consumed != proof_bytes.len()`.
Review details
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:
- [SUGGESTION] lines 271-296: Pre-restore auto-backup is taken before backup::restore_from acquires the destination EXCLUSIVE lock
STILL VALID at 03fd3dd3 (carried forward from prior review). `restore_from_inner` opens `dest_db_path` read-only, page-streams a snapshot via `run_auto_backup`, drops the read-only handle, and only then calls `backup::restore_from`, where `BEGIN EXCLUSIVE` is acquired internally. A cross-process writer (sibling process, FFI consumer, another `SqlitePersister`) can commit during the snapshot-drop → EXCLUSIVE window; those writes are neither captured in the rollback snapshot nor preserved after the restore overwrites the destination, silently breaking the safe-by-default contract the docstring advertises. The companion `delete_wallet` path already added a post-EXCLUSIVE footprint re-check to detect this exact race (persister.rs:493-514); restore has no equivalent guard. Either take the snapshot under the same EXCLUSIVE-held connection `backup::restore_from` owns, or add a footprint/hash re-check after EXCLUSIVE and abort with a typed `ConcurrentMutationDuringRestore` if it diverges.
In `packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:
- [SUGGESTION] lines 458-514: delete_wallet's ConcurrentMutationDuringDelete detector only compares row counts, so in-place updates evade the safety check
The new delete hardening snapshots `wallet_footprint` before `run_auto_backup` and re-checks it under EXCLUSIVE (lines 464, 501-514). But `wallet_footprint` / `wallet_footprint_tx` (persister.rs:1288-1325) only record `(table_name, row_count)` per `PER_WALLET_TABLES` row. A peer that updates existing rows in place during the snapshot→EXCLUSIVE window — changing balances, sync heights, blob payloads, nonces — leaves counts unchanged, so the delete proceeds even though the auto-backup is now stale and rollback-from-backup would silently lose those committed updates. The typed `ConcurrentMutationDuringDelete` shield therefore only catches cardinality changes, not content drift. Extend the footprint to a per-table content checksum (e.g. row-hashed `MD5`/`SHA-1` aggregate over the ordered serialized rows) so any peer write during the gap aborts the delete.
In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [SUGGESTION] lines 2266-2271: Swift→Rust wallet restore accepts account_xpub_bytes with trailing garbage
`build_wallet_start_state` decodes `WalletRestoreEntryFFI.accounts[*].account_xpub_bytes` with `bincode::decode_from_slice(...)` but discards the returned `consumed` length, so a host row containing a valid `ExtendedPubKey` prefix followed by extra bytes is silently accepted. That weakens corruption/version-drift detection at the host-storage→Rust boundary: Rust reconstructs an account from bytes that are not an exact encoding of the expected payload instead of failing the load and surfacing the mismatch. Reject any decode where `consumed != xpub_bytes.len()` with a typed `PersistenceError::backend("trailing N bytes")`.
In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [SUGGESTION] lines 2956-2970: Swift→Rust asset-lock restore accepts proof_bytes with trailing garbage
`UnresolvedAssetLockTxRecordFFI.proof_bytes` is decoded with `dpp::bincode::decode_from_slice::<AssetLockProof, _>(...)` and again ignores the returned consumed-byte count, so a malformed or forward-versioned host row carrying a valid `AssetLockProof` prefix plus trailing bytes is treated as a successful restore. Because these rows resume in-flight asset-lock workflows after restart, the boundary should reject non-exact payloads. Mirror the xpub fix: reject when `consumed != proof_bytes.len()`.
In `packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs`:
- [NITPICK] lines 257-282: peek_schema_version uses rusqlite::Connection::open and silently creates a zero-byte file at the user-supplied --db path
STILL VALID at 03fd3dd3 (carried forward; verified line 259 unchanged). `peek_schema_version` calls `rusqlite::Connection::open(db)?`, which opens with `SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE`. It is invoked from the migrate path before `SqlitePersister::open` owns the path, so a typo or failed migration attempt against a non-existent `--db` materialises a fresh, default-permission empty SQLite file at the typo'd location — bypassing the 0o600 invariant the rest of the crate enforces, and on a later retry that stub masquerades as a fresh DB. Open read-only and let a `NotFound` surface as a typed error.
In `packages/rs-platform-wallet-storage/src/sqlite/backup.rs`:
- [NITPICK] lines 311-348: restore_from unlinks -wal / -shm siblings while the destination EXCLUSIVE-holding connection is still alive
STILL VALID at 03fd3dd3 (carried forward). At backup.rs:311-322 (step 5) `std::fs::remove_file` is called on `<db>-wal` and `<db>-shm` before step 7 (lines 342-348) drops `dest_lock_conn`, which still holds the SQLite-native EXCLUSIVE transaction. On Unix the unlinked inodes remain reachable through SQLite's live fds until the conn drops; on stricter platforms (Windows, some FUSE/AV setups) the remove can fail while those handles are open, propagating an io::Error and leaving the destination in a partially-mutated state (some siblings removed, others not, `dest_lock_conn` rolled back). The step-7 comment notes the lock must be released before the rename — extend that to the WAL/SHM cleanup too: drop `dest_lock_conn` first, then unlink siblings, then persist.
Reviewed commit: 03fd3dd3
| fn restore_from_inner( | ||
| dest_db_path: &Path, | ||
| src_backup: &Path, | ||
| auto_backup_dir: Option<&Path>, | ||
| skip_backup: bool, | ||
| ) -> Result<(), WalletStorageError> { | ||
| if !skip_backup && dest_db_path.exists() { | ||
| let dir = auto_backup_dir.ok_or(WalletStorageError::AutoBackupDisabled { | ||
| operation: AutoBackupOperation::Restore, | ||
| })?; | ||
| // Open the destination read-only just long enough to | ||
| // page-stream a snapshot to disk under auto_backup_dir. | ||
| let dest_conn = crate::sqlite::conn::open_conn( | ||
| dest_db_path, | ||
| crate::sqlite::conn::Access::ReadOnly, | ||
| )?; | ||
| run_auto_backup( | ||
| &dest_conn, | ||
| Some(dir), | ||
| BackupKind::PreRestore, | ||
| AutoBackupOperation::Restore, | ||
| )?; | ||
| drop(dest_conn); | ||
| } | ||
| backup::restore_from(dest_db_path, src_backup) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Pre-restore auto-backup is taken before backup::restore_from acquires the destination EXCLUSIVE lock
STILL VALID at 03fd3dd (carried forward from prior review). restore_from_inner opens dest_db_path read-only, page-streams a snapshot via run_auto_backup, drops the read-only handle, and only then calls backup::restore_from, where BEGIN EXCLUSIVE is acquired internally. A cross-process writer (sibling process, FFI consumer, another SqlitePersister) can commit during the snapshot-drop → EXCLUSIVE window; those writes are neither captured in the rollback snapshot nor preserved after the restore overwrites the destination, silently breaking the safe-by-default contract the docstring advertises. The companion delete_wallet path already added a post-EXCLUSIVE footprint re-check to detect this exact race (persister.rs:493-514); restore has no equivalent guard. Either take the snapshot under the same EXCLUSIVE-held connection backup::restore_from owns, or add a footprint/hash re-check after EXCLUSIVE and abort with a typed ConcurrentMutationDuringRestore if it diverges.
source: ['claude', 'codex']
| // Snapshot the wallet's footprint BEFORE auto_backup so | ||
| // the post-EXCLUSIVE re-check has a baseline to compare | ||
| // against. `wallet_footprint` queries every PER_WALLET_TABLES | ||
| // row count; mismatches between pre-backup and post-lock | ||
| // mean a peer mutated the wallet inside the lock-free | ||
| // window the rusqlite Backup API forces us to leave open. | ||
| let pre_backup_footprint = wallet_footprint(&conn, &wallet_id)?; | ||
|
|
||
| let backup_path = if skip_backup { | ||
| None | ||
| } else { | ||
| run_auto_backup( | ||
| &conn, | ||
| self.config.auto_backup_dir.as_deref(), | ||
| BackupKind::PreDelete { wallet_id }, | ||
| AutoBackupOperation::DeleteWallet, | ||
| )? | ||
| }; | ||
|
|
||
| // Test-only hook: fires between the backup snapshot and | ||
| // the cascade EXCLUSIVE so TC-CODE-006-3 can simulate a | ||
| // cross-process peer that mutates `wallet_metadata` in | ||
| // the gap rusqlite's Backup API forces us to leave open. | ||
| #[cfg(any(test, feature = "__test-helpers"))] | ||
| self.consume_post_backup_hook(); | ||
|
|
||
| // SQLite-native EXCLUSIVE for the cascade window. Excludes | ||
| // cross-process peers (other rusqlite Connections, sibling | ||
| // `SqlitePersister`s) that would otherwise commit rows for | ||
| // `wallet_id` between the backup snapshot and the cascade. | ||
| // The in-process mutex on `conn` alone never gave that | ||
| // guarantee. Peers waiting on the lock back off via | ||
| // SQLite's `busy_timeout`. | ||
| let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Exclusive)?; | ||
|
|
||
| // Re-snapshot the wallet's footprint under EXCLUSIVE and | ||
| // compare against the pre-backup snapshot. Any change means | ||
| // a peer mutated the wallet between the backup and the lock | ||
| // acquisition — the backup we just took is now inconsistent | ||
| // with the live state, so rollback-from-backup would | ||
| // silently lose those writes. Abort with the typed | ||
| // `ConcurrentMutationDuringDelete` so the operator can | ||
| // retry after quiescing the peer. | ||
| let post_lock_footprint = wallet_footprint_tx(&tx, &wallet_id)?; | ||
| if post_lock_footprint != pre_backup_footprint { | ||
| tracing::warn!( | ||
| wallet_id = %hex::encode(wallet_id), | ||
| pre_backup = ?pre_backup_footprint, | ||
| post_lock = ?post_lock_footprint, | ||
| "delete_wallet aborted: peer mutated wallet between auto-backup and EXCLUSIVE" | ||
| ); | ||
| // Roll back the empty EXCLUSIVE — no destructive work | ||
| // has happened yet inside this tx, so drop is enough, | ||
| // but be explicit. | ||
| let _ = tx.rollback(); | ||
| return Err(WalletStorageError::ConcurrentMutationDuringDelete { wallet_id }); | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: delete_wallet's ConcurrentMutationDuringDelete detector only compares row counts, so in-place updates evade the safety check
The new delete hardening snapshots wallet_footprint before run_auto_backup and re-checks it under EXCLUSIVE (lines 464, 501-514). But wallet_footprint / wallet_footprint_tx (persister.rs:1288-1325) only record (table_name, row_count) per PER_WALLET_TABLES row. A peer that updates existing rows in place during the snapshot→EXCLUSIVE window — changing balances, sync heights, blob payloads, nonces — leaves counts unchanged, so the delete proceeds even though the auto-backup is now stale and rollback-from-backup would silently lose those committed updates. The typed ConcurrentMutationDuringDelete shield therefore only catches cardinality changes, not content drift. Extend the footprint to a per-table content checksum (e.g. row-hashed MD5/SHA-1 aggregate over the ordered serialized rows) so any peer write during the gap aborts the delete.
source: ['codex']
| let xpub_bytes = | ||
| unsafe { slice_from_raw(spec.account_xpub_bytes, spec.account_xpub_bytes_len) }; | ||
| let (account_xpub, _): (ExtendedPubKey, usize) = | ||
| bincode::decode_from_slice(xpub_bytes, config::standard()) | ||
| .map_err(|e| format!("failed to decode account xpub: {}", e))?; | ||
| bincode::decode_from_slice(xpub_bytes, config::standard()).map_err(|e| { | ||
| PersistenceError::backend(format!("failed to decode account xpub: {}", e)) | ||
| })?; |
There was a problem hiding this comment.
🟡 Suggestion: Swift→Rust wallet restore accepts account_xpub_bytes with trailing garbage
build_wallet_start_state decodes WalletRestoreEntryFFI.accounts[*].account_xpub_bytes with bincode::decode_from_slice(...) but discards the returned consumed length, so a host row containing a valid ExtendedPubKey prefix followed by extra bytes is silently accepted. That weakens corruption/version-drift detection at the host-storage→Rust boundary: Rust reconstructs an account from bytes that are not an exact encoding of the expected payload instead of failing the load and surfacing the mismatch. Reject any decode where consumed != xpub_bytes.len() with a typed PersistenceError::backend("trailing N bytes").
| let xpub_bytes = | |
| unsafe { slice_from_raw(spec.account_xpub_bytes, spec.account_xpub_bytes_len) }; | |
| let (account_xpub, _): (ExtendedPubKey, usize) = | |
| bincode::decode_from_slice(xpub_bytes, config::standard()) | |
| .map_err(|e| format!("failed to decode account xpub: {}", e))?; | |
| bincode::decode_from_slice(xpub_bytes, config::standard()).map_err(|e| { | |
| PersistenceError::backend(format!("failed to decode account xpub: {}", e)) | |
| })?; | |
| let (account_xpub, consumed): (ExtendedPubKey, usize) = | |
| bincode::decode_from_slice(xpub_bytes, config::standard()).map_err(|e| { | |
| PersistenceError::backend(format!("failed to decode account xpub: {}", e)) | |
| })?; | |
| if consumed != xpub_bytes.len() { | |
| return Err(PersistenceError::backend(format!( | |
| "failed to decode account xpub: trailing {} bytes", | |
| xpub_bytes.len() - consumed | |
| ))); | |
| } |
source: ['codex']
| config::standard(), | ||
| ) | ||
| .map_err(|e| { | ||
| PersistenceError::from(format!("tracked asset lock: failed to decode proof: {}", e)) | ||
| PersistenceError::backend(format!( | ||
| "tracked asset lock: failed to decode proof: {}", | ||
| e | ||
| )) | ||
| })?; | ||
| Some(proof) | ||
| }; |
There was a problem hiding this comment.
🟡 Suggestion: Swift→Rust asset-lock restore accepts proof_bytes with trailing garbage
UnresolvedAssetLockTxRecordFFI.proof_bytes is decoded with dpp::bincode::decode_from_slice::<AssetLockProof, _>(...) and again ignores the returned consumed-byte count, so a malformed or forward-versioned host row carrying a valid AssetLockProof prefix plus trailing bytes is treated as a successful restore. Because these rows resume in-flight asset-lock workflows after restart, the boundary should reject non-exact payloads. Mirror the xpub fix: reject when consumed != proof_bytes.len().
| config::standard(), | |
| ) | |
| .map_err(|e| { | |
| PersistenceError::from(format!("tracked asset lock: failed to decode proof: {}", e)) | |
| PersistenceError::backend(format!( | |
| "tracked asset lock: failed to decode proof: {}", | |
| e | |
| )) | |
| })?; | |
| Some(proof) | |
| }; | |
| let (proof, consumed) = dpp::bincode::decode_from_slice::<dpp::prelude::AssetLockProof, _>( | |
| proof_bytes, | |
| config::standard(), | |
| ) | |
| .map_err(|e| { | |
| PersistenceError::backend(format!( | |
| "tracked asset lock: failed to decode proof: {}", | |
| e | |
| )) | |
| })?; | |
| if consumed != proof_bytes.len() { | |
| return Err(PersistenceError::backend(format!( | |
| "tracked asset lock: failed to decode proof: trailing {} bytes", | |
| proof_bytes.len() - consumed | |
| ))); | |
| } |
source: ['codex']
| fn peek_schema_version(db: &Path) -> Result<Option<i64>, rusqlite::Error> { | ||
| use rusqlite::OptionalExtension; | ||
| let conn = rusqlite::Connection::open(db)?; | ||
| // Pre-migration the history table may not exist yet — that is a | ||
| // legitimate "no version" answer, not a failure. | ||
| let has_history = conn | ||
| .query_row( | ||
| "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'refinery_schema_history'", | ||
| [], | ||
| |_| Ok(()), | ||
| ) | ||
| .optional()? | ||
| .is_some(); | ||
| if !has_history { | ||
| return Ok(None); | ||
| } | ||
| let v = conn | ||
| .query_row( | ||
| "SELECT MAX(version) FROM refinery_schema_history", | ||
| [], | ||
| |row| row.get::<_, Option<i64>>(0), | ||
| ) | ||
| .optional()? | ||
| .flatten(); | ||
| Ok(v) | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: peek_schema_version uses rusqlite::Connection::open and silently creates a zero-byte file at the user-supplied --db path
STILL VALID at 03fd3dd (carried forward; verified line 259 unchanged). peek_schema_version calls rusqlite::Connection::open(db)?, which opens with SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE. It is invoked from the migrate path before SqlitePersister::open owns the path, so a typo or failed migration attempt against a non-existent --db materialises a fresh, default-permission empty SQLite file at the typo'd location — bypassing the 0o600 invariant the rest of the crate enforces, and on a later retry that stub masquerades as a fresh DB. Open read-only and let a NotFound surface as a typed error.
| fn peek_schema_version(db: &Path) -> Result<Option<i64>, rusqlite::Error> { | |
| use rusqlite::OptionalExtension; | |
| let conn = rusqlite::Connection::open(db)?; | |
| // Pre-migration the history table may not exist yet — that is a | |
| // legitimate "no version" answer, not a failure. | |
| let has_history = conn | |
| .query_row( | |
| "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'refinery_schema_history'", | |
| [], | |
| |_| Ok(()), | |
| ) | |
| .optional()? | |
| .is_some(); | |
| if !has_history { | |
| return Ok(None); | |
| } | |
| let v = conn | |
| .query_row( | |
| "SELECT MAX(version) FROM refinery_schema_history", | |
| [], | |
| |row| row.get::<_, Option<i64>>(0), | |
| ) | |
| .optional()? | |
| .flatten(); | |
| Ok(v) | |
| } | |
| fn peek_schema_version(db: &Path) -> Result<Option<i64>, rusqlite::Error> { | |
| use rusqlite::{OpenFlags, OptionalExtension}; | |
| let conn = rusqlite::Connection::open_with_flags( | |
| db, | |
| OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, | |
| )?; | |
| // Pre-migration the history table may not exist yet — that is a | |
| // legitimate "no version" answer, not a failure. | |
| let has_history = conn | |
| .query_row( | |
| "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'refinery_schema_history'", | |
| [], | |
| |_| Ok(()), | |
| ) | |
| .optional()? | |
| .is_some(); | |
| if !has_history { | |
| return Ok(None); | |
| } | |
| let v = conn | |
| .query_row( | |
| "SELECT MAX(version) FROM refinery_schema_history", | |
| [], | |
| |row| row.get::<_, Option<i64>>(0), | |
| ) | |
| .optional()? | |
| .flatten(); | |
| Ok(v) | |
| } |
source: ['claude', 'codex']
| if let Some(file_name) = dest_db_path.file_name() { | ||
| for ext in ["-wal", "-shm"] { | ||
| let mut sibling_name = file_name.to_os_string(); | ||
| sibling_name.push(ext); | ||
| let sibling = dest_db_path.with_file_name(sibling_name); | ||
| match std::fs::remove_file(&sibling) { | ||
| Ok(()) => {} | ||
| Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} | ||
| Err(e) => return Err(WalletStorageError::Io(e)), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // 6. ATOM-010 (A-5): chmod 600 on the temp BEFORE persist so the | ||
| // destination inherits owner-only mode via the atomic rename. | ||
| // Pre-A-5 the chmod ran post-persist — a rare chmod failure | ||
| // returned Err while leaving the new DB live at the destination | ||
| // (caller thought restore rolled back, reality was mixed). | ||
| #[cfg(unix)] | ||
| { | ||
| use std::os::unix::fs::PermissionsExt; | ||
| tmp.as_file() | ||
| .set_permissions(std::fs::Permissions::from_mode(0o600))?; | ||
| } | ||
|
|
||
| // 7. Release the SQLite-native EXCLUSIVE lock BEFORE the rename. | ||
| // Dropping `dest_lock_conn` causes SQLite to close its file | ||
| // handle on the old inode; if we kept it alive across `persist` | ||
| // the handle would point at the unlinked old inode while the | ||
| // new DB took its place — peers reopening would race the rename | ||
| // and (on some filesystems) the rename itself could fail. | ||
| if let Some(conn) = dest_lock_conn.take() { | ||
| // Best-effort rollback of the empty EXCLUSIVE tx; an error here | ||
| // means SQLite is already in trouble and `drop(conn)` covers | ||
| // the rest. Silent because the conn is about to drop anyway. | ||
| let _ = conn.execute_batch("ROLLBACK"); | ||
| drop(conn); | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: restore_from unlinks -wal / -shm siblings while the destination EXCLUSIVE-holding connection is still alive
STILL VALID at 03fd3dd (carried forward). At backup.rs:311-322 (step 5) std::fs::remove_file is called on <db>-wal and <db>-shm before step 7 (lines 342-348) drops dest_lock_conn, which still holds the SQLite-native EXCLUSIVE transaction. On Unix the unlinked inodes remain reachable through SQLite's live fds until the conn drops; on stricter platforms (Windows, some FUSE/AV setups) the remove can fail while those handles are open, propagating an io::Error and leaving the destination in a partially-mutated state (some siblings removed, others not, dest_lock_conn rolled back). The step-7 comment notes the lock must be released before the rename — extend that to the WAL/SHM cleanup too: drop dest_lock_conn first, then unlink siblings, then persist.
source: ['claude', 'codex']
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Latest delta 03fd3dd..97713ee is documentation-only — SCHEMA.md was split into four domain-grouped Mermaid diagrams with a refined trigger-edge caption (+98/-33). No Rust, schema, build, or test code changed in this push, so there are no new latest-delta findings. Re-validating the cumulative PR against current HEAD: 8 prior findings remain STILL VALID (3 cross-process locking/atomicity gaps in restore/delete, 2 FFI host→Rust decode trust-boundary gaps that accept trailing bytes, 1 dead-schema column whose misleading caption is preserved by this very delta, 1 CLI peek that silently creates DB files, 1 documented-but-incomplete load() rehydration). Reviewers' claims that other prior findings (in-memory buffer reconcile, forward-version gate, blob trailing-byte/oversize rejection, root-sensitive test) were FIXED are consistent with the cumulative diff and are recorded under resolved_findings. No blockers — all carried-forward issues are suggestions or nitpicks, so review_action is COMMENT.
🟡 1 suggestion(s) | 💬 2 nitpick(s)
5 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/persister.rs:458-514: Carried-forward: delete_wallet's ConcurrentMutationDuringDelete guard fingerprints only (table, row_count), so same-cardinality in-place updates evade detection
STILL VALID at 97713eec — `wallet_footprint` and `wallet_footprint_tx` (persister.rs:1288-1326) reduce each entry in `PER_WALLET_TABLES` to `(table_name, i64 row_count)`, and `delete_wallet_inner` compares the pre-backup and post-EXCLUSIVE vectors with `!=` at line 502. A peer that commits an in-place UPDATE during the backup-to-lock window — `wallet_metadata.label`, `core_sync_state`/`platform_address_sync` cursors, `identities.tombstoned`, any `*_blob` payload, a `token_balances` row — leaves row counts unchanged, the equality check passes, and the cascade proceeds against state the auto-backup artifact does not reflect. The advertised safety net silently loses recently-updated rows on rollback for an entire class of mutation races. Consider a content-aware fingerprint (aggregate hash per table) or a wallet-scoped `change_seq` counter that participates in the comparison.
| // Snapshot the wallet's footprint BEFORE auto_backup so | ||
| // the post-EXCLUSIVE re-check has a baseline to compare | ||
| // against. `wallet_footprint` queries every PER_WALLET_TABLES | ||
| // row count; mismatches between pre-backup and post-lock | ||
| // mean a peer mutated the wallet inside the lock-free | ||
| // window the rusqlite Backup API forces us to leave open. | ||
| let pre_backup_footprint = wallet_footprint(&conn, &wallet_id)?; | ||
|
|
||
| let backup_path = if skip_backup { | ||
| None | ||
| } else { | ||
| run_auto_backup( | ||
| &conn, | ||
| self.config.auto_backup_dir.as_deref(), | ||
| BackupKind::PreDelete { wallet_id }, | ||
| AutoBackupOperation::DeleteWallet, | ||
| )? | ||
| }; | ||
|
|
||
| // Test-only hook: fires between the backup snapshot and | ||
| // the cascade EXCLUSIVE so TC-CODE-006-3 can simulate a | ||
| // cross-process peer that mutates `wallet_metadata` in | ||
| // the gap rusqlite's Backup API forces us to leave open. | ||
| #[cfg(any(test, feature = "__test-helpers"))] | ||
| self.consume_post_backup_hook(); | ||
|
|
||
| // SQLite-native EXCLUSIVE for the cascade window. Excludes | ||
| // cross-process peers (other rusqlite Connections, sibling | ||
| // `SqlitePersister`s) that would otherwise commit rows for | ||
| // `wallet_id` between the backup snapshot and the cascade. | ||
| // The in-process mutex on `conn` alone never gave that | ||
| // guarantee. Peers waiting on the lock back off via | ||
| // SQLite's `busy_timeout`. | ||
| let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Exclusive)?; | ||
|
|
||
| // Re-snapshot the wallet's footprint under EXCLUSIVE and | ||
| // compare against the pre-backup snapshot. Any change means | ||
| // a peer mutated the wallet between the backup and the lock | ||
| // acquisition — the backup we just took is now inconsistent | ||
| // with the live state, so rollback-from-backup would | ||
| // silently lose those writes. Abort with the typed | ||
| // `ConcurrentMutationDuringDelete` so the operator can | ||
| // retry after quiescing the peer. | ||
| let post_lock_footprint = wallet_footprint_tx(&tx, &wallet_id)?; | ||
| if post_lock_footprint != pre_backup_footprint { | ||
| tracing::warn!( | ||
| wallet_id = %hex::encode(wallet_id), | ||
| pre_backup = ?pre_backup_footprint, | ||
| post_lock = ?post_lock_footprint, | ||
| "delete_wallet aborted: peer mutated wallet between auto-backup and EXCLUSIVE" | ||
| ); | ||
| // Roll back the empty EXCLUSIVE — no destructive work | ||
| // has happened yet inside this tx, so drop is enough, | ||
| // but be explicit. | ||
| let _ = tx.rollback(); | ||
| return Err(WalletStorageError::ConcurrentMutationDuringDelete { wallet_id }); | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Carried-forward: delete_wallet's ConcurrentMutationDuringDelete guard fingerprints only (table, row_count), so same-cardinality in-place updates evade detection
STILL VALID at 97713ee — wallet_footprint and wallet_footprint_tx (persister.rs:1288-1326) reduce each entry in PER_WALLET_TABLES to (table_name, i64 row_count), and delete_wallet_inner compares the pre-backup and post-EXCLUSIVE vectors with != at line 502. A peer that commits an in-place UPDATE during the backup-to-lock window — wallet_metadata.label, core_sync_state/platform_address_sync cursors, identities.tombstoned, any *_blob payload, a token_balances row — leaves row counts unchanged, the equality check passes, and the cascade proceeds against state the auto-backup artifact does not reflect. The advertised safety net silently loses recently-updated rows on rollback for an entire class of mutation races. Consider a content-aware fingerprint (aggregate hash per table) or a wallet-scoped change_seq counter that participates in the comparison.
source: ['claude']
| CREATE TABLE identity_keys ( | ||
| identity_id BLOB NOT NULL, | ||
| key_id INTEGER NOT NULL, | ||
| public_key_blob BLOB NOT NULL, | ||
| public_key_hash BLOB NOT NULL, | ||
| derivation_blob BLOB, | ||
| PRIMARY KEY (identity_id, key_id), | ||
| FOREIGN KEY (identity_id) REFERENCES identities(identity_id) ON DELETE CASCADE | ||
| ); |
There was a problem hiding this comment.
💬 Nitpick: Carried-forward: identity_keys.derivation_blob is dead schema, and the new SCHEMA.md split preserves a misleading caption for it
STILL VALID at 97713ee — V001 declares identity_keys.derivation_blob BLOB at line 145, but the writer in src/sqlite/schema/identity_keys.rs unconditionally inserts NULL and the ON CONFLICT(identity_id, key_id) DO UPDATE clause forces derivation_blob = NULL on every upsert. The row decoder reconstructs IdentityKeyWire.derivation_indices from public_key_blob, so no read path ever consults this column. The latest delta (this very push) re-renders SCHEMA.md:124 as BLOB derivation_blob "NULL when derivation indices are absent" inside the new Diagram 2, which implies the column is sometimes non-NULL — directly contradicting the writer and risking a future migration (or an external SQL consumer) treating the column as authoritative. Either drop the column in V002 and revise the SCHEMA.md caption, or revise the caption to state the column is reserved and writers always insert NULL.
source: ['claude']
| fn load(&self) -> Result<ClientStartState, PersistenceError> { | ||
| let conn = self.conn().map_err(PersistenceError::from)?; | ||
| let mut state = ClientStartState::default(); | ||
|
|
||
| let addrs_all = schema::platform_addrs::load_all(&conn).map_err(PersistenceError::from)?; | ||
| let wallets_seen = addrs_all.len(); | ||
| let mut addresses_loaded: usize = 0; | ||
|
|
||
| for (wallet_id, (addrs, count)) in addrs_all { | ||
| if count > 0 | ||
| || addrs.sync_height > 0 | ||
| || addrs.sync_timestamp > 0 | ||
| || addrs.last_known_recent_block > 0 | ||
| { | ||
| addresses_loaded += count; | ||
| state.platform_addresses.insert(wallet_id, addrs); | ||
| } | ||
| } | ||
|
|
||
| tracing::info!( | ||
| wallets_seen, | ||
| addresses_loaded, | ||
| wallets_rehydrated = 0usize, | ||
| wallets_pending_rehydration = wallets_seen, | ||
| unimplemented = ?LOAD_UNIMPLEMENTED, | ||
| "load() summary" | ||
| ); | ||
| Ok(state) | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: Carried-forward: SqlitePersister::load never repopulates ClientStartState.wallets — README headline differs from observed behavior
STILL VALID at 97713ee — load only rebuilds state.platform_addresses and the structured log at lines 1048-1055 explicitly reports wallets_rehydrated = 0usize, unimplemented = LOAD_UNIMPLEMENTED. PlatformWalletManager::load_from_persistor() iterates ClientStartState { wallets, ... } so a process restart under this backend rebuilds zero wallets even though the DB contains all the underlying rows. The PR description acknowledges this as deferred behind an upstream Wallet::from_persisted, and the structured log advertises the gap, but the user-observable behavior is currently inconsistent with the README's headline value proposition ("persist wallet state across restarts"). Two minimal asks: surface the limitation at the top of load()'s rustdoc (not after the query-budget paragraph), and add a tracking-issue link inside the LOAD_UNIMPLEMENTED constant so the deferral cannot silently become permanent.
source: ['claude']
…on TEXT columns Adds SQLite CHECK constraints to the 5 enum-shaped TEXT columns (network, account_type×3, pool_type, status). The CHECK IN lists are generated from `pub(crate) const *_LABELS` arrays in the writer modules, making the writer and constraint share a single source of truth. The in-tree `AssetLockStatus` enum carries a `# Schema coupling` doc-block flagging that variant changes require updating the corresponding writer + LABELS array. The three upstream enums (`key_wallet::Network`, `key_wallet::account::AccountType`, `key_wallet::managed_account::address_pool::AddressPoolType`) live in the external rust-dashcore crate and cannot be edited from here; coupling is instead enforced from the local side via writer rustdoc, exhaustive-match arms in the parity tests (compile-fail on a new variant), and the `*_labels_match_enum` unit tests. See the "Upstream-enum coupling" section in SCHEMA.md. Per-row CHECK cost is ~290ns on 1M-row bulk inserts (verified via sqlite3 benchmark), negligible at wallet workload volumes. Per user direction: V001 edited in place (no V002) since PR is unmerged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Latest delta 97713ee..aa95add is a focused hardening pass: enum-domain CHECK (col IN (...)) constraints on five TEXT columns are interpolated from in-source *_LABELS const arrays at V001 migration time, with exhaustive-match parity tests that compile-fail on upstream enum drift and a tests/sqlite_check_constraints.rs runtime smoke suite. Lowercase labels match the writer codomains, CHECK clauses are sourced from compile-time consts (no SQL-injection surface), and the V001 in-place churn is safe because the crate has no prior released schema. No new defects in the latest delta. Cumulative prior-finding reconciliation against current HEAD aa95add: 8 STILL VALID carried-forward findings remain — FFI xpub/asset-lock-proof trailing-bytes accepted (persistence.rs:2268, :2959), pre-restore snapshot taken before EXCLUSIVE lock (persister.rs:271-296), delete_wallet footprint compares only (table, row_count) (persister.rs:458-514), peek_schema_version opens with default CREATE flags (bin:259), restore_from unlinks -wal/-shm before dropping the EXCLUSIVE-holding conn (backup.rs:311-348), identity_keys.derivation_blob dead schema (V001:169), and load() never rehydrates wallets (persister.rs:1029-1057). Several other prior findings from sibling reviewers were actually FIXED by this delta (enum-domain CHECK constraints, parity tests) or by earlier commits in the branch (native FK constraints, marker-only wallet preservation, UTXO bucketing, identity-key typed/blob cross-check). Action: COMMENT — all remaining findings are suggestions/nitpicks; none blocking.
Note: review_poster suppressed 8 already-posted inline finding(s); posting a top-level current-SHA coverage review because no exact-sha review existed for aa95add.
Reviewed commit: aa95add
🟡 4 suggestion(s) | 💬 4 nitpick(s)
Verified findings
suggestion: [Carried-forward] Swift→Rust wallet-restore xpub decoder accepts trailing bytes
packages/rs-platform-wallet-ffi/src/persistence.rs (line 2266)
STILL VALID at aa95add — line 2268 still binds the consumed-byte count from bincode::decode_from_slice(xpub_bytes, config::standard()) to _. Trailing bytes after a valid ExtendedPubKey prefix are silently accepted, so a host-storage row whose payload is a valid xpub followed by extra bytes (corruption, version drift, an attacker-influenced host store) restores successfully under a (WalletId, account_type, network) tuple the operator did not produce. This is the trust-boundary discipline the storage crate's own blob::decode already enforces (consumed != len rejection); the FFI restore path should match so non-exact decodes surface as PersistenceError::backend(...) instead of resurrecting ambiguous foreign payloads.
let (account_xpub, consumed): (ExtendedPubKey, usize) =
bincode::decode_from_slice(xpub_bytes, config::standard()).map_err(|e| {
PersistenceError::backend(format!("failed to decode account xpub: {}", e))
})?;
if consumed != xpub_bytes.len() {
return Err(PersistenceError::backend(format!(
"failed to decode account xpub: trailing {} bytes",
xpub_bytes.len() - consumed
)));
}
suggestion: [Carried-forward] Swift→Rust asset-lock proof decoder accepts trailing bytes
packages/rs-platform-wallet-ffi/src/persistence.rs (line 2956)
STILL VALID at aa95add — line 2959 decodes proof_bytes via dpp::bincode::decode_from_slice::<AssetLockProof, _> and discards the returned consumed-byte count. Any payload starting with a valid AssetLockProof prefix plus trailing bytes is accepted as a successful restore and replayed into the asset-lock resumption pipeline after restart. Same trust-boundary class as the xpub finding, with higher consequence because these records resume in-flight asset-lock workflows. Apply the consumed != input.len() rejection mirroring blob::decode.
let (proof, consumed) = dpp::bincode::decode_from_slice::<dpp::prelude::AssetLockProof, _>(
proof_bytes,
config::standard(),
)
.map_err(|e| {
PersistenceError::backend(format!(
"tracked asset lock: failed to decode proof: {}",
e
))
})?;
if consumed != proof_bytes.len() {
return Err(PersistenceError::backend(format!(
"tracked asset lock: failed to decode proof: trailing {} bytes",
proof_bytes.len() - consumed
)));
}
Some(proof)
suggestion: [Carried-forward] Pre-restore auto-backup snapshot taken before destination EXCLUSIVE lock
packages/rs-platform-wallet-storage/src/sqlite/persister.rs (line 271)
STILL VALID at aa95add — restore_from_inner is unchanged. The function opens dest_db_path read-only (lines 283-286), page-streams the auto-backup via run_auto_backup, drops the read-only handle at line 293, and only then calls backup::restore_from where the destination-side BEGIN EXCLUSIVE is acquired. A cross-process writer (sibling SqlitePersister, another process holding rusqlite, dash-evo-tool, manual sqlite3) can commit between snapshot completion and EXCLUSIVE acquisition. Those writes are (a) absent from the pre-restore rollback snapshot, and (b) overwritten when the restore copies src_backup over dest_db_path. The companion delete_wallet path was hardened with a post-EXCLUSIVE footprint re-check specifically to close the equivalent window; restore has no analogous mitigation. Either acquire the destination EXCLUSIVE first and snapshot from inside that lock, or document the race as an explicit non-goal of the rollback snapshot.
suggestion: [Carried-forward] delete_wallet footprint fingerprints only (table, row_count) — same-cardinality in-place updates evade detection
packages/rs-platform-wallet-storage/src/sqlite/persister.rs (line 458)
STILL VALID at aa95add — wallet_footprint / wallet_footprint_tx (persister.rs:1288-1326) still reduce each entry in PER_WALLET_TABLES to (table_name, i64 row_count), and delete_wallet_inner compares those vectors with != at line 502. A peer that commits an in-place UPDATE during the backup-to-lock window — wallet_metadata.label, core_sync_state/platform_address_sync cursors, identities.tombstoned, any *_blob payload, a token_balances row — leaves row counts unchanged, the equality check passes, and the cascade proceeds against state the auto-backup artifact does not reflect. The advertised rollback safety net silently loses recently-updated rows for an entire class of mutation races. Consider a content-aware fingerprint (per-table aggregate hash) or a wallet-scoped change_seq counter that participates in the comparison.
nitpick: [Carried-forward] peek_schema_version uses Connection::open, silently creating a zero-byte file at the user-supplied --db path
packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs (line 257)
STILL VALID at aa95add — line 259 still calls rusqlite::Connection::open(db)?, which opens with SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE. peek_schema_version runs from migrate before SqlitePersister::open ever owns the path, so a typo'd --db argument silently materializes a zero-byte SQLite file at default umask (bypassing the chmod 0o600 invariant the rest of the crate enforces). A subsequent retry then opens that stub as a 'fresh DB needing all migrations'. The probe is conceptually read-only; opening with SQLITE_OPEN_READ_ONLY | SQLITE_OPEN_NO_MUTEX and treating a missing file as Ok(None) avoids the unintended mutation.
fn peek_schema_version(db: &Path) -> Result<Option<i64>, rusqlite::Error> {
use rusqlite::{OpenFlags, OptionalExtension};
if !db.exists() {
return Ok(None);
}
let conn = rusqlite::Connection::open_with_flags(
db,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
let has_history = conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'refinery_schema_history'",
[],
|_| Ok(()),
)
.optional()?
.is_some();
if !has_history {
return Ok(None);
}
let v = conn
.query_row(
"SELECT MAX(version) FROM refinery_schema_history",
[],
|row| row.get::<_, Option<i64>>(0),
)
.optional()?
.flatten();
Ok(v)
}
nitpick: [Carried-forward] restore_from unlinks -wal / -shm siblings while the EXCLUSIVE-holding conn is still alive
packages/rs-platform-wallet-storage/src/sqlite/backup.rs (line 311)
STILL VALID at aa95add — step 5 (lines 311-322) still calls std::fs::remove_file on the destination's -wal/-shm siblings before step 7 (lines 342-348) drops dest_lock_conn, which still owns the EXCLUSIVE transaction and the SQLite-native file handles on those siblings. On Unix the unlinked inodes stay reachable through the live fds, so this works; on Windows / some FUSE mounts / antivirus-shimmed filesystems remove_file can fail because SQLite still has the files open. If the unlink errors out, the function returns Err mid-restore with WAL/SHM cleanup in a partial state and the destination DB not yet replaced — violating the atomicity contract the surrounding comments advertise. Dropping dest_lock_conn first (current step 7), then unlinking siblings, then persisting, matches file-handle lifetime to the cleanup step without weakening the cross-process write exclusion (the persist itself remains atomic).
nitpick: [Carried-forward] identity_keys.derivation_blob is dead schema
packages/rs-platform-wallet-storage/migrations/V001__initial.rs (line 164)
STILL VALID at aa95add — V001 still declares identity_keys.derivation_blob BLOB at line 169 (relocated by the CHECK-constraint reshape). The writer in src/sqlite/schema/identity_keys.rs unconditionally inserts NULL, the ON CONFLICT(identity_id, key_id) DO UPDATE clause forces derivation_blob = NULL on every upsert, and the row decoder reconstructs IdentityKeyWire.derivation_indices from public_key_blob — no read path ever consults this column. SCHEMA.md still renders it as BLOB derivation_blob "NULL when derivation indices are absent", which implies the column is sometimes non-NULL, directly contradicting the writer. Either drop the column in V002 and revise the SCHEMA.md caption, or revise the caption to state the column is reserved and writers always insert NULL.
nitpick: [Carried-forward] SqlitePersister::load never repopulates ClientStartState.wallets — surface the limitation prominently
packages/rs-platform-wallet-storage/src/sqlite/persister.rs (line 1029)
STILL VALID at aa95add — load still only rebuilds state.platform_addresses and the structured log at lines 1048-1055 still reports wallets_rehydrated = 0usize, unimplemented = LOAD_UNIMPLEMENTED. PlatformWalletManager::load_from_persistor() iterates ClientStartState { wallets, .. }, so a process restart under this backend rebuilds zero wallets even though the DB contains all the underlying rows. The PR description acknowledges this as deferred behind an upstream Wallet::from_persisted, but the user-observable behavior is inconsistent with the README's headline value proposition ('persist wallet state across restarts'). Two minimal asks: surface the limitation at the top of load()'s rustdoc (not after the query-budget paragraph), and add a tracking-issue link inside the LOAD_UNIMPLEMENTED constant so the deferral cannot silently become permanent.
🤖 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.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/persistence.rs`:2266-2271: [Carried-forward] Swift→Rust wallet-restore xpub decoder accepts trailing bytes
STILL VALID at aa95add3 — line 2268 still binds the consumed-byte count from `bincode::decode_from_slice(xpub_bytes, config::standard())` to `_`. Trailing bytes after a valid `ExtendedPubKey` prefix are silently accepted, so a host-storage row whose payload is a valid xpub followed by extra bytes (corruption, version drift, an attacker-influenced host store) restores successfully under a `(WalletId, account_type, network)` tuple the operator did not produce. This is the trust-boundary discipline the storage crate's own `blob::decode` already enforces (`consumed != len` rejection); the FFI restore path should match so non-exact decodes surface as `PersistenceError::backend(...)` instead of resurrecting ambiguous foreign payloads.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/persistence.rs`:2956-2970: [Carried-forward] Swift→Rust asset-lock proof decoder accepts trailing bytes
STILL VALID at aa95add3 — line 2959 decodes `proof_bytes` via `dpp::bincode::decode_from_slice::<AssetLockProof, _>` and discards the returned consumed-byte count. Any payload starting with a valid `AssetLockProof` prefix plus trailing bytes is accepted as a successful restore and replayed into the asset-lock resumption pipeline after restart. Same trust-boundary class as the xpub finding, with higher consequence because these records resume in-flight asset-lock workflows. Apply the `consumed != input.len()` rejection mirroring `blob::decode`.
- [SUGGESTION] In `packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:271-296: [Carried-forward] Pre-restore auto-backup snapshot taken before destination EXCLUSIVE lock
STILL VALID at aa95add3 — `restore_from_inner` is unchanged. The function opens `dest_db_path` read-only (lines 283-286), page-streams the auto-backup via `run_auto_backup`, drops the read-only handle at line 293, and only then calls `backup::restore_from` where the destination-side `BEGIN EXCLUSIVE` is acquired. A cross-process writer (sibling `SqlitePersister`, another process holding rusqlite, dash-evo-tool, manual `sqlite3`) can commit between snapshot completion and EXCLUSIVE acquisition. Those writes are (a) absent from the pre-restore rollback snapshot, and (b) overwritten when the restore copies `src_backup` over `dest_db_path`. The companion `delete_wallet` path was hardened with a post-EXCLUSIVE footprint re-check specifically to close the equivalent window; restore has no analogous mitigation. Either acquire the destination EXCLUSIVE first and snapshot from inside that lock, or document the race as an explicit non-goal of the rollback snapshot.
- [SUGGESTION] In `packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:458-514: [Carried-forward] delete_wallet footprint fingerprints only (table, row_count) — same-cardinality in-place updates evade detection
STILL VALID at aa95add3 — `wallet_footprint` / `wallet_footprint_tx` (persister.rs:1288-1326) still reduce each entry in `PER_WALLET_TABLES` to `(table_name, i64 row_count)`, and `delete_wallet_inner` compares those vectors with `!=` at line 502. A peer that commits an in-place UPDATE during the backup-to-lock window — `wallet_metadata.label`, `core_sync_state`/`platform_address_sync` cursors, `identities.tombstoned`, any `*_blob` payload, a `token_balances` row — leaves row counts unchanged, the equality check passes, and the cascade proceeds against state the auto-backup artifact does not reflect. The advertised rollback safety net silently loses recently-updated rows for an entire class of mutation races. Consider a content-aware fingerprint (per-table aggregate hash) or a wallet-scoped `change_seq` counter that participates in the comparison.
- [NITPICK] In `packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs`:257-282: [Carried-forward] peek_schema_version uses Connection::open, silently creating a zero-byte file at the user-supplied --db path
STILL VALID at aa95add3 — line 259 still calls `rusqlite::Connection::open(db)?`, which opens with `SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE`. `peek_schema_version` runs from `migrate` before `SqlitePersister::open` ever owns the path, so a typo'd `--db` argument silently materializes a zero-byte SQLite file at default umask (bypassing the `chmod 0o600` invariant the rest of the crate enforces). A subsequent retry then opens that stub as a 'fresh DB needing all migrations'. The probe is conceptually read-only; opening with `SQLITE_OPEN_READ_ONLY | SQLITE_OPEN_NO_MUTEX` and treating a missing file as `Ok(None)` avoids the unintended mutation.
- [NITPICK] In `packages/rs-platform-wallet-storage/src/sqlite/backup.rs`:311-348: [Carried-forward] restore_from unlinks -wal / -shm siblings while the EXCLUSIVE-holding conn is still alive
STILL VALID at aa95add3 — step 5 (lines 311-322) still calls `std::fs::remove_file` on the destination's `-wal`/`-shm` siblings before step 7 (lines 342-348) drops `dest_lock_conn`, which still owns the EXCLUSIVE transaction and the SQLite-native file handles on those siblings. On Unix the unlinked inodes stay reachable through the live fds, so this works; on Windows / some FUSE mounts / antivirus-shimmed filesystems `remove_file` can fail because SQLite still has the files open. If the unlink errors out, the function returns `Err` mid-restore with WAL/SHM cleanup in a partial state and the destination DB not yet replaced — violating the atomicity contract the surrounding comments advertise. Dropping `dest_lock_conn` first (current step 7), then unlinking siblings, then persisting, matches file-handle lifetime to the cleanup step without weakening the cross-process write exclusion (the persist itself remains atomic).
- [NITPICK] In `packages/rs-platform-wallet-storage/migrations/V001__initial.rs`:164-172: [Carried-forward] identity_keys.derivation_blob is dead schema
STILL VALID at aa95add3 — V001 still declares `identity_keys.derivation_blob BLOB` at line 169 (relocated by the CHECK-constraint reshape). The writer in `src/sqlite/schema/identity_keys.rs` unconditionally inserts NULL, the `ON CONFLICT(identity_id, key_id) DO UPDATE` clause forces `derivation_blob = NULL` on every upsert, and the row decoder reconstructs `IdentityKeyWire.derivation_indices` from `public_key_blob` — no read path ever consults this column. SCHEMA.md still renders it as `BLOB derivation_blob "NULL when derivation indices are absent"`, which implies the column is sometimes non-NULL, directly contradicting the writer. Either drop the column in V002 and revise the SCHEMA.md caption, or revise the caption to state the column is reserved and writers always insert NULL.
- [NITPICK] In `packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:1029-1057: [Carried-forward] SqlitePersister::load never repopulates ClientStartState.wallets — surface the limitation prominently
STILL VALID at aa95add3 — `load` still only rebuilds `state.platform_addresses` and the structured log at lines 1048-1055 still reports `wallets_rehydrated = 0usize, unimplemented = LOAD_UNIMPLEMENTED`. `PlatformWalletManager::load_from_persistor()` iterates `ClientStartState { wallets, .. }`, so a process restart under this backend rebuilds zero wallets even though the DB contains all the underlying rows. The PR description acknowledges this as deferred behind an upstream `Wallet::from_persisted`, but the user-observable behavior is inconsistent with the README's headline value proposition ('persist wallet state across restarts'). Two minimal asks: surface the limitation at the top of `load()`'s rustdoc (not after the query-budget paragraph), and add a tracking-issue link inside the `LOAD_UNIMPLEMENTED` constant so the deferral cannot silently become permanent.
SQLite schema reference:
packages/rs-platform-wallet-storage/SCHEMA.md— Mermaid ER diagram + per-table column reference for the 19-table V001 schema (renders inline on GitHub).Issue being fixed or feature implemented
platform-wallet'sPlatformWalletPersistencetrait (packages/rs-platform-wallet/src/changeset/traits.rs:118) shipped with onlyNoPlatformPersistence— a no-op stub. The canonical SQLite implementation lived downstream insidedash-evo-tooland was not reusable by FFI consumers, desktop apps, tests, or CLI tooling. This PR introduces a new workspace crate,platform-wallet-storage, that owns the canonical SQLite persister today and reserves a submodule slot for a futureSecretStore(OS keyring + encrypted file backends) so storage concerns for the wallet stack live under one roof.User story
As a wallet integrator (Rust embedder, FFI consumer, or desktop-app author), I want a ready-made storage crate for
platform-walletso that I can persist wallet state across restarts without writing my own schema, my own migration pipeline, or my own backup/restore tooling — and I want the persister to take an automatic backup before anything destructive so I cannot accidentally lose data on a bad migration or a wrong wallet ID. Later, I want the same crate to offer aSecretStoreabstraction so I do not have to roll my own OS-keyring integration on every platform.What was done?
Two crates touched
platform-wallet(packages/rs-platform-wallet/) — added an optionalserdeCargo feature that gates#[derive(Serialize, Deserialize)]on every changeset type. Feature is off by default; library behaviour unchanged when not activated. Cherry-pickable into its own upstream PR via commite26945cfdf.platform-wallet-storage(packages/rs-platform-wallet-storage/, new) — the actual storage crate. Module layout:Cargo features:
default = ["sqlite", "cli"].--no-default-featuresproduces a bare crate so futuresecrets-only consumers can opt in just to that.Highlights
SqlitePersisterimplementsPlatformWalletPersistence(sync,Send + Sync, object-safe behindArc<dyn …>).tests/secrets_scan.rs), contacts, platform addresses, asset locks, token balances, DashPay overlays, wallet metadata, account registrations, address pools. Single.dbholds many wallets.ON DELETE CASCADE. Composite-key parent-existence + cascade are trigger-emulated (barrel's sqlite3 backend cannot emit compositeFKclauses portably;PRAGMA foreign_keys = ONonly enforces FKs declared inCREATE TABLE).Mutex<HashMap<WalletId, PlatformWalletChangeSet>>merging via upstreamMergeimpls; oneflush()call = one SQLite transaction.FlushMode::{Manual, Immediate}switch +commit_writes()end-of-batch hook.rusqlite::backup::Backup::run_to_completion. Retention bykeep_last_n+max_ageAND-semantics. TOCTOU-safe restore viatempfile::NamedTempFile::new_in(parent_dir)+ atomicpersist.open, pre-delete indelete_wallet. Library returns typedAutoBackupDisabledifauto_backup_dir = None; CLI escape hatch is a separatedelete_wallet_skip_backupsibling method (does NOT mutate config — keeps library safe-by-default).platform-wallet-storagewithbackup,restore,prune,inspect,migrate,delete-walletsubcommands. Destructive ops require--yes.-v/-qwired totracing_subscriber. Unix stream conventions.bincode::serde::encode_to_vecover serde-derived changeset types No hand-rolled binary format. One carve-out: dpp'sIdentityPublicKeyuses#[serde(tag = "$formatVersion")]which bincode-serde rejects, soidentity_keys.rsuses a wire-shape struct that re-encodes that one field via dpp's native bincode 2 derives — one blob per row preserved.SECRETS.md. The reservedsrc/secrets/slot is the only directory exempt from thesecrets_scan.rsforbidden-substring grep.Cargo.tomlmembers,DockerfileCOPY --parentsblocks (3 stages),.github/workflows/tests-rs-workspace.yml(--packagelists in both shards),.github/workflows/pr.ymlallowed-scope (wallet-storage).Commits (strict file boundaries for clean cherry-pick)
0f9437cd44adf421257ccea9ddad4de26945cfdfpackages/rs-platform-wallet/— independently cherry-pickable8e0830626d74acc8152b5bac6e304d540decf6524cfec30375bd4216dbe2f58e78459387f38c0f15f45ab091aef18a1e4b8cbed413e513blob::decodeenforces bincode bytes-consumed == input lengthe2746c3207ENOTDIRfor every UID incl. rootWorkflow
Built end-to-end via the
claudius:workflow-featureskill: Diziet's Requirements + DX spec (26 FRs, 10 NFRs, 15-variant error catalogue) → Marvin's 83-case test specification → Bilby's implementation → parallel Phase-3 QA wave (Marvin / Smythe / Adams / Diziet / Trillian) → Bilby's fix wave (resolved 2 CRITICAL from Adams, 1 HIGH from Diziet, 2 HIGH from Marvin, 3 HIGH from Adams, 4 MEDIUM from Smythe / Trillian / Diziet) → reshape (crate rename + serde swap) → Lessons Learned (18 memories persisted).How Has This Been Tested?
tests/sqlite_delete_buffer_reconcile.rs,tests/sqlite_restore_staged_validation.rs, and theblob::decodetrailing-bytes negative test).--no-default-features) — clean.Coverage gained in the encoder swap (previously deferred per-sub-changeset round-trips, now green): TC-007 (identity keys), TC-009 (platform addresses), TC-010 (asset locks incl. embedded
Transaction), TC-012 (DashPay overlays), TC-014 (account registration with fullExtendedPubKey).Deferred (with rationale, follow-up PRs)
ClientStartState.walletsreconstruction inload()— blocked on a future upstreamWallet::from_persisted. All data IS persisted; only the wallet rehydration step is gated.TODOinpersister.rs::load.lock_conn_for_testtest-helper accessor.InstantLock,Transactiondirect round-trips) — kept onconsensus::Encodablefor those bytes (canonical Bitcoin-style wire format); a future PR may unify on serde if dashcore gains compatible derives.bincode 2.0.1advisory (RUSTSEC-2025-0141, unmaintained) — workspace concern, not crate-specific.key_wallet::Wallet'sWalletType::Mnemonicdoes not zeroize its seed fields on drop. Out of scope; flagged for upstream follow-up.Breaking Changes
None. New crate, opt-in. Existing
NoPlatformPersistenceremains in place and is unchanged. The newplatform-wallet/serdefeature is off by default.Checklist
For repository code-owners and collaborators only
🤖 Co-authored by Claudius the Magnificent AI Agent
Summary by CodeRabbit
New Features
Documentation
Tests