Skip to content

feat(platform-wallet-storage): snapshot readers + unified V002 schema migration#3986

Open
Claudius-Maginificent wants to merge 20 commits into
feat/platform-wallet-storage-rehydrationfrom
feat/3968-snapshot-redirect
Open

feat(platform-wallet-storage): snapshot readers + unified V002 schema migration#3986
Claudius-Maginificent wants to merge 20 commits into
feat/platform-wallet-storage-rehydrationfrom
feat/3968-snapshot-redirect

Conversation

@Claudius-Maginificent

@Claudius-Maginificent Claudius-Maginificent commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Issue being fixed or feature implemented

Redirects #3968 (stacked on its head branch): the persisted store cannot yet serve a verbatim snapshot at load. Core address pools have no per-index rows (used_core_addresses is re-derived at read time), every UTXO is attributed to hardcoded account_index 0, and there is no per-domain versioning or store-generation identity — blocking verbatim rehydration (the live e2e deep-derivation-window restore blocker) and any client cache-invalidation strategy.

What was done?

One additive V002 migration (V001 untouched — refinery checksums make it immutable) plus writers and snapshot readers:

  • core_address_pool: per-index pool rows with monotonic used flags. Primary key is (wallet_id, account_type, account_index, key_class, pool_type, address_index)account_type is included so distinct account kinds that share the same numeric (account_index, key_class) (e.g. IdentityRegistration, ProviderVotingKeys, or Standard/CoinJoin both at index 0) never collide; pool_type keeps External/Internal from colliding; a script column supports verbatim reads and UTXO attribution. Persisted idempotently per changeset.
  • Real account_index: per-UTXO account attribution via pool-row script match (deterministic tie-break: ORDER BY account_type, account_index, key_class, pool_type, address_index when multiple rows share a script); hardcoded CORE_UTXO_ACCOUNT_INDEX = 0 deleted.
  • meta_data_versions (wallet_id, domain, seq): saturating per-domain bump executed inside the single flush transaction — atomic with the data it versions. Exhaustive changeset destructure (re-armed via a pass-through shielded feature) makes adding a changeset field without wiring its domain a compile error.
  • meta_store_generation: 16-byte store-generation token. Rotation is folded into the pre-swap staged file during restore, so the atomic rename swaps in the restored bytes and the fresh token together — no window where restored content is observable under a stale token.
  • Snapshot reader: used_core_addresses read verbatim from pool rows, UNIONED with the UTXO-derived set (mixed/pre-pool stores keep historical used addresses). Both core_pool and core_state variable-width blob readers gate on a cheap pre-read length check before materializing, bounding allocation on a corrupt/oversize column.
  • Schema freeze guards: content-level SQL fingerprint (the built-in refinery fingerprint is content-blind) + retired-name grep guard.
  • Migration safety: pre-migration auto-backup (verified byte-faithful and restorable); forward-version rejection (SchemaVersionUnsupported); interrupted-migration recovery proven against a committed populated-V001 fixture.

Deferred by decision (design preserved in the WS-B plan §7): manifest MAC/authentication — the trust boundary stays open and tracked as the headline follow-up.

How Has This Been Tested?

  • Migration-execution suite against a populated V001 fixture: data preservation, backup restorability, deterministic re-migration, forward-version rejection, idempotent re-entry, interrupted-migration recovery (real rolled-back partial-DDL artifact, byte-equal reconvergence), empty-store path.
  • Cross-wallet cascade/isolation tests for all new V002 tables; mixed-store union-reader test; two-wallet reader isolation.
  • Cross-account-type collision regression: two distinct AccountTypes sharing (account_index, key_class) now persist as independent pool rows (covers both the identity/provider-key family and Standard/CoinJoin at index 0).
  • Blob-size-gate regression tests for both load_used_addresses implementations (core_pool, core_state).
  • Store-generation atomicity regression: restore leaves no stale -wal/-shm siblings and the rotated token is observable immediately after the swap.
  • Full platform-wallet-storage suite: 60 test binaries, 0 failures; platform-wallet lib 216/216; platform-wallet-ffi suite green; fmt + clippy -D warnings clean across all three crates.
  • Independent adversarial QA: two review cycles, all findings (4 MEDIUM in the first pass, 4 further findings — one CRITICAL PK-collision bug plus 3 MEDIUM — in the second) fixed and re-verified; final verdict PASS, 0 findings remaining.

Breaking Changes

None. V001 is byte-identical; V002 is additive; stores migrate forward automatically with a pre-migration backup. No FFI or SkipReason/CorruptKind shape changes.

Checklist:

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

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

Attribution

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 16 commits July 2, 2026 15:31
Snapshot a realistic multi-wallet store — one fully-populated wallet
(metadata, BIP44 registration, a tx record, an unspent UTXO at the
hardcoded account_index=0, an identity, a contact) plus one bare
registered-but-never-synced wallet — built by the current V001-only
persister and captured via a checkpointed backup_to().

The committed .db is the regression anchor for the migration-execution
suites: once V002 lands, a populated V001-only store is no longer
reproducible from source. Ships an #[ignore] regenerator plus an
always-run guard asserting the fixture opens at schema version 1 with
the seeded rows intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
Additive V002 leaves V001 byte-identical so refinery's version-1 checksum
never diverges on existing stores; max_supported_version() ticks 1->2 from
the derived embedded list. V002 lands three tables in one migration event:

- core_address_pool: per-index pool rows with a `used` flag, PK
  (wallet_id, account_index, key_class, pool_type, address_index). pool_type
  is in the key so External/Internal pools never collide at the same index;
  `address` is stored so the reader returns used addresses verbatim.
- meta_data_versions: monotonic per-(wallet_id, domain) seq, no FK (soft
  cascade trigger reaps on wallet delete), matching the meta_* pattern.
- meta_store_generation: single-row token seeded via randomblob(16) so the
  rendered SQL stays deterministic while the value is unique per store.

No MAC column — manifest authentication is deferred (dev-plan §7).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…ore them

The B1 commit inadvertently staged the SQLite -wal/-shm side-files a test
produced when opening the committed fixture. Remove them and ignore the
pattern so future test runs never re-stage transient WAL state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…mes (WS-B B2)

Add embedded_migrations_sql_fingerprint() — SHA-256 over each migration's
rendered SQL body — closing the documented content-blind gap in the
identity-only fingerprint. Pin both fingerprints as golden constants so an
in-place DDL edit (a silent table rename under the D0 freeze) breaks CI
instead of slipping through.

