From 20b20e77c88d88a1ee6d43e25e71f8ddd02b70dc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:15:05 +0200 Subject: [PATCH 01/24] fix(platform-wallet-storage): rehydrate core_derived_addresses from pools; contain flush blast radius MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a genesis (birth_height=0) rescan, SPV can match a UTXO at a registered pool address before the live addresses_derived event for it lands. The UTXO writer's account lookup then missed, raised the fatal UtxoAddressNotDerived, and handle_flush_error dropped the WHOLE buffered changeset; core_bridge re-emitted it and the flush looped forever (173x in prod). core_derived_addresses (the per-address account_index map the UTXO writer joins) was fed only by live derive events and never from the account_address_pools snapshots that already hold every registered address. This wires the snapshot as the second source and stops one unresolvable UTXO from aborting an entire flush. C1 — apply_pools now mirrors every snapshot AddressInfo into core_derived_addresses in the same transaction, carrying the snapshot's real `used`. The row write is DRY'd into core_state::upsert_derived_address_row, called by both apply_pools and the live core_state::apply path through a shared core_state::derivation_path_label, so the two sources cannot drift. C2 — load() rehydrates core_derived_addresses from pools per wallet when the derived table is empty (already-persisted prod DBs), via core_state::rehydrate_derived_addresses_from_pools. It no-ops when rows already exist (no duplicates, no cost) and never touches ClientWalletStartState. No migration: V001 already defines both tables and the addr index. C3 — execute_upsert_utxo now SKIPS an unresolvable unspent UTXO (structured tracing::warn) instead of erroring, so the surrounding valid records, IS-locks, and sync-height still commit and the buffer drains. Funds-safe: the balance re-warms when the address later derives. The spent-only arm keeps its inert account-0 fallback. The UtxoAddressNotDerived variant and its fatal (non-transient) classification are retained for the exhaustive-match contract; it simply stops being raised. Tests: new sqlite_pool_derived_rehydration.rs covers genesis-rescan persist, pool/live row-shape parity, idempotent load rehydration, and blast-radius isolation. The structural-hardening contract test is rewritten to assert the skip semantics; the is_transient exhaustiveness gate still pins the variant as fatal. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/sqlite/error.rs | 8 +- .../src/sqlite/persister.rs | 18 +- .../src/sqlite/schema/accounts.rs | 18 + .../src/sqlite/schema/core_state.rs | 206 +++++++-- .../tests/sqlite_compile_time.rs | 4 + .../tests/sqlite_pool_derived_rehydration.rs | 393 ++++++++++++++++++ .../tests/sqlite_structural_hardening.rs | 37 +- 7 files changed, 636 insertions(+), 48 deletions(-) create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs index 17cd68daa3..a620c827a0 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -219,9 +219,11 @@ pub enum WalletStorageError { }, /// An unspent UTXO named an address absent from - /// `core_derived_addresses`, so its account index can't be resolved; - /// persisting it would mis-file live funds, so the write is refused. - /// Spent-only placeholder rows tolerate a missing mapping. + /// `core_derived_addresses`, so its account index can't be resolved. + /// Retained as a fatal-classified typed marker; the apply path no + /// longer raises it — it skips such a UTXO (logged) so one + /// unresolvable row never aborts a whole flush, and the balance + /// re-warms when the address later derives. #[error("unspent utxo address {address} is not in core_derived_addresses")] UtxoAddressNotDerived { address: String }, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 07b62afd97..e073982110 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -861,7 +861,7 @@ impl PlatformWalletPersistence for SqlitePersister { /// # } /// ``` fn load(&self) -> Result { - let conn = self.conn().map_err(PersistenceError::from)?; + let mut conn = self.conn().map_err(PersistenceError::from)?; let mut state = ClientStartState::default(); let addrs_all = schema::platform_addrs::load_all(&conn).map_err(PersistenceError::from)?; @@ -901,6 +901,22 @@ impl PlatformWalletPersistence for SqlitePersister { )) })?; + // Repair already-persisted DBs whose `core_derived_addresses` was + // never populated from pools: rehydrate it from the snapshots so + // the next sync's UTXO writer can resolve pool-address accounts. + // No-op when the table already has rows for this wallet. + { + let tx = conn + .transaction() + .map_err(WalletStorageError::from) + .map_err(PersistenceError::from)?; + schema::core_state::rehydrate_derived_addresses_from_pools(&tx, &wallet_id) + .map_err(PersistenceError::from)?; + tx.commit() + .map_err(WalletStorageError::from) + .map_err(PersistenceError::from)?; + } + let account_manifest = schema::accounts::load_state(&conn, &wallet_id).map_err(PersistenceError::from)?; let core_state = schema::core_state::load_state(&conn, &wallet_id, network) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs index 0d929a6f57..882ee916ea 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs @@ -152,6 +152,24 @@ pub fn apply_pools( pool_type, payload, ])?; + // Mirror every snapshot address into `core_derived_addresses` (same + // tx) so a UTXO landing on a pool address resolves its account even + // when no live derive event preceded it (genesis-rescan). The shared + // helper keeps these rows identical to the live derive path. + for info in &entry.addresses { + let address = info.address.to_string(); + let path = + crate::sqlite::schema::core_state::derivation_path_label(pool_type, info.index); + crate::sqlite::schema::core_state::upsert_derived_address_row( + tx, + wallet_id, + account_type, + i64::from(account_index), + &address, + &path, + info.used, + )?; + } } Ok(()) } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 1479e37530..43285d85cf 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -56,31 +56,23 @@ pub fn apply( } // Derived addresses are written before UTXOs (same tx) so the UTXO // writer's address→account_index lookup sees the fresh rows. - if !cs.addresses_derived.is_empty() { - let mut stmt = tx.prepare_cached( - "INSERT INTO core_derived_addresses \ - (wallet_id, account_type, account_index, address, derivation_path, used) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6) \ - ON CONFLICT(wallet_id, account_type, address) DO UPDATE SET \ - account_index = excluded.account_index, \ - derivation_path = excluded.derivation_path", + for da in &cs.addresses_derived { + let account_type = crate::sqlite::schema::accounts::account_type_db_label(&da.account_type); + let account_index = crate::sqlite::schema::accounts::account_index(&da.account_type); + let pool_type = crate::sqlite::schema::accounts::pool_type_db_label(&da.pool_type); + let address = da.address.to_string(); + let path = derivation_path_label(pool_type, da.derivation_index); + // Live derive events carry no `used` flag — default false; a pool + // snapshot (the other caller of this helper) carries the real one. + upsert_derived_address_row( + tx, + wallet_id, + account_type, + i64::from(account_index), + &address, + &path, + false, )?; - for da in &cs.addresses_derived { - let account_type = - crate::sqlite::schema::accounts::account_type_db_label(&da.account_type); - let account_index = crate::sqlite::schema::accounts::account_index(&da.account_type); - let pool_type = crate::sqlite::schema::accounts::pool_type_db_label(&da.pool_type); - let address = da.address.to_string(); - let path = format!("{}/{}", pool_type, da.derivation_index); - stmt.execute(params![ - wallet_id.as_slice(), - account_type, - i64::from(account_index), - address, - path, - false - ])?; - } } if !cs.new_utxos.is_empty() { let mut stmt = tx.prepare_cached(UPSERT_UTXO_SQL)?; @@ -167,13 +159,20 @@ fn execute_upsert_utxo( .optional()?; let account_index: i64 = match looked_up { Some(idx) => idx, - // Refuse an unspent UTXO whose address we never derived — it would - // mis-bucket live money under account 0 and never re-derive. The - // spent-only path below tolerates the fallback (a wrong index is inert). + // Skip an unspent UTXO whose address we never derived: bucketing it + // under account 0 would mis-file live money, and erroring would abort + // the whole flush (genesis-rescan can match a UTXO before its derive + // event lands). The address re-warms its balance when it later + // derives — funds-safe. The spent-only arm keeps the inert fallback. None if !spent => { - return Err(WalletStorageError::UtxoAddressNotDerived { - address: address.clone(), - }); + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + address = %address, + txid = %utxo.outpoint.txid, + vout = utxo.outpoint.vout, + "skipping unspent UTXO at an address absent from core_derived_addresses; balance re-warms when the address derives" + ); + return Ok(()); } None => { tracing::debug!( @@ -196,6 +195,112 @@ fn execute_upsert_utxo( Ok(()) } +/// The `derivation_path` TEXT stored on a `core_derived_addresses` row: +/// `"{pool_type}/{index}"`. Rendering both the live derive path and the +/// pool-snapshot path through this one function keeps the two writers +/// byte-identical (the snapshot's own BIP32 `path` is deliberately not +/// used, so a row is independent of which source produced it). +pub(crate) fn derivation_path_label(pool_type: &str, derivation_index: u32) -> String { + format!("{pool_type}/{derivation_index}") +} + +const UPSERT_DERIVED_ADDRESS_SQL: &str = "INSERT INTO core_derived_addresses \ + (wallet_id, account_type, account_index, address, derivation_path, used) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6) \ + ON CONFLICT(wallet_id, account_type, address) DO UPDATE SET \ + account_index = excluded.account_index, \ + derivation_path = excluded.derivation_path"; + +/// Upsert one `core_derived_addresses` row. Single writer for both the +/// live `addresses_derived` event path and the `apply_pools` snapshot +/// path, so the address→account_index map the UTXO writer joins is +/// identical regardless of which source populated it. `used` is set on +/// insert only — the conflict clause leaves an existing `used` untouched +/// so a later live re-derive (which carries no flag) cannot clear a +/// snapshot's real value. +pub(crate) fn upsert_derived_address_row( + tx: &Transaction<'_>, + wallet_id: &WalletId, + account_type: &str, + account_index: i64, + address: &str, + derivation_path: &str, + used: bool, +) -> Result<(), WalletStorageError> { + let mut stmt = tx.prepare_cached(UPSERT_DERIVED_ADDRESS_SQL)?; + stmt.execute(params![ + wallet_id.as_slice(), + account_type, + account_index, + address, + derivation_path, + used, + ])?; + Ok(()) +} + +/// Repopulate `core_derived_addresses` for `wallet_id` from its +/// `account_address_pools` snapshots when the derived table is empty for +/// that wallet (already-persisted DBs predating the pool→derived mirror). +/// +/// A no-op when the wallet already has any derived row — no duplicates, +/// no scan cost. Mirrors every `AddressInfo` of every pool through +/// [`upsert_derived_address_row`], so a rehydrated row is identical to a +/// freshly-applied one. Decoding a snapshot blob is fail-hard +/// (corruption is never skipped). +pub fn rehydrate_derived_addresses_from_pools( + tx: &Transaction<'_>, + wallet_id: &WalletId, +) -> Result<(), WalletStorageError> { + let already_present: bool = tx + .query_row( + "SELECT 1 FROM core_derived_addresses WHERE wallet_id = ?1 LIMIT 1", + params![wallet_id.as_slice()], + |_| Ok(true), + ) + .optional()? + .unwrap_or(false); + if already_present { + return Ok(()); + } + + let snapshots: Vec> = { + let mut stmt = tx.prepare_cached( + "SELECT snapshot_blob FROM account_address_pools WHERE wallet_id = ?1", + )?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + row.get::<_, Vec>(0) + })?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + out + }; + + for payload in snapshots { + let entry: platform_wallet::changeset::AccountAddressPoolEntry = blob::decode(&payload)?; + let account_type = + crate::sqlite::schema::accounts::account_type_db_label(&entry.account_type); + let account_index = crate::sqlite::schema::accounts::account_index(&entry.account_type); + let pool_type = crate::sqlite::schema::accounts::pool_type_db_label(&entry.pool_type); + for info in &entry.addresses { + let address = info.address.to_string(); + let path = derivation_path_label(pool_type, info.index); + upsert_derived_address_row( + tx, + wallet_id, + account_type, + i64::from(account_index), + &address, + &path, + info.used, + )?; + } + } + Ok(()) +} + fn upsert_sync_state( tx: &Transaction<'_>, wallet_id: &WalletId, @@ -443,3 +548,44 @@ pub fn list_unspent_utxos( } Ok(by_account) } + +/// One `core_derived_addresses` row. Used by tests that assert the +/// address→account map written by `apply_pools` matches the live derive +/// path (row-shape parity) and that load-time rehydration repopulates it. +#[cfg(any(test, feature = "__test-helpers"))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DerivedAddressRow { + pub account_type: String, + pub account_index: i64, + pub address: String, + pub derivation_path: String, + pub used: bool, +} + +/// Every `core_derived_addresses` row for one wallet, ordered for +/// determinism. Retained for this crate's integration tests. +#[cfg(any(test, feature = "__test-helpers"))] +pub fn list_derived_addresses_for_test( + conn: &Connection, + wallet_id: &WalletId, +) -> Result, WalletStorageError> { + let mut stmt = conn.prepare( + "SELECT account_type, account_index, address, derivation_path, used \ + FROM core_derived_addresses WHERE wallet_id = ?1 \ + ORDER BY account_type, account_index, address", + )?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + Ok(DerivedAddressRow { + account_type: row.get(0)?, + account_index: row.get(1)?, + address: row.get(2)?, + derivation_path: row.get(3)?, + used: row.get(4)?, + }) + })?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs index 2294792564..d85cd9be24 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -58,6 +58,10 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ "SELECT wallet_id, account_index, account_xpub_bytes FROM account_registrations", ), ("core_state.rs", "SELECT outpoint, value, script, height"), + ( + "core_state.rs", + "SELECT account_type, account_index, address, derivation_path, used", + ), // Full-rehydration readers — one-shot SELECTs in `load_state`. ( "accounts.rs", diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs new file mode 100644 index 0000000000..56bb13b12e --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs @@ -0,0 +1,393 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Genesis-rescan rehydration of `core_derived_addresses` from +//! `account_address_pools` snapshots, and the flush blast-radius +//! containment for an unspent UTXO at a genuinely-undeclared address. +//! +//! On a `birth_height = 0` rescan SPV can match a UTXO at a registered +//! pool address before the live `addresses_derived` event for it lands. +//! `account_address_pools` already holds that address (with its real +//! `used` flag), so `apply_pools` mirrors it into `core_derived_addresses` +//! in the same transaction — the UTXO writer's account lookup resolves +//! and the flush commits instead of dropping the whole changeset. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; + +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::AddressInfo; +use platform_wallet::changeset::{ + AccountAddressPoolEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::sqlite::schema::core_state; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +/// Snapshot a freshly seeded wallet's Standard BIP44 external pool as a +/// single `AccountAddressPoolEntry`, mirroring the production registration +/// round in `wallet_lifecycle.rs`. One pool keeps the derived-row keys +/// `(account_type, address)` unique across the returned set, so the test +/// reads back exactly what it wrote — the cross-account label collapse +/// the schema PK allows is exercised elsewhere, not load-bearing here. +fn standard_external_pool(info: &ManagedWalletInfo) -> AccountAddressPoolEntry { + use key_wallet::account::AccountType; + use key_wallet::managed_account::address_pool::AddressPoolType; + for managed in info.all_managed_accounts() { + let account_type = managed.managed_account_type().to_account_type(); + if !matches!(account_type, AccountType::Standard { index: 0, .. }) { + continue; + } + for pool in managed.managed_account_type().address_pools() { + if pool.pool_type != AddressPoolType::External { + continue; + } + let infos: Vec = pool.addresses.values().cloned().collect(); + if infos.is_empty() { + continue; + } + return AccountAddressPoolEntry { + account_type, + pool_type: pool.pool_type, + addresses: infos, + }; + } + } + panic!("wallet must expose a non-empty Standard BIP44 external pool"); +} + +/// A wallet's Standard BIP44 external pool plus its first `AddressInfo` — +/// the load-bearing target the UTXO writer must resolve. +fn wallet_with_pools(seed_byte: u8) -> (Vec, AddressInfo) { + let seed = [seed_byte; 64]; + let wallet = Wallet::from_seed_bytes( + seed, + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 0); + let pool = standard_external_pool(&info); + let target = pool + .addresses + .first() + .cloned() + .expect("non-empty external pool"); + (vec![pool], target) +} + +fn utxo_at(addr: &dashcore::Address, vout: u32, value: u64) -> key_wallet::Utxo { + use dashcore::hashes::Hash; + key_wallet::Utxo { + outpoint: dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([0x42; 32]), + vout, + }, + txout: dashcore::TxOut { + value, + script_pubkey: addr.script_pubkey(), + }, + address: addr.clone(), + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + } +} + +fn reopen(path: &std::path::Path) -> SqlitePersister { + SqlitePersister::open(SqlitePersisterConfig::new(path)).expect("reopen") +} + +/// Genesis-rescan persist: a wallet registered with pools but with NO +/// live `addresses_derived` event still resolves the account index of a +/// UTXO landing on a pool address — `apply_pools` mirrored the pool into +/// `core_derived_addresses` in the same round. +#[test] +fn genesis_rescan_utxo_at_pool_address_persists() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA0); + ensure_wallet_meta(&persister, &w); + + let (snapshots, target) = wallet_with_pools(0x11); + let addr = target.address.clone(); + + // Registration round carries pools only — no addresses_derived. + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: snapshots, + ..Default::default() + }, + ) + .unwrap(); + + // SPV matches a UTXO at the pool address before any derive-event. + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo_at(&addr, 0, 555_000)], + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("UTXO at a pool address must persist without a derive-event"); + + let conn = persister.lock_conn_for_test(); + let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); + let total: usize = by_account.values().map(|v| v.len()).sum(); + assert_eq!(total, 1, "the pool-address UTXO must be persisted"); + let derived = core_state::list_derived_addresses_for_test(&conn, &w).unwrap(); + assert!( + derived.iter().any(|r| r.address == addr.to_string()), + "apply_pools must have mirrored the pool address into core_derived_addresses" + ); +} + +/// Row-shape parity: a `core_derived_addresses` row written via +/// `apply_pools` is byte-identical (account_index, derivation_path, used) +/// to the row the live `core_state::apply` writes for the same address — +/// the two sources share one helper, so they cannot drift. +#[test] +fn pool_and_live_derived_rows_are_identical() { + let (snapshots, target) = wallet_with_pools(0x22); + let addr = target.address.clone(); + + // Locate the account_type + pool_type that owns the target address so + // the live event describes the same derivation. + let owning = snapshots + .iter() + .find(|p| p.addresses.iter().any(|ai| ai.address == addr)) + .expect("owning pool"); + + // Row A — written by apply_pools (with the real `used` from the pool). + let row_pool = { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xB1); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: snapshots.clone(), + ..Default::default() + }, + ) + .unwrap(); + let conn = persister.lock_conn_for_test(); + core_state::list_derived_addresses_for_test(&conn, &w) + .unwrap() + .into_iter() + .find(|r| r.address == addr.to_string()) + .expect("pool-written row") + }; + + // Row B — written by the live core_state::apply derive path. + let row_live = { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xB2); + ensure_wallet_meta(&persister, &w); + let derived = platform_wallet::DerivedAddress { + account_type: owning.account_type, + pool_type: owning.pool_type, + derivation_index: target.index, + address: addr.clone(), + public_key: dashcore::PublicKey::from_slice(&[ + 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, + 0x87, 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, + 0x5b, 0x16, 0xf8, 0x17, 0x98, + ]) + .unwrap(), + }; + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + addresses_derived: vec![derived], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + let conn = persister.lock_conn_for_test(); + core_state::list_derived_addresses_for_test(&conn, &w) + .unwrap() + .into_iter() + .find(|r| r.address == addr.to_string()) + .expect("live-written row") + }; + + assert_eq!( + row_pool.account_type, row_live.account_type, + "account_type label must match" + ); + assert_eq!( + row_pool.account_index, row_live.account_index, + "account_index must match" + ); + assert_eq!( + row_pool.derivation_path, row_live.derivation_path, + "derivation_path must be rendered identically by both sources" + ); + // The live path hardcodes used=false; an unused pool address agrees. + assert!( + !target.used, + "fixture relies on a fresh (unused) first external address" + ); + assert_eq!(row_pool.used, row_live.used, "used flag must match"); +} + +/// Load-path rehydrate: a DB with pool snapshots but ZERO derived rows is +/// repopulated by `load`, and a second `load` is a no-op (no duplicates). +#[test] +fn load_rehydrates_derived_rows_from_pools_idempotently() { + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xC0); + ensure_wallet_meta(&persister, &w); + + let (snapshots, target) = wallet_with_pools(0x33); + let addr = target.address.clone(); + + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: snapshots, + ..Default::default() + }, + ) + .unwrap(); + + // Simulate an already-persisted prod DB: pools present, derived table + // empty (the bug — derived rows were never written for pools). + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "DELETE FROM core_derived_addresses WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + ) + .unwrap(); + let n = core_state::list_derived_addresses_for_test(&conn, &w) + .unwrap() + .len(); + assert_eq!(n, 0, "precondition: derived table emptied"); + } + drop(persister); + + let p2 = reopen(&path); + PlatformWalletPersistence::load(&p2).expect("first load"); + let first = { + let conn = p2.lock_conn_for_test(); + core_state::list_derived_addresses_for_test(&conn, &w).unwrap() + }; + assert!( + first.iter().any(|r| r.address == addr.to_string()), + "load must rehydrate derived rows from pools" + ); + let count_after_first = first.len(); + + // A second load must not duplicate or re-insert (table already full). + PlatformWalletPersistence::load(&p2).expect("second load"); + let count_after_second = { + let conn = p2.lock_conn_for_test(); + core_state::list_derived_addresses_for_test(&conn, &w) + .unwrap() + .len() + }; + assert_eq!( + count_after_first, count_after_second, + "second load must be a no-op (no duplicate derived rows)" + ); +} + +/// Blast-radius isolation: a batch mixing a valid pool-address UTXO, a +/// sync-height bump, and ONE unspent UTXO at a genuinely undeclared +/// address (not in pools, not derived) commits the valid UTXO + height +/// and SKIPS only the bad UTXO — no error escapes, so the buffer drains +/// instead of looping. +#[test] +fn undeclared_unspent_utxo_is_skipped_not_fatal() { + use dashcore::hashes::Hash; + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xD0); + ensure_wallet_meta(&persister, &w); + + let (snapshots, good) = wallet_with_pools(0x44); + let good_addr = good.address.clone(); + + // A genuinely undeclared address: not in any pool, never derived. + let undeclared = { + use dashcore::address::Payload; + use dashcore::PubkeyHash; + dashcore::Address::new( + dashcore::Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([0xEE; 20])), + ) + }; + assert_ne!(undeclared, good_addr, "fixture sanity"); + + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: snapshots, + ..Default::default() + }, + ) + .unwrap(); + + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![ + utxo_at(&good_addr, 0, 100_000), + utxo_at(&undeclared, 9, 200_000), + ], + last_processed_height: Some(123), + synced_height: Some(123), + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("mixed batch must commit; the undeclared UTXO is skipped, not fatal"); + + let conn = persister.lock_conn_for_test(); + let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); + let all: Vec<_> = by_account.values().flatten().collect(); + assert_eq!( + all.len(), + 1, + "only the good UTXO commits; the bad one is skipped" + ); + assert!( + all.iter().all(|r| r.value == 100_000), + "the committed UTXO is the good one" + ); + + // The sync-height bump committed in the same transaction. + let synced: Option = conn + .query_row( + "SELECT synced_height FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + synced, + Some(123), + "sync-height must commit alongside valid records" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs b/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs index 4c6d6ed4fc..5c48dd4656 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs @@ -170,11 +170,14 @@ fn multi_account_utxos_bucket_to_real_account() { } /// A NEW unspent UTXO whose address is absent from -/// `core_derived_addresses` cannot resolve an owning account, so the -/// write is refused with the typed `UtxoAddressNotDerived` instead of -/// silently mis-filing live funds under account 0. +/// `core_derived_addresses` cannot resolve an owning account. Rather than +/// mis-filing live funds under account 0 (corruption) or aborting the +/// whole flush (the genesis-rescan fatal loop), the writer SKIPS just +/// that row: `apply` returns `Ok`, no `core_utxos` row is written, and +/// the surrounding records still commit. The address re-warms its +/// balance once it later derives — funds-safe. #[test] -fn unspent_utxo_on_undeclared_address_is_rejected() { +fn unspent_utxo_on_undeclared_address_is_skipped() { use platform_wallet_storage::sqlite::schema::core_state; let (persister, _tmp, _path) = fresh_persister(); @@ -182,17 +185,23 @@ fn unspent_utxo_on_undeclared_address_is_rejected() { ensure_wallet_meta(&persister, &w); let addr_unknown = p2pkh(0xEE); - let mut conn = persister.lock_conn_for_test(); - let cs = CoreChangeSet { - new_utxos: vec![make_utxo(&addr_unknown, 0, 3000)], - ..Default::default() - }; - let tx = conn.transaction().unwrap(); - let err = core_state::apply(&tx, &w, &cs) - .expect_err("unspent UTXO on an undeclared address must error"); + { + let mut conn = persister.lock_conn_for_test(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr_unknown, 0, 3000)], + ..Default::default() + }; + let tx = conn.transaction().unwrap(); + core_state::apply(&tx, &w, &cs) + .expect("an undeclared-address unspent UTXO must be skipped, not error"); + tx.commit().unwrap(); + } + + let conn = persister.lock_conn_for_test(); + let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); assert!( - matches!(err, WalletStorageError::UtxoAddressNotDerived { .. }), - "expected UtxoAddressNotDerived, got {err:?}" + by_account.is_empty(), + "the unresolvable unspent UTXO must be skipped, leaving no core_utxos row" ); } From 4f432c9baf10eeb051e70bc0370b1b7505b7d9c5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:40:12 +0200 Subject: [PATCH 02/24] fix(platform-wallet-storage): repair partial-state derived-address rehydrate; review fixups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The load-time reconcile fired only when core_derived_addresses was fully empty for a wallet, so a wallet with SOME live-derived rows plus a pool address that never derived was left un-repaired — its balance under-reported. Make the reconcile self-heal partial state, purely additively: - QA-003: drop the empty-only gate. The reconcile now upserts every pool-snapshot address via a dedicated INSERT ... ON CONFLICT DO NOTHING (INSERT_DERIVED_ADDRESS_IF_ABSENT_SQL), so it fills gaps without ever clobbering an existing live/mirrored row's account_index, derivation_path, or used flag. The live apply path keeps its DO UPDATE behavior unchanged. New test load_reconciles_partial_state_without_clobbering_live_rows seeds a live row (used=true, off-pool index) + a missing pool address and asserts the gap is filled while the live row is untouched (RED before, GREEN after). - PROJ-002: SCHEMA.md core_derived_addresses now documents all three population sources (live events, apply_pools mirror, load reconcile) routed through upsert_derived_address_row, and the skip (not reject) UTXO contract. - PROJ-003: rehydrate_derived_addresses_from_pools demoted to pub(crate). - QA-002: present-state comment at the upsert ON CONFLICT clause documenting write-once used (refreshes account_index/derivation_path, never used). - QA-004: reworded the C3 skip comment + warn — the balance re-warms only once the address is later derived (gap-limit dependent), not unconditionally. - QA-005: blast-radius test now mixes a live derive record with the skipped UTXO + sync-height bump and asserts the derive record committed — proving non-skipped records survive, not just sync-state. - QA-001 (deferred): TODO at the V001 core_derived_addresses PK noting it omits account_index/pool_type (ON CONFLICT collapse), practically unreachable under honest BIP32. PK unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/rs-platform-wallet-storage/SCHEMA.md | 21 ++- .../migrations/V001__initial.rs | 4 + .../src/sqlite/persister.rs | 9 +- .../src/sqlite/schema/core_state.rs | 61 ++++---- .../tests/sqlite_pool_derived_rehydration.rs | 138 +++++++++++++++++- 5 files changed, 196 insertions(+), 37 deletions(-) diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index c05725583f..91e300421b 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -403,8 +403,25 @@ finalized. Rows are removed when the transaction becomes confirmed. ### `core_derived_addresses` -Address-to-account-index map. Written before UTXOs in the same -transaction so the UTXO writer can resolve `account_index` by address. +Address-to-account-index map the UTXO writer joins to resolve a UTXO's +`account_index` by address. Populated from three sources, all routed +through the shared `core_state::upsert_derived_address_row` helper so the +rows are identical regardless of origin: + +1. **Live `addresses_derived` events** — written before UTXOs in the same + transaction so the writer sees fresh rows. +2. **`apply_pools` registration mirror** — every pool-snapshot address is + mirrored here at registration, so a UTXO landing on a registered + address resolves even before its live derive event arrives + (genesis-rescan). +3. **Load-time reconcile** — on load, pool snapshots fill any address the + table is missing, purely additively (`ON CONFLICT DO NOTHING`), so an + authoritative live/mirrored row is never overwritten. + +An unspent UTXO whose address is absent from this table cannot resolve an +account; the writer **skips** it (with a `warn`) rather than erroring, so +one unresolvable row never aborts a whole flush. Its balance re-warms once +the address is later derived. - PK: `(wallet_id, account_type, address)`. - FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. diff --git a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs index 7caaaf2519..309be9b22b 100644 --- a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs +++ b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs @@ -150,6 +150,10 @@ CREATE TABLE core_derived_addresses ( address TEXT NOT NULL, derivation_path TEXT NOT NULL, used INTEGER NOT NULL, + -- TODO: PK omits account_index/pool_type, so two addresses sharing + -- (wallet_id, account_type, address) collapse via ON CONFLICT. Pre-existing, + -- practically unreachable under honest BIP32 (needs a hash collision); + -- tracked for future hardening. PRIMARY KEY (wallet_id, account_type, address), FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE ); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index e073982110..c4e83b972c 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -901,10 +901,11 @@ impl PlatformWalletPersistence for SqlitePersister { )) })?; - // Repair already-persisted DBs whose `core_derived_addresses` was - // never populated from pools: rehydrate it from the snapshots so - // the next sync's UTXO writer can resolve pool-address accounts. - // No-op when the table already has rows for this wallet. + // Reconcile `core_derived_addresses` against the pool snapshots: + // fill any pool address the derived table is missing (DBs predating + // the mirror, or partial state) so the next sync's UTXO writer can + // resolve pool-address accounts. Additive — never clobbers a live + // row; rows already covering the pools cost only no-op inserts. { let tx = conn .transaction() diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 43285d85cf..b70d4ea408 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -162,15 +162,16 @@ fn execute_upsert_utxo( // Skip an unspent UTXO whose address we never derived: bucketing it // under account 0 would mis-file live money, and erroring would abort // the whole flush (genesis-rescan can match a UTXO before its derive - // event lands). The address re-warms its balance when it later - // derives — funds-safe. The spent-only arm keeps the inert fallback. + // event lands). Funds-safe: the balance re-warms only once the + // address is later derived (gap-limit dependent), not unconditionally. + // The spent-only arm keeps the inert fallback. None if !spent => { tracing::warn!( wallet_id = %hex::encode(wallet_id), address = %address, txid = %utxo.outpoint.txid, vout = utxo.outpoint.vout, - "skipping unspent UTXO at an address absent from core_derived_addresses; balance re-warms when the address derives" + "skipping unspent UTXO at an address absent from core_derived_addresses; balance re-warms only once the address is later derived" ); return Ok(()); } @@ -204,6 +205,9 @@ pub(crate) fn derivation_path_label(pool_type: &str, derivation_index: u32) -> S format!("{pool_type}/{derivation_index}") } +// `used` is preserved on conflict (write-once): the clause refreshes +// account_index / derivation_path but never `used`, so a later live +// re-derive (which carries used=false) can't clear a true flag. const UPSERT_DERIVED_ADDRESS_SQL: &str = "INSERT INTO core_derived_addresses \ (wallet_id, account_type, account_index, address, derivation_path, used) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6) \ @@ -211,6 +215,13 @@ const UPSERT_DERIVED_ADDRESS_SQL: &str = "INSERT INTO core_derived_addresses \ account_index = excluded.account_index, \ derivation_path = excluded.derivation_path"; +// Additive reconcile: fill gaps only, never touch an existing row. A live +// or already-mirrored row is authoritative. +const INSERT_DERIVED_ADDRESS_IF_ABSENT_SQL: &str = "INSERT INTO core_derived_addresses \ + (wallet_id, account_type, account_index, address, derivation_path, used) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6) \ + ON CONFLICT(wallet_id, account_type, address) DO NOTHING"; + /// Upsert one `core_derived_addresses` row. Single writer for both the /// live `addresses_derived` event path and the `apply_pools` snapshot /// path, so the address→account_index map the UTXO writer joins is @@ -239,31 +250,21 @@ pub(crate) fn upsert_derived_address_row( Ok(()) } -/// Repopulate `core_derived_addresses` for `wallet_id` from its -/// `account_address_pools` snapshots when the derived table is empty for -/// that wallet (already-persisted DBs predating the pool→derived mirror). +/// Reconcile `core_derived_addresses` for `wallet_id` against its +/// `account_address_pools` snapshots, filling any address the snapshots +/// declare but the derived table is missing (already-persisted DBs that +/// predate the pool→derived mirror, or partial state where some addresses +/// derived live but others never did). /// -/// A no-op when the wallet already has any derived row — no duplicates, -/// no scan cost. Mirrors every `AddressInfo` of every pool through -/// [`upsert_derived_address_row`], so a rehydrated row is identical to a -/// freshly-applied one. Decoding a snapshot blob is fail-hard -/// (corruption is never skipped). -pub fn rehydrate_derived_addresses_from_pools( +/// Purely additive: every insert is `ON CONFLICT DO NOTHING`, so an +/// existing live or mirrored row (authoritative) keeps its account_index, +/// derivation_path, and used flag untouched. A wallet whose derived rows +/// already cover its pools incurs only no-op inserts. Decoding a snapshot +/// blob is fail-hard (corruption is never skipped). +pub(crate) fn rehydrate_derived_addresses_from_pools( tx: &Transaction<'_>, wallet_id: &WalletId, ) -> Result<(), WalletStorageError> { - let already_present: bool = tx - .query_row( - "SELECT 1 FROM core_derived_addresses WHERE wallet_id = ?1 LIMIT 1", - params![wallet_id.as_slice()], - |_| Ok(true), - ) - .optional()? - .unwrap_or(false); - if already_present { - return Ok(()); - } - let snapshots: Vec> = { let mut stmt = tx.prepare_cached( "SELECT snapshot_blob FROM account_address_pools WHERE wallet_id = ?1", @@ -278,6 +279,7 @@ pub fn rehydrate_derived_addresses_from_pools( out }; + let mut insert_stmt = tx.prepare_cached(INSERT_DERIVED_ADDRESS_IF_ABSENT_SQL)?; for payload in snapshots { let entry: platform_wallet::changeset::AccountAddressPoolEntry = blob::decode(&payload)?; let account_type = @@ -287,15 +289,14 @@ pub fn rehydrate_derived_addresses_from_pools( for info in &entry.addresses { let address = info.address.to_string(); let path = derivation_path_label(pool_type, info.index); - upsert_derived_address_row( - tx, - wallet_id, + insert_stmt.execute(params![ + wallet_id.as_slice(), account_type, i64::from(account_index), - &address, - &path, + address, + path, info.used, - )?; + ])?; } } Ok(()) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs index 56bb13b12e..2cbc3537b2 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs @@ -103,6 +103,27 @@ fn reopen(path: &std::path::Path) -> SqlitePersister { SqlitePersister::open(SqlitePersisterConfig::new(path)).expect("reopen") } +/// A live `DerivedAddress` event for one pool `AddressInfo` — a valid, +/// non-UTXO record for the blast-radius batch. +fn derived_for( + pool: &AccountAddressPoolEntry, + info: &AddressInfo, +) -> platform_wallet::DerivedAddress { + // Compressed secp256k1 generator point — a valid placeholder pubkey. + const PUBKEY_G: [u8; 33] = [ + 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87, + 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16, + 0xf8, 0x17, 0x98, + ]; + platform_wallet::DerivedAddress { + account_type: pool.account_type, + pool_type: pool.pool_type, + derivation_index: info.index, + address: info.address.clone(), + public_key: dashcore::PublicKey::from_slice(&PUBKEY_G).expect("valid compressed pubkey"), + } +} + /// Genesis-rescan persist: a wallet registered with pools but with NO /// live `addresses_derived` event still resolves the account index of a /// UTXO landing on a pool address — `apply_pools` mirrored the pool into @@ -310,6 +331,96 @@ fn load_rehydrates_derived_rows_from_pools_idempotently() { ); } +/// Partial-state self-heal: a wallet with SOME live-derived rows (one +/// `used = true`) plus a pool address that was never derived is repaired +/// on `load` — the missing address is added with its pool account_index, +/// and every pre-existing live row is left untouched (the reconcile is +/// purely additive, a live row is authoritative). +#[test] +fn load_reconciles_partial_state_without_clobbering_live_rows() { + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xC5); + ensure_wallet_meta(&persister, &w); + + let (snapshots, _target) = wallet_with_pools(0x55); + let pool = &snapshots[0]; + assert!( + pool.addresses.len() >= 2, + "fixture needs at least two pool addresses" + ); + let pool_account_index = i64::from(match pool.account_type { + key_wallet::account::AccountType::Standard { index, .. } => index, + _ => unreachable!("fixture uses a Standard account"), + }); + + // A pool address we deliberately pre-seed as a live row, with a + // non-pool account_index and used=true, so a clobber would be visible. + let live_addr = pool.addresses[0].address.to_string(); + // A pool address left un-derived — the gap the reconcile must fill. + let missing_addr = pool.addresses[1].address.to_string(); + const LIVE_ACCOUNT_INDEX: i64 = 4242; + + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: snapshots.clone(), + ..Default::default() + }, + ) + .unwrap(); + + // Recreate a partial prod DB: drop the auto-mirrored rows, then seed + // ONLY the live row (authoritative, used=true, off-pool index). + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "DELETE FROM core_derived_addresses WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_derived_addresses \ + (wallet_id, account_type, account_index, address, derivation_path, used) \ + VALUES (?1, 'standard', ?2, ?3, 'live/0', 1)", + rusqlite::params![w.as_slice(), LIVE_ACCOUNT_INDEX, live_addr], + ) + .unwrap(); + } + drop(persister); + + let p2 = reopen(&path); + PlatformWalletPersistence::load(&p2).expect("load"); + + let rows = { + let conn = p2.lock_conn_for_test(); + core_state::list_derived_addresses_for_test(&conn, &w).unwrap() + }; + + let missing = rows + .iter() + .find(|r| r.address == missing_addr) + .expect("the un-derived pool address must be reconciled on load"); + assert_eq!( + missing.account_index, pool_account_index, + "reconciled row must carry the pool account_index" + ); + + let live = rows + .iter() + .find(|r| r.address == live_addr) + .expect("the live row must survive"); + assert_eq!( + live.account_index, LIVE_ACCOUNT_INDEX, + "reconcile must NOT overwrite a live row's account_index" + ); + assert_eq!( + live.derivation_path, "live/0", + "reconcile must NOT overwrite a live row's derivation_path" + ); + assert!(live.used, "reconcile must NOT clear a live row's used flag"); +} + /// Blast-radius isolation: a batch mixing a valid pool-address UTXO, a /// sync-height bump, and ONE unspent UTXO at a genuinely undeclared /// address (not in pools, not derived) commits the valid UTXO + height @@ -324,6 +435,11 @@ fn undeclared_unspent_utxo_is_skipped_not_fatal() { let (snapshots, good) = wallet_with_pools(0x44); let good_addr = good.address.clone(); + // A second pool address for a live derive record in the same batch. + let extra = snapshots[0].addresses[1].clone(); + let extra_derived = derived_for(&snapshots[0], &extra); + let extra_addr = extra.address.to_string(); + assert_ne!(extra_addr, good_addr.to_string(), "fixture sanity"); // A genuinely undeclared address: not in any pool, never derived. let undeclared = { @@ -340,17 +456,29 @@ fn undeclared_unspent_utxo_is_skipped_not_fatal() { .store( w, PlatformWalletChangeSet { - account_address_pools: snapshots, + account_address_pools: snapshots.clone(), ..Default::default() }, ) .unwrap(); + // Wipe derived rows so the batch's own live derive is the only source + // of `extra_addr`, making its commit unambiguous. + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "DELETE FROM core_derived_addresses WHERE wallet_id = ?1 AND address = ?2", + rusqlite::params![w.as_slice(), extra_addr], + ) + .unwrap(); + } + persister .store( w, PlatformWalletChangeSet { core: Some(CoreChangeSet { + addresses_derived: vec![extra_derived], new_utxos: vec![ utxo_at(&good_addr, 0, 100_000), utxo_at(&undeclared, 9, 200_000), @@ -377,6 +505,14 @@ fn undeclared_unspent_utxo_is_skipped_not_fatal() { "the committed UTXO is the good one" ); + // A normal valid record in the same batch (the live derive) committed — + // the skip isolates only the bad UTXO, not the surrounding records. + let derived = core_state::list_derived_addresses_for_test(&conn, &w).unwrap(); + assert!( + derived.iter().any(|r| r.address == extra_addr), + "the live derive record in the mixed batch must commit" + ); + // The sync-height bump committed in the same transaction. let synced: Option = conn .query_row( From 67d0eba8d35f7ba580c1d201c783da84155447a5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 10 Jun 2026 09:24:28 +0200 Subject: [PATCH 03/24] fix(platform-wallet-storage): harden core_derived_addresses with BIP32-leaf PK + UNIQUE(address) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core_derived_addresses read-index PK was (wallet_id, account_type, address), which silently collapsed two addresses sharing that key via ON CONFLICT. Re-key the table on the BIP32 leaf identity and demote `address` to a UNIQUE-guarded derived attribute, so EVERY address collision — within-pool or cross-pool — surfaces loud instead of corrupting the address->account_index map. PK is (wallet_id, account_type, pool_type, derivation_index): one row per derived leaf, so a pool of N addresses persists N rows. `account_index` stays a column (account-level context, the value the read returns) but is not a uniqueness discriminator. UNIQUE(wallet_id, address) is the sole arbiter of address uniqueness and its index backs ACCOUNT_INDEX_BY_ADDRESS_SQL (the standalone idx_core_derived_addresses_addr is dropped as redundant). The free-text derivation_path column ("pool_type/index") is dropped: it was a denormalised mirror of the now-typed pool_type + derivation_index columns with no production reader. derivation_path_label is removed and derivation_index is plumbed as a typed u32 through upsert_derived_address_row (pub(crate)). Authoritative upsert is ON CONFLICT() DO NOTHING — a same-leaf re-derive is deterministic (address slot-derived, used write-once) so there is nothing to update; a different leaf yielding the same address trips UNIQUE(address). Reconcile stays INSERT OR IGNORE so a would-be UNIQUE collision is skipped rather than aborting load(). V001 edited in place — no committed DB fixture exists. SCHEMA.md updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/rs-platform-wallet-storage/SCHEMA.md | 21 +- .../migrations/V001__initial.rs | 17 +- .../src/sqlite/schema/accounts.rs | 5 +- .../src/sqlite/schema/core_state.rs | 81 ++--- .../tests/sqlite_compile_time.rs | 2 +- .../tests/sqlite_migrations.rs | 2 +- .../tests/sqlite_object_metadata.rs | 2 +- .../tests/sqlite_pool_derived_rehydration.rs | 287 +++++++++++++++++- .../tests/sqlite_structural_hardening.rs | 10 +- 9 files changed, 350 insertions(+), 77 deletions(-) diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index 91e300421b..132e664173 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -105,9 +105,10 @@ erDiagram CORE_DERIVED_ADDRESSES { BLOB wallet_id PK TEXT account_type PK - TEXT address PK "bech32 / Base58 address string" - INTEGER account_index - TEXT derivation_path "pool_type/derivation_index" + TEXT pool_type PK "external | internal | absent | absent_hardened" + INTEGER derivation_index PK + INTEGER account_index "account-level context; the value the read returns" + TEXT address UK "bech32 / Base58 address string" INTEGER used "0 | 1" } @@ -415,17 +416,22 @@ rows are identical regardless of origin: address resolves even before its live derive event arrives (genesis-rescan). 3. **Load-time reconcile** — on load, pool snapshots fill any address the - table is missing, purely additively (`ON CONFLICT DO NOTHING`), so an - authoritative live/mirrored row is never overwritten. + table is missing, purely additively (`INSERT OR IGNORE`), so an + authoritative live/mirrored row is never overwritten and a would-be + `UNIQUE(address)` collision is skipped rather than aborting the load. An unspent UTXO whose address is absent from this table cannot resolve an account; the writer **skips** it (with a `warn`) rather than erroring, so one unresolvable row never aborts a whole flush. Its balance re-warms once the address is later derived. -- PK: `(wallet_id, account_type, address)`. +- PK: `(wallet_id, account_type, pool_type, derivation_index)` — the BIP32 + leaf identity (one row per derived address). +- `UNIQUE(wallet_id, address)` — the read-index invariant (one + account_index per address); its index also backs the address lookup, so + no separate index is needed. `address` is a derived attribute, never a + key, so every collision surfaces loud. - FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. -- Index: `idx_core_derived_addresses_addr(wallet_id, address)`. ### `core_sync_state` @@ -621,6 +627,7 @@ fifth (`contacts.state`) is a synthetic lifecycle label naming which | `account_address_pools` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` | | `account_address_pools` | `pool_type` | `sqlite::schema::accounts::POOL_TYPE_LABELS` | | `core_derived_addresses` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` | +| `core_derived_addresses` | `pool_type` | `sqlite::schema::accounts::POOL_TYPE_LABELS` | | `asset_locks` | `status` | `sqlite::schema::asset_locks::ASSET_LOCK_STATUS_LABELS` | | `contacts` | `state` | `sqlite::schema::contacts::CONTACT_STATE_LABELS` | diff --git a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs index 309be9b22b..3420d0f73e 100644 --- a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs +++ b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs @@ -147,19 +147,20 @@ CREATE TABLE core_derived_addresses ( wallet_id BLOB NOT NULL, account_type TEXT NOT NULL CHECK (account_type IN {account_type_check}), account_index INTEGER NOT NULL, + pool_type TEXT NOT NULL CHECK (pool_type IN {pool_type_check}), + derivation_index INTEGER NOT NULL, address TEXT NOT NULL, - derivation_path TEXT NOT NULL, used INTEGER NOT NULL, - -- TODO: PK omits account_index/pool_type, so two addresses sharing - -- (wallet_id, account_type, address) collapse via ON CONFLICT. Pre-existing, - -- practically unreachable under honest BIP32 (needs a hash collision); - -- tracked for future hardening. - PRIMARY KEY (wallet_id, account_type, address), + -- PK is the BIP32 leaf identity. `address` is a derived attribute, not + -- a key, so every collision (within- or cross-pool) trips + -- UNIQUE(address) loud. `account_index` is account-level context (the + -- value the read returns), not a uniqueness discriminator. The UNIQUE + -- index also backs ACCOUNT_INDEX_BY_ADDRESS_SQL. + PRIMARY KEY (wallet_id, account_type, pool_type, derivation_index), + UNIQUE (wallet_id, address), FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE ); -CREATE INDEX idx_core_derived_addresses_addr ON core_derived_addresses(wallet_id, address); - CREATE TABLE core_sync_state ( wallet_id BLOB NOT NULL PRIMARY KEY, last_processed_height INTEGER, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs index 882ee916ea..c04c078f2b 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs @@ -158,15 +158,14 @@ pub fn apply_pools( // helper keeps these rows identical to the live derive path. for info in &entry.addresses { let address = info.address.to_string(); - let path = - crate::sqlite::schema::core_state::derivation_path_label(pool_type, info.index); crate::sqlite::schema::core_state::upsert_derived_address_row( tx, wallet_id, account_type, i64::from(account_index), + pool_type, + info.index, &address, - &path, info.used, )?; } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index b70d4ea408..9b4fa49318 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -61,7 +61,6 @@ pub fn apply( let account_index = crate::sqlite::schema::accounts::account_index(&da.account_type); let pool_type = crate::sqlite::schema::accounts::pool_type_db_label(&da.pool_type); let address = da.address.to_string(); - let path = derivation_path_label(pool_type, da.derivation_index); // Live derive events carry no `used` flag — default false; a pool // snapshot (the other caller of this helper) carries the real one. upsert_derived_address_row( @@ -69,8 +68,9 @@ pub fn apply( wallet_id, account_type, i64::from(account_index), + pool_type, + da.derivation_index, &address, - &path, false, )?; } @@ -196,31 +196,23 @@ fn execute_upsert_utxo( Ok(()) } -/// The `derivation_path` TEXT stored on a `core_derived_addresses` row: -/// `"{pool_type}/{index}"`. Rendering both the live derive path and the -/// pool-snapshot path through this one function keeps the two writers -/// byte-identical (the snapshot's own BIP32 `path` is deliberately not -/// used, so a row is independent of which source produced it). -pub(crate) fn derivation_path_label(pool_type: &str, derivation_index: u32) -> String { - format!("{pool_type}/{derivation_index}") -} - -// `used` is preserved on conflict (write-once): the clause refreshes -// account_index / derivation_path but never `used`, so a later live -// re-derive (which carries used=false) can't clear a true flag. +// Conflict target = the BIP32-leaf PK. A same-leaf re-derive is +// deterministic — `address` is a pure function of the slot and `used` is +// write-once — so there is nothing legitimate to update; DO NOTHING. A +// different leaf yielding the same `address` is a UNIQUE(address) +// violation, not a PK hit, so it surfaces loud. const UPSERT_DERIVED_ADDRESS_SQL: &str = "INSERT INTO core_derived_addresses \ - (wallet_id, account_type, account_index, address, derivation_path, used) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6) \ - ON CONFLICT(wallet_id, account_type, address) DO UPDATE SET \ - account_index = excluded.account_index, \ - derivation_path = excluded.derivation_path"; + (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ + ON CONFLICT(wallet_id, account_type, pool_type, derivation_index) DO NOTHING"; -// Additive reconcile: fill gaps only, never touch an existing row. A live -// or already-mirrored row is authoritative. -const INSERT_DERIVED_ADDRESS_IF_ABSENT_SQL: &str = "INSERT INTO core_derived_addresses \ - (wallet_id, account_type, account_index, address, derivation_path, used) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6) \ - ON CONFLICT(wallet_id, account_type, address) DO NOTHING"; +// Additive reconcile: fill gaps only, never touch an existing row. `OR +// IGNORE` skips ALL constraint violations (PK and UNIQUE(address)) so a +// would-be address collision can't abort the load — safe because an +// authoritative row already owns any colliding address. +const INSERT_DERIVED_ADDRESS_IF_ABSENT_SQL: &str = "INSERT OR IGNORE INTO core_derived_addresses \ + (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"; /// Upsert one `core_derived_addresses` row. Single writer for both the /// live `addresses_derived` event path and the `apply_pools` snapshot @@ -229,13 +221,17 @@ const INSERT_DERIVED_ADDRESS_IF_ABSENT_SQL: &str = "INSERT INTO core_derived_add /// insert only — the conflict clause leaves an existing `used` untouched /// so a later live re-derive (which carries no flag) cannot clear a /// snapshot's real value. +// Args map 1:1 onto the row's NOT-NULL columns; a wrapper struct would add +// a single-use type for the two call sites without improving clarity. +#[allow(clippy::too_many_arguments)] pub(crate) fn upsert_derived_address_row( tx: &Transaction<'_>, wallet_id: &WalletId, account_type: &str, account_index: i64, + pool_type: &str, + derivation_index: u32, address: &str, - derivation_path: &str, used: bool, ) -> Result<(), WalletStorageError> { let mut stmt = tx.prepare_cached(UPSERT_DERIVED_ADDRESS_SQL)?; @@ -243,8 +239,9 @@ pub(crate) fn upsert_derived_address_row( wallet_id.as_slice(), account_type, account_index, + pool_type, + i64::from(derivation_index), address, - derivation_path, used, ])?; Ok(()) @@ -256,11 +253,13 @@ pub(crate) fn upsert_derived_address_row( /// predate the pool→derived mirror, or partial state where some addresses /// derived live but others never did). /// -/// Purely additive: every insert is `ON CONFLICT DO NOTHING`, so an -/// existing live or mirrored row (authoritative) keeps its account_index, -/// derivation_path, and used flag untouched. A wallet whose derived rows -/// already cover its pools incurs only no-op inserts. Decoding a snapshot -/// blob is fail-hard (corruption is never skipped). +/// Purely additive: every insert is `INSERT OR IGNORE`, so an existing +/// authoritative row (live or mirrored) keeps its account_index, +/// pool_type, derivation_index, and used flag untouched, and a would-be +/// UNIQUE(address) collision is skipped rather than aborting the load. A +/// wallet whose derived rows already cover its pools incurs only no-op +/// inserts. Decoding a snapshot blob is fail-hard (corruption is never +/// skipped). pub(crate) fn rehydrate_derived_addresses_from_pools( tx: &Transaction<'_>, wallet_id: &WalletId, @@ -288,13 +287,13 @@ pub(crate) fn rehydrate_derived_addresses_from_pools( let pool_type = crate::sqlite::schema::accounts::pool_type_db_label(&entry.pool_type); for info in &entry.addresses { let address = info.address.to_string(); - let path = derivation_path_label(pool_type, info.index); insert_stmt.execute(params![ wallet_id.as_slice(), account_type, i64::from(account_index), + pool_type, + i64::from(info.index), address, - path, info.used, ])?; } @@ -558,8 +557,9 @@ pub fn list_unspent_utxos( pub struct DerivedAddressRow { pub account_type: String, pub account_index: i64, + pub pool_type: String, + pub derivation_index: i64, pub address: String, - pub derivation_path: String, pub used: bool, } @@ -571,17 +571,18 @@ pub fn list_derived_addresses_for_test( wallet_id: &WalletId, ) -> Result, WalletStorageError> { let mut stmt = conn.prepare( - "SELECT account_type, account_index, address, derivation_path, used \ + "SELECT account_type, account_index, pool_type, derivation_index, address, used \ FROM core_derived_addresses WHERE wallet_id = ?1 \ - ORDER BY account_type, account_index, address", + ORDER BY account_type, pool_type, derivation_index", )?; let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { Ok(DerivedAddressRow { account_type: row.get(0)?, account_index: row.get(1)?, - address: row.get(2)?, - derivation_path: row.get(3)?, - used: row.get(4)?, + pool_type: row.get(2)?, + derivation_index: row.get(3)?, + address: row.get(4)?, + used: row.get(5)?, }) })?; let mut out = Vec::new(); diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs index d85cd9be24..c294935cbb 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -60,7 +60,7 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ ("core_state.rs", "SELECT outpoint, value, script, height"), ( "core_state.rs", - "SELECT account_type, account_index, address, derivation_path, used", + "SELECT account_type, account_index, pool_type, derivation_index, address, used", ), // Full-rehydration readers — one-shot SELECTs in `load_state`. ( diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs index 628d790c09..8418d268b4 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs @@ -111,7 +111,7 @@ fn tc027_smoke_insert_every_table() { ), ( "core_derived_addresses", - "INSERT INTO core_derived_addresses (wallet_id, account_type, account_index, address, derivation_path, used) VALUES (?1, 'standard', 0, 'addr', '', 0)", + "INSERT INTO core_derived_addresses (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) VALUES (?1, 'standard', 0, 'external', 0, 'addr', 0)", &[&wallet_id.as_slice()], ), ( diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs b/packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs index a2f748545d..807f80df0c 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs @@ -1021,7 +1021,7 @@ fn delete_wallet_leaves_no_surviving_rows() { ("INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) VALUES (?1, ?2, 0, X'00')", &[&a.as_slice(), &txid]), ("INSERT INTO core_utxos (wallet_id, outpoint, value, script, account_index, spent) VALUES (?1, ?2, 0, X'00', 0, 0)", &[&a.as_slice(), &outpoint]), ("INSERT INTO core_instant_locks (wallet_id, txid, islock_blob) VALUES (?1, ?2, X'00')", &[&a.as_slice(), &txid]), - ("INSERT INTO core_derived_addresses (wallet_id, account_type, account_index, address, derivation_path, used) VALUES (?1, 'standard', 0, 'addr', '', 0)", &[&a.as_slice()]), + ("INSERT INTO core_derived_addresses (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) VALUES (?1, 'standard', 0, 'external', 0, 'addr', 0)", &[&a.as_slice()]), ("INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) VALUES (?1, 1, 1)", &[&a.as_slice()]), ("INSERT INTO identity_keys (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) VALUES (?1, ?2, 0, X'00', X'00', NULL)", &[&a.as_slice(), &idy.as_slice()]), ("INSERT INTO platform_address_sync (wallet_id, sync_height, sync_timestamp, last_known_recent_block) VALUES (?1, 0, 0, 0)", &[&a.as_slice()]), diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs index 2cbc3537b2..1b450ce28f 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs @@ -28,10 +28,8 @@ use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; /// Snapshot a freshly seeded wallet's Standard BIP44 external pool as a /// single `AccountAddressPoolEntry`, mirroring the production registration -/// round in `wallet_lifecycle.rs`. One pool keeps the derived-row keys -/// `(account_type, address)` unique across the returned set, so the test -/// reads back exactly what it wrote — the cross-account label collapse -/// the schema PK allows is exercised elsewhere, not load-bearing here. +/// round in `wallet_lifecycle.rs`. One pool of distinct BIP32 leaves keeps +/// every derived row unique, so the test reads back exactly what it wrote. fn standard_external_pool(info: &ManagedWalletInfo) -> AccountAddressPoolEntry { use key_wallet::account::AccountType; use key_wallet::managed_account::address_pool::AddressPoolType; @@ -174,7 +172,8 @@ fn genesis_rescan_utxo_at_pool_address_persists() { } /// Row-shape parity: a `core_derived_addresses` row written via -/// `apply_pools` is byte-identical (account_index, derivation_path, used) +/// `apply_pools` is byte-identical (account_index, pool_type, +/// derivation_index, used) /// to the row the live `core_state::apply` writes for the same address — /// the two sources share one helper, so they cannot drift. #[test] @@ -257,8 +256,12 @@ fn pool_and_live_derived_rows_are_identical() { "account_index must match" ); assert_eq!( - row_pool.derivation_path, row_live.derivation_path, - "derivation_path must be rendered identically by both sources" + row_pool.pool_type, row_live.pool_type, + "pool_type must match" + ); + assert_eq!( + row_pool.derivation_index, row_live.derivation_index, + "derivation_index must match" ); // The live path hardcodes used=false; an unused pool address agrees. assert!( @@ -359,6 +362,10 @@ fn load_reconciles_partial_state_without_clobbering_live_rows() { // A pool address left un-derived — the gap the reconcile must fill. let missing_addr = pool.addresses[1].address.to_string(); const LIVE_ACCOUNT_INDEX: i64 = 4242; + // Off-pool leaf: a different pool_type/derivation_index than the + // external-pool leaf the reconcile would assign, so the would-be + // reconcile insert is a UNIQUE(address) skip, not a PK no-op. + const LIVE_DERIVATION_INDEX: i64 = 999; persister .store( @@ -381,9 +388,14 @@ fn load_reconciles_partial_state_without_clobbering_live_rows() { .unwrap(); conn.execute( "INSERT INTO core_derived_addresses \ - (wallet_id, account_type, account_index, address, derivation_path, used) \ - VALUES (?1, 'standard', ?2, ?3, 'live/0', 1)", - rusqlite::params![w.as_slice(), LIVE_ACCOUNT_INDEX, live_addr], + (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ + VALUES (?1, 'standard', ?2, 'internal', ?3, ?4, 1)", + rusqlite::params![ + w.as_slice(), + LIVE_ACCOUNT_INDEX, + LIVE_DERIVATION_INDEX, + live_addr + ], ) .unwrap(); } @@ -415,8 +427,12 @@ fn load_reconciles_partial_state_without_clobbering_live_rows() { "reconcile must NOT overwrite a live row's account_index" ); assert_eq!( - live.derivation_path, "live/0", - "reconcile must NOT overwrite a live row's derivation_path" + live.pool_type, "internal", + "reconcile must NOT overwrite a live row's pool_type" + ); + assert_eq!( + live.derivation_index, LIVE_DERIVATION_INDEX, + "reconcile must NOT overwrite a live row's derivation_index" ); assert!(live.used, "reconcile must NOT clear a live row's used flag"); } @@ -527,3 +543,250 @@ fn undeclared_unspent_utxo_is_skipped_not_fatal() { "sync-height must commit alongside valid records" ); } + +/// Build a live `DerivedAddress` for an explicit slot + address — the raw +/// material for the Design-Z invariant tests below. +fn derived_at( + account_type: key_wallet::account::AccountType, + pool_type: key_wallet::managed_account::address_pool::AddressPoolType, + derivation_index: u32, + address: dashcore::Address, +) -> platform_wallet::DerivedAddress { + const PUBKEY_G: [u8; 33] = [ + 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87, + 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16, + 0xf8, 0x17, 0x98, + ]; + platform_wallet::DerivedAddress { + account_type, + pool_type, + derivation_index, + address, + public_key: dashcore::PublicKey::from_slice(&PUBKEY_G).expect("valid compressed pubkey"), + } +} + +/// An arbitrary testnet P2PKH address from a byte pattern. +fn addr_from(byte: u8) -> dashcore::Address { + use dashcore::address::Payload; + use dashcore::hashes::Hash; + use dashcore::PubkeyHash; + dashcore::Address::new( + dashcore::Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([byte; 20])), + ) +} + +/// A Standard BIP44 account type for the explicit-slot fixtures. +fn standard_account() -> key_wallet::account::AccountType { + key_wallet::account::AccountType::Standard { + index: 0, + standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, + } +} + +/// Assert a storage error is a SQLite UNIQUE-constraint violation. +fn assert_unique_violation(err: platform_wallet_storage::WalletStorageError) { + match err { + platform_wallet_storage::WalletStorageError::Sqlite(rusqlite::Error::SqliteFailure( + e, + _, + )) => assert_eq!( + e.code, + rusqlite::ErrorCode::ConstraintViolation, + "expected a UNIQUE constraint violation, got {e:?}" + ), + other => panic!("expected a SQLite constraint error, got {other:?}"), + } +} + +/// The whole BIP32 leaf grain: a multi-address pool persists ONE row per +/// derivation index — never a single collapsed row. Regression guard for +/// the 1-row collapse a non-leaf PK caused. +#[test] +fn multi_address_pool_persists_one_row_per_leaf() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF0); + ensure_wallet_meta(&persister, &w); + + let (snapshots, _target) = wallet_with_pools(0x22); + let pool_len = snapshots[0].addresses.len(); + assert!(pool_len >= 2, "fixture needs a multi-address pool"); + + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: snapshots, + ..Default::default() + }, + ) + .unwrap(); + + let conn = persister.lock_conn_for_test(); + let rows = core_state::list_derived_addresses_for_test(&conn, &w).unwrap(); + assert_eq!( + rows.len(), + pool_len, + "every pool address must persist its own row (no PK collapse)" + ); +} + +/// Within-pool collision goes LOUD: two distinct `derivation_index` in the +/// SAME pool resolving to the SAME address must NOT silently collapse — the +/// second authoritative write fails on UNIQUE(wallet_id, address). +#[test] +fn within_pool_address_collision_is_loud() { + use key_wallet::managed_account::address_pool::AddressPoolType; + + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF1); + ensure_wallet_meta(&persister, &w); + + let addr = addr_from(0x71); + let acct = standard_account(); + + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + core_state::apply( + &tx, + &w, + &CoreChangeSet { + addresses_derived: vec![derived_at(acct, AddressPoolType::External, 0, addr.clone())], + ..Default::default() + }, + ) + .expect("first leaf at a fresh address must persist"); + + // Leaf 1 of the SAME pool yielding the SAME address — a distinct PK + // leaf, so this is a UNIQUE(address) violation, not a PK no-op. + let err = core_state::apply( + &tx, + &w, + &CoreChangeSet { + addresses_derived: vec![derived_at(acct, AddressPoolType::External, 1, addr.clone())], + ..Default::default() + }, + ) + .expect_err("a different leaf reusing the same address must violate UNIQUE(address)"); + + assert_unique_violation(err); +} + +/// Cross-pool collision goes loud: the same address at a different +/// pool_type is still a UNIQUE(address) violation. +#[test] +fn cross_pool_address_collision_is_loud() { + use key_wallet::managed_account::address_pool::AddressPoolType; + + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF2); + ensure_wallet_meta(&persister, &w); + + let addr = addr_from(0x72); + let acct = standard_account(); + + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + core_state::apply( + &tx, + &w, + &CoreChangeSet { + addresses_derived: vec![derived_at(acct, AddressPoolType::External, 0, addr.clone())], + ..Default::default() + }, + ) + .expect("external-pool leaf must persist"); + + let err = core_state::apply( + &tx, + &w, + &CoreChangeSet { + addresses_derived: vec![derived_at(acct, AddressPoolType::Internal, 0, addr.clone())], + ..Default::default() + }, + ) + .expect_err("the same address in a different pool must violate UNIQUE(address)"); + + assert_unique_violation(err); +} + +/// Reconcile stays non-fatal: a pre-existing live row holds an address at +/// one leaf; the pool snapshot declares the SAME address at a DIFFERENT +/// leaf. On `load`, the gap-fill `INSERT OR IGNORE` must SILENTLY skip the +/// would-be UNIQUE(address) collision rather than aborting the load — and +/// the authoritative live row must survive untouched. +#[test] +fn load_reconcile_silently_skips_unique_address_collision() { + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xF3); + ensure_wallet_meta(&persister, &w); + + let (snapshots, target) = wallet_with_pools(0x66); + let pool_addr = target.address.to_string(); + + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: snapshots.clone(), + ..Default::default() + }, + ) + .unwrap(); + + // Recreate a partial DB: drop the auto-mirrored rows, then seed ONE + // live row claiming the pool address at a DIFFERENT leaf (off-pool + // pool_type + derivation_index). Reconcile would try to (re)insert the + // pool address at its real leaf — a UNIQUE(address) collision. + const LIVE_ACCOUNT_INDEX: i64 = 0; + const LIVE_DERIVATION_INDEX: i64 = 7777; + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "DELETE FROM core_derived_addresses WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_derived_addresses \ + (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ + VALUES (?1, 'standard', ?2, 'internal', ?3, ?4, 1)", + rusqlite::params![ + w.as_slice(), + LIVE_ACCOUNT_INDEX, + LIVE_DERIVATION_INDEX, + pool_addr + ], + ) + .unwrap(); + } + drop(persister); + + let p2 = reopen(&path); + PlatformWalletPersistence::load(&p2).expect("reconcile must not abort load on a UNIQUE skip"); + + let rows = { + let conn = p2.lock_conn_for_test(); + core_state::list_derived_addresses_for_test(&conn, &w).unwrap() + }; + let at_addr: Vec<_> = rows.iter().filter(|r| r.address == pool_addr).collect(); + assert_eq!( + at_addr.len(), + 1, + "UNIQUE(address) guarantees exactly one read-index row per address" + ); + let live = at_addr[0]; + assert_eq!( + live.pool_type, "internal", + "the authoritative live row's pool_type must survive the skipped reconcile insert" + ); + assert_eq!( + live.derivation_index, LIVE_DERIVATION_INDEX, + "the authoritative live row's derivation_index must survive" + ); + assert!( + live.used, + "reconcile must not clear the live row's used flag" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs b/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs index 5c48dd4656..317ad51eb4 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs @@ -133,12 +133,14 @@ fn multi_account_utxos_bucket_to_real_account() { { let mut conn = persister.lock_conn_for_test(); // Pre-seed the derived-address map with two distinct accounts. - for (acct, addr) in [(5u32, &addr_acct5), (9u32, &addr_acct9)] { + // Distinct derivation_index keeps the rows off the same BIP32-leaf + // PK (account_index is account-level context, not a key). + for (acct, deriv, addr) in [(5u32, 0u32, &addr_acct5), (9u32, 1u32, &addr_acct9)] { conn.execute( "INSERT INTO core_derived_addresses \ - (wallet_id, account_type, account_index, address, derivation_path, used) \ - VALUES (?1, 'standard', ?2, ?3, '0/0', 0)", - params![w.as_slice(), acct as i64, addr.to_string()], + (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ + VALUES (?1, 'standard', ?2, 'external', ?3, ?4, 0)", + params![w.as_slice(), acct as i64, deriv as i64, addr.to_string()], ) .unwrap(); } From ff1208a78eb3929867120fc4bd9017dbf6fae375 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 10 Jun 2026 09:52:50 +0200 Subject: [PATCH 04/24] test(platform-wallet-storage): close core_derived_addresses coverage gaps Follow-up to the BIP32-leaf schema hardening. Test-only; no production changes. - Guard `used` write-once on the authoritative apply path: a same-leaf live re-derive (used=false) must not clear a stored used=true. The DO NOTHING clause enforces it; mutation-verified RED under DO UPDATE SET used = excluded.used, GREEN restored. - Tighten assert_unique_violation to assert SQLITE_CONSTRAINT_UNIQUE (2067), not the generic constraint bucket, matching its contract. - Anchor the new CHECK constraints directly: reject an invalid pool_type and account_type inserted into core_derived_addresses. - Exercise the FK ON DELETE CASCADE for core_derived_addresses: rows vanish when their wallet is deleted. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/sqlite_check_constraints.rs | 43 +++++++++- .../tests/sqlite_foreign_keys.rs | 42 ++++++++++ .../tests/sqlite_pool_derived_rehydration.rs | 84 +++++++++++++++++-- 3 files changed, 160 insertions(+), 9 deletions(-) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs b/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs index 8321468b14..2f8edf5176 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs @@ -1,10 +1,11 @@ //! Smoke tests for the enum-domain `CHECK` constraints. The schema has //! seven such TEXT columns across five domains (`account_type` is reused //! by `account_registrations`, `account_address_pools`, and -//! `core_derived_addresses`). These tests exercise one column per -//! upstream-enum domain: `wallets.network`, +//! `core_derived_addresses`; `pool_type` by `account_address_pools` and +//! `core_derived_addresses`). These tests exercise `wallets.network`, //! `account_registrations.account_type`, `account_address_pools.pool_type`, -//! and `asset_locks.status`. The synthetic `contacts.state` domain is not +//! `asset_locks.status`, and both `core_derived_addresses.pool_type` / +//! `account_type` directly. The synthetic `contacts.state` domain is not //! exercised here. //! //! The per-module parity unit tests in `src/sqlite/schema/*` cover the @@ -95,6 +96,42 @@ fn check_rejects_bad_pool_type() { assert_constraint_check(res, "account_address_pools.pool_type"); } +#[test] +fn check_rejects_bad_pool_type_on_derived_addresses() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", + params![wid(5).as_slice(), "testnet", 0i64], + ) + .expect("seed wallets"); + let res = conn.execute( + "INSERT INTO core_derived_addresses \ + (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ + VALUES (?1, 'standard', 0, ?2, 0, 'addr', 0)", + params![wid(5).as_slice(), "not_a_pool"], + ); + assert_constraint_check(res, "core_derived_addresses.pool_type"); +} + +#[test] +fn check_rejects_bad_account_type_on_derived_addresses() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", + params![wid(6).as_slice(), "testnet", 0i64], + ) + .expect("seed wallets"); + let res = conn.execute( + "INSERT INTO core_derived_addresses \ + (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ + VALUES (?1, ?2, 0, 'external', 0, 'addr', 0)", + params![wid(6).as_slice(), "bogus_account_type"], + ); + assert_constraint_check(res, "core_derived_addresses.account_type"); +} + #[test] fn check_rejects_bad_asset_lock_status() { let (persister, _tmp, _path) = fresh_persister(); diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs b/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs index bab3480557..b1490decb7 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs @@ -65,6 +65,48 @@ fn tc047_delete_wallet_cascade() { assert_eq!(n, 0); } +/// deleting a wallet cascades `core_derived_addresses` rows away — the +/// `ON DELETE CASCADE` on the address→account read-index, exercised +/// directly rather than asserted by comment. +#[test] +fn tc047b_delete_wallet_cascades_derived_addresses() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xC1); + ensure_wallet_meta(&persister, &w); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_derived_addresses \ + (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ + VALUES (?1, 'standard', 0, 'external', 0, 'addr', 0)", + rusqlite::params![w.as_slice()], + ) + .unwrap(); + } + + let before: i64 = persister + .lock_conn_for_test() + .query_row( + "SELECT COUNT(*) FROM core_derived_addresses WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(before, 1, "seed row must exist before delete"); + + persister.delete_wallet(w).expect("delete_wallet"); + + let after: i64 = persister + .lock_conn_for_test() + .query_row( + "SELECT COUNT(*) FROM core_derived_addresses WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(after, 0, "cascade must purge core_derived_addresses rows"); +} + /// deleting a core_transactions row sets `spent_in_txid = NULL` on UTXOs. #[test] fn tc048_setnull_on_tx_delete() { diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs index 1b450ce28f..38deb6d494 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs @@ -585,17 +585,27 @@ fn standard_account() -> key_wallet::account::AccountType { } } -/// Assert a storage error is a SQLite UNIQUE-constraint violation. +/// Assert a storage error is specifically a SQLite UNIQUE-constraint +/// violation (`SQLITE_CONSTRAINT_UNIQUE`, 2067) — not merely some generic +/// constraint, so a PK/CHECK/NOT-NULL failure cannot satisfy it. fn assert_unique_violation(err: platform_wallet_storage::WalletStorageError) { match err { platform_wallet_storage::WalletStorageError::Sqlite(rusqlite::Error::SqliteFailure( e, _, - )) => assert_eq!( - e.code, - rusqlite::ErrorCode::ConstraintViolation, - "expected a UNIQUE constraint violation, got {e:?}" - ), + )) => { + assert_eq!( + e.code, + rusqlite::ErrorCode::ConstraintViolation, + "expected a constraint violation, got {e:?}" + ); + assert_eq!( + e.extended_code, + rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE, + "expected SQLITE_CONSTRAINT_UNIQUE (2067), got extended_code={}", + e.extended_code + ); + } other => panic!("expected a SQLite constraint error, got {other:?}"), } } @@ -711,6 +721,68 @@ fn cross_pool_address_collision_is_loud() { assert_unique_violation(err); } +/// `used` is write-once on the AUTHORITATIVE path: a live re-derive of the +/// same leaf carries `used=false` and must NOT clear a stored `used=true`. +/// The `DO NOTHING` conflict clause is what preserves it — a `DO UPDATE SET +/// used = excluded.used` regression would clobber the flag, and this test +/// is the guard. +#[test] +fn authoritative_redrive_preserves_used_true() { + use key_wallet::managed_account::address_pool::AddressPoolType; + + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF4); + ensure_wallet_meta(&persister, &w); + + let addr = addr_from(0x74); + let acct = standard_account(); + + // Seed the leaf with used=true (the snapshot's real flag). + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_derived_addresses \ + (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ + VALUES (?1, 'standard', 0, 'external', 0, ?2, 1)", + rusqlite::params![w.as_slice(), addr.to_string()], + ) + .unwrap(); + } + + // Live re-derive of the SAME leaf — the apply path hardcodes used=false. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + core_state::apply( + &tx, + &w, + &CoreChangeSet { + addresses_derived: vec![derived_at( + acct, + AddressPoolType::External, + 0, + addr.clone(), + )], + ..Default::default() + }, + ) + .expect("same-leaf re-derive must be a no-op, not an error"); + tx.commit().unwrap(); + } + + let conn = persister.lock_conn_for_test(); + let rows = core_state::list_derived_addresses_for_test(&conn, &w).unwrap(); + let at_addr: Vec<_> = rows + .iter() + .filter(|r| r.address == addr.to_string()) + .collect(); + assert_eq!(at_addr.len(), 1, "re-derive must not insert a second row"); + assert!( + at_addr[0].used, + "a live re-derive (used=false) must NOT clear a stored used=true" + ); +} + /// Reconcile stays non-fatal: a pre-existing live row holds an address at /// one leaf; the pool snapshot declares the SAME address at a DIFFERENT /// leaf. On `load`, the gap-fill `INSERT OR IGNORE` must SILENTLY skip the From d8d2239f3a6e54390a8e5a262b5d41bc3de85fa1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:03:14 +0200 Subject: [PATCH 05/24] fix(platform-wallet-storage): fail loud when a pool-declared address is missing from the derived index After the eager-mirror + load-time reconcile fix, every address in a wallet's persisted account_address_pools must also live in core_derived_addresses (declared => mapped). The unspent-UTXO writer used to warn-and-skip ANY address it could not resolve, which would silently swallow a regression in that mirror/reconcile and drop live money no one would notice. Discriminate the unspent miss by the pools: a declared-but-unmapped address is now a fatal DerivedIndexInvariantViolated (non-transient => Fatal at the trait boundary); a genuinely-undeclared address keeps the benign warn+skip (not-ours, or an SPV gap-limit edge). The pool-address set is decoded lazily at most once per apply, only on the first miss, and the spent-only synthetic path is left untouched (its inert account_index placeholder is excluded from reads). Co-Authored-By: Claude Opus 4.6 --- packages/rs-platform-wallet-storage/SCHEMA.md | 18 ++- .../src/sqlite/error.rs | 15 ++ .../src/sqlite/schema/core_state.rs | 91 +++++++++-- .../tests/persistence_error_kind_mapping.rs | 6 + .../tests/sqlite_error_classification.rs | 6 + .../tests/sqlite_pool_derived_rehydration.rs | 145 ++++++++++++++++++ 6 files changed, 269 insertions(+), 12 deletions(-) diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index 132e664173..a35779e010 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -421,9 +421,21 @@ rows are identical regardless of origin: `UNIQUE(address)` collision is skipped rather than aborting the load. An unspent UTXO whose address is absent from this table cannot resolve an -account; the writer **skips** it (with a `warn`) rather than erroring, so -one unresolvable row never aborts a whole flush. Its balance re-warms once -the address is later derived. +account. The writer tells two cases apart by the wallet's +`account_address_pools`: + +- **Declared but unmapped** — the address IS in a persisted pool snapshot + yet missing here, so the eager-mirror (`apply_pools`) / load-time + reconcile invariant "declared ⟹ mapped" is broken. This is a **fatal** + `DerivedIndexInvariantViolated` error: silently skipping would drop live + money over a logic regression no one would notice. +- **Truly undeclared** — the address is in no pool (not ours, or a + registration changeset not yet applied — an SPV gap-limit edge). The + writer **skips** it (with a `warn`) so one unresolvable row never aborts a + whole flush; its balance re-warms once the address is later derived. + +(The spent-only synthetic-row path is exempt from both: a spent row uses an +inert `account_index` placeholder and is excluded from reads.) - PK: `(wallet_id, account_type, pool_type, derivation_index)` — the BIP32 leaf identity (one row per derived address). diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs index a620c827a0..1448dceb1f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -227,6 +227,19 @@ pub enum WalletStorageError { #[error("unspent utxo address {address} is not in core_derived_addresses")] UtxoAddressNotDerived { address: String }, + /// An unspent UTXO named an address this wallet's persisted + /// `account_address_pools` DECLARE, yet it is missing from + /// `core_derived_addresses` — the eager-mirror (`apply_pools`) / + /// load-time reconcile invariant is broken. A declared address must + /// always carry a derived-index row, so a miss here is a logic + /// regression, never a benign SPV gap; failing loud surfaces it + /// instead of silently mis-filing or dropping live money. + #[error( + "derived-index invariant violated: pool-declared address {address} is missing from \ + core_derived_addresses (eager-mirror/reconcile broken)" + )] + DerivedIndexInvariantViolated { address: String }, + /// `PRAGMA foreign_keys = ON` was issued on open but the read-back /// reported the constraint enforcement is still off — the linked /// SQLite build silently ignores the pragma (no FK support compiled @@ -375,6 +388,7 @@ impl WalletStorageError { | Self::AssetLockEntryMismatch { .. } | Self::BlobTooLarge { .. } | Self::UtxoAddressNotDerived { .. } + | Self::DerivedIndexInvariantViolated { .. } | Self::IntegerOverflow { .. } => false, } } @@ -452,6 +466,7 @@ impl WalletStorageError { Self::AssetLockEntryMismatch { .. } => "asset_lock_entry_mismatch", Self::BlobTooLarge { .. } => "blob_too_large", Self::UtxoAddressNotDerived { .. } => "utxo_address_not_derived", + Self::DerivedIndexInvariantViolated { .. } => "derived_index_invariant_violated", Self::IntegerOverflow { .. } => "integer_overflow", } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 9b4fa49318..d8f3e4ae51 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -74,11 +74,25 @@ pub fn apply( false, )?; } + // Lazily-built set of every address this wallet's `account_address_pools` + // declare, used only to discriminate an unspent-miss (see + // `execute_upsert_utxo`). Built at most once per `apply`, on the first + // miss across both UTXO loops; `None` until then so a miss-free flush + // never decodes a snapshot blob. + let mut pool_addrs: Option> = None; if !cs.new_utxos.is_empty() { let mut stmt = tx.prepare_cached(UPSERT_UTXO_SQL)?; let mut lookup_stmt = tx.prepare_cached(ACCOUNT_INDEX_BY_ADDRESS_SQL)?; for utxo in &cs.new_utxos { - execute_upsert_utxo(&mut stmt, &mut lookup_stmt, wallet_id, utxo, false)?; + execute_upsert_utxo( + tx, + &mut stmt, + &mut lookup_stmt, + wallet_id, + utxo, + false, + &mut pool_addrs, + )?; } } if !cs.spent_utxos.is_empty() { @@ -101,7 +115,15 @@ pub fn apply( // Spent-only synthetic row: best-effort account_index. A wrong // index is inert since spent rows are excluded from // `list_unspent_utxos`. - execute_upsert_utxo(&mut upsert_stmt, &mut lookup_stmt, wallet_id, utxo, true)?; + execute_upsert_utxo( + tx, + &mut upsert_stmt, + &mut lookup_stmt, + wallet_id, + utxo, + true, + &mut pool_addrs, + )?; } } } @@ -144,12 +166,15 @@ const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ account_index = excluded.account_index, \ spent = excluded.spent"; +#[allow(clippy::too_many_arguments)] fn execute_upsert_utxo( + tx: &Transaction<'_>, stmt: &mut rusqlite::CachedStatement<'_>, lookup_stmt: &mut rusqlite::CachedStatement<'_>, wallet_id: &WalletId, utxo: &Utxo, spent: bool, + pool_addrs: &mut Option>, ) -> Result<(), WalletStorageError> { let op = blob::encode_outpoint(&utxo.outpoint)?; let address = utxo.address.to_string(); @@ -159,19 +184,33 @@ fn execute_upsert_utxo( .optional()?; let account_index: i64 = match looked_up { Some(idx) => idx, - // Skip an unspent UTXO whose address we never derived: bucketing it - // under account 0 would mis-file live money, and erroring would abort - // the whole flush (genesis-rescan can match a UTXO before its derive - // event lands). Funds-safe: the balance re-warms only once the - // address is later derived (gap-limit dependent), not unconditionally. - // The spent-only arm keeps the inert fallback. + // An unspent miss is one of two cases, told apart by the pools: + // + // 1. The address IS declared in this wallet's `account_address_pools` + // but absent from `core_derived_addresses` — the eager-mirror / + // load-time reconcile invariant ("declared ⟹ mapped") is broken. + // Fatal: silently skipping would drop live money over a logic + // regression no one would ever notice. + // 2. The address is NOT declared (not ours, or a registration + // changeset not yet applied — an SPV gap-limit edge). Benign: + // skip (warn) so one unresolvable row never aborts a whole flush; + // the balance re-warms once the address is later derived. + // + // The spent-only arm keeps the inert fallback regardless. None if !spent => { + let pools = match pool_addrs { + Some(set) => &*set, + None => pool_addrs.insert(pool_declared_addresses(tx, wallet_id)?), + }; + if pools.contains(&address) { + return Err(WalletStorageError::DerivedIndexInvariantViolated { address }); + } tracing::warn!( wallet_id = %hex::encode(wallet_id), address = %address, txid = %utxo.outpoint.txid, vout = utxo.outpoint.vout, - "skipping unspent UTXO at an address absent from core_derived_addresses; balance re-warms only once the address is later derived" + "skipping unspent UTXO at an undeclared address absent from core_derived_addresses; balance re-warms only once the address is later derived" ); return Ok(()); } @@ -247,6 +286,40 @@ pub(crate) fn upsert_derived_address_row( Ok(()) } +/// Every address this wallet's persisted `account_address_pools` snapshots +/// declare, as a membership set. Used to discriminate an unspent-UTXO miss: +/// a declared address absent from `core_derived_addresses` is a broken-mirror +/// invariant violation, an undeclared one is a benign skip. Reuses the +/// snapshot-decode pattern of [`rehydrate_derived_addresses_from_pools`]; +/// a corrupt snapshot is fail-hard (never skipped). +fn pool_declared_addresses( + tx: &Transaction<'_>, + wallet_id: &WalletId, +) -> Result, WalletStorageError> { + let snapshots: Vec> = { + let mut stmt = tx.prepare_cached( + "SELECT snapshot_blob FROM account_address_pools WHERE wallet_id = ?1", + )?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + row.get::<_, Vec>(0) + })?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + out + }; + + let mut set = std::collections::HashSet::new(); + for payload in snapshots { + let entry: platform_wallet::changeset::AccountAddressPoolEntry = blob::decode(&payload)?; + for info in &entry.addresses { + set.insert(info.address.to_string()); + } + } + Ok(set) +} + /// Reconcile `core_derived_addresses` for `wallet_id` against its /// `account_address_pools` snapshots, filling any address the snapshots /// declare but the derived table is missing (already-persisted DBs that diff --git a/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs b/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs index c9223c5320..1e14353753 100644 --- a/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs +++ b/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs @@ -217,6 +217,12 @@ fn tc_code_004_b_fatal_variants_map_to_fatal_kind() { path: PathBuf::from("/tmp/x"), }, ), + ( + "DerivedIndexInvariantViolated", + WalletStorageError::DerivedIndexInvariantViolated { + address: "yMockAddress".into(), + }, + ), ]; for (label, err) in fatal_cases { diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs index 21e909fdf0..5aad580e99 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs @@ -160,6 +160,9 @@ fn samples() -> Vec { WalletStorageError::UtxoAddressNotDerived { address: "yMockAddress".into(), }, + WalletStorageError::DerivedIndexInvariantViolated { + address: "yMockAddress".into(), + }, // BincodeEncode / BincodeDecode / HashDecode / ConsensusCodec // need real upstream errors; omitted but covered by their arms. WalletStorageError::BlobDecode { @@ -246,6 +249,9 @@ fn tc_p2_005_is_transient_table() { } WalletStorageError::BlobTooLarge { .. } => (false, "blob_too_large"), WalletStorageError::UtxoAddressNotDerived { .. } => (false, "utxo_address_not_derived"), + WalletStorageError::DerivedIndexInvariantViolated { .. } => { + (false, "derived_index_invariant_violated") + } WalletStorageError::ForeignKeysNotEnforced => (false, "foreign_keys_not_enforced"), WalletStorageError::JournalModeNotApplied { .. } => (false, "journal_mode_not_applied"), WalletStorageError::SchemaHistoryMalformed { .. } => { diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs index 38deb6d494..8c888014e3 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs @@ -862,3 +862,148 @@ fn load_reconcile_silently_skips_unique_address_collision() { "reconcile must not clear the live row's used flag" ); } + +/// Derived-index invariant goes FATAL: an address this wallet's persisted +/// `account_address_pools` DECLARE, yet that the eager-mirror/reconcile +/// failed to write into `core_derived_addresses`, must NOT be silently +/// skipped. A UTXO landing on that declared-but-unmapped address aborts the +/// flush with [`WalletStorageError::DerivedIndexInvariantViolated`] +/// (non-transient → `Fatal`), surfacing the broken invariant instead of +/// dropping live money. This is the loud counterpart to the quiet skip for a +/// genuinely-undeclared address. +#[test] +fn pool_declared_address_missing_from_derived_is_fatal() { + use platform_wallet::changeset::PersistenceErrorKind; + use platform_wallet_storage::WalletStorageError; + + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE0); + ensure_wallet_meta(&persister, &w); + + let (snapshots, target) = wallet_with_pools(0x77); + let addr = target.address.clone(); + + // Register the pool: `apply_pools` writes the snapshot AND mirrors its + // addresses into `core_derived_addresses`. + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: snapshots, + ..Default::default() + }, + ) + .unwrap(); + + // Simulate a broken mirror/reconcile: keep the pool snapshot but wipe + // the derived rows it should have produced. The invariant + // "declared ⟹ mapped" is now violated. + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "DELETE FROM core_derived_addresses WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + ) + .unwrap(); + let derived = core_state::list_derived_addresses_for_test(&conn, &w).unwrap(); + assert!( + derived.iter().all(|r| r.address != addr.to_string()), + "precondition: the declared address must be missing from the derived index" + ); + } + + // A UTXO at the declared-but-unmapped address must abort the flush. + let err = { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + core_state::apply( + &tx, + &w, + &CoreChangeSet { + new_utxos: vec![utxo_at(&addr, 0, 555_000)], + ..Default::default() + }, + ) + .expect_err("a pool-declared address missing from the derived index must be fatal") + }; + + match &err { + WalletStorageError::DerivedIndexInvariantViolated { address } => { + assert_eq!( + *address, + addr.to_string(), + "the violation must name the declared address" + ); + } + other => panic!("expected DerivedIndexInvariantViolated, got {other:?}"), + } + assert!( + !err.is_transient(), + "an invariant violation is a logic regression, never a retryable failure" + ); + assert_eq!( + err.persistence_kind(), + PersistenceErrorKind::Fatal, + "the invariant violation must classify Fatal at the trait boundary" + ); +} + +/// The guard does not over-fire: a UTXO at a genuinely-undeclared address +/// (in NO pool, never derived) is still SKIPPED quietly — no error escapes, +/// and the rest of the batch persists. Guards against the invariant guard +/// firing on a benign SPV gap-limit miss. +#[test] +fn undeclared_unspent_utxo_at_apply_level_is_skipped_not_fatal() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE1); + ensure_wallet_meta(&persister, &w); + + // Register a pool so `account_address_pools` is NON-empty (the guard + // must decode it, find the address absent, and still skip). + let (snapshots, good) = wallet_with_pools(0x88); + let good_addr = good.address.clone(); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: snapshots, + ..Default::default() + }, + ) + .unwrap(); + + // An address in NO pool and never derived. + let undeclared = addr_from(0x9A); + assert_ne!(undeclared, good_addr, "fixture sanity"); + + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + core_state::apply( + &tx, + &w, + &CoreChangeSet { + new_utxos: vec![ + utxo_at(&good_addr, 0, 100_000), + utxo_at(&undeclared, 9, 200_000), + ], + ..Default::default() + }, + ) + .expect("a genuinely-undeclared address must be skipped, not fatal"); + tx.commit().unwrap(); + } + + let conn = persister.lock_conn_for_test(); + let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); + let all: Vec<_> = by_account.values().flatten().collect(); + assert_eq!( + all.len(), + 1, + "the declared-address UTXO commits; the undeclared one is skipped" + ); + assert!( + all.iter().all(|r| r.value == 100_000), + "the committed UTXO is the one at the declared pool address" + ); +} From cccd2172adab9d19d412cfe659b750de94011305 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:48:42 +0200 Subject: [PATCH 06/24] test(platform-wallet-storage): assert resolved account_index of the surviving UTXO in the over-fire test The skip-not-fatal test only checked the surviving declared-address UTXO's value, not its resolved account_index. Also assert the survivor is bucketed under the pool's account index (read from the derived map apply_pools mirrored), proving that skipping the undeclared row leaves the good row's attribution intact rather than mis-filing it. Co-Authored-By: Claude Opus 4.6 --- .../tests/sqlite_pool_derived_rehydration.rs | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs index 8c888014e3..a35a92f899 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs @@ -995,6 +995,19 @@ fn undeclared_unspent_utxo_at_apply_level_is_skipped_not_fatal() { } let conn = persister.lock_conn_for_test(); + + // The expected account index of the good address, read from the derived + // map `apply_pools` mirrored — the same source the UTXO writer joins. + let derived = core_state::list_derived_addresses_for_test(&conn, &w).unwrap(); + let expected_index = u32::try_from( + derived + .iter() + .find(|r| r.address == good_addr.to_string()) + .expect("the good pool address must be in the derived map") + .account_index, + ) + .expect("a Standard account index fits in u32"); + let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); let all: Vec<_> = by_account.values().flatten().collect(); assert_eq!( @@ -1002,8 +1015,15 @@ fn undeclared_unspent_utxo_at_apply_level_is_skipped_not_fatal() { 1, "the declared-address UTXO commits; the undeclared one is skipped" ); - assert!( - all.iter().all(|r| r.value == 100_000), + let survivor = all[0]; + assert_eq!( + survivor.value, 100_000, "the committed UTXO is the one at the declared pool address" ); + // Skipping the undeclared row must leave the good row's attribution + // intact — it resolves to the pool's account index, not a placeholder. + assert_eq!( + survivor.account_index, expected_index, + "the surviving UTXO must keep its resolved account index" + ); } From 63fea7a23785419f90a0b46ce1ca1cd2272a6477 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:34:02 +0200 Subject: [PATCH 07/24] fix(platform-wallet): emit in-band pool snapshot on derivation so account_address_pools stays complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet-event adapter wrapped every post-registration CoreChangeSet with `account_address_pools` empty (`..Default::default()`), so the pool manifest was frozen at registration and never tracked gap-limit extensions. Those flowed only through the live `addresses_derived` delta, which has no `used` flag and is not a full pool — it can't rebuild the manifest. Attach a full pool snapshot in-band whenever the core delta derived new addresses: read the whole current pool for each affected account straight from the `WalletManager` the adapter holds, and ship it on the SAME changeset. The persister applies `account_address_pools` before the core UTXO delta in one tx, so a freshly-derived address is resolvable when that changeset's UTXOs are written — closing the gap-limit race in-band. Empty deltas emit nothing, so no write amplification on blocks that derive nothing. Additive only: the storage-side eager-mirror, load reconcile, and invariant guard are untouched this phase. Co-Authored-By: Claude Opus 4.6 --- .../src/changeset/changeset.rs | 8 +- .../src/changeset/core_bridge.rs | 249 +++++++++++++++++- 2 files changed, 249 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index d530b45d32..97cb7ba1d0 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -951,9 +951,11 @@ pub struct PlatformWalletChangeSet { /// the merge policy (plain `Vec::extend`, dedup is the apply-side /// caller's job). pub account_registrations: Vec, - /// Address-pool snapshots emitted at wallet create (initial - /// gap-limit population) and on any pool extension / "used" flip. - /// See [`AccountAddressPoolEntry`] for the merge policy. + /// Full address-pool snapshots: emitted at wallet create and, in-band, + /// on every block that derives new pool addresses (the + /// `core.addresses_derived` delta). Each entry is the whole current + /// pool, not just the new index. See [`AccountAddressPoolEntry`] for + /// the merge policy and `core_bridge::build_platform_changeset`. pub account_address_pools: Vec, /// Shielded sub-wallet deltas: per-subwallet decrypted notes, /// spent marks, sync watermarks, nullifier checkpoints. The diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 46945667ef..e755fc5417 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -24,10 +24,12 @@ //! manager's lifetime; on shutdown, fire the [`CancellationToken`] to //! make the task exit cleanly. +use std::collections::BTreeSet; use std::sync::Arc; use dashcore::blockdata::transaction::{txout::TxOut, OutPoint}; use dashcore::ScriptBuf; +use key_wallet::account::AccountType; use key_wallet::managed_account::transaction_record::{OutputRole, TransactionRecord}; use key_wallet::transaction_checking::TransactionContext; use key_wallet::Utxo; @@ -37,7 +39,9 @@ use tokio::sync::RwLock; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; -use crate::changeset::changeset::{CoreChangeSet, PlatformWalletChangeSet}; +use crate::changeset::changeset::{ + AccountAddressPoolEntry, CoreChangeSet, PlatformWalletChangeSet, +}; use crate::changeset::traits::PlatformWalletPersistence; use crate::wallet::platform_wallet::PlatformWalletInfo; @@ -85,10 +89,8 @@ where // persist. Skip the round-trip. continue; } - let cs = PlatformWalletChangeSet { - core: Some(core), - ..PlatformWalletChangeSet::default() - }; + let cs = + build_platform_changeset(&wallet_manager, &wallet_id, core).await; if let Err(e) = persister.store(wallet_id, cs) { tracing::warn!( wallet_id = %hex::encode(wallet_id), @@ -228,6 +230,76 @@ async fn build_core_changeset( } } +/// Wrap a [`CoreChangeSet`] in a [`PlatformWalletChangeSet`], attaching a +/// full pool snapshot in-band when the delta derived new addresses. +/// +/// `addresses_derived` is a delta with no `used` flag, so it can't be the +/// manifest. When non-empty the in-memory pool just changed, so we read +/// the whole current pool. It must ride the same changeset because the +/// persister applies `account_address_pools` before the core UTXO delta +/// in one tx, making any newly-derived address resolvable when this +/// changeset's UTXOs are written — closing the gap-limit race in-band. +/// +/// A `used` flip with no derivation leaves the delta empty and is +/// intentionally not snapshot here: `used` doesn't affect +/// address→account resolution, and emitting on every wallet-touching +/// block would be pure write amplification. +async fn build_platform_changeset( + wallet_manager: &Arc>>, + wallet_id: &WalletId, + core: CoreChangeSet, +) -> PlatformWalletChangeSet { + let mut account_address_pools = Vec::new(); + if !core.addresses_derived.is_empty() { + let guard = wallet_manager.read().await; + let affected: BTreeSet = core + .addresses_derived + .iter() + .map(|d| d.account_type) + .collect(); + for account_type in &affected { + account_address_pools.extend(snapshot_account_pools(&guard, wallet_id, account_type)); + } + } + PlatformWalletChangeSet { + core: Some(core), + account_address_pools, + ..PlatformWalletChangeSet::default() + } +} + +/// Snapshot one account's non-empty pools straight from the live +/// `WalletManager`, mirroring the enumeration the registration path uses +/// (`account_address_pools_blocking` is on a different type, so the shared +/// walk lives here). Empty vec when the wallet/account is absent. +fn snapshot_account_pools( + guard: &WalletManager, + wallet_id: &WalletId, + account_type: &AccountType, +) -> Vec { + let Some(info) = guard.get_wallet_info(wallet_id) else { + return Vec::new(); + }; + let accounts = info.core_wallet.accounts.all_accounts(); + let Some(account) = accounts + .iter() + .find(|a| &a.managed_account_type().to_account_type() == account_type) + else { + return Vec::new(); + }; + account + .managed_account_type() + .address_pools() + .iter() + .filter(|pool| !pool.addresses.is_empty()) + .map(|pool| AccountAddressPoolEntry { + account_type: *account_type, + pool_type: pool.pool_type, + addresses: pool.addresses.values().cloned().collect(), + }) + .collect() +} + /// Returns `true` when the wallet's stored record for `txid` is in a /// chain-locked block. Used to gate IS-lock projection. async fn is_chain_locked( @@ -357,3 +429,170 @@ impl CoreChangeSet { && self.addresses_derived.is_empty() } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::wallet::Wallet; + use key_wallet::Network; + use key_wallet_manager::DerivedAddress; + + use super::*; + use crate::wallet::core::WalletBalance; + use crate::wallet::identity::IdentityManager; + + /// Register a seeded wallet (default account creation derives the + /// gap-limit pools) into a fresh manager. + fn manager_with_wallet() -> (Arc>>, WalletId) { + let wallet = Wallet::from_seed_bytes( + [0x42; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .expect("wallet from seed"); + let info = PlatformWalletInfo { + core_wallet: ManagedWalletInfo::from_wallet(&wallet, 0), + balance: Arc::new(WalletBalance::new()), + identity_manager: IdentityManager::new(), + tracked_asset_locks: BTreeMap::new(), + }; + let mut wm = WalletManager::::new(Network::Testnet); + let wallet_id = wm.insert_wallet(wallet, info).expect("insert wallet"); + (Arc::new(RwLock::new(wm)), wallet_id) + } + + /// First funds account's type plus the index count of its external + /// pool, and a `DerivedAddress` forged from a real address in it (the + /// emitter keys only off `account_type`). + fn funds_account_and_derived( + guard: &WalletManager, + wallet_id: &WalletId, + ) -> (AccountType, usize, DerivedAddress) { + let info = guard.get_wallet_info(wallet_id).expect("wallet present"); + for account in &info.core_wallet.accounts.all_accounts() { + let account_type = account.managed_account_type().to_account_type(); + for pool in account.managed_account_type().address_pools() { + if pool.pool_type != AddressPoolType::External || pool.addresses.is_empty() { + continue; + } + let addr = pool.addresses.values().next().expect("address present"); + let Some(PublicKeyType::ECDSA(bytes)) = addr.public_key.as_ref() else { + continue; + }; + let derived = DerivedAddress { + account_type, + pool_type: pool.pool_type, + derivation_index: addr.index, + address: addr.address.clone(), + public_key: dashcore::PublicKey::from_slice(bytes).expect("valid key"), + }; + return (account_type, pool.addresses.len(), derived); + } + } + panic!("no funds account with a populated ECDSA external pool"); + } + + fn core_with_derived(derived: Vec) -> CoreChangeSet { + CoreChangeSet { + last_processed_height: Some(100), + addresses_derived: derived, + ..CoreChangeSet::default() + } + } + + /// A derivation delta yields the FULL pool (every index), not just the + /// new one, and it rides the SAME changeset as the core delta. + #[tokio::test] + async fn extension_snapshots_full_pool_in_band() { + let (wm, wallet_id) = manager_with_wallet(); + let (account_type, full_len, derived) = { + let guard = wm.read().await; + funds_account_and_derived(&guard, &wallet_id) + }; + assert!(full_len > 1, "external pool populated to the gap limit"); + + let cs = build_platform_changeset(&wm, &wallet_id, core_with_derived(vec![derived])).await; + + assert!(cs.core.is_some(), "core delta rides the same changeset"); + let entry = cs + .account_address_pools + .iter() + .find(|e| e.account_type == account_type && e.pool_type == AddressPoolType::External) + .expect("external pool snapshot for the affected account"); + assert_eq!(entry.addresses.len(), full_len, "FULL pool, not the delta"); + } + + /// An empty delta emits no pool snapshot — no write amplification on + /// blocks that derive nothing. + #[tokio::test] + async fn empty_derivation_emits_no_snapshot() { + let (wm, wallet_id) = manager_with_wallet(); + let cs = build_platform_changeset(&wm, &wallet_id, core_with_derived(Vec::new())).await; + assert!(cs.account_address_pools.is_empty()); + assert!(cs.core.is_some(), "core delta still carried"); + } + + /// The emitter snapshot matches the registration-path enumeration for + /// the same account (same pools, same addresses per pool), so + /// registration semantics are preserved. `AccountAddressPoolEntry` + /// isn't `PartialEq`, so compare on a stable projection. + #[tokio::test] + async fn emitter_matches_registration_shape() { + let (wm, wallet_id) = manager_with_wallet(); + let guard = wm.read().await; + let (account_type, _, _) = funds_account_and_derived(&guard, &wallet_id); + + let project = |entries: &[AccountAddressPoolEntry]| -> Vec<(AddressPoolType, Vec)> { + let mut out: Vec<_> = entries + .iter() + .map(|e| { + let mut addrs: Vec = + e.addresses.iter().map(|a| a.address.to_string()).collect(); + addrs.sort(); + (e.pool_type, addrs) + }) + .collect(); + out.sort_by_key(|(pt, _)| *pt); + out + }; + + let emitted = snapshot_account_pools(&guard, &wallet_id, &account_type); + + let info = guard.get_wallet_info(&wallet_id).expect("wallet present"); + let account = info + .core_wallet + .accounts + .all_accounts() + .into_iter() + .find(|a| a.managed_account_type().to_account_type() == account_type) + .expect("account present"); + let registration_shape: Vec = account + .managed_account_type() + .address_pools() + .iter() + .filter(|p| !p.addresses.is_empty()) + .map(|p| AccountAddressPoolEntry { + account_type, + pool_type: p.pool_type, + addresses: p.addresses.values().cloned().collect(), + }) + .collect(); + + assert_eq!(project(&emitted), project(®istration_shape)); + assert!(emitted.iter().all(|e| e.account_type == account_type)); + assert!(!emitted.is_empty()); + } + + /// Unknown wallet → empty snapshot, not a panic. + #[tokio::test] + async fn snapshot_unknown_wallet_is_empty() { + let (wm, wallet_id) = manager_with_wallet(); + let guard = wm.read().await; + let (account_type, _, _) = funds_account_and_derived(&guard, &wallet_id); + assert!(snapshot_account_pools(&guard, &[0xAB; 32], &account_type).is_empty()); + } +} From f7b61368707e96bc4da8220d91895c4b5b98c8d0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:54:20 +0200 Subject: [PATCH 08/24] test(platform-wallet-storage): prove in-band pool-snapshot resolution gate Adds Marvin's verification-gate scratch tests for the PR #3828 Phase-2 gate. They pin the single load-bearing claim the workaround-retirement plan rests on: a UTXO landing on a freshly-derived address resolves to the correct account_index when the pool snapshot rides the same PlatformWalletChangeSet as the UTXO, because apply_changeset_to_tx applies account_address_pools before the core UTXO delta inside one transaction. Three cases: in-band single changeset, adversarial merged-buffer arrival (UTXO stored before snapshot, Manual flush), and the non-fatal skip of a UTXO on a genuinely undeclared address. All pass on the current tree with the eager-mirror still present, demonstrating the manifest alone is a sufficient resolution source. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/marvin_gate_in_band_ordering.rs | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 packages/rs-platform-wallet-storage/tests/marvin_gate_in_band_ordering.rs diff --git a/packages/rs-platform-wallet-storage/tests/marvin_gate_in_band_ordering.rs b/packages/rs-platform-wallet-storage/tests/marvin_gate_in_band_ordering.rs new file mode 100644 index 0000000000..04929082b8 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/marvin_gate_in_band_ordering.rs @@ -0,0 +1,239 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Marvin's verification-gate scratch tests (PR #3828 Phase-2 gate). +//! +//! These prove the single load-bearing claim the retirement plan rests on: +//! a UTXO landing on a freshly-derived address resolves to the correct +//! account_index when the pool snapshot rides the SAME `PlatformWalletChangeSet` +//! as the UTXO — purely because `apply_changeset_to_tx` applies +//! `account_address_pools` (persister.rs:1073) BEFORE the core UTXO delta +//! (:1077) inside one transaction. If this holds, the manifest is a +//! sufficient resolution source and the eager-mirror is redundant. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, fresh_persister_with_mode, wid}; + +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::AddressInfo; +use platform_wallet::changeset::{ + AccountAddressPoolEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::sqlite::schema::core_state; +use platform_wallet_storage::FlushMode; + +/// Snapshot the wallet's Standard BIP44 external pool plus the expected +/// `account_index` (0 for BIP44 index-0) and the LAST address in the pool — +/// the gap-limit-edge address, the one most likely to be a fresh extension. +fn pool_and_edge_address(seed_byte: u8) -> (AccountAddressPoolEntry, u32, AddressInfo) { + use key_wallet::account::AccountType; + use key_wallet::managed_account::address_pool::AddressPoolType; + + let wallet = Wallet::from_seed_bytes( + [seed_byte; 64], + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 0); + + for managed in info.all_managed_accounts() { + let account_type = managed.managed_account_type().to_account_type(); + if !matches!(account_type, AccountType::Standard { index: 0, .. }) { + continue; + } + for pool in managed.managed_account_type().address_pools() { + if pool.pool_type != AddressPoolType::External || pool.addresses.is_empty() { + continue; + } + let infos: Vec = pool.addresses.values().cloned().collect(); + let edge = infos.last().cloned().unwrap(); + let account_index = account_index_of(&account_type); + return ( + AccountAddressPoolEntry { + account_type, + pool_type: pool.pool_type, + addresses: infos, + }, + account_index, + edge, + ); + } + } + panic!("wallet must expose a non-empty Standard BIP44 external pool"); +} + +/// Mirror the persister's own `account_index` derivation so the test asserts +/// against the SAME value the writer computes, not a hand-picked constant. +fn account_index_of(account_type: &key_wallet::account::AccountType) -> u32 { + use key_wallet::account::AccountType; + match account_type { + AccountType::Standard { index, .. } | AccountType::CoinJoin { index } => *index, + _ => 0, + } +} + +fn utxo_at(addr: &dashcore::Address, vout: u32, value: u64) -> key_wallet::Utxo { + use dashcore::hashes::Hash; + key_wallet::Utxo { + outpoint: dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([0x7E; 32]), + vout, + }, + txout: dashcore::TxOut { + value, + script_pubkey: addr.script_pubkey(), + }, + address: addr.clone(), + height: 7, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + } +} + +/// THE GATE: a single changeset carrying the pool snapshot AND a UTXO on the +/// gap-limit-edge address resolves the UTXO to the correct account_index. The +/// pool snapshot is the ONLY thing that makes the address resolvable in this +/// changeset — there is no prior `addresses_derived` event and no prior round. +/// This is the gap-limit race (design §2.3) closed in-band. +#[test] +fn in_band_pool_snapshot_resolves_utxo_on_fresh_address_same_changeset() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xD1); + ensure_wallet_meta(&persister, &w); + + let (pool, expected_index, edge) = pool_and_edge_address(0x55); + let addr = edge.address.clone(); + + // ONE changeset: snapshot + UTXO on the edge address, exactly as the + // Phase-1 emitter ships them (build_platform_changeset attaches the pool + // to the same PlatformWalletChangeSet as the core delta). + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![pool], + core: Some(CoreChangeSet { + new_utxos: vec![utxo_at(&addr, 0, 777_000)], + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("in-band snapshot+UTXO changeset must persist"); + + let conn = persister.lock_conn_for_test(); + let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); + + let total: usize = by_account.values().map(|v| v.len()).sum(); + assert_eq!(total, 1, "exactly the one edge-address UTXO must persist"); + + // The load-bearing assertion: the UTXO is attributed to the RIGHT account, + // not the spent-only `0` placeholder and not dropped. + let rows = by_account + .get(&expected_index) + .unwrap_or_else(|| panic!("UTXO must resolve to account_index {expected_index}")); + assert_eq!( + rows.len(), + 1, + "the edge-address UTXO under the right account" + ); + assert_eq!(rows[0].value, 777_000, "value preserved"); +} + +/// Adversarial buffering: the snapshot and the UTXO arrive in TWO separate +/// `store` calls (Manual flush mode batches them), forcing the buffer's +/// `Merge` to combine them before a single flush. The merged changeset must +/// STILL apply pools before core — so the UTXO resolves. This proves the +/// in-band guarantee survives the merge path, not just the single-store path. +#[test] +fn merged_buffer_preserves_pool_before_core_ordering() { + let (persister, _tmp, _path) = fresh_persister_with_mode(FlushMode::Manual); + let w: WalletId = wid(0xD2); + ensure_wallet_meta(&persister, &w); + + let (pool, expected_index, edge) = pool_and_edge_address(0x66); + let addr = edge.address.clone(); + + // Store the UTXO-bearing changeset FIRST, snapshot SECOND — the adversarial + // arrival order. Merge must still order pools-before-core at apply time. + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo_at(&addr, 0, 888_000)], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![pool], + ..Default::default() + }, + ) + .unwrap(); + + persister.flush(w).expect("merged flush must commit"); + + let conn = persister.lock_conn_for_test(); + let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); + let rows = by_account.get(&expected_index).unwrap_or_else(|| { + panic!("merged changeset must resolve the UTXO to account_index {expected_index}") + }); + assert_eq!(rows.len(), 1, "edge-address UTXO resolved after merge"); + assert_eq!(rows[0].value, 888_000); +} + +/// Negative control: a UTXO on a genuinely-undeclared address (NOT in any +/// snapshot) is skipped non-fatally — it does NOT abort the flush and does NOT +/// appear in the unspent set. This is the benign SPV-gap branch the guard +/// relaxation relies on (design §3.3 step 3, non-fatal skip). +#[test] +fn utxo_on_undeclared_address_skips_non_fatally() { + use dashcore::address::Payload; + use dashcore::hashes::Hash; + use dashcore::PubkeyHash; + + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xD3); + ensure_wallet_meta(&persister, &w); + + // An address the wallet never declared — a fixed hash160 not in any pool. + let undeclared = dashcore::Address::new( + key_wallet::Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([0xEE; 20])), + ); + + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo_at(&undeclared, 0, 123_000)], + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("an undeclared-address UTXO must skip, not abort the flush"); + + let conn = persister.lock_conn_for_test(); + let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); + let total: usize = by_account.values().map(|v| v.len()).sum(); + assert_eq!( + total, 0, + "the undeclared-address UTXO is skipped, not persisted" + ); +} From ebb4b30dba37272c378a06a642e5945686d52c38 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:36:36 +0200 Subject: [PATCH 09/24] refactor(platform-wallet-storage): replace pool mirror/reconcile with manifest fallback; relocate fatal guard to emitter-contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the Phase-1 emitter making account_address_pools a complete, in-band manifest, the three storage workarounds collapse into one resolution rule. - Remove the eager-mirror: apply_pools writes only the snapshot blob; core_derived_addresses is fed exclusively by the live addresses_derived path, acting as an indexed read-cache in front of the manifest. - Remove the load-time reconcile (rehydrate_derived_addresses_from_pools and its load-path call); resolution-on-read needs no backfill. - UTXO resolution: derived-cache hit, else fall back to the pool manifest (the in-band snapshot is applied earlier in the same tx), else warn+skip a genuinely undeclared address. The old per-UTXO fatal branch becomes the manifest-fallback success path. - Relocate the fatal guard to the emitter contract: a live addresses_derived entry absent from the manifest aborts the flush (DerivedIndexInvariantViolated) at the storage trust boundary. Lag-safe — dropped events produce no changeset, so it can only fire on an emitter bug. No migration: the new resolution is a strict superset of the old read path. Tests reframed to the new model (manifest fallback, emitter-contract guard, old-DB back-compat); SCHEMA.md updated. Co-Authored-By: Claude Opus 4.6 --- packages/rs-platform-wallet-storage/SCHEMA.md | 65 +- .../src/sqlite/error.rs | 18 +- .../src/sqlite/persister.rs | 19 +- .../src/sqlite/schema/accounts.rs | 17 - .../src/sqlite/schema/core_state.rs | 180 ++---- .../tests/sqlite_core_state_reader.rs | 29 +- .../tests/sqlite_load_wiring.rs | 29 +- .../tests/sqlite_pool_derived_rehydration.rs | 612 +++++++++--------- 8 files changed, 482 insertions(+), 487 deletions(-) diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index a35779e010..98865a0185 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -404,38 +404,39 @@ finalized. Rows are removed when the transaction becomes confirmed. ### `core_derived_addresses` -Address-to-account-index map the UTXO writer joins to resolve a UTXO's -`account_index` by address. Populated from three sources, all routed -through the shared `core_state::upsert_derived_address_row` helper so the -rows are identical regardless of origin: - -1. **Live `addresses_derived` events** — written before UTXOs in the same - transaction so the writer sees fresh rows. -2. **`apply_pools` registration mirror** — every pool-snapshot address is - mirrored here at registration, so a UTXO landing on a registered - address resolves even before its live derive event arrives - (genesis-rescan). -3. **Load-time reconcile** — on load, pool snapshots fill any address the - table is missing, purely additively (`INSERT OR IGNORE`), so an - authoritative live/mirrored row is never overwritten and a would-be - `UNIQUE(address)` collision is skipped rather than aborting the load. - -An unspent UTXO whose address is absent from this table cannot resolve an -account. The writer tells two cases apart by the wallet's -`account_address_pools`: - -- **Declared but unmapped** — the address IS in a persisted pool snapshot - yet missing here, so the eager-mirror (`apply_pools`) / load-time - reconcile invariant "declared ⟹ mapped" is broken. This is a **fatal** - `DerivedIndexInvariantViolated` error: silently skipping would drop live - money over a logic regression no one would notice. -- **Truly undeclared** — the address is in no pool (not ours, or a - registration changeset not yet applied — an SPV gap-limit edge). The - writer **skips** it (with a `warn`) so one unresolvable row never aborts a - whole flush; its balance re-warms once the address is later derived. - -(The spent-only synthetic-row path is exempt from both: a spent row uses an -inert `account_index` placeholder and is excluded from reads.) +A live-fed indexed read-cache the UTXO writer joins to resolve a UTXO's +`account_index` by address. The authoritative manifest is +`account_address_pools` (kept complete and in-band by the +`core_bridge` emitter); this table is the fast B-tree probe in front of it. +Fed by exactly one source: + +- **Live `addresses_derived` events** — written before UTXOs in the same + transaction so the writer sees fresh rows. + +UTXO resolution for an unspent UTXO: + +1. **Cache hit** — resolve from this table. +2. **Cache miss, manifest hit** — fall back to `account_address_pools` + (the in-band snapshot is applied earlier in the same tx). Resolved. +3. **Miss in both** — a genuinely undeclared address (not ours, or an SPV + gap-limit edge). The writer **skips** it (with a `warn`) so one + unresolvable row never aborts a whole flush; its balance re-warms once + the address is later derived. + +(The spent-only synthetic-row path is exempt: a spent row uses an inert +`account_index` placeholder and is excluded from reads.) + +A live `addresses_derived` entry whose address is absent from the manifest +is a **fatal** `DerivedIndexInvariantViolated` — the emitter must attach +the pool snapshot in-band with every derivation, so this can only fire on +an emitter bug, never on a benign gap. + +> The non-ECDSA pool gap (BLS/EdDSA addresses are dropped from the event +> projection, so they never produce an `addresses_derived` entry) cannot +> manifest here: only ECDSA Standard/CoinJoin External/Internal addresses +> are ever classified `Received`/`Change`, so a non-ECDSA address can never +> be a `new_utxos` UTXO address. This is an upstream classifier property +> (`key-wallet` `account_checker`), not enforceable at the storage layer. - PK: `(wallet_id, account_type, pool_type, derivation_index)` — the BIP32 leaf identity (one row per derived address). diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs index 1448dceb1f..d805f54aa9 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -227,16 +227,16 @@ pub enum WalletStorageError { #[error("unspent utxo address {address} is not in core_derived_addresses")] UtxoAddressNotDerived { address: String }, - /// An unspent UTXO named an address this wallet's persisted - /// `account_address_pools` DECLARE, yet it is missing from - /// `core_derived_addresses` — the eager-mirror (`apply_pools`) / - /// load-time reconcile invariant is broken. A declared address must - /// always carry a derived-index row, so a miss here is a logic - /// regression, never a benign SPV gap; failing loud surfaces it - /// instead of silently mis-filing or dropping live money. + /// A live `addresses_derived` entry arrived without its address in the + /// wallet's `account_address_pools` manifest. The emitter must attach a + /// full pool snapshot in-band with every derivation, so a derived + /// address absent from the manifest means the emitter contract is + /// broken — a logic regression, not a benign SPV gap. Failing loud at + /// the storage trust boundary surfaces it instead of persisting a row + /// the manifest can't vouch for. #[error( - "derived-index invariant violated: pool-declared address {address} is missing from \ - core_derived_addresses (eager-mirror/reconcile broken)" + "emitter contract violated: derived address {address} is absent from the \ + account_address_pools manifest (pool snapshot not emitted in-band)" )] DerivedIndexInvariantViolated { address: String }, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index c4e83b972c..07b62afd97 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -861,7 +861,7 @@ impl PlatformWalletPersistence for SqlitePersister { /// # } /// ``` fn load(&self) -> Result { - let mut conn = self.conn().map_err(PersistenceError::from)?; + let conn = self.conn().map_err(PersistenceError::from)?; let mut state = ClientStartState::default(); let addrs_all = schema::platform_addrs::load_all(&conn).map_err(PersistenceError::from)?; @@ -901,23 +901,6 @@ impl PlatformWalletPersistence for SqlitePersister { )) })?; - // Reconcile `core_derived_addresses` against the pool snapshots: - // fill any pool address the derived table is missing (DBs predating - // the mirror, or partial state) so the next sync's UTXO writer can - // resolve pool-address accounts. Additive — never clobbers a live - // row; rows already covering the pools cost only no-op inserts. - { - let tx = conn - .transaction() - .map_err(WalletStorageError::from) - .map_err(PersistenceError::from)?; - schema::core_state::rehydrate_derived_addresses_from_pools(&tx, &wallet_id) - .map_err(PersistenceError::from)?; - tx.commit() - .map_err(WalletStorageError::from) - .map_err(PersistenceError::from)?; - } - let account_manifest = schema::accounts::load_state(&conn, &wallet_id).map_err(PersistenceError::from)?; let core_state = schema::core_state::load_state(&conn, &wallet_id, network) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs index c04c078f2b..0d929a6f57 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs @@ -152,23 +152,6 @@ pub fn apply_pools( pool_type, payload, ])?; - // Mirror every snapshot address into `core_derived_addresses` (same - // tx) so a UTXO landing on a pool address resolves its account even - // when no live derive event preceded it (genesis-rescan). The shared - // helper keeps these rows identical to the live derive path. - for info in &entry.addresses { - let address = info.address.to_string(); - crate::sqlite::schema::core_state::upsert_derived_address_row( - tx, - wallet_id, - account_type, - i64::from(account_index), - pool_type, - info.index, - &address, - info.used, - )?; - } } Ok(()) } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index d8f3e4ae51..85ad6f26a9 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -54,15 +54,32 @@ pub fn apply( ])?; } } + // Lazily-built address → account_index map from this wallet's + // `account_address_pools` (the in-band manifest, applied earlier in the + // same tx). Doubles as the UTXO writer's fallback (`execute_upsert_utxo`) + // and the emitter-contract guard below. Built at most once per `apply`, + // `None` until first needed so a manifest-free flush never decodes a blob. + let mut pool_addrs: Option> = None; // Derived addresses are written before UTXOs (same tx) so the UTXO // writer's address→account_index lookup sees the fresh rows. for da in &cs.addresses_derived { + let address = da.address.to_string(); + // Emitter contract: a derivation must arrive with its pool snapshot + // in the same changeset. Absent from the manifest ⇒ the emitter is + // broken; fail loud at the storage trust boundary rather than persist + // a row whose owning pool the manifest can't vouch for. Lag-safe: + // dropped events produce no changeset, so this only fires on a bug. + let pools = match &mut pool_addrs { + Some(map) => &*map, + None => pool_addrs.insert(pool_declared_address_indices(tx, wallet_id)?), + }; + if !pools.contains_key(&address) { + return Err(WalletStorageError::DerivedIndexInvariantViolated { address }); + } let account_type = crate::sqlite::schema::accounts::account_type_db_label(&da.account_type); let account_index = crate::sqlite::schema::accounts::account_index(&da.account_type); let pool_type = crate::sqlite::schema::accounts::pool_type_db_label(&da.pool_type); - let address = da.address.to_string(); - // Live derive events carry no `used` flag — default false; a pool - // snapshot (the other caller of this helper) carries the real one. + // Live derive events carry no `used` flag — default false. upsert_derived_address_row( tx, wallet_id, @@ -74,12 +91,6 @@ pub fn apply( false, )?; } - // Lazily-built set of every address this wallet's `account_address_pools` - // declare, used only to discriminate an unspent-miss (see - // `execute_upsert_utxo`). Built at most once per `apply`, on the first - // miss across both UTXO loops; `None` until then so a miss-free flush - // never decodes a snapshot blob. - let mut pool_addrs: Option> = None; if !cs.new_utxos.is_empty() { let mut stmt = tx.prepare_cached(UPSERT_UTXO_SQL)?; let mut lookup_stmt = tx.prepare_cached(ACCOUNT_INDEX_BY_ADDRESS_SQL)?; @@ -174,45 +185,40 @@ fn execute_upsert_utxo( wallet_id: &WalletId, utxo: &Utxo, spent: bool, - pool_addrs: &mut Option>, + pool_addrs: &mut Option>, ) -> Result<(), WalletStorageError> { let op = blob::encode_outpoint(&utxo.outpoint)?; let address = utxo.address.to_string(); - // `Utxo` carries no account index; recover it from the derived-address map. + // `Utxo` carries no account index; recover it from the live derived-address + // cache, then fall back to the pool manifest. let looked_up: Option = lookup_stmt .query_row(params![wallet_id.as_slice(), &address], |row| row.get(0)) .optional()?; let account_index: i64 = match looked_up { Some(idx) => idx, - // An unspent miss is one of two cases, told apart by the pools: - // - // 1. The address IS declared in this wallet's `account_address_pools` - // but absent from `core_derived_addresses` — the eager-mirror / - // load-time reconcile invariant ("declared ⟹ mapped") is broken. - // Fatal: silently skipping would drop live money over a logic - // regression no one would ever notice. - // 2. The address is NOT declared (not ours, or a registration - // changeset not yet applied — an SPV gap-limit edge). Benign: - // skip (warn) so one unresolvable row never aborts a whole flush; - // the balance re-warms once the address is later derived. - // - // The spent-only arm keeps the inert fallback regardless. + // Cache miss on an unspent UTXO: fall back to the pool manifest + // (the in-band emitter snapshot is applied earlier in this same tx). + // Resolved there → use it. Absent from both → benign SPV gap-limit + // edge: warn + skip so one unresolvable row never aborts the flush; + // the balance re-warms once the address is later derived. None if !spent => { let pools = match pool_addrs { - Some(set) => &*set, - None => pool_addrs.insert(pool_declared_addresses(tx, wallet_id)?), + Some(map) => &*map, + None => pool_addrs.insert(pool_declared_address_indices(tx, wallet_id)?), }; - if pools.contains(&address) { - return Err(WalletStorageError::DerivedIndexInvariantViolated { address }); + match pools.get(&address) { + Some(idx) => *idx, + None => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + address = %address, + txid = %utxo.outpoint.txid, + vout = utxo.outpoint.vout, + "skipping unspent UTXO at an address absent from both core_derived_addresses and the pool manifest; balance re-warms once the address is later derived" + ); + return Ok(()); + } } - tracing::warn!( - wallet_id = %hex::encode(wallet_id), - address = %address, - txid = %utxo.outpoint.txid, - vout = utxo.outpoint.vout, - "skipping unspent UTXO at an undeclared address absent from core_derived_addresses; balance re-warms only once the address is later derived" - ); - return Ok(()); } None => { tracing::debug!( @@ -245,25 +251,14 @@ const UPSERT_DERIVED_ADDRESS_SQL: &str = "INSERT INTO core_derived_addresses \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ ON CONFLICT(wallet_id, account_type, pool_type, derivation_index) DO NOTHING"; -// Additive reconcile: fill gaps only, never touch an existing row. `OR -// IGNORE` skips ALL constraint violations (PK and UNIQUE(address)) so a -// would-be address collision can't abort the load — safe because an -// authoritative row already owns any colliding address. -const INSERT_DERIVED_ADDRESS_IF_ABSENT_SQL: &str = "INSERT OR IGNORE INTO core_derived_addresses \ - (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"; - -/// Upsert one `core_derived_addresses` row. Single writer for both the -/// live `addresses_derived` event path and the `apply_pools` snapshot -/// path, so the address→account_index map the UTXO writer joins is -/// identical regardless of which source populated it. `used` is set on -/// insert only — the conflict clause leaves an existing `used` untouched -/// so a later live re-derive (which carries no flag) cannot clear a -/// snapshot's real value. +/// Upsert one `core_derived_addresses` row from the live +/// `addresses_derived` event path. `used` is set on insert only — the +/// conflict clause leaves an existing `used` untouched so a later live +/// re-derive (which carries no flag) cannot clear an earlier value. // Args map 1:1 onto the row's NOT-NULL columns; a wrapper struct would add -// a single-use type for the two call sites without improving clarity. +// a single-use type for the one call site without improving clarity. #[allow(clippy::too_many_arguments)] -pub(crate) fn upsert_derived_address_row( +fn upsert_derived_address_row( tx: &Transaction<'_>, wallet_id: &WalletId, account_type: &str, @@ -286,57 +281,16 @@ pub(crate) fn upsert_derived_address_row( Ok(()) } -/// Every address this wallet's persisted `account_address_pools` snapshots -/// declare, as a membership set. Used to discriminate an unspent-UTXO miss: -/// a declared address absent from `core_derived_addresses` is a broken-mirror -/// invariant violation, an undeclared one is a benign skip. Reuses the -/// snapshot-decode pattern of [`rehydrate_derived_addresses_from_pools`]; -/// a corrupt snapshot is fail-hard (never skipped). -fn pool_declared_addresses( - tx: &Transaction<'_>, - wallet_id: &WalletId, -) -> Result, WalletStorageError> { - let snapshots: Vec> = { - let mut stmt = tx.prepare_cached( - "SELECT snapshot_blob FROM account_address_pools WHERE wallet_id = ?1", - )?; - let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { - row.get::<_, Vec>(0) - })?; - let mut out = Vec::new(); - for r in rows { - out.push(r?); - } - out - }; - - let mut set = std::collections::HashSet::new(); - for payload in snapshots { - let entry: platform_wallet::changeset::AccountAddressPoolEntry = blob::decode(&payload)?; - for info in &entry.addresses { - set.insert(info.address.to_string()); - } - } - Ok(set) -} - -/// Reconcile `core_derived_addresses` for `wallet_id` against its -/// `account_address_pools` snapshots, filling any address the snapshots -/// declare but the derived table is missing (already-persisted DBs that -/// predate the pool→derived mirror, or partial state where some addresses -/// derived live but others never did). -/// -/// Purely additive: every insert is `INSERT OR IGNORE`, so an existing -/// authoritative row (live or mirrored) keeps its account_index, -/// pool_type, derivation_index, and used flag untouched, and a would-be -/// UNIQUE(address) collision is skipped rather than aborting the load. A -/// wallet whose derived rows already cover its pools incurs only no-op -/// inserts. Decoding a snapshot blob is fail-hard (corruption is never -/// skipped). -pub(crate) fn rehydrate_derived_addresses_from_pools( +/// Address → owning `account_index` for every address this wallet's +/// persisted `account_address_pools` snapshots declare. This is the +/// authoritative manifest the UTXO writer falls back to when an address +/// is not yet in the live `core_derived_addresses` cache, and the set the +/// apply-time emitter-contract guard checks `addresses_derived` against. +/// A corrupt snapshot is fail-hard (never skipped). +fn pool_declared_address_indices( tx: &Transaction<'_>, wallet_id: &WalletId, -) -> Result<(), WalletStorageError> { +) -> Result, WalletStorageError> { let snapshots: Vec> = { let mut stmt = tx.prepare_cached( "SELECT snapshot_blob FROM account_address_pools WHERE wallet_id = ?1", @@ -351,27 +305,17 @@ pub(crate) fn rehydrate_derived_addresses_from_pools( out }; - let mut insert_stmt = tx.prepare_cached(INSERT_DERIVED_ADDRESS_IF_ABSENT_SQL)?; + let mut map = std::collections::HashMap::new(); for payload in snapshots { let entry: platform_wallet::changeset::AccountAddressPoolEntry = blob::decode(&payload)?; - let account_type = - crate::sqlite::schema::accounts::account_type_db_label(&entry.account_type); - let account_index = crate::sqlite::schema::accounts::account_index(&entry.account_type); - let pool_type = crate::sqlite::schema::accounts::pool_type_db_label(&entry.pool_type); + let account_index = i64::from(crate::sqlite::schema::accounts::account_index( + &entry.account_type, + )); for info in &entry.addresses { - let address = info.address.to_string(); - insert_stmt.execute(params![ - wallet_id.as_slice(), - account_type, - i64::from(account_index), - pool_type, - i64::from(info.index), - address, - info.used, - ])?; + map.insert(info.address.to_string(), account_index); } } - Ok(()) + Ok(map) } fn upsert_sync_state( diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs index 8c39013416..e01d41336d 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs @@ -13,9 +13,10 @@ use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; +use key_wallet::AddressInfo; use key_wallet::Utxo; use platform_wallet::changeset::{ - CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, + AccountAddressPoolEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, }; use platform_wallet_storage::sqlite::schema::core_state; use platform_wallet_storage::WalletStorageError; @@ -88,6 +89,27 @@ fn derived_for(address: &dashcore::Address) -> platform_wallet::DerivedAddress { } } +/// The in-band pool snapshot the emitter ships with the derivation above — +/// `core_state::apply` now requires every `addresses_derived` address to be +/// in the `account_address_pools` manifest. Matches `derived_for`'s slot. +fn manifest_for(address: &dashcore::Address) -> AccountAddressPoolEntry { + let info = AddressInfo::new_from_script_pubkey_p2pkh( + address.script_pubkey(), + 0, + Default::default(), + key_wallet::Network::Testnet, + ) + .expect("p2pkh AddressInfo"); + AccountAddressPoolEntry { + account_type: key_wallet::account::AccountType::Standard { + index: 0, + standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, + }, + pool_type: key_wallet::managed_account::address_pool::AddressPoolType::External, + addresses: vec![info], + } +} + /// A non-zero balance survives store → drop → reopen → load, guarding /// against a silent-zero-balance reconstruction. #[test] @@ -107,6 +129,7 @@ fn rt2_nonzero_balance_survives_reopen() { synced_height: Some(200), ..Default::default() }), + account_address_pools: vec![manifest_for(&utxo.address)], ..Default::default() }; persister.store(w, cs).unwrap(); @@ -161,6 +184,7 @@ fn b2_spent_utxo_excluded() { spent_utxos: vec![u_spent.clone()], ..Default::default() }), + account_address_pools: vec![manifest_for(&u_unspent.address)], ..Default::default() }, ) @@ -268,11 +292,12 @@ fn f2_no_bip44_wallet_nonzero_balance_survives_reopen() { PlatformWalletChangeSet { core: Some(CoreChangeSet { addresses_derived: vec![derived_for(&utxo.address)], - new_utxos: vec![utxo], + new_utxos: vec![utxo.clone()], last_processed_height: Some(60), synced_height: Some(60), ..Default::default() }), + account_address_pools: vec![manifest_for(&utxo.address)], ..Default::default() }, ) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs b/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs index e5aa4b65ee..d6688fe69b 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs @@ -12,9 +12,10 @@ use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; +use key_wallet::AddressInfo; use platform_wallet::changeset::{ - AccountRegistrationEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, - WalletMetadataEntry, + AccountAddressPoolEntry, AccountRegistrationEntry, CoreChangeSet, PlatformWalletChangeSet, + PlatformWalletPersistence, WalletMetadataEntry, }; use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; @@ -47,6 +48,27 @@ fn derived_for(address: &dashcore::Address) -> platform_wallet::DerivedAddress { } } +/// The in-band pool snapshot the emitter ships with the derivation above — +/// `core_state::apply` requires every `addresses_derived` address to be in +/// the `account_address_pools` manifest. Matches `derived_for`'s slot. +fn manifest_for(address: &dashcore::Address) -> AccountAddressPoolEntry { + let info = AddressInfo::new_from_script_pubkey_p2pkh( + address.script_pubkey(), + 0, + Default::default(), + key_wallet::Network::Testnet, + ) + .expect("p2pkh AddressInfo"); + AccountAddressPoolEntry { + account_type: key_wallet::account::AccountType::Standard { + index: 0, + standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, + }, + pool_type: key_wallet::managed_account::address_pool::AddressPoolType::External, + addresses: vec![info], + } +} + /// A registered wallet with UTXOs round-trips into the keyless `wallets` /// payload — manifest, network, birth height, core state. #[test] @@ -114,11 +136,12 @@ fn c1_load_populates_keyless_wallet_payload() { PlatformWalletChangeSet { core: Some(CoreChangeSet { addresses_derived: vec![derived_for(&utxo.address)], - new_utxos: vec![utxo], + new_utxos: vec![utxo.clone()], last_processed_height: Some(50), synced_height: Some(50), ..Default::default() }), + account_address_pools: vec![manifest_for(&utxo.address)], ..Default::default() }, ) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs index a35a92f899..3830c7b03c 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs @@ -1,15 +1,14 @@ #![allow(clippy::field_reassign_with_default)] -//! Genesis-rescan rehydration of `core_derived_addresses` from -//! `account_address_pools` snapshots, and the flush blast-radius -//! containment for an unspent UTXO at a genuinely-undeclared address. +//! UTXO account-index resolution against the `account_address_pools` +//! manifest, the emitter-contract guard, and flush blast-radius +//! containment for a UTXO at a genuinely-undeclared address. //! -//! On a `birth_height = 0` rescan SPV can match a UTXO at a registered -//! pool address before the live `addresses_derived` event for it lands. -//! `account_address_pools` already holds that address (with its real -//! `used` flag), so `apply_pools` mirrors it into `core_derived_addresses` -//! in the same transaction — the UTXO writer's account lookup resolves -//! and the flush commits instead of dropping the whole changeset. +//! `core_derived_addresses` is a live-fed indexed cache; the authoritative +//! manifest is `account_address_pools` (kept complete and in-band by the +//! `core_bridge` emitter). On a cache miss the UTXO writer falls back to +//! the manifest, so a UTXO landing on a registered pool address resolves +//! even with no live `addresses_derived` event — no mirror, no reconcile. mod common; @@ -122,10 +121,10 @@ fn derived_for( } } -/// Genesis-rescan persist: a wallet registered with pools but with NO -/// live `addresses_derived` event still resolves the account index of a -/// UTXO landing on a pool address — `apply_pools` mirrored the pool into -/// `core_derived_addresses` in the same round. +/// Genesis-rescan persist: a wallet registered with pools but with NO live +/// `addresses_derived` event still resolves the account index of a UTXO +/// landing on a pool address — the UTXO writer falls back to the +/// `account_address_pools` manifest. No mirror writes a derived row. #[test] fn genesis_rescan_utxo_at_pool_address_persists() { let (persister, _tmp, _path) = fresh_persister(); @@ -134,6 +133,10 @@ fn genesis_rescan_utxo_at_pool_address_persists() { let (snapshots, target) = wallet_with_pools(0x11); let addr = target.address.clone(); + let expected_index = match snapshots[0].account_type { + key_wallet::account::AccountType::Standard { index, .. } => index, + _ => unreachable!("fixture uses a Standard account"), + }; // Registration round carries pools only — no addresses_derived. persister @@ -164,123 +167,123 @@ fn genesis_rescan_utxo_at_pool_address_persists() { let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); let total: usize = by_account.values().map(|v| v.len()).sum(); assert_eq!(total, 1, "the pool-address UTXO must be persisted"); + let resolved = by_account + .get(&expected_index) + .map(|v| v.len()) + .unwrap_or(0); + assert_eq!( + resolved, 1, + "the UTXO must resolve to the pool's account index via the manifest fallback" + ); + // The manifest fallback resolves without writing a derived-cache row. let derived = core_state::list_derived_addresses_for_test(&conn, &w).unwrap(); assert!( - derived.iter().any(|r| r.address == addr.to_string()), - "apply_pools must have mirrored the pool address into core_derived_addresses" + derived.iter().all(|r| r.address != addr.to_string()), + "no mirror: the pool address must NOT be written into core_derived_addresses" ); } -/// Row-shape parity: a `core_derived_addresses` row written via -/// `apply_pools` is byte-identical (account_index, pool_type, -/// derivation_index, used) -/// to the row the live `core_state::apply` writes for the same address — -/// the two sources share one helper, so they cannot drift. +/// Back-compat, no migration: an "old-style" DB (frozen registration pool +/// snapshot + a live-fed `core_derived_addresses` row, never any mirror) +/// resolves both address classes under the new model — a gap-limit address +/// via the live cache hit, a registration-only address via the manifest +/// fallback. The fallback is a strict superset of the old read path. #[test] -fn pool_and_live_derived_rows_are_identical() { - let (snapshots, target) = wallet_with_pools(0x22); - let addr = target.address.clone(); +fn old_style_db_resolves_via_cache_and_manifest_fallback() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xB1); + ensure_wallet_meta(&persister, &w); - // Locate the account_type + pool_type that owns the target address so - // the live event describes the same derivation. - let owning = snapshots - .iter() - .find(|p| p.addresses.iter().any(|ai| ai.address == addr)) - .expect("owning pool"); - - // Row A — written by apply_pools (with the real `used` from the pool). - let row_pool = { - let (persister, _tmp, _path) = fresh_persister(); - let w: WalletId = wid(0xB1); - ensure_wallet_meta(&persister, &w); - persister - .store( - w, - PlatformWalletChangeSet { - account_address_pools: snapshots.clone(), - ..Default::default() - }, - ) - .unwrap(); - let conn = persister.lock_conn_for_test(); - core_state::list_derived_addresses_for_test(&conn, &w) - .unwrap() - .into_iter() - .find(|r| r.address == addr.to_string()) - .expect("pool-written row") + let (snapshots, registration_target) = wallet_with_pools(0x22); + let pool = &snapshots[0]; + assert!( + pool.addresses.len() >= 2, + "fixture needs two pool addresses" + ); + let account_index = match pool.account_type { + key_wallet::account::AccountType::Standard { index, .. } => index, + _ => unreachable!("fixture uses a Standard account"), }; + // A gap-limit address persisted ONLY as a live-fed derived row. + let gap_addr = pool.addresses[1].address.clone(); - // Row B — written by the live core_state::apply derive path. - let row_live = { - let (persister, _tmp, _path) = fresh_persister(); - let w: WalletId = wid(0xB2); - ensure_wallet_meta(&persister, &w); - let derived = platform_wallet::DerivedAddress { - account_type: owning.account_type, - pool_type: owning.pool_type, - derivation_index: target.index, - address: addr.clone(), - public_key: dashcore::PublicKey::from_slice(&[ - 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, - 0x87, 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, - 0x5b, 0x16, 0xf8, 0x17, 0x98, - ]) - .unwrap(), - }; - persister - .store( - w, - PlatformWalletChangeSet { - core: Some(CoreChangeSet { - addresses_derived: vec![derived], - ..Default::default() - }), - ..Default::default() - }, - ) - .unwrap(); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: snapshots.clone(), + ..Default::default() + }, + ) + .unwrap(); + + // Seed the live-fed derived row for the gap-limit address (as the live + // `addresses_derived` path would have, at extension time). + { let conn = persister.lock_conn_for_test(); - core_state::list_derived_addresses_for_test(&conn, &w) - .unwrap() - .into_iter() - .find(|r| r.address == addr.to_string()) - .expect("live-written row") - }; + conn.execute( + "INSERT INTO core_derived_addresses \ + (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ + VALUES (?1, 'standard', ?2, 'external', ?3, ?4, 0)", + rusqlite::params![ + w.as_slice(), + i64::from(account_index), + i64::from(pool.addresses[1].index), + gap_addr.to_string() + ], + ) + .unwrap(); + } + // Two UTXOs: one on the live-cached gap address, one on a + // registration-only pool address (in the manifest, not in the cache). + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![ + utxo_at(&gap_addr, 0, 111_000), + utxo_at(®istration_target.address, 1, 222_000), + ], + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("both address classes must resolve without migration"); + + let conn = persister.lock_conn_for_test(); + let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); + let rows = by_account.get(&account_index).expect("account row"); assert_eq!( - row_pool.account_type, row_live.account_type, - "account_type label must match" - ); - assert_eq!( - row_pool.account_index, row_live.account_index, - "account_index must match" - ); - assert_eq!( - row_pool.pool_type, row_live.pool_type, - "pool_type must match" - ); - assert_eq!( - row_pool.derivation_index, row_live.derivation_index, - "derivation_index must match" + rows.len(), + 2, + "both UTXOs resolve to the same account index" ); - // The live path hardcodes used=false; an unused pool address agrees. + let values: std::collections::BTreeSet = rows.iter().map(|r| r.value).collect(); assert!( - !target.used, - "fixture relies on a fresh (unused) first external address" + values.contains(&111_000) && values.contains(&222_000), + "the cache-hit and manifest-fallback UTXOs both committed" ); - assert_eq!(row_pool.used, row_live.used, "used flag must match"); } -/// Load-path rehydrate: a DB with pool snapshots but ZERO derived rows is -/// repopulated by `load`, and a second `load` is a no-op (no duplicates). +/// Reload-then-resolve: a DB with pool snapshots but ZERO derived rows +/// (no mirror, no reconcile) survives a `load` untouched, and a UTXO that +/// lands on a pool address afterwards still resolves via the manifest +/// fallback. `load` does NOT backfill the derived cache. #[test] -fn load_rehydrates_derived_rows_from_pools_idempotently() { +fn reload_resolves_pool_address_without_reconcile() { let (persister, _tmp, path) = fresh_persister(); let w: WalletId = wid(0xC0); ensure_wallet_meta(&persister, &w); let (snapshots, target) = wallet_with_pools(0x33); let addr = target.address.clone(); + let account_index = match snapshots[0].account_type { + key_wallet::account::AccountType::Standard { index, .. } => index, + _ => unreachable!("fixture uses a Standard account"), + }; persister .store( @@ -291,57 +294,49 @@ fn load_rehydrates_derived_rows_from_pools_idempotently() { }, ) .unwrap(); + drop(persister); - // Simulate an already-persisted prod DB: pools present, derived table - // empty (the bug — derived rows were never written for pools). + let p2 = reopen(&path); + PlatformWalletPersistence::load(&p2).expect("load"); + + // load must NOT backfill the derived cache from pools. { - let conn = persister.lock_conn_for_test(); - conn.execute( - "DELETE FROM core_derived_addresses WHERE wallet_id = ?1", - rusqlite::params![w.as_slice()], - ) - .unwrap(); + let conn = p2.lock_conn_for_test(); let n = core_state::list_derived_addresses_for_test(&conn, &w) .unwrap() .len(); - assert_eq!(n, 0, "precondition: derived table emptied"); + assert_eq!(n, 0, "load does not reconcile the derived cache"); } - drop(persister); - let p2 = reopen(&path); - PlatformWalletPersistence::load(&p2).expect("first load"); - let first = { - let conn = p2.lock_conn_for_test(); - core_state::list_derived_addresses_for_test(&conn, &w).unwrap() - }; - assert!( - first.iter().any(|r| r.address == addr.to_string()), - "load must rehydrate derived rows from pools" - ); - let count_after_first = first.len(); + p2.store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo_at(&addr, 0, 555_000)], + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("a UTXO at a pool address resolves via the manifest after reload"); - // A second load must not duplicate or re-insert (table already full). - PlatformWalletPersistence::load(&p2).expect("second load"); - let count_after_second = { - let conn = p2.lock_conn_for_test(); - core_state::list_derived_addresses_for_test(&conn, &w) - .unwrap() - .len() - }; + let conn = p2.lock_conn_for_test(); + let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); assert_eq!( - count_after_first, count_after_second, - "second load must be a no-op (no duplicate derived rows)" + by_account.get(&account_index).map(|v| v.len()).unwrap_or(0), + 1, + "the pool-address UTXO resolves to its account index via the manifest" ); } -/// Partial-state self-heal: a wallet with SOME live-derived rows (one -/// `used = true`) plus a pool address that was never derived is repaired -/// on `load` — the missing address is added with its pool account_index, -/// and every pre-existing live row is left untouched (the reconcile is -/// purely additive, a live row is authoritative). +/// Partial-state resolution: a DB holding a live derived-cache row at an +/// OFF-pool account_index plus a pool address that was never derived. The +/// live cache row stays authoritative (the manifest fallback never +/// overrides a cache hit), and the un-derived pool address resolves via +/// the manifest fallback. No reconcile rewrites anything. #[test] -fn load_reconciles_partial_state_without_clobbering_live_rows() { - let (persister, _tmp, path) = fresh_persister(); +fn partial_state_cache_hit_wins_over_manifest_fallback() { + let (persister, _tmp, _path) = fresh_persister(); let w: WalletId = wid(0xC5); ensure_wallet_meta(&persister, &w); @@ -351,21 +346,17 @@ fn load_reconciles_partial_state_without_clobbering_live_rows() { pool.addresses.len() >= 2, "fixture needs at least two pool addresses" ); - let pool_account_index = i64::from(match pool.account_type { + let pool_account_index = match pool.account_type { key_wallet::account::AccountType::Standard { index, .. } => index, _ => unreachable!("fixture uses a Standard account"), - }); - - // A pool address we deliberately pre-seed as a live row, with a - // non-pool account_index and used=true, so a clobber would be visible. - let live_addr = pool.addresses[0].address.to_string(); - // A pool address left un-derived — the gap the reconcile must fill. - let missing_addr = pool.addresses[1].address.to_string(); - const LIVE_ACCOUNT_INDEX: i64 = 4242; - // Off-pool leaf: a different pool_type/derivation_index than the - // external-pool leaf the reconcile would assign, so the would-be - // reconcile insert is a UNIQUE(address) skip, not a PK no-op. - const LIVE_DERIVATION_INDEX: i64 = 999; + }; + + // A pool address pre-seeded as a live cache row at an OFF-pool index, + // so a fallback override would be visible. + let live_addr = pool.addresses[0].address.clone(); + // A pool address present only in the manifest, never live-derived. + let manifest_only_addr = pool.addresses[1].address.clone(); + const LIVE_ACCOUNT_INDEX: u32 = 4242; persister .store( @@ -377,64 +368,50 @@ fn load_reconciles_partial_state_without_clobbering_live_rows() { ) .unwrap(); - // Recreate a partial prod DB: drop the auto-mirrored rows, then seed - // ONLY the live row (authoritative, used=true, off-pool index). { let conn = persister.lock_conn_for_test(); - conn.execute( - "DELETE FROM core_derived_addresses WHERE wallet_id = ?1", - rusqlite::params![w.as_slice()], - ) - .unwrap(); conn.execute( "INSERT INTO core_derived_addresses \ (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ - VALUES (?1, 'standard', ?2, 'internal', ?3, ?4, 1)", - rusqlite::params![ - w.as_slice(), - LIVE_ACCOUNT_INDEX, - LIVE_DERIVATION_INDEX, - live_addr - ], + VALUES (?1, 'standard', ?2, 'internal', 999, ?3, 1)", + rusqlite::params![w.as_slice(), LIVE_ACCOUNT_INDEX, live_addr.to_string()], ) .unwrap(); } - drop(persister); - let p2 = reopen(&path); - PlatformWalletPersistence::load(&p2).expect("load"); - - let rows = { - let conn = p2.lock_conn_for_test(); - core_state::list_derived_addresses_for_test(&conn, &w).unwrap() - }; + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![ + utxo_at(&live_addr, 0, 100_000), + utxo_at(&manifest_only_addr, 1, 200_000), + ], + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("both UTXOs resolve"); - let missing = rows - .iter() - .find(|r| r.address == missing_addr) - .expect("the un-derived pool address must be reconciled on load"); - assert_eq!( - missing.account_index, pool_account_index, - "reconciled row must carry the pool account_index" - ); + let conn = persister.lock_conn_for_test(); + let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); - let live = rows - .iter() - .find(|r| r.address == live_addr) - .expect("the live row must survive"); - assert_eq!( - live.account_index, LIVE_ACCOUNT_INDEX, - "reconcile must NOT overwrite a live row's account_index" - ); - assert_eq!( - live.pool_type, "internal", - "reconcile must NOT overwrite a live row's pool_type" + // The live cache row wins: its UTXO resolves to the off-pool index. + let live_rows = by_account + .get(&LIVE_ACCOUNT_INDEX) + .expect("off-pool index row"); + assert!( + live_rows.iter().any(|r| r.value == 100_000), + "the cache hit resolves to the live (off-pool) account_index, not the manifest's" ); - assert_eq!( - live.derivation_index, LIVE_DERIVATION_INDEX, - "reconcile must NOT overwrite a live row's derivation_index" + // The manifest-only address resolves via the fallback to its pool index. + let pool_rows = by_account.get(&pool_account_index).expect("pool index row"); + assert!( + pool_rows.iter().any(|r| r.value == 200_000), + "the manifest-only address resolves via the fallback" ); - assert!(live.used, "reconcile must NOT clear a live row's used flag"); } /// Blast-radius isolation: a batch mixing a valid pool-address UTXO, a @@ -566,6 +543,30 @@ fn derived_at( } } +/// A single-address `account_address_pools` snapshot for an explicit slot, +/// so the apply-time emitter-contract guard accepts a matching +/// `addresses_derived` event. `new_from_script_pubkey_p2pkh` yields a +/// keyless `AddressInfo` — enough for the manifest membership check. +fn manifest_entry( + account_type: key_wallet::account::AccountType, + pool_type: key_wallet::managed_account::address_pool::AddressPoolType, + index: u32, + address: &dashcore::Address, +) -> AccountAddressPoolEntry { + let info = AddressInfo::new_from_script_pubkey_p2pkh( + address.script_pubkey(), + index, + Default::default(), + key_wallet::Network::Testnet, + ) + .expect("p2pkh AddressInfo"); + AccountAddressPoolEntry { + account_type, + pool_type, + addresses: vec![info], + } +} + /// An arbitrary testnet P2PKH address from a byte pattern. fn addr_from(byte: u8) -> dashcore::Address { use dashcore::address::Payload; @@ -610,9 +611,9 @@ fn assert_unique_violation(err: platform_wallet_storage::WalletStorageError) { } } -/// The whole BIP32 leaf grain: a multi-address pool persists ONE row per -/// derivation index — never a single collapsed row. Regression guard for -/// the 1-row collapse a non-leaf PK caused. +/// The whole BIP32 leaf grain: each live derivation in a multi-address pool +/// persists ONE row per derivation index — never a single collapsed row. +/// Regression guard for the 1-row collapse a non-leaf PK caused. #[test] fn multi_address_pool_persists_one_row_per_leaf() { let (persister, _tmp, _path) = fresh_persister(); @@ -620,14 +621,26 @@ fn multi_address_pool_persists_one_row_per_leaf() { ensure_wallet_meta(&persister, &w); let (snapshots, _target) = wallet_with_pools(0x22); - let pool_len = snapshots[0].addresses.len(); + let pool = &snapshots[0]; + let pool_len = pool.addresses.len(); assert!(pool_len >= 2, "fixture needs a multi-address pool"); + // The manifest vouches for every leaf, then each is live-derived. + let derived: Vec = pool + .addresses + .iter() + .map(|info| derived_for(pool, info)) + .collect(); + persister .store( w, PlatformWalletChangeSet { - account_address_pools: snapshots, + core: Some(CoreChangeSet { + addresses_derived: derived, + ..Default::default() + }), + account_address_pools: snapshots.clone(), ..Default::default() }, ) @@ -638,7 +651,7 @@ fn multi_address_pool_persists_one_row_per_leaf() { assert_eq!( rows.len(), pool_len, - "every pool address must persist its own row (no PK collapse)" + "every derived leaf must persist its own row (no PK collapse)" ); } @@ -656,6 +669,22 @@ fn within_pool_address_collision_is_loud() { let addr = addr_from(0x71); let acct = standard_account(); + // The manifest must vouch for the derived address (emitter contract). + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![manifest_entry( + acct, + AddressPoolType::External, + 0, + &addr, + )], + ..Default::default() + }, + ) + .unwrap(); + let mut conn = persister.lock_conn_for_test(); let tx = conn.transaction().unwrap(); core_state::apply( @@ -696,6 +725,21 @@ fn cross_pool_address_collision_is_loud() { let addr = addr_from(0x72); let acct = standard_account(); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![manifest_entry( + acct, + AddressPoolType::External, + 0, + &addr, + )], + ..Default::default() + }, + ) + .unwrap(); + let mut conn = persister.lock_conn_for_test(); let tx = conn.transaction().unwrap(); core_state::apply( @@ -737,6 +781,21 @@ fn authoritative_redrive_preserves_used_true() { let addr = addr_from(0x74); let acct = standard_account(); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![manifest_entry( + acct, + AddressPoolType::External, + 0, + &addr, + )], + ..Default::default() + }, + ) + .unwrap(); + // Seed the leaf with used=true (the snapshot's real flag). { let conn = persister.lock_conn_for_test(); @@ -783,13 +842,13 @@ fn authoritative_redrive_preserves_used_true() { ); } -/// Reconcile stays non-fatal: a pre-existing live row holds an address at -/// one leaf; the pool snapshot declares the SAME address at a DIFFERENT -/// leaf. On `load`, the gap-fill `INSERT OR IGNORE` must SILENTLY skip the -/// would-be UNIQUE(address) collision rather than aborting the load — and -/// the authoritative live row must survive untouched. +/// Reload leaves the live cache authoritative: a pool address is ALSO held +/// as a live derived-cache row at an off-pool leaf. After `load` (no +/// reconcile), that single live row is still the only read-index for the +/// address — untouched leaf, untouched `used` — so resolution is stable +/// and `UNIQUE(address)` holds. #[test] -fn load_reconcile_silently_skips_unique_address_collision() { +fn reload_keeps_single_live_row_for_pool_address() { let (persister, _tmp, path) = fresh_persister(); let w: WalletId = wid(0xF3); ensure_wallet_meta(&persister, &w); @@ -807,36 +866,22 @@ fn load_reconcile_silently_skips_unique_address_collision() { ) .unwrap(); - // Recreate a partial DB: drop the auto-mirrored rows, then seed ONE - // live row claiming the pool address at a DIFFERENT leaf (off-pool - // pool_type + derivation_index). Reconcile would try to (re)insert the - // pool address at its real leaf — a UNIQUE(address) collision. - const LIVE_ACCOUNT_INDEX: i64 = 0; + // Seed ONE live row claiming the pool address at an off-pool leaf. const LIVE_DERIVATION_INDEX: i64 = 7777; { let conn = persister.lock_conn_for_test(); - conn.execute( - "DELETE FROM core_derived_addresses WHERE wallet_id = ?1", - rusqlite::params![w.as_slice()], - ) - .unwrap(); conn.execute( "INSERT INTO core_derived_addresses \ (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ - VALUES (?1, 'standard', ?2, 'internal', ?3, ?4, 1)", - rusqlite::params![ - w.as_slice(), - LIVE_ACCOUNT_INDEX, - LIVE_DERIVATION_INDEX, - pool_addr - ], + VALUES (?1, 'standard', 0, 'internal', ?2, ?3, 1)", + rusqlite::params![w.as_slice(), LIVE_DERIVATION_INDEX, pool_addr], ) .unwrap(); } drop(persister); let p2 = reopen(&path); - PlatformWalletPersistence::load(&p2).expect("reconcile must not abort load on a UNIQUE skip"); + PlatformWalletPersistence::load(&p2).expect("load"); let rows = { let conn = p2.lock_conn_for_test(); @@ -846,33 +891,30 @@ fn load_reconcile_silently_skips_unique_address_collision() { assert_eq!( at_addr.len(), 1, - "UNIQUE(address) guarantees exactly one read-index row per address" + "exactly one read-index row per address after reload (no reconcile dupe)" ); let live = at_addr[0]; assert_eq!( live.pool_type, "internal", - "the authoritative live row's pool_type must survive the skipped reconcile insert" + "the live row's pool_type is untouched" ); assert_eq!( live.derivation_index, LIVE_DERIVATION_INDEX, - "the authoritative live row's derivation_index must survive" - ); - assert!( - live.used, - "reconcile must not clear the live row's used flag" + "the live row's derivation_index is untouched" ); + assert!(live.used, "the live row's used flag is untouched"); } /// Derived-index invariant goes FATAL: an address this wallet's persisted -/// `account_address_pools` DECLARE, yet that the eager-mirror/reconcile -/// failed to write into `core_derived_addresses`, must NOT be silently -/// skipped. A UTXO landing on that declared-but-unmapped address aborts the -/// flush with [`WalletStorageError::DerivedIndexInvariantViolated`] -/// (non-transient → `Fatal`), surfacing the broken invariant instead of -/// dropping live money. This is the loud counterpart to the quiet skip for a -/// genuinely-undeclared address. +/// Emitter contract goes FATAL: a live `addresses_derived` entry whose +/// address is ABSENT from the `account_address_pools` manifest means the +/// emitter failed to attach the pool snapshot in-band. `core_state::apply` +/// aborts the flush with [`WalletStorageError::DerivedIndexInvariantViolated`] +/// (non-transient → `Fatal`), surfacing the broken contract at the storage +/// trust boundary instead of persisting a row the manifest can't vouch for. #[test] -fn pool_declared_address_missing_from_derived_is_fatal() { +fn derivation_absent_from_manifest_is_fatal() { + use key_wallet::managed_account::address_pool::AddressPoolType; use platform_wallet::changeset::PersistenceErrorKind; use platform_wallet_storage::WalletStorageError; @@ -880,39 +922,28 @@ fn pool_declared_address_missing_from_derived_is_fatal() { let w: WalletId = wid(0xE0); ensure_wallet_meta(&persister, &w); - let (snapshots, target) = wallet_with_pools(0x77); - let addr = target.address.clone(); + let acct = standard_account(); + let declared = addr_from(0x77); + let orphan = addr_from(0x78); + assert_ne!(declared, orphan, "fixture sanity"); - // Register the pool: `apply_pools` writes the snapshot AND mirrors its - // addresses into `core_derived_addresses`. + // Manifest vouches for `declared` only. persister .store( w, PlatformWalletChangeSet { - account_address_pools: snapshots, + account_address_pools: vec![manifest_entry( + acct, + AddressPoolType::External, + 0, + &declared, + )], ..Default::default() }, ) .unwrap(); - // Simulate a broken mirror/reconcile: keep the pool snapshot but wipe - // the derived rows it should have produced. The invariant - // "declared ⟹ mapped" is now violated. - { - let conn = persister.lock_conn_for_test(); - conn.execute( - "DELETE FROM core_derived_addresses WHERE wallet_id = ?1", - rusqlite::params![w.as_slice()], - ) - .unwrap(); - let derived = core_state::list_derived_addresses_for_test(&conn, &w).unwrap(); - assert!( - derived.iter().all(|r| r.address != addr.to_string()), - "precondition: the declared address must be missing from the derived index" - ); - } - - // A UTXO at the declared-but-unmapped address must abort the flush. + // A derivation for an address the manifest never declared must abort. let err = { let mut conn = persister.lock_conn_for_test(); let tx = conn.transaction().unwrap(); @@ -920,31 +951,36 @@ fn pool_declared_address_missing_from_derived_is_fatal() { &tx, &w, &CoreChangeSet { - new_utxos: vec![utxo_at(&addr, 0, 555_000)], + addresses_derived: vec![derived_at( + acct, + AddressPoolType::External, + 1, + orphan.clone(), + )], ..Default::default() }, ) - .expect_err("a pool-declared address missing from the derived index must be fatal") + .expect_err("a derivation absent from the manifest must be fatal") }; match &err { WalletStorageError::DerivedIndexInvariantViolated { address } => { assert_eq!( *address, - addr.to_string(), - "the violation must name the declared address" + orphan.to_string(), + "the violation must name the orphaned derived address" ); } other => panic!("expected DerivedIndexInvariantViolated, got {other:?}"), } assert!( !err.is_transient(), - "an invariant violation is a logic regression, never a retryable failure" + "an emitter-contract violation is a logic regression, never retryable" ); assert_eq!( err.persistence_kind(), PersistenceErrorKind::Fatal, - "the invariant violation must classify Fatal at the trait boundary" + "the violation must classify Fatal at the trait boundary" ); } @@ -958,10 +994,14 @@ fn undeclared_unspent_utxo_at_apply_level_is_skipped_not_fatal() { let w: WalletId = wid(0xE1); ensure_wallet_meta(&persister, &w); - // Register a pool so `account_address_pools` is NON-empty (the guard - // must decode it, find the address absent, and still skip). + // Register a pool so `account_address_pools` is NON-empty: the good + // address resolves via the manifest fallback, the undeclared one skips. let (snapshots, good) = wallet_with_pools(0x88); let good_addr = good.address.clone(); + let expected_index = match snapshots[0].account_type { + key_wallet::account::AccountType::Standard { index, .. } => index, + _ => unreachable!("fixture uses a Standard account"), + }; persister .store( w, @@ -996,17 +1036,13 @@ fn undeclared_unspent_utxo_at_apply_level_is_skipped_not_fatal() { let conn = persister.lock_conn_for_test(); - // The expected account index of the good address, read from the derived - // map `apply_pools` mirrored — the same source the UTXO writer joins. + // The good pool address resolves via the manifest fallback — no mirrored + // derived row exists for it. let derived = core_state::list_derived_addresses_for_test(&conn, &w).unwrap(); - let expected_index = u32::try_from( - derived - .iter() - .find(|r| r.address == good_addr.to_string()) - .expect("the good pool address must be in the derived map") - .account_index, - ) - .expect("a Standard account index fits in u32"); + assert!( + derived.iter().all(|r| r.address != good_addr.to_string()), + "no mirror: the good pool address is resolved from the manifest, not the cache" + ); let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); let all: Vec<_> = by_account.values().flatten().collect(); From 156bd9884eeabd9fc2068e72ceb73ca682ddfd19 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:57:39 +0200 Subject: [PATCH 10/24] fix(platform-wallet-storage): add account_index to core_derived_addresses PK; split standard label BIP32/BIP44 Closes the PK collision where Standard{0}/Standard{1} and BIP32/BIP44 standard accounts collapsed to one row, dropping UTXOs and tripping the fatal flush guard into an unflushable loop. - V001: extend core_derived_addresses PRIMARY KEY to include account_index (was missing, causing Standard{idx:0} and Standard{idx:1} at the same pool slot to silently clobber each other via ON CONFLICT DO NOTHING). - accounts.rs: split AccountType::Standard arm in account_type_db_label by StandardAccountType: BIP44Account -> "standard_bip44", BIP32Account -> "standard_bip32". Removes the axis-2 collision where a BIP32 depth-1 and BIP44 depth-3 standard acct-0 both resolved to "standard" and the BIP32 row overwrote the BIP44 xpub in account_registrations (todo 0e3ad26b). - ACCOUNT_TYPE_LABELS: replaces "standard" with both new labels. - UPSERT_DERIVED_ADDRESS_SQL ON CONFLICT target updated to match new PK. - list_derived_addresses_for_test ORDER BY extended with account_index. - All test fixtures using literal 'standard' updated to 'standard_bip44'. - Three new regression tests: multi_account_index_same_slot_persists_both_rows, bip32_and_bip44_standard_acct0_persist_both_rows, account_registrations_bip32_and_bip44_both_survive. Co-Authored-By: Claude Opus 4.6 --- .../migrations/V001__initial.rs | 13 +- .../src/sqlite/schema/accounts.rs | 34 ++- .../src/sqlite/schema/core_state.rs | 4 +- .../tests/sqlite_accounts_reader.rs | 2 +- .../tests/sqlite_check_constraints.rs | 2 +- .../tests/sqlite_foreign_keys.rs | 2 +- .../tests/sqlite_migrations.rs | 6 +- .../tests/sqlite_object_metadata.rs | 6 +- .../tests/sqlite_pool_derived_rehydration.rs | 286 +++++++++++++++++- .../tests/sqlite_structural_hardening.rs | 2 +- 10 files changed, 328 insertions(+), 29 deletions(-) diff --git a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs index 3420d0f73e..cbd9d88814 100644 --- a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs +++ b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs @@ -151,12 +151,13 @@ CREATE TABLE core_derived_addresses ( derivation_index INTEGER NOT NULL, address TEXT NOT NULL, used INTEGER NOT NULL, - -- PK is the BIP32 leaf identity. `address` is a derived attribute, not - -- a key, so every collision (within- or cross-pool) trips - -- UNIQUE(address) loud. `account_index` is account-level context (the - -- value the read returns), not a uniqueness discriminator. The UNIQUE - -- index also backs ACCOUNT_INDEX_BY_ADDRESS_SQL. - PRIMARY KEY (wallet_id, account_type, pool_type, derivation_index), + -- PK is the BIP32 leaf identity: the full tuple (wallet, account_type, + -- account_index, pool, derivation_index) uniquely identifies one derived + -- leaf. `account_type` uses distinct labels per StandardAccountType + -- variant so BIP32 and BIP44 standard accounts never collapse. `address` + -- is a derived attribute — cross-leaf collisions trip UNIQUE(address). + -- The UNIQUE index also backs ACCOUNT_INDEX_BY_ADDRESS_SQL. + PRIMARY KEY (wallet_id, account_type, account_index, pool_type, derivation_index), UNIQUE (wallet_id, address), FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE ); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs index 0d929a6f57..0a4872c281 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs @@ -187,8 +187,13 @@ pub fn load_state( /// `migrations/V001__initial.rs` interpolates it into each table's /// `CHECK (account_type IN (...))`; `account_type_labels_match_enum` keeps it /// in sync with [`account_type_db_label`]. +/// +/// `Standard` maps to two distinct labels by `StandardAccountType` variant +/// (`"standard_bip44"` / `"standard_bip32"`) so BIP44 and BIP32 standard +/// accounts with the same index never collide on their shared PK columns. pub(crate) const ACCOUNT_TYPE_LABELS: &[&str] = &[ - "standard", + "standard_bip44", + "standard_bip32", "coinjoin", "identity_registration", "identity_topup", @@ -215,13 +220,22 @@ pub(crate) const ACCOUNT_TYPE_LABELS: &[&str] = &[ pub(crate) const POOL_TYPE_LABELS: &[&str] = &["external", "internal", "absent", "absent_hardened"]; /// Stable database label for an `AccountType` variant (the `Debug` impl is not -/// a stable format; this match is the contract). Variants sharing a label are -/// distinguished by the companion `account_index` column. An added upstream -/// variant fails this match's exhaustiveness check at compile time. +/// a stable format; this match is the contract). An added upstream variant +/// fails this match's exhaustiveness check at compile time. +/// +/// `Standard` maps to two distinct labels by `StandardAccountType` so BIP44 +/// and BIP32 accounts with the same `index` never collapse onto the same PK. pub(crate) fn account_type_db_label(at: &key_wallet::account::AccountType) -> &'static str { - use key_wallet::account::AccountType; + use key_wallet::account::{AccountType, StandardAccountType}; match at { - AccountType::Standard { .. } => "standard", + AccountType::Standard { + standard_account_type: StandardAccountType::BIP44Account, + .. + } => "standard_bip44", + AccountType::Standard { + standard_account_type: StandardAccountType::BIP32Account, + .. + } => "standard_bip32", AccountType::CoinJoin { .. } => "coinjoin", AccountType::IdentityRegistration => "identity_registration", AccountType::IdentityTopUp { .. } => "identity_topup", @@ -282,7 +296,9 @@ mod tests { use std::collections::HashSet; /// Every [`key_wallet::account::AccountType`] variant; the wildcard-free - /// match below fails to compile if upstream adds one. + /// match below fails to compile if upstream adds one. `Standard` appears + /// twice — once per `StandardAccountType` — because both map to distinct + /// labels. fn all_account_type_variants() -> Vec { use key_wallet::account::{AccountType, StandardAccountType}; let variants = vec![ @@ -290,6 +306,10 @@ mod tests { index: 0, standard_account_type: StandardAccountType::BIP44Account, }, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP32Account, + }, AccountType::CoinJoin { index: 0 }, AccountType::IdentityRegistration, AccountType::IdentityTopUp { diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 85ad6f26a9..8cab077ad6 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -249,7 +249,7 @@ fn execute_upsert_utxo( const UPSERT_DERIVED_ADDRESS_SQL: &str = "INSERT INTO core_derived_addresses \ (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ - ON CONFLICT(wallet_id, account_type, pool_type, derivation_index) DO NOTHING"; + ON CONFLICT(wallet_id, account_type, account_index, pool_type, derivation_index) DO NOTHING"; /// Upsert one `core_derived_addresses` row from the live /// `addresses_derived` event path. `used` is set on insert only — the @@ -590,7 +590,7 @@ pub fn list_derived_addresses_for_test( let mut stmt = conn.prepare( "SELECT account_type, account_index, pool_type, derivation_index, address, used \ FROM core_derived_addresses WHERE wallet_id = ?1 \ - ORDER BY account_type, pool_type, derivation_index", + ORDER BY account_type, account_index, pool_type, derivation_index", )?; let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { Ok(DerivedAddressRow { diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs index a351cc0c7c..5839b6e097 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs @@ -108,7 +108,7 @@ fn a1_corrupt_blob_is_hard_error() { conn.execute( "INSERT INTO account_registrations \ (wallet_id, account_type, account_index, account_xpub_bytes) \ - VALUES (?1, 'standard', 0, X'00')", + VALUES (?1, 'standard_bip44', 0, X'00')", rusqlite::params![w.as_slice()], ) .unwrap(); diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs b/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs index 2f8edf5176..418e9e0c8b 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs @@ -108,7 +108,7 @@ fn check_rejects_bad_pool_type_on_derived_addresses() { let res = conn.execute( "INSERT INTO core_derived_addresses \ (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ - VALUES (?1, 'standard', 0, ?2, 0, 'addr', 0)", + VALUES (?1, 'standard_bip44', 0, ?2, 0, 'addr', 0)", params![wid(5).as_slice(), "not_a_pool"], ); assert_constraint_check(res, "core_derived_addresses.pool_type"); diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs b/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs index b1490decb7..6af9fd62a9 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs @@ -78,7 +78,7 @@ fn tc047b_delete_wallet_cascades_derived_addresses() { conn.execute( "INSERT INTO core_derived_addresses \ (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ - VALUES (?1, 'standard', 0, 'external', 0, 'addr', 0)", + VALUES (?1, 'standard_bip44', 0, 'external', 0, 'addr', 0)", rusqlite::params![w.as_slice()], ) .unwrap(); diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs index 8418d268b4..ae5408d8eb 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs @@ -86,12 +86,12 @@ fn tc027_smoke_insert_every_table() { // Labels must match the writer-side canonical strings — see the // CHECK constraint sourced from `ACCOUNT_TYPE_LABELS` in // `sqlite::schema::accounts`. - "INSERT INTO account_registrations (wallet_id, account_type, account_index, account_xpub_bytes) VALUES (?1, 'standard', 0, X'00')", + "INSERT INTO account_registrations (wallet_id, account_type, account_index, account_xpub_bytes) VALUES (?1, 'standard_bip44', 0, X'00')", &[&wallet_id.as_slice()], ), ( "account_address_pools", - "INSERT INTO account_address_pools (wallet_id, account_type, account_index, pool_type, snapshot_blob) VALUES (?1, 'standard', 0, 'external', X'00')", + "INSERT INTO account_address_pools (wallet_id, account_type, account_index, pool_type, snapshot_blob) VALUES (?1, 'standard_bip44', 0, 'external', X'00')", &[&wallet_id.as_slice()], ), ( @@ -111,7 +111,7 @@ fn tc027_smoke_insert_every_table() { ), ( "core_derived_addresses", - "INSERT INTO core_derived_addresses (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) VALUES (?1, 'standard', 0, 'external', 0, 'addr', 0)", + "INSERT INTO core_derived_addresses (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) VALUES (?1, 'standard_bip44', 0, 'external', 0, 'addr', 0)", &[&wallet_id.as_slice()], ), ( diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs b/packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs index 807f80df0c..cb74bfedf5 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs @@ -1016,12 +1016,12 @@ fn delete_wallet_leaves_no_surviving_rows() { let txid = vec![0x01u8; 32]; let outpoint = vec![0x02u8; 36]; let stmts: &[(&str, &[&dyn rusqlite::ToSql])] = &[ - ("INSERT INTO account_registrations (wallet_id, account_type, account_index, account_xpub_bytes) VALUES (?1, 'standard', 0, X'00')", &[&a.as_slice()]), - ("INSERT INTO account_address_pools (wallet_id, account_type, account_index, pool_type, snapshot_blob) VALUES (?1, 'standard', 0, 'external', X'00')", &[&a.as_slice()]), + ("INSERT INTO account_registrations (wallet_id, account_type, account_index, account_xpub_bytes) VALUES (?1, 'standard_bip44', 0, X'00')", &[&a.as_slice()]), + ("INSERT INTO account_address_pools (wallet_id, account_type, account_index, pool_type, snapshot_blob) VALUES (?1, 'standard_bip44', 0, 'external', X'00')", &[&a.as_slice()]), ("INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) VALUES (?1, ?2, 0, X'00')", &[&a.as_slice(), &txid]), ("INSERT INTO core_utxos (wallet_id, outpoint, value, script, account_index, spent) VALUES (?1, ?2, 0, X'00', 0, 0)", &[&a.as_slice(), &outpoint]), ("INSERT INTO core_instant_locks (wallet_id, txid, islock_blob) VALUES (?1, ?2, X'00')", &[&a.as_slice(), &txid]), - ("INSERT INTO core_derived_addresses (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) VALUES (?1, 'standard', 0, 'external', 0, 'addr', 0)", &[&a.as_slice()]), + ("INSERT INTO core_derived_addresses (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) VALUES (?1, 'standard_bip44', 0, 'external', 0, 'addr', 0)", &[&a.as_slice()]), ("INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) VALUES (?1, 1, 1)", &[&a.as_slice()]), ("INSERT INTO identity_keys (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) VALUES (?1, ?2, 0, X'00', X'00', NULL)", &[&a.as_slice(), &idy.as_slice()]), ("INSERT INTO platform_address_sync (wallet_id, sync_height, sync_timestamp, last_known_recent_block) VALUES (?1, 0, 0, 0)", &[&a.as_slice()]), diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs index 3830c7b03c..8c99034436 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs @@ -224,7 +224,7 @@ fn old_style_db_resolves_via_cache_and_manifest_fallback() { conn.execute( "INSERT INTO core_derived_addresses \ (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ - VALUES (?1, 'standard', ?2, 'external', ?3, ?4, 0)", + VALUES (?1, 'standard_bip44', ?2, 'external', ?3, ?4, 0)", rusqlite::params![ w.as_slice(), i64::from(account_index), @@ -373,7 +373,7 @@ fn partial_state_cache_hit_wins_over_manifest_fallback() { conn.execute( "INSERT INTO core_derived_addresses \ (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ - VALUES (?1, 'standard', ?2, 'internal', 999, ?3, 1)", + VALUES (?1, 'standard_bip44', ?2, 'internal', 999, ?3, 1)", rusqlite::params![w.as_slice(), LIVE_ACCOUNT_INDEX, live_addr.to_string()], ) .unwrap(); @@ -802,7 +802,7 @@ fn authoritative_redrive_preserves_used_true() { conn.execute( "INSERT INTO core_derived_addresses \ (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ - VALUES (?1, 'standard', 0, 'external', 0, ?2, 1)", + VALUES (?1, 'standard_bip44', 0, 'external', 0, ?2, 1)", rusqlite::params![w.as_slice(), addr.to_string()], ) .unwrap(); @@ -873,7 +873,7 @@ fn reload_keeps_single_live_row_for_pool_address() { conn.execute( "INSERT INTO core_derived_addresses \ (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ - VALUES (?1, 'standard', 0, 'internal', ?2, ?3, 1)", + VALUES (?1, 'standard_bip44', 0, 'internal', ?2, ?3, 1)", rusqlite::params![w.as_slice(), LIVE_DERIVATION_INDEX, pool_addr], ) .unwrap(); @@ -984,6 +984,284 @@ fn derivation_absent_from_manifest_is_fatal() { ); } +/// PK axis-1 regression: two Standard accounts with the same pool slot but +/// DIFFERENT `account_index` (index 0 and index 1, both BIP44) must each +/// persist their own row in `core_derived_addresses`. Before the fix both +/// collapsed to the same PK and the second row was silently dropped. +/// +/// Also verifies that a UTXO lookup at each address resolves to the CORRECT +/// `account_index`, not the survivor's. +#[test] +fn multi_account_index_same_slot_persists_both_rows() { + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::address_pool::AddressPoolType; + + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x51); + ensure_wallet_meta(&persister, &w); + + // Two BIP44 standard accounts at index 0 and 1, both deriving slot 0 of + // their external pool at distinct addresses. + let acct0 = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let acct1 = AccountType::Standard { + index: 1, + standard_account_type: StandardAccountType::BIP44Account, + }; + let addr0 = addr_from(0xA0); + let addr1 = addr_from(0xA1); + + // Register both pools so the emitter-contract guard accepts both events. + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![ + manifest_entry(acct0, AddressPoolType::External, 0, &addr0), + manifest_entry(acct1, AddressPoolType::External, 0, &addr1), + ], + ..Default::default() + }, + ) + .unwrap(); + + // Live derive both leaves in the same changeset. + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + addresses_derived: vec![ + derived_at(acct0, AddressPoolType::External, 0, addr0.clone()), + derived_at(acct1, AddressPoolType::External, 0, addr1.clone()), + ], + new_utxos: vec![utxo_at(&addr0, 0, 100_000), utxo_at(&addr1, 1, 200_000)], + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("both derived rows must survive (account_index is now in the PK)"); + + let conn = persister.lock_conn_for_test(); + let derived = core_state::list_derived_addresses_for_test(&conn, &w).unwrap(); + assert_eq!( + derived.len(), + 2, + "both derived rows must exist — no PK collapse" + ); + + let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); + let utxo0 = by_account + .get(&0) + .and_then(|v| v.iter().find(|r| r.value == 100_000)); + let utxo1 = by_account + .get(&1) + .and_then(|v| v.iter().find(|r| r.value == 200_000)); + assert!( + utxo0.is_some(), + "the account-0 UTXO must resolve to account_index 0" + ); + assert!( + utxo1.is_some(), + "the account-1 UTXO must resolve to account_index 1" + ); +} + +/// PK axis-2 regression: a BIP32 standard acct-0 and a BIP44 standard +/// acct-0 derive to distinct addresses at the same pool slot. Before the +/// fix both collapsed to one row (both mapped to the `"standard"` label), +/// and the second write was dropped. After the fix the labels are distinct +/// (`"standard_bip32"` vs `"standard_bip44"`) so both rows coexist. +#[test] +fn bip32_and_bip44_standard_acct0_persist_both_rows() { + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::address_pool::AddressPoolType; + + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x52); + ensure_wallet_meta(&persister, &w); + + let bip44 = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let bip32 = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP32Account, + }; + let addr_bip44 = addr_from(0xB4); + let addr_bip32 = addr_from(0xB2); + + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![ + manifest_entry(bip44, AddressPoolType::External, 0, &addr_bip44), + manifest_entry(bip32, AddressPoolType::External, 0, &addr_bip32), + ], + ..Default::default() + }, + ) + .unwrap(); + + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + addresses_derived: vec![ + derived_at(bip44, AddressPoolType::External, 0, addr_bip44.clone()), + derived_at(bip32, AddressPoolType::External, 0, addr_bip32.clone()), + ], + new_utxos: vec![ + utxo_at(&addr_bip44, 0, 444_000), + utxo_at(&addr_bip32, 1, 322_000), + ], + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("BIP32 and BIP44 standard acct-0 rows must coexist (distinct labels)"); + + let conn = persister.lock_conn_for_test(); + let derived = core_state::list_derived_addresses_for_test(&conn, &w).unwrap(); + assert_eq!( + derived.len(), + 2, + "BIP32 and BIP44 derived rows must both survive" + ); + let labels: std::collections::BTreeSet<&str> = + derived.iter().map(|r| r.account_type.as_str()).collect(); + assert!( + labels.contains("standard_bip44") && labels.contains("standard_bip32"), + "both account_type labels must be present in the derived cache" + ); + + let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); + let total: usize = by_account.values().map(|v| v.len()).sum(); + assert_eq!( + total, 2, + "both UTXOs must resolve via their respective accounts" + ); +} + +/// `account_registrations` PK regression (todo 0e3ad26b): a BIP32 and a +/// BIP44 standard acct-0 registered with DIFFERENT xpubs must each persist +/// their own row. Before the fix both resolved to label `"standard"` so the +/// second INSERT clobbered the first row's xpub. After the fix their labels +/// differ and both rows coexist with their original xpubs intact. +#[test] +fn account_registrations_bip32_and_bip44_both_survive() { + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::Wallet; + use platform_wallet::changeset::AccountRegistrationEntry; + use platform_wallet_storage::sqlite::schema::accounts; + + // Two distinct xpubs: seed them from different seed bytes. + let xpub_bip44 = { + let w = Wallet::from_seed_bytes( + [0xBBu8; 64], + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + w.accounts + .all_accounts() + .first() + .expect("at least one account") + .account_xpub + }; + let xpub_bip32 = { + let w = Wallet::from_seed_bytes( + [0xCCu8; 64], + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + w.accounts + .all_accounts() + .first() + .expect("at least one account") + .account_xpub + }; + assert_ne!( + xpub_bip44, xpub_bip32, + "fixture: seeds must yield distinct xpubs" + ); + + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x53); + ensure_wallet_meta(&persister, &w); + + let bip44_entry = AccountRegistrationEntry { + account_type: AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + account_xpub: xpub_bip44, + }; + let bip32_entry = AccountRegistrationEntry { + account_type: AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP32Account, + }, + account_xpub: xpub_bip32, + }; + + persister + .store( + w, + PlatformWalletChangeSet { + account_registrations: vec![bip44_entry.clone(), bip32_entry.clone()], + ..Default::default() + }, + ) + .expect("both registrations must persist without clobbering each other"); + + let conn = persister.lock_conn_for_test(); + let manifest = accounts::load_state(&conn, &w).expect("load_state"); + drop(conn); + + assert_eq!( + manifest.len(), + 2, + "both account_registrations rows must survive — no xpub clobber" + ); + + let has_bip44 = manifest.iter().any(|e| { + matches!( + e.account_type, + AccountType::Standard { + standard_account_type: StandardAccountType::BIP44Account, + .. + } + ) && e.account_xpub == xpub_bip44 + }); + let has_bip32 = manifest.iter().any(|e| { + matches!( + e.account_type, + AccountType::Standard { + standard_account_type: StandardAccountType::BIP32Account, + .. + } + ) && e.account_xpub == xpub_bip32 + }); + assert!( + has_bip44, + "the BIP44 registration with its original xpub must survive" + ); + assert!( + has_bip32, + "the BIP32 registration with its original xpub must survive" + ); +} + /// The guard does not over-fire: a UTXO at a genuinely-undeclared address /// (in NO pool, never derived) is still SKIPPED quietly — no error escapes, /// and the rest of the batch persists. Guards against the invariant guard diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs b/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs index 317ad51eb4..963fa2c62c 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs @@ -139,7 +139,7 @@ fn multi_account_utxos_bucket_to_real_account() { conn.execute( "INSERT INTO core_derived_addresses \ (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ - VALUES (?1, 'standard', ?2, 'external', ?3, ?4, 0)", + VALUES (?1, 'standard_bip44', ?2, 'external', ?3, ?4, 0)", params![w.as_slice(), acct as i64, deriv as i64, addr.to_string()], ) .unwrap(); From 925b109d88fb416fccfa2675db8850b780858154 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:07:53 +0200 Subject: [PATCH 11/24] fix(platform-wallet-storage): repair label-split fallout in pool_type CHECK test and SCHEMA.md The "standard" -> "standard_bip44"/"standard_bip32" label split left two stale spots: - check_rejects_bad_pool_type seeded account_type="standard" as a valid placeholder so the pool_type CHECK fired on "not_a_pool". With "standard" no longer a valid label the INSERT tripped the account_type CHECK instead, so the test passed for the wrong reason and stopped exercising pool_type. Placeholder is now "standard_bip44" so pool_type is again what fails. - SCHEMA.md: refreshed the ACCOUNT_REGISTRATIONS account_type label list, marked CORE_DERIVED_ADDRESSES.account_index as a PK column with a corrected annotation, and updated the prose PK to the 5-column tuple (wallet_id, account_type, account_index, pool_type, derivation_index). Co-Authored-By: Claude Opus 4.6 --- packages/rs-platform-wallet-storage/SCHEMA.md | 6 +++--- .../tests/sqlite_check_constraints.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index 98865a0185..8b62518fa4 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -62,7 +62,7 @@ erDiagram ACCOUNT_REGISTRATIONS { BLOB wallet_id PK - TEXT account_type PK "standard | coinjoin | identity_registration | ..." + TEXT account_type PK "standard_bip44 | standard_bip32 | coinjoin | identity_registration | ..." INTEGER account_index PK BLOB account_xpub_bytes "bincode-encoded AccountRegistrationEntry" } @@ -105,9 +105,9 @@ erDiagram CORE_DERIVED_ADDRESSES { BLOB wallet_id PK TEXT account_type PK + INTEGER account_index PK "owning account; also the value the read returns" TEXT pool_type PK "external | internal | absent | absent_hardened" INTEGER derivation_index PK - INTEGER account_index "account-level context; the value the read returns" TEXT address UK "bech32 / Base58 address string" INTEGER used "0 | 1" } @@ -438,7 +438,7 @@ an emitter bug, never on a benign gap. > be a `new_utxos` UTXO address. This is an upstream classifier property > (`key-wallet` `account_checker`), not enforceable at the storage layer. -- PK: `(wallet_id, account_type, pool_type, derivation_index)` — the BIP32 +- PK: `(wallet_id, account_type, account_index, pool_type, derivation_index)` — the BIP32 leaf identity (one row per derived address). - `UNIQUE(wallet_id, address)` — the read-index invariant (one account_index per address); its index also backs the address lookup, so diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs b/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs index 418e9e0c8b..2933bedd2a 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs @@ -87,7 +87,7 @@ fn check_rejects_bad_pool_type() { VALUES (?1, ?2, ?3, ?4, ?5)", params![ wid(3).as_slice(), - "standard", + "standard_bip44", 0i64, "not_a_pool", &[0u8; 4][..] From b4506492f7c2ae9015835caf31e15111d0be8a49 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:50:37 +0200 Subject: [PATCH 12/24] fix(platform-wallet): add background_generation guard to PlatformAddressSyncManager The exiting sync-loop thread cleared *background_cancel = None unconditionally; under restart-in-place a lagging old thread could clobber a freshly-installed cancel token, making the new loop uncancellable and letting a later start() spawn a duplicate loop. Gate the clear on a per-start generation counter, mirroring identity_sync.rs / shielded_sync.rs. Co-Authored-By: Claude Opus 4.6 --- .../src/manager/platform_address_sync.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/manager/platform_address_sync.rs b/packages/rs-platform-wallet/src/manager/platform_address_sync.rs index e1a229806c..14dd80bb52 100644 --- a/packages/rs-platform-wallet/src/manager/platform_address_sync.rs +++ b/packages/rs-platform-wallet/src/manager/platform_address_sync.rs @@ -97,6 +97,10 @@ pub struct PlatformAddressSyncManager { event_manager: Arc, /// Cancel token for the background loop, if running. background_cancel: StdMutex>, + /// Monotonically increasing generation counter. Incremented each + /// time `start()` installs a new cancel token so the exiting + /// thread can tell whether its token is still current. + background_generation: AtomicU64, interval_secs: AtomicU64, is_syncing: AtomicBool, /// Set by [`quiesce`](Self::quiesce) to gate new passes while it @@ -125,6 +129,7 @@ impl PlatformAddressSyncManager { wallets, event_manager, background_cancel: StdMutex::new(None), + background_generation: AtomicU64::new(0), interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS), is_syncing: AtomicBool::new(false), quiescing: AtomicBool::new(false), @@ -201,6 +206,7 @@ impl PlatformAddressSyncManager { } let cancel = CancellationToken::new(); *guard = Some(cancel.clone()); + let my_gen = self.background_generation.fetch_add(1, Ordering::AcqRel) + 1; drop(guard); let handle = tokio::runtime::Handle::current(); @@ -223,8 +229,12 @@ impl PlatformAddressSyncManager { } } + // Only clear the slot if no newer start() has + // installed a replacement token since we launched. if let Ok(mut guard) = this.background_cancel.lock() { - *guard = None; + if this.background_generation.load(Ordering::Acquire) == my_gen { + *guard = None; + } } }); }) From 1f3ea29c69b5ad7187eb8801aee7dd26dd23d332 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:20:58 +0200 Subject: [PATCH 13/24] fix(platform-wallet): close shielded_sync generation-guard TOCTOU (lock-first-then-check) ShieldedSyncManager's exiting sync-loop thread checked background_generation before acquiring the background_cancel lock; a concurrent start() between the check and the lock could have the stale thread clobber the freshly-installed token. Reorder to lock-first-then-check, matching identity_sync.rs and platform_address_sync.rs (start() bumps the generation under the same lock). Co-Authored-By: Claude Opus 4.6 --- .../src/manager/shielded_sync.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/shielded_sync.rs b/packages/rs-platform-wallet/src/manager/shielded_sync.rs index 482674b432..de3be85efb 100644 --- a/packages/rs-platform-wallet/src/manager/shielded_sync.rs +++ b/packages/rs-platform-wallet/src/manager/shielded_sync.rs @@ -261,14 +261,15 @@ impl ShieldedSyncManager { } } - // Only clear `background_cancel` if the active - // generation is still ours. Without this guard a - // tight `stop()` → `start()` reschedule has the - // exiting thread overwrite the *new* generation's - // token, leaving the new loop running but - // unreflectable via `is_running()` / `stop()`. - if this.background_generation.load(Ordering::Acquire) == my_gen { - if let Ok(mut guard) = this.background_cancel.lock() { + // Clear `background_cancel` only if the active + // generation is still ours. Acquire the lock + // FIRST, then check — `start()` bumps the + // generation while holding this same lock, so + // once we hold it the generation is final w.r.t. + // any concurrent token swap (no TOCTOU between + // the check and the clear). + if let Ok(mut guard) = this.background_cancel.lock() { + if this.background_generation.load(Ordering::Acquire) == my_gen { *guard = None; } } From fa4584d83432def9f6667852b7392687e362eb97 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:33:24 +0200 Subject: [PATCH 14/24] docs(platform-wallet-storage): update stale doc comment on ACCOUNT_INDEX_BY_ADDRESS_SQL The previous comment described a pre-UNIQUE multi-row model ("an address can be derived under multiple account_types"). V001 adds UNIQUE(wallet_id, address), so each (wallet_id, address) pair maps to at most one row; the ORDER BY ... LIMIT 1 is now a defensive guard only. Addresses nit raised by thepastaclaw at HEAD 1f3ea29c. Co-Authored-By: Claude Sonnet 4.6 --- .../src/sqlite/schema/core_state.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 8cab077ad6..ef4519a034 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -160,9 +160,9 @@ pub fn apply( } /// Resolve a UTXO's owning account index via the `core_derived_addresses` map. -/// An address can be derived under multiple `account_type`s, so `ORDER BY` with -/// `LIMIT 1` makes the choice deterministic (SQLite would otherwise pick an -/// arbitrary matching row). +/// `UNIQUE(wallet_id, address)` in V001 guarantees at most one row per +/// `(wallet_id, address)` pair, so the query returns 0 or 1 rows. +/// The `ORDER BY … LIMIT 1` is kept as a defensive guard against schema drift. const ACCOUNT_INDEX_BY_ADDRESS_SQL: &str = "SELECT account_index FROM core_derived_addresses \ WHERE wallet_id = ?1 AND address = ?2 \ ORDER BY account_type, account_index LIMIT 1"; From aed5652d70a73d0d1f7438230565f51276f99db9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:08:13 +0200 Subject: [PATCH 15/24] docs(platform-wallet): correct CHECK-column count and port generation-guard rationale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two doc-only corrections surfaced during review of the core-derived rehydration work: - SCHEMA.md "Enum-domain CHECK constraints": the table lists eight TEXT columns since the PR added core_derived_addresses.pool_type, but the prose still said "Seven". Bump the count word; the table and the "five enum domains" framing were already correct. - platform_address_sync.rs: port the fuller generation-guard rationale from ShieldedSyncManager — the background_generation field doc now spells out the stop()->start() overlap race, and the cleanup block explains the acquire-lock-FIRST-then-check ordering that closes the TOCTOU. Brings the two managers' comments to parity. Co-Authored-By: Claude Opus 4.6 --- packages/rs-platform-wallet-storage/SCHEMA.md | 2 +- .../src/manager/platform_address_sync.rs | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index 8b62518fa4..5015f949f0 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -626,7 +626,7 @@ before the address exists. ## Enum-domain CHECK constraints -Seven TEXT columns carry a `CHECK (col IN (...))` across five enum +Eight TEXT columns carry a `CHECK (col IN (...))` across five enum domains — `account_type` is reused in three tables. The IN-list is built at migration time from `pub(crate) const *_LABELS` arrays declared next to each writer function. Four domains mirror an upstream Rust enum; the diff --git a/packages/rs-platform-wallet/src/manager/platform_address_sync.rs b/packages/rs-platform-wallet/src/manager/platform_address_sync.rs index 14dd80bb52..895f0a6c47 100644 --- a/packages/rs-platform-wallet/src/manager/platform_address_sync.rs +++ b/packages/rs-platform-wallet/src/manager/platform_address_sync.rs @@ -97,9 +97,13 @@ pub struct PlatformAddressSyncManager { event_manager: Arc, /// Cancel token for the background loop, if running. background_cancel: StdMutex>, - /// Monotonically increasing generation counter. Incremented each - /// time `start()` installs a new cancel token so the exiting - /// thread can tell whether its token is still current. + /// Monotonically increasing generation counter. Bumped on every + /// `start()` so the exiting thread can tell whether its + /// generation is still the active one before clearing + /// `background_cancel`. Without this, a `stop()` → `start()` + /// overlap lets the prior thread's cleanup strip the new + /// generation's token, leaving the new loop running but + /// untrackable via `is_running()`. background_generation: AtomicU64, interval_secs: AtomicU64, is_syncing: AtomicBool, @@ -229,8 +233,13 @@ impl PlatformAddressSyncManager { } } - // Only clear the slot if no newer start() has - // installed a replacement token since we launched. + // Clear `background_cancel` only if the active + // generation is still ours. Acquire the lock + // FIRST, then check — `start()` bumps the + // generation while holding this same lock, so + // once we hold it the generation is final w.r.t. + // any concurrent token swap (no TOCTOU between + // the check and the clear). if let Ok(mut guard) = this.background_cancel.lock() { if this.background_generation.load(Ordering::Acquire) == my_gen { *guard = None; From ca686909e50b8cb8f1a9f2535586358f5018c500 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:08:32 +0200 Subject: [PATCH 16/24] test(platform-wallet): cover generation-guard restart and contacts.state CHECK Lock in two previously-untested invariants from the core-derived rehydration work: - Restart-in-place regression for both PlatformAddressSyncManager and ShieldedSyncManager: a tight start() -> stop() -> start() must leave the manager running on the new generation. The cancelled gen-1 loop must not strip gen-2's freshly installed cancel token as it exits. The test waits on a real lifecycle signal (last_sync_unix) to park gen-1 in its interval sleep, then a bounded poll asserts is_running() stays true (a regression flips it false within ms). Multi-thread flavor required since start() drives its loop via block_on on a dedicated OS thread. shielded_sync.rs gains its first test module. Verified non-vacuous: temporarily dropping the guard makes the test fail with the exact assertion message, and it passes 8/8 repeats. - contacts.state CHECK constraint: add reject-bad-label and accept-every-known-label tests mirroring the wallets.network pattern. Labels are hardcoded (the source-of-truth CONTACT_STATE_LABELS const is pub(crate), unreachable from this integration-test crate); the per-module contact_state_labels_match_enum unit test guards the const against drift. Co-Authored-By: Claude Opus 4.6 --- .../tests/sqlite_check_constraints.rs | 62 ++++++++++++++-- .../src/manager/platform_address_sync.rs | 52 ++++++++++++++ .../src/manager/shielded_sync.rs | 72 +++++++++++++++++++ 3 files changed, 182 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs b/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs index 2933bedd2a..91dc50a6f8 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs @@ -1,12 +1,11 @@ //! Smoke tests for the enum-domain `CHECK` constraints. The schema has -//! seven such TEXT columns across five domains (`account_type` is reused +//! eight such TEXT columns across five domains (`account_type` is reused //! by `account_registrations`, `account_address_pools`, and //! `core_derived_addresses`; `pool_type` by `account_address_pools` and //! `core_derived_addresses`). These tests exercise `wallets.network`, //! `account_registrations.account_type`, `account_address_pools.pool_type`, -//! `asset_locks.status`, and both `core_derived_addresses.pool_type` / -//! `account_type` directly. The synthetic `contacts.state` domain is not -//! exercised here. +//! `asset_locks.status`, both `core_derived_addresses.pool_type` / +//! `account_type`, and the synthetic `contacts.state` domain directly. //! //! The per-module parity unit tests in `src/sqlite/schema/*` cover the //! Rust↔const-array equality. These tests cover the runtime half: a @@ -174,3 +173,58 @@ fn check_accepts_every_known_label_network() { .unwrap_or_else(|e| panic!("network={label} should be accepted: {e}")); } } + +#[test] +fn check_rejects_bad_contact_state() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + // Seed a valid parent wallet so the insert trips the state CHECK, not the FK. + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", + params![wid(7).as_slice(), "testnet", 0i64], + ) + .expect("seed wallets"); + let res = conn.execute( + "INSERT INTO contacts (wallet_id, owner_id, contact_id, state) \ + VALUES (?1, ?2, ?3, ?4)", + params![ + wid(7).as_slice(), + &[0xAAu8; 32][..], + &[0xBBu8; 32][..], + "not_a_contact_state" + ], + ); + assert_constraint_check(res, "contacts.state"); +} + +#[test] +fn check_accepts_every_known_contact_state_label() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", + params![wid(8).as_slice(), "testnet", 0i64], + ) + .expect("seed wallets"); + // Mirrors `sqlite::schema::contacts::CONTACT_STATE_LABELS`; hardcoded + // because that const is `pub(crate)` and unreachable from this separate + // integration-test crate (same constraint as the network test above). + // The per-module `contact_state_labels_match_enum` unit test guards the + // const itself against drift, so a label added there without updating + // this list surfaces in that test, not as a silent gap here. + for (i, label) in ["sent", "received", "established"].iter().enumerate() { + // Same wallet+owner, distinct contact_id per label to keep the + // composite PK (wallet_id, owner_id, contact_id) unique. + conn.execute( + "INSERT INTO contacts (wallet_id, owner_id, contact_id, state) \ + VALUES (?1, ?2, ?3, ?4)", + params![ + wid(8).as_slice(), + &[0xC0u8; 32][..], + &[i as u8; 32][..], + *label + ], + ) + .unwrap_or_else(|e| panic!("contact state={label} should be accepted: {e}")); + } +} diff --git a/packages/rs-platform-wallet/src/manager/platform_address_sync.rs b/packages/rs-platform-wallet/src/manager/platform_address_sync.rs index 895f0a6c47..30885258ba 100644 --- a/packages/rs-platform-wallet/src/manager/platform_address_sync.rs +++ b/packages/rs-platform-wallet/src/manager/platform_address_sync.rs @@ -524,4 +524,56 @@ mod tests { assert_eq!(counter.completions.load(AtomicOrdering::SeqCst), 0); assert!(!mgr.is_syncing()); } + + /// Restart-in-place regression for the generation guard: a tight + /// `start()` → `stop()` → `start()` must leave the manager *running* + /// on the new generation. The cancelled gen-1 loop races to clear + /// `background_cancel` as it exits; the generation guard must stop it + /// from stripping gen-2's freshly installed token — otherwise the new + /// loop keeps running but becomes invisible to `is_running()` / + /// `stop()`. + /// + /// Determinism: the only wait is a *bounded* poll. With the guard in + /// place `is_running()` is true for the whole window, so the test + /// never fails spuriously on correct code. A regression flips it false + /// within milliseconds once the stale loop clears the slot, which the + /// poll catches. Needs the multi-thread flavor because `start()` + /// drives its loop via `Handle::current().block_on` on a dedicated OS + /// thread, which would deadlock a single-threaded test runtime. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn restart_in_place_keeps_running_after_stale_loop_exits() { + let (mgr, _counter) = make_manager(); + + // Gen 1. Wait (bounded) for the first pass to land — a real + // lifecycle signal that the loop is now parked in its interval + // sleep, so its cleanup is still pending when we stop+restart. + Arc::clone(&mgr).start(); + let mut waited = 0; + while mgr.last_sync_unix_seconds().is_none() { + assert!(waited < 200, "gen-1's first sync pass never completed"); + tokio::time::sleep(Duration::from_millis(10)).await; + waited += 1; + } + + // Tight stop→start with no await between: the just-cancelled gen-1 + // loop cannot reach its cleanup before gen 2 is installed, so the + // race window the guard protects is reliably open. + mgr.stop(); + Arc::clone(&mgr).start(); + + // Give the stale gen-1 loop ample time to run its (guarded) + // cleanup. `is_running()` must stay true throughout. + for _ in 0..100 { + assert!( + mgr.is_running(), + "stale gen-1 loop cleared gen-2's cancel token — generation guard regressed" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + + // The surviving loop is the tracked one: a single `stop()` fully + // reflects it, so there is no orphaned unreflectable duplicate. + mgr.stop(); + assert!(!mgr.is_running(), "stop() must reflect the live loop"); + } } diff --git a/packages/rs-platform-wallet/src/manager/shielded_sync.rs b/packages/rs-platform-wallet/src/manager/shielded_sync.rs index de3be85efb..e7892e8dff 100644 --- a/packages/rs-platform-wallet/src/manager/shielded_sync.rs +++ b/packages/rs-platform-wallet/src/manager/shielded_sync.rs @@ -476,3 +476,75 @@ impl std::fmt::Debug for ShieldedSyncManager { .finish() } } + +#[cfg(test)] +mod tests { + use super::*; + + use crate::events::PlatformEventHandler; + + /// Build a manager with an empty coordinator slot and a no-handler + /// event manager. An empty slot makes `sync_now` return an empty + /// summary, but it still drives the full timestamp + completion + /// protocol and — crucially for this test — the generation-guarded + /// background loop, without needing a live `NetworkShieldedCoordinator`. + fn make_manager() -> Arc { + let event_manager = Arc::new(PlatformEventManager::new(Vec::< + Arc, + >::new())); + let coordinator_slot = Arc::new(RwLock::new(None)); + Arc::new(ShieldedSyncManager::new(event_manager, coordinator_slot)) + } + + /// Restart-in-place regression for the generation guard: a tight + /// `start()` → `stop()` → `start()` must leave the manager *running* + /// on the new generation. The cancelled gen-1 loop races to clear + /// `background_cancel` as it exits; the generation guard must stop it + /// from stripping gen-2's freshly installed token — otherwise the new + /// loop keeps running but becomes invisible to `is_running()` / + /// `stop()`. + /// + /// Determinism: the only wait is a *bounded* poll. With the guard in + /// place `is_running()` is true for the whole window, so the test + /// never fails spuriously on correct code. A regression flips it false + /// within milliseconds once the stale loop clears the slot, which the + /// poll catches. Needs the multi-thread flavor because `start()` + /// drives its loop via `Handle::current().block_on` on a dedicated OS + /// thread, which would deadlock a single-threaded test runtime. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn restart_in_place_keeps_running_after_stale_loop_exits() { + let mgr = make_manager(); + + // Gen 1. Wait (bounded) for the first pass to land — a real + // lifecycle signal that the loop is now parked in its interval + // sleep, so its cleanup is still pending when we stop+restart. + Arc::clone(&mgr).start(); + let mut waited = 0; + while mgr.last_sync_unix_seconds().is_none() { + assert!(waited < 200, "gen-1's first sync pass never completed"); + tokio::time::sleep(Duration::from_millis(10)).await; + waited += 1; + } + + // Tight stop→start with no await between: the just-cancelled gen-1 + // loop cannot reach its cleanup before gen 2 is installed, so the + // race window the guard protects is reliably open. + mgr.stop(); + Arc::clone(&mgr).start(); + + // Give the stale gen-1 loop ample time to run its (guarded) + // cleanup. `is_running()` must stay true throughout. + for _ in 0..100 { + assert!( + mgr.is_running(), + "stale gen-1 loop cleared gen-2's cancel token — generation guard regressed" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + + // The surviving loop is the tracked one: a single `stop()` fully + // reflects it, so there is no orphaned unreflectable duplicate. + mgr.stop(); + assert!(!mgr.is_running(), "stop() must reflect the live loop"); + } +} From 8d8724e9923b04884370bfd86d33463b540423b1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:26:47 +0200 Subject: [PATCH 17/24] refactor(platform-wallet)!: hardcode core UTXO account_index=0; retire in-band pool snapshot Replace the in-band `account_address_pools` snapshot + address->account lookup machinery (a full snapshot smuggled into a per-block diff) with a hardcoded `account_index = 0` at the storage writer, plus a fail-loud single-account guard in `core_bridge` (the only site with `record.account_type`). The product uses only the default account, and `core_utxos.account_index` has exactly one consumer (the per-account grouping reader), so this is correct with no public changeset-API change. Storage (platform-wallet-storage): - core_state::apply: drop the addresses_derived write loop, the emitter-contract guard, and the manifest-fallback lookup; execute_upsert_utxo now writes a const CORE_UTXO_ACCOUNT_INDEX (0). Deleted ACCOUNT_INDEX_BY_ADDRESS_SQL, UPSERT_DERIVED_ADDRESS_SQL, upsert_derived_address_row, pool_declared_address_indices, and the DerivedAddressRow/list_derived_addresses_for_test test helpers. - V001 (pre-release, edited in place): drop core_derived_addresses and account_address_pools tables; core_utxos unchanged (account_index column kept). Removed the now-unused pool_type CHECK interpolation. - accounts.rs: delete apply_pools; drop now-dead POOL_TYPE_LABELS / pool_type_db_label + their parity test. account_registrations and the account_index helper (used by apply_registrations) stay. - persister.rs: drop the apply_pools branch (account_address_pools field kept for API stability, no longer persisted). - error.rs: remove the now-unreachable DerivedIndexInvariantViolated and vestigial UtxoAddressNotDerived variants. Bridge (platform-wallet): - core_bridge: remove build_platform_changeset's snapshot block and snapshot_account_pools; build_core_changeset now returns Result and runs ensure_default_account on every UTXO-bearing record. A non-default funds account (AccountType::index() == Some(n != 0)) is a fail-loud CoreBridgeError::NonDefaultAccount, logged and skipped by the adapter so mis-attributed UTXOs are never persisted. addresses_derived is still forwarded (feeds the iOS registry via FFI); the FFI C ABI is unchanged. Tests: genesis-rescan regression rewritten (a UTXO on a fresh gap-limit address persists under account 0, no snapshot, no abort); single-account guard test (verified non-vacuous by neutering the guard); deleted the table-gone sqlite_pool_derived_rehydration suite; updated CHECK / migration / error-classification / object-metadata / FK / reader / structural-hardening tests to the new schema. Also fixes a batch of pre-existing stale WalletMetadataEntry initializers (missing wallet_group_id) that blocked the storage test build. BREAKING CHANGE: removes WalletStorageError::DerivedIndexInvariantViolated and WalletStorageError::UtxoAddressNotDerived (pub enum variants). Co-Authored-By: Claude Opus 4.6 --- .../migrations/V001__initial.rs | 30 - .../src/sqlite/error.rs | 26 - .../src/sqlite/persister.rs | 7 +- .../src/sqlite/schema/accounts.rs | 99 +- .../src/sqlite/schema/core_state.rs | 253 +--- .../tests/marvin_gate_in_band_ordering.rs | 172 +-- .../tests/persistence_error_kind_mapping.rs | 6 - .../tests/sqlite_check_constraints.rs | 70 +- .../tests/sqlite_core_state_reader.rs | 55 +- .../tests/sqlite_dashpay_overlay_contract.rs | 1 + .../sqlite_delete_partial_commit_window.rs | 1 + .../tests/sqlite_delete_real_apply_failure.rs | 1 + .../tests/sqlite_error_classification.rs | 10 - .../tests/sqlite_fk_changeset_ordering.rs | 1 + .../tests/sqlite_foreign_keys.rs | 42 - .../tests/sqlite_load_wiring.rs | 54 +- .../tests/sqlite_migrations.rs | 10 - .../tests/sqlite_object_metadata.rs | 4 - .../tests/sqlite_pool_derived_rehydration.rs | 1343 ----------------- .../tests/sqlite_structural_hardening.rs | 86 +- .../tests/sqlite_wallet_db_identity.rs | 1 + .../src/changeset/core_bridge.rs | 363 ++--- 22 files changed, 204 insertions(+), 2431 deletions(-) delete mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs diff --git a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs index cbd9d88814..4ee50a3fc7 100644 --- a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs +++ b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs @@ -51,7 +51,6 @@ pub fn migration() -> String { let network_check = build_check_in(crate::sqlite::schema::wallets::NETWORK_LABELS); let account_type_check = build_check_in(crate::sqlite::schema::accounts::ACCOUNT_TYPE_LABELS); - let pool_type_check = build_check_in(crate::sqlite::schema::accounts::POOL_TYPE_LABELS); let asset_lock_status_check = build_check_in(crate::sqlite::schema::asset_locks::ASSET_LOCK_STATUS_LABELS); let contact_state_check = @@ -82,16 +81,6 @@ CREATE TABLE account_registrations ( FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE ); -CREATE TABLE account_address_pools ( - wallet_id BLOB NOT NULL, - account_type TEXT NOT NULL CHECK (account_type IN {account_type_check}), - account_index INTEGER NOT NULL, - pool_type TEXT NOT NULL CHECK (pool_type IN {pool_type_check}), - snapshot_blob BLOB NOT NULL, - PRIMARY KEY (wallet_id, account_type, account_index, pool_type), - FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE -); - CREATE TABLE core_transactions ( wallet_id BLOB NOT NULL, txid BLOB NOT NULL, @@ -143,25 +132,6 @@ CREATE TABLE core_instant_locks ( FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE ); -CREATE TABLE core_derived_addresses ( - wallet_id BLOB NOT NULL, - account_type TEXT NOT NULL CHECK (account_type IN {account_type_check}), - account_index INTEGER NOT NULL, - pool_type TEXT NOT NULL CHECK (pool_type IN {pool_type_check}), - derivation_index INTEGER NOT NULL, - address TEXT NOT NULL, - used INTEGER NOT NULL, - -- PK is the BIP32 leaf identity: the full tuple (wallet, account_type, - -- account_index, pool, derivation_index) uniquely identifies one derived - -- leaf. `account_type` uses distinct labels per StandardAccountType - -- variant so BIP32 and BIP44 standard accounts never collapse. `address` - -- is a derived attribute — cross-leaf collisions trip UNIQUE(address). - -- The UNIQUE index also backs ACCOUNT_INDEX_BY_ADDRESS_SQL. - PRIMARY KEY (wallet_id, account_type, account_index, pool_type, derivation_index), - UNIQUE (wallet_id, address), - FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE -); - CREATE TABLE core_sync_state ( wallet_id BLOB NOT NULL PRIMARY KEY, last_processed_height INTEGER, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs index d805f54aa9..4864709501 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -218,28 +218,6 @@ pub enum WalletStorageError { limit_bytes: usize, }, - /// An unspent UTXO named an address absent from - /// `core_derived_addresses`, so its account index can't be resolved. - /// Retained as a fatal-classified typed marker; the apply path no - /// longer raises it — it skips such a UTXO (logged) so one - /// unresolvable row never aborts a whole flush, and the balance - /// re-warms when the address later derives. - #[error("unspent utxo address {address} is not in core_derived_addresses")] - UtxoAddressNotDerived { address: String }, - - /// A live `addresses_derived` entry arrived without its address in the - /// wallet's `account_address_pools` manifest. The emitter must attach a - /// full pool snapshot in-band with every derivation, so a derived - /// address absent from the manifest means the emitter contract is - /// broken — a logic regression, not a benign SPV gap. Failing loud at - /// the storage trust boundary surfaces it instead of persisting a row - /// the manifest can't vouch for. - #[error( - "emitter contract violated: derived address {address} is absent from the \ - account_address_pools manifest (pool snapshot not emitted in-band)" - )] - DerivedIndexInvariantViolated { address: String }, - /// `PRAGMA foreign_keys = ON` was issued on open but the read-back /// reported the constraint enforcement is still off — the linked /// SQLite build silently ignores the pragma (no FK support compiled @@ -387,8 +365,6 @@ impl WalletStorageError { | Self::IdentityEntryIdMismatch | Self::AssetLockEntryMismatch { .. } | Self::BlobTooLarge { .. } - | Self::UtxoAddressNotDerived { .. } - | Self::DerivedIndexInvariantViolated { .. } | Self::IntegerOverflow { .. } => false, } } @@ -465,8 +441,6 @@ impl WalletStorageError { Self::IdentityEntryIdMismatch => "identity_entry_id_mismatch", Self::AssetLockEntryMismatch { .. } => "asset_lock_entry_mismatch", Self::BlobTooLarge { .. } => "blob_too_large", - Self::UtxoAddressNotDerived { .. } => "utxo_address_not_derived", - Self::DerivedIndexInvariantViolated { .. } => "derived_index_invariant_violated", Self::IntegerOverflow { .. } => "integer_overflow", } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 07b62afd97..9369fce02d 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1053,9 +1053,10 @@ fn apply_changeset_to_tx( if !cs.account_registrations.is_empty() { schema::accounts::apply_registrations(tx, wallet_id, &cs.account_registrations)?; } - if !cs.account_address_pools.is_empty() { - schema::accounts::apply_pools(tx, wallet_id, &cs.account_address_pools)?; - } + // `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. if let Some(core) = cs.core.as_ref() { schema::core_state::apply(tx, wallet_id, core)?; } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs index 0a4872c281..00149467ec 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs @@ -1,22 +1,21 @@ -//! `account_registrations` + `account_address_pools` writers + readers -//! (platform-payment registrations and the keyless account-manifest -//! reader). +//! `account_registrations` writer + keyless reader (platform-payment +//! registrations and the rehydration account-manifest oracle). use std::collections::BTreeMap; use key_wallet::bip32::ExtendedPubKey; use rusqlite::{params, Connection, Transaction}; -use platform_wallet::changeset::{AccountAddressPoolEntry, AccountRegistrationEntry}; +use platform_wallet::changeset::AccountRegistrationEntry; use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::blob; use crate::sqlite::schema::blob::impl_persistable_blob; -// PUBLIC material only: account-manifest types (account xpubs / pool -// snapshots) reaching `_blob` columns. -impl_persistable_blob!(AccountRegistrationEntry, AccountAddressPoolEntry); +// PUBLIC material only: the account-registration xpub manifest reaching +// the `account_xpub_bytes` blob column. +impl_persistable_blob!(AccountRegistrationEntry); /// Decoded `platform_payment` account registration: the DIP-17 account /// index and its extended public key, recovered from the bincode-serde @@ -125,37 +124,6 @@ pub fn apply_registrations( Ok(()) } -pub fn apply_pools( - tx: &Transaction<'_>, - wallet_id: &WalletId, - entries: &[AccountAddressPoolEntry], -) -> Result<(), WalletStorageError> { - if entries.is_empty() { - return Ok(()); - } - let mut stmt = tx.prepare_cached( - "INSERT INTO account_address_pools \ - (wallet_id, account_type, account_index, pool_type, snapshot_blob) \ - VALUES (?1, ?2, ?3, ?4, ?5) \ - ON CONFLICT(wallet_id, account_type, account_index, pool_type) DO UPDATE SET \ - snapshot_blob = excluded.snapshot_blob", - )?; - for entry in entries { - let account_type = account_type_db_label(&entry.account_type); - let account_index = account_index(&entry.account_type); - let pool_type = pool_type_db_label(&entry.pool_type); - let payload = blob::encode(entry)?; - stmt.execute(params![ - wallet_id.as_slice(), - account_type, - i64::from(account_index), - pool_type, - payload, - ])?; - } - Ok(()) -} - /// Read every `account_registrations` row for `wallet_id` into a keyless /// [`AccountRegistrationEntry`] manifest — the rehydration account-set oracle /// (which accounts to re-derive + the per-account xpubs the wrong-account gate @@ -210,15 +178,6 @@ pub(crate) const ACCOUNT_TYPE_LABELS: &[&str] = &[ "platform_payment", ]; -/// Single source of truth for the `account_address_pools.pool_type` -/// TEXT-column domain. -/// -/// Mirrors every variant of -/// [`key_wallet::managed_account::address_pool::AddressPoolType`] -/// (writer side: [`pool_type_db_label`]). See [`ACCOUNT_TYPE_LABELS`] -/// for the broader rationale and the parity-test contract. -pub(crate) const POOL_TYPE_LABELS: &[&str] = &["external", "internal", "absent", "absent_hardened"]; - /// Stable database label for an `AccountType` variant (the `Debug` impl is not /// a stable format; this match is the contract). An added upstream variant /// fails this match's exhaustiveness check at compile time. @@ -253,19 +212,6 @@ pub(crate) fn account_type_db_label(at: &key_wallet::account::AccountType) -> &' } } -/// Stable database label for an `AddressPoolType` variant. -pub(crate) fn pool_type_db_label( - pool: &key_wallet::managed_account::address_pool::AddressPoolType, -) -> &'static str { - use key_wallet::managed_account::address_pool::AddressPoolType; - match pool { - AddressPoolType::External => "external", - AddressPoolType::Internal => "internal", - AddressPoolType::Absent => "absent", - AddressPoolType::AbsentHardened => "absent_hardened", - } -} - /// Numeric account index embedded in an `AccountType`, persisted in the /// `account_index` column of `account_registrations`, `account_address_pools`, /// and `core_derived_addresses`. @@ -360,25 +306,6 @@ mod tests { variants } - fn all_pool_type_variants() -> Vec { - use key_wallet::managed_account::address_pool::AddressPoolType; - let variants = vec![ - AddressPoolType::External, - AddressPoolType::Internal, - AddressPoolType::Absent, - AddressPoolType::AbsentHardened, - ]; - for v in &variants { - match v { - AddressPoolType::External - | AddressPoolType::Internal - | AddressPoolType::Absent - | AddressPoolType::AbsentHardened => {} - } - } - variants - } - #[test] fn account_type_labels_match_enum() { let from_writer: HashSet<&'static str> = all_account_type_variants() @@ -392,18 +319,4 @@ mod tests { from_const, from_writer ); } - - #[test] - fn pool_type_labels_match_enum() { - let from_writer: HashSet<&'static str> = all_pool_type_variants() - .iter() - .map(pool_type_db_label) - .collect(); - let from_const: HashSet<&'static str> = POOL_TYPE_LABELS.iter().copied().collect(); - assert_eq!( - from_writer, from_const, - "POOL_TYPE_LABELS ({:?}) drifted from pool_type_db_label codomain ({:?})", - from_const, from_writer - ); - } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index ef4519a034..567b2e0998 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -54,56 +54,15 @@ pub fn apply( ])?; } } - // Lazily-built address → account_index map from this wallet's - // `account_address_pools` (the in-band manifest, applied earlier in the - // same tx). Doubles as the UTXO writer's fallback (`execute_upsert_utxo`) - // and the emitter-contract guard below. Built at most once per `apply`, - // `None` until first needed so a manifest-free flush never decodes a blob. - let mut pool_addrs: Option> = None; - // Derived addresses are written before UTXOs (same tx) so the UTXO - // writer's address→account_index lookup sees the fresh rows. - for da in &cs.addresses_derived { - let address = da.address.to_string(); - // Emitter contract: a derivation must arrive with its pool snapshot - // in the same changeset. Absent from the manifest ⇒ the emitter is - // broken; fail loud at the storage trust boundary rather than persist - // a row whose owning pool the manifest can't vouch for. Lag-safe: - // dropped events produce no changeset, so this only fires on a bug. - let pools = match &mut pool_addrs { - Some(map) => &*map, - None => pool_addrs.insert(pool_declared_address_indices(tx, wallet_id)?), - }; - if !pools.contains_key(&address) { - return Err(WalletStorageError::DerivedIndexInvariantViolated { address }); - } - let account_type = crate::sqlite::schema::accounts::account_type_db_label(&da.account_type); - let account_index = crate::sqlite::schema::accounts::account_index(&da.account_type); - let pool_type = crate::sqlite::schema::accounts::pool_type_db_label(&da.pool_type); - // Live derive events carry no `used` flag — default false. - upsert_derived_address_row( - tx, - wallet_id, - account_type, - i64::from(account_index), - pool_type, - da.derivation_index, - &address, - false, - )?; - } + // `addresses_derived` is intentionally NOT persisted here. The iOS + // address registry is fed by the FFI `addresses_derived` callback (fired + // before the UTXO changeset in the same round), and UTXO attribution is + // hardcoded to the default account (index 0), so the storage layer no + // longer keeps a derived-address lookup table. if !cs.new_utxos.is_empty() { let mut stmt = tx.prepare_cached(UPSERT_UTXO_SQL)?; - let mut lookup_stmt = tx.prepare_cached(ACCOUNT_INDEX_BY_ADDRESS_SQL)?; for utxo in &cs.new_utxos { - execute_upsert_utxo( - tx, - &mut stmt, - &mut lookup_stmt, - wallet_id, - utxo, - false, - &mut pool_addrs, - )?; + execute_upsert_utxo(&mut stmt, wallet_id, utxo, false)?; } } if !cs.spent_utxos.is_empty() { @@ -113,7 +72,6 @@ pub fn apply( "UPDATE core_utxos SET spent = 1 WHERE wallet_id = ?1 AND outpoint = ?2", )?; let mut upsert_stmt = tx.prepare_cached(UPSERT_UTXO_SQL)?; - let mut lookup_stmt = tx.prepare_cached(ACCOUNT_INDEX_BY_ADDRESS_SQL)?; for utxo in &cs.spent_utxos { let op = blob::encode_outpoint(&utxo.outpoint)?; let exists: bool = exists_stmt @@ -123,18 +81,11 @@ pub fn apply( if exists { mark_spent_stmt.execute(params![wallet_id.as_slice(), &op[..]])?; } else { - // Spent-only synthetic row: best-effort account_index. A wrong - // index is inert since spent rows are excluded from + // Spent-only synthetic row for a UTXO we never saw unspent. + // account_index is the hardcoded default like every row, and + // inert anyway since spent rows are excluded from // `list_unspent_utxos`. - execute_upsert_utxo( - tx, - &mut upsert_stmt, - &mut lookup_stmt, - wallet_id, - utxo, - true, - &mut pool_addrs, - )?; + execute_upsert_utxo(&mut upsert_stmt, wallet_id, utxo, true)?; } } } @@ -159,14 +110,6 @@ pub fn apply( Ok(()) } -/// Resolve a UTXO's owning account index via the `core_derived_addresses` map. -/// `UNIQUE(wallet_id, address)` in V001 guarantees at most one row per -/// `(wallet_id, address)` pair, so the query returns 0 or 1 rows. -/// The `ORDER BY … LIMIT 1` is kept as a defensive guard against schema drift. -const ACCOUNT_INDEX_BY_ADDRESS_SQL: &str = "SELECT account_index FROM core_derived_addresses \ - WHERE wallet_id = ?1 AND address = ?2 \ - ORDER BY account_type, account_index LIMIT 1"; - const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL) \ @@ -177,147 +120,34 @@ const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ account_index = excluded.account_index, \ spent = excluded.spent"; -#[allow(clippy::too_many_arguments)] +/// Account index written for every `core_utxos` row. The product uses only +/// the default account (index 0); a non-default funds account is rejected +/// upstream by the `core_bridge` single-account guard, so the writer never +/// resolves per-UTXO attribution. The one reader (`list_unspent_utxos` +/// per-account grouping) groups everything under 0. +const CORE_UTXO_ACCOUNT_INDEX: i64 = 0; + +/// Upsert one `core_utxos` row. `account_index` is the hardcoded default +/// ([`CORE_UTXO_ACCOUNT_INDEX`]); `spent` marks spent-only synthetic rows. fn execute_upsert_utxo( - tx: &Transaction<'_>, stmt: &mut rusqlite::CachedStatement<'_>, - lookup_stmt: &mut rusqlite::CachedStatement<'_>, wallet_id: &WalletId, utxo: &Utxo, spent: bool, - pool_addrs: &mut Option>, ) -> Result<(), WalletStorageError> { let op = blob::encode_outpoint(&utxo.outpoint)?; - let address = utxo.address.to_string(); - // `Utxo` carries no account index; recover it from the live derived-address - // cache, then fall back to the pool manifest. - let looked_up: Option = lookup_stmt - .query_row(params![wallet_id.as_slice(), &address], |row| row.get(0)) - .optional()?; - let account_index: i64 = match looked_up { - Some(idx) => idx, - // Cache miss on an unspent UTXO: fall back to the pool manifest - // (the in-band emitter snapshot is applied earlier in this same tx). - // Resolved there → use it. Absent from both → benign SPV gap-limit - // edge: warn + skip so one unresolvable row never aborts the flush; - // the balance re-warms once the address is later derived. - None if !spent => { - let pools = match pool_addrs { - Some(map) => &*map, - None => pool_addrs.insert(pool_declared_address_indices(tx, wallet_id)?), - }; - match pools.get(&address) { - Some(idx) => *idx, - None => { - tracing::warn!( - wallet_id = %hex::encode(wallet_id), - address = %address, - txid = %utxo.outpoint.txid, - vout = utxo.outpoint.vout, - "skipping unspent UTXO at an address absent from both core_derived_addresses and the pool manifest; balance re-warms once the address is later derived" - ); - return Ok(()); - } - } - } - None => { - tracing::debug!( - wallet_id = %hex::encode(wallet_id), - address = %address, - "spent-only UTXO address not found in core_derived_addresses; using account_index 0 placeholder" - ); - 0 - } - }; stmt.execute(params![ wallet_id.as_slice(), &op[..], crate::sqlite::util::safe_cast::u64_to_i64("core_utxos.value", utxo.value())?, utxo.txout.script_pubkey.as_bytes(), i64::from(utxo.height), - account_index, + CORE_UTXO_ACCOUNT_INDEX, spent, ])?; Ok(()) } -// Conflict target = the BIP32-leaf PK. A same-leaf re-derive is -// deterministic — `address` is a pure function of the slot and `used` is -// write-once — so there is nothing legitimate to update; DO NOTHING. A -// different leaf yielding the same `address` is a UNIQUE(address) -// violation, not a PK hit, so it surfaces loud. -const UPSERT_DERIVED_ADDRESS_SQL: &str = "INSERT INTO core_derived_addresses \ - (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ - ON CONFLICT(wallet_id, account_type, account_index, pool_type, derivation_index) DO NOTHING"; - -/// Upsert one `core_derived_addresses` row from the live -/// `addresses_derived` event path. `used` is set on insert only — the -/// conflict clause leaves an existing `used` untouched so a later live -/// re-derive (which carries no flag) cannot clear an earlier value. -// Args map 1:1 onto the row's NOT-NULL columns; a wrapper struct would add -// a single-use type for the one call site without improving clarity. -#[allow(clippy::too_many_arguments)] -fn upsert_derived_address_row( - tx: &Transaction<'_>, - wallet_id: &WalletId, - account_type: &str, - account_index: i64, - pool_type: &str, - derivation_index: u32, - address: &str, - used: bool, -) -> Result<(), WalletStorageError> { - let mut stmt = tx.prepare_cached(UPSERT_DERIVED_ADDRESS_SQL)?; - stmt.execute(params![ - wallet_id.as_slice(), - account_type, - account_index, - pool_type, - i64::from(derivation_index), - address, - used, - ])?; - Ok(()) -} - -/// Address → owning `account_index` for every address this wallet's -/// persisted `account_address_pools` snapshots declare. This is the -/// authoritative manifest the UTXO writer falls back to when an address -/// is not yet in the live `core_derived_addresses` cache, and the set the -/// apply-time emitter-contract guard checks `addresses_derived` against. -/// A corrupt snapshot is fail-hard (never skipped). -fn pool_declared_address_indices( - tx: &Transaction<'_>, - wallet_id: &WalletId, -) -> Result, WalletStorageError> { - let snapshots: Vec> = { - let mut stmt = tx.prepare_cached( - "SELECT snapshot_blob FROM account_address_pools WHERE wallet_id = ?1", - )?; - let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { - row.get::<_, Vec>(0) - })?; - let mut out = Vec::new(); - for r in rows { - out.push(r?); - } - out - }; - - let mut map = std::collections::HashMap::new(); - for payload in snapshots { - let entry: platform_wallet::changeset::AccountAddressPoolEntry = blob::decode(&payload)?; - let account_index = i64::from(crate::sqlite::schema::accounts::account_index( - &entry.account_type, - )); - for info in &entry.addresses { - map.insert(info.address.to_string(), account_index); - } - } - Ok(map) -} - fn upsert_sync_state( tx: &Transaction<'_>, wallet_id: &WalletId, @@ -565,46 +395,3 @@ pub fn list_unspent_utxos( } Ok(by_account) } - -/// One `core_derived_addresses` row. Used by tests that assert the -/// address→account map written by `apply_pools` matches the live derive -/// path (row-shape parity) and that load-time rehydration repopulates it. -#[cfg(any(test, feature = "__test-helpers"))] -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DerivedAddressRow { - pub account_type: String, - pub account_index: i64, - pub pool_type: String, - pub derivation_index: i64, - pub address: String, - pub used: bool, -} - -/// Every `core_derived_addresses` row for one wallet, ordered for -/// determinism. Retained for this crate's integration tests. -#[cfg(any(test, feature = "__test-helpers"))] -pub fn list_derived_addresses_for_test( - conn: &Connection, - wallet_id: &WalletId, -) -> Result, WalletStorageError> { - let mut stmt = conn.prepare( - "SELECT account_type, account_index, pool_type, derivation_index, address, used \ - FROM core_derived_addresses WHERE wallet_id = ?1 \ - ORDER BY account_type, account_index, pool_type, derivation_index", - )?; - let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { - Ok(DerivedAddressRow { - account_type: row.get(0)?, - account_index: row.get(1)?, - pool_type: row.get(2)?, - derivation_index: row.get(3)?, - address: row.get(4)?, - used: row.get(5)?, - }) - })?; - let mut out = Vec::new(); - for r in rows { - out.push(r?); - } - Ok(out) -} diff --git a/packages/rs-platform-wallet-storage/tests/marvin_gate_in_band_ordering.rs b/packages/rs-platform-wallet-storage/tests/marvin_gate_in_band_ordering.rs index 04929082b8..d604c48aa8 100644 --- a/packages/rs-platform-wallet-storage/tests/marvin_gate_in_band_ordering.rs +++ b/packages/rs-platform-wallet-storage/tests/marvin_gate_in_band_ordering.rs @@ -1,34 +1,35 @@ #![allow(clippy::field_reassign_with_default)] -//! Marvin's verification-gate scratch tests (PR #3828 Phase-2 gate). +//! Genesis-rescan regression for the hardcoded `account_index = 0` design +//! (PR #3828). //! -//! These prove the single load-bearing claim the retirement plan rests on: -//! a UTXO landing on a freshly-derived address resolves to the correct -//! account_index when the pool snapshot rides the SAME `PlatformWalletChangeSet` -//! as the UTXO — purely because `apply_changeset_to_tx` applies -//! `account_address_pools` (persister.rs:1073) BEFORE the core UTXO delta -//! (:1077) inside one transaction. If this holds, the manifest is a -//! sufficient resolution source and the eager-mirror is redundant. +//! Before: a UTXO landing on a freshly-derived gap-limit-edge address could +//! race address-derivation persistence and be mis-attributed or dropped, so +//! the bridge smuggled a full pool snapshot in-band to resolve it. Now UTXO +//! attribution is hardcoded to the default account (index 0) at the storage +//! writer — no in-band snapshot, no address→account lookup table. This test +//! pins that a UTXO on a real gap-limit-edge address persists directly with +//! `account_index == 0`, contributes the exact balance, and never aborts +//! the flush. mod common; -use common::{ensure_wallet_meta, fresh_persister, fresh_persister_with_mode, wid}; +use common::{ensure_wallet_meta, fresh_persister, wid}; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; use key_wallet::AddressInfo; use platform_wallet::changeset::{ - AccountAddressPoolEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, }; use platform_wallet::wallet::platform_wallet::WalletId; use platform_wallet_storage::sqlite::schema::core_state; -use platform_wallet_storage::FlushMode; -/// Snapshot the wallet's Standard BIP44 external pool plus the expected -/// `account_index` (0 for BIP44 index-0) and the LAST address in the pool — -/// the gap-limit-edge address, the one most likely to be a fresh extension. -fn pool_and_edge_address(seed_byte: u8) -> (AccountAddressPoolEntry, u32, AddressInfo) { +/// The LAST address in the wallet's Standard BIP44 external pool — the +/// gap-limit-edge address, the one most likely to be a fresh extension and +/// thus the worst case for the retired attribution race. +fn gap_limit_edge_address(seed_byte: u8) -> AddressInfo { use key_wallet::account::AccountType; use key_wallet::managed_account::address_pool::AddressPoolType; @@ -50,32 +51,12 @@ fn pool_and_edge_address(seed_byte: u8) -> (AccountAddressPoolEntry, u32, Addres continue; } let infos: Vec = pool.addresses.values().cloned().collect(); - let edge = infos.last().cloned().unwrap(); - let account_index = account_index_of(&account_type); - return ( - AccountAddressPoolEntry { - account_type, - pool_type: pool.pool_type, - addresses: infos, - }, - account_index, - edge, - ); + return infos.last().cloned().unwrap(); } } panic!("wallet must expose a non-empty Standard BIP44 external pool"); } -/// Mirror the persister's own `account_index` derivation so the test asserts -/// against the SAME value the writer computes, not a hand-picked constant. -fn account_index_of(account_type: &key_wallet::account::AccountType) -> u32 { - use key_wallet::account::AccountType; - match account_type { - AccountType::Standard { index, .. } | AccountType::CoinJoin { index } => *index, - _ => 0, - } -} - fn utxo_at(addr: &dashcore::Address, vout: u32, value: u64) -> key_wallet::Utxo { use dashcore::hashes::Hash; key_wallet::Utxo { @@ -97,28 +78,22 @@ fn utxo_at(addr: &dashcore::Address, vout: u32, value: u64) -> key_wallet::Utxo } } -/// THE GATE: a single changeset carrying the pool snapshot AND a UTXO on the -/// gap-limit-edge address resolves the UTXO to the correct account_index. The -/// pool snapshot is the ONLY thing that makes the address resolvable in this -/// changeset — there is no prior `addresses_derived` event and no prior round. -/// This is the gap-limit race (design §2.3) closed in-band. +/// A UTXO on a freshly-derived gap-limit-edge address persists directly with +/// the hardcoded `account_index == 0`: no snapshot, no lookup, no flush +/// abort, and the unspent balance is exact. #[test] -fn in_band_pool_snapshot_resolves_utxo_on_fresh_address_same_changeset() { +fn utxo_on_fresh_gap_limit_address_persists_under_account_zero() { let (persister, _tmp, _path) = fresh_persister(); let w: WalletId = wid(0xD1); ensure_wallet_meta(&persister, &w); - let (pool, expected_index, edge) = pool_and_edge_address(0x55); + let edge = gap_limit_edge_address(0x55); let addr = edge.address.clone(); - // ONE changeset: snapshot + UTXO on the edge address, exactly as the - // Phase-1 emitter ships them (build_platform_changeset attaches the pool - // to the same PlatformWalletChangeSet as the core delta). persister .store( w, PlatformWalletChangeSet { - account_address_pools: vec![pool], core: Some(CoreChangeSet { new_utxos: vec![utxo_at(&addr, 0, 777_000)], ..Default::default() @@ -126,114 +101,21 @@ fn in_band_pool_snapshot_resolves_utxo_on_fresh_address_same_changeset() { ..Default::default() }, ) - .expect("in-band snapshot+UTXO changeset must persist"); + .expect("a UTXO on a fresh gap-limit address must persist, not abort"); let conn = persister.lock_conn_for_test(); let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); let total: usize = by_account.values().map(|v| v.len()).sum(); - assert_eq!(total, 1, "exactly the one edge-address UTXO must persist"); + assert_eq!(total, 1, "the edge-address UTXO must persist"); - // The load-bearing assertion: the UTXO is attributed to the RIGHT account, - // not the spent-only `0` placeholder and not dropped. let rows = by_account - .get(&expected_index) - .unwrap_or_else(|| panic!("UTXO must resolve to account_index {expected_index}")); + .get(&0) + .expect("UTXO must be attributed to the default account (index 0)"); assert_eq!( rows.len(), 1, - "the edge-address UTXO under the right account" + "exactly the one edge-address UTXO under account 0" ); assert_eq!(rows[0].value, 777_000, "value preserved"); } - -/// Adversarial buffering: the snapshot and the UTXO arrive in TWO separate -/// `store` calls (Manual flush mode batches them), forcing the buffer's -/// `Merge` to combine them before a single flush. The merged changeset must -/// STILL apply pools before core — so the UTXO resolves. This proves the -/// in-band guarantee survives the merge path, not just the single-store path. -#[test] -fn merged_buffer_preserves_pool_before_core_ordering() { - let (persister, _tmp, _path) = fresh_persister_with_mode(FlushMode::Manual); - let w: WalletId = wid(0xD2); - ensure_wallet_meta(&persister, &w); - - let (pool, expected_index, edge) = pool_and_edge_address(0x66); - let addr = edge.address.clone(); - - // Store the UTXO-bearing changeset FIRST, snapshot SECOND — the adversarial - // arrival order. Merge must still order pools-before-core at apply time. - persister - .store( - w, - PlatformWalletChangeSet { - core: Some(CoreChangeSet { - new_utxos: vec![utxo_at(&addr, 0, 888_000)], - ..Default::default() - }), - ..Default::default() - }, - ) - .unwrap(); - persister - .store( - w, - PlatformWalletChangeSet { - account_address_pools: vec![pool], - ..Default::default() - }, - ) - .unwrap(); - - persister.flush(w).expect("merged flush must commit"); - - let conn = persister.lock_conn_for_test(); - let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); - let rows = by_account.get(&expected_index).unwrap_or_else(|| { - panic!("merged changeset must resolve the UTXO to account_index {expected_index}") - }); - assert_eq!(rows.len(), 1, "edge-address UTXO resolved after merge"); - assert_eq!(rows[0].value, 888_000); -} - -/// Negative control: a UTXO on a genuinely-undeclared address (NOT in any -/// snapshot) is skipped non-fatally — it does NOT abort the flush and does NOT -/// appear in the unspent set. This is the benign SPV-gap branch the guard -/// relaxation relies on (design §3.3 step 3, non-fatal skip). -#[test] -fn utxo_on_undeclared_address_skips_non_fatally() { - use dashcore::address::Payload; - use dashcore::hashes::Hash; - use dashcore::PubkeyHash; - - let (persister, _tmp, _path) = fresh_persister(); - let w: WalletId = wid(0xD3); - ensure_wallet_meta(&persister, &w); - - // An address the wallet never declared — a fixed hash160 not in any pool. - let undeclared = dashcore::Address::new( - key_wallet::Network::Testnet, - Payload::PubkeyHash(PubkeyHash::from_byte_array([0xEE; 20])), - ); - - persister - .store( - w, - PlatformWalletChangeSet { - core: Some(CoreChangeSet { - new_utxos: vec![utxo_at(&undeclared, 0, 123_000)], - ..Default::default() - }), - ..Default::default() - }, - ) - .expect("an undeclared-address UTXO must skip, not abort the flush"); - - let conn = persister.lock_conn_for_test(); - let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); - let total: usize = by_account.values().map(|v| v.len()).sum(); - assert_eq!( - total, 0, - "the undeclared-address UTXO is skipped, not persisted" - ); -} diff --git a/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs b/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs index 1e14353753..c9223c5320 100644 --- a/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs +++ b/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs @@ -217,12 +217,6 @@ fn tc_code_004_b_fatal_variants_map_to_fatal_kind() { path: PathBuf::from("/tmp/x"), }, ), - ( - "DerivedIndexInvariantViolated", - WalletStorageError::DerivedIndexInvariantViolated { - address: "yMockAddress".into(), - }, - ), ]; for (label, err) in fatal_cases { diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs b/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs index 91dc50a6f8..68eadff1d1 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs @@ -1,11 +1,7 @@ //! Smoke tests for the enum-domain `CHECK` constraints. The schema has -//! eight such TEXT columns across five domains (`account_type` is reused -//! by `account_registrations`, `account_address_pools`, and -//! `core_derived_addresses`; `pool_type` by `account_address_pools` and -//! `core_derived_addresses`). These tests exercise `wallets.network`, -//! `account_registrations.account_type`, `account_address_pools.pool_type`, -//! `asset_locks.status`, both `core_derived_addresses.pool_type` / -//! `account_type`, and the synthetic `contacts.state` domain directly. +//! four such TEXT columns across four domains: `wallets.network`, +//! `account_registrations.account_type`, `asset_locks.status`, and the +//! synthetic `contacts.state`. These tests exercise each directly. //! //! The per-module parity unit tests in `src/sqlite/schema/*` cover the //! Rust↔const-array equality. These tests cover the runtime half: a @@ -71,66 +67,6 @@ fn check_rejects_bad_account_type_on_registrations() { assert_constraint_check(res, "account_registrations.account_type"); } -#[test] -fn check_rejects_bad_pool_type() { - let (persister, _tmp, _path) = fresh_persister(); - let conn = persister.lock_conn_for_test(); - conn.execute( - "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", - params![wid(3).as_slice(), "testnet", 0i64], - ) - .expect("seed wallets"); - let res = conn.execute( - "INSERT INTO account_address_pools \ - (wallet_id, account_type, account_index, pool_type, snapshot_blob) \ - VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - wid(3).as_slice(), - "standard_bip44", - 0i64, - "not_a_pool", - &[0u8; 4][..] - ], - ); - assert_constraint_check(res, "account_address_pools.pool_type"); -} - -#[test] -fn check_rejects_bad_pool_type_on_derived_addresses() { - let (persister, _tmp, _path) = fresh_persister(); - let conn = persister.lock_conn_for_test(); - conn.execute( - "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", - params![wid(5).as_slice(), "testnet", 0i64], - ) - .expect("seed wallets"); - let res = conn.execute( - "INSERT INTO core_derived_addresses \ - (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ - VALUES (?1, 'standard_bip44', 0, ?2, 0, 'addr', 0)", - params![wid(5).as_slice(), "not_a_pool"], - ); - assert_constraint_check(res, "core_derived_addresses.pool_type"); -} - -#[test] -fn check_rejects_bad_account_type_on_derived_addresses() { - let (persister, _tmp, _path) = fresh_persister(); - let conn = persister.lock_conn_for_test(); - conn.execute( - "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", - params![wid(6).as_slice(), "testnet", 0i64], - ) - .expect("seed wallets"); - let res = conn.execute( - "INSERT INTO core_derived_addresses \ - (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ - VALUES (?1, ?2, 0, 'external', 0, 'addr', 0)", - params![wid(6).as_slice(), "bogus_account_type"], - ); - assert_constraint_check(res, "core_derived_addresses.account_type"); -} - #[test] fn check_rejects_bad_asset_lock_status() { let (persister, _tmp, _path) = fresh_persister(); diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs index e01d41336d..ee23b15883 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs @@ -13,10 +13,9 @@ use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; -use key_wallet::AddressInfo; use key_wallet::Utxo; use platform_wallet::changeset::{ - AccountAddressPoolEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, }; use platform_wallet_storage::sqlite::schema::core_state; use platform_wallet_storage::WalletStorageError; @@ -64,52 +63,6 @@ fn wallet_and_utxo(seed: [u8; 64], value: u64, height: u32, vout: u32) -> (Walle (w, utxo) } -/// The `core_derived_addresses` row a real scan records before a UTXO on -/// `address` lands. The strict UTXO writer refuses an unspent UTXO whose -/// address was never derived, so every test paying a wallet address must -/// seed the matching derivation. The writer keys its lookup on -/// `(wallet_id, address)` only, so account_type/index/pubkey here are -/// inert placeholders — the address is the load-bearing field. -fn derived_for(address: &dashcore::Address) -> platform_wallet::DerivedAddress { - // Compressed secp256k1 generator point — a valid placeholder pubkey. - const PUBKEY_G: [u8; 33] = [ - 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87, - 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16, - 0xf8, 0x17, 0x98, - ]; - platform_wallet::DerivedAddress { - account_type: key_wallet::account::AccountType::Standard { - index: 0, - standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, - }, - pool_type: key_wallet::managed_account::address_pool::AddressPoolType::External, - derivation_index: 0, - address: address.clone(), - public_key: dashcore::PublicKey::from_slice(&PUBKEY_G).expect("valid compressed pubkey"), - } -} - -/// The in-band pool snapshot the emitter ships with the derivation above — -/// `core_state::apply` now requires every `addresses_derived` address to be -/// in the `account_address_pools` manifest. Matches `derived_for`'s slot. -fn manifest_for(address: &dashcore::Address) -> AccountAddressPoolEntry { - let info = AddressInfo::new_from_script_pubkey_p2pkh( - address.script_pubkey(), - 0, - Default::default(), - key_wallet::Network::Testnet, - ) - .expect("p2pkh AddressInfo"); - AccountAddressPoolEntry { - account_type: key_wallet::account::AccountType::Standard { - index: 0, - standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, - }, - pool_type: key_wallet::managed_account::address_pool::AddressPoolType::External, - addresses: vec![info], - } -} - /// A non-zero balance survives store → drop → reopen → load, guarding /// against a silent-zero-balance reconstruction. #[test] @@ -123,13 +76,11 @@ fn rt2_nonzero_balance_survives_reopen() { let cs = PlatformWalletChangeSet { core: Some(CoreChangeSet { - addresses_derived: vec![derived_for(&utxo.address)], new_utxos: vec![utxo.clone()], last_processed_height: Some(200), synced_height: Some(200), ..Default::default() }), - account_address_pools: vec![manifest_for(&utxo.address)], ..Default::default() }; persister.store(w, cs).unwrap(); @@ -179,12 +130,10 @@ fn b2_spent_utxo_excluded() { w, PlatformWalletChangeSet { core: Some(CoreChangeSet { - addresses_derived: vec![derived_for(&u_unspent.address)], new_utxos: vec![u_unspent.clone()], spent_utxos: vec![u_spent.clone()], ..Default::default() }), - account_address_pools: vec![manifest_for(&u_unspent.address)], ..Default::default() }, ) @@ -291,13 +240,11 @@ fn f2_no_bip44_wallet_nonzero_balance_survives_reopen() { w, PlatformWalletChangeSet { core: Some(CoreChangeSet { - addresses_derived: vec![derived_for(&utxo.address)], new_utxos: vec![utxo.clone()], last_processed_height: Some(60), synced_height: Some(60), ..Default::default() }), - account_address_pools: vec![manifest_for(&utxo.address)], ..Default::default() }, ) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_dashpay_overlay_contract.rs b/packages/rs-platform-wallet-storage/tests/sqlite_dashpay_overlay_contract.rs index 4a2d11f8ec..dd235e1e4d 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_dashpay_overlay_contract.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_dashpay_overlay_contract.rs @@ -72,6 +72,7 @@ fn overlay_only_write_does_not_corrupt_load() { let mut core_cs = PlatformWalletChangeSet::default(); core_cs.wallet_metadata = Some(WalletMetadataEntry { network: key_wallet::Network::Testnet, + wallet_group_id: [0u8; 32], birth_height: 0, }); core_cs.core = Some(CoreChangeSet { diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_delete_partial_commit_window.rs b/packages/rs-platform-wallet-storage/tests/sqlite_delete_partial_commit_window.rs index f8ee46d83e..1d34a2b12d 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_delete_partial_commit_window.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_delete_partial_commit_window.rs @@ -23,6 +23,7 @@ fn full_changeset(synced: u32) -> PlatformWalletChangeSet { let mut cs = PlatformWalletChangeSet::default(); cs.wallet_metadata = Some(WalletMetadataEntry { network: Network::Testnet, + wallet_group_id: [0u8; 32], birth_height: 0, }); cs.core = Some(CoreChangeSet { diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_delete_real_apply_failure.rs b/packages/rs-platform-wallet-storage/tests/sqlite_delete_real_apply_failure.rs index 7bc531f2fd..a0d0cf8a25 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_delete_real_apply_failure.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_delete_real_apply_failure.rs @@ -22,6 +22,7 @@ fn full_changeset(synced: u32) -> PlatformWalletChangeSet { let mut cs = PlatformWalletChangeSet::default(); cs.wallet_metadata = Some(WalletMetadataEntry { network: Network::Testnet, + wallet_group_id: [0u8; 32], birth_height: 0, }); cs.core = Some(CoreChangeSet { diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs index 5aad580e99..9c1e5fdb0d 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs @@ -157,12 +157,6 @@ fn samples() -> Vec { WalletStorageError::InvalidWalletIdLength { actual: 10 }, WalletStorageError::ConfigInvalid { reason: "bad knob" }, WalletStorageError::IdentityEntryIdMismatch, - WalletStorageError::UtxoAddressNotDerived { - address: "yMockAddress".into(), - }, - WalletStorageError::DerivedIndexInvariantViolated { - address: "yMockAddress".into(), - }, // BincodeEncode / BincodeDecode / HashDecode / ConsensusCodec // need real upstream errors; omitted but covered by their arms. WalletStorageError::BlobDecode { @@ -248,10 +242,6 @@ fn tc_p2_005_is_transient_table() { (false, "asset_lock_entry_mismatch") } WalletStorageError::BlobTooLarge { .. } => (false, "blob_too_large"), - WalletStorageError::UtxoAddressNotDerived { .. } => (false, "utxo_address_not_derived"), - WalletStorageError::DerivedIndexInvariantViolated { .. } => { - (false, "derived_index_invariant_violated") - } WalletStorageError::ForeignKeysNotEnforced => (false, "foreign_keys_not_enforced"), WalletStorageError::JournalModeNotApplied { .. } => (false, "journal_mode_not_applied"), WalletStorageError::SchemaHistoryMalformed { .. } => { diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_fk_changeset_ordering.rs b/packages/rs-platform-wallet-storage/tests/sqlite_fk_changeset_ordering.rs index 546cf59d64..867a6f9ce3 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_fk_changeset_ordering.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_fk_changeset_ordering.rs @@ -249,6 +249,7 @@ fn wallets_anchor_and_children_in_same_changeset_commits() { PlatformWalletChangeSet { wallet_metadata: Some(WalletMetadataEntry { network: Network::Testnet, + wallet_group_id: [0u8; 32], birth_height: 0, }), identities: Some(identities_changeset(identity, Some(w))), diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs b/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs index 6af9fd62a9..bab3480557 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs @@ -65,48 +65,6 @@ fn tc047_delete_wallet_cascade() { assert_eq!(n, 0); } -/// deleting a wallet cascades `core_derived_addresses` rows away — the -/// `ON DELETE CASCADE` on the address→account read-index, exercised -/// directly rather than asserted by comment. -#[test] -fn tc047b_delete_wallet_cascades_derived_addresses() { - let (persister, _tmp, _path) = fresh_persister(); - let w = wid(0xC1); - ensure_wallet_meta(&persister, &w); - { - let conn = persister.lock_conn_for_test(); - conn.execute( - "INSERT INTO core_derived_addresses \ - (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ - VALUES (?1, 'standard_bip44', 0, 'external', 0, 'addr', 0)", - rusqlite::params![w.as_slice()], - ) - .unwrap(); - } - - let before: i64 = persister - .lock_conn_for_test() - .query_row( - "SELECT COUNT(*) FROM core_derived_addresses WHERE wallet_id = ?1", - rusqlite::params![w.as_slice()], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(before, 1, "seed row must exist before delete"); - - persister.delete_wallet(w).expect("delete_wallet"); - - let after: i64 = persister - .lock_conn_for_test() - .query_row( - "SELECT COUNT(*) FROM core_derived_addresses WHERE wallet_id = ?1", - rusqlite::params![w.as_slice()], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(after, 0, "cascade must purge core_derived_addresses rows"); -} - /// deleting a core_transactions row sets `spent_in_txid = NULL` on UTXOs. #[test] fn tc048_setnull_on_tx_delete() { diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs b/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs index d6688fe69b..ff4d91744f 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs @@ -12,10 +12,9 @@ use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; -use key_wallet::AddressInfo; use platform_wallet::changeset::{ - AccountAddressPoolEntry, AccountRegistrationEntry, CoreChangeSet, PlatformWalletChangeSet, - PlatformWalletPersistence, WalletMetadataEntry, + AccountRegistrationEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, + WalletMetadataEntry, }; use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; @@ -23,52 +22,6 @@ fn reopen(path: &std::path::Path) -> SqlitePersister { SqlitePersister::open(SqlitePersisterConfig::new(path)).expect("reopen") } -/// The `core_derived_addresses` row a real scan records before a UTXO on -/// `address` lands. The strict UTXO writer refuses an unspent UTXO whose -/// address was never derived, so a stored UTXO must carry its matching -/// derivation. The writer keys its lookup on `(wallet_id, address)` only -/// and the read side re-attributes by topology, so the account fields -/// here are inert placeholders — the address is the load-bearing field. -fn derived_for(address: &dashcore::Address) -> platform_wallet::DerivedAddress { - // Compressed secp256k1 generator point — a valid placeholder pubkey. - const PUBKEY_G: [u8; 33] = [ - 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87, - 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16, - 0xf8, 0x17, 0x98, - ]; - platform_wallet::DerivedAddress { - account_type: key_wallet::account::AccountType::Standard { - index: 0, - standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, - }, - pool_type: key_wallet::managed_account::address_pool::AddressPoolType::External, - derivation_index: 0, - address: address.clone(), - public_key: dashcore::PublicKey::from_slice(&PUBKEY_G).expect("valid compressed pubkey"), - } -} - -/// The in-band pool snapshot the emitter ships with the derivation above — -/// `core_state::apply` requires every `addresses_derived` address to be in -/// the `account_address_pools` manifest. Matches `derived_for`'s slot. -fn manifest_for(address: &dashcore::Address) -> AccountAddressPoolEntry { - let info = AddressInfo::new_from_script_pubkey_p2pkh( - address.script_pubkey(), - 0, - Default::default(), - key_wallet::Network::Testnet, - ) - .expect("p2pkh AddressInfo"); - AccountAddressPoolEntry { - account_type: key_wallet::account::AccountType::Standard { - index: 0, - standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, - }, - pool_type: key_wallet::managed_account::address_pool::AddressPoolType::External, - addresses: vec![info], - } -} - /// A registered wallet with UTXOs round-trips into the keyless `wallets` /// payload — manifest, network, birth height, core state. #[test] @@ -102,6 +55,7 @@ fn c1_load_populates_keyless_wallet_payload() { let reg = PlatformWalletChangeSet { wallet_metadata: Some(WalletMetadataEntry { network: key_wallet::Network::Testnet, + wallet_group_id: [0u8; 32], birth_height: 7, }), account_registrations: manifest.clone(), @@ -135,13 +89,11 @@ fn c1_load_populates_keyless_wallet_payload() { w, PlatformWalletChangeSet { core: Some(CoreChangeSet { - addresses_derived: vec![derived_for(&utxo.address)], new_utxos: vec![utxo.clone()], last_processed_height: Some(50), synced_height: Some(50), ..Default::default() }), - account_address_pools: vec![manifest_for(&utxo.address)], ..Default::default() }, ) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs index ae5408d8eb..fb402eeb2f 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs @@ -89,11 +89,6 @@ fn tc027_smoke_insert_every_table() { "INSERT INTO account_registrations (wallet_id, account_type, account_index, account_xpub_bytes) VALUES (?1, 'standard_bip44', 0, X'00')", &[&wallet_id.as_slice()], ), - ( - "account_address_pools", - "INSERT INTO account_address_pools (wallet_id, account_type, account_index, pool_type, snapshot_blob) VALUES (?1, 'standard_bip44', 0, 'external', X'00')", - &[&wallet_id.as_slice()], - ), ( "core_transactions", "INSERT INTO core_transactions (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) VALUES (?1, ?2, NULL, NULL, NULL, 0, X'00')", @@ -109,11 +104,6 @@ fn tc027_smoke_insert_every_table() { "INSERT INTO core_instant_locks (wallet_id, txid, islock_blob) VALUES (?1, ?2, X'00')", &[&wallet_id.as_slice(), &txid], ), - ( - "core_derived_addresses", - "INSERT INTO core_derived_addresses (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) VALUES (?1, 'standard_bip44', 0, 'external', 0, 'addr', 0)", - &[&wallet_id.as_slice()], - ), ( "core_sync_state", "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) VALUES (?1, NULL, NULL)", diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs b/packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs index cb74bfedf5..1efd76e224 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs @@ -1017,11 +1017,9 @@ fn delete_wallet_leaves_no_surviving_rows() { let outpoint = vec![0x02u8; 36]; let stmts: &[(&str, &[&dyn rusqlite::ToSql])] = &[ ("INSERT INTO account_registrations (wallet_id, account_type, account_index, account_xpub_bytes) VALUES (?1, 'standard_bip44', 0, X'00')", &[&a.as_slice()]), - ("INSERT INTO account_address_pools (wallet_id, account_type, account_index, pool_type, snapshot_blob) VALUES (?1, 'standard_bip44', 0, 'external', X'00')", &[&a.as_slice()]), ("INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) VALUES (?1, ?2, 0, X'00')", &[&a.as_slice(), &txid]), ("INSERT INTO core_utxos (wallet_id, outpoint, value, script, account_index, spent) VALUES (?1, ?2, 0, X'00', 0, 0)", &[&a.as_slice(), &outpoint]), ("INSERT INTO core_instant_locks (wallet_id, txid, islock_blob) VALUES (?1, ?2, X'00')", &[&a.as_slice(), &txid]), - ("INSERT INTO core_derived_addresses (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) VALUES (?1, 'standard_bip44', 0, 'external', 0, 'addr', 0)", &[&a.as_slice()]), ("INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) VALUES (?1, 1, 1)", &[&a.as_slice()]), ("INSERT INTO identity_keys (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) VALUES (?1, ?2, 0, X'00', X'00', NULL)", &[&a.as_slice(), &idy.as_slice()]), ("INSERT INTO platform_address_sync (wallet_id, sync_height, sync_timestamp, last_known_recent_block) VALUES (?1, 0, 0, 0)", &[&a.as_slice()]), @@ -1188,11 +1186,9 @@ fn delete_wallet_leaves_no_surviving_rows() { let wallet_scoped = [ "wallets", "account_registrations", - "account_address_pools", "core_transactions", "core_utxos", "core_instant_locks", - "core_derived_addresses", "core_sync_state", "identities", "contacts", diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs deleted file mode 100644 index 8c99034436..0000000000 --- a/packages/rs-platform-wallet-storage/tests/sqlite_pool_derived_rehydration.rs +++ /dev/null @@ -1,1343 +0,0 @@ -#![allow(clippy::field_reassign_with_default)] - -//! UTXO account-index resolution against the `account_address_pools` -//! manifest, the emitter-contract guard, and flush blast-radius -//! containment for a UTXO at a genuinely-undeclared address. -//! -//! `core_derived_addresses` is a live-fed indexed cache; the authoritative -//! manifest is `account_address_pools` (kept complete and in-band by the -//! `core_bridge` emitter). On a cache miss the UTXO writer falls back to -//! the manifest, so a UTXO landing on a registered pool address resolves -//! even with no live `addresses_derived` event — no mirror, no reconcile. - -mod common; - -use common::{ensure_wallet_meta, fresh_persister, wid}; - -use key_wallet::wallet::initialization::WalletAccountCreationOptions; -use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; -use key_wallet::wallet::Wallet; -use key_wallet::AddressInfo; -use platform_wallet::changeset::{ - AccountAddressPoolEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, -}; -use platform_wallet::wallet::platform_wallet::WalletId; -use platform_wallet_storage::sqlite::schema::core_state; -use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; - -/// Snapshot a freshly seeded wallet's Standard BIP44 external pool as a -/// single `AccountAddressPoolEntry`, mirroring the production registration -/// round in `wallet_lifecycle.rs`. One pool of distinct BIP32 leaves keeps -/// every derived row unique, so the test reads back exactly what it wrote. -fn standard_external_pool(info: &ManagedWalletInfo) -> AccountAddressPoolEntry { - use key_wallet::account::AccountType; - use key_wallet::managed_account::address_pool::AddressPoolType; - for managed in info.all_managed_accounts() { - let account_type = managed.managed_account_type().to_account_type(); - if !matches!(account_type, AccountType::Standard { index: 0, .. }) { - continue; - } - for pool in managed.managed_account_type().address_pools() { - if pool.pool_type != AddressPoolType::External { - continue; - } - let infos: Vec = pool.addresses.values().cloned().collect(); - if infos.is_empty() { - continue; - } - return AccountAddressPoolEntry { - account_type, - pool_type: pool.pool_type, - addresses: infos, - }; - } - } - panic!("wallet must expose a non-empty Standard BIP44 external pool"); -} - -/// A wallet's Standard BIP44 external pool plus its first `AddressInfo` — -/// the load-bearing target the UTXO writer must resolve. -fn wallet_with_pools(seed_byte: u8) -> (Vec, AddressInfo) { - let seed = [seed_byte; 64]; - let wallet = Wallet::from_seed_bytes( - seed, - key_wallet::Network::Testnet, - WalletAccountCreationOptions::Default, - ) - .unwrap(); - let info = ManagedWalletInfo::from_wallet(&wallet, 0); - let pool = standard_external_pool(&info); - let target = pool - .addresses - .first() - .cloned() - .expect("non-empty external pool"); - (vec![pool], target) -} - -fn utxo_at(addr: &dashcore::Address, vout: u32, value: u64) -> key_wallet::Utxo { - use dashcore::hashes::Hash; - key_wallet::Utxo { - outpoint: dashcore::OutPoint { - txid: dashcore::Txid::from_byte_array([0x42; 32]), - vout, - }, - txout: dashcore::TxOut { - value, - script_pubkey: addr.script_pubkey(), - }, - address: addr.clone(), - height: 1, - is_coinbase: false, - is_confirmed: true, - is_instantlocked: false, - is_locked: false, - is_trusted: false, - } -} - -fn reopen(path: &std::path::Path) -> SqlitePersister { - SqlitePersister::open(SqlitePersisterConfig::new(path)).expect("reopen") -} - -/// A live `DerivedAddress` event for one pool `AddressInfo` — a valid, -/// non-UTXO record for the blast-radius batch. -fn derived_for( - pool: &AccountAddressPoolEntry, - info: &AddressInfo, -) -> platform_wallet::DerivedAddress { - // Compressed secp256k1 generator point — a valid placeholder pubkey. - const PUBKEY_G: [u8; 33] = [ - 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87, - 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16, - 0xf8, 0x17, 0x98, - ]; - platform_wallet::DerivedAddress { - account_type: pool.account_type, - pool_type: pool.pool_type, - derivation_index: info.index, - address: info.address.clone(), - public_key: dashcore::PublicKey::from_slice(&PUBKEY_G).expect("valid compressed pubkey"), - } -} - -/// Genesis-rescan persist: a wallet registered with pools but with NO live -/// `addresses_derived` event still resolves the account index of a UTXO -/// landing on a pool address — the UTXO writer falls back to the -/// `account_address_pools` manifest. No mirror writes a derived row. -#[test] -fn genesis_rescan_utxo_at_pool_address_persists() { - let (persister, _tmp, _path) = fresh_persister(); - let w: WalletId = wid(0xA0); - ensure_wallet_meta(&persister, &w); - - let (snapshots, target) = wallet_with_pools(0x11); - let addr = target.address.clone(); - let expected_index = match snapshots[0].account_type { - key_wallet::account::AccountType::Standard { index, .. } => index, - _ => unreachable!("fixture uses a Standard account"), - }; - - // Registration round carries pools only — no addresses_derived. - persister - .store( - w, - PlatformWalletChangeSet { - account_address_pools: snapshots, - ..Default::default() - }, - ) - .unwrap(); - - // SPV matches a UTXO at the pool address before any derive-event. - persister - .store( - w, - PlatformWalletChangeSet { - core: Some(CoreChangeSet { - new_utxos: vec![utxo_at(&addr, 0, 555_000)], - ..Default::default() - }), - ..Default::default() - }, - ) - .expect("UTXO at a pool address must persist without a derive-event"); - - let conn = persister.lock_conn_for_test(); - let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); - let total: usize = by_account.values().map(|v| v.len()).sum(); - assert_eq!(total, 1, "the pool-address UTXO must be persisted"); - let resolved = by_account - .get(&expected_index) - .map(|v| v.len()) - .unwrap_or(0); - assert_eq!( - resolved, 1, - "the UTXO must resolve to the pool's account index via the manifest fallback" - ); - // The manifest fallback resolves without writing a derived-cache row. - let derived = core_state::list_derived_addresses_for_test(&conn, &w).unwrap(); - assert!( - derived.iter().all(|r| r.address != addr.to_string()), - "no mirror: the pool address must NOT be written into core_derived_addresses" - ); -} - -/// Back-compat, no migration: an "old-style" DB (frozen registration pool -/// snapshot + a live-fed `core_derived_addresses` row, never any mirror) -/// resolves both address classes under the new model — a gap-limit address -/// via the live cache hit, a registration-only address via the manifest -/// fallback. The fallback is a strict superset of the old read path. -#[test] -fn old_style_db_resolves_via_cache_and_manifest_fallback() { - let (persister, _tmp, _path) = fresh_persister(); - let w: WalletId = wid(0xB1); - ensure_wallet_meta(&persister, &w); - - let (snapshots, registration_target) = wallet_with_pools(0x22); - let pool = &snapshots[0]; - assert!( - pool.addresses.len() >= 2, - "fixture needs two pool addresses" - ); - let account_index = match pool.account_type { - key_wallet::account::AccountType::Standard { index, .. } => index, - _ => unreachable!("fixture uses a Standard account"), - }; - // A gap-limit address persisted ONLY as a live-fed derived row. - let gap_addr = pool.addresses[1].address.clone(); - - persister - .store( - w, - PlatformWalletChangeSet { - account_address_pools: snapshots.clone(), - ..Default::default() - }, - ) - .unwrap(); - - // Seed the live-fed derived row for the gap-limit address (as the live - // `addresses_derived` path would have, at extension time). - { - let conn = persister.lock_conn_for_test(); - conn.execute( - "INSERT INTO core_derived_addresses \ - (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ - VALUES (?1, 'standard_bip44', ?2, 'external', ?3, ?4, 0)", - rusqlite::params![ - w.as_slice(), - i64::from(account_index), - i64::from(pool.addresses[1].index), - gap_addr.to_string() - ], - ) - .unwrap(); - } - - // Two UTXOs: one on the live-cached gap address, one on a - // registration-only pool address (in the manifest, not in the cache). - persister - .store( - w, - PlatformWalletChangeSet { - core: Some(CoreChangeSet { - new_utxos: vec![ - utxo_at(&gap_addr, 0, 111_000), - utxo_at(®istration_target.address, 1, 222_000), - ], - ..Default::default() - }), - ..Default::default() - }, - ) - .expect("both address classes must resolve without migration"); - - let conn = persister.lock_conn_for_test(); - let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); - let rows = by_account.get(&account_index).expect("account row"); - assert_eq!( - rows.len(), - 2, - "both UTXOs resolve to the same account index" - ); - let values: std::collections::BTreeSet = rows.iter().map(|r| r.value).collect(); - assert!( - values.contains(&111_000) && values.contains(&222_000), - "the cache-hit and manifest-fallback UTXOs both committed" - ); -} - -/// Reload-then-resolve: a DB with pool snapshots but ZERO derived rows -/// (no mirror, no reconcile) survives a `load` untouched, and a UTXO that -/// lands on a pool address afterwards still resolves via the manifest -/// fallback. `load` does NOT backfill the derived cache. -#[test] -fn reload_resolves_pool_address_without_reconcile() { - let (persister, _tmp, path) = fresh_persister(); - let w: WalletId = wid(0xC0); - ensure_wallet_meta(&persister, &w); - - let (snapshots, target) = wallet_with_pools(0x33); - let addr = target.address.clone(); - let account_index = match snapshots[0].account_type { - key_wallet::account::AccountType::Standard { index, .. } => index, - _ => unreachable!("fixture uses a Standard account"), - }; - - persister - .store( - w, - PlatformWalletChangeSet { - account_address_pools: snapshots, - ..Default::default() - }, - ) - .unwrap(); - drop(persister); - - let p2 = reopen(&path); - PlatformWalletPersistence::load(&p2).expect("load"); - - // load must NOT backfill the derived cache from pools. - { - let conn = p2.lock_conn_for_test(); - let n = core_state::list_derived_addresses_for_test(&conn, &w) - .unwrap() - .len(); - assert_eq!(n, 0, "load does not reconcile the derived cache"); - } - - p2.store( - w, - PlatformWalletChangeSet { - core: Some(CoreChangeSet { - new_utxos: vec![utxo_at(&addr, 0, 555_000)], - ..Default::default() - }), - ..Default::default() - }, - ) - .expect("a UTXO at a pool address resolves via the manifest after reload"); - - let conn = p2.lock_conn_for_test(); - let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); - assert_eq!( - by_account.get(&account_index).map(|v| v.len()).unwrap_or(0), - 1, - "the pool-address UTXO resolves to its account index via the manifest" - ); -} - -/// Partial-state resolution: a DB holding a live derived-cache row at an -/// OFF-pool account_index plus a pool address that was never derived. The -/// live cache row stays authoritative (the manifest fallback never -/// overrides a cache hit), and the un-derived pool address resolves via -/// the manifest fallback. No reconcile rewrites anything. -#[test] -fn partial_state_cache_hit_wins_over_manifest_fallback() { - let (persister, _tmp, _path) = fresh_persister(); - let w: WalletId = wid(0xC5); - ensure_wallet_meta(&persister, &w); - - let (snapshots, _target) = wallet_with_pools(0x55); - let pool = &snapshots[0]; - assert!( - pool.addresses.len() >= 2, - "fixture needs at least two pool addresses" - ); - let pool_account_index = match pool.account_type { - key_wallet::account::AccountType::Standard { index, .. } => index, - _ => unreachable!("fixture uses a Standard account"), - }; - - // A pool address pre-seeded as a live cache row at an OFF-pool index, - // so a fallback override would be visible. - let live_addr = pool.addresses[0].address.clone(); - // A pool address present only in the manifest, never live-derived. - let manifest_only_addr = pool.addresses[1].address.clone(); - const LIVE_ACCOUNT_INDEX: u32 = 4242; - - persister - .store( - w, - PlatformWalletChangeSet { - account_address_pools: snapshots.clone(), - ..Default::default() - }, - ) - .unwrap(); - - { - let conn = persister.lock_conn_for_test(); - conn.execute( - "INSERT INTO core_derived_addresses \ - (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ - VALUES (?1, 'standard_bip44', ?2, 'internal', 999, ?3, 1)", - rusqlite::params![w.as_slice(), LIVE_ACCOUNT_INDEX, live_addr.to_string()], - ) - .unwrap(); - } - - persister - .store( - w, - PlatformWalletChangeSet { - core: Some(CoreChangeSet { - new_utxos: vec![ - utxo_at(&live_addr, 0, 100_000), - utxo_at(&manifest_only_addr, 1, 200_000), - ], - ..Default::default() - }), - ..Default::default() - }, - ) - .expect("both UTXOs resolve"); - - let conn = persister.lock_conn_for_test(); - let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); - - // The live cache row wins: its UTXO resolves to the off-pool index. - let live_rows = by_account - .get(&LIVE_ACCOUNT_INDEX) - .expect("off-pool index row"); - assert!( - live_rows.iter().any(|r| r.value == 100_000), - "the cache hit resolves to the live (off-pool) account_index, not the manifest's" - ); - // The manifest-only address resolves via the fallback to its pool index. - let pool_rows = by_account.get(&pool_account_index).expect("pool index row"); - assert!( - pool_rows.iter().any(|r| r.value == 200_000), - "the manifest-only address resolves via the fallback" - ); -} - -/// Blast-radius isolation: a batch mixing a valid pool-address UTXO, a -/// sync-height bump, and ONE unspent UTXO at a genuinely undeclared -/// address (not in pools, not derived) commits the valid UTXO + height -/// and SKIPS only the bad UTXO — no error escapes, so the buffer drains -/// instead of looping. -#[test] -fn undeclared_unspent_utxo_is_skipped_not_fatal() { - use dashcore::hashes::Hash; - let (persister, _tmp, _path) = fresh_persister(); - let w: WalletId = wid(0xD0); - ensure_wallet_meta(&persister, &w); - - let (snapshots, good) = wallet_with_pools(0x44); - let good_addr = good.address.clone(); - // A second pool address for a live derive record in the same batch. - let extra = snapshots[0].addresses[1].clone(); - let extra_derived = derived_for(&snapshots[0], &extra); - let extra_addr = extra.address.to_string(); - assert_ne!(extra_addr, good_addr.to_string(), "fixture sanity"); - - // A genuinely undeclared address: not in any pool, never derived. - let undeclared = { - use dashcore::address::Payload; - use dashcore::PubkeyHash; - dashcore::Address::new( - dashcore::Network::Testnet, - Payload::PubkeyHash(PubkeyHash::from_byte_array([0xEE; 20])), - ) - }; - assert_ne!(undeclared, good_addr, "fixture sanity"); - - persister - .store( - w, - PlatformWalletChangeSet { - account_address_pools: snapshots.clone(), - ..Default::default() - }, - ) - .unwrap(); - - // Wipe derived rows so the batch's own live derive is the only source - // of `extra_addr`, making its commit unambiguous. - { - let conn = persister.lock_conn_for_test(); - conn.execute( - "DELETE FROM core_derived_addresses WHERE wallet_id = ?1 AND address = ?2", - rusqlite::params![w.as_slice(), extra_addr], - ) - .unwrap(); - } - - persister - .store( - w, - PlatformWalletChangeSet { - core: Some(CoreChangeSet { - addresses_derived: vec![extra_derived], - new_utxos: vec![ - utxo_at(&good_addr, 0, 100_000), - utxo_at(&undeclared, 9, 200_000), - ], - last_processed_height: Some(123), - synced_height: Some(123), - ..Default::default() - }), - ..Default::default() - }, - ) - .expect("mixed batch must commit; the undeclared UTXO is skipped, not fatal"); - - let conn = persister.lock_conn_for_test(); - let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); - let all: Vec<_> = by_account.values().flatten().collect(); - assert_eq!( - all.len(), - 1, - "only the good UTXO commits; the bad one is skipped" - ); - assert!( - all.iter().all(|r| r.value == 100_000), - "the committed UTXO is the good one" - ); - - // A normal valid record in the same batch (the live derive) committed — - // the skip isolates only the bad UTXO, not the surrounding records. - let derived = core_state::list_derived_addresses_for_test(&conn, &w).unwrap(); - assert!( - derived.iter().any(|r| r.address == extra_addr), - "the live derive record in the mixed batch must commit" - ); - - // The sync-height bump committed in the same transaction. - let synced: Option = conn - .query_row( - "SELECT synced_height FROM core_sync_state WHERE wallet_id = ?1", - rusqlite::params![w.as_slice()], - |row| row.get(0), - ) - .unwrap(); - assert_eq!( - synced, - Some(123), - "sync-height must commit alongside valid records" - ); -} - -/// Build a live `DerivedAddress` for an explicit slot + address — the raw -/// material for the Design-Z invariant tests below. -fn derived_at( - account_type: key_wallet::account::AccountType, - pool_type: key_wallet::managed_account::address_pool::AddressPoolType, - derivation_index: u32, - address: dashcore::Address, -) -> platform_wallet::DerivedAddress { - const PUBKEY_G: [u8; 33] = [ - 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87, - 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16, - 0xf8, 0x17, 0x98, - ]; - platform_wallet::DerivedAddress { - account_type, - pool_type, - derivation_index, - address, - public_key: dashcore::PublicKey::from_slice(&PUBKEY_G).expect("valid compressed pubkey"), - } -} - -/// A single-address `account_address_pools` snapshot for an explicit slot, -/// so the apply-time emitter-contract guard accepts a matching -/// `addresses_derived` event. `new_from_script_pubkey_p2pkh` yields a -/// keyless `AddressInfo` — enough for the manifest membership check. -fn manifest_entry( - account_type: key_wallet::account::AccountType, - pool_type: key_wallet::managed_account::address_pool::AddressPoolType, - index: u32, - address: &dashcore::Address, -) -> AccountAddressPoolEntry { - let info = AddressInfo::new_from_script_pubkey_p2pkh( - address.script_pubkey(), - index, - Default::default(), - key_wallet::Network::Testnet, - ) - .expect("p2pkh AddressInfo"); - AccountAddressPoolEntry { - account_type, - pool_type, - addresses: vec![info], - } -} - -/// An arbitrary testnet P2PKH address from a byte pattern. -fn addr_from(byte: u8) -> dashcore::Address { - use dashcore::address::Payload; - use dashcore::hashes::Hash; - use dashcore::PubkeyHash; - dashcore::Address::new( - dashcore::Network::Testnet, - Payload::PubkeyHash(PubkeyHash::from_byte_array([byte; 20])), - ) -} - -/// A Standard BIP44 account type for the explicit-slot fixtures. -fn standard_account() -> key_wallet::account::AccountType { - key_wallet::account::AccountType::Standard { - index: 0, - standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, - } -} - -/// Assert a storage error is specifically a SQLite UNIQUE-constraint -/// violation (`SQLITE_CONSTRAINT_UNIQUE`, 2067) — not merely some generic -/// constraint, so a PK/CHECK/NOT-NULL failure cannot satisfy it. -fn assert_unique_violation(err: platform_wallet_storage::WalletStorageError) { - match err { - platform_wallet_storage::WalletStorageError::Sqlite(rusqlite::Error::SqliteFailure( - e, - _, - )) => { - assert_eq!( - e.code, - rusqlite::ErrorCode::ConstraintViolation, - "expected a constraint violation, got {e:?}" - ); - assert_eq!( - e.extended_code, - rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE, - "expected SQLITE_CONSTRAINT_UNIQUE (2067), got extended_code={}", - e.extended_code - ); - } - other => panic!("expected a SQLite constraint error, got {other:?}"), - } -} - -/// The whole BIP32 leaf grain: each live derivation in a multi-address pool -/// persists ONE row per derivation index — never a single collapsed row. -/// Regression guard for the 1-row collapse a non-leaf PK caused. -#[test] -fn multi_address_pool_persists_one_row_per_leaf() { - let (persister, _tmp, _path) = fresh_persister(); - let w: WalletId = wid(0xF0); - ensure_wallet_meta(&persister, &w); - - let (snapshots, _target) = wallet_with_pools(0x22); - let pool = &snapshots[0]; - let pool_len = pool.addresses.len(); - assert!(pool_len >= 2, "fixture needs a multi-address pool"); - - // The manifest vouches for every leaf, then each is live-derived. - let derived: Vec = pool - .addresses - .iter() - .map(|info| derived_for(pool, info)) - .collect(); - - persister - .store( - w, - PlatformWalletChangeSet { - core: Some(CoreChangeSet { - addresses_derived: derived, - ..Default::default() - }), - account_address_pools: snapshots.clone(), - ..Default::default() - }, - ) - .unwrap(); - - let conn = persister.lock_conn_for_test(); - let rows = core_state::list_derived_addresses_for_test(&conn, &w).unwrap(); - assert_eq!( - rows.len(), - pool_len, - "every derived leaf must persist its own row (no PK collapse)" - ); -} - -/// Within-pool collision goes LOUD: two distinct `derivation_index` in the -/// SAME pool resolving to the SAME address must NOT silently collapse — the -/// second authoritative write fails on UNIQUE(wallet_id, address). -#[test] -fn within_pool_address_collision_is_loud() { - use key_wallet::managed_account::address_pool::AddressPoolType; - - let (persister, _tmp, _path) = fresh_persister(); - let w: WalletId = wid(0xF1); - ensure_wallet_meta(&persister, &w); - - let addr = addr_from(0x71); - let acct = standard_account(); - - // The manifest must vouch for the derived address (emitter contract). - persister - .store( - w, - PlatformWalletChangeSet { - account_address_pools: vec![manifest_entry( - acct, - AddressPoolType::External, - 0, - &addr, - )], - ..Default::default() - }, - ) - .unwrap(); - - let mut conn = persister.lock_conn_for_test(); - let tx = conn.transaction().unwrap(); - core_state::apply( - &tx, - &w, - &CoreChangeSet { - addresses_derived: vec![derived_at(acct, AddressPoolType::External, 0, addr.clone())], - ..Default::default() - }, - ) - .expect("first leaf at a fresh address must persist"); - - // Leaf 1 of the SAME pool yielding the SAME address — a distinct PK - // leaf, so this is a UNIQUE(address) violation, not a PK no-op. - let err = core_state::apply( - &tx, - &w, - &CoreChangeSet { - addresses_derived: vec![derived_at(acct, AddressPoolType::External, 1, addr.clone())], - ..Default::default() - }, - ) - .expect_err("a different leaf reusing the same address must violate UNIQUE(address)"); - - assert_unique_violation(err); -} - -/// Cross-pool collision goes loud: the same address at a different -/// pool_type is still a UNIQUE(address) violation. -#[test] -fn cross_pool_address_collision_is_loud() { - use key_wallet::managed_account::address_pool::AddressPoolType; - - let (persister, _tmp, _path) = fresh_persister(); - let w: WalletId = wid(0xF2); - ensure_wallet_meta(&persister, &w); - - let addr = addr_from(0x72); - let acct = standard_account(); - - persister - .store( - w, - PlatformWalletChangeSet { - account_address_pools: vec![manifest_entry( - acct, - AddressPoolType::External, - 0, - &addr, - )], - ..Default::default() - }, - ) - .unwrap(); - - let mut conn = persister.lock_conn_for_test(); - let tx = conn.transaction().unwrap(); - core_state::apply( - &tx, - &w, - &CoreChangeSet { - addresses_derived: vec![derived_at(acct, AddressPoolType::External, 0, addr.clone())], - ..Default::default() - }, - ) - .expect("external-pool leaf must persist"); - - let err = core_state::apply( - &tx, - &w, - &CoreChangeSet { - addresses_derived: vec![derived_at(acct, AddressPoolType::Internal, 0, addr.clone())], - ..Default::default() - }, - ) - .expect_err("the same address in a different pool must violate UNIQUE(address)"); - - assert_unique_violation(err); -} - -/// `used` is write-once on the AUTHORITATIVE path: a live re-derive of the -/// same leaf carries `used=false` and must NOT clear a stored `used=true`. -/// The `DO NOTHING` conflict clause is what preserves it — a `DO UPDATE SET -/// used = excluded.used` regression would clobber the flag, and this test -/// is the guard. -#[test] -fn authoritative_redrive_preserves_used_true() { - use key_wallet::managed_account::address_pool::AddressPoolType; - - let (persister, _tmp, _path) = fresh_persister(); - let w: WalletId = wid(0xF4); - ensure_wallet_meta(&persister, &w); - - let addr = addr_from(0x74); - let acct = standard_account(); - - persister - .store( - w, - PlatformWalletChangeSet { - account_address_pools: vec![manifest_entry( - acct, - AddressPoolType::External, - 0, - &addr, - )], - ..Default::default() - }, - ) - .unwrap(); - - // Seed the leaf with used=true (the snapshot's real flag). - { - let conn = persister.lock_conn_for_test(); - conn.execute( - "INSERT INTO core_derived_addresses \ - (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ - VALUES (?1, 'standard_bip44', 0, 'external', 0, ?2, 1)", - rusqlite::params![w.as_slice(), addr.to_string()], - ) - .unwrap(); - } - - // Live re-derive of the SAME leaf — the apply path hardcodes used=false. - { - let mut conn = persister.lock_conn_for_test(); - let tx = conn.transaction().unwrap(); - core_state::apply( - &tx, - &w, - &CoreChangeSet { - addresses_derived: vec![derived_at( - acct, - AddressPoolType::External, - 0, - addr.clone(), - )], - ..Default::default() - }, - ) - .expect("same-leaf re-derive must be a no-op, not an error"); - tx.commit().unwrap(); - } - - let conn = persister.lock_conn_for_test(); - let rows = core_state::list_derived_addresses_for_test(&conn, &w).unwrap(); - let at_addr: Vec<_> = rows - .iter() - .filter(|r| r.address == addr.to_string()) - .collect(); - assert_eq!(at_addr.len(), 1, "re-derive must not insert a second row"); - assert!( - at_addr[0].used, - "a live re-derive (used=false) must NOT clear a stored used=true" - ); -} - -/// Reload leaves the live cache authoritative: a pool address is ALSO held -/// as a live derived-cache row at an off-pool leaf. After `load` (no -/// reconcile), that single live row is still the only read-index for the -/// address — untouched leaf, untouched `used` — so resolution is stable -/// and `UNIQUE(address)` holds. -#[test] -fn reload_keeps_single_live_row_for_pool_address() { - let (persister, _tmp, path) = fresh_persister(); - let w: WalletId = wid(0xF3); - ensure_wallet_meta(&persister, &w); - - let (snapshots, target) = wallet_with_pools(0x66); - let pool_addr = target.address.to_string(); - - persister - .store( - w, - PlatformWalletChangeSet { - account_address_pools: snapshots.clone(), - ..Default::default() - }, - ) - .unwrap(); - - // Seed ONE live row claiming the pool address at an off-pool leaf. - const LIVE_DERIVATION_INDEX: i64 = 7777; - { - let conn = persister.lock_conn_for_test(); - conn.execute( - "INSERT INTO core_derived_addresses \ - (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ - VALUES (?1, 'standard_bip44', 0, 'internal', ?2, ?3, 1)", - rusqlite::params![w.as_slice(), LIVE_DERIVATION_INDEX, pool_addr], - ) - .unwrap(); - } - drop(persister); - - let p2 = reopen(&path); - PlatformWalletPersistence::load(&p2).expect("load"); - - let rows = { - let conn = p2.lock_conn_for_test(); - core_state::list_derived_addresses_for_test(&conn, &w).unwrap() - }; - let at_addr: Vec<_> = rows.iter().filter(|r| r.address == pool_addr).collect(); - assert_eq!( - at_addr.len(), - 1, - "exactly one read-index row per address after reload (no reconcile dupe)" - ); - let live = at_addr[0]; - assert_eq!( - live.pool_type, "internal", - "the live row's pool_type is untouched" - ); - assert_eq!( - live.derivation_index, LIVE_DERIVATION_INDEX, - "the live row's derivation_index is untouched" - ); - assert!(live.used, "the live row's used flag is untouched"); -} - -/// Derived-index invariant goes FATAL: an address this wallet's persisted -/// Emitter contract goes FATAL: a live `addresses_derived` entry whose -/// address is ABSENT from the `account_address_pools` manifest means the -/// emitter failed to attach the pool snapshot in-band. `core_state::apply` -/// aborts the flush with [`WalletStorageError::DerivedIndexInvariantViolated`] -/// (non-transient → `Fatal`), surfacing the broken contract at the storage -/// trust boundary instead of persisting a row the manifest can't vouch for. -#[test] -fn derivation_absent_from_manifest_is_fatal() { - use key_wallet::managed_account::address_pool::AddressPoolType; - use platform_wallet::changeset::PersistenceErrorKind; - use platform_wallet_storage::WalletStorageError; - - let (persister, _tmp, _path) = fresh_persister(); - let w: WalletId = wid(0xE0); - ensure_wallet_meta(&persister, &w); - - let acct = standard_account(); - let declared = addr_from(0x77); - let orphan = addr_from(0x78); - assert_ne!(declared, orphan, "fixture sanity"); - - // Manifest vouches for `declared` only. - persister - .store( - w, - PlatformWalletChangeSet { - account_address_pools: vec![manifest_entry( - acct, - AddressPoolType::External, - 0, - &declared, - )], - ..Default::default() - }, - ) - .unwrap(); - - // A derivation for an address the manifest never declared must abort. - let err = { - let mut conn = persister.lock_conn_for_test(); - let tx = conn.transaction().unwrap(); - core_state::apply( - &tx, - &w, - &CoreChangeSet { - addresses_derived: vec![derived_at( - acct, - AddressPoolType::External, - 1, - orphan.clone(), - )], - ..Default::default() - }, - ) - .expect_err("a derivation absent from the manifest must be fatal") - }; - - match &err { - WalletStorageError::DerivedIndexInvariantViolated { address } => { - assert_eq!( - *address, - orphan.to_string(), - "the violation must name the orphaned derived address" - ); - } - other => panic!("expected DerivedIndexInvariantViolated, got {other:?}"), - } - assert!( - !err.is_transient(), - "an emitter-contract violation is a logic regression, never retryable" - ); - assert_eq!( - err.persistence_kind(), - PersistenceErrorKind::Fatal, - "the violation must classify Fatal at the trait boundary" - ); -} - -/// PK axis-1 regression: two Standard accounts with the same pool slot but -/// DIFFERENT `account_index` (index 0 and index 1, both BIP44) must each -/// persist their own row in `core_derived_addresses`. Before the fix both -/// collapsed to the same PK and the second row was silently dropped. -/// -/// Also verifies that a UTXO lookup at each address resolves to the CORRECT -/// `account_index`, not the survivor's. -#[test] -fn multi_account_index_same_slot_persists_both_rows() { - use key_wallet::account::{AccountType, StandardAccountType}; - use key_wallet::managed_account::address_pool::AddressPoolType; - - let (persister, _tmp, _path) = fresh_persister(); - let w: WalletId = wid(0x51); - ensure_wallet_meta(&persister, &w); - - // Two BIP44 standard accounts at index 0 and 1, both deriving slot 0 of - // their external pool at distinct addresses. - let acct0 = AccountType::Standard { - index: 0, - standard_account_type: StandardAccountType::BIP44Account, - }; - let acct1 = AccountType::Standard { - index: 1, - standard_account_type: StandardAccountType::BIP44Account, - }; - let addr0 = addr_from(0xA0); - let addr1 = addr_from(0xA1); - - // Register both pools so the emitter-contract guard accepts both events. - persister - .store( - w, - PlatformWalletChangeSet { - account_address_pools: vec![ - manifest_entry(acct0, AddressPoolType::External, 0, &addr0), - manifest_entry(acct1, AddressPoolType::External, 0, &addr1), - ], - ..Default::default() - }, - ) - .unwrap(); - - // Live derive both leaves in the same changeset. - persister - .store( - w, - PlatformWalletChangeSet { - core: Some(CoreChangeSet { - addresses_derived: vec![ - derived_at(acct0, AddressPoolType::External, 0, addr0.clone()), - derived_at(acct1, AddressPoolType::External, 0, addr1.clone()), - ], - new_utxos: vec![utxo_at(&addr0, 0, 100_000), utxo_at(&addr1, 1, 200_000)], - ..Default::default() - }), - ..Default::default() - }, - ) - .expect("both derived rows must survive (account_index is now in the PK)"); - - let conn = persister.lock_conn_for_test(); - let derived = core_state::list_derived_addresses_for_test(&conn, &w).unwrap(); - assert_eq!( - derived.len(), - 2, - "both derived rows must exist — no PK collapse" - ); - - let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); - let utxo0 = by_account - .get(&0) - .and_then(|v| v.iter().find(|r| r.value == 100_000)); - let utxo1 = by_account - .get(&1) - .and_then(|v| v.iter().find(|r| r.value == 200_000)); - assert!( - utxo0.is_some(), - "the account-0 UTXO must resolve to account_index 0" - ); - assert!( - utxo1.is_some(), - "the account-1 UTXO must resolve to account_index 1" - ); -} - -/// PK axis-2 regression: a BIP32 standard acct-0 and a BIP44 standard -/// acct-0 derive to distinct addresses at the same pool slot. Before the -/// fix both collapsed to one row (both mapped to the `"standard"` label), -/// and the second write was dropped. After the fix the labels are distinct -/// (`"standard_bip32"` vs `"standard_bip44"`) so both rows coexist. -#[test] -fn bip32_and_bip44_standard_acct0_persist_both_rows() { - use key_wallet::account::{AccountType, StandardAccountType}; - use key_wallet::managed_account::address_pool::AddressPoolType; - - let (persister, _tmp, _path) = fresh_persister(); - let w: WalletId = wid(0x52); - ensure_wallet_meta(&persister, &w); - - let bip44 = AccountType::Standard { - index: 0, - standard_account_type: StandardAccountType::BIP44Account, - }; - let bip32 = AccountType::Standard { - index: 0, - standard_account_type: StandardAccountType::BIP32Account, - }; - let addr_bip44 = addr_from(0xB4); - let addr_bip32 = addr_from(0xB2); - - persister - .store( - w, - PlatformWalletChangeSet { - account_address_pools: vec![ - manifest_entry(bip44, AddressPoolType::External, 0, &addr_bip44), - manifest_entry(bip32, AddressPoolType::External, 0, &addr_bip32), - ], - ..Default::default() - }, - ) - .unwrap(); - - persister - .store( - w, - PlatformWalletChangeSet { - core: Some(CoreChangeSet { - addresses_derived: vec![ - derived_at(bip44, AddressPoolType::External, 0, addr_bip44.clone()), - derived_at(bip32, AddressPoolType::External, 0, addr_bip32.clone()), - ], - new_utxos: vec![ - utxo_at(&addr_bip44, 0, 444_000), - utxo_at(&addr_bip32, 1, 322_000), - ], - ..Default::default() - }), - ..Default::default() - }, - ) - .expect("BIP32 and BIP44 standard acct-0 rows must coexist (distinct labels)"); - - let conn = persister.lock_conn_for_test(); - let derived = core_state::list_derived_addresses_for_test(&conn, &w).unwrap(); - assert_eq!( - derived.len(), - 2, - "BIP32 and BIP44 derived rows must both survive" - ); - let labels: std::collections::BTreeSet<&str> = - derived.iter().map(|r| r.account_type.as_str()).collect(); - assert!( - labels.contains("standard_bip44") && labels.contains("standard_bip32"), - "both account_type labels must be present in the derived cache" - ); - - let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); - let total: usize = by_account.values().map(|v| v.len()).sum(); - assert_eq!( - total, 2, - "both UTXOs must resolve via their respective accounts" - ); -} - -/// `account_registrations` PK regression (todo 0e3ad26b): a BIP32 and a -/// BIP44 standard acct-0 registered with DIFFERENT xpubs must each persist -/// their own row. Before the fix both resolved to label `"standard"` so the -/// second INSERT clobbered the first row's xpub. After the fix their labels -/// differ and both rows coexist with their original xpubs intact. -#[test] -fn account_registrations_bip32_and_bip44_both_survive() { - use key_wallet::account::{AccountType, StandardAccountType}; - use key_wallet::wallet::initialization::WalletAccountCreationOptions; - use key_wallet::wallet::Wallet; - use platform_wallet::changeset::AccountRegistrationEntry; - use platform_wallet_storage::sqlite::schema::accounts; - - // Two distinct xpubs: seed them from different seed bytes. - let xpub_bip44 = { - let w = Wallet::from_seed_bytes( - [0xBBu8; 64], - key_wallet::Network::Testnet, - WalletAccountCreationOptions::Default, - ) - .unwrap(); - w.accounts - .all_accounts() - .first() - .expect("at least one account") - .account_xpub - }; - let xpub_bip32 = { - let w = Wallet::from_seed_bytes( - [0xCCu8; 64], - key_wallet::Network::Testnet, - WalletAccountCreationOptions::Default, - ) - .unwrap(); - w.accounts - .all_accounts() - .first() - .expect("at least one account") - .account_xpub - }; - assert_ne!( - xpub_bip44, xpub_bip32, - "fixture: seeds must yield distinct xpubs" - ); - - let (persister, _tmp, _path) = fresh_persister(); - let w: WalletId = wid(0x53); - ensure_wallet_meta(&persister, &w); - - let bip44_entry = AccountRegistrationEntry { - account_type: AccountType::Standard { - index: 0, - standard_account_type: StandardAccountType::BIP44Account, - }, - account_xpub: xpub_bip44, - }; - let bip32_entry = AccountRegistrationEntry { - account_type: AccountType::Standard { - index: 0, - standard_account_type: StandardAccountType::BIP32Account, - }, - account_xpub: xpub_bip32, - }; - - persister - .store( - w, - PlatformWalletChangeSet { - account_registrations: vec![bip44_entry.clone(), bip32_entry.clone()], - ..Default::default() - }, - ) - .expect("both registrations must persist without clobbering each other"); - - let conn = persister.lock_conn_for_test(); - let manifest = accounts::load_state(&conn, &w).expect("load_state"); - drop(conn); - - assert_eq!( - manifest.len(), - 2, - "both account_registrations rows must survive — no xpub clobber" - ); - - let has_bip44 = manifest.iter().any(|e| { - matches!( - e.account_type, - AccountType::Standard { - standard_account_type: StandardAccountType::BIP44Account, - .. - } - ) && e.account_xpub == xpub_bip44 - }); - let has_bip32 = manifest.iter().any(|e| { - matches!( - e.account_type, - AccountType::Standard { - standard_account_type: StandardAccountType::BIP32Account, - .. - } - ) && e.account_xpub == xpub_bip32 - }); - assert!( - has_bip44, - "the BIP44 registration with its original xpub must survive" - ); - assert!( - has_bip32, - "the BIP32 registration with its original xpub must survive" - ); -} - -/// The guard does not over-fire: a UTXO at a genuinely-undeclared address -/// (in NO pool, never derived) is still SKIPPED quietly — no error escapes, -/// and the rest of the batch persists. Guards against the invariant guard -/// firing on a benign SPV gap-limit miss. -#[test] -fn undeclared_unspent_utxo_at_apply_level_is_skipped_not_fatal() { - let (persister, _tmp, _path) = fresh_persister(); - let w: WalletId = wid(0xE1); - ensure_wallet_meta(&persister, &w); - - // Register a pool so `account_address_pools` is NON-empty: the good - // address resolves via the manifest fallback, the undeclared one skips. - let (snapshots, good) = wallet_with_pools(0x88); - let good_addr = good.address.clone(); - let expected_index = match snapshots[0].account_type { - key_wallet::account::AccountType::Standard { index, .. } => index, - _ => unreachable!("fixture uses a Standard account"), - }; - persister - .store( - w, - PlatformWalletChangeSet { - account_address_pools: snapshots, - ..Default::default() - }, - ) - .unwrap(); - - // An address in NO pool and never derived. - let undeclared = addr_from(0x9A); - assert_ne!(undeclared, good_addr, "fixture sanity"); - - { - let mut conn = persister.lock_conn_for_test(); - let tx = conn.transaction().unwrap(); - core_state::apply( - &tx, - &w, - &CoreChangeSet { - new_utxos: vec![ - utxo_at(&good_addr, 0, 100_000), - utxo_at(&undeclared, 9, 200_000), - ], - ..Default::default() - }, - ) - .expect("a genuinely-undeclared address must be skipped, not fatal"); - tx.commit().unwrap(); - } - - let conn = persister.lock_conn_for_test(); - - // The good pool address resolves via the manifest fallback — no mirrored - // derived row exists for it. - let derived = core_state::list_derived_addresses_for_test(&conn, &w).unwrap(); - assert!( - derived.iter().all(|r| r.address != good_addr.to_string()), - "no mirror: the good pool address is resolved from the manifest, not the cache" - ); - - let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); - let all: Vec<_> = by_account.values().flatten().collect(); - assert_eq!( - all.len(), - 1, - "the declared-address UTXO commits; the undeclared one is skipped" - ); - let survivor = all[0]; - assert_eq!( - survivor.value, 100_000, - "the committed UTXO is the one at the declared pool address" - ); - // Skipping the undeclared row must leave the good row's attribution - // intact — it resolves to the pool's account index, not a placeholder. - assert_eq!( - survivor.account_index, expected_index, - "the surviving UTXO must keep its resolved account index" - ); -} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs b/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs index 963fa2c62c..2f1babb8f4 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs @@ -116,40 +116,26 @@ fn make_utxo(addr: &Address, vout: u32, value: u64) -> Utxo { Utxo::new(outpoint, txout, addr.clone(), 10, false) } -/// UTXOs resolve their real `account_index` from the derived-address -/// map written earlier in the same transaction, instead of a hardcoded -/// 0. +/// Every `core_utxos` row is written with the hardcoded default +/// `account_index = 0` (the product uses only the default account; a +/// non-default account is rejected upstream by the `core_bridge` guard), +/// so the per-account grouping reader buckets every unspent UTXO — at any +/// address — under account 0. #[test] -fn multi_account_utxos_bucket_to_real_account() { +fn utxos_bucket_under_default_account_index_zero() { use platform_wallet_storage::sqlite::schema::core_state; let (persister, _tmp, _path) = fresh_persister(); let w: WalletId = wid(0xC7); ensure_wallet_meta(&persister, &w); - let addr_acct5 = p2pkh(0x05); - let addr_acct9 = p2pkh(0x09); + let addr_a = p2pkh(0x05); + let addr_b = p2pkh(0x09); { let mut conn = persister.lock_conn_for_test(); - // Pre-seed the derived-address map with two distinct accounts. - // Distinct derivation_index keeps the rows off the same BIP32-leaf - // PK (account_index is account-level context, not a key). - for (acct, deriv, addr) in [(5u32, 0u32, &addr_acct5), (9u32, 1u32, &addr_acct9)] { - conn.execute( - "INSERT INTO core_derived_addresses \ - (wallet_id, account_type, account_index, pool_type, derivation_index, address, used) \ - VALUES (?1, 'standard_bip44', ?2, 'external', ?3, ?4, 0)", - params![w.as_slice(), acct as i64, deriv as i64, addr.to_string()], - ) - .unwrap(); - } - let cs = CoreChangeSet { - new_utxos: vec![ - make_utxo(&addr_acct5, 0, 1000), - make_utxo(&addr_acct9, 1, 2000), - ], + new_utxos: vec![make_utxo(&addr_a, 0, 1000), make_utxo(&addr_b, 1, 2000)], ..Default::default() }; let tx = conn.transaction().unwrap(); @@ -160,56 +146,20 @@ fn multi_account_utxos_bucket_to_real_account() { let conn = persister.lock_conn_for_test(); let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); assert_eq!( - by_account.get(&5).map(|v| v.len()), - Some(1), - "account 5 should hold exactly one UTXO" + by_account.len(), + 1, + "all UTXOs bucket under a single (default) account" ); assert_eq!( - by_account.get(&9).map(|v| v.len()), - Some(1), - "account 9 should hold exactly one UTXO" - ); -} - -/// A NEW unspent UTXO whose address is absent from -/// `core_derived_addresses` cannot resolve an owning account. Rather than -/// mis-filing live funds under account 0 (corruption) or aborting the -/// whole flush (the genesis-rescan fatal loop), the writer SKIPS just -/// that row: `apply` returns `Ok`, no `core_utxos` row is written, and -/// the surrounding records still commit. The address re-warms its -/// balance once it later derives — funds-safe. -#[test] -fn unspent_utxo_on_undeclared_address_is_skipped() { - use platform_wallet_storage::sqlite::schema::core_state; - - let (persister, _tmp, _path) = fresh_persister(); - let w: WalletId = wid(0xC8); - ensure_wallet_meta(&persister, &w); - - let addr_unknown = p2pkh(0xEE); - { - let mut conn = persister.lock_conn_for_test(); - let cs = CoreChangeSet { - new_utxos: vec![make_utxo(&addr_unknown, 0, 3000)], - ..Default::default() - }; - let tx = conn.transaction().unwrap(); - core_state::apply(&tx, &w, &cs) - .expect("an undeclared-address unspent UTXO must be skipped, not error"); - tx.commit().unwrap(); - } - - let conn = persister.lock_conn_for_test(); - let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); - assert!( - by_account.is_empty(), - "the unresolvable unspent UTXO must be skipped, leaving no core_utxos row" + by_account.get(&0).map(|v| v.len()), + Some(2), + "both UTXOs are attributed to the default account (index 0)" ); } -/// A spent-only placeholder UTXO whose address was never derived still -/// persists with the account-0 fallback — spent rows are excluded from -/// the unspent set, so the placeholder index is inert. +/// A spent-only placeholder UTXO (no prior unspent row to mark) persists +/// with the hardcoded account 0 — spent rows are excluded from the unspent +/// set, so the index is inert. #[test] fn spent_only_utxo_on_undeclared_address_uses_zero_fallback() { use platform_wallet_storage::sqlite::schema::core_state; diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_wallet_db_identity.rs b/packages/rs-platform-wallet-storage/tests/sqlite_wallet_db_identity.rs index 61ea6b8fc3..ae3ef2ce92 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_wallet_db_identity.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_wallet_db_identity.rs @@ -60,6 +60,7 @@ fn fresh_wallet_db() -> (tempfile::TempDir, std::path::PathBuf) { let mut cs = PlatformWalletChangeSet::default(); cs.wallet_metadata = Some(WalletMetadataEntry { network: key_wallet::Network::Testnet, + wallet_group_id: [0u8; 32], birth_height: 0, }); cs.core = Some(CoreChangeSet { diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index e755fc5417..ce1b1fdcd4 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -24,12 +24,10 @@ //! manager's lifetime; on shutdown, fire the [`CancellationToken`] to //! make the task exit cleanly. -use std::collections::BTreeSet; use std::sync::Arc; use dashcore::blockdata::transaction::{txout::TxOut, OutPoint}; use dashcore::ScriptBuf; -use key_wallet::account::AccountType; use key_wallet::managed_account::transaction_record::{OutputRole, TransactionRecord}; use key_wallet::transaction_checking::TransactionContext; use key_wallet::Utxo; @@ -39,12 +37,37 @@ use tokio::sync::RwLock; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; -use crate::changeset::changeset::{ - AccountAddressPoolEntry, CoreChangeSet, PlatformWalletChangeSet, -}; +use crate::changeset::changeset::{CoreChangeSet, PlatformWalletChangeSet}; use crate::changeset::traits::PlatformWalletPersistence; use crate::wallet::platform_wallet::PlatformWalletInfo; +/// Error from projecting a [`WalletEvent`] into a [`CoreChangeSet`]. +#[derive(Debug, thiserror::Error)] +pub(crate) enum CoreBridgeError { + /// A UTXO-bearing record belongs to a non-default funds account. The + /// storage layer hardcodes `core_utxos.account_index = 0` (the product + /// uses only the default account), so a non-zero index would be silently + /// mis-grouped. Caught here — the only site with `record.account_type`. + #[error( + "non-default account index {index}: the storage layer assumes the default \ + account (index 0); refusing to persist mis-attributed UTXOs" + )] + NonDefaultAccount { index: u32 }, +} + +/// Single-account guard: every UTXO-bearing record must belong to the +/// default account (index 0). The storage writer hardcodes +/// `core_utxos.account_index = 0`, so a non-default funds account would be +/// silently mis-grouped — fail loud here instead. Identity/provider account +/// types carry no funds index (`AccountType::index() == None`) and never +/// emit `Received`/`Change` UTXOs, so they pass. +fn ensure_default_account(record: &TransactionRecord) -> Result<(), CoreBridgeError> { + match record.account_type.index() { + None | Some(0) => Ok(()), + Some(index) => Err(CoreBridgeError::NonDefaultAccount { index }), + } +} + /// Spawn the wallet-event subscriber task. /// /// Subscribes to `wallet_manager.subscribe_events()` from inside the @@ -82,15 +105,32 @@ where // state (today only `TransactionInstantLocked`, // which checks finality before recording the IS // lock), grab a brief read lock on the manager. - let core = build_core_changeset(&wallet_manager, &event).await; + let core = match build_core_changeset(&wallet_manager, &event).await { + Ok(core) => core, + Err(e) => { + // Single-account guard tripped: a UTXO's + // owning account isn't the default (index + // 0). Fail loud and skip — never persist + // mis-attributed UTXOs, and don't corrupt + // the buffer by storing a partial set. + tracing::error!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "core-bridge single-account guard rejected event; not persisting" + ); + continue; + } + }; if core.is_empty_no_records() { // SyncHeightAdvanced for an unknown wallet, // empty BlockProcessed, etc. — nothing to // persist. Skip the round-trip. continue; } - let cs = - build_platform_changeset(&wallet_manager, &wallet_id, core).await; + let cs = PlatformWalletChangeSet { + core: Some(core), + ..PlatformWalletChangeSet::default() + }; if let Err(e) = persister.store(wallet_id, cs) { tracing::warn!( wallet_id = %hex::encode(wallet_id), @@ -124,28 +164,27 @@ where async fn build_core_changeset( wallet_manager: &Arc>>, event: &WalletEvent, -) -> CoreChangeSet { +) -> Result { match event { WalletEvent::TransactionDetected { record, addresses_derived, .. } => { + // Refuse a non-default funds account before projecting UTXOs. + ensure_default_account(record)?; // Derive UTXO deltas before moving the record into `records` // so the per-record borrows are still live. - CoreChangeSet { + Ok(CoreChangeSet { new_utxos: derive_new_utxos(record), spent_utxos: derive_spent_utxos(record), records: vec![(**record).clone()], - // Mirror the upstream-emitted derived addresses - // through to the persister so newly-extended pool - // rows are written transactionally with the tx that - // triggered the extension. See - // `CoreChangeSet.addresses_derived` for the cascade- - // link rationale. + // Forward the upstream-emitted derived addresses to the + // persister; the FFI layer feeds the iOS address registry + // from this delta. See `CoreChangeSet.addresses_derived`. addresses_derived: addresses_derived.clone(), ..CoreChangeSet::default() - } + }) } WalletEvent::TransactionInstantLocked { wallet_id, @@ -157,12 +196,12 @@ async fn build_core_changeset( // wallet has already chain-locked this txid, drop the lock — // chain-lock supersedes IS finality. if is_chain_locked(wallet_manager, wallet_id, txid).await { - return CoreChangeSet::default(); + return Ok(CoreChangeSet::default()); } let mut cs = CoreChangeSet::default(); cs.instant_locks_for_non_final_records .insert(*txid, instant_lock.clone()); - cs + Ok(cs) } WalletEvent::BlockProcessed { height, @@ -173,8 +212,10 @@ async fn build_core_changeset( .. } => { let mut cs = CoreChangeSet::default(); - // Inserted records bring fresh UTXOs and may consume previous ones. + // Inserted records bring fresh UTXOs and may consume previous + // ones — guard each before projecting. for r in inserted { + ensure_default_account(r)?; cs.new_utxos.extend(derive_new_utxos(r)); cs.spent_utxos.extend(derive_spent_utxos(r)); } @@ -191,12 +232,12 @@ async fn build_core_changeset( // Already deduped upstream by `project_derived_addresses`; // `Merge` re-dedupes if multiple events fold together. cs.addresses_derived = addresses_derived.clone(); - cs + Ok(cs) } - WalletEvent::SyncHeightAdvanced { height, .. } => CoreChangeSet { + WalletEvent::SyncHeightAdvanced { height, .. } => Ok(CoreChangeSet { synced_height: Some(*height), ..CoreChangeSet::default() - }, + }), WalletEvent::ChainLockProcessed { chain_lock, .. } => { // The wallet has already promoted the matching records from // `InBlock` to `InChainLockedBlock` by the time this event @@ -222,82 +263,12 @@ async fn build_core_changeset( // The earlier `TransactionsChainlocked`-only signal had a // gap on the "metadata advanced but per-account empty" // path; the new event closes it deterministically. - CoreChangeSet { + Ok(CoreChangeSet { last_applied_chain_lock: Some(chain_lock.clone()), ..CoreChangeSet::default() - } - } - } -} - -/// Wrap a [`CoreChangeSet`] in a [`PlatformWalletChangeSet`], attaching a -/// full pool snapshot in-band when the delta derived new addresses. -/// -/// `addresses_derived` is a delta with no `used` flag, so it can't be the -/// manifest. When non-empty the in-memory pool just changed, so we read -/// the whole current pool. It must ride the same changeset because the -/// persister applies `account_address_pools` before the core UTXO delta -/// in one tx, making any newly-derived address resolvable when this -/// changeset's UTXOs are written — closing the gap-limit race in-band. -/// -/// A `used` flip with no derivation leaves the delta empty and is -/// intentionally not snapshot here: `used` doesn't affect -/// address→account resolution, and emitting on every wallet-touching -/// block would be pure write amplification. -async fn build_platform_changeset( - wallet_manager: &Arc>>, - wallet_id: &WalletId, - core: CoreChangeSet, -) -> PlatformWalletChangeSet { - let mut account_address_pools = Vec::new(); - if !core.addresses_derived.is_empty() { - let guard = wallet_manager.read().await; - let affected: BTreeSet = core - .addresses_derived - .iter() - .map(|d| d.account_type) - .collect(); - for account_type in &affected { - account_address_pools.extend(snapshot_account_pools(&guard, wallet_id, account_type)); + }) } } - PlatformWalletChangeSet { - core: Some(core), - account_address_pools, - ..PlatformWalletChangeSet::default() - } -} - -/// Snapshot one account's non-empty pools straight from the live -/// `WalletManager`, mirroring the enumeration the registration path uses -/// (`account_address_pools_blocking` is on a different type, so the shared -/// walk lives here). Empty vec when the wallet/account is absent. -fn snapshot_account_pools( - guard: &WalletManager, - wallet_id: &WalletId, - account_type: &AccountType, -) -> Vec { - let Some(info) = guard.get_wallet_info(wallet_id) else { - return Vec::new(); - }; - let accounts = info.core_wallet.accounts.all_accounts(); - let Some(account) = accounts - .iter() - .find(|a| &a.managed_account_type().to_account_type() == account_type) - else { - return Vec::new(); - }; - account - .managed_account_type() - .address_pools() - .iter() - .filter(|pool| !pool.addresses.is_empty()) - .map(|pool| AccountAddressPoolEntry { - account_type: *account_type, - pool_type: pool.pool_type, - addresses: pool.addresses.values().cloned().collect(), - }) - .collect() } /// Returns `true` when the wallet's stored record for `txid` is in a @@ -432,167 +403,67 @@ impl CoreChangeSet { #[cfg(test)] mod tests { - use std::collections::BTreeMap; - - use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType}; - use key_wallet::wallet::initialization::WalletAccountCreationOptions; - use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; - use key_wallet::wallet::Wallet; - use key_wallet::Network; - use key_wallet_manager::DerivedAddress; + use dashcore::blockdata::transaction::Transaction; + use dashcore::hashes::Hash; + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; use super::*; - use crate::wallet::core::WalletBalance; - use crate::wallet::identity::IdentityManager; - /// Register a seeded wallet (default account creation derives the - /// gap-limit pools) into a fresh manager. - fn manager_with_wallet() -> (Arc>>, WalletId) { - let wallet = Wallet::from_seed_bytes( - [0x42; 64], - Network::Testnet, - WalletAccountCreationOptions::Default, - ) - .expect("wallet from seed"); - let info = PlatformWalletInfo { - core_wallet: ManagedWalletInfo::from_wallet(&wallet, 0), - balance: Arc::new(WalletBalance::new()), - identity_manager: IdentityManager::new(), - tracked_asset_locks: BTreeMap::new(), + /// Minimal confirmed `TransactionRecord` owned by `account_type`. The + /// single-account guard reads only `record.account_type`, so the tx body + /// and the input/output details are intentionally empty. + fn record_for_account(account_type: AccountType) -> TransactionRecord { + let tx = Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, }; - let mut wm = WalletManager::::new(Network::Testnet); - let wallet_id = wm.insert_wallet(wallet, info).expect("insert wallet"); - (Arc::new(RwLock::new(wm)), wallet_id) - } - - /// First funds account's type plus the index count of its external - /// pool, and a `DerivedAddress` forged from a real address in it (the - /// emitter keys only off `account_type`). - fn funds_account_and_derived( - guard: &WalletManager, - wallet_id: &WalletId, - ) -> (AccountType, usize, DerivedAddress) { - let info = guard.get_wallet_info(wallet_id).expect("wallet present"); - for account in &info.core_wallet.accounts.all_accounts() { - let account_type = account.managed_account_type().to_account_type(); - for pool in account.managed_account_type().address_pools() { - if pool.pool_type != AddressPoolType::External || pool.addresses.is_empty() { - continue; - } - let addr = pool.addresses.values().next().expect("address present"); - let Some(PublicKeyType::ECDSA(bytes)) = addr.public_key.as_ref() else { - continue; - }; - let derived = DerivedAddress { - account_type, - pool_type: pool.pool_type, - derivation_index: addr.index, - address: addr.address.clone(), - public_key: dashcore::PublicKey::from_slice(bytes).expect("valid key"), - }; - return (account_type, pool.addresses.len(), derived); - } - } - panic!("no funds account with a populated ECDSA external pool"); + TransactionRecord::new( + tx, + account_type, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 42, + dashcore::BlockHash::from_byte_array([3u8; 32]), + 1_735_689_600, + )), + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + Vec::new(), + 100, + ) } - fn core_with_derived(derived: Vec) -> CoreChangeSet { - CoreChangeSet { - last_processed_height: Some(100), - addresses_derived: derived, - ..CoreChangeSet::default() + fn standard(index: u32) -> AccountType { + AccountType::Standard { + index, + standard_account_type: StandardAccountType::BIP44Account, } } - /// A derivation delta yields the FULL pool (every index), not just the - /// new one, and it rides the SAME changeset as the core delta. - #[tokio::test] - async fn extension_snapshots_full_pool_in_band() { - let (wm, wallet_id) = manager_with_wallet(); - let (account_type, full_len, derived) = { - let guard = wm.read().await; - funds_account_and_derived(&guard, &wallet_id) - }; - assert!(full_len > 1, "external pool populated to the gap limit"); - - let cs = build_platform_changeset(&wm, &wallet_id, core_with_derived(vec![derived])).await; - - assert!(cs.core.is_some(), "core delta rides the same changeset"); - let entry = cs - .account_address_pools - .iter() - .find(|e| e.account_type == account_type && e.pool_type == AddressPoolType::External) - .expect("external pool snapshot for the affected account"); - assert_eq!(entry.addresses.len(), full_len, "FULL pool, not the delta"); - } - - /// An empty delta emits no pool snapshot — no write amplification on - /// blocks that derive nothing. - #[tokio::test] - async fn empty_derivation_emits_no_snapshot() { - let (wm, wallet_id) = manager_with_wallet(); - let cs = build_platform_changeset(&wm, &wallet_id, core_with_derived(Vec::new())).await; - assert!(cs.account_address_pools.is_empty()); - assert!(cs.core.is_some(), "core delta still carried"); - } - - /// The emitter snapshot matches the registration-path enumeration for - /// the same account (same pools, same addresses per pool), so - /// registration semantics are preserved. `AccountAddressPoolEntry` - /// isn't `PartialEq`, so compare on a stable projection. - #[tokio::test] - async fn emitter_matches_registration_shape() { - let (wm, wallet_id) = manager_with_wallet(); - let guard = wm.read().await; - let (account_type, _, _) = funds_account_and_derived(&guard, &wallet_id); - - let project = |entries: &[AccountAddressPoolEntry]| -> Vec<(AddressPoolType, Vec)> { - let mut out: Vec<_> = entries - .iter() - .map(|e| { - let mut addrs: Vec = - e.addresses.iter().map(|a| a.address.to_string()).collect(); - addrs.sort(); - (e.pool_type, addrs) - }) - .collect(); - out.sort_by_key(|(pt, _)| *pt); - out - }; - - let emitted = snapshot_account_pools(&guard, &wallet_id, &account_type); - - let info = guard.get_wallet_info(&wallet_id).expect("wallet present"); - let account = info - .core_wallet - .accounts - .all_accounts() - .into_iter() - .find(|a| a.managed_account_type().to_account_type() == account_type) - .expect("account present"); - let registration_shape: Vec = account - .managed_account_type() - .address_pools() - .iter() - .filter(|p| !p.addresses.is_empty()) - .map(|p| AccountAddressPoolEntry { - account_type, - pool_type: p.pool_type, - addresses: p.addresses.values().cloned().collect(), - }) - .collect(); - - assert_eq!(project(&emitted), project(®istration_shape)); - assert!(emitted.iter().all(|e| e.account_type == account_type)); - assert!(!emitted.is_empty()); + /// The default funds account (index 0) passes the single-account guard. + #[test] + fn default_account_passes_guard() { + assert!(ensure_default_account(&record_for_account(standard(0))).is_ok()); } - /// Unknown wallet → empty snapshot, not a panic. - #[tokio::test] - async fn snapshot_unknown_wallet_is_empty() { - let (wm, wallet_id) = manager_with_wallet(); - let guard = wm.read().await; - let (account_type, _, _) = funds_account_and_derived(&guard, &wallet_id); - assert!(snapshot_account_pools(&guard, &[0xAB; 32], &account_type).is_empty()); + /// A non-default funds account fails loud: the storage layer hardcodes + /// `core_utxos.account_index = 0`, so a non-zero index would otherwise be + /// silently mis-grouped. The error names the offending index. + #[test] + fn non_default_account_fails_guard() { + let err = ensure_default_account(&record_for_account(standard(7))) + .expect_err("a non-default account must be rejected"); + let CoreBridgeError::NonDefaultAccount { index } = err; + assert_eq!( + index, 7, + "the violation must name the offending account index" + ); } } From e8308ed6da62f5fe36928f39914dbdefc210aaa6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:48:12 +0200 Subject: [PATCH 18/24] fix(platform-wallet): persist non-default-account UTXOs under index 0 (warn, don't skip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-account guard added in 8d8724e9 returned an Err that made the event adapter SKIP a non-default-account record's UTXOs. But a core UTXO can legitimately belong to a non-default account (multi-account / CoinJoin), and skipping it undercounts the wallet balance — i.e. loses funds. Since `core_utxos.account_index` only drives cosmetic per-account grouping (storage already writes a const 0 unconditionally), the safe behavior is to never skip and never fail: always persist the UTXO under index 0, and just warn on a non-default account so the approximate grouping is visible. - core_bridge: replace `ensure_default_account` (Err on non-default) with `warn_if_non_default_account` (tracing::warn! only, no value). Revert `build_core_changeset` to infallible (`-> CoreChangeSet`) and the adapter loop to the direct call. Delete the now-unused `CoreBridgeError` enum. - Tests: replace the skip-asserting guard test with end-to-end projections through `build_core_changeset` — `default_account_utxo_persists` and `non_default_account_utxo_persists_under_zero` (the fund-loss regression: a non-default-account UTXO is still projected, never dropped). Co-Authored-By: Claude Opus 4.6 --- .../src/changeset/core_bridge.rs | 192 ++++++++++-------- 1 file changed, 111 insertions(+), 81 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index ce1b1fdcd4..7008f358ea 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -41,30 +41,25 @@ use crate::changeset::changeset::{CoreChangeSet, PlatformWalletChangeSet}; use crate::changeset::traits::PlatformWalletPersistence; use crate::wallet::platform_wallet::PlatformWalletInfo; -/// Error from projecting a [`WalletEvent`] into a [`CoreChangeSet`]. -#[derive(Debug, thiserror::Error)] -pub(crate) enum CoreBridgeError { - /// A UTXO-bearing record belongs to a non-default funds account. The - /// storage layer hardcodes `core_utxos.account_index = 0` (the product - /// uses only the default account), so a non-zero index would be silently - /// mis-grouped. Caught here — the only site with `record.account_type`. - #[error( - "non-default account index {index}: the storage layer assumes the default \ - account (index 0); refusing to persist mis-attributed UTXOs" - )] - NonDefaultAccount { index: u32 }, -} - -/// Single-account guard: every UTXO-bearing record must belong to the -/// default account (index 0). The storage writer hardcodes -/// `core_utxos.account_index = 0`, so a non-default funds account would be -/// silently mis-grouped — fail loud here instead. Identity/provider account -/// types carry no funds index (`AccountType::index() == None`) and never -/// emit `Received`/`Change` UTXOs, so they pass. -fn ensure_default_account(record: &TransactionRecord) -> Result<(), CoreBridgeError> { - match record.account_type.index() { - None | Some(0) => Ok(()), - Some(index) => Err(CoreBridgeError::NonDefaultAccount { index }), +/// Single-account observation. The storage writer hardcodes +/// `core_utxos.account_index = 0` (the product uses only the default +/// account, and that column drives only cosmetic per-account grouping). A +/// UTXO-bearing record owned by a non-default funds account is STILL +/// persisted under index 0 — never skipped, because skipping it would +/// undercount the wallet balance and lose funds. We only `warn!` so the +/// approximate grouping is visible. Identity/provider account types carry +/// no funds index (`AccountType::index() == None`) and never emit +/// `Received`/`Change` UTXOs, so they never warn. +fn warn_if_non_default_account(record: &TransactionRecord) { + if let Some(index) = record.account_type.index() { + if index != 0 { + tracing::warn!( + account_index = index, + txid = %record.txid, + "non-default account UTXO persisted under account_index 0; \ + per-account grouping is approximate" + ); + } } } @@ -105,22 +100,7 @@ where // state (today only `TransactionInstantLocked`, // which checks finality before recording the IS // lock), grab a brief read lock on the manager. - let core = match build_core_changeset(&wallet_manager, &event).await { - Ok(core) => core, - Err(e) => { - // Single-account guard tripped: a UTXO's - // owning account isn't the default (index - // 0). Fail loud and skip — never persist - // mis-attributed UTXOs, and don't corrupt - // the buffer by storing a partial set. - tracing::error!( - wallet_id = %hex::encode(wallet_id), - error = %e, - "core-bridge single-account guard rejected event; not persisting" - ); - continue; - } - }; + let core = build_core_changeset(&wallet_manager, &event).await; if core.is_empty_no_records() { // SyncHeightAdvanced for an unknown wallet, // empty BlockProcessed, etc. — nothing to @@ -164,18 +144,18 @@ where async fn build_core_changeset( wallet_manager: &Arc>>, event: &WalletEvent, -) -> Result { +) -> CoreChangeSet { match event { WalletEvent::TransactionDetected { record, addresses_derived, .. } => { - // Refuse a non-default funds account before projecting UTXOs. - ensure_default_account(record)?; + // Persist regardless of account; warn on a non-default account. + warn_if_non_default_account(record); // Derive UTXO deltas before moving the record into `records` // so the per-record borrows are still live. - Ok(CoreChangeSet { + CoreChangeSet { new_utxos: derive_new_utxos(record), spent_utxos: derive_spent_utxos(record), records: vec![(**record).clone()], @@ -184,7 +164,7 @@ async fn build_core_changeset( // from this delta. See `CoreChangeSet.addresses_derived`. addresses_derived: addresses_derived.clone(), ..CoreChangeSet::default() - }) + } } WalletEvent::TransactionInstantLocked { wallet_id, @@ -196,12 +176,12 @@ async fn build_core_changeset( // wallet has already chain-locked this txid, drop the lock — // chain-lock supersedes IS finality. if is_chain_locked(wallet_manager, wallet_id, txid).await { - return Ok(CoreChangeSet::default()); + return CoreChangeSet::default(); } let mut cs = CoreChangeSet::default(); cs.instant_locks_for_non_final_records .insert(*txid, instant_lock.clone()); - Ok(cs) + cs } WalletEvent::BlockProcessed { height, @@ -213,9 +193,9 @@ async fn build_core_changeset( } => { let mut cs = CoreChangeSet::default(); // Inserted records bring fresh UTXOs and may consume previous - // ones — guard each before projecting. + // ones — warn on a non-default account, but always project. for r in inserted { - ensure_default_account(r)?; + warn_if_non_default_account(r); cs.new_utxos.extend(derive_new_utxos(r)); cs.spent_utxos.extend(derive_spent_utxos(r)); } @@ -232,12 +212,12 @@ async fn build_core_changeset( // Already deduped upstream by `project_derived_addresses`; // `Merge` re-dedupes if multiple events fold together. cs.addresses_derived = addresses_derived.clone(); - Ok(cs) + cs } - WalletEvent::SyncHeightAdvanced { height, .. } => Ok(CoreChangeSet { + WalletEvent::SyncHeightAdvanced { height, .. } => CoreChangeSet { synced_height: Some(*height), ..CoreChangeSet::default() - }), + }, WalletEvent::ChainLockProcessed { chain_lock, .. } => { // The wallet has already promoted the matching records from // `InBlock` to `InChainLockedBlock` by the time this event @@ -263,10 +243,10 @@ async fn build_core_changeset( // The earlier `TransactionsChainlocked`-only signal had a // gap on the "metadata advanced but per-account empty" // path; the new event closes it deterministically. - Ok(CoreChangeSet { + CoreChangeSet { last_applied_chain_lock: Some(chain_lock.clone()), ..CoreChangeSet::default() - }) + } } } } @@ -403,25 +383,52 @@ impl CoreChangeSet { #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use dashcore::blockdata::transaction::Transaction; use dashcore::hashes::Hash; use key_wallet::account::{AccountType, StandardAccountType}; use key_wallet::managed_account::transaction_record::{ - TransactionDirection, TransactionRecord, + OutputDetail, TransactionDirection, TransactionRecord, }; use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; + use key_wallet::WalletCoreBalance; use super::*; - /// Minimal confirmed `TransactionRecord` owned by `account_type`. The - /// single-account guard reads only `record.account_type`, so the tx body - /// and the input/output details are intentionally empty. - fn record_for_account(account_type: AccountType) -> TransactionRecord { + fn standard(index: u32) -> AccountType { + AccountType::Standard { + index, + standard_account_type: StandardAccountType::BIP44Account, + } + } + + /// A throwaway testnet P2PKH address keyed off `seed`. + fn p2pkh(seed: u8) -> dashcore::Address { + use dashcore::address::Payload; + use dashcore::PubkeyHash; + dashcore::Address::new( + dashcore::Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([seed; 20])), + ) + } + + /// A confirmed `TransactionRecord` owned by `account_type` carrying a + /// single `Received` output worth `value` at `addr`, so + /// `derive_new_utxos` yields exactly one UTXO. + fn record_with_received_output( + account_type: AccountType, + addr: &dashcore::Address, + value: u64, + ) -> TransactionRecord { let tx = Transaction { version: 3, lock_time: 0, input: vec![], - output: vec![], + output: vec![dashcore::TxOut { + value, + script_pubkey: addr.script_pubkey(), + }], special_transaction_payload: None, }; TransactionRecord::new( @@ -435,35 +442,58 @@ mod tests { TransactionType::Standard, TransactionDirection::Incoming, Vec::new(), - Vec::new(), - 100, + vec![OutputDetail { + index: 0, + role: OutputRole::Received, + address: Some(addr.clone()), + value, + }], + value as i64, ) } - fn standard(index: u32) -> AccountType { - AccountType::Standard { - index, - standard_account_type: StandardAccountType::BIP44Account, - } + /// Project a `TransactionDetected` for `record` through the real bridge + /// path. `balance`/`account_balances` are unused by the projection. + async fn changeset_for(record: TransactionRecord) -> CoreChangeSet { + let wm = Arc::new(RwLock::new(WalletManager::::new( + key_wallet::Network::Testnet, + ))); + let event = WalletEvent::TransactionDetected { + wallet_id: [0u8; 32], + record: Box::new(record), + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + addresses_derived: Vec::new(), + }; + build_core_changeset(&wm, &event).await } - /// The default funds account (index 0) passes the single-account guard. - #[test] - fn default_account_passes_guard() { - assert!(ensure_default_account(&record_for_account(standard(0))).is_ok()); + /// A default-account (index 0) UTXO is projected into the changeset. + #[tokio::test] + async fn default_account_utxo_persists() { + let addr = p2pkh(0x11); + let cs = changeset_for(record_with_received_output(standard(0), &addr, 500_000)).await; + assert_eq!( + cs.new_utxos.len(), + 1, + "the default-account UTXO must be projected" + ); + assert_eq!(cs.new_utxos[0].value(), 500_000); } - /// A non-default funds account fails loud: the storage layer hardcodes - /// `core_utxos.account_index = 0`, so a non-zero index would otherwise be - /// silently mis-grouped. The error names the offending index. - #[test] - fn non_default_account_fails_guard() { - let err = ensure_default_account(&record_for_account(standard(7))) - .expect_err("a non-default account must be rejected"); - let CoreBridgeError::NonDefaultAccount { index } = err; + /// REGRESSION (fund-loss): a non-default-account (index != 0) UTXO is + /// STILL projected — never dropped. Storage persists it under + /// `account_index 0`; the only cost is approximate per-account grouping + /// (a `warn!` is logged). Dropping it would undercount the balance. + #[tokio::test] + async fn non_default_account_utxo_persists_under_zero() { + let addr = p2pkh(0x22); + let cs = changeset_for(record_with_received_output(standard(7), &addr, 900_000)).await; assert_eq!( - index, 7, - "the violation must name the offending account index" + cs.new_utxos.len(), + 1, + "a non-default-account UTXO must NOT be dropped" ); + assert_eq!(cs.new_utxos[0].value(), 900_000, "funds preserved"); } } From ea0082e625620c91db2b282a71134f405de562a8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:56:53 +0200 Subject: [PATCH 19/24] docs(platform-wallet-storage): drop deleted-table refs from accounts.rs doc comments QA-002: the ACCOUNT_TYPE_LABELS and account_index() doc comments still named the account_address_pools and core_derived_addresses tables, both dropped in the hardcoded-account_index rearch. Both helpers now serve only account_registrations; update the wording to match the post-rearch schema. Comment-only. Co-Authored-By: Claude Opus 4.6 --- .../src/sqlite/schema/accounts.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs index 00149467ec..7a71ad881e 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs @@ -149,10 +149,9 @@ pub fn load_state( Ok(out) } -/// Source of truth for the `account_type` TEXT domain across -/// `account_registrations`, `account_address_pools`, and -/// `core_derived_addresses`, mirroring [`key_wallet::account::AccountType`]. -/// `migrations/V001__initial.rs` interpolates it into each table's +/// Source of truth for the `account_registrations.account_type` TEXT domain, +/// mirroring [`key_wallet::account::AccountType`]. +/// `migrations/V001__initial.rs` interpolates it into the table's /// `CHECK (account_type IN (...))`; `account_type_labels_match_enum` keeps it /// in sync with [`account_type_db_label`]. /// @@ -213,8 +212,7 @@ pub(crate) fn account_type_db_label(at: &key_wallet::account::AccountType) -> &' } /// Numeric account index embedded in an `AccountType`, persisted in the -/// `account_index` column of `account_registrations`, `account_address_pools`, -/// and `core_derived_addresses`. +/// `account_registrations.account_index` column. pub(crate) fn account_index(at: &key_wallet::account::AccountType) -> u32 { use key_wallet::account::AccountType; match at { From 4e4b38c406e30c60b17b18f5f7397df702fa885b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:13:17 +0200 Subject: [PATCH 20/24] docs(platform-wallet-storage): sync SCHEMA.md and stale code comments to retired pool-snapshot design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threads addressed: - 17 & 18 + PROJ-003 (SCHEMA.md): remove `core_derived_addresses` and `account_address_pools` from erDiagram, delete their dedicated table sections (including manifest-fallback / cache-hit prose), and revise the CHECK-constraint inventory from 8 columns / 5 domains down to 4 columns / 4 domains. Table count corrected from 23 → 21 throughout (intro paragraph, Diagram-1 description, and Migrations table). Upstream-enum coupling section updated: AddressPoolType removed, counts fixed (Three→Two, fourth→third, TODO updated accordingly). - 19 (changeset.rs ~966): `account_address_pools` field doc rewrote to describe current usage — registration-time seed only; incremental derivations via `core.addresses_derived` / FFI; storage persister explicitly ignores this field (UTXO attribution hardcoded to index 0). Reference to removed `core_bridge::build_platform_changeset` deleted. - 20 (core_state.rs ~123): CORE_UTXO_ACCOUNT_INDEX comment corrected from "rejected upstream by the core_bridge guard" to the actual behaviour: `warn_if_non_default_account` logs a `warn!` and still persists under 0. - 21 (sqlite_structural_hardening.rs ~119): same "rejected" wording fixed to warn-and-persist-under-0 in the test-function doc comment. No logic, SQL, or test code changed. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- packages/rs-platform-wallet-storage/SCHEMA.md | 113 +++--------------- .../src/sqlite/schema/core_state.rs | 9 +- .../tests/sqlite_structural_hardening.rs | 7 +- .../src/changeset/changeset.rs | 12 +- 4 files changed, 34 insertions(+), 107 deletions(-) diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index 5015f949f0..a47dcec901 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -11,9 +11,9 @@ chain. ## What it stores — and the boundary The persister stores **public** wallet-state material (UTXOs, transactions, -account registrations, address pools, identities, identity public keys, -contacts, asset locks, token balances, DashPay overlays, and -platform-address sync snapshots) in a SQLite database managed by +account registrations, identities, identity public keys, contacts, asset +locks, token balances, DashPay overlays, and platform-address sync +snapshots) in a SQLite database managed by [refinery](https://crates.io/crates/refinery) migrations. **No secrets are stored here.** Mnemonics, seeds, and raw private keys never @@ -37,20 +37,18 @@ Any `meta_*` row whose parent object does not exist — because it was never cre A future garbage-collection pass is expected to reap orphan metadata — rows with no live parent object older than approximately one week — but no such GC is implemented yet. Callers should not rely on orphan metadata persisting forever, nor assume it will be cleaned up promptly. `meta_global` is intentionally parentless and always survives. -The 23 tables are split into five domain diagrams below. `WALLETS` is the root anchor and appears in each diagram. For full column listings see the [Tables](#tables) section. +The 21 tables are split into five domain diagrams below. `WALLETS` is the root anchor and appears in each diagram. For full column listings see the [Tables](#tables) section. ## Diagram 1 — Core / L1 (Bitcoin/Dash layer) -Account registrations, address-pool snapshots, transactions, UTXOs, instant locks, derived addresses, and SPV sync state. +Account registrations, transactions, UTXOs, instant locks, and SPV sync state. ```mermaid erDiagram WALLETS ||--o{ ACCOUNT_REGISTRATIONS : "registers" - WALLETS ||--o{ ACCOUNT_ADDRESS_POOLS : "snapshots" WALLETS ||--o{ CORE_TRANSACTIONS : "records" WALLETS ||--o{ CORE_UTXOS : "owns" WALLETS ||--o{ CORE_INSTANT_LOCKS : "holds" - WALLETS ||--o{ CORE_DERIVED_ADDRESSES : "derives" WALLETS ||--o| CORE_SYNC_STATE : "tracks" CORE_TRANSACTIONS ||--o{ CORE_UTXOS : "spends" @@ -67,14 +65,6 @@ erDiagram BLOB account_xpub_bytes "bincode-encoded AccountRegistrationEntry" } - ACCOUNT_ADDRESS_POOLS { - BLOB wallet_id PK - TEXT account_type PK - INTEGER account_index PK - TEXT pool_type PK "external | internal | absent | absent_hardened" - BLOB snapshot_blob "bincode-encoded AccountAddressPoolEntry" - } - CORE_TRANSACTIONS { BLOB wallet_id PK BLOB txid PK "32-byte Txid" @@ -102,16 +92,6 @@ erDiagram BLOB islock_blob "bincode-encoded InstantLock" } - CORE_DERIVED_ADDRESSES { - BLOB wallet_id PK - TEXT account_type PK - INTEGER account_index PK "owning account; also the value the read returns" - TEXT pool_type PK "external | internal | absent | absent_hardened" - INTEGER derivation_index PK - TEXT address UK "bech32 / Base58 address string" - INTEGER used "0 | 1" - } - CORE_SYNC_STATE { BLOB wallet_id PK "one row per wallet" INTEGER last_processed_height "NULL until first block processed" @@ -365,14 +345,6 @@ lookups without blob decoding. - PK: `(wallet_id, account_type, account_index)`. - FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. -### `account_address_pools` - -Address-pool snapshot per `(wallet, account, pool_type)`. `pool_type` is -one of `external`, `internal`, `absent`, `absent_hardened`. - -- PK: `(wallet_id, account_type, account_index, pool_type)`. -- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. - ### `core_transactions` One row per transaction the wallet has seen. `height`, `block_hash`, and @@ -402,50 +374,6 @@ finalized. Rows are removed when the transaction becomes confirmed. - PK: `(wallet_id, txid)`. - FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. -### `core_derived_addresses` - -A live-fed indexed read-cache the UTXO writer joins to resolve a UTXO's -`account_index` by address. The authoritative manifest is -`account_address_pools` (kept complete and in-band by the -`core_bridge` emitter); this table is the fast B-tree probe in front of it. -Fed by exactly one source: - -- **Live `addresses_derived` events** — written before UTXOs in the same - transaction so the writer sees fresh rows. - -UTXO resolution for an unspent UTXO: - -1. **Cache hit** — resolve from this table. -2. **Cache miss, manifest hit** — fall back to `account_address_pools` - (the in-band snapshot is applied earlier in the same tx). Resolved. -3. **Miss in both** — a genuinely undeclared address (not ours, or an SPV - gap-limit edge). The writer **skips** it (with a `warn`) so one - unresolvable row never aborts a whole flush; its balance re-warms once - the address is later derived. - -(The spent-only synthetic-row path is exempt: a spent row uses an inert -`account_index` placeholder and is excluded from reads.) - -A live `addresses_derived` entry whose address is absent from the manifest -is a **fatal** `DerivedIndexInvariantViolated` — the emitter must attach -the pool snapshot in-band with every derivation, so this can only fire on -an emitter bug, never on a benign gap. - -> The non-ECDSA pool gap (BLS/EdDSA addresses are dropped from the event -> projection, so they never produce an `addresses_derived` entry) cannot -> manifest here: only ECDSA Standard/CoinJoin External/Internal addresses -> are ever classified `Received`/`Change`, so a non-ECDSA address can never -> be a `new_utxos` UTXO address. This is an upstream classifier property -> (`key-wallet` `account_checker`), not enforceable at the storage layer. - -- PK: `(wallet_id, account_type, account_index, pool_type, derivation_index)` — the BIP32 - leaf identity (one row per derived address). -- `UNIQUE(wallet_id, address)` — the read-index invariant (one - account_index per address); its index also backs the address lookup, so - no separate index is needed. `address` is a derived attribute, never a - key, so every collision surfaces loud. -- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. - ### `core_sync_state` One row per wallet, holding monotonically-advancing SPV sync watermarks. @@ -626,28 +554,24 @@ before the address exists. ## Enum-domain CHECK constraints -Eight TEXT columns carry a `CHECK (col IN (...))` across five enum -domains — `account_type` is reused in three tables. The IN-list is built -at migration time from `pub(crate) const *_LABELS` arrays declared next -to each writer function. Four domains mirror an upstream Rust enum; the -fifth (`contacts.state`) is a synthetic lifecycle label naming which -`ContactChangeSet` slot a row came from: +Four TEXT columns carry a `CHECK (col IN (...))` across four enum +domains. The IN-list is built at migration time from +`pub(crate) const *_LABELS` arrays declared next to each writer function. +Three domains mirror an upstream Rust enum; the fourth (`contacts.state`) +is a synthetic lifecycle label naming which `ContactChangeSet` slot a row +came from: | Table | Column | Source-of-truth const | |---|---|---| | `wallets` | `network` | `sqlite::schema::wallets::NETWORK_LABELS` | | `account_registrations` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` | -| `account_address_pools` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` | -| `account_address_pools` | `pool_type` | `sqlite::schema::accounts::POOL_TYPE_LABELS` | -| `core_derived_addresses` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` | -| `core_derived_addresses` | `pool_type` | `sqlite::schema::accounts::POOL_TYPE_LABELS` | | `asset_locks` | `status` | `sqlite::schema::asset_locks::ASSET_LOCK_STATUS_LABELS` | | `contacts` | `state` | `sqlite::schema::contacts::CONTACT_STATE_LABELS` | The const arrays are the single source of truth shared by the writer mapping functions (`network_to_str`, `account_type_db_label`, -`pool_type_db_label`, `status_str`, `contact_state_db_label`) and the -migration's CHECK clauses. +`status_str`, `contact_state_db_label`) and the migration's CHECK +clauses. Per-module `*_labels_match_enum` unit tests enforce set-equality between each const and the writer's codomain — drift (a renamed/added upstream variant) fails the test rather than landing as silent garbage @@ -656,10 +580,9 @@ in this document; the source files are canonical. ### Upstream-enum coupling -Three of the persisted enums live in the external `rust-dashcore` -crate (`key_wallet::Network`, `key_wallet::account::AccountType`, -`key_wallet::managed_account::address_pool::AddressPoolType`); the -fourth (`platform_wallet::wallet::asset_lock::tracked::AssetLockStatus`) +Two of the persisted enums live in the external `rust-dashcore` +crate (`key_wallet::Network`, `key_wallet::account::AccountType`); the +third (`platform_wallet::wallet::asset_lock::tracked::AssetLockStatus`) is in-tree and carries a `# Schema coupling` rustdoc block. Because the upstream definitions cannot be edited from this repository, @@ -676,7 +599,7 @@ mechanisms working together: TODO(rust-dashcore): once the upstream `key_wallet` crate is vendored or the project gains push access there, mirror the in-tree -`AssetLockStatus` `# Schema coupling` doc block on the three upstream +`AssetLockStatus` `# Schema coupling` doc block on the two upstream enums so a developer editing them upstream sees the constraint without having to grep this repo. @@ -720,4 +643,4 @@ having to grep this repo. | Version | File | Description | |---|---|---| -| V001 | `V001__initial.rs` | Full schema: all 23 tables (including the six `meta_*` per-object metadata tables), every index, and six triggers (`setnull_core_utxos_on_tx_delete` + the five `meta_*` soft-cascade triggers) | +| V001 | `V001__initial.rs` | Full schema: all 21 tables (including the six `meta_*` per-object metadata tables), every index, and six triggers (`setnull_core_utxos_on_tx_delete` + the five `meta_*` soft-cascade triggers) | diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 567b2e0998..4d725b99d4 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -121,10 +121,11 @@ const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ spent = excluded.spent"; /// Account index written for every `core_utxos` row. The product uses only -/// the default account (index 0); a non-default funds account is rejected -/// upstream by the `core_bridge` single-account guard, so the writer never -/// resolves per-UTXO attribution. The one reader (`list_unspent_utxos` -/// per-account grouping) groups everything under 0. +/// the default account (index 0); a non-default funds account causes +/// `core_bridge::warn_if_non_default_account` to emit a `warn!` log but +/// the record is still persisted under index 0 (dropping it would +/// undercount the balance and lose funds). The one reader +/// (`list_unspent_utxos` per-account grouping) groups everything under 0. const CORE_UTXO_ACCOUNT_INDEX: i64 = 0; /// Upsert one `core_utxos` row. `account_index` is the hardcoded default diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs b/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs index 2f1babb8f4..9c79b36baf 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs @@ -118,9 +118,10 @@ fn make_utxo(addr: &Address, vout: u32, value: u64) -> Utxo { /// Every `core_utxos` row is written with the hardcoded default /// `account_index = 0` (the product uses only the default account; a -/// non-default account is rejected upstream by the `core_bridge` guard), -/// so the per-account grouping reader buckets every unspent UTXO — at any -/// address — under account 0. +/// non-default account causes `core_bridge::warn_if_non_default_account` +/// to log a `warn!` but still persists the record under index 0 to avoid +/// fund loss), so the per-account grouping reader buckets every unspent +/// UTXO — at any address — under account 0. #[test] fn utxos_bucket_under_default_account_index_zero() { use platform_wallet_storage::sqlite::schema::core_state; diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 7b0839cf1e..cc8b705df9 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -963,11 +963,13 @@ pub struct PlatformWalletChangeSet { /// the merge policy (plain `Vec::extend`, dedup is the apply-side /// caller's job). pub account_registrations: Vec, - /// Full address-pool snapshots: emitted at wallet create and, in-band, - /// on every block that derives new pool addresses (the - /// `core.addresses_derived` delta). Each entry is the whole current - /// pool, not just the new index. See [`AccountAddressPoolEntry`] for - /// the merge policy and `core_bridge::build_platform_changeset`. + /// Full address-pool snapshots: emitted once at wallet registration. + /// Incremental derivations are delivered via `core.addresses_derived` + /// (the `WalletEvent` bus / FFI path); no per-block in-band pool + /// snapshot is written. The storage persister intentionally ignores this + /// field (UTXO attribution is hardcoded to account 0); non-storage + /// consumers (e.g. the iOS FFI address registry) may still read it. + /// See [`AccountAddressPoolEntry`] for the merge policy. pub account_address_pools: Vec, /// Shielded sub-wallet deltas: per-subwallet decrypted notes, /// spent marks, sync watermarks, nullifier checkpoints. The From e553015f4f3cb0de604d92815c824cb1179a741f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:56:58 +0200 Subject: [PATCH 21/24] fix(platform-wallet): extend AddressPool depth on rehydration to cover deep-index UTXOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After restart-in-place, watch-only rehydration eagerly derived only the gap-limit window (indices 0..=29) per chain, but persisted UTXOs can sit at deeper derivation indices (e.g. internal/change idx 300). The wallet total stayed exact, yet the per-address view undercounted because those deep addresses were never derived into their pools. `apply_persisted_core_state` now resolves each restored UTXO address back to its (chain, index) by forward-deriving from the keyless account xpub, then extends each chain's pool exactly to its own deepest restored index and refills the gap window beyond — mirroring the sync path's mark_used -> maintain_gap_limit shape. Throwaway probe pools drive the index search so the real pools are never over-derived across chains, and a bounded scan caps pathological/foreign addresses (re-warmed on the next full sync). The schema retains no derivation index (account_index is hardcoded 0, and the depth tables were retired), so the address is the only join key; the xpub is recovered from the keyless manifest by account type. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../tests/sqlite_core_state_reader.rs | 31 ++- .../rs-platform-wallet/src/manager/load.rs | 8 +- .../src/manager/rehydrate.rs | 258 +++++++++++++++++- 3 files changed, 287 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs index ee23b15883..7b1bb5ccaf 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs @@ -15,11 +15,24 @@ use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; use key_wallet::Utxo; use platform_wallet::changeset::{ - CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, + AccountRegistrationEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, }; use platform_wallet_storage::sqlite::schema::core_state; use platform_wallet_storage::WalletStorageError; +/// Keyless account manifest the rehydration path resolves xpubs from. +fn manifest_of(wallet: &Wallet) -> Vec { + wallet + .accounts + .all_accounts() + .into_iter() + .map(|a| AccountRegistrationEntry { + account_type: a.account_type, + account_xpub: a.account_xpub, + }) + .collect() +} + fn reopen(path: &std::path::Path) -> platform_wallet_storage::SqlitePersister { platform_wallet_storage::SqlitePersister::open( platform_wallet_storage::SqlitePersisterConfig::new(path), @@ -102,8 +115,12 @@ fn rt2_nonzero_balance_survives_reopen() { // rehydration path) and assert the wallet balance is the persisted // amount — NOT a silent zero. let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); - platform_wallet::manager::rehydrate::apply_persisted_core_state(&mut info, &core) - .expect("BIP44 reconstruction must not error"); + platform_wallet::manager::rehydrate::apply_persisted_core_state( + &mut info, + &manifest_of(&wallet), + &core, + ) + .expect("BIP44 reconstruction must not error"); let bal = WalletInfoInterface::balance(&info); let total = bal.confirmed() + bal.unconfirmed() + bal.immature() + bal.locked(); assert_eq!( @@ -258,8 +275,12 @@ fn f2_no_bip44_wallet_nonzero_balance_survives_reopen() { assert_eq!(core.new_utxos.len(), 1); let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); - platform_wallet::manager::rehydrate::apply_persisted_core_state(&mut info, &core) - .expect("CoinJoin-only reconstruction must not error"); + platform_wallet::manager::rehydrate::apply_persisted_core_state( + &mut info, + &manifest_of(&wallet), + &core, + ) + .expect("CoinJoin-only reconstruction must not error"); let bal = WalletInfoInterface::balance(&info); let total = bal.confirmed() + bal.unconfirmed() + bal.immature() + bal.locked(); assert_eq!( diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 1d8baf163b..6f4c118b03 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -120,9 +120,11 @@ impl PlatformWalletManager

{ // UTXOs but no funds account hard-fails here rather than // reconstructing a silent zero balance. let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, birth_height); - if let Err(e) = - super::rehydrate::apply_persisted_core_state(&mut wallet_info, &core_state) - { + if let Err(e) = super::rehydrate::apply_persisted_core_state( + &mut wallet_info, + &account_manifest, + &core_state, + ) { load_error = Some(e); break 'load; } diff --git a/packages/rs-platform-wallet/src/manager/rehydrate.rs b/packages/rs-platform-wallet/src/manager/rehydrate.rs index d5fca89a20..732d0d2367 100644 --- a/packages/rs-platform-wallet/src/manager/rehydrate.rs +++ b/packages/rs-platform-wallet/src/manager/rehydrate.rs @@ -130,6 +130,7 @@ pub(super) fn build_watch_only_wallet( /// This never logs and never touches key material. pub fn apply_persisted_core_state( wallet_info: &mut ManagedWalletInfo, + manifest: &[AccountRegistrationEntry], core: &crate::changeset::CoreChangeSet, ) -> Result<(), PlatformWalletError> { use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; @@ -164,9 +165,12 @@ pub fn apply_persisted_core_state( .next() { Some(account) => { - for utxo in unspent { - account.utxos.insert(utxo.outpoint, utxo.clone()); + for utxo in &unspent { + account.utxos.insert(utxo.outpoint, (*utxo).clone()); } + // Eager derivation covers only `0..=gap_limit`; extend each + // chain to cover restored UTXOs at deeper indices. + extend_pools_for_restored_utxos(account, manifest, &unspent); } None => { return Err(PlatformWalletError::RehydrationTopologyUnsupported { @@ -184,6 +188,127 @@ pub fn apply_persisted_core_state( Ok(()) } +/// Upper bound on forward derivation while resolving a restored UTXO +/// address to its derivation index. Addresses that don't resolve within +/// this many indices (e.g. they belong to a different funds account whose +/// UTXOs were routed here, or are corrupt) are left for the next full +/// rescan to re-warm — generous enough to cover any realistic per-account +/// derivation depth. The common (single funds account) path terminates at +/// the true high-water mark well before this and never reaches the cap. +const MAX_REHYDRATION_DERIVATION_INDEX: u32 = 10_000; + +/// Extend `account`'s address pools so every restored UTXO address is +/// derived at its exact `(chain, index)` slot, then refill the gap window +/// beyond — mirroring the sync path's `mark_used` → `maintain_gap_limit` +/// shape. Each chain is extended only to its own deepest restored index; +/// addresses that don't resolve against this account's xpub are skipped +/// (they re-warm on the next full sync). Never touches key material — the +/// xpub is the keyless account public key. +fn extend_pools_for_restored_utxos( + account: &mut key_wallet::managed_account::ManagedCoreFundsAccount, + manifest: &[AccountRegistrationEntry], + restored: &[&key_wallet::Utxo], +) { + use key_wallet::managed_account::address_pool::{AddressPool, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use std::collections::{BTreeSet, HashSet}; + + // The funds account carries no key material; recover its watch-only + // xpub from the keyless manifest by account type. + let account_type = account.managed_account_type().to_account_type(); + let Some(account_xpub) = manifest + .iter() + .find(|e| e.account_type == account_type) + .map(|e| e.account_xpub) + else { + return; + }; + let key_source = KeySource::Public(account_xpub); + + // Restored addresses not already covered by the eager derivation. + let mut unresolved: HashSet = { + let pools = account.managed_account_type().address_pools(); + restored + .iter() + .map(|u| u.address.clone()) + .filter(|addr| !pools.iter().any(|p| p.contains_address(addr))) + .collect() + }; + if unresolved.is_empty() { + return; + } + + // Probe pools mirror each real pool's chain so the index search derives + // into throwaway state (real pools keep their own exact depth). Probe + // order matches `address_pools_mut()`, so the later positional zip holds. + let mut probes: Vec<(AddressPool, BTreeSet)> = account + .managed_account_type() + .address_pools() + .iter() + .map(|p| { + ( + AddressPool::new_without_generation( + p.base_path.clone(), + p.pool_type, + p.gap_limit, + p.network, + ), + BTreeSet::new(), + ) + }) + .collect(); + + // Lockstep forward across all chains until every restored address is + // located (the true high-water mark) or the safety bound is hit. + let mut index: u32 = 0; + while !unresolved.is_empty() && index <= MAX_REHYDRATION_DERIVATION_INDEX { + for (probe, matched) in probes.iter_mut() { + if let Some(addr) = ensure_derived(probe, &key_source, index) { + if unresolved.remove(&addr) { + matched.insert(index); + } + } + } + index = index.saturating_add(1); + } + + // Apply each chain's resolved depth to its real pool: derive up to the + // deepest restored index, mark the restored slots used, then maintain + // the gap window beyond the highest used index. + let mut pools = account.managed_account_type_mut().address_pools_mut(); + for (i, (_, matched)) in probes.iter().enumerate() { + let Some(&deepest) = matched.iter().next_back() else { + continue; + }; + let pool = &mut *pools[i]; + ensure_derived(pool, &key_source, deepest); + for &idx in matched { + pool.mark_index_used(idx); + } + let _ = pool.maintain_gap_limit(&key_source); + } +} + +/// Ensure `pool` has derived through `index` (generating only the missing +/// tail), and return that index's address. `None` only on a derivation +/// error. +fn ensure_derived( + pool: &mut key_wallet::managed_account::address_pool::AddressPool, + key_source: &key_wallet::managed_account::address_pool::KeySource, + index: u32, +) -> Option { + let needs_more = match pool.highest_generated { + Some(highest) => highest < index, + None => true, + }; + if needs_more { + let start = pool.highest_generated.map(|h| h + 1).unwrap_or(0); + pool.generate_addresses(index - start + 1, key_source, true) + .ok()?; + } + pool.address_at_index(index) +} + #[cfg(test)] mod tests { use super::*; @@ -235,4 +360,133 @@ mod tests { .expect_err("empty manifest must be MissingManifest"); assert!(matches!(err, RehydrateRowError::MissingManifest)); } + + /// Regression: after restart-in-place the watch-only pools eagerly + /// cover only `0..=gap_limit`, but persisted UTXOs can sit at deeper + /// derivation indices (e.g. internal idx 300). Rehydration must extend + /// each chain's pool to its deepest restored index so the per-address + /// view reconciles with the wallet total instead of undercounting. + #[test] + fn rehydration_extends_pools_to_cover_deep_index_utxos() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + use std::collections::HashSet; + + let seed = [7u8; 64]; + let wallet = Wallet::from_seed_bytes( + seed, + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + + // Mint the watch-only skeleton (pools cover only the eager gap + // window) and resolve the first funds account's keyless xpub. + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + let funds_type = wallet_info + .accounts + .all_funding_accounts() + .first() + .unwrap() + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("funds account xpub"); + + // Derive addresses on each chain from the same account xpub the + // pools use; the deep ones land past the eager window. + let derive = |pool_type, index: u32| -> Address { + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + pool_type, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(index + 1, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(index).unwrap() + }; + let shallow_recv = derive(AddressPoolType::External, 3); + let deep_recv = derive(AddressPoolType::External, 35); + let deep_change = derive(AddressPoolType::Internal, 300); + + let utxo = |addr: Address, value: u64, n: u8| Utxo { + outpoint: OutPoint { + txid: Txid::from([n; 32]), + vout: 0, + }, + txout: TxOut { + value, + script_pubkey: addr.script_pubkey(), + }, + address: addr, + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + let new_utxos = vec![ + utxo(shallow_recv, 1_000, 1), + utxo(deep_recv.clone(), 20_000, 2), + utxo(deep_change.clone(), 300_000, 3), + ]; + let expected_total: u64 = new_utxos.iter().map(|u| u.value()).sum(); + let core = crate::changeset::CoreChangeSet { + new_utxos, + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + + // The wallet total is exact regardless (a sum over the UTXO set). + assert_eq!(wallet_info.balance.total(), expected_total); + + // The per-address view joins pool addresses to UTXOs; every + // restored UTXO address must now be derived into a pool so the + // view reconciles to the total (the regression hid the deep ones). + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pool_addresses: HashSet

= funds + .managed_account_type() + .address_pools() + .iter() + .flat_map(|p| p.addresses.values().map(|i| i.address.clone())) + .collect(); + let visible: u64 = funds + .utxos + .values() + .filter(|u| pool_addresses.contains(&u.address)) + .map(|u| u.value()) + .sum(); + assert_eq!( + visible, expected_total, + "deep-index UTXO addresses must be derived into their pools" + ); + + // Each deep address resolves to its exact derivation slot, not + // merely some slot — proving correct (chain, index) attribution. + let pools = funds.managed_account_type().address_pools(); + let external = pools.iter().find(|p| p.is_external()).unwrap(); + let internal = pools.iter().find(|p| p.is_internal()).unwrap(); + assert_eq!(external.address_at_index(35).as_ref(), Some(&deep_recv)); + assert_eq!(internal.address_at_index(300).as_ref(), Some(&deep_change)); + } } From bef9bda5c4582bf45f16499301bd572e5df41310 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:42:03 +0200 Subject: [PATCH 22/24] fix(platform-wallet): bound rehydration pool-extension per-chain and log unresolved addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA-001/002: replace the flat 10k lockstep scan with independent per-chain loops. Each chain stops once no unresolved address resolves within a gap_limit-sized window past the deepest matched index (MAX_REHYDRATION_DERIVATION_INDEX = 10_000 remains the hard ceiling). Unresolved UTXO addresses (foreign key / multi-account mismatch) are counted and emitted via tracing::warn! instead of silently skipped. Total balance is unaffected; per-address visibility re-warms on next sync. QA-003: add rehydration_coinjoin_single_pool_deep_index test — verifies extend_pools_for_restored_utxos handles single-pool (CoinJoin/External) topology at a deep index (idx 30, just past the eager window). Narrows function doc claim to "tested with Standard BIP44 + CoinJoin". QA-004: add rehydration_unresolvable_address_is_deferred_not_panics test — asserts no panic/hang, total balance exact, foreign address deferred. QA-005: extend rehydration_extends_pools_to_cover_deep_index_utxos with an assertion that maintain_gap_limit fills beyond the deepest restored index (external.highest_generated >= deepest + gap_limit). Adjust test indices to work with the smarter scan bound (gap_limit=30): external 30 (anchor) + 50 (deep), internal 30. QA-006: document why chains with no resolved UTXOs skip mark_index_used and maintain_gap_limit (eager gap window already intact; no-op anyway). QA-007: soften "mirroring the sync path" to "following the … sequence". PROJ-003: rename manifest_of → manifest_for in sqlite_core_state_reader.rs for consistency with the same helper in rehydrate.rs unit tests. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../tests/sqlite_core_state_reader.rs | 6 +- .../src/manager/rehydrate.rs | 414 ++++++++++++++++-- 2 files changed, 387 insertions(+), 33 deletions(-) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs index 7b1bb5ccaf..1e619ee46b 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs @@ -21,7 +21,7 @@ use platform_wallet_storage::sqlite::schema::core_state; use platform_wallet_storage::WalletStorageError; /// Keyless account manifest the rehydration path resolves xpubs from. -fn manifest_of(wallet: &Wallet) -> Vec { +fn manifest_for(wallet: &Wallet) -> Vec { wallet .accounts .all_accounts() @@ -117,7 +117,7 @@ fn rt2_nonzero_balance_survives_reopen() { let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); platform_wallet::manager::rehydrate::apply_persisted_core_state( &mut info, - &manifest_of(&wallet), + &manifest_for(&wallet), &core, ) .expect("BIP44 reconstruction must not error"); @@ -277,7 +277,7 @@ fn f2_no_bip44_wallet_nonzero_balance_survives_reopen() { let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); platform_wallet::manager::rehydrate::apply_persisted_core_state( &mut info, - &manifest_of(&wallet), + &manifest_for(&wallet), &core, ) .expect("CoinJoin-only reconstruction must not error"); diff --git a/packages/rs-platform-wallet/src/manager/rehydrate.rs b/packages/rs-platform-wallet/src/manager/rehydrate.rs index 732d0d2367..dcdb860d28 100644 --- a/packages/rs-platform-wallet/src/manager/rehydrate.rs +++ b/packages/rs-platform-wallet/src/manager/rehydrate.rs @@ -197,13 +197,22 @@ pub fn apply_persisted_core_state( /// the true high-water mark well before this and never reaches the cap. const MAX_REHYDRATION_DERIVATION_INDEX: u32 = 10_000; -/// Extend `account`'s address pools so every restored UTXO address is +/// Extend `account`'s address pools so every resolved UTXO address is /// derived at its exact `(chain, index)` slot, then refill the gap window -/// beyond — mirroring the sync path's `mark_used` → `maintain_gap_limit` -/// shape. Each chain is extended only to its own deepest restored index; -/// addresses that don't resolve against this account's xpub are skipped -/// (they re-warm on the next full sync). Never touches key material — the -/// xpub is the keyless account public key. +/// beyond — following the sync path's `mark_used` → `maintain_gap_limit` +/// sequence. Each chain is scanned independently, stopping once no +/// unresolved address matches within a `gap_limit`-sized window past the +/// deepest resolved index; [`MAX_REHYDRATION_DERIVATION_INDEX`] is the +/// hard ceiling. Addresses not derivable from this account's xpub (foreign +/// keys, multi-account mismatch) are counted and logged via +/// `tracing::warn!`; they re-warm on the next full sync. +/// +/// Tested with Standard BIP44 topology (External + Internal pools) and +/// CoinJoin topology (single External pool). The per-chain probe loop has +/// no topology-specific branches, so Absent and AbsentHardened pool types +/// follow the same code path with a different relative derivation path. +/// +/// Never touches key material — the xpub is the keyless account public key. fn extend_pools_for_restored_utxos( account: &mut key_wallet::managed_account::ManagedCoreFundsAccount, manifest: &[AccountRegistrationEntry], @@ -239,8 +248,7 @@ fn extend_pools_for_restored_utxos( } // Probe pools mirror each real pool's chain so the index search derives - // into throwaway state (real pools keep their own exact depth). Probe - // order matches `address_pools_mut()`, so the later positional zip holds. + // into throwaway state (real pools keep their own exact depth). let mut probes: Vec<(AddressPool, BTreeSet)> = account .managed_account_type() .address_pools() @@ -258,23 +266,63 @@ fn extend_pools_for_restored_utxos( }) .collect(); - // Lockstep forward across all chains until every restored address is - // located (the true high-water mark) or the safety bound is hit. - let mut index: u32 = 0; - while !unresolved.is_empty() && index <= MAX_REHYDRATION_DERIVATION_INDEX { - for (probe, matched) in probes.iter_mut() { + // Per-chain scan: each chain advances independently. We stop a chain + // once no unresolved address resolves within gap_limit indices past the + // deepest match on that chain (preventing a full 10k scan when the UTXO + // set contains foreign addresses). MAX_REHYDRATION_DERIVATION_INDEX is + // the hard ceiling regardless. + for (probe, matched) in probes.iter_mut() { + if unresolved.is_empty() { + break; + } + let chain_gap = probe.gap_limit; + let mut deepest_resolved: Option = None; + let mut index: u32 = 0; + + loop { + // Horizon: gap_limit past the deepest match, or the initial + // gap_limit window when nothing has resolved yet. + let horizon = deepest_resolved + .map(|d| d.saturating_add(chain_gap)) + .unwrap_or(chain_gap); + + if index > horizon || index > MAX_REHYDRATION_DERIVATION_INDEX { + break; + } + if let Some(addr) = ensure_derived(probe, &key_source, index) { if unresolved.remove(&addr) { matched.insert(index); + deepest_resolved = Some(index); } } + + if unresolved.is_empty() { + break; + } + + index = index.saturating_add(1); } - index = index.saturating_add(1); + } + + // Addresses still unresolved are not derivable from this account's xpub + // (foreign key, routed from a different funds account, or corrupt). + // They re-warm on the next full sync; total balance is exact regardless. + if !unresolved.is_empty() { + tracing::warn!( + unresolved_count = unresolved.len(), + "rehydration: {} UTXO address(es) unresolved for this account \ + xpub — will re-warm on next sync; balance total is exact", + unresolved.len(), + ); } // Apply each chain's resolved depth to its real pool: derive up to the - // deepest restored index, mark the restored slots used, then maintain + // deepest resolved index, mark the resolved slots used, then maintain // the gap window beyond the highest used index. + // Chains with no resolved UTXOs are skipped — their eager gap window + // (from initialization) already covers the correct depth, and calling + // maintain_gap_limit without any used indices would be a no-op anyway. let mut pools = account.managed_account_type_mut().address_pools_mut(); for (i, (_, matched)) in probes.iter().enumerate() { let Some(&deepest) = matched.iter().next_back() else { @@ -363,9 +411,19 @@ mod tests { /// Regression: after restart-in-place the watch-only pools eagerly /// cover only `0..=gap_limit`, but persisted UTXOs can sit at deeper - /// derivation indices (e.g. internal idx 300). Rehydration must extend - /// each chain's pool to its deepest restored index so the per-address - /// view reconciles with the wallet total instead of undercounting. + /// derivation indices. Rehydration must extend each chain's pool to its + /// deepest restored index so the per-address view reconciles with the + /// wallet total instead of undercounting. + /// + /// Index layout (gap_limit = 30): + /// - external idx 3: within eager window (not in `unresolved`), balance included + /// - external idx 30: first index past eager window; anchors the initial scan + /// window and extends it to idx 60 + /// - external idx 50: within extended window (50 < 60), resolved + /// - internal idx 30: within initial scan window, resolved + /// + /// QA-003: Standard BIP44 topology (External + Internal pools) is exercised. + /// QA-005: asserts that maintain_gap_limit fills beyond the deepest resolved. #[test] fn rehydration_extends_pools_to_cover_deep_index_utxos() { use dashcore::blockdata::transaction::txout::TxOut; @@ -404,7 +462,8 @@ mod tests { .expect("funds account xpub"); // Derive addresses on each chain from the same account xpub the - // pools use; the deep ones land past the eager window. + // pools use; `base_path` is record-keeping only and does not affect + // the derived address, so DerivationPath::master() is fine here. let derive = |pool_type, index: u32| -> Address { let mut p = AddressPool::new_without_generation( DerivationPath::master(), @@ -416,9 +475,18 @@ mod tests { .unwrap(); p.address_at_index(index).unwrap() }; + + // idx 3: within eager window (0..=29) — covered by init, NOT in + // unresolved. Contributes to balance but needs no pool extension. let shallow_recv = derive(AddressPoolType::External, 3); - let deep_recv = derive(AddressPoolType::External, 35); - let deep_change = derive(AddressPoolType::Internal, 300); + // idx 30: first past eager window; falls in initial scan window + // (horizon = gap_limit = 30 on a chain with no prior matches). + // Anchors the external probe and extends horizon to 60. + let mid_recv = derive(AddressPoolType::External, 30); + // idx 50: within the extended window (50 < 30+30=60), resolved. + let deep_recv = derive(AddressPoolType::External, 50); + // idx 30: within the internal chain's initial scan window (<=30). + let deep_change = derive(AddressPoolType::Internal, 30); let utxo = |addr: Address, value: u64, n: u8| Utxo { outpoint: OutPoint { @@ -439,8 +507,9 @@ mod tests { }; let new_utxos = vec![ utxo(shallow_recv, 1_000, 1), - utxo(deep_recv.clone(), 20_000, 2), - utxo(deep_change.clone(), 300_000, 3), + utxo(mid_recv.clone(), 10_000, 2), + utxo(deep_recv.clone(), 20_000, 3), + utxo(deep_change.clone(), 300_000, 4), ]; let expected_total: u64 = new_utxos.iter().map(|u| u.value()).sum(); let core = crate::changeset::CoreChangeSet { @@ -456,8 +525,7 @@ mod tests { assert_eq!(wallet_info.balance.total(), expected_total); // The per-address view joins pool addresses to UTXOs; every - // restored UTXO address must now be derived into a pool so the - // view reconciles to the total (the regression hid the deep ones). + // resolved UTXO address must now be derived into a pool. let funds = wallet_info .accounts .all_funding_accounts() @@ -478,15 +546,301 @@ mod tests { .sum(); assert_eq!( visible, expected_total, - "deep-index UTXO addresses must be derived into their pools" + "all UTXO addresses (including deep-index) must be derived into their pools" ); - // Each deep address resolves to its exact derivation slot, not - // merely some slot — proving correct (chain, index) attribution. + // Each deep address resolves to its exact derivation slot. let pools = funds.managed_account_type().address_pools(); let external = pools.iter().find(|p| p.is_external()).unwrap(); let internal = pools.iter().find(|p| p.is_internal()).unwrap(); - assert_eq!(external.address_at_index(35).as_ref(), Some(&deep_recv)); - assert_eq!(internal.address_at_index(300).as_ref(), Some(&deep_change)); + assert_eq!(external.address_at_index(30).as_ref(), Some(&mid_recv)); + assert_eq!(external.address_at_index(50).as_ref(), Some(&deep_recv)); + assert_eq!(internal.address_at_index(30).as_ref(), Some(&deep_change)); + + // QA-005: maintain_gap_limit must refill BEYOND the deepest restored + // index so the gap window is actually exercised, not just the restore. + // Deepest external resolved = idx 50; gap window must reach >= 50+30=80. + let expected_min_gen = 50 + DEFAULT_EXTERNAL_GAP_LIMIT; + assert!( + external.highest_generated >= Some(expected_min_gen), + "maintain_gap_limit must extend external pool to >= {} (got {:?})", + expected_min_gen, + external.highest_generated, + ); + } + + /// QA-004: a UTXO whose address is not derivable from this account's + /// xpub (foreign key, multi-account mismatch) must not cause a panic or + /// hang. The total balance is exact (the UTXO is in the set regardless), + /// but the foreign address is absent from the pool so per-address + /// visibility is reduced. `tracing::warn!` fires for the unresolved count. + #[test] + fn rehydration_unresolvable_address_is_deferred_not_panics() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + use std::collections::HashSet; + + let seed = [13u8; 64]; + let wallet = Wallet::from_seed_bytes( + seed, + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let funds_type = wallet_info + .accounts + .all_funding_accounts() + .first() + .unwrap() + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("funds account xpub"); + + // Normal UTXO at external index 3 (within eager window, pool-visible). + let normal_addr = { + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(4, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(3).unwrap() + }; + + // Foreign address: derive from a completely different wallet seed so + // it cannot be resolved from this wallet's xpub. + let foreign_addr = { + let fw = Wallet::from_seed_bytes( + [99u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let fw_info = ManagedWalletInfo::from_wallet(&fw, 1); + fw_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap() + .managed_account_type() + .address_pools() + .first() + .unwrap() + .address_at_index(0) + .unwrap() + }; + assert_ne!( + normal_addr, foreign_addr, + "test fixture: foreign address must differ from normal" + ); + + let utxo = |addr: Address, value: u64, n: u8| Utxo { + outpoint: OutPoint { + txid: Txid::from([n; 32]), + vout: 0, + }, + txout: TxOut { + value, + script_pubkey: addr.script_pubkey(), + }, + address: addr, + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + + let normal_val = 100_000u64; + let foreign_val = 200_000u64; + let expected_total = normal_val + foreign_val; + + let core = crate::changeset::CoreChangeSet { + new_utxos: vec![ + utxo(normal_addr, normal_val, 1), + utxo(foreign_addr, foreign_val, 2), + ], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + // Must not panic. tracing::warn! fires for the unresolved count. + apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + + // Total balance is exact — foreign UTXO is in the set regardless. + assert_eq!( + wallet_info.balance.total(), + expected_total, + "total must include foreign UTXO even though it is unresolved" + ); + + // Per-address visible: only the normal UTXO is in the pool. + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pool_addresses: HashSet
= funds + .managed_account_type() + .address_pools() + .iter() + .flat_map(|p| p.addresses.values().map(|i| i.address.clone())) + .collect(); + let visible: u64 = funds + .utxos + .values() + .filter(|u| pool_addresses.contains(&u.address)) + .map(|u| u.value()) + .sum(); + assert_eq!( + visible, normal_val, + "only the non-foreign UTXO is pool-visible; foreign deferred to re-warm" + ); + assert!( + visible < expected_total, + "foreign UTXO is deferred — per-address visible < total" + ); + } + + /// QA-003: CoinJoin topology (single External pool, no Internal chain). + /// Verifies that `extend_pools_for_restored_utxos` handles a single-pool + /// account at a deep derivation index (idx 30, just past the eager window). + #[test] + fn rehydration_coinjoin_single_pool_deep_index() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::managed_account::address_pool::{AddressPool, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::Utxo; + use std::collections::BTreeSet; + + // CoinJoin-only wallet: no BIP44, one CoinJoin account at index 0. + let mut cj_set = BTreeSet::new(); + cj_set.insert(0u32); + let opts = WalletAccountCreationOptions::SpecificAccounts( + BTreeSet::new(), + BTreeSet::new(), + cj_set, + BTreeSet::new(), + BTreeSet::new(), + None, + ); + let seed = [11u8; 64]; + let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, opts).unwrap(); + assert!( + !wallet.accounts.coinjoin_accounts.is_empty(), + "fixture must have a CoinJoin account" + ); + + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + // Extract pool metadata before the mutable borrow of wallet_info. + let (funds_type, pool_base_path, pool_type_val, pool_gap_limit) = { + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .expect("CoinJoin account must be the only funds account"); + let ft = funds.managed_account_type().to_account_type(); + let pools = funds.managed_account_type().address_pools(); + // CoinJoin has a single pool (External). + assert_eq!( + pools.len(), + 1, + "CoinJoin topology: must have exactly one pool" + ); + let p = &pools[0]; + (ft, p.base_path.clone(), p.pool_type, p.gap_limit) + }; + + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("CoinJoin xpub must be in manifest"); + + // Derive the CoinJoin address at index 30 (first past the eager + // window 0..=29) using the real pool's base_path and pool_type. + let mut probe = AddressPool::new_without_generation( + pool_base_path, + pool_type_val, + pool_gap_limit, + Network::Testnet, + ); + probe + .generate_addresses(31, &KeySource::Public(xpub), true) + .unwrap(); + let deep_cj_addr = probe.address_at_index(30).unwrap(); + + let utxo_val = 7_777u64; + let utxo = Utxo { + outpoint: OutPoint { + txid: Txid::from([7u8; 32]), + vout: 0, + }, + txout: TxOut { + value: utxo_val, + script_pubkey: deep_cj_addr.script_pubkey(), + }, + address: deep_cj_addr.clone(), + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + + let core = crate::changeset::CoreChangeSet { + new_utxos: vec![utxo], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + + // Balance is exact. + assert_eq!( + wallet_info.balance.total(), + utxo_val, + "CoinJoin deep-index balance must be exact" + ); + + // The CoinJoin pool was extended to include the deep-index address. + let funds_post = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let cj_pool = &funds_post.managed_account_type().address_pools()[0]; + assert_eq!( + cj_pool.address_at_index(30).as_ref(), + Some(&deep_cj_addr), + "CoinJoin pool must be extended to cover deep-index address at idx 30" + ); } } From 4f83a07489136f288119ba0da6054dfb2e8b7d81 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:44:10 +0200 Subject: [PATCH 23/24] docs(platform-wallet): document apply_persisted_core_state manifest param + depth bound PROJ-002: add # Parameters section documenting each argument including the manifest param (purpose + absent-account_type behaviour). Extend # Reconstructed with the address-pool-depth guarantee. Extend # Deferred with the MAX_REHYDRATION_DERIVATION_INDEX ceiling, the gap-limit scan bound, and the tracing::warn! re-warm behaviour. Remove the now-stale "This never logs" caveat since extend_pools_for_restored_utxos may emit a warn! for unresolved addresses. Co-authored by Claudius the Magnificent AI Agent --- .../src/manager/rehydrate.rs | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/manager/rehydrate.rs b/packages/rs-platform-wallet/src/manager/rehydrate.rs index dcdb860d28..6bb5eda0f9 100644 --- a/packages/rs-platform-wallet/src/manager/rehydrate.rs +++ b/packages/rs-platform-wallet/src/manager/rehydrate.rs @@ -89,6 +89,17 @@ pub(super) fn build_watch_only_wallet( /// Apply the keyless persisted core-state projection onto a /// freshly-minted `ManagedWalletInfo` skeleton. /// +/// # Parameters +/// +/// - `wallet_info`: the skeleton to hydrate in place. +/// - `manifest`: keyless account manifest (one entry per registered +/// account). Each entry carries an `account_type` → `account_xpub` +/// mapping used by [`extend_pools_for_restored_utxos`] to derive +/// addresses for restored UTXOs. If an account's `account_type` is +/// absent from the manifest, pool extension is skipped for that +/// account (no xpub → no derivation possible). +/// - `core`: the persisted core-state changeset to apply. +/// /// # Reconstructed (safety-critical-correct) /// /// - **Wallet balance** (`wallet_info.balance`, the no-silent-zero @@ -99,6 +110,10 @@ pub(super) fn build_watch_only_wallet( /// - **UTXO set**: every unspent persisted outpoint is restored into a /// funds-bearing account of the wallet (whatever topology it has — /// BIP44, BIP32, CoinJoin, DashPay). +/// - **Address-pool depth**: each pool is forward-derived to cover +/// restored UTXOs at deep derivation indices, then the gap window is +/// refilled beyond the deepest restored index so the per-address view +/// reconciles with the wallet total. /// - **Sync watermarks**: `synced_height` / `last_processed_height`. /// /// # Deferred to the first post-load `sync` (safe re-warm) @@ -109,6 +124,13 @@ pub(super) fn build_watch_only_wallet( /// first funds-bearing account and re-attributed on the next scan. /// The *wallet total* is unaffected (it is a sum across all funds /// accounts). +/// - **Deep-index address visibility**: each chain's pool scan stops +/// after [`MAX_REHYDRATION_DERIVATION_INDEX`] or after +/// `gap_limit` consecutive non-matching indices past the deepest +/// resolved index. UTXO addresses that fall outside that window +/// (foreign keys, multi-account mismatch) are counted and logged via +/// `tracing::warn!`; they re-warm on the next full sync. Total +/// balance is unaffected. /// - **`last_applied_chain_lock`**: not a persisted column (V001) and /// never written by the core-state writer; always `None` from disk. /// SPV re-applies a fresh chainlock on the first post-restart sync. @@ -127,7 +149,7 @@ pub(super) fn build_watch_only_wallet( /// than reconstructing a silent zero balance (the no-silent-zero /// mandate). An empty UTXO set is always `Ok`. /// -/// This never logs and never touches key material. +/// This never touches key material. pub fn apply_persisted_core_state( wallet_info: &mut ManagedWalletInfo, manifest: &[AccountRegistrationEntry], From eb59f7cf9e6a4ac6281d2455da04da6d90f90173 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:18:39 +0200 Subject: [PATCH 24/24] fix(rs-platform-wallet): harden core-state rehydration + tidy storage docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address open review threads on the sqlite wallet storage + rehydration PR. rehydrate.rs (extend_pools_for_restored_utxos): - Mark EVERY restored UTXO address used — not just deep-resolved ones. In-window addresses (inside the eager gap window) were never visited by the discovery scan, so a funded address kept used=false and could be handed out as a fresh receive address. Now a single mark_used pass covers in-window and deep-resolved alike; mark_used is a no-op for addresses a pool doesn't hold, so an underived index is never marked. - Replace bare pools[i] indexing: verify pools mirror the discovery probes 1:1 and fail closed with the new RehydrationPoolMismatch error on a structural break, then iterate via iter_mut().zip() with a pool_type debug_assert pinning chain-order parity. - Stop swallowing results: log on maintain_gap_limit Err and on apply-side ensure_derived returning None (deferring that address) instead of silently proceeding. - Add wallet_id + account_type structured fields to the unresolved warn, and emit a once-per-chain warn when discovery scans abnormally deep. Document why an explicit aggregate-derivation cap is unnecessary (chains x MAX is already bounded). - Document the horizon-walk limitation explicitly (chosen behavior): a legitimately-owned deep-and-sparse address — not only foreign keys — can land unresolved; the wallet total stays exact while the per-address view is incomplete until the next sync. Scope the AbsentHardened note: hardened derivation needs the private key, so those pools always defer under watch-only rehydration. core_bridge.rs: - Aggregate the per-record non-default-account warn in the BlockProcessed loop into one summary warn (count + sample txid) via a shared predicate; the single-record TransactionDetected path is unchanged. storage: - Drop the stale pool_type entry from the V001 migration header column list. - Reword the core_state addresses_derived comment to present-state. - Delete the phantom READ_ONLY_PREPARE_ALLOWED entry for a SELECT/columns that no longer exist. - Rename the genesis-rescan test to a behavioral sqlite_-prefixed name. Tests: in-window used-state is now asserted; a deep-and-sparse idx-45 UTXO is pinned as left-unresolved-but-balance-exact; the no-funds-account topology guard is covered (Err with the right count) plus its empty-UTXO Ok companion. Co-Authored-By: Claude Opus 4.8 --- .../migrations/V001__initial.rs | 4 +- .../src/sqlite/schema/core_state.rs | 4 +- ....rs => sqlite_account_zero_attribution.rs} | 0 .../tests/sqlite_compile_time.rs | 4 - .../src/changeset/core_bridge.rs | 43 +- packages/rs-platform-wallet/src/error.rs | 16 + .../src/manager/rehydrate.rs | 633 +++++++++++++++--- 7 files changed, 581 insertions(+), 123 deletions(-) rename packages/rs-platform-wallet-storage/tests/{marvin_gate_in_band_ordering.rs => sqlite_account_zero_attribution.rs} (100%) diff --git a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs index 4ee50a3fc7..30a0d8b851 100644 --- a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs +++ b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs @@ -29,8 +29,8 @@ //! read back) at every connection open via `open_conn` //! (`src/sqlite/conn.rs`). //! -//! Enum-shaped TEXT columns (`network`, `account_type`, `pool_type`, -//! `status`, `state`) carry a `CHECK (col IN (...))` clause whose +//! Enum-shaped TEXT columns (`network`, `account_type`, `status`, +//! `state`) carry a `CHECK (col IN (...))` clause whose //! IN-list is built from the `*_LABELS` const arrays in //! `crate::sqlite::schema::{wallets, accounts, asset_locks, //! contacts}`. The consts are the single source of truth shared with diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 4d725b99d4..8da392a5c7 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -57,8 +57,8 @@ pub fn apply( // `addresses_derived` is intentionally NOT persisted here. The iOS // address registry is fed by the FFI `addresses_derived` callback (fired // before the UTXO changeset in the same round), and UTXO attribution is - // hardcoded to the default account (index 0), so the storage layer no - // longer keeps a derived-address lookup table. + // hardcoded to the default account (index 0); the storage layer keeps no + // derived-address lookup table. if !cs.new_utxos.is_empty() { let mut stmt = tx.prepare_cached(UPSERT_UTXO_SQL)?; for utxo in &cs.new_utxos { diff --git a/packages/rs-platform-wallet-storage/tests/marvin_gate_in_band_ordering.rs b/packages/rs-platform-wallet-storage/tests/sqlite_account_zero_attribution.rs similarity index 100% rename from packages/rs-platform-wallet-storage/tests/marvin_gate_in_band_ordering.rs rename to packages/rs-platform-wallet-storage/tests/sqlite_account_zero_attribution.rs diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs index c294935cbb..2294792564 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -58,10 +58,6 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ "SELECT wallet_id, account_index, account_xpub_bytes FROM account_registrations", ), ("core_state.rs", "SELECT outpoint, value, script, height"), - ( - "core_state.rs", - "SELECT account_type, account_index, pool_type, derivation_index, address, used", - ), // Full-rehydration readers — one-shot SELECTs in `load_state`. ( "accounts.rs", diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 7008f358ea..a4f2cb32c3 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -51,18 +51,24 @@ use crate::wallet::platform_wallet::PlatformWalletInfo; /// no funds index (`AccountType::index() == None`) and never emit /// `Received`/`Change` UTXOs, so they never warn. fn warn_if_non_default_account(record: &TransactionRecord) { - if let Some(index) = record.account_type.index() { - if index != 0 { - tracing::warn!( - account_index = index, - txid = %record.txid, - "non-default account UTXO persisted under account_index 0; \ - per-account grouping is approximate" - ); - } + if let Some(index) = non_default_account_index(record) { + tracing::warn!( + account_index = index, + txid = %record.txid, + "non-default account UTXO persisted under account_index 0; \ + per-account grouping is approximate" + ); } } +/// The record's funds account index when it is a *non-default* (index != 0) +/// funds account, else `None`. Identity/provider account types carry no +/// funds index (`index() == None`) and never emit `Received`/`Change` +/// UTXOs, so they yield `None`. +fn non_default_account_index(record: &TransactionRecord) -> Option { + record.account_type.index().filter(|&index| index != 0) +} + /// Spawn the wallet-event subscriber task. /// /// Subscribes to `wallet_manager.subscribe_events()` from inside the @@ -193,12 +199,27 @@ async fn build_core_changeset( } => { let mut cs = CoreChangeSet::default(); // Inserted records bring fresh UTXOs and may consume previous - // ones — warn on a non-default account, but always project. + // ones — always project. Non-default-account records are tallied + // and surfaced in a single aggregated warn after the loop (rather + // than one warn per record) to keep a busy block quiet. + let mut non_default_count = 0usize; + let mut non_default_sample: Option = None; for r in inserted { - warn_if_non_default_account(r); + if non_default_account_index(r).is_some() { + non_default_count += 1; + non_default_sample.get_or_insert(r.txid); + } cs.new_utxos.extend(derive_new_utxos(r)); cs.spent_utxos.extend(derive_spent_utxos(r)); } + if non_default_count > 0 { + tracing::warn!( + non_default_count, + sample_txid = ?non_default_sample, + "non-default account UTXO(s) persisted under account_index 0; \ + per-account grouping is approximate" + ); + } // Updated records (re-confirmation, IS-lock applied to a known // mempool tx, etc.) don't usually change UTXO topology — the // record's content does change though, so re-emit it. diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index b6217c9839..9514f2db54 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -26,6 +26,22 @@ pub enum PlatformWalletError { utxo_count: usize, }, + /// The deep-index discovery probes did not mirror the account's real + /// address pools 1:1 during rehydration, so applying probe depths by + /// position would index the wrong pool. Fail-closed instead of risking + /// a misattributed derivation — the probes are built directly from the + /// same `address_pools()` enumeration, so a mismatch is a structural + /// invariant break, not user-reachable. + #[error( + "rehydration pool/probe mismatch: expected {expected} address pool(s) to mirror the discovery probes, found {found}" + )] + RehydrationPoolMismatch { + /// Number of discovery probes built from `address_pools()`. + expected: usize, + /// Number of real address pools from `address_pools_mut()`. + found: usize, + }, + #[error("Wallet not found: {0}")] WalletNotFound(String), diff --git a/packages/rs-platform-wallet/src/manager/rehydrate.rs b/packages/rs-platform-wallet/src/manager/rehydrate.rs index 6bb5eda0f9..44527d1ee2 100644 --- a/packages/rs-platform-wallet/src/manager/rehydrate.rs +++ b/packages/rs-platform-wallet/src/manager/rehydrate.rs @@ -96,8 +96,9 @@ pub(super) fn build_watch_only_wallet( /// account). Each entry carries an `account_type` → `account_xpub` /// mapping used by [`extend_pools_for_restored_utxos`] to derive /// addresses for restored UTXOs. If an account's `account_type` is -/// absent from the manifest, pool extension is skipped for that -/// account (no xpub → no derivation possible). +/// absent from the manifest, deep-index derivation is skipped for that +/// account (no xpub → no derivation possible); already-derived in-window +/// addresses are still marked used. /// - `core`: the persisted core-state changeset to apply. /// /// # Reconstructed (safety-critical-correct) @@ -125,12 +126,19 @@ pub(super) fn build_watch_only_wallet( /// The *wallet total* is unaffected (it is a sum across all funds /// accounts). /// - **Deep-index address visibility**: each chain's pool scan stops -/// after [`MAX_REHYDRATION_DERIVATION_INDEX`] or after -/// `gap_limit` consecutive non-matching indices past the deepest -/// resolved index. UTXO addresses that fall outside that window -/// (foreign keys, multi-account mismatch) are counted and logged via -/// `tracing::warn!`; they re-warm on the next full sync. Total -/// balance is unaffected. +/// after [`MAX_REHYDRATION_DERIVATION_INDEX`] or after `gap_limit` +/// consecutive non-matching indices past the deepest resolved index. +/// The horizon only advances when an unspent UTXO anchors a match, so a +/// UTXO address can be left unresolved in two distinct cases: (1) it is +/// genuinely foreign (a different account's key routed here, or corrupt), +/// and (2) it is a *legitimately-owned but deep-and-sparse* address — +/// owned by this account, yet sitting past the first `gap_limit` window +/// with no nearer unspent UTXO to walk the horizon out to it. Both cases +/// are counted and logged via `tracing::warn!` and re-warm on the next +/// full sync. The wallet *total* stays exact (every UTXO is summed +/// regardless of pool visibility); only the per-address view is +/// incomplete until that sync. This is the accepted behavior of the +/// horizon-walk algorithm — see [`extend_pools_for_restored_utxos`]. /// - **`last_applied_chain_lock`**: not a persisted column (V001) and /// never written by the core-state writer; always `None` from disk. /// SPV re-applies a fresh chainlock on the first post-restart sync. @@ -157,6 +165,10 @@ pub fn apply_persisted_core_state( ) -> Result<(), PlatformWalletError> { use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + // Captured before the mutable account borrow below so it can flow into + // pool-extension diagnostics without re-borrowing `wallet_info`. + let wallet_id = wallet_info.wallet_id; + // Sync watermarks first so `update_balance`'s maturity check sees // the restored tip. if let Some(h) = core.last_processed_height { @@ -192,11 +204,11 @@ pub fn apply_persisted_core_state( } // Eager derivation covers only `0..=gap_limit`; extend each // chain to cover restored UTXOs at deeper indices. - extend_pools_for_restored_utxos(account, manifest, &unspent); + extend_pools_for_restored_utxos(account, manifest, &unspent, wallet_id)?; } None => { return Err(PlatformWalletError::RehydrationTopologyUnsupported { - wallet_id: wallet_info.wallet_id, + wallet_id, utxo_count: core.new_utxos.len(), }); } @@ -219,58 +231,70 @@ pub fn apply_persisted_core_state( /// the true high-water mark well before this and never reaches the cap. const MAX_REHYDRATION_DERIVATION_INDEX: u32 = 10_000; +/// Soft threshold past which a single chain's discovery scan is treated as +/// abnormally deep and worth a `tracing::warn!`. Real funds chains anchor +/// well below this; reaching it means either a corrupt / foreign-heavy UTXO +/// set walking the horizon out, or an approach toward the hard +/// [`MAX_REHYDRATION_DERIVATION_INDEX`] ceiling — both worth surfacing. +const REHYDRATION_DEEP_SCAN_WARN_INDEX: u32 = 1_000; + /// Extend `account`'s address pools so every resolved UTXO address is -/// derived at its exact `(chain, index)` slot, then refill the gap window -/// beyond — following the sync path's `mark_used` → `maintain_gap_limit` -/// sequence. Each chain is scanned independently, stopping once no -/// unresolved address matches within a `gap_limit`-sized window past the -/// deepest resolved index; [`MAX_REHYDRATION_DERIVATION_INDEX`] is the -/// hard ceiling. Addresses not derivable from this account's xpub (foreign -/// keys, multi-account mismatch) are counted and logged via -/// `tracing::warn!`; they re-warm on the next full sync. +/// derived at its exact `(chain, index)` slot and marked used, then refill +/// the gap window beyond — following the sync path's `mark_used` → +/// `maintain_gap_limit` sequence. Each chain is scanned independently, +/// stopping once no unresolved address matches within a `gap_limit`-sized +/// window past the deepest resolved index; [`MAX_REHYDRATION_DERIVATION_INDEX`] +/// is the hard ceiling. Addresses that don't resolve from this account's +/// xpub — foreign keys, multi-account mismatch, or legitimately-owned but +/// deep-and-sparse slots with no nearer unspent UTXO to anchor the horizon — +/// are counted and logged via `tracing::warn!`; they re-warm on the next +/// full sync. Every restored address the pools *do* hold (in-window or +/// deep-resolved) is marked used so a funded address is never handed out as +/// a fresh receive address. /// /// Tested with Standard BIP44 topology (External + Internal pools) and -/// CoinJoin topology (single External pool). The per-chain probe loop has -/// no topology-specific branches, so Absent and AbsentHardened pool types -/// follow the same code path with a different relative derivation path. +/// CoinJoin topology (single External pool). The per-chain probe loop has no +/// topology-specific branches, so the non-hardened single-pool type +/// (`Absent`) follows the same code path with a different relative derivation +/// path. `AbsentHardened` pools cannot be derived from a public xpub at all — +/// hardened child derivation needs the private key — so under watch-only +/// rehydration their addresses never resolve and always defer to the next +/// sync (shared code path, but the outcome is "unresolved"). +/// +/// # Errors +/// +/// [`PlatformWalletError::RehydrationPoolMismatch`] if the discovery probes +/// don't mirror the real pools 1:1 (a structural invariant break, not +/// user-reachable). Fail-closed rather than apply a probe depth to the wrong +/// pool by position. /// /// Never touches key material — the xpub is the keyless account public key. fn extend_pools_for_restored_utxos( account: &mut key_wallet::managed_account::ManagedCoreFundsAccount, manifest: &[AccountRegistrationEntry], restored: &[&key_wallet::Utxo], -) { + wallet_id: [u8; 32], +) -> Result<(), PlatformWalletError> { use key_wallet::managed_account::address_pool::{AddressPool, KeySource}; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use std::collections::{BTreeSet, HashSet}; - // The funds account carries no key material; recover its watch-only - // xpub from the keyless manifest by account type. let account_type = account.managed_account_type().to_account_type(); - let Some(account_xpub) = manifest + + // The funds account carries no key material; recover its watch-only xpub + // from the keyless manifest by account type. Without it we cannot derive + // deeper, but can still mark already-derived (in-window) addresses used. + let key_source = manifest .iter() .find(|e| e.account_type == account_type) - .map(|e| e.account_xpub) - else { - return; - }; - let key_source = KeySource::Public(account_xpub); + .map(|e| KeySource::Public(e.account_xpub)); - // Restored addresses not already covered by the eager derivation. - let mut unresolved: HashSet = { - let pools = account.managed_account_type().address_pools(); - restored - .iter() - .map(|u| u.address.clone()) - .filter(|addr| !pools.iter().any(|p| p.contains_address(addr))) - .collect() - }; - if unresolved.is_empty() { - return; - } - - // Probe pools mirror each real pool's chain so the index search derives - // into throwaway state (real pools keep their own exact depth). + // Probe pools mirror each real pool's chain 1:1 so the index search + // derives into throwaway state (real pools keep their own exact depth) + // and the resolved depth can be applied back by position. Re-deriving + // each probe from index 0 is an accepted, bounded one-time-load cost + // (per chain capped at MAX_REHYDRATION_DERIVATION_INDEX); rehydration + // runs once per wallet at startup, never on a hot path. let mut probes: Vec<(AddressPool, BTreeSet)> = account .managed_account_type() .address_pools() @@ -288,75 +312,160 @@ fn extend_pools_for_restored_utxos( }) .collect(); - // Per-chain scan: each chain advances independently. We stop a chain - // once no unresolved address resolves within gap_limit indices past the - // deepest match on that chain (preventing a full 10k scan when the UTXO - // set contains foreign addresses). MAX_REHYDRATION_DERIVATION_INDEX is - // the hard ceiling regardless. - for (probe, matched) in probes.iter_mut() { - if unresolved.is_empty() { - break; - } - let chain_gap = probe.gap_limit; - let mut deepest_resolved: Option = None; - let mut index: u32 = 0; - - loop { - // Horizon: gap_limit past the deepest match, or the initial - // gap_limit window when nothing has resolved yet. - let horizon = deepest_resolved - .map(|d| d.saturating_add(chain_gap)) - .unwrap_or(chain_gap); - - if index > horizon || index > MAX_REHYDRATION_DERIVATION_INDEX { + // Deep-index discovery (requires the xpub): resolve restored addresses the + // eager derivation didn't already cover, recording the matching index per + // chain. Each chain advances independently and stops once no unresolved + // address resolves within gap_limit indices past its deepest match + // (preventing a full scan when the UTXO set carries foreign addresses); + // MAX_REHYDRATION_DERIVATION_INDEX is the hard ceiling regardless. + if let Some(key_source) = key_source.as_ref() { + let mut unresolved: HashSet = { + let pools = account.managed_account_type().address_pools(); + restored + .iter() + .map(|u| u.address.clone()) + .filter(|addr| !pools.iter().any(|p| p.contains_address(addr))) + .collect() + }; + + for (probe, matched) in probes.iter_mut() { + if unresolved.is_empty() { break; } + let chain_gap = probe.gap_limit; + let mut deepest_resolved: Option = None; + let mut index: u32 = 0; + + loop { + // Horizon: gap_limit past the deepest match, or the initial + // gap_limit window when nothing has resolved yet. + let horizon = deepest_resolved + .map(|d| d.saturating_add(chain_gap)) + .unwrap_or(chain_gap); + + if index > horizon || index > MAX_REHYDRATION_DERIVATION_INDEX { + break; + } + + if let Some(addr) = ensure_derived(probe, key_source, index) { + if unresolved.remove(&addr) { + matched.insert(index); + deepest_resolved = Some(index); + } + } - if let Some(addr) = ensure_derived(probe, &key_source, index) { - if unresolved.remove(&addr) { - matched.insert(index); - deepest_resolved = Some(index); + if unresolved.is_empty() { + break; } + + index = index.saturating_add(1); } - if unresolved.is_empty() { - break; + // Surface an abnormally deep scan once per chain (outside the loop + // — never log inside the per-index walk). + if index > REHYDRATION_DEEP_SCAN_WARN_INDEX { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + account_type = ?account_type, + pool_type = ?probe.pool_type, + deepest_resolved = ?deepest_resolved, + scanned_to = index.saturating_sub(1), + "rehydration: chain discovery scanned abnormally deep — \ + likely a foreign-heavy or sparse UTXO set" + ); } + } - index = index.saturating_add(1); + // Still-unresolved addresses are either foreign (a different account's + // key routed here, or corrupt) or legitimately-owned but deep-and-sparse + // (past the first gap window with no nearer unspent UTXO to anchor the + // horizon). Either way they re-warm on the next full sync; the wallet + // total is exact regardless. + if !unresolved.is_empty() { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + account_type = ?account_type, + unresolved_count = unresolved.len(), + "rehydration: UTXO address(es) unresolved for this account xpub \ + — will re-warm on next sync; balance total is exact" + ); } } - // Addresses still unresolved are not derivable from this account's xpub - // (foreign key, routed from a different funds account, or corrupt). - // They re-warm on the next full sync; total balance is exact regardless. - if !unresolved.is_empty() { - tracing::warn!( - unresolved_count = unresolved.len(), - "rehydration: {} UTXO address(es) unresolved for this account \ - xpub — will re-warm on next sync; balance total is exact", - unresolved.len(), - ); - } + // No explicit aggregate-derivation cap is needed: a funds account exposes + // a fixed, small number of chains (Standard = 2, others = 1), each already + // capped at MAX_REHYDRATION_DERIVATION_INDEX, so total derivation is bounded + // by chains × MAX with no unbounded growth — an aggregate cap would either + // equal that natural bound (no-op) or clip a legitimate deep multi-chain + // wallet. The per-chain ceiling plus the deep-scan warn above are the + // proportionate guard against a corrupt/foreign-heavy UTXO set. - // Apply each chain's resolved depth to its real pool: derive up to the - // deepest resolved index, mark the resolved slots used, then maintain - // the gap window beyond the highest used index. - // Chains with no resolved UTXOs are skipped — their eager gap window - // (from initialization) already covers the correct depth, and calling - // maintain_gap_limit without any used indices would be a no-op anyway. + // Apply discovered depths and mark restored addresses used. `probes` is + // built directly from `address_pools()`, so it mirrors `address_pools_mut()` + // 1:1 and in chain order; verify that invariant before zipping by position. let mut pools = account.managed_account_type_mut().address_pools_mut(); - for (i, (_, matched)) in probes.iter().enumerate() { - let Some(&deepest) = matched.iter().next_back() else { - continue; - }; - let pool = &mut *pools[i]; - ensure_derived(pool, &key_source, deepest); - for &idx in matched { - pool.mark_index_used(idx); + if pools.len() != probes.len() { + return Err(PlatformWalletError::RehydrationPoolMismatch { + expected: probes.len(), + found: pools.len(), + }); + } + for (pool, (probe, matched)) in pools.iter_mut().zip(probes.iter()) { + // `iter_mut()` over `Vec<&mut AddressPool>` yields `&mut &mut _`; + // reborrow once so the pool flows into `ensure_derived` cleanly. + let pool: &mut AddressPool = pool; + debug_assert_eq!( + pool.pool_type, probe.pool_type, + "probe/pool chain order must match for by-position depth apply" + ); + + // Derive up to the deepest discovered index so its address exists in + // the real pool before we mark it used. + if let Some(&deepest) = matched.iter().next_back() { + if let Some(key_source) = key_source.as_ref() { + if ensure_derived(pool, key_source, deepest).is_none() { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + account_type = ?account_type, + pool_type = ?pool.pool_type, + index = deepest, + "rehydration: failed to derive resolved index into pool; \ + deferring its address to the next sync" + ); + } + } + } + + // Mark every restored address this pool now holds as used — covers both + // deep-resolved addresses (just derived) and in-window addresses the + // discovery scan never visits. Without this an already-derived but + // funded address keeps `used = false` and could be handed out as a fresh + // receive address. `mark_used` is a no-op for addresses not in this + // pool, so an underived (foreign / sparse) index is never marked. + let mut marked_any = false; + for u in restored { + if pool.mark_used(&u.address) { + marked_any = true; + } + } + + // Refill the gap window past the deepest used index (needs the xpub). + if marked_any { + if let Some(key_source) = key_source.as_ref() { + if let Err(e) = pool.maintain_gap_limit(key_source) { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + account_type = ?account_type, + pool_type = ?pool.pool_type, + error = %e, + "rehydration: gap-limit maintenance failed; pool window \ + may be short until the next sync" + ); + } + } } - let _ = pool.maintain_gap_limit(&key_source); } + Ok(()) } /// Ensure `pool` has derived through `index` (generating only the missing @@ -444,8 +553,8 @@ mod tests { /// - external idx 50: within extended window (50 < 60), resolved /// - internal idx 30: within initial scan window, resolved /// - /// QA-003: Standard BIP44 topology (External + Internal pools) is exercised. - /// QA-005: asserts that maintain_gap_limit fills beyond the deepest resolved. + /// Standard BIP44 topology (External + Internal pools) is exercised. + /// Asserts that maintain_gap_limit fills beyond the deepest resolved. #[test] fn rehydration_extends_pools_to_cover_deep_index_utxos() { use dashcore::blockdata::transaction::txout::TxOut; @@ -579,7 +688,7 @@ mod tests { assert_eq!(external.address_at_index(50).as_ref(), Some(&deep_recv)); assert_eq!(internal.address_at_index(30).as_ref(), Some(&deep_change)); - // QA-005: maintain_gap_limit must refill BEYOND the deepest restored + // maintain_gap_limit must refill BEYOND the deepest restored // index so the gap window is actually exercised, not just the restore. // Deepest external resolved = idx 50; gap window must reach >= 50+30=80. let expected_min_gen = 50 + DEFAULT_EXTERNAL_GAP_LIMIT; @@ -591,7 +700,7 @@ mod tests { ); } - /// QA-004: a UTXO whose address is not derivable from this account's + /// A UTXO whose address is not derivable from this account's /// xpub (foreign key, multi-account mismatch) must not cause a panic or /// hang. The total balance is exact (the UTXO is in the set regardless), /// but the foreign address is absent from the pool so per-address @@ -743,7 +852,7 @@ mod tests { ); } - /// QA-003: CoinJoin topology (single External pool, no Internal chain). + /// CoinJoin topology (single External pool, no Internal chain). /// Verifies that `extend_pools_for_restored_utxos` handles a single-pool /// account at a deep derivation index (idx 30, just past the eager window). #[test] @@ -865,4 +974,320 @@ mod tests { "CoinJoin pool must be extended to cover deep-index address at idx 30" ); } + + /// In-window restored UTXO: an address already covered by the eager + /// derivation (idx 3, inside `0..=gap_limit-1`) must still be marked + /// `used` during rehydration. The discovery scan never visits in-window + /// addresses, so without an explicit mark pass a funded address would keep + /// `used = false` and could later be handed out as a fresh receive address. + #[test] + fn rehydration_marks_in_window_restored_address_used() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + + let wallet = Wallet::from_seed_bytes( + [5u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let funds_type = wallet_info + .accounts + .all_funding_accounts() + .first() + .unwrap() + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("funds account xpub"); + + // External idx 3 — inside the eager window, so NOT in the discovery set. + let in_window: Address = { + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(4, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(3).unwrap() + }; + + let core = crate::changeset::CoreChangeSet { + new_utxos: vec![Utxo { + outpoint: OutPoint { + txid: Txid::from([1u8; 32]), + vout: 0, + }, + txout: TxOut { + value: 12_345, + script_pubkey: in_window.script_pubkey(), + }, + address: in_window.clone(), + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pools = funds.managed_account_type().address_pools(); + let external = pools.iter().find(|p| p.is_external()).unwrap(); + let info = external + .address_info(&in_window) + .expect("in-window address must be present in the pool"); + assert!( + info.used, + "in-window restored UTXO address must be marked used" + ); + assert!( + external.used_indices.contains(&3), + "used_indices must record the in-window slot" + ); + assert_eq!( + external.highest_used, + Some(3), + "highest_used must reflect the in-window slot" + ); + } + + /// Documented limitation (solution b): a legitimately-owned but + /// deep-and-sparse UTXO — external idx 45 with nothing unspent at idx + /// <= 30 — is left unresolved because the discovery horizon (gap_limit + /// past the deepest match) never advances far enough to reach it. The + /// wallet total stays exact; only the per-address view is incomplete + /// until the next sync (a `tracing::warn!` records the deferral). + #[test] + fn rehydration_deep_sparse_utxo_left_unresolved_total_exact() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + use std::collections::HashSet; + + let wallet = Wallet::from_seed_bytes( + [21u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let funds_type = wallet_info + .accounts + .all_funding_accounts() + .first() + .unwrap() + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("funds account xpub"); + + // External idx 45 — past the eager window AND past the initial scan + // window (horizon = gap_limit = 30 with no nearer match to extend it). + let sparse_deep: Address = { + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(46, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(45).unwrap() + }; + + let value = 500_000u64; + let core = crate::changeset::CoreChangeSet { + new_utxos: vec![Utxo { + outpoint: OutPoint { + txid: Txid::from([4u8; 32]), + vout: 0, + }, + txout: TxOut { + value, + script_pubkey: sparse_deep.script_pubkey(), + }, + address: sparse_deep.clone(), + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + + // The wallet total is exact regardless (a sum over the UTXO set). + assert_eq!(wallet_info.balance.total(), value); + + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pools = funds.managed_account_type().address_pools(); + let external = pools.iter().find(|p| p.is_external()).unwrap(); + assert!( + !external.contains_address(&sparse_deep), + "deep-sparse idx 45 must be left unresolved (absent from the pool)" + ); + + // Per-address view: the deep-sparse UTXO is not pool-visible yet. + let pool_addresses: HashSet
= pools + .iter() + .flat_map(|p| p.addresses.values().map(|i| i.address.clone())) + .collect(); + let visible: u64 = funds + .utxos + .values() + .filter(|u| pool_addresses.contains(&u.address)) + .map(|u| u.value()) + .sum(); + assert_eq!( + visible, 0, + "the deep-sparse UTXO is deferred — not pool-visible until next sync" + ); + assert!(visible < value, "per-address visible < exact total"); + } + + /// Topology guard: a wallet with persisted UTXOs but NO funds-bearing + /// account cannot hold them — fail closed with + /// `RehydrationTopologyUnsupported` (reporting the persisted count) rather + /// than reconstruct a silent zero balance. + #[test] + fn rehydration_utxos_without_funds_account_errors() { + use dashcore::address::Payload; + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::hashes::Hash; + use dashcore::{OutPoint, PubkeyHash, Txid}; + use key_wallet::account::AccountType; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + use std::collections::BTreeSet; + + // Keys-only wallet: a single IdentityRegistration account, no funds. + let opts = WalletAccountCreationOptions::SpecificAccounts( + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + Some(vec![AccountType::IdentityRegistration]), + ); + let wallet = Wallet::from_seed_bytes([23u8; 64], Network::Testnet, opts).unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + assert!( + wallet_info.accounts.all_funding_accounts().is_empty(), + "fixture must have NO funds-bearing account" + ); + + let addr = Address::new( + Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([9u8; 20])), + ); + let core = crate::changeset::CoreChangeSet { + new_utxos: vec![Utxo { + outpoint: OutPoint { + txid: Txid::from([2u8; 32]), + vout: 0, + }, + txout: TxOut { + value: 800_000, + script_pubkey: addr.script_pubkey(), + }, + address: addr, + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + let err = apply_persisted_core_state(&mut wallet_info, &manifest, &core) + .expect_err("must fail closed when no funds account can hold the UTXOs"); + match err { + PlatformWalletError::RehydrationTopologyUnsupported { utxo_count, .. } => { + assert_eq!(utxo_count, 1, "utxo_count must match the persisted set"); + } + other => panic!("expected RehydrationTopologyUnsupported, got {other:?}"), + } + } + + /// Companion to the topology guard: the same keys-only wallet with an + /// EMPTY persisted UTXO set is `Ok` — there is nothing to hold, so the + /// guard does not trip. + #[test] + fn rehydration_no_funds_account_empty_utxos_ok() { + use key_wallet::account::AccountType; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use std::collections::BTreeSet; + + let opts = WalletAccountCreationOptions::SpecificAccounts( + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + Some(vec![AccountType::IdentityRegistration]), + ); + let wallet = Wallet::from_seed_bytes([24u8; 64], Network::Testnet, opts).unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + assert!(wallet_info.accounts.all_funding_accounts().is_empty()); + + let core = crate::changeset::CoreChangeSet { + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + apply_persisted_core_state(&mut wallet_info, &manifest, &core) + .expect("empty UTXO set must be Ok even with no funds account"); + } }