feat(platform-wallet-storage): snapshot readers + unified V002 schema migration#3986
Conversation
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
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
✅ Review complete (commit c0f17da) |
thepastaclaw
left a comment
There was a problem hiding this comment.
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`).
| 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 | ||
| ); |
There was a problem hiding this comment.
🔴 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 forPlatformPayment— every other variant maps to the sentinel0.account_index()collapses at least nine distinct variants (IdentityRegistration,IdentityTopUpNotBoundToIdentity,IdentityInvitation,AssetLockAddressTopUp,AssetLockShieldedAddressTopUp, all fourProvider*Keys) to0.
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}andCoinJoin{index:0}collide on(wallet_id, 0, 0, {0,1}, address_index)(both derive External/Internal pools).IdentityRegistration,IdentityInvitation,ProviderVotingKeys,ProviderOwnerKeys,ProviderOperatorKeys, etc. — allAddressPoolType::Absentataddress_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']
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
| // 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(()) |
There was a problem hiding this comment.
🟡 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:
-
Semantics of
Errafter the swap. Any failure in steps 8–11 (fsync_parent_dir,apply_secure_permissions,open_conn, or theUPDATE meta_store_generation) bubbles up asErr(WalletStorageError::…). A caller that treatsErras "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. -
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']
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
| 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()?; |
There was a problem hiding this comment.
🟡 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.
| 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']
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
| 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) | ||
| } |
There was a problem hiding this comment.
🟡 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).
| 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']
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
…8-snapshot-redirect
…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>
There was a problem hiding this comment.
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
a511bdecare STILL VALID at0614fc4c. - New findings in latest delta: none. The
a511bdec..0614fc4cdelta 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.
There was a problem hiding this comment.
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
| 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 | ||
| ); |
There was a problem hiding this comment.
🟡 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']
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_addressesis re-derived at read time), every UTXO is attributed to hardcodedaccount_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 monotonicusedflags. Primary key is(wallet_id, account_type, account_index, key_class, pool_type, address_index)—account_typeis included so distinct account kinds that share the same numeric(account_index, key_class)(e.g.IdentityRegistration,ProviderVotingKeys, orStandard/CoinJoinboth at index 0) never collide;pool_typekeeps External/Internal from colliding; ascriptcolumn supports verbatim reads and UTXO attribution. Persisted idempotently per changeset.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_indexwhen multiple rows share a script); hardcodedCORE_UTXO_ACCOUNT_INDEX = 0deleted.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-throughshieldedfeature) 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.used_core_addressesread verbatim from pool rows, UNIONED with the UTXO-derived set (mixed/pre-pool stores keep historical used addresses). Bothcore_poolandcore_statevariable-width blob readers gate on a cheap pre-read length check before materializing, bounding allocation on a corrupt/oversize column.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?
AccountTypes sharing(account_index, key_class)now persist as independent pool rows (covers both the identity/provider-key family andStandard/CoinJoinat index 0).load_used_addressesimplementations (core_pool,core_state).-wal/-shmsiblings and the rotated token is observable immediately after the swap.platform-wallet-storagesuite: 60 test binaries, 0 failures;platform-walletlib 216/216;platform-wallet-ffisuite green; fmt + clippy-D warningsclean across all three crates.Breaking Changes
None. V001 is byte-identical; V002 is additive; stores migrate forward automatically with a pre-migration backup. No FFI or
SkipReason/CorruptKindshape changes.Checklist:
For repository code-owners and collaborators only
Attribution
🤖 Co-authored by Claudius the Magnificent AI Agent