Add the retired-name grep guard (TC-B-041): no migration/writer/reader/
backup SQL may reference wallet_metadata, account_address_pools, or
core_derived_addresses. The source scan matches only SQL-keyword-led table
usage so the legitimate `cs.wallet_metadata` / `cs.account_address_pools`
Rust fields are never false-flagged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…WS-B B3)

Reverse the "account_address_pools intentionally NOT applied" no-op: expand
each pool snapshot into per-index core_address_pool rows (idempotent upsert,
monotonic `used` via MAX so a used address never reverts — the reuse-guard
invariant). Delete the CORE_UTXO_ACCOUNT_INDEX=0 constant and attribute each
UTXO to its owning account by matching the outpoint's script against a pool
row, falling back to account 0 only when no pool row covers it (the one-way
historical default, R7). Pools are applied before core in the single flush
tx so attribution reads freshly-written rows.

Refines V002: the pool column stores the reconstructable `script` (renamed
from `address`) with a lookup index; B1/B2 goldens updated to match. V002 is
new in this PR, so editing its DDL is safe — no shipped store carries it.

Covers TC-B-001/002/010/015 plus the unattributed-UTXO fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…sh tx (WS-B B4)

Add the Domain enum (one variant per persisted changeset field) and bump
each touched domain's seq inside apply_changeset_to_tx's single transaction,
so a domain's cache-invalidation marker commits atomically with its data
(TC-B-011) and a partial-failure flush rolls back both (TC-B-012). The bump
saturates at i64::MAX and never wraps — a wrap would look like a cache
rollback and reintroduce staleness (TC-B-014).

touched_domains destructures the changeset exhaustively, so a newly added
field is a compile error until it is assigned a domain (R8 keystone), and
populated_field_count now derives from it — one source of truth. Every
domain maps to exactly its own bump, none silently excluded (TC-B-013).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…-B B5)

Add the generation getter and rotate the meta_store_generation token in
restore_from after the atomic swap, so a restored copy is distinguishable
from its byte-identical source (a client cache keyed on the pre-restore
generation misses instead of serving stale entries). A normal flush never
touches the token, so it stays stable across writes (TC-B-004); a pre-V002
backup has no generation table and is (re)seeded on its later V002 migration.

Covers TC-B-004 and TC-B-024.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…(WS-B B6)

Redirect load()'s used_core_addresses to read core_address_pool used=1 rows
verbatim (reconstructing each address from its stored script), with no
horizon-walk re-derivation — so a wallet whose pool advanced past the old
gap-limit-30 window restores its full used-set (TC-B-023). Fall back to the
core_utxos-derived set only for a pre-pool / migrated-V001 store with no pool
rows. An empty wallet loads empty-but-valid, never corrupt (TC-B-025/007).

Covers TC-B-020/023/025 plus the pre-pool fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…s (WS-B B7)

The persister now expands account_address_pools into per-index
core_address_pool rows (used state + owning-account attribution) and the
reader restores the used-set from them verbatim; the field doc said storage
ignored it. rehydrate consumes the verbatim used_core_addresses snapshot
through the existing (kept) horizon-walk path with fallback + fail-closed
SkipReason shapes unchanged — full horizon-walk deletion is Workstream E.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…re (WS-B B8)

Drive the populated-V001 fixture through the post-redirect binary: migration
preserves every row and backfills nothing destructively (pre-existing UTXOs
keep account_index=0, the R7 one-way default) while the new V002 tables land
with sane defaults (TC-B-031); the empty wallet migrates without a NOT NULL
violation and reads empty-but-valid (TC-B-036); a byte-faithful
pre-migration auto-backup is written at the V001 state (TC-B-032); that
backup restores and re-migrates deterministically to the same end state
(TC-B-033); the forward-version gate rejects at the new max of 2 (TC-B-034);
and reopening a migrated store is idempotent (TC-B-035).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…t (WS-B B9)

Close the workstream: FFI needs no change (no new SkipReason/CorruptKind
variant crossed the boundary, rs-platform-wallet-ffi compiles clean) and
both crates pass clippy --all-targets -D warnings and their full test suites.

Fixes surfaced by the gates:
- touched_domains uses a named-fields + `..` destructure so clippy's
  unexpected_cfgs no longer trips on the feature-gated `shielded` field and
  Cargo feature-unification can't make the pattern non-exhaustive.
- Reword `seed`-substring doc comments in versions.rs / V002 that the
  schema secrets-scan flags (SQL body unchanged — fingerprints stable).
- Allow-list the pool reader's read-only `SELECT DISTINCT script` in the
  prepared-statement writer check (readers use plain `prepare`).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
… (QA-001)

The trailing `..` in `touched_domains` silently absorbed any newly added
changeset field, disarming the R8 forgotten-domain guard tc_b_013 claims to
enforce. Declare a pass-through `shielded` feature so the feature-gated
`PlatformWalletChangeSet::shielded` field is visible, then restore the
exhaustive destructure with a cfg-gated `shielded` binding and drop `..`.
An added always-on field is now a genuine compile error. Compiles clean under
both default and `--features shielded`. Corrected the stale code/test comments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…QA-002, QA-005)

The reader previously *replaced* the `core_utxos`-derived used-set with the
`core_address_pool` set whenever any pool row existed, so a mixed store — a
historical UTXO address a later partial pool snapshot never enumerates — could
silently drop that address from the reuse guard (address-reuse / funds-safety
hazard). `core_pool::load_used_addresses` now returns a plain `Vec` and
`load()` unions it with the UTXO-derived set, deduped by script. Added a
mixed-store regression test and an explicit two-wallet no-leakage test
(TC-B-026).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…QA-003)

TC-B-035 previously only reopened a fully-migrated fixture twice, never
exercising an interrupted migration. Replaced with a test that applies part of
V002's DDL inside a rolled-back transaction (modelling a crash before the
migration's single COMMIT), asserts the store stays at V001 with no partial
table, then re-opens and asserts byte-equal convergence with a clean direct
migration — empirically demonstrating refinery's per-migration transaction
guarantee. Kept a separate reopen-idempotency test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
…2 tables (QA-004)

No test covered multi-wallet behaviour of the new `core_address_pool` /
`meta_data_versions` tables. Add TC-B-006: two wallets with fully-overlapping
keys coexist without PK collision or cross-wallet read leakage, and deleting
one cascades away only its rows (FK ON DELETE CASCADE for the pool, the
soft-cascade trigger for versions) while the other's survive intact.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
… docs (QA-006)

