Skip to content

feat(platform-wallet)!: add platform-wallet-storage crate (sqlite persister)#3625

Open
Claudius-Maginificent wants to merge 135 commits into
v3.1-devfrom
feat/platform-wallet-sqlite-persistor
Open

feat(platform-wallet)!: add platform-wallet-storage crate (sqlite persister)#3625
Claudius-Maginificent wants to merge 135 commits into
v3.1-devfrom
feat/platform-wallet-sqlite-persistor

Conversation

@Claudius-Maginificent
Copy link
Copy Markdown
Collaborator

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

Why this PR exists

Wallets on Dash Platform need a durable, local home for their public state — UTXOs, transactions, account registrations, address pools, identities and their public keys, contacts, asset locks, token balances, DashPay overlays, and platform-address sync snapshots — so a client can restart and pick up where it left off without re-scanning the chain from genesis. Today that storage is bespoke per integrator.

This PR adds platform-wallet-storage: a ready-to-use, embeddable SQLite persistence backend for platform-wallet, plus a small set of operational tools around it. Integrators (DET, the iOS/Android SDKs, any host app) get one .db file that holds many wallets, durable across restarts, with online backup, restore, and migration handled for them — and a clear contract that no private-key material is ever written to that file.

What ships:

  • SQLite persister implementing platform-wallet's PlatformWalletPersistence — one database file, many wallets, every per-wallet row keyed by wallet_id. Backups use SQLite's online backup API (safe under a concurrent writer); restores run under BEGIN EXCLUSIVE so peers back off instead of racing the swap.
  • Per-object KV metadata (KvStore / ObjectId) for stashing app-managed metadata (aliases, flags, notes, sync hints) alongside wallet objects, with a no-foreign-key soft cascade so a wallet delete cleans up its metadata even when written ahead of sync.
  • Secret storage (secrets module): an in-house encrypted-file vault (Argon2id + XChaCha20-Poly1305) and an OS-keyring backend behind one SecretStore front door, with zeroizing wrappers and a typed, secret-free error surface. Fully implemented and on by default.
  • Maintenance CLI (platform-wallet-storage): migrate, backup, restore, and prune.

For the full API, schema, and threat model, read the crate docs rather than this description:

  • packages/rs-platform-wallet-storage/README.md — library + CLI usage, flush/load semantics, Cargo features.
  • packages/rs-platform-wallet-storage/SCHEMA.md — the 23-table SQLite schema, cascade triggers, enum-domain checks, orphan-metadata semantics.
  • packages/rs-platform-wallet-storage/SECRETS.md — the private-key boundary and the secrets backends.

What integrators get

  • Durable multi-wallet storage in a single SQLite file, with prepare_cached writers and WAL journaling by default.
  • Fail-hard loading: a corrupt row or a wrong-width wallet_id aborts the whole load() with a typed error — no silent row loss, no partial Ok.
  • Retry-or-restore flush: transient SQLite failures (SQLITE_BUSY / SQLITE_LOCKED) return a retryable error and the buffered changeset is merged back, so a retry is always safe; fatal and constraint failures drop the buffer.
  • Crypto on by default: the secrets graph is in the default feature set so Cargo.lock unconditionally pins the reviewed crypto stack.

Cargo features

default = ["sqlite", "cli", "secrets", "kv"]

Feature Default Brings
sqlite yes the SQLite persister and its native deps
cli yes the platform-wallet-storage maintenance binary (implies sqlite)
secrets yes the secrets module — encrypted-file vault + OS keyring + zeroizing wrappers
kv yes the KvStore / ObjectId metadata API (implies sqlite)

--no-default-features --features sqlite,cli is the persister-only build (no crypto graph).

Breaking changes

This PR reshapes platform-wallet's persistence error surface so a caller can react correctly to a failed write:

pub enum PersistenceErrorKind {
    Transient,   // not committed, buffer preserved — caller MAY retry
    Fatal,       // unrecoverable — caller MUST NOT retry, buffer dropped
    Constraint,  // SQL constraint violation — buffer dropped
}

PersistenceErrorKind is intentionally not #[non_exhaustive]: a future variant must force every consumer match to update explicitly. platform-wallet also gains an opt-in serde feature consumed by the storage crate.

Notes for reviewers

  • The schema is hand-written CREATE TABLE … FOREIGN KEY … SQL applied via refinery on every open; foreign-key enforcement is turned on and read-back-asserted at every connection open.
  • The meta_* metadata tables carry no foreign key (writes may precede the parent object); cleanup is an AFTER DELETE soft cascade keyed on wallet_id / identity_id. Orphan metadata (a parent that never existed or was removed outside the cascade) is an accepted limitation reaped by a future GC pass — see SCHEMA.md.
  • Implementation details (per-commit history, internal step ordering) are deliberately omitted here — the crate docs are the source of truth for behaviour.

🤖 Co-authored by Claudius the Magnificent AI Agent

Summary by CodeRabbit

  • New Features

    • Added SQLite-based wallet storage system with backup and restore capabilities
    • Added encrypted secrets storage with passphrase protection and optional OS keyring support
    • Added metadata key-value storage API scoped by wallet and object type
    • Added CLI maintenance commands for database migration, backup, restore, and cleanup operations
  • Chores

    • Updated CI workflows to include new wallet storage crate in test matrix and builds

lklimek and others added 3 commits May 11, 2026 12:24
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>
@github-actions github-actions Bot added this to the v3.1.0 milestone May 11, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 11, 2026

Review Change Stack

Note

Reviews paused

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

Use the following commands to manage reviews:

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

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a new Rust crate for SQLite-backed wallet storage with migrations, buffer/flush, backup/restore, KV metadata, secrets subsystem, a CLI tool, extensive tests, and updates to workspace, CI, Docker, and serde adapters in platform-wallet.

Changes

SQLite Wallet Storage

