Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
50cab4c
test(platform-wallet-storage): capture populated-V001 fixture (WS-B B0)
lklimek Jul 2, 2026
61c292c
feat(platform-wallet-storage): add V002 unified migration (WS-B B1)
lklimek Jul 2, 2026
c9603d6
chore(platform-wallet-storage): drop committed WAL side-files, gitign…
lklimek Jul 2, 2026
e663a35
test(platform-wallet-storage): pin schema content + freeze retired na…
lklimek Jul 2, 2026
503b251
feat(platform-wallet-storage): real account_index + pool-row writer (…
lklimek Jul 2, 2026
f06f9ff
feat(platform-wallet-storage): bump meta_data_versions inside the flu…
lklimek Jul 2, 2026
00499e0
feat(platform-wallet-storage): rotate store-generation on restore (WS…
lklimek Jul 2, 2026
d514d01
feat(platform-wallet-storage): read used-set verbatim from pool rows …
lklimek Jul 2, 2026
c44d684
docs(platform-wallet): correct account_address_pools storage semantic…
lklimek Jul 2, 2026
1d2612e
test(platform-wallet-storage): migration execution against V001 fixtu…
lklimek Jul 2, 2026
a7c1aa6
chore(platform-wallet-storage): QA pass — clippy/secrets/prepared-stm…
lklimek Jul 2, 2026
6dd08d4
fix(platform-wallet-storage): re-arm exhaustive touched_domains guard…
lklimek Jul 2, 2026
ab12991
fix(platform-wallet-storage): union pool + utxo used-sets in reader (…
lklimek Jul 2, 2026
4f95f46
test(platform-wallet-storage): prove interrupted-migration recovery (…
lklimek Jul 2, 2026
e3eb645
test(platform-wallet-storage): cross-wallet cascade/isolation for V00…
lklimek Jul 2, 2026
a511bde
docs(platform-wallet-storage): drop ephemeral plan-task IDs from test…
lklimek Jul 2, 2026
0614fc4
Merge branch 'feat/platform-wallet-storage-rehydration' into feat/396…
lklimek Jul 3, 2026
d22d5c5
fix(platform-wallet-storage): populate core_wallet_info in SQLite reh…
lklimek Jul 3, 2026
aa11d17
fix(platform-wallet-storage): harden core address-pool storage
lklimek Jul 3, 2026
c0f17da
fix(platform-wallet-storage): rotate store generation within restore …
lklimek Jul 3, 2026
c159f34
Merge remote-tracking branch 'origin/feat/platform-wallet-storage-reh…
lklimek Jul 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/rs-platform-wallet-storage/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,9 @@ kv = ["sqlite"]
__test-helpers = ["sqlite"]
# e2e tests that drive the #3692 manager-apply path; enabled in the integrated stack (dash-evo-tool).
rehydration-apply = []
# Pass-through to `platform-wallet/shielded` so the feature-gated
# `PlatformWalletChangeSet::shielded` field is visible to the exhaustive
# `versions::touched_domains` destructure (the R8 forgotten-domain guard).
# Storage persists no shielded state itself; this only aligns visibility so
# an added always-on field stays a compile error.
shielded = ["platform-wallet/shielded"]
75 changes: 75 additions & 0 deletions packages/rs-platform-wallet-storage/migrations/V002__unified.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//! Unified additive migration for `platform-wallet-storage` (#3968).
//!
//! Additive-only: V001 stays byte-identical so refinery's applied-migration
//! checksum for version 1 never diverges on an existing store. V002 lifts
//! `max_supported_version()` from 1 to 2 automatically (the value is derived
//! from the embedded list) and lands three concerns in one migration event:
//!
//! - `core_address_pool` — per-index address-pool rows with a `used` flag,
//! the first-class row store that replaces `core_utxos` script-derivation
//! for the address-reuse guard. `pool_type` is in the primary key so an
//! External (receive) and Internal (change) pool never collide at the same
//! `address_index`. `script` (the address' `script_pubkey`) is stored so
//! the reader returns used addresses verbatim and the UTXO writer can
//! attribute an outpoint to its owning account, both without re-deriving.
//! - `meta_data_versions` — per-`(wallet_id, domain)` monotonic `seq`
//! bumped inside the flush transaction, the cache-invalidation keystone.
//! No FK (a domain row may be written before its typed parent syncs,
//! mirroring the `meta_*` tables); a soft-cascade trigger reaps rows on
//! wallet delete.
//! - `meta_store_generation` — a single-row store-generation token,
//! initialized with `randomblob(16)` so the rendered SQL stays deterministic (the
//! content fingerprint pins the text, the runtime value is unique per
//! store). Regenerated on restore.
//!
//! No MAC column ships here — manifest authentication is deferred out of
//! this workstream (dev-plan §7).

pub fn migration() -> String {
"\
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
);
Comment on lines +34 to +45

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 +34 to +45

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']


CREATE INDEX idx_core_address_pool_used
ON core_address_pool(wallet_id, used);

-- The UTXO writer attributes an outpoint to its owning account by matching
-- the outpoint's script against a pool row.
CREATE INDEX idx_core_address_pool_script
ON core_address_pool(wallet_id, script);

CREATE TABLE meta_data_versions (
wallet_id BLOB NOT NULL,
domain TEXT NOT NULL,
seq INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (wallet_id, domain)
);

-- Soft-cascade reap, matching the meta_* tables: no FK (a domain may be
-- bumped before its typed parent exists), so a trigger clears rows when
-- the owning wallet is deleted.
CREATE TRIGGER cascade_meta_data_versions_on_wallet_delete
AFTER DELETE ON wallets
FOR EACH ROW
BEGIN
DELETE FROM meta_data_versions WHERE wallet_id = OLD.wallet_id;
END;

CREATE TABLE meta_store_generation (
id INTEGER NOT NULL PRIMARY KEY CHECK (id = 0),
generation BLOB NOT NULL
);

INSERT INTO meta_store_generation (id, generation) VALUES (0, randomblob(16));
"
.to_string()
}
11 changes: 11 additions & 0 deletions packages/rs-platform-wallet-storage/src/sqlite/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,17 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet

// 10. Re-tighten perms (idempotent; SQLite may re-materialise -wal/-shm).
apply_secure_permissions(dest_db_path)?;

// 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.

}

Expand Down
46 changes: 46 additions & 0 deletions packages/rs-platform-wallet-storage/src/sqlite/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,52 @@ pub fn embedded_migrations_fingerprint() -> [u8; 32] {
hasher.finalize().into()
}

/// SHA-256 over `(version, name, rendered SQL)` of every embedded migration
/// in version order. Unlike [`embedded_migrations_fingerprint`] this is
/// content-level: it pins each migration's SQL body, so an in-place DDL edit
/// (e.g. renaming a table inside a same-named file) breaks the golden test.
/// This is the guard the D0 schema freeze relies on; the identity-only
/// fingerprint cannot catch a same-name body edit.
///
/// The SQL *text* is deterministic even where a value is generated at run
/// time (`randomblob(16)`): the literal string is hashed, not the runtime
/// bytes.
#[cfg(any(test, feature = "__test-helpers"))]
pub fn embedded_migrations_sql_fingerprint() -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut migrations = migrations::runner().get_migrations().clone();
migrations.sort_by_key(|m| m.version());
let mut hasher = Sha256::new();
for m in &migrations {
hasher.update((m.version() as u32).to_be_bytes());
hasher.update([0u8]);
hasher.update(m.name().as_bytes());
hasher.update([0u8]);
let sql = m
.sql()
.expect("embedded migrations always carry rendered SQL");
hasher.update(sql.as_bytes());
hasher.update([0u8]);
}
hasher.finalize().into()
}

/// Rendered SQL of every embedded migration, in version order. Used by the
/// schema-freeze grep guard to scan for retired table names.
#[cfg(any(test, feature = "__test-helpers"))]
pub fn embedded_migrations_sql() -> Vec<String> {
let mut migrations = migrations::runner().get_migrations().clone();
migrations.sort_by_key(|m| m.version());
migrations
.iter()
.map(|m| {
m.sql()
.expect("embedded migrations always carry rendered SQL")
.to_string()
})
.collect()
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
63 changes: 33 additions & 30 deletions packages/rs-platform-wallet-storage/src/sqlite/persister.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use rusqlite::{Connection, OptionalExtension};

use platform_wallet::changeset::{
ClientStartState, Merge, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence,
ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence,
};
use platform_wallet::wallet::platform_wallet::WalletId;

Expand Down Expand Up @@ -940,13 +940,27 @@ impl PlatformWalletPersistence for SqlitePersister {
.map_err(PersistenceError::from)?;
let identity_keys = schema::identity_keys::load_state(&conn, &wallet_id)
.map_err(PersistenceError::from)?;
// Every address that ever held a UTXO (spent + unspent) is "used":
// the address-reuse guard so a used-then-emptied address is never
// handed back as a fresh receive address. The in-band pool snapshot
// was retired, so we derive this from the full core_utxos set.
let used_core_addresses =
schema::core_state::load_used_addresses(&conn, &wallet_id, network)
// Used addresses drive the reuse guard: a used-then-emptied
// address must never be handed back as a fresh receive address.
// Union the verbatim `core_address_pool` used-set with the
// `core_utxos`-derived set (spent + unspent). The guard is
// monotonic, so a mixed store — historical UTXOs plus a later
// partial pool snapshot that never enumerates them — must surface
// both; neither source may shadow the other. Deduped by script.
let used_core_addresses = {
let mut seen = std::collections::HashSet::new();
let mut union = Vec::new();
let pool = schema::core_pool::load_used_addresses(&conn, &wallet_id, network)
.map_err(PersistenceError::from)?;
let utxo = schema::core_state::load_used_addresses(&conn, &wallet_id, network)
.map_err(PersistenceError::from)?;
for addr in pool.into_iter().chain(utxo) {
if seen.insert(addr.script_pubkey().to_bytes()) {
union.push(addr);
}
}
union
};

state.wallets.insert(
wallet_id,
Expand Down Expand Up @@ -994,25 +1008,9 @@ impl PlatformWalletPersistence for SqlitePersister {
/// from the public fields so no storage-only helper leaks into the
/// `rs-platform-wallet` API.
fn populated_field_count(cs: &PlatformWalletChangeSet) -> usize {
[
cs.core.is_empty(),
cs.identities.is_empty(),
cs.identity_keys.is_empty(),
cs.contacts.is_empty(),
cs.platform_addresses.is_empty(),
cs.asset_locks.is_empty(),
cs.token_balances.is_empty(),
cs.dashpay_profiles.as_ref().is_none_or(|m| m.is_empty()),
cs.dashpay_payments_overlay
.as_ref()
.is_none_or(|m| m.is_empty()),
cs.wallet_metadata.is_none(),
cs.account_registrations.is_empty(),
cs.account_address_pools.is_empty(),
]
.iter()
.filter(|empty| !**empty)
.count()
// Single source of truth with the version-domain mapping: each populated
// field is exactly one touched domain.
schema::versions::touched_domains(cs).len()
}

fn validate_config(config: &SqlitePersisterConfig) -> Result<(), WalletStorageError> {
Expand Down Expand Up @@ -1088,10 +1086,12 @@ fn apply_changeset_to_tx(
if !cs.account_registrations.is_empty() {
schema::accounts::apply_registrations(tx, wallet_id, &cs.account_registrations)?;
}
// `account_address_pools` is intentionally NOT applied: UTXO attribution
// is hardcoded to the default account (index 0) in `core_state`, so the
// pool snapshot is no longer a storage input. The changeset field is kept
// for API stability and still feeds non-storage consumers.
// Pools land before core so the UTXO writer can attribute each outpoint
// to its owning account by matching the outpoint's script against a
// freshly-written `core_address_pool` row.
if !cs.account_address_pools.is_empty() {
schema::core_pool::apply_pools(tx, wallet_id, &cs.account_address_pools)?;
}
if let Some(core) = cs.core.as_ref() {
schema::core_state::apply(tx, wallet_id, core)?;
}
Expand Down Expand Up @@ -1121,6 +1121,9 @@ fn apply_changeset_to_tx(
cs.dashpay_payments_overlay.as_ref(),
)?;
}
// Bump each touched domain's version inside this same tx so a domain's
// cache-invalidation marker commits atomically with its data.
schema::versions::bump_touched_domains(tx, wallet_id, cs)?;
Ok(())
}

Expand Down
140 changes: 140 additions & 0 deletions packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
//! Writer + account-attribution helper for the `core_address_pool` table.
//!
//! Per-index address-pool rows carrying a `used` flag, scoped by
//! `(wallet_id, account_index, key_class, pool_type, address_index)`. The
//! first-class row store the reader consumes verbatim — no `core_utxos`
//! script-derivation, no horizon-walk re-derivation. Populated from the
//! `account_address_pools` changeset snapshots; the UTXO writer reads it
//! back to attribute an outpoint to its owning account.

use rusqlite::{params, OptionalExtension, Transaction};

use platform_wallet::changeset::AccountAddressPoolEntry;
use platform_wallet::wallet::platform_wallet::WalletId;

use key_wallet::managed_account::address_pool::AddressPoolType;

use crate::sqlite::error::WalletStorageError;
use crate::sqlite::schema::accounts;

/// Stored `pool_type` discriminant. Kept in the primary key so an External
/// and an Internal pool never collide at the same `address_index`.
pub(crate) fn pool_type_to_i64(pool_type: AddressPoolType) -> i64 {
match pool_type {
AddressPoolType::External => 0,
AddressPoolType::Internal => 1,
AddressPoolType::Absent => 2,
AddressPoolType::AbsentHardened => 3,
}
}

const UPSERT_POOL_SQL: &str = "INSERT INTO core_address_pool \
(wallet_id, account_index, key_class, pool_type, address_index, script, used) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
ON CONFLICT(wallet_id, account_index, key_class, pool_type, address_index) DO UPDATE SET \
script = excluded.script, \
used = MAX(used, excluded.used)";

/// Expand `account_address_pools` snapshots into per-index
/// `core_address_pool` rows. Idempotent: `script` is derivation-stable and
/// `used` is monotonic (`MAX`), so re-applying the same snapshot is a no-op
/// and a used address can never revert to unused (the reuse-guard invariant).
pub fn apply_pools(
tx: &Transaction<'_>,
wallet_id: &WalletId,
pools: &[AccountAddressPoolEntry],
) -> Result<(), WalletStorageError> {
if pools.is_empty() {
return Ok(());
}
let mut stmt = tx.prepare_cached(UPSERT_POOL_SQL)?;
for entry in pools {
let account_index = i64::from(accounts::account_index(&entry.account_type));
// TODO(key_class): PlatformPayment carries a real key_class; every
// other account maps to the 0 sentinel until the pool snapshot
// threads a per-pool key class.
let key_class = i64::from(accounts::account_key_class(&entry.account_type));
let pool_type = pool_type_to_i64(entry.pool_type);
for info in &entry.addresses {
stmt.execute(params![
wallet_id.as_slice(),
account_index,
key_class,
pool_type,
i64::from(info.index),
info.script_pubkey.as_bytes(),
info.used,
])?;
}
}
Ok(())
}

/// Owning account index for a UTXO, matched by its `script_pubkey` against a
/// pool row. `None` when no pool row covers the script — the UTXO writer
/// then falls back to account 0 (the one-way historical-attribution default,
/// R7): funds are never dropped, only conservatively bucketed.
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 LIMIT 1",
)?
.query_row(params![wallet_id.as_slice(), script], |row| row.get(0))
.optional()?;
Comment on lines +94 to +102

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.

idx.map(|v| crate::sqlite::util::safe_cast::i64_to_u32("core_address_pool.account_index", v))
.transpose()
}

/// Used addresses for a wallet, read verbatim from `core_address_pool`
/// (`used = 1`) with no re-derivation. Possibly empty. The caller **unions**
/// this with the `core_utxos`-derived set — the reuse guard is monotonic, so
/// a mixed store (historical UTXOs a later partial pool snapshot never
/// enumerates) must surface both sources, never drop the historical ones.
///
/// `network` turns each stored `script` back into an [`Address`]; a script
/// that isn't a valid address is a hard error — corruption is never silently
/// dropped, matching [`crate::sqlite::schema::core_state::load_used_addresses`].
pub fn load_used_addresses(
conn: &rusqlite::Connection,
wallet_id: &WalletId,
network: dashcore::Network,
) -> Result<Vec<dashcore::Address>, WalletStorageError> {
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)
}
Comment on lines +134 to +150

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.


#[cfg(test)]
mod tests {
use super::*;

#[test]
fn pool_type_discriminants_are_stable_and_distinct() {
let all = [
AddressPoolType::External,
AddressPoolType::Internal,
AddressPoolType::Absent,
AddressPoolType::AbsentHardened,
];
let mapped: Vec<i64> = all.iter().copied().map(pool_type_to_i64).collect();
assert_eq!(mapped, vec![0, 1, 2, 3]);
}
}
Loading
Loading