Stripped the transient "WS-B task BN" / "BN" plan-task tags from test module
docs, the fixture regenerator, and the V002 header. Kept the durable TC-B-NNN
spec IDs and the #3968 PR reference. V002's `migration()` SQL body is
untouched, so the pinned content fingerprint is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2po24WxgfDKoWP61ueG2Q
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

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

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 29f8d691-bf0e-4255-8fec-d2986c5dbc65

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

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/3968-snapshot-redirect

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@lklimek lklimek marked this pull request as ready for review July 3, 2026 07:50
@thepastaclaw

thepastaclaw commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit c0f17da)

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

V002 migration is generally well-engineered, but multiple reviewers independently identified a blocking correctness bug: the new core_address_pool primary key drops the account_type discriminant that the sibling account_registrations table already carries, causing silent row overwrites between distinct AccountType variants that collapse to (account_index=0, key_class=0) (e.g. default Standard vs CoinJoin, and every degenerate identity/provider variant). Additional suggestions cluster around the restore_from generation-rotation window (four reviewers), non-deterministic account_index_for_script lookup, and an inconsistent blob-length gate in the new pool reader.

🔴 1 blocking | 🟡 3 suggestion(s) | 💬 1 nitpick(s)

1 additional finding(s) omitted (not in diff).

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet-storage/migrations/V002__unified.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/migrations/V002__unified.rs:30-40: core_address_pool PK omits account_type — silent cross-account row collisions in the common case
  The `core_address_pool` PK is `(wallet_id, account_index, key_class, pool_type, address_index)`. Verified against `accounts.rs:273-303`:

  - `account_key_class()` returns the real key class only for `PlatformPayment` — every other variant maps to the sentinel `0`.
  - `account_index()` collapses at least nine distinct variants (`IdentityRegistration`, `IdentityTopUpNotBoundToIdentity`, `IdentityInvitation`, `AssetLockAddressTopUp`, `AssetLockShieldedAddressTopUp`, all four `Provider*Keys`) to `0`.

  So the following distinct accounts, all created concurrently in a normal wallet, upsert onto the same PK tuple in `core_address_pool`:

  - `Standard{index:0, BIP44}` and `CoinJoin{index:0}` collide on `(wallet_id, 0, 0, {0,1}, address_index)` (both derive External/Internal pools).
  - `IdentityRegistration`, `IdentityInvitation`, `ProviderVotingKeys`, `ProviderOwnerKeys`, `ProviderOperatorKeys`, etc. — all `AddressPoolType::Absent` at `address_index=0` — collide on `(wallet_id, 0, 0, 2, 0)`.

  `UPSERT_POOL_SQL` at `core_pool.rs:31-36` (`ON CONFLICT ... DO UPDATE SET script = excluded.script, used = MAX(used, excluded.used)`) silently overwrites the earlier account's `script` with the last one applied and merges `used` flags from unrelated addresses. This breaks both the "verbatim snapshot" guarantee this PR advertises and the address-reuse guard (a `used` bit can bleed onto an unrelated address once a colliding pool row's script wins the last-writer race).

  This is a direct regression relative to `account_registrations`, whose PK explicitly widens with `account_type` TEXT specifically to disambiguate these variants (see the `apply_registrations` insert at `accounts.rs:122-127` and the comment at 117-120: "distinct accounts that share `(account_type, account_index)` don't overwrite each other"). The pattern for correct disambiguation was already in the codebase but wasn't applied to the new table.

  Test gap: none of the new tests (`sqlite_core_pool_writer.rs`, `sqlite_pool_reader.rs`, `sqlite_v002_isolation.rs`, `sqlite_version_bump.rs`) exercise more than one account type sharing `(account_index, key_class)` in the same wallet — only `Standard` and `PlatformPayment` are used, and never together — so the collision is invisible to CI.

  Fix: widen the PK with an `account_type` discriminator (mirroring `account_registrations`), and add a regression test that flushes two different AccountType variants sharing `(0, 0)` (e.g. `IdentityRegistration` + `ProviderVotingKeys`, and `Standard{0}` + `CoinJoin{0}`) into the same wallet and asserts both survive with correct scripts and `used` flags.

In `packages/rs-platform-wallet-storage/src/sqlite/backup.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/backup.rs:278-288: restore_from rotates the store-generation token AFTER the atomic swap — two coupled hazards
  Step 7 (`tmp.persist(dest_db_path)`) is the commit point: once it succeeds the destination inode holds the restored bytes. Steps 8–11 then run on a fresh RW connection outside the step-6 EXCLUSIVE lock, culminating in `regenerate_generation` at step 11. Two coupled hazards fall out of this ordering:

  1. **Semantics of `Err` after the swap.** Any failure in steps 8–11 (`fsync_parent_dir`, `apply_secure_permissions`, `open_conn`, or the `UPDATE meta_store_generation`) bubbles up as `Err(WalletStorageError::…)`. A caller that treats `Err` as "restore did not happen" (a reasonable read of a function that documents its swap as atomic) will act on a stale mental model — the destination has in fact been replaced with the source bytes, minus the token rotation.

  2. **Weakened cache-invalidation guarantee.** Between steps 7 and 11 the on-disk file carries the SOURCE's un-rotated generation token on top of the RESTORED bytes. A concurrent peer opening the DB in that window (contract violation per the module doc, but also a WAL/crash case where the UPDATE never fsyncs) sees restored content under a generation value its cache already has memoised — defeating the invalidation this token exists to guarantee (see `schema/versions.rs:227-239`).

  Both hazards close by folding the regeneration into the staged temp file before step 7, so the atomic rename is the single commit point that swaps in both restored bytes and a rotated token together. Alternatively, perform the rotation on the still-held EXCLUSIVE-lock connection before dropping it, and follow with `PRAGMA wal_checkpoint(FULL)` + parent-dir fsync so the token can't be lost to a crash between step 11 and the next fsync.

In `packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs:82-88: account_index_for_script uses LIMIT 1 with no ORDER BY — non-deterministic on any script collision
  `SELECT account_index FROM core_address_pool WHERE wallet_id = ?1 AND script = ?2 LIMIT 1` has no `ORDER BY`, so if two rows ever share a script for one wallet (a subtly-introduced schema evolution, a wallet-import bug, or — most immediately — the PK-collision fix above landing a schema where the same script can appear under multiple `(account_index, key_class, ...)` tuples), SQLite is free to return either row. UTXO attribution then depends on incidental row-insertion or query-plan order and can flip across restarts.

  Make the tie-break explicit — e.g. `ORDER BY account_index, key_class, pool_type, address_index ASC LIMIT 1` — so the attribution is auditable and reproducible.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs:107-123: load_used_addresses skips the pre-read length() gate used elsewhere in this PR
  Every other reader touching a variable-width BLOB in this crate (`core_state::load_state`, `core_state::load_used_addresses`, `accounts::load_state`) pre-reads `length(<col>)` and calls `blob::check_size` before materialising the `Vec<u8>`, so a tampered/corrupt DB with an oversize column can't force a large allocation before validation. The new `core_pool::load_used_addresses` reads `script` directly via `row.get::<_, Vec<u8>>(0)` with no such gate.

  The connection-wide `SQLITE_LIMIT_LENGTH` backstop (32 MiB) applies, but that's 2× looser than the crate's typed per-column cap (`BLOB_SIZE_LIMIT_BYTES`, 16 MiB) used everywhere else, and this query has no `LIMIT` and iterates `DISTINCT script` for every used address — so a corrupt store can force a larger aggregate allocation than the codebase's own stated per-column contract. Adding the same `length(script)` + `blob::check_size` gate keeps this reader consistent with the pattern the file's own doc already references (`load_used_addresses` doc comment cites the sibling in `core_state.rs`).