Layer / File(s) Summary
All changes (review checkpoint)
packages/rs-platform-wallet-storage/*, packages/rs-platform-wallet/*, .github/*, Cargo.toml, Dockerfile
New wallet-storage crate with SQLite persister, schema, backups/restores, KV, secrets, CLI, tests; integrated into workspace/CI/Docker; platform-wallet adds serde adapters and persistence trait updates.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant User
  participant CLI
  participant Persister
  participant SQLite
  participant FS

  User->>CLI: run migrate/backup/restore/prune
  CLI->>Persister: open(config)
  Persister->>SQLite: enforce PRAGMAs + run migrations
  alt backup
    Persister->>SQLite: online backup copy
    Persister->>FS: persist .db file atomically
  else restore
    Persister->>FS: stage temp copy + integrity/schema checks
    Persister->>SQLite: BEGIN EXCLUSIVE on destination
    Persister->>FS: atomic replace + fsync
  else store/flush
    Persister->>SQLite: tx apply buffered changes
  end
  Persister-->>CLI: reports (Commit/Delete) or errors (kind-classified)
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~180 minutes

Possibly related issues

Suggested labels

ready for final review

Suggested reviewers

  • shumkov
  • QuantumExplorer
  • thepastaclaw

Poem

A bunny with a schema scroll,
Taps out WALs to keep control.
Backups hop, restores are neat,
Flush and prune with tidy feet.
Secrets burrow, bytes asleep—
Wallets wake, their balances keep.
Carrots logged; migrations leap!

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/platform-wallet-sqlite-persistor

@Claudius-Maginificent Claudius-Maginificent changed the title feat(wallet-sqlite): add platform-wallet-sqlite crate feat(platform-wallet): add platform-wallet-sqlite persister crate May 11, 2026
lklimek and others added 3 commits May 11, 2026 14:20
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>
@Claudius-Maginificent Claudius-Maginificent changed the title feat(platform-wallet): add platform-wallet-sqlite persister crate feat(platform-wallet): add platform-wallet-storage crate (sqlite persister) May 11, 2026
lklimek and others added 7 commits May 11, 2026 15:19
…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>
@lklimek
Copy link
Copy Markdown
Contributor

lklimek commented May 11, 2026

@coderabbitai review all

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 11, 2026

@lklimek Sure! I'll kick off a full review of all the changes in this PR right away.

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

🧹 Nitpick comments (11)
packages/rs-platform-wallet-storage/src/sqlite/config.rs (1)

111-118: 💤 Low value

Minor: Simplify empty parent check.

The .filter(|p| !p.as_os_str().is_empty()) check at line 114 is defensive, but Path::parent() should not return an empty OsStr in 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 value

Hardcoded 'testnet' may collide with tests asserting on network.

ensure_wallet_meta always writes network = 'testnet'. Tests that later assert on wallet metadata or persist a WalletMetadataEntry with a different network (e.g., tc023_one_flush_is_one_transaction writes Network::Testnet and would silently match, but other tests writing Mainnet would observe a stale row from this helper). Consider taking network as 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 value

Two if let Cmd::Migrate branches — fold them.

The Migrate command is special-cased twice in succession (lines 219–231 to tweak config / validate flags, then lines 235–244 to actually run it). Folding both into a single if let Cmd::Migrate(m) = &cli.cmd { ... return ... } block (or a run_migrate helper) would keep all of the migrate-specific logic in one place and remove the unreachable!() 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 value

Reject uppercase / odd-length hex consistently.

parse_wallet_id checks s.len() == 64 then defers to hex::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 the wallet id is not valid hex style 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 value

Remove redundant _unused_btreemap dead-code shim.

BTreeMap is now actually used in tc023_one_flush_is_one_transaction (see line 308), so the #[allow(dead_code)] fn _unused_btreemap workaround 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 value

Proptest 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 on core_sync_state for a single wallet, you could hoist persister creation out of the proptest closure and just use a fresh wid per 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_exists writes outside the buffer/flush transaction boundary.

ensure_exists takes a &Connection and runs an immediate INSERT OR IGNORE rather than participating in the in-memory merge buffer + per-flush transaction model used by apply. This is fine for the documented use case (tests poking the DB before exercising identity_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 win

Consider widening SafeCastTarget to cover u32-bound writes.

Several call sites (e.g., asset_locks::list_active, core_state::list_unspent_utxos) need to fail when an i64 won't fit in u32 and currently report SafeCastTarget::U64, which is wrong. Adding a U32 variant here (and a small i64_to_u32 helper) 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 value

Comment references inject_custom but the code appends raw DDL.

The comment claims constraints/indexes are layered "via inject_custom", but the implementation just concatenates DDL after m.make::<Sqlite>(). Either switch to barrel's inject_custom API or update the comment to reflect what's actually happening so future maintainers don't go looking for an inject_custom call 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 value

Unused _cols parameter in parent_check closure.

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 than wallet_id (which would let you fold the bespoke identity_keys / dashpay_profiles triggers 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::U64 is misleading for a u32 overflow.

The cast here is i64 → u32, but the surfaced target is SafeCastTarget::U64. Operators reading the error will see "u64" and assume the value didn't fit in u64, when in fact it didn't fit in u32. Consider adding a U32 variant to SafeCastTarget (and a corresponding i64_to_u32 helper in safe_cast) so the error type accurately describes the target, and to avoid repeating this try_from pattern at every reader. This same issue recurs in core_state.rs for core_utxos.height and core_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

📥 Commits

Reviewing files that changed from the base of the PR and between 3b9fe6b and d7a88a9.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (55)
  • .github/workflows/pr.yml
  • .github/workflows/tests-rs-workspace.yml
  • Cargo.toml
  • Dockerfile
  • packages/rs-platform-wallet-storage/Cargo.toml
  • packages/rs-platform-wallet-storage/README.md
  • packages/rs-platform-wallet-storage/SECRETS.md
  • packages/rs-platform-wallet-storage/build.rs
  • packages/rs-platform-wallet-storage/migrations/V001__initial.rs
  • packages/rs-platform-wallet-storage/migrations/V002__defensive_update_triggers.rs
  • packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs
  • packages/rs-platform-wallet-storage/src/lib.rs
  • packages/rs-platform-wallet-storage/src/sqlite/backup.rs
  • packages/rs-platform-wallet-storage/src/sqlite/buffer.rs
  • packages/rs-platform-wallet-storage/src/sqlite/config.rs
  • packages/rs-platform-wallet-storage/src/sqlite/error.rs
  • packages/rs-platform-wallet-storage/src/sqlite/migrations.rs
  • packages/rs-platform-wallet-storage/src/sqlite/mod.rs
  • packages/rs-platform-wallet-storage/src/sqlite/persister.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/token_balances.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/wallet_meta.rs
  • packages/rs-platform-wallet-storage/src/sqlite/util/mod.rs
  • packages/rs-platform-wallet-storage/src/sqlite/util/safe_cast.rs
  • packages/rs-platform-wallet-storage/tests/common/mod.rs
  • packages/rs-platform-wallet-storage/tests/secrets_scan.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_backup_restore.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_buffer_semantics.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_cli_smoke.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs
  • packages/rs-platform-wallet/Cargo.toml
  • packages/rs-platform-wallet/src/changeset/changeset.rs
  • packages/rs-platform-wallet/src/changeset/mod.rs
  • packages/rs-platform-wallet/src/changeset/serde_adapters.rs
  • packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs
  • packages/rs-platform-wallet/src/wallet/identity/types/block_time.rs
  • packages/rs-platform-wallet/src/wallet/identity/types/dashpay/contact_request.rs
  • packages/rs-platform-wallet/src/wallet/identity/types/dashpay/established_contact.rs
  • packages/rs-platform-wallet/src/wallet/identity/types/dashpay/payment.rs
  • packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs
  • packages/rs-platform-wallet/src/wallet/identity/types/key_storage.rs

Comment thread packages/rs-platform-wallet-storage/src/sqlite/backup.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/sqlite/persister.rs
Comment thread packages/rs-platform-wallet-storage/src/sqlite/persister.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/sqlite/schema/wallet_meta.rs Outdated
Comment thread packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs Outdated
lklimek and others added 2 commits May 13, 2026 13:42
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>
@Claudius-Maginificent
Copy link
Copy Markdown
Collaborator Author

SEC-011 fix landed (f6e90d1fca)

Out-of-band security audit (by an automated agent surveying the sqlite persister's secret-handling posture) flagged that the restore path applies chmod 0o600 to the staged SQLite file but the initial DB creation and backup creation paths don't — files land at the process umask, which on shared systems could be world-readable.

The audit classified this as MEDIUM under the umbrella "SEC-011: missing explicit 0o600 on initial DB + backup creation".

Fix shape: extracted a small apply_secure_permissions(&Path) -> Result<(), WalletStorageError> helper in a new packages/rs-platform-wallet-storage/src/sqlite/util/permissions.rs (Unix-gated via #[cfg(unix)], mode 0o600, propagates I/O errors through the existing WalletStorageError: #[from] std::io::Error). Called at all three sites:

  • persister.rs:108 after Connection::open(&config.path) — initial DB
  • backup.rs:48 after Connection::open(dest) in run_to — backup
  • backup.rs restore_from — refactored from the existing inline pattern to call the helper

Net change: 4 files, +35/-7 lines. cargo fmt, cargo check --tests, cargo clippy --tests -D warnings, and cargo test --lib (10/10) all clean.

Companion privacy finding flagged in the same audit pass (SEC-012: accounts.account_xpub_bytes enables tx-graph reconstruction if the SQLite file is exfiltrated) — that's a documentation/policy decision rather than a code fix. Best handled in a follow-up SECRETS.md update noting the privacy trade-off explicitly.

🤖 Co-authored by Claudius the Magnificent AI Agent

Claudius-Maginificent and others added 3 commits May 18, 2026 19:40
…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>
lklimek and others added 20 commits June 3, 2026 18:33
…ollow-ups

Add a flush rustdoc TODO noting wallet-less/global sentinel-scope saving is
not yet expressible through flush and that hosts must use per-wallet flush
in place of the removed commit_writes. Flag list_wallets and delete_wallet
as deferred cross-backend contract candidates. In the FFI persister, note
the deferred transient-retry/trailing-byte handling and the deferred dedup
of the per-account reconstruction shared with the SQLite backend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The read-only handle (backup source, restore probe, CLI peek) passed
`SQLITE_OPEN_URI`, which makes SQLite parse the path as a `file:` URI and
honour embedded query parameters. The crate never builds such URIs, so a
future caller forwarding an externally-derived path would silently
inherit URI semantics and could, in principle, smuggle `?mode=rwc` to
defeat the read-only intent. The read-write path never set the flag;
align the read-only path by dropping it too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-id hex parsing

- crypto::seal maps an encrypt-path failure to KdfFailure instead of the
  Decrypt variant; Decrypt is a decryption concept and was semantically
  inverted on the encrypt path (it only fires when plaintext exceeds the
  AEAD length limit, so no behaviour change in practice).
- decode_wallet_id_hex and parse_service now share a single
  wallet_id_hex_to_bytes helper that enforces the canonical form
  WalletId::to_hex emits (64 lowercase hex chars). The on-disk outer-key
  check previously accepted uppercase while the SPI parse rejected it;
  both seams now agree. Adds a unit test for the on-disk path.
- Strip transient review-finding IDs and orphan spec-ID prefixes from all
  secrets comments/rustdoc; the rationale prose carries the why, and the
  permanent CWE/RUSTSEC/issue references stay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the review-finding ID prefixes from backup.rs comments and rustdoc;
the rationale prose already carries the why. No behaviour change. The
other non-core sqlite util/config/buffer files carried no such IDs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- SCHEMA.md: rewrite the cascade narrative and trigger set to the
  wallet-rooted soft cascade — deleting a wallet brooms every meta_*
  row by wallet_id / identity_id, parentless rows included and
  independent of any contact's lifecycle state; meta_global survives.
- README: add a KV metadata section (ObjectId scopes, KvStore methods,
  no-FK soft-cascade contract, usage example) and the missing kv
  feature row; correct the trigger description (one setnull + five
  meta_* soft-cascade triggers); drop the removed inspect subcommand
  from the CLI synopsis; describe load_all's grouped-scan shape.
- SECRETS.md: rename the Busy error variant to AlreadyLocked (3 spots),
  add VaultTooLarge to the lossless variant list and its SPI group,
  document the Argon2 m/t ceilings, drop the transient PR reference,
  remove the unused link-reference definitions.
- Strip orphan spec-ID prefixes and reviewer annotations across the
  three docs; the prose carries the meaning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… on load

load_state and load_all now rebuild PerWalletPlatformAddressState from the
platform_payment account registrations (xpub per account index) plus the
platform_addresses rows, instead of leaving per_account at its default.
load_all collapses to a fixed set of grouped scans (sync, addresses,
registrations) driven by wallet_meta::list_ids, so cost is constant w.r.t.
wallet count rather than a per-wallet fan-out. load() omits a wallet that
carries no platform state at all (no registrations, no addresses, zero
watermarks). Adds a registrations reader to the accounts module.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…the manual sync

put now rejects a value larger than MAX_VALUE_LEN before the INSERT, so a
write can never plant a row a later get would refuse to materialise; the
KvStore::put contract documents the requirement. Soften the MAX_VALUE_LEN
rustdoc to state it is kept in sync with the blob-module ceiling manually,
since the sqlite and kv features compile independently and a const-level
cross-reference can't be relied on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…o the persister API

Demote the schema and migrations modules to pub(crate) in production builds
(widened to pub only under the __test-helpers feature so integration tests
can still drive the per-area readers/writers) and make buffer pub(crate)
unconditionally. Gate the test-only readers and stub-row helpers behind
__test-helpers so the production build carries no dead code, splitting the
imports those readers need behind the same gate. The identities fetch reader
now also returns the tombstoned flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…close the chmod-by-path window

Bring the SQLite DB path to parity with the secrets-vault file path: before
rusqlite opens the database, pre-create it owner-only (0600) with O_EXCL so
the file is born at 0600 (no umask window between create and chmod) and an
attacker-planted symlink at the path makes the create fail rather than
redirect the write. The post-open chmod still re-tightens the live DB and
sweeps the WAL/SHM sidecars.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e obsolete tests

Add a cascade-completeness test that seeds a wallet with one row per
FK-bearing table plus metadata in every scope (including parentless rows
and non-established contacts), deletes the wallet, and asserts zero rows
survive in any per-wallet or wallet/identity-scoped meta table while
meta_global persists — the replacement for the removed per-table row-count
fingerprint. Add ensure_contact_sent/ensure_contact_received helpers. Make
the buffer-merge property test use Manual flush + one explicit flush so it
genuinely exercises the buffer merge. Remove the backup-failure
buffer-restore test (root-dependent) and the redundant feature-flag and
secrets-off compile-check tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ource comments

Replace the ephemeral review-finding IDs (CMT-/CODE-/SEC-/ATOM-/TC-/NFR-/FR-
/D-/N- etc.) carried in source comments and rustdoc with plain prose. These
IDs are reassigned on every triage run and become dead references after
merge. Also refreshes the load() query-budget doc to describe the grouped
constant-query path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… test comments

Replace the ephemeral review-finding IDs carried in the comments of the
test files touched by this change with plain prose, and tidy the doc
fragments left behind. Test function names that encode a test-case ID are
left as-is for this batch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…an wording, rename hardening test

Drop the last transient review IDs from the trait flush rustdoc and tidy the
test module headers left terse by the strip. Reword two new schema comments
so they no longer trip the secret-substring guard, extend the
prepare_cached allowlist with the new grouped bulk readers, and rename the
structural-hardening test file to drop the embedded PR number.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ate-agnostic Contact doc

Add an INTENTIONAL comment on assert_identities_belong_to_wallet noting the
diagnostic-only zero-pad of a wrong-width stored wallet_id is cosmetic with no
security impact. Reword ObjectId::Contact rustdoc to be lifecycle-state-agnostic,
matching the unified contact cascade.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ta-cascade scope

SECRETS.md documented a deleted test file (secrets_off_state.rs). Rewrite
the audit-hooks entry to describe the test that exists,
secrets_default_on_compiles.rs — a build-time guard that the default
feature set exposes the secrets surface as public re-exports. Note the
negative off-state direction is enforced by the feature gate plus the CI
off-state build, not a test file.

SCHEMA.md overstated that a wallet delete cleans ALL meta even without a
typed parent. That is true only for wallet_id-scoped meta
(meta_wallet/meta_contact/meta_platform_address), which the wallet broom
keys on wallet_id. Identity-scoped meta (meta_identity/meta_token) carries
no wallet_id and is only reached when its identity row is deleted, so
metadata written for an identity_id never synced into identities is NOT
wallet-reachable. State this limitation honestly in the intro and the
per-table notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-account reconstruction test asserted only that the account key
existed, leaving account_state_from_rows' body uncovered — a bug that
dropped address rows or mismatched the account index would pass green.

Assert the reconstructed PerAccountPlatformAddressState: the decoded
xpub matches the registration, both seeded addresses land in the
index<->address bimap, and the funds map mirrors what each entry seeded.

Add read-only accessors (addresses, extended_public_key; promote found
to pub) on PerAccountPlatformAddressState so the storage crate's
integration test can inspect the reconstructed contents.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
delete_wallet_leaves_no_surviving_rows asserted whole-table COUNT(*)==0
with a single wallet present, so it could not distinguish a cascade
scoped to the deleted wallet from one that deletes everything.

Seed a second wallet with its own per-wallet, identity-owned, contact,
platform-address, and meta rows. Delete only the first wallet, then scope
every count by wallet_id / identity_id: the first wallet's rows must be
gone AND the second wallet's rows (and meta_global) must survive. This
closes the over-deletion blind spot in the cascade-completeness test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the remaining ephemeral review-finding IDs that the earlier strip
pass missed — they go stale after the next consolidation and become dead
breadcrumbs. Replace each tag with the rationale prose it annotated;
where a comment also carried historical narrative, rephrase to
present-state.

Touched comments/strings only (no test function names): ATOM-NNN in
backup.rs atomic-write helpers, the EDIT-2 reviewer label in
secrets_guard.rs (rustdoc + panic string), and residual
CMT-/CODE-/TC-CODE-/EDIT IDs across the untouched-by-batch test files.
Permitted refs (CWE/SEC-REQ/#issue) are kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…imitation with future GC

Generalise the narrowly-scoped identity-only caveat into a clear
"Orphan metadata and future garbage collection" subsection in SCHEMA.md
that covers all meta_* types and scopes.  Add a matching concise note
to the ObjectId / KvStore rustdoc in src/kv.rs, and a pointer sentence
in README.md — so callers at every entry point learn that parentless
meta rows may persist until a planned GC pass removes them.

No code, schema, or trigger changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Claudius-Maginificent Claudius-Maginificent changed the title feat(platform-wallet)!: add platform-wallet-storage crate (sqlite persister) feat(wallet-storage)!: add platform-wallet-storage crate (sqlite persister) Jun 3, 2026
@Claudius-Maginificent
Copy link
Copy Markdown
Collaborator Author

Review-triage fix batch applied (this push)

Following a full grumpy-review + PR-comment verification, this push lands the triaged fixes. Highlights for reviewers:

  • Metadata cascade is now state-agnostic + parentless-complete. meta_* stays foreign-key-free; wallet deletion cleans dependent metadata via two AFTER DELETE brooms (wallet-rooted by wallet_id, identity-rooted by identity_id). Fixes the established-only meta_contact gate and the parentless meta_token / meta_contact / meta_platform_address leaks; adds pending→established contact promotion.
  • All per-table row counting removed (the delete_wallet receipt, the inspect CLI sub-command, count_rows_for_wallet_sql, PER_WALLET_TABLES) → replaced by a cascade-completeness test (asserts zero surviving rows after delete, with a second-wallet survival check).
  • PlatformWalletPersistence trait slimmed to { store, flush, load, get_core_tx_record }; delete_wallet / commit_writes are now inherent SqlitePersister methods, and the report types moved to sqlite::reports.
  • Platform-address per_account reconstructed in the storage layer on load, from account_registrations (platform-payment xpubs) + platform_addresses, via a fixed set of grouped queries (no per-wallet N+1).
  • Hardening: DB file created 0600 with O_EXCL (closes the chmod-by-path / symlink window); SQLITE_OPEN_URI dropped from the read-only open; bincode / getrandom exact-pinned.

Accepted limitation worth flagging

Orphan metadata — any meta_* row whose parent never existed or fell outside the cascade (e.g. metadata written for an identity that is never synced into identities, which therefore has no wallet linkage) — can persist. This is an accepted limitation across all metadata types, documented in SCHEMA.md, to be reaped by a future garbage-collection pass (no live parent, ~1 week old). Not implemented in this PR by design.

Deferred (out of scope here)

  • FFI transient-retry classification + trailing-byte handling (FFI is not touched in this PR).
  • assert_schema_version_supported → bool rename (kept the typed error — better contract).
  • From/TryFrom conversion sugar for ManagedIdentity / IdentityKeyWire (signatures don't fit a single-arg conversion).

Gate at the pushed tip: cargo fmt, clippy under both --all-features and --no-default-features --features sqlite,cli (-D warnings), cargo test -p platform-wallet-storage, and cargo check -p platform-wallet-ffi — all green.

🤖 Co-authored by Claudius the Magnificent AI Agent

@Claudius-Maginificent Claudius-Maginificent changed the title feat(wallet-storage)!: add platform-wallet-storage crate (sqlite persister) feat(platform-wallet)!: add platform-wallet-storage crate (sqlite persister) Jun 3, 2026
@lklimek
Copy link
Copy Markdown
Contributor

lklimek commented Jun 3, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 3, 2026

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@lklimek lklimek requested a review from Copilot June 3, 2026 19:34
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Cumulative review at head 6f1e3e4. Latest delta (0466241..6f1e3e4, ~7.1k lines) closes every previously blocking storage-layer finding: meta_* cascade is now wallet-rooted via AFTER DELETE triggers keyed on wallet_id/identity_id alone (migrations/V001__initial.rs:332-385), KvStore::put gained a symmetric MAX_VALUE_LEN guard (kv.rs:186-196), the unified contacts ON CONFLICT clause auto-promotes a row to 'established' when the opposite blob is present (schema/contacts.rs:120-180), and the delete_wallet pre-flight semantics around orphan metadata are now codified as an accepted limitation reaped by a future GC pass (SCHEMA.md). Of the three remaining items Codex re-raised at this head, all are real but explicitly deferred in-source: the sentinel-scope flush gap is documented in traits.rs:236-241, and the FFI flush Fatal-classification and FFI trailing-byte decode gaps are both covered by the TODO at persistence.rs:1263-1267 ('no behavior change in this change'). These sit outside the storage-package scope of the latest delta and the maintainer has drawn an explicit scope boundary; they should be tracked as follow-ups, not block this PR. No new latest-delta findings.

_Note: Inline posting failed (command failed (1): python3 scripts/review_poster.py dashpay/platform 3625 6f1e3e4 --dry-run
STDOUT:

STDERR:
Traceback (most recent call last):
File "/Users/claw/.openclaw/workspace/scripts/review_poster.py", line 854, in
result = post_review(
File ), so I posted the same verified findings as a top-level review body._

Reviewed commit: 6f1e3e4

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

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

⚠️ Outside diff range comments (1)
packages/rs-platform-wallet-storage/src/secrets/file/mod.rs (1)

565-569: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Refuse special files before open() can block on them.

O_NOFOLLOW only stops final symlinks. On Unix, a pre-created FIFO at the vault path will block this read-only open() before the later metadata/size checks run, so a local attacker can hang EncryptedFileStore::open() indefinitely in a shared directory. Open the file nonblocking and reject anything that's not a regular file before reading it.

Suggested hardening
 #[cfg(unix)]
 fn open_no_follow(path: &Path) -> std::io::Result<fs::File> {
     use std::os::unix::fs::OpenOptionsExt;
-    fs::OpenOptions::new()
+    let file = fs::OpenOptions::new()
         .read(true)
-        .custom_flags(libc::O_NOFOLLOW)
-        .open(path)
+        .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
+        .open(path)?;
+    if !file.metadata()?.is_file() {
+        return Err(std::io::Error::new(
+            std::io::ErrorKind::InvalidInput,
+            "vault path must be a regular file",
+        ));
+    }
+    Ok(file)
 }

Also applies to: 1032-1042

🤖 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/secrets/file/mod.rs` around lines 565
- 569, The current open_no_follow(path) call can block if the target is a FIFO
or other special file; change the open path in EncryptedFileStore::open (and the
similar block at the other occurrence around lines 1032-1042) to open the file
with nonblocking flags (e.g. include O_NONBLOCK on Unix) and immediately fstat
the opened descriptor to verify it's a regular file (metadata.is_file() /
S_IFREG) before any read; if the file is not a regular file, close and return
Err(SecretStoreError::io_at(...)) or Ok(None as appropriate to reject it, and
ensure symlink/no-follow semantics are still preserved. Ensure the
implementation uses the same open_no_follow semantics but adds O_NONBLOCK and
performs an immediate metadata regular-file check to avoid blocking on FIFOs or
special files.
♻️ Duplicate comments (1)
packages/rs-platform-wallet-storage/src/sqlite/persister.rs (1)

403-415: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

delete_wallet() still rejects wallets whose only durable state is parentless metadata.

This preflight only checks wallet_metadata, but sqlite/kv.rs explicitly allows meta_wallet, meta_contact, and meta_platform_address rows to exist before the parent row does. That leaves a persisted metadata-only wallet undeletable: delete_wallet() returns WalletNotFound even though state for that wallet_id is on disk.

Please widen the existence gate to include the wallet-scoped meta_* tables as well.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet-storage/src/sqlite/persister.rs` around lines 403
- 415, The preflight in delete_wallet() only checks wallet_metadata so it
erroneously treats wallets that only have parentless meta_* rows as
non-existent; update the existence check (the conn.query_row call that sets
exists_pre_flush) to also consider meta_wallet, meta_contact, and
meta_platform_address rows for the same wallet_id (e.g., a single SQL that
queries wallet_metadata OR any of the meta_* tables, or run additional query_row
checks and OR their results) so that if any of those tables contain rows for
wallet_id the function treats the wallet as existing and does not return
WalletStorageError::WalletNotFound.
🧹 Nitpick comments (1)
packages/rs-platform-wallet-storage/tests/sqlite_delete_wallet.rs (1)

45-55: ⚡ Quick win

This regression still misses the parent-row resurrection path.

The worker only races CoreChangeSet, so after delete_wallet() any blocked flush can only try to write core_sync_state, which the FK rejects once wallet_metadata is gone. The concurrency bug in delete_wallet_inner is the case where the racing store carries wallet_metadata itself, because that path can recreate the parent row after the delete commits.

Please add a second racing store that includes wallet_metadata so this test covers the actual resurrection case.

Also applies to: 69-90

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet-storage/tests/sqlite_delete_wallet.rs` around
lines 45 - 55, The test only races a persister.store that writes CoreChangeSet,
missing the resurrection path where a concurrent store also includes
wallet_metadata and can recreate the parent row after delete_wallet_inner
commits; update the test to add a second racing store invocation (also using
persister.store) that supplies a PlatformWalletChangeSet whose wallet_metadata
is set (e.g., set cs.wallet_metadata = Some(...) or construct a change set that
includes the wallet metadata fields) alongside the existing CoreChangeSet loop
so one racing writer only writes core while the other writes wallet_metadata,
ensuring the delete vs. resurrection path is exercised; apply the same addition
to the other occurrence referenced (lines 69-90).
🤖 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/SCHEMA.md`:
- Line 10: The markdown jumps from body text directly to an h3; change the
heading "Orphan metadata and future garbage collection" from "### Orphan
metadata and future garbage collection" to a level-2 heading (prepend it with
"##") so the heading levels are sequential and satisfy markdownlint MD001;
update the single heading line in SCHEMA.md (the line containing that exact
heading text) accordingly.

In `@packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:
- Around line 372-377: Race between Immediate-mode store() and delete_wallet()
allows a store to write into the in-memory buffer and be drained by
flush_inner() before acquiring self.conn(), letting a racing flush recreate the
wallet after delete commits; add a wallet-scoped deletion guard (e.g., a
per-wallet enum/state or AtomicBool referenced by wallet id) that
delete_wallet() sets to "deleting/deleted" before draining and committing and
that both store() and flush_inner() check: store() must fail fast or drop any
wallet_metadata changes if the guard is set, and flush_inner()/take_for_flush
must discard any pending changes for wallets marked deleting/deleted (and avoid
acquiring conn for them), so no flush can escape the post-commit discard window
— update code paths around store(), flush_inner(), take_for_flush, and
delete_wallet() to consult this guard consistently.

In `@packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs`:
- Around line 21-32: The decoder must validate the blob's variant and account
metadata before returning: in decode_platform_payment_row, after
blob::decode(xpub_bytes) and obtaining an AccountRegistrationEntry, match that
enum and ensure it is the PlatformPayment (or PlatformPaymentRegistration)
variant and that any embedded account_index/account_type inside the decoded
entry matches the account_index parameter; if it does not, return a
WalletStorageError (a data-integrity/invalid-row error) instead of silently
returning the xpub. Ensure you reference AccountRegistrationEntry, the
PlatformPayment variant, decode_platform_payment_row, and WalletStorageError
when implementing the checks.

In `@packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs`:
- Around line 121-131: When decoding an identity row, do not trust the decoded
IdentityEntry blindly—after calling blob::decode(...) verify that the decoded
entry's identity_id matches the requested identity_id parameter; if they differ,
return an error (fail hard) indicating a corrupt row rather than hydrating the
wrong identity. Update the two read sites that call blob::decode (the block that
returns Some((blob::decode(&payload)?, ...)) around the shown snippet and the
similar block at lines 164–170) to perform this identity_id check against the
incoming identity_id and propagate a descriptive error when mismatched.

---

Outside diff comments:
In `@packages/rs-platform-wallet-storage/src/secrets/file/mod.rs`:
- Around line 565-569: The current open_no_follow(path) call can block if the
target is a FIFO or other special file; change the open path in
EncryptedFileStore::open (and the similar block at the other occurrence around
lines 1032-1042) to open the file with nonblocking flags (e.g. include
O_NONBLOCK on Unix) and immediately fstat the opened descriptor to verify it's a
regular file (metadata.is_file() / S_IFREG) before any read; if the file is not
a regular file, close and return Err(SecretStoreError::io_at(...)) or Ok(None as
appropriate to reject it, and ensure symlink/no-follow semantics are still
preserved. Ensure the implementation uses the same open_no_follow semantics but
adds O_NONBLOCK and performs an immediate metadata regular-file check to avoid
blocking on FIFOs or special files.

---

Duplicate comments:
In `@packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:
- Around line 403-415: The preflight in delete_wallet() only checks
wallet_metadata so it erroneously treats wallets that only have parentless
meta_* rows as non-existent; update the existence check (the conn.query_row call
that sets exists_pre_flush) to also consider meta_wallet, meta_contact, and
meta_platform_address rows for the same wallet_id (e.g., a single SQL that
queries wallet_metadata OR any of the meta_* tables, or run additional query_row
checks and OR their results) so that if any of those tables contain rows for
wallet_id the function treats the wallet as existing and does not return
WalletStorageError::WalletNotFound.

---

Nitpick comments:
In `@packages/rs-platform-wallet-storage/tests/sqlite_delete_wallet.rs`:
- Around line 45-55: The test only races a persister.store that writes
CoreChangeSet, missing the resurrection path where a concurrent store also
includes wallet_metadata and can recreate the parent row after
delete_wallet_inner commits; update the test to add a second racing store
invocation (also using persister.store) that supplies a PlatformWalletChangeSet
whose wallet_metadata is set (e.g., set cs.wallet_metadata = Some(...) or
construct a change set that includes the wallet metadata fields) alongside the
existing CoreChangeSet loop so one racing writer only writes core while the
other writes wallet_metadata, ensuring the delete vs. resurrection path is
exercised; apply the same addition to the other occurrence referenced (lines
69-90).
🪄 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: 9a48c77c-6ae5-4af8-933e-a3c9efe29542

📥 Commits

Reviewing files that changed from the base of the PR and between 3166090 and 6f1e3e4.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (65)
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet-storage/Cargo.toml
  • packages/rs-platform-wallet-storage/README.md
  • packages/rs-platform-wallet-storage/SCHEMA.md
  • packages/rs-platform-wallet-storage/SECRETS.md
  • packages/rs-platform-wallet-storage/migrations/V001__initial.rs
  • packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs
  • packages/rs-platform-wallet-storage/src/kv.rs
  • packages/rs-platform-wallet-storage/src/lib.rs
  • packages/rs-platform-wallet-storage/src/secrets/error.rs
  • packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs
  • packages/rs-platform-wallet-storage/src/secrets/file/format.rs
  • packages/rs-platform-wallet-storage/src/secrets/file/mod.rs
  • packages/rs-platform-wallet-storage/src/secrets/keyring.rs
  • packages/rs-platform-wallet-storage/src/secrets/mod.rs
  • packages/rs-platform-wallet-storage/src/secrets/secret.rs
  • packages/rs-platform-wallet-storage/src/secrets/store.rs
  • packages/rs-platform-wallet-storage/src/secrets/validate.rs
  • packages/rs-platform-wallet-storage/src/sqlite/backup.rs
  • packages/rs-platform-wallet-storage/src/sqlite/conn.rs
  • packages/rs-platform-wallet-storage/src/sqlite/error.rs
  • packages/rs-platform-wallet-storage/src/sqlite/kv.rs
  • packages/rs-platform-wallet-storage/src/sqlite/migrations.rs
  • packages/rs-platform-wallet-storage/src/sqlite/mod.rs
  • packages/rs-platform-wallet-storage/src/sqlite/persister.rs
  • packages/rs-platform-wallet-storage/src/sqlite/reports.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/wallet_meta.rs
  • packages/rs-platform-wallet-storage/src/sqlite/util/permissions.rs
  • packages/rs-platform-wallet-storage/tests/common/mod.rs
  • packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs
  • packages/rs-platform-wallet-storage/tests/secrets_api.rs
  • packages/rs-platform-wallet-storage/tests/secrets_guard.rs
  • packages/rs-platform-wallet-storage/tests/secrets_scan.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_backup_restore.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_buffer_semantics.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_cli_smoke.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_delete_buffer_reconcile.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_delete_cross_process_exclusion.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_delete_wallet.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_open_integrity_check.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_open_version_gate.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_permissions.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_restore_cross_process_exclusion.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_restore_staged_validation.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_trait_dispatch.rs
  • packages/rs-platform-wallet/src/changeset/mod.rs
  • packages/rs-platform-wallet/src/changeset/traits.rs
  • packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs
✅ Files skipped from review due to trivial changes (1)
  • packages/rs-platform-wallet-storage/SECRETS.md
🚧 Files skipped from review as they are similar to previous changes (32)
  • packages/rs-platform-wallet-storage/tests/sqlite_delete_cross_process_exclusion.rs
  • packages/rs-platform-wallet-storage/src/lib.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs
  • packages/rs-platform-wallet-storage/src/secrets/mod.rs
  • packages/rs-platform-wallet-storage/src/secrets/keyring.rs
  • packages/rs-platform-wallet-storage/src/secrets/validate.rs
  • packages/rs-platform-wallet-storage/src/secrets/secret.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_delete_buffer_reconcile.rs
  • packages/rs-platform-wallet-storage/tests/common/mod.rs
  • packages/rs-platform-wallet-storage/src/secrets/store.rs
  • packages/rs-platform-wallet-storage/migrations/V001__initial.rs
  • packages/rs-platform-wallet-storage/tests/secrets_scan.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs
  • packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs
  • packages/rs-platform-wallet-storage/src/secrets/file/format.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs
  • packages/rs-platform-wallet-storage/tests/secrets_api.rs
  • packages/rs-platform-wallet-storage/tests/secrets_guard.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/wallet_meta.rs
  • packages/rs-platform-wallet-storage/src/kv.rs
  • packages/rs-platform-wallet-storage/src/sqlite/migrations.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_backup_restore.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs
  • packages/rs-platform-wallet-storage/src/secrets/error.rs
  • packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet-storage/src/sqlite/backup.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_buffer_semantics.rs

- **`wallet_id`-scoped meta** (`meta_wallet`, `meta_contact`, `meta_platform_address`) carries a `wallet_id` column, so `cascade_meta_on_wallet_delete` brooms it directly — regardless of the lifecycle state of any typed parent and even for rows written ahead of (or without) a typed parent.
- **identity-scoped meta** (`meta_identity`, `meta_token`) carries no `wallet_id` — only `identity_id` (+ `token_id`). It is cleaned by `cascade_meta_on_identity_delete` (AFTER DELETE ON `identities`), which fires for the wallet's own identities when the FK cascade removes them on a wallet delete.

### Orphan metadata and future garbage collection
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix the heading-level jump here.

### Orphan metadata and future garbage collection skips straight to an h3 after body text, which is what markdownlint is flagging with MD001. Make this a ## heading (or add the missing intermediate heading) so docs lint stays clean.

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 10-10: Heading levels should only increment by one level at a time
Expected: h2; Actual: h3

(MD001, heading-increment)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet-storage/SCHEMA.md` at line 10, The markdown jumps
from body text directly to an h3; change the heading "Orphan metadata and future
garbage collection" from "### Orphan metadata and future garbage collection" to
a level-2 heading (prepend it with "##") so the heading levels are sequential
and satisfy markdownlint MD001; update the single heading line in SCHEMA.md (the
line containing that exact heading text) accordingly.

Comment on lines +372 to +377
// Acquire the connection mutex FIRST so concurrent in-process
// `store()` calls block on it. Cross-process peers (other
// rusqlite Connections / sibling `SqlitePersister`s) are excluded
// by `BEGIN EXCLUSIVE` below — the in-process mutex alone never
// gave that guarantee.
let mut conn = self.conn()?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Immediate-mode store() can still recreate a wallet after delete_wallet() returns.

The delete path only re-drains the buffer, but store() writes into the buffer before it needs conn, and flush_inner() removes that changeset from the buffer before blocking on self.conn(). A racing Immediate-mode store that includes wallet_metadata can therefore become invisible to the final take_for_flush, wait behind the delete’s connection lock, and then recreate the wallet right after the delete commits. The current test only races core_sync_state, so it misses this because the FK rejects child-only writes once the parent row is gone.

This needs a wallet-scoped “delete in progress/deleted” guard that both store() and flush_inner() consult, or equivalent synchronization that prevents a racing flush from escaping the post-commit discard window.

Also applies to: 493-500, 604-620, 837-842

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet-storage/src/sqlite/persister.rs` around lines 372
- 377, Race between Immediate-mode store() and delete_wallet() allows a store to
write into the in-memory buffer and be drained by flush_inner() before acquiring
self.conn(), letting a racing flush recreate the wallet after delete commits;
add a wallet-scoped deletion guard (e.g., a per-wallet enum/state or AtomicBool
referenced by wallet id) that delete_wallet() sets to "deleting/deleted" before
draining and committing and that both store() and flush_inner() check: store()
must fail fast or drop any wallet_metadata changes if the guard is set, and
flush_inner()/take_for_flush must discard any pending changes for wallets marked
deleting/deleted (and avoid acquiring conn for them), so no flush can escape the
post-commit discard window — update code paths around store(), flush_inner(),
take_for_flush, and delete_wallet() to consult this guard consistently.

Comment on lines +21 to +32
fn decode_platform_payment_row(
account_index: i64,
xpub_bytes: &[u8],
) -> Result<PlatformPaymentRegistration, WalletStorageError> {
let account_index =
u32::try_from(account_index).map_err(|_| WalletStorageError::IntegerOverflow {
field: "account_registrations.account_index",
value: account_index as u64,
target: crate::sqlite::util::safe_cast::SafeCastTarget::U64,
})?;
let entry: AccountRegistrationEntry = blob::decode(xpub_bytes)?;
Ok((account_index, entry.account_xpub))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail hard when account_xpub_bytes disagrees with the typed registration columns.

This decoder trusts the row’s typed account_type/account_index and never checks that the blob itself is a PlatformPayment entry for the same account. A torn or manually-corrupted row will therefore seed platform_addrs::load_all() with the wrong xpub instead of aborting load. Validate the decoded variant/account before returning it.

💡 Minimal fix
 fn decode_platform_payment_row(
     account_index: i64,
     xpub_bytes: &[u8],
 ) -> Result<PlatformPaymentRegistration, WalletStorageError> {
     let account_index =
         u32::try_from(account_index).map_err(|_| WalletStorageError::IntegerOverflow {
             field: "account_registrations.account_index",
             value: account_index as u64,
             target: crate::sqlite::util::safe_cast::SafeCastTarget::U64,
         })?;
     let entry: AccountRegistrationEntry = blob::decode(xpub_bytes)?;
-    Ok((account_index, entry.account_xpub))
+    match entry {
+        AccountRegistrationEntry {
+            account_type: key_wallet::account::AccountType::PlatformPayment { account, .. },
+            account_xpub,
+            ..
+        } if account == account_index => Ok((account_index, account_xpub)),
+        _ => Err(WalletStorageError::blob_decode(
+            "account_registrations row disagrees with typed platform_payment columns",
+        )),
+    }
 }
📝 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.

Suggested change
fn decode_platform_payment_row(
account_index: i64,
xpub_bytes: &[u8],
) -> Result<PlatformPaymentRegistration, WalletStorageError> {
let account_index =
u32::try_from(account_index).map_err(|_| WalletStorageError::IntegerOverflow {
field: "account_registrations.account_index",
value: account_index as u64,
target: crate::sqlite::util::safe_cast::SafeCastTarget::U64,
})?;
let entry: AccountRegistrationEntry = blob::decode(xpub_bytes)?;
Ok((account_index, entry.account_xpub))
fn decode_platform_payment_row(
account_index: i64,
xpub_bytes: &[u8],
) -> Result<PlatformPaymentRegistration, WalletStorageError> {
let account_index =
u32::try_from(account_index).map_err(|_| WalletStorageError::IntegerOverflow {
field: "account_registrations.account_index",
value: account_index as u64,
target: crate::sqlite::util::safe_cast::SafeCastTarget::U64,
})?;
let entry: AccountRegistrationEntry = blob::decode(xpub_bytes)?;
match entry {
AccountRegistrationEntry {
account_type: key_wallet::account::AccountType::PlatformPayment { account, .. },
account_xpub,
..
} if account == account_index => Ok((account_index, account_xpub)),
_ => Err(WalletStorageError::blob_decode(
"account_registrations row disagrees with typed platform_payment columns",
)),
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs` around
lines 21 - 32, The decoder must validate the blob's variant and account metadata
before returning: in decode_platform_payment_row, after blob::decode(xpub_bytes)
and obtaining an AccountRegistrationEntry, match that enum and ensure it is the
PlatformPayment (or PlatformPaymentRegistration) variant and that any embedded
account_index/account_type inside the decoded entry matches the account_index
parameter; if it does not, return a WalletStorageError (a
data-integrity/invalid-row error) instead of silently returning the xpub. Ensure
you reference AccountRegistrationEntry, the PlatformPayment variant,
decode_platform_payment_row, and WalletStorageError when implementing the
checks.

Comment on lines +121 to +131
let row: Option<(Vec<u8>, i64)> = conn
.query_row(
"SELECT entry_blob, tombstoned FROM identities \
WHERE identity_id = ?1 AND wallet_id IS ?2",
params![&identity_id[..], wallet_id_param],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?;
match row {
None => Ok(None),
Some((payload, tombstoned)) => Ok(Some((blob::decode(&payload)?, tombstoned != 0))),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail hard when identity_id and entry_blob disagree.

apply() now prevents new mismatches, but both readers still trust the decoded IdentityEntry blindly. A legacy row written before the Line 49 guard—or any manually corrupted row—will hydrate the wrong identity instead of tripping the documented corrupt-row fail-hard behavior.

Proposed fix
-    let row: Option<(Vec<u8>, i64)> = conn
+    let row: Option<(Vec<u8>, Vec<u8>, i64)> = conn
         .query_row(
-            "SELECT entry_blob, tombstoned FROM identities \
+            "SELECT identity_id, entry_blob, tombstoned FROM identities \
              WHERE identity_id = ?1 AND wallet_id IS ?2",
             params![&identity_id[..], wallet_id_param],
-            |row| Ok((row.get(0)?, row.get(1)?)),
+            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
         )
         .optional()?;
     match row {
         None => Ok(None),
-        Some((payload, tombstoned)) => Ok(Some((blob::decode(&payload)?, tombstoned != 0))),
+        Some((typed_id, payload, tombstoned)) => {
+            let entry: IdentityEntry = blob::decode(&payload)?;
+            if entry.id.as_slice() != typed_id.as_slice() {
+                return Err(WalletStorageError::IdentityEntryIdMismatch);
+            }
+            Ok(Some((entry, tombstoned != 0)))
+        }
     }
@@
-        let _identity_id: Vec<u8> = row.get(0)?;
+        let typed_id: Vec<u8> = row.get(0)?;
         let payload: Vec<u8> = row.get(1)?;
         let tombstoned: i64 = row.get(2)?;
         if tombstoned != 0 {
             continue;
         }
         let entry: IdentityEntry = blob::decode(&payload)?;
+        if entry.id.as_slice() != typed_id.as_slice() {
+            return Err(WalletStorageError::IdentityEntryIdMismatch);
+        }
         let managed = managed_identity_from_entry(&entry, wallet_id);

Also applies to: 164-170

🤖 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 121 - 131, When decoding an identity row, do not trust the decoded
IdentityEntry blindly—after calling blob::decode(...) verify that the decoded
entry's identity_id matches the requested identity_id parameter; if they differ,
return an error (fail hard) indicating a corrupt row rather than hydrating the
wrong identity. Update the two read sites that call blob::decode (the block that
returns Some((blob::decode(&payload)?, ...)) around the shown snippet and the
similar block at lines 164–170) to perform this identity_id check against the
incoming identity_id and propagate a descriptive error when mismatched.

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

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

8 participants