Comment on lines +30 to +40
CREATE TABLE core_address_pool (
wallet_id BLOB NOT NULL,
account_index INTEGER NOT NULL,
key_class INTEGER NOT NULL DEFAULT 0,
pool_type INTEGER NOT NULL CHECK (pool_type IN (0, 1, 2, 3)),
address_index INTEGER NOT NULL,
script BLOB NOT NULL,
used INTEGER NOT NULL DEFAULT 0 CHECK (used IN (0, 1)),
PRIMARY KEY (wallet_id, account_index, key_class, pool_type, address_index),
FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: core_address_pool PK omits account_type — silent cross-account row collisions in the common case

The core_address_pool PK is (wallet_id, account_index, key_class, pool_type, address_index). Verified against accounts.rs:273-303:

  • account_key_class() returns the real key class only for PlatformPayment — every other variant maps to the sentinel 0.
  • account_index() collapses at least nine distinct variants (IdentityRegistration, IdentityTopUpNotBoundToIdentity, IdentityInvitation, AssetLockAddressTopUp, AssetLockShieldedAddressTopUp, all four Provider*Keys) to 0.

So the following distinct accounts, all created concurrently in a normal wallet, upsert onto the same PK tuple in core_address_pool:

  • Standard{index:0, BIP44} and CoinJoin{index:0} collide on (wallet_id, 0, 0, {0,1}, address_index) (both derive External/Internal pools).
  • IdentityRegistration, IdentityInvitation, ProviderVotingKeys, ProviderOwnerKeys, ProviderOperatorKeys, etc. — all AddressPoolType::Absent at address_index=0 — collide on (wallet_id, 0, 0, 2, 0).

UPSERT_POOL_SQL at core_pool.rs:31-36 (ON CONFLICT ... DO UPDATE SET script = excluded.script, used = MAX(used, excluded.used)) silently overwrites the earlier account's script with the last one applied and merges used flags from unrelated addresses. This breaks both the "verbatim snapshot" guarantee this PR advertises and the address-reuse guard (a used bit can bleed onto an unrelated address once a colliding pool row's script wins the last-writer race).

This is a direct regression relative to account_registrations, whose PK explicitly widens with account_type TEXT specifically to disambiguate these variants (see the apply_registrations insert at accounts.rs:122-127 and the comment at 117-120: "distinct accounts that share (account_type, account_index) don't overwrite each other"). The pattern for correct disambiguation was already in the codebase but wasn't applied to the new table.

Test gap: none of the new tests (sqlite_core_pool_writer.rs, sqlite_pool_reader.rs, sqlite_v002_isolation.rs, sqlite_version_bump.rs) exercise more than one account type sharing (account_index, key_class) in the same wallet — only Standard and PlatformPayment are used, and never together — so the collision is invisible to CI.

Fix: widen the PK with an account_type discriminator (mirroring account_registrations), and add a regression test that flushes two different AccountType variants sharing (0, 0) (e.g. IdentityRegistration + ProviderVotingKeys, and Standard{0} + CoinJoin{0}) into the same wallet and asserts both survive with correct scripts and used flags.

source: ['claude']

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed and agreed — blocking. Verified account_index/account_key_class collapse ≥9 AccountType variants to (0, 0), and UPSERT_POOL_SQL's ON CONFLICT genuinely overwrites script/merges used across unrelated accounts. Also checked: this does NOT recur elsewhere in the schema — account_registrations already disambiguates correctly via account_type in its PK; core_utxos/asset_locks/platform_addresses key on real UTXO/address identity with account_index as attribute-only. Isolated to this one new table. Needs a fix before merge — not yet fixed.

🤖 Co-authored by Claudius the Magnificent AI Agent

@thepastaclaw thepastaclaw Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Correction: my automated reconciler posted an erroneous "resolved" reply here while processing the 0614fc4 review. This finding is still valid at 0614fc4 and was carried forward in the current review.

The misleading resolved text came from using the live PR diff after the branch had already advanced to a newer commit. Please ignore the earlier resolved wording in this reply thread.

Comment on lines 278 to 288
// 11. Regenerate the store-generation token so the restored copy is
// distinguishable from its source — a client cache keyed on the old
// generation misses rather than serving stale entries. A pre-V002
// backup has no generation table; it is (re)seeded on its later
// migration to V002.
{
let conn =
crate::sqlite::conn::open_conn(dest_db_path, crate::sqlite::conn::Access::ReadWrite)?;
crate::sqlite::schema::versions::regenerate_generation(&conn)?;
}
Ok(())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: restore_from rotates the store-generation token AFTER the atomic swap — two coupled hazards

Step 7 (tmp.persist(dest_db_path)) is the commit point: once it succeeds the destination inode holds the restored bytes. Steps 8–11 then run on a fresh RW connection outside the step-6 EXCLUSIVE lock, culminating in regenerate_generation at step 11. Two coupled hazards fall out of this ordering:

  1. Semantics of Err after the swap. Any failure in steps 8–11 (fsync_parent_dir, apply_secure_permissions, open_conn, or the UPDATE meta_store_generation) bubbles up as Err(WalletStorageError::…). A caller that treats Err as "restore did not happen" (a reasonable read of a function that documents its swap as atomic) will act on a stale mental model — the destination has in fact been replaced with the source bytes, minus the token rotation.

  2. Weakened cache-invalidation guarantee. Between steps 7 and 11 the on-disk file carries the SOURCE's un-rotated generation token on top of the RESTORED bytes. A concurrent peer opening the DB in that window (contract violation per the module doc, but also a WAL/crash case where the UPDATE never fsyncs) sees restored content under a generation value its cache already has memoised — defeating the invalidation this token exists to guarantee (see schema/versions.rs:227-239).

Both hazards close by folding the regeneration into the staged temp file before step 7, so the atomic rename is the single commit point that swaps in both restored bytes and a rotated token together. Alternatively, perform the rotation on the still-held EXCLUSIVE-lock connection before dropping it, and follow with PRAGMA wal_checkpoint(FULL) + parent-dir fsync so the token can't be lost to a crash between step 11 and the next fsync.

source: ['claude']

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed — steps 8-11 (WAL cleanup, fsync, perms, generation regen) run after the step-7 atomic commit, outside the lock, so an Err there is ambiguous about whether the restore actually happened. Valid, not blocking. Not yet fixed.

🤖 Co-authored by Claudius the Magnificent AI Agent

@thepastaclaw thepastaclaw Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Correction: my automated reconciler posted an erroneous "resolved" reply here while processing the 0614fc4 review. This finding is still valid at 0614fc4 and was carried forward in the current review.

The misleading resolved text came from using the live PR diff after the branch had already advanced to a newer commit. Please ignore the earlier resolved wording in this reply thread.

Comment on lines +82 to +88
let idx: Option<i64> = tx
.prepare_cached(
"SELECT account_index FROM core_address_pool \
WHERE wallet_id = ?1 AND script = ?2 LIMIT 1",
)?
.query_row(params![wallet_id.as_slice(), script], |row| row.get(0))
.optional()?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: account_index_for_script uses LIMIT 1 with no ORDER BY — non-deterministic on any script collision

SELECT account_index FROM core_address_pool WHERE wallet_id = ?1 AND script = ?2 LIMIT 1 has no ORDER BY, so if two rows ever share a script for one wallet (a subtly-introduced schema evolution, a wallet-import bug, or — most immediately — the PK-collision fix above landing a schema where the same script can appear under multiple (account_index, key_class, ...) tuples), SQLite is free to return either row. UTXO attribution then depends on incidental row-insertion or query-plan order and can flip across restarts.

Make the tie-break explicit — e.g. ORDER BY account_index, key_class, pool_type, address_index ASC LIMIT 1 — so the attribution is auditable and reproducible.

Suggested change
let idx: Option<i64> = tx
.prepare_cached(
"SELECT account_index FROM core_address_pool \
WHERE wallet_id = ?1 AND script = ?2 LIMIT 1",
)?
.query_row(params![wallet_id.as_slice(), script], |row| row.get(0))
.optional()?;
pub fn account_index_for_script(
tx: &Transaction<'_>,
wallet_id: &WalletId,
script: &[u8],
) -> Result<Option<u32>, WalletStorageError> {
let idx: Option<i64> = tx
.prepare_cached(
"SELECT account_index FROM core_address_pool \
WHERE wallet_id = ?1 AND script = ?2 \
ORDER BY account_index, key_class, pool_type, address_index ASC LIMIT 1",
)?
.query_row(params![wallet_id.as_slice(), script], |row| row.get(0))
.optional()?;
idx.map(|v| crate::sqlite::util::safe_cast::i64_to_u32("core_address_pool.account_index", v))
.transpose()
}

source: ['claude']

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed real, and it gets more likely once finding #1 (PK missing account_type) is fixed — widening the PK increases the chance of two rows legitimately sharing a script across accounts (e.g. address reuse across account families). Fix in the same pass as #1. Not yet fixed.

🤖 Co-authored by Claudius the Magnificent AI Agent

@thepastaclaw thepastaclaw Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Correction: my automated reconciler posted an erroneous "resolved" reply here while processing the 0614fc4 review. This finding is still valid at 0614fc4 and was carried forward in the current review.

The misleading resolved text came from using the live PR diff after the branch had already advanced to a newer commit. Please ignore the earlier resolved wording in this reply thread.

Comment on lines +107 to +123
let mut stmt = conn.prepare(
"SELECT DISTINCT script FROM core_address_pool \
WHERE wallet_id = ?1 AND used = 1 ORDER BY script",
)?;
let rows = stmt.query_map(params![wallet_id.as_slice()], |row| {
row.get::<_, Vec<u8>>(0)
})?;
let mut out = Vec::new();
for r in rows {
let script = dashcore::ScriptBuf::from_bytes(r?);
let address = dashcore::Address::from_script(&script, network).map_err(|_| {
WalletStorageError::blob_decode("core_address_pool.script not an address")
})?;
out.push(address);
}
Ok(out)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: load_used_addresses skips the pre-read length() gate used elsewhere in this PR

Every other reader touching a variable-width BLOB in this crate (core_state::load_state, core_state::load_used_addresses, accounts::load_state) pre-reads length(<col>) and calls blob::check_size before materialising the Vec<u8>, so a tampered/corrupt DB with an oversize column can't force a large allocation before validation. The new core_pool::load_used_addresses reads script directly via row.get::<_, Vec<u8>>(0) with no such gate.

The connection-wide SQLITE_LIMIT_LENGTH backstop (32 MiB) applies, but that's 2× looser than the crate's typed per-column cap (BLOB_SIZE_LIMIT_BYTES, 16 MiB) used everywhere else, and this query has no LIMIT and iterates DISTINCT script for every used address — so a corrupt store can force a larger aggregate allocation than the codebase's own stated per-column contract. Adding the same length(script) + blob::check_size gate keeps this reader consistent with the pattern the file's own doc already references (load_used_addresses doc comment cites the sibling in core_state.rs).

Suggested change
let mut stmt = conn.prepare(
"SELECT DISTINCT script FROM core_address_pool \
WHERE wallet_id = ?1 AND used = 1 ORDER BY script",
)?;
let rows = stmt.query_map(params![wallet_id.as_slice()], |row| {
row.get::<_, Vec<u8>>(0)
})?;
let mut out = Vec::new();
for r in rows {
let script = dashcore::ScriptBuf::from_bytes(r?);
let address = dashcore::Address::from_script(&script, network).map_err(|_| {
WalletStorageError::blob_decode("core_address_pool.script not an address")
})?;
out.push(address);
}
Ok(out)
}
let mut stmt = conn.prepare(
"SELECT DISTINCT length(script), script FROM core_address_pool \
WHERE wallet_id = ?1 AND used = 1 ORDER BY script",
)?;
let mut rows = stmt.query(params![wallet_id.as_slice()])?;
let mut out = Vec::new();
while let Some(row) = rows.next()? {
crate::sqlite::schema::blob::check_size(row.get::<_, i64>(0)?)?;
let script_bytes: Vec<u8> = row.get(1)?;
let script = dashcore::ScriptBuf::from_bytes(script_bytes);
let address = dashcore::Address::from_script(&script, network).map_err(|_| {
WalletStorageError::blob_decode("core_address_pool.script not an address")
})?;
out.push(address);
}
Ok(out)

source: ['claude']

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed real — and one correction to the comment's own citation: core_state::load_used_addresses is NOT actually gated (verified directly — it does row.get::<_, Vec<u8>>(0) on core_utxos.script with no length()/check_size precheck), so it's not a safe example to mirror, it's a second live instance of the same gap. platform_addrs.rs's two functions and accounts::load_state genuinely are gated correctly. Recommend widening this fix to cover core_state::load_used_addresses too. Not yet fixed.

🤖 Co-authored by Claudius the Magnificent AI Agent

@thepastaclaw thepastaclaw Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Correction: my automated reconciler posted an erroneous "resolved" reply here while processing the 0614fc4 review. This finding is still valid at 0614fc4 and was carried forward in the current review.

The misleading resolved text came from using the live PR diff after the branch had already advanced to a newer commit. Please ignore the earlier resolved wording in this reply thread.

lklimek and others added 4 commits July 3, 2026 11:19
…ydration

The rehydration merge added a `core_wallet_info` field to
`ClientWalletStartState`; the SQLite persister replays the keyless
projection onto a fresh skeleton, so it mints no full snapshot and sets
the field to `None`. Without it the crate no longer compiles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three defects in the core address-pool read/write path:

- account_type PK (CRITICAL): several AccountType variants collapse to the
  same (account_index=0, key_class=0) sentinel (IdentityRegistration,
  ProviderVotingKeys, Standard{0}, CoinJoin{0}, ...). With those the only PK
  discriminators, two such accounts upserted onto one core_address_pool row
  and silently overwrote each other's script / merged used flags. Widen the
  PK with an account_type TEXT column, reusing accounts::account_type_db_label
  (the same discriminator account_registrations already uses).

- deterministic script lookup: account_index_for_script used LIMIT 1 with no
  ORDER BY, so a script shared by several pool rows resolved to an arbitrary
  account. Add a PK-ordered tie-break (account_type first).

- blob-size gate: core_pool and core_state load_used_addresses materialized
  script blobs with no size gate, unlike every other blob reader. Gate the
  largest stored script with a cheap MAX(length(script)) aggregate before the
  DISTINCT/ORDER BY read, so a corrupt oversize column raises a typed
  BlobTooLarge instead of SQLite's own TooBig (core_utxos has no script index
  to stream by) and never OOMs the host.

Re-pins the content-level migration-SQL fingerprint (expected churn for the
unshipped V002 DDL; the identity fingerprint is unchanged).

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

restore_from rotated the store-generation token on a fresh connection AFTER
the atomic rename (the commit point). An Err from the post-rename steps was
ambiguous — the restore had already happened — and left a window where
restored content was observable carrying the source's stale token.

Fold the regeneration into the staged temp before the rename: switch the
staged DB to DELETE journaling (so the UPDATE lands in the main file with no
-wal frames stranded outside the rename), regenerate, fsync, then persist.
The single commit point now swaps in the restored bytes and the fresh token
together.

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

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Delta a511bde..0614fc4 is a merge commit that pulls sibling FFI/Swift work into the branch; nothing under packages/rs-platform-wallet-storage/** changed, so all five prior findings carry forward as still valid at HEAD. The blocking issue is the core_address_pool primary key omitting an account_type discriminator: accounts::account_index collapses nine AccountType variants (IdentityRegistration, IdentityTopUpNotBoundToIdentity, IdentityInvitation, AssetLockAddressTopUp/ShieldedAddressTopUp, all four Provider*Keys) plus Standard{0}/CoinJoin{0} to (account_index=0, key_class=0), so their UPSERT rows collide silently and the last writer's script/used flag wins — feeding non-deterministic UTXO attribution through account_index_for_script and bleeding the used bit onto unrelated addresses. Four supporting suggestions/nitpicks around restore ordering, LIMIT-1-without-ORDER-BY, and missing blob length-gate remain unaddressed.

🔴 1 blocking | 🟡 3 suggestion(s) | 💬 1 nitpick(s)

Source: reviewers: opus general/security-auditor/rust-quality/ffi-engineer ok; claude-sonnet-5 general/security-auditor/rust-quality/ffi-engineer ok; gpt-5.5[high] general/security-auditor/rust-quality/ffi-engineer failed; verifier: opus; specialists: security-auditor, rust-quality, ffi-engineer.

Reconciliation

  • Carried-forward prior findings: all 5 prior findings from a511bdec are STILL VALID at 0614fc4c.
  • New findings in latest delta: none. The a511bdec..0614fc4c delta is a merge of sibling FFI/Swift work and does not change the wallet-storage files that own these findings.
  • Canonical review action: REQUEST_CHANGES (GitHub rendered this submitted review as COMMENT because duplicate inline findings were suppressed).
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet-storage/migrations/V002__unified.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/migrations/V002__unified.rs:30-40: core_address_pool PK omits account_type/DashPay-id — silent cross-account row collisions and used-bit bleed
  The `core_address_pool` PK at line 38 is `(wallet_id, account_index, key_class, pool_type, address_index)`. There is no `account_type` (nor DashPay identity) discriminator, unlike the sibling `account_registrations` table in V001__initial.rs:75-88, whose PK is `(wallet_id, account_type, account_index, key_class, user_identity_id, friend_identity_id)` precisely to disambiguate variants sharing `(account_index, key_class)`.
  
  Verified against src/sqlite/schema/accounts.rs:273-303: `account_index()` returns the sentinel `0` for nine distinct `AccountType` variants (`IdentityRegistration`, `IdentityTopUpNotBoundToIdentity`, `IdentityInvitation`, `AssetLockAddressTopUp`, `AssetLockShieldedAddressTopUp`, `ProviderVotingKeys`, `ProviderOwnerKeys`, `ProviderOperatorKeys`, `ProviderPlatformKeys`), and `account_key_class()` returns the sentinel `0` for every variant except `PlatformPayment`. Additionally, `Standard{index:0, BIP44}` and `CoinJoin{index:0}` both collapse to `(0, 0)`, and `DashpayReceivingFunds{index:0, (a,b)}` collides across differing DashPay identity pairs (that axis isn't in the PK at all).
  
  `UPSERT_POOL_SQL` at core_pool.rs:31-36 does `ON CONFLICT ... DO UPDATE SET script = excluded.script, used = MAX(used, excluded.used)` — the losing account's `script` is silently overwritten with the last-applied one, and the `used` bit is merged monotonically across unrelated addresses. Consequences at a normal multi-account wallet:
  
  1. Verbatim-snapshot invariant broken: `core_pool::load_used_addresses` (lines 107-123) surfaces a script the losing account never derived.
  2. Reuse-guard weakened: a used address whose row is overwritten drops out of the pool set; the `core_utxos` fallback UNION only rescues addresses whose UTXOs are still tracked, so used-then-fully-spent addresses on the losing side can be silently missing from the guard.
  3. UTXO attribution wrong: `core_state::resolve_account_index` calls `account_index_for_script` to bucket every incoming UTXO — funds from account A can be attributed to account B.
  
  None of the new tests (`sqlite_core_pool_writer.rs`, `sqlite_pool_reader.rs`, `sqlite_v002_isolation.rs`) exercise two AccountType variants sharing `(account_index, key_class)` in the same wallet — only distinct `Standard` indices and `PlatformPayment` appear, never together — so CI does not catch the collision.
  
  Fix: widen the `core_address_pool` PK with an `account_type` discriminator (and DashPay identity pair, matching `account_registrations`), and mirror the change in `UPSERT_POOL_SQL`, `account_index_for_script`, and readers. Add a regression test flushing two colliding variants (e.g. `Standard{0}` + `CoinJoin{0}`, and `IdentityRegistration` + `ProviderVotingKeys`) into the same wallet and asserting both rows survive with correct scripts, `used` flags, and attribution.

In `packages/rs-platform-wallet-storage/src/sqlite/backup.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/backup.rs:246-289: restore_from rotates the store-generation token AFTER the atomic swap — stale generation on partial failure or crash
  `tmp.persist(dest_db_path)` at line 251 is the documented commit point — once it succeeds, the destination inode holds the restored bytes. Steps 8-11 (WAL/SHM cleanup, `fsync_parent_dir`, `apply_secure_permissions`, `open_conn` at 285 + `regenerate_generation` at 286) then run on a fresh RW connection outside the step-6 EXCLUSIVE lock. Two coupled hazards fall out of that ordering:
  
  1. **Ambiguous `Err` after the swap.** Any failure in steps 8-11 bubbles up as `Err(WalletStorageError::…)` after the destination has already been replaced. A caller reading the doc-comment as an atomic swap will act on a stale mental model — the destination is in fact restored, just missing the token rotation and durable sibling cleanup.
  2. **Weakened cache-invalidation guarantee.** Between step 7 (line 251) and step 11 (line 286), the on-disk file carries the SOURCE's un-rotated generation token on top of the RESTORED bytes. A crash between the two (or a concurrent peer opening the DB in that window) sees restored content under an un-rotated generation value a client cache may already have memoised, defeating the invalidation the token exists to enforce (see schema/versions.rs:227-239).
  
  Fold the regeneration into the staged temp file before step 7: open the staged temp RW, call `regenerate_generation` there, close, then `persist`. The atomic rename then becomes the single commit point that swaps in both the restored bytes and the rotated token together.
- [NITPICK] packages/rs-platform-wallet-storage/src/sqlite/backup.rs:175-197: restore_from skips the EXCLUSIVE lock when the destination doesn't yet exist
  When `dest_db_path.exists()` is false at line 175, `dest_lock_conn` stays `None` and no `BEGIN EXCLUSIVE` is taken. The module-level comment already documents `Callers must serialize restore intent across processes`, but a concurrent peer that creates `dest_db_path` between the existence check and step 7 `tmp.persist(dest_db_path)` (line 251) would have its file silently overwritten by the atomic rename — `persist`, not `persist_noclobber`.
  
  Note: the prior finding cited `persister.rs:175-197`; the canonical location is `backup::restore_from`. If defence-in-depth against a racing peer is intended on the fresh-destination path, switch to `persist_noclobber` and map `AlreadyExists` to a typed `RestoreDestinationRacedIntoExistence` error, mirroring the pattern `backup::run_to` already uses.

In `packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs:82-88: account_index_for_script uses LIMIT 1 with no ORDER BY — non-deterministic UTXO attribution on any script collision
  `SELECT account_index FROM core_address_pool WHERE wallet_id = ?1 AND script = ?2 LIMIT 1` has no `ORDER BY`, so if two rows ever share a script for one wallet — realistically caused by the PK collision above, or by wallet-import / schema-evolution scenarios in the future — SQLite is free to return either row, and which one wins depends on incidental row-insertion or query-plan order. `core_state::resolve_account_index` feeds the result directly into every UTXO's persisted `account_index`, so balance-by-account reporting can flip across restarts for the same wallet state.
  
  Add an explicit tie-break so attribution is auditable and reproducible.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs:102-123: load_used_addresses skips the pre-read length() gate used by every other BLOB reader in this crate
  Every other variable-width BLOB reader in this crate (`core_state::load_state`, `core_state::load_used_addresses`, `accounts::load_state`) pre-reads `length(<col>)` and calls `blob::check_size` (per-column cap `BLOB_SIZE_LIMIT_BYTES` = 16 MiB) before materialising the `Vec<u8>`, so a tampered/corrupt DB can't force a large allocation before validation. The new `core_pool::load_used_addresses` reads `script` directly via `row.get::<_, Vec<u8>>(0)` at line 112 with no such gate.
  
  The connection-wide `SQLITE_LIMIT_LENGTH` backstop (32 MiB) applies, but that's 2× looser than the crate's typed per-column cap, and this query has no `LIMIT` and iterates `DISTINCT script` for every used address — a corrupt store can force a larger aggregate allocation than the crate's own stated per-column contract. The reader's own doc-comment (line 101) already cites `core_state::load_used_addresses` as the pattern to follow, and that sibling gates. Adding the same gate keeps the invariant crate-wide.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Cumulative review at c0f17da reconciling all 5 prior findings from 0614fc4. Prior-2, prior-3, and prior-4 are cleanly FIXED with matching regression tests (generation-rotate-before-swap under DELETE journal mode + sync_all, PK-ordered ORDER BY tie-break, and MAX(length(script)) blob-size gate). Prior-1 is PARTIALLY_FIXED — the account_type discriminator that closes the IdentityRegistration/ProviderVotingKeys and Standard{0}/CoinJoin{0} collisions landed with regression tests, but the DashPay (user_identity_id, friend_identity_id) axis that account_registrations discriminates on was not wired into core_address_pool. Verified in-tree: register_contact_account (contacts.rs:100) does not currently emit an AccountAddressPoolEntry to the persister, so DashPay pool rows are not being written to core_address_pool today, which reduces this from Sonnet's blocking to a schema-forethought suggestion on this additive-only migration. Prior-5 (EXCLUSIVE lock skipped when destination file does not exist) remains a caller-serialization nitpick, unchanged in this delta. No new blocking regressions from the c0f17da delta.

🟡 1 suggestion(s) | 💬 1 nitpick(s)

1 additional finding(s) omitted (not in diff).

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet-storage/migrations/V002__unified.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/migrations/V002__unified.rs:34-45: core_address_pool PK omits DashPay (user_identity_id, friend_identity_id) — schema forethought while the migration is still landing
  The widened PK (wallet_id, account_type, account_index, key_class, pool_type, address_index) correctly closes the account_type-variant collisions the prior review flagged, verified by distinct_account_types_sharing_index_zero_do_not_collide and standard_and_coinjoin_index_zero_do_not_collide in sqlite_core_pool_writer.rs.

  However, account_type_db_label collapses every DashpayReceivingFunds{index, user_identity_id, friend_identity_id} to the fixed string "dashpay_receiving" (accounts.rs:265) and account_index() returns just the inner index field (accounts.rs:288). send_contact_request (contact_requests.rs:162) hardcodes account_index = 0 for every DashPay contact, so two contacts on the same wallet would collapse onto the identical PK tuple (wallet_id, 'dashpay_receiving', 0, 0, pool_type, address_index) and silently overwrite each other via ON CONFLICT ... DO UPDATE SET script = excluded.script (core_pool.rs:36-38).

  Mitigation for THIS PR: verified in-tree that register_contact_account (contacts.rs:100-156) only inserts the managed account into in-memory info.core_wallet.accounts and never emits an AccountAddressPoolEntry to the persister — DashPay pool rows are not currently written to core_address_pool in production, so no live collision. The sibling account_registrations table (V001) does carry (user_identity_id, friend_identity_id) in its PK for exactly this reason, with a passing distinct_dashpay_friends_do_not_collide test, and account_dashpay_ids() (accounts.rs:309) already exposes the pair.

  Since V002 is additive-only, widening the PK later requires another migration. If DashPay pool persistence is on the near-term roadmap for this workstream, mirror account_registrations' PK now (add user_identity_id/friend_identity_id columns, thread account_dashpay_ids through apply_pools/UPSERT_POOL_SQL/account_index_for_script, add a regression test with two DashpayReceivingFunds entries sharing (account_type, account_index, key_class) but differing only by contact identity). Otherwise, add a brief note to the V002 migration doc-comment explicitly deferring the DashPay identity axis (mirroring the manifest-MAC deferral note) and track it as a follow-up.

Source: reviewers: opus (general), claude-sonnet-5 (general), gpt-5.5[high] (general, failed_or_unparseable), opus (security-auditor), claude-sonnet-5 (security-auditor), gpt-5.5[high] (security-auditor, failed_or_unparseable), opus (rust-quality), claude-sonnet-5 (rust-quality), gpt-5.5[high] (rust-quality, failed_or_unparseable), opus (ffi-engineer), claude-sonnet-5 (ffi-engineer), gpt-5.5[high] (ffi-engineer, failed_or_unparseable); verifier: opus; specialists: security-auditor, rust-quality, ffi-engineer

Comment on lines +34 to +45
CREATE TABLE core_address_pool (
wallet_id BLOB NOT NULL,
account_type TEXT NOT NULL,
account_index INTEGER NOT NULL,
key_class INTEGER NOT NULL DEFAULT 0,
pool_type INTEGER NOT NULL CHECK (pool_type IN (0, 1, 2, 3)),
address_index INTEGER NOT NULL,
script BLOB NOT NULL,
used INTEGER NOT NULL DEFAULT 0 CHECK (used IN (0, 1)),
PRIMARY KEY (wallet_id, account_type, account_index, key_class, pool_type, address_index),
FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: core_address_pool PK omits DashPay (user_identity_id, friend_identity_id) — schema forethought while the migration is still landing

The widened PK (wallet_id, account_type, account_index, key_class, pool_type, address_index) correctly closes the account_type-variant collisions the prior review flagged, verified by distinct_account_types_sharing_index_zero_do_not_collide and standard_and_coinjoin_index_zero_do_not_collide in sqlite_core_pool_writer.rs.

However, account_type_db_label collapses every DashpayReceivingFunds{index, user_identity_id, friend_identity_id} to the fixed string "dashpay_receiving" (accounts.rs:265) and account_index() returns just the inner index field (accounts.rs:288). send_contact_request (contact_requests.rs:162) hardcodes account_index = 0 for every DashPay contact, so two contacts on the same wallet would collapse onto the identical PK tuple (wallet_id, 'dashpay_receiving', 0, 0, pool_type, address_index) and silently overwrite each other via ON CONFLICT ... DO UPDATE SET script = excluded.script (core_pool.rs:36-38).

Mitigation for THIS PR: verified in-tree that register_contact_account (contacts.rs:100-156) only inserts the managed account into in-memory info.core_wallet.accounts and never emits an AccountAddressPoolEntry to the persister — DashPay pool rows are not currently written to core_address_pool in production, so no live collision. The sibling account_registrations table (V001) does carry (user_identity_id, friend_identity_id) in its PK for exactly this reason, with a passing distinct_dashpay_friends_do_not_collide test, and account_dashpay_ids() (accounts.rs:309) already exposes the pair.

Since V002 is additive-only, widening the PK later requires another migration. If DashPay pool persistence is on the near-term roadmap for this workstream, mirror account_registrations' PK now (add user_identity_id/friend_identity_id columns, thread account_dashpay_ids through apply_pools/UPSERT_POOL_SQL/account_index_for_script, add a regression test with two DashpayReceivingFunds entries sharing (account_type, account_index, key_class) but differing only by contact identity). Otherwise, add a brief note to the V002 migration doc-comment explicitly deferring the DashPay identity axis (mirroring the manifest-MAC deferral note) and track it as a follow-up.

source: ['claude']

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants