diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml index 99524b6ca8..5190b35c4f 100644 --- a/packages/rs-platform-wallet-storage/Cargo.toml +++ b/packages/rs-platform-wallet-storage/Cargo.toml @@ -247,3 +247,9 @@ kv = ["sqlite"] __test-helpers = ["sqlite"] # e2e tests that drive the #3692 manager-apply path; enabled in the integrated stack (dash-evo-tool). rehydration-apply = [] +# Pass-through to `platform-wallet/shielded` so the feature-gated +# `PlatformWalletChangeSet::shielded` field is visible to the exhaustive +# `versions::touched_domains` destructure (the R8 forgotten-domain guard). +# Storage persists no shielded state itself; this only aligns visibility so +# an added always-on field stays a compile error. +shielded = ["platform-wallet/shielded"] diff --git a/packages/rs-platform-wallet-storage/migrations/V002__unified.rs b/packages/rs-platform-wallet-storage/migrations/V002__unified.rs new file mode 100644 index 0000000000..75a23ac37f --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V002__unified.rs @@ -0,0 +1,80 @@ +//! Unified additive migration for `platform-wallet-storage` (#3968). +//! +//! Additive-only: V001 stays byte-identical so refinery's applied-migration +//! checksum for version 1 never diverges on an existing store. V002 lifts +//! `max_supported_version()` from 1 to 2 automatically (the value is derived +//! from the embedded list) and lands three concerns in one migration event: +//! +//! - `core_address_pool` — per-index address-pool rows with a `used` flag, +//! the first-class row store that replaces `core_utxos` script-derivation +//! for the address-reuse guard. `account_type` and `pool_type` are both in +//! the primary key: `account_type` so two accounts that collapse to the same +//! `(account_index, key_class)` sentinel (e.g. `IdentityRegistration` and +//! `ProviderVotingKeys`, both `0, 0`) never overwrite each other, and +//! `pool_type` so an External (receive) and Internal (change) pool never +//! collide at the same `address_index`. `script` (the address' +//! `script_pubkey`) is stored so the reader returns used addresses verbatim +//! and the UTXO writer can attribute an outpoint to its owning account, both +//! without re-deriving. +//! - `meta_data_versions` — per-`(wallet_id, domain)` monotonic `seq` +//! bumped inside the flush transaction, the cache-invalidation keystone. +//! No FK (a domain row may be written before its typed parent syncs, +//! mirroring the `meta_*` tables); a soft-cascade trigger reaps rows on +//! wallet delete. +//! - `meta_store_generation` — a single-row store-generation token, +//! initialized with `randomblob(16)` so the rendered SQL stays deterministic (the +//! content fingerprint pins the text, the runtime value is unique per +//! store). Regenerated on restore. +//! +//! No MAC column ships here — manifest authentication is deferred out of +//! this workstream (dev-plan §7). + +pub fn migration() -> String { + "\ +CREATE TABLE core_address_pool ( + wallet_id BLOB NOT NULL, + account_type TEXT NOT NULL, + account_index INTEGER NOT NULL, + key_class INTEGER NOT NULL DEFAULT 0, + pool_type INTEGER NOT NULL CHECK (pool_type IN (0, 1, 2, 3)), + address_index INTEGER NOT NULL, + script BLOB NOT NULL, + used INTEGER NOT NULL DEFAULT 0 CHECK (used IN (0, 1)), + PRIMARY KEY (wallet_id, account_type, account_index, key_class, pool_type, address_index), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); + +CREATE INDEX idx_core_address_pool_used + ON core_address_pool(wallet_id, used); + +-- The UTXO writer attributes an outpoint to its owning account by matching +-- the outpoint's script against a pool row. +CREATE INDEX idx_core_address_pool_script + ON core_address_pool(wallet_id, script); + +CREATE TABLE meta_data_versions ( + wallet_id BLOB NOT NULL, + domain TEXT NOT NULL, + seq INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (wallet_id, domain) +); + +-- Soft-cascade reap, matching the meta_* tables: no FK (a domain may be +-- bumped before its typed parent exists), so a trigger clears rows when +-- the owning wallet is deleted. +CREATE TRIGGER cascade_meta_data_versions_on_wallet_delete +AFTER DELETE ON wallets +FOR EACH ROW +BEGIN + DELETE FROM meta_data_versions WHERE wallet_id = OLD.wallet_id; +END; + +CREATE TABLE meta_store_generation ( + id INTEGER NOT NULL PRIMARY KEY CHECK (id = 0), + generation BLOB NOT NULL +); + +INSERT INTO meta_store_generation (id, generation) VALUES (0, randomblob(16)); +" + .to_string() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/backup.rs b/packages/rs-platform-wallet-storage/src/sqlite/backup.rs index ac175b6af5..b5f3ae80fa 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/backup.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/backup.rs @@ -132,14 +132,17 @@ pub fn run_to(src: &Connection, dest: &Path) -> Result<(), WalletStorageError> { /// /// Validation runs against the source and again against the STAGED bytes, /// under a SQLite-native `BEGIN EXCLUSIVE` on `dest_db_path` that blocks -/// every other SQLite peer (which advisory flock could not). The staged -/// temp is `persist`-ed as an atomic rename only after all gates pass, and -/// that rename is the commit point: if it fails, the live DB and its WAL/SHM -/// siblings are left untouched, so a failed restore never strands the old DB -/// without its WAL-committed state. The now-stale WAL/SHM siblings are -/// unlinked only AFTER the swap succeeds (so a leftover `-wal` can't shadow -/// the restored DB); the parent dir is fsynced afterward. See the numbered -/// steps in the body for the per-phase rationale. +/// every other SQLite peer (which advisory flock could not). The +/// store-generation token is rotated INTO the staged temp before the swap, +/// so the single commit point brings in the restored bytes and the fresh +/// token together — a peer never observes restored content carrying the +/// source's stale token. The staged temp is `persist`-ed as an atomic rename +/// only after all gates pass, and that rename is the commit point: if it +/// fails, the live DB and its WAL/SHM siblings are left untouched, so a failed +/// restore never strands the old DB without its WAL-committed state. The +/// now-stale WAL/SHM siblings are unlinked only AFTER the swap succeeds (so a +/// leftover `-wal` can't shadow the restored DB); the parent dir is fsynced +/// afterward. See the numbered steps in the body for the per-phase rationale. /// /// # Lock-release-before-rename trade-off /// @@ -224,7 +227,27 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet crate::sqlite::migrations::assert_schema_history_well_formed(&staged)?; } - // 5. chmod 0o600 on the temp BEFORE persist so the destination + // 5. Regenerate the store-generation token INTO the staged temp, before + // the atomic rename, so the single commit point (step 8) swaps in the + // restored bytes and the rotated token together — there is no window + // where restored content is observable with the source's stale token. + // The staged DB is switched to DELETE journaling first so the UPDATE + // lands in the main file with no `-wal` frames stranded outside the + // rename; the reopened destination is forced back to its configured + // journal mode on its next open. A pre-V002 backup has no generation + // table; `regenerate_generation` is a no-op there and the token is + // (re)seeded on its later migration to V002. + { + let conn = + crate::sqlite::conn::open_conn(tmp.path(), crate::sqlite::conn::Access::ReadWrite)?; + conn.pragma_update(None, "journal_mode", "DELETE")?; + crate::sqlite::schema::versions::regenerate_generation(&conn)?; + drop(conn); + // Durably flush the regenerated token before the rename commits it. + tmp.as_file().sync_all()?; + } + + // 6. chmod 0o600 on the temp BEFORE persist so the destination // inherits owner-only mode via the rename (post-persist chmod could // fail with the new DB already live). #[cfg(unix)] @@ -234,7 +257,7 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet .set_permissions(std::fs::Permissions::from_mode(0o600))?; } - // 6. Release the EXCLUSIVE lock before the rename/unlinks: on Windows / + // 7. Release the EXCLUSIVE lock before the rename/unlinks: on Windows / // some FUSE mounts `remove_file` on a still-open file returns // `PermissionDenied`, and the rename window wants a clean close (see // lock-release trade-off above). @@ -243,18 +266,20 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet drop(conn); } - // 7. Persist the staged DB atomically over the destination FIRST. The - // atomic rename is the commit point: if it fails (disk full, EXDEV, - // perms) the live DB and its WAL/SHM siblings are left untouched, so a - // failed restore can never strand the old DB without its WAL-committed - // state. Sibling cleanup (step 8) runs only once the swap has succeeded. + // 8. Persist the staged DB atomically over the destination. The atomic + // rename is the single commit point: it swaps in both the restored + // bytes and the rotated generation token together. If it fails (disk + // full, EXDEV, perms) the live DB and its WAL/SHM siblings are left + // untouched, so a failed restore can never strand the old DB without + // its WAL-committed state. Sibling cleanup (step 9) runs only once the + // swap has succeeded. tmp.persist(dest_db_path) .map_err(|e| WalletStorageError::Io(e.error))?; - // 8. Clear the now-stale WAL/SHM siblings AFTER the swap so a leftover + // 9. Clear the now-stale WAL/SHM siblings AFTER the swap so a leftover // `-wal` can't shadow the restored DB on the next open. Sibling paths // use `OsString::push` so non-UTF-8 bytes round-trip; `NotFound` is a - // silent no-op. The lock conn was dropped in step 6 for cross-platform + // silent no-op. The lock conn was dropped in step 7 for cross-platform // unlink semantics. if let Some(file_name) = dest_db_path.file_name() { for ext in ["-wal", "-shm"] { @@ -269,10 +294,10 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet } } - // 9. Make the rename + unlink dentry updates durable. + // 10. Make the rename + unlink dentry updates durable. fsync_parent_dir(dest_db_path)?; - // 10. Re-tighten perms (idempotent; SQLite may re-materialise -wal/-shm). + // 11. Re-tighten perms (idempotent; SQLite may re-materialise -wal/-shm). apply_secure_permissions(dest_db_path)?; Ok(()) } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs index b2e25c6517..fb54e19b57 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs @@ -175,6 +175,52 @@ pub fn embedded_migrations_fingerprint() -> [u8; 32] { hasher.finalize().into() } +/// SHA-256 over `(version, name, rendered SQL)` of every embedded migration +/// in version order. Unlike [`embedded_migrations_fingerprint`] this is +/// content-level: it pins each migration's SQL body, so an in-place DDL edit +/// (e.g. renaming a table inside a same-named file) breaks the golden test. +/// This is the guard the D0 schema freeze relies on; the identity-only +/// fingerprint cannot catch a same-name body edit. +/// +/// The SQL *text* is deterministic even where a value is generated at run +/// time (`randomblob(16)`): the literal string is hashed, not the runtime +/// bytes. +#[cfg(any(test, feature = "__test-helpers"))] +pub fn embedded_migrations_sql_fingerprint() -> [u8; 32] { + use sha2::{Digest, Sha256}; + let mut migrations = migrations::runner().get_migrations().clone(); + migrations.sort_by_key(|m| m.version()); + let mut hasher = Sha256::new(); + for m in &migrations { + hasher.update((m.version() as u32).to_be_bytes()); + hasher.update([0u8]); + hasher.update(m.name().as_bytes()); + hasher.update([0u8]); + let sql = m + .sql() + .expect("embedded migrations always carry rendered SQL"); + hasher.update(sql.as_bytes()); + hasher.update([0u8]); + } + hasher.finalize().into() +} + +/// Rendered SQL of every embedded migration, in version order. Used by the +/// schema-freeze grep guard to scan for retired table names. +#[cfg(any(test, feature = "__test-helpers"))] +pub fn embedded_migrations_sql() -> Vec { + let mut migrations = migrations::runner().get_migrations().clone(); + migrations.sort_by_key(|m| m.version()); + migrations + .iter() + .map(|m| { + m.sql() + .expect("embedded migrations always carry rendered SQL") + .to_string() + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index b4126abb96..ec4cba4dce 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; use rusqlite::{Connection, OptionalExtension}; use platform_wallet::changeset::{ - ClientStartState, Merge, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, }; use platform_wallet::wallet::platform_wallet::WalletId; @@ -940,13 +940,27 @@ impl PlatformWalletPersistence for SqlitePersister { .map_err(PersistenceError::from)?; let identity_keys = schema::identity_keys::load_state(&conn, &wallet_id) .map_err(PersistenceError::from)?; - // Every address that ever held a UTXO (spent + unspent) is "used": - // the address-reuse guard so a used-then-emptied address is never - // handed back as a fresh receive address. The in-band pool snapshot - // was retired, so we derive this from the full core_utxos set. - let used_core_addresses = - schema::core_state::load_used_addresses(&conn, &wallet_id, network) + // Used addresses drive the reuse guard: a used-then-emptied + // address must never be handed back as a fresh receive address. + // Union the verbatim `core_address_pool` used-set with the + // `core_utxos`-derived set (spent + unspent). The guard is + // monotonic, so a mixed store — historical UTXOs plus a later + // partial pool snapshot that never enumerates them — must surface + // both; neither source may shadow the other. Deduped by script. + let used_core_addresses = { + let mut seen = std::collections::HashSet::new(); + let mut union = Vec::new(); + let pool = schema::core_pool::load_used_addresses(&conn, &wallet_id, network) .map_err(PersistenceError::from)?; + let utxo = schema::core_state::load_used_addresses(&conn, &wallet_id, network) + .map_err(PersistenceError::from)?; + for addr in pool.into_iter().chain(utxo) { + if seen.insert(addr.script_pubkey().to_bytes()) { + union.push(addr); + } + } + union + }; state.wallets.insert( wallet_id, @@ -954,6 +968,9 @@ impl PlatformWalletPersistence for SqlitePersister { network, birth_height, account_manifest, + // SQLite rehydration replays the keyless projection onto a + // fresh skeleton; it mints no full snapshot. + core_wallet_info: None, core_state, identity_manager, unused_asset_locks, @@ -994,25 +1011,9 @@ impl PlatformWalletPersistence for SqlitePersister { /// from the public fields so no storage-only helper leaks into the /// `rs-platform-wallet` API. fn populated_field_count(cs: &PlatformWalletChangeSet) -> usize { - [ - cs.core.is_empty(), - cs.identities.is_empty(), - cs.identity_keys.is_empty(), - cs.contacts.is_empty(), - cs.platform_addresses.is_empty(), - cs.asset_locks.is_empty(), - cs.token_balances.is_empty(), - cs.dashpay_profiles.as_ref().is_none_or(|m| m.is_empty()), - cs.dashpay_payments_overlay - .as_ref() - .is_none_or(|m| m.is_empty()), - cs.wallet_metadata.is_none(), - cs.account_registrations.is_empty(), - cs.account_address_pools.is_empty(), - ] - .iter() - .filter(|empty| !**empty) - .count() + // Single source of truth with the version-domain mapping: each populated + // field is exactly one touched domain. + schema::versions::touched_domains(cs).len() } fn validate_config(config: &SqlitePersisterConfig) -> Result<(), WalletStorageError> { @@ -1088,10 +1089,12 @@ fn apply_changeset_to_tx( if !cs.account_registrations.is_empty() { schema::accounts::apply_registrations(tx, wallet_id, &cs.account_registrations)?; } - // `account_address_pools` is intentionally NOT applied: UTXO attribution - // is hardcoded to the default account (index 0) in `core_state`, so the - // pool snapshot is no longer a storage input. The changeset field is kept - // for API stability and still feeds non-storage consumers. + // Pools land before core so the UTXO writer can attribute each outpoint + // to its owning account by matching the outpoint's script against a + // freshly-written `core_address_pool` row. + if !cs.account_address_pools.is_empty() { + schema::core_pool::apply_pools(tx, wallet_id, &cs.account_address_pools)?; + } if let Some(core) = cs.core.as_ref() { schema::core_state::apply(tx, wallet_id, core)?; } @@ -1121,6 +1124,9 @@ fn apply_changeset_to_tx( cs.dashpay_payments_overlay.as_ref(), )?; } + // Bump each touched domain's version inside this same tx so a domain's + // cache-invalidation marker commits atomically with its data. + schema::versions::bump_touched_domains(tx, wallet_id, cs)?; Ok(()) } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs new file mode 100644 index 0000000000..6f729d6617 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs @@ -0,0 +1,216 @@ +//! Writer + account-attribution helper for the `core_address_pool` table. +//! +//! Per-index address-pool rows carrying a `used` flag, scoped by +//! `(wallet_id, account_type, account_index, key_class, pool_type, +//! address_index)`. The first-class row store the reader consumes verbatim — +//! no `core_utxos` script-derivation, no horizon-walk re-derivation. Populated +//! from the `account_address_pools` changeset snapshots; the UTXO writer reads +//! it back to attribute an outpoint to its owning account. + +use rusqlite::{params, OptionalExtension, Transaction}; + +use platform_wallet::changeset::AccountAddressPoolEntry; +use platform_wallet::wallet::platform_wallet::WalletId; + +use key_wallet::managed_account::address_pool::AddressPoolType; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::accounts; +use crate::sqlite::schema::blob; + +/// Stored `pool_type` discriminant. Kept in the primary key so an External +/// and an Internal pool never collide at the same `address_index`. +pub(crate) fn pool_type_to_i64(pool_type: AddressPoolType) -> i64 { + match pool_type { + AddressPoolType::External => 0, + AddressPoolType::Internal => 1, + AddressPoolType::Absent => 2, + AddressPoolType::AbsentHardened => 3, + } +} + +const UPSERT_POOL_SQL: &str = "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, address_index, script, used) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \ + ON CONFLICT(wallet_id, account_type, account_index, key_class, pool_type, address_index) \ + DO UPDATE SET \ + script = excluded.script, \ + used = MAX(used, excluded.used)"; + +/// Expand `account_address_pools` snapshots into per-index +/// `core_address_pool` rows. Idempotent: `script` is derivation-stable and +/// `used` is monotonic (`MAX`), so re-applying the same snapshot is a no-op +/// and a used address can never revert to unused (the reuse-guard invariant). +pub fn apply_pools( + tx: &Transaction<'_>, + wallet_id: &WalletId, + pools: &[AccountAddressPoolEntry], +) -> Result<(), WalletStorageError> { + if pools.is_empty() { + return Ok(()); + } + let mut stmt = tx.prepare_cached(UPSERT_POOL_SQL)?; + for entry in pools { + // `account_type` discriminates accounts that collapse to the same + // `(account_index, key_class)` sentinel (e.g. `IdentityRegistration` + // and `ProviderVotingKeys`, both `0, 0`); without it they would upsert + // onto the same PK and overwrite each other's rows. + let account_type = accounts::account_type_db_label(&entry.account_type); + let account_index = i64::from(accounts::account_index(&entry.account_type)); + // TODO(key_class): PlatformPayment carries a real key_class; every + // other account maps to the 0 sentinel until the pool snapshot + // threads a per-pool key class. + let key_class = i64::from(accounts::account_key_class(&entry.account_type)); + let pool_type = pool_type_to_i64(entry.pool_type); + for info in &entry.addresses { + stmt.execute(params![ + wallet_id.as_slice(), + account_type, + account_index, + key_class, + pool_type, + i64::from(info.index), + info.script_pubkey.as_bytes(), + info.used, + ])?; + } + } + Ok(()) +} + +/// Owning account index for a UTXO, matched by its `script_pubkey` against a +/// pool row. `None` when no pool row covers the script — the UTXO writer +/// then falls back to account 0 (the one-way historical-attribution default, +/// R7): funds are never dropped, only conservatively bucketed. +pub fn account_index_for_script( + tx: &Transaction<'_>, + wallet_id: &WalletId, + script: &[u8], +) -> Result, WalletStorageError> { + // A script can appear under several pool rows (distinct account_type / + // key_class / pool_type share the same `script_pubkey` for reused keys); + // an explicit PK-ordered tie-break makes the pick deterministic instead of + // relying on SQLite's arbitrary `LIMIT 1` row. + let idx: Option = tx + .prepare_cached( + "SELECT account_index FROM core_address_pool \ + WHERE wallet_id = ?1 AND script = ?2 \ + ORDER BY account_type, account_index, key_class, pool_type, address_index ASC \ + LIMIT 1", + )? + .query_row(params![wallet_id.as_slice(), script], |row| row.get(0)) + .optional()?; + idx.map(|v| crate::sqlite::util::safe_cast::i64_to_u32("core_address_pool.account_index", v)) + .transpose() +} + +/// Used addresses for a wallet, read verbatim from `core_address_pool` +/// (`used = 1`) with no re-derivation. Possibly empty. The caller **unions** +/// this with the `core_utxos`-derived set — the reuse guard is monotonic, so +/// a mixed store (historical UTXOs a later partial pool snapshot never +/// enumerates) must surface both sources, never drop the historical ones. +/// +/// `network` turns each stored `script` back into an [`Address`]; a script +/// that isn't a valid address is a hard error — corruption is never silently +/// dropped, matching [`crate::sqlite::schema::core_state::load_used_addresses`]. +pub fn load_used_addresses( + conn: &rusqlite::Connection, + wallet_id: &WalletId, + network: dashcore::Network, +) -> Result, WalletStorageError> { + // Gate the largest stored `script` with a cheap aggregate BEFORE the + // `DISTINCT ... ORDER BY script` read materializes or sorts any blob, so a + // corrupt/oversize column raises a typed `BlobTooLarge` (the crate's 16 MiB + // cap) rather than SQLite's own `TooBig` mid-sort, and never OOMs the host. + let max_script_len: Option = conn.query_row( + "SELECT MAX(length(script)) FROM core_address_pool \ + WHERE wallet_id = ?1 AND used = 1", + params![wallet_id.as_slice()], + |row| row.get(0), + )?; + if let Some(len) = max_script_len { + blob::check_size(len)?; + } + let mut stmt = conn.prepare( + "SELECT DISTINCT script FROM core_address_pool \ + WHERE wallet_id = ?1 AND used = 1 ORDER BY script", + )?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + row.get::<_, Vec>(0) + })?; + let mut out = Vec::new(); + for r in rows { + let script = dashcore::ScriptBuf::from_bytes(r?); + let address = dashcore::Address::from_script(&script, network).map_err(|_| { + WalletStorageError::blob_decode("core_address_pool.script not an address") + })?; + out.push(address); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// In-memory connection with the full schema migrated in, so tests insert + /// through the production DDL. + fn migrated_conn() -> rusqlite::Connection { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + conn + } + + /// `account_index_for_script` is deterministic when several pool rows + /// share one script: the PK-ordered tie-break (`account_type` first) picks + /// the same row regardless of insert order, closing the `LIMIT 1`-without- + /// `ORDER BY` non-determinism. + #[test] + fn account_index_for_script_is_deterministic_on_shared_script() { + let mut conn = migrated_conn(); + let w = [0x77u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&w[..]], + ) + .unwrap(); + let script = [0xABu8; 25]; + let tx = conn.transaction().unwrap(); + // Same script under two account types with different account_index. + // Insert the later-sorting `standard_bip44` FIRST so a bare `LIMIT 1` + // could return either row depending on SQLite's scan order. + tx.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, 'standard_bip44', 9, 0, 0, 0, ?2, 1)", + params![&w[..], &script[..]], + ) + .unwrap(); + tx.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, 'coinjoin', 4, 0, 0, 0, ?2, 1)", + params![&w[..], &script[..]], + ) + .unwrap(); + // ORDER BY account_type ASC: 'coinjoin' < 'standard_bip44', so the + // coinjoin row (account_index 4) is the deterministic winner. + let got = account_index_for_script(&tx, &w, &script).unwrap(); + assert_eq!(got, Some(4), "tie-break must pick the account_type-min row"); + tx.commit().unwrap(); + } + + #[test] + fn pool_type_discriminants_are_stable_and_distinct() { + let all = [ + AddressPoolType::External, + AddressPoolType::Internal, + AddressPoolType::Absent, + AddressPoolType::AbsentHardened, + ]; + let mapped: Vec = all.iter().copied().map(pool_type_to_i64).collect(); + assert_eq!(mapped, vec![0, 1, 2, 3]); + } +} 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 9d81eddbf7..e6fae4bdd0 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 @@ -99,15 +99,16 @@ 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); the storage layer keeps no - // derived-address lookup table. + // `addresses_derived` is intentionally NOT persisted here — the pool + // snapshot (`account_address_pools`) is the derived-address source, and + // it is applied to `core_address_pool` before this in the same flush tx, + // so a UTXO's owning account resolves by matching its script against a + // pool row (falling back to account 0 when no pool row covers it). if !cs.new_utxos.is_empty() { let mut stmt = tx.prepare_cached(UPSERT_UTXO_SQL)?; for utxo in &cs.new_utxos { - execute_upsert_utxo(&mut stmt, wallet_id, utxo, false)?; + let account_index = resolve_account_index(tx, wallet_id, utxo)?; + execute_upsert_utxo(&mut stmt, wallet_id, utxo, account_index, false)?; } } if !cs.spent_utxos.is_empty() { @@ -126,11 +127,11 @@ pub fn apply( if exists { mark_spent_stmt.execute(params![wallet_id.as_slice(), &op[..]])?; } else { - // 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(&mut upsert_stmt, wallet_id, utxo, true)?; + // Spent-only synthetic row for a UTXO we never saw unspent; + // attribute like any other row (inert — spent rows are + // excluded from `list_unspent_utxos`). + let account_index = resolve_account_index(tx, wallet_id, utxo)?; + execute_upsert_utxo(&mut upsert_stmt, wallet_id, utxo, account_index, true)?; } } } @@ -179,20 +180,29 @@ const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ account_index = excluded.account_index, \ 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 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; +/// Owning account for a UTXO, resolved by matching its `script_pubkey` +/// against a `core_address_pool` row. Falls back to account 0 when no pool +/// row covers the script — the one-way historical-attribution default (R7): +/// funds are never dropped, only conservatively bucketed. +fn resolve_account_index( + tx: &Transaction<'_>, + wallet_id: &WalletId, + utxo: &Utxo, +) -> Result { + let script = utxo.txout.script_pubkey.as_bytes(); + let account = + crate::sqlite::schema::core_pool::account_index_for_script(tx, wallet_id, script)? + .unwrap_or(0); + Ok(i64::from(account)) +} -/// Upsert one `core_utxos` row. `account_index` is the hardcoded default -/// ([`CORE_UTXO_ACCOUNT_INDEX`]); `spent` marks spent-only synthetic rows. +/// Upsert one `core_utxos` row with its resolved `account_index`; `spent` +/// marks spent-only synthetic rows. fn execute_upsert_utxo( stmt: &mut rusqlite::CachedStatement<'_>, wallet_id: &WalletId, utxo: &Utxo, + account_index: i64, spent: bool, ) -> Result<(), WalletStorageError> { let op = blob::encode_outpoint(&utxo.outpoint)?; @@ -202,7 +212,7 @@ fn execute_upsert_utxo( crate::sqlite::util::safe_cast::u64_to_i64("core_utxos.value", utxo.value())?, utxo.txout.script_pubkey.as_bytes(), i64::from(utxo.height), - CORE_UTXO_ACCOUNT_INDEX, + account_index, spent, ])?; Ok(()) @@ -423,6 +433,20 @@ pub fn load_used_addresses( wallet_id: &WalletId, network: dashcore::Network, ) -> Result, WalletStorageError> { + // Gate the largest stored `script` with a cheap aggregate BEFORE the + // `DISTINCT ... ORDER BY script` read materializes or sorts any blob, so a + // corrupt/oversize column raises a typed `BlobTooLarge` (the crate's 16 MiB + // cap) rather than SQLite's own `TooBig` mid-sort, and never OOMs the host. + // `core_utxos` has no `(wallet_id, script)` index, so the read would sort + // the blob; the aggregate gate fires first regardless of query plan. + let max_script_len: Option = conn.query_row( + "SELECT MAX(length(script)) FROM core_utxos WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| row.get(0), + )?; + if let Some(len) = max_script_len { + blob::check_size(len)?; + } let mut stmt = conn .prepare("SELECT DISTINCT script FROM core_utxos WHERE wallet_id = ?1 ORDER BY script")?; let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs index 40a8ed251d..67a866ef8a 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs @@ -10,12 +10,14 @@ pub mod accounts; pub mod asset_locks; pub mod blob; pub mod contacts; +pub mod core_pool; pub mod core_state; pub mod dashpay; pub mod identities; pub mod identity_keys; pub mod platform_addrs; pub mod token_balances; +pub mod versions; pub mod wallets; /// Reject any `identity_id` in `touched` whose `identities` row does not diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs new file mode 100644 index 0000000000..1da424d9d6 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs @@ -0,0 +1,251 @@ +//! Store-scoped version + generation metadata: `meta_data_versions` and +//! `meta_store_generation`. +//! +//! `meta_data_versions` carries a monotonic `seq` per `(wallet_id, domain)`, +//! bumped inside the flush transaction so a domain's cache-invalidation +//! marker and its data commit atomically. `meta_store_generation` holds the +//! single store-generation token, stable across flushes and regenerated on +//! restore. + +use rusqlite::{params, Transaction}; + +use platform_wallet::changeset::{Merge, PlatformWalletChangeSet}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; + +/// A wallet-state family whose durable version `seq` is bumped when the +/// matching changeset field is flushed. One variant per persisted +/// changeset field — the cache-invalidation keystone (R8). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Domain { + Core, + Identities, + IdentityKeys, + Contacts, + PlatformAddresses, + AssetLocks, + TokenBalances, + DashpayProfiles, + DashpayPaymentsOverlay, + WalletMetadata, + AccountRegistrations, + AccountAddressPools, +} + +impl Domain { + /// Stable `meta_data_versions.domain` label. `Debug` is not a stable + /// wire format; this match is the contract. + pub fn as_str(self) -> &'static str { + match self { + Domain::Core => "core", + Domain::Identities => "identities", + Domain::IdentityKeys => "identity_keys", + Domain::Contacts => "contacts", + Domain::PlatformAddresses => "platform_addresses", + Domain::AssetLocks => "asset_locks", + Domain::TokenBalances => "token_balances", + Domain::DashpayProfiles => "dashpay_profiles", + Domain::DashpayPaymentsOverlay => "dashpay_payments_overlay", + Domain::WalletMetadata => "wallet_metadata", + Domain::AccountRegistrations => "account_registrations", + Domain::AccountAddressPools => "account_address_pools", + } + } + + /// Every domain, for coverage tests. + #[cfg(any(test, feature = "__test-helpers"))] + pub const ALL: [Domain; 12] = [ + Domain::Core, + Domain::Identities, + Domain::IdentityKeys, + Domain::Contacts, + Domain::PlatformAddresses, + Domain::AssetLocks, + Domain::TokenBalances, + Domain::DashpayProfiles, + Domain::DashpayPaymentsOverlay, + Domain::WalletMetadata, + Domain::AccountRegistrations, + Domain::AccountAddressPools, + ]; +} + +/// Domains carrying data in `cs`. The destructure is exhaustive (no `..`), so +/// adding a field to `PlatformWalletChangeSet` is a compile error here until +/// it gains a `Domain` variant and an arm below — the R8 forgotten-domain +/// guard. The feature-gated `shielded` field is bound under the storage +/// crate's pass-through `shielded` feature; storage versions no shielded state +/// here, so it is deliberately ignored, not mapped to a domain. +pub fn touched_domains(cs: &PlatformWalletChangeSet) -> Vec { + let PlatformWalletChangeSet { + core, + identities, + identity_keys, + contacts, + platform_addresses, + asset_locks, + token_balances, + dashpay_profiles, + dashpay_payments_overlay, + wallet_metadata, + account_registrations, + account_address_pools, + #[cfg(feature = "shielded")] + shielded, + } = cs; + #[cfg(feature = "shielded")] + let _ = shielded; + + // A sub-changeset carried but empty (`Some(default)`) is not a real + // change; the `Merge::is_empty` bound is the shared emptiness contract. + fn present(opt: &Option) -> bool { + !opt.is_empty() + } + + let mut out = Vec::new(); + if present(core) { + out.push(Domain::Core); + } + if present(identities) { + out.push(Domain::Identities); + } + if present(identity_keys) { + out.push(Domain::IdentityKeys); + } + if present(contacts) { + out.push(Domain::Contacts); + } + if present(platform_addresses) { + out.push(Domain::PlatformAddresses); + } + if present(asset_locks) { + out.push(Domain::AssetLocks); + } + if present(token_balances) { + out.push(Domain::TokenBalances); + } + if dashpay_profiles.as_ref().is_some_and(|m| !m.is_empty()) { + out.push(Domain::DashpayProfiles); + } + if dashpay_payments_overlay + .as_ref() + .is_some_and(|m| !m.is_empty()) + { + out.push(Domain::DashpayPaymentsOverlay); + } + if wallet_metadata.is_some() { + out.push(Domain::WalletMetadata); + } + if !account_registrations.is_empty() { + out.push(Domain::AccountRegistrations); + } + if !account_address_pools.is_empty() { + out.push(Domain::AccountAddressPools); + } + out +} + +/// Saturating increment of one domain's `seq`, inside the caller's flush tx. +/// The first bump sets `seq = 1`; thereafter it increments but never wraps past +/// `i64::MAX` — a wrap to a lower value would look like a rollback to a +/// client's memoized `(generation, domain, seq)` cache and silently +/// reintroduce staleness (the exact bug class R8 exists to prevent). +pub fn bump_domain( + tx: &Transaction<'_>, + wallet_id: &WalletId, + domain: Domain, +) -> Result<(), WalletStorageError> { + tx.prepare_cached( + "INSERT INTO meta_data_versions (wallet_id, domain, seq) VALUES (?1, ?2, 1) \ + ON CONFLICT(wallet_id, domain) DO UPDATE SET \ + seq = CASE WHEN seq >= 9223372036854775807 THEN seq ELSE seq + 1 END", + )? + .execute(params![wallet_id.as_slice(), domain.as_str()])?; + Ok(()) +} + +/// Bump every domain touched by `cs`, inside the caller's flush tx. +pub fn bump_touched_domains( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &PlatformWalletChangeSet, +) -> Result<(), WalletStorageError> { + for domain in touched_domains(cs) { + bump_domain(tx, wallet_id, domain)?; + } + Ok(()) +} + +/// Read the current `seq` for one `(wallet_id, domain)`; `0` when the domain +/// has never been bumped (no row). +#[cfg(any(test, feature = "__test-helpers"))] +pub fn read_seq( + conn: &rusqlite::Connection, + wallet_id: &WalletId, + domain: Domain, +) -> Result { + use rusqlite::OptionalExtension; + let seq: Option = conn + .query_row( + "SELECT seq FROM meta_data_versions WHERE wallet_id = ?1 AND domain = ?2", + params![wallet_id.as_slice(), domain.as_str()], + |row| row.get(0), + ) + .optional()?; + Ok(seq.unwrap_or(0)) +} + +/// Read the 16-byte store-generation token written by V002. `None` on a +/// pre-V002 store (the table is absent). +#[cfg(any(test, feature = "__test-helpers"))] +pub fn read_generation( + conn: &rusqlite::Connection, +) -> Result, WalletStorageError> { + use rusqlite::OptionalExtension; + if !generation_table_exists(conn)? { + return Ok(None); + } + let bytes: Option> = conn + .query_row( + "SELECT generation FROM meta_store_generation WHERE id = 0", + [], + |row| row.get(0), + ) + .optional()?; + match bytes { + None => Ok(None), + Some(b) => { + let arr: [u8; 16] = b.as_slice().try_into().map_err(|_| { + WalletStorageError::blob_decode("meta_store_generation.generation not 16 bytes") + })?; + Ok(Some(arr)) + } + } +} + +/// Regenerate the store-generation token so a restored copy is +/// distinguishable from its source. A no-op on a pre-V002 store (no table); +/// such a store gets a fresh token when it later migrates to V002. +pub fn regenerate_generation(conn: &rusqlite::Connection) -> Result<(), WalletStorageError> { + if !generation_table_exists(conn)? { + return Ok(()); + } + conn.execute( + "UPDATE meta_store_generation SET generation = randomblob(16) WHERE id = 0", + [], + )?; + Ok(()) +} + +fn generation_table_exists(conn: &rusqlite::Connection) -> Result { + use rusqlite::OptionalExtension; + Ok(conn + .query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta_store_generation'", + [], + |_| Ok(()), + ) + .optional()? + .is_some()) +} diff --git a/packages/rs-platform-wallet-storage/tests/fixture_gen.rs b/packages/rs-platform-wallet-storage/tests/fixture_gen.rs new file mode 100644 index 0000000000..8c1993ad45 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/fixture_gen.rs @@ -0,0 +1,386 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Populated-V001 fixture capture. +//! +//! `regenerate_populated_v001_fixture` (`#[ignore]`) writes a realistic +//! multi-wallet store, built by the CURRENT V001-only persister, to +//! `tests/fixtures/populated_v001.db`. That committed `.db` is the +//! regression anchor for the migration-execution suites (TC-B-031/032/033/ +//! 035/036): once V002 lands, a populated V001-only store is no longer +//! reproducible from source, so the bytes must be captured before any +//! schema change. +//! +//! `populated_v001_fixture_is_present_and_openable` is the always-run guard +//! that keeps the committed fixture honest: it opens read-only, asserts the +//! store is at schema version 1, and spot-checks the seeded rows. + +mod common; + +use std::path::{Path, PathBuf}; + +use common::wid; +use dpp::prelude::Identifier; +use key_wallet::account::{AccountType, StandardAccountType}; +use key_wallet::bip32::ExtendedPubKey; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::Network; +use platform_wallet::changeset::{ + AccountRegistrationEntry, ContactChangeSet, ContactRequestEntry, CoreChangeSet, + IdentityChangeSet, IdentityEntry, PlatformWalletChangeSet, PlatformWalletPersistence, + SentContactRequestKey, WalletMetadataEntry, +}; +use platform_wallet::wallet::identity::{ContactRequest, IdentityStatus}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +/// The two wallets the fixture carries. +const FULL_WALLET: u8 = 0xA1; +const EMPTY_WALLET: u8 = 0xB2; + +/// Absolute path to the committed fixture under the crate's test tree. +fn fixture_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("populated_v001.db") +} + +/// A deterministic test xpub decoded from a fixed serialized form, matching +/// the reconstruction suite so registrations round-trip reproducibly. +fn test_xpub() -> ExtendedPubKey { + ExtendedPubKey::decode(&hex::decode( + "0488B21E000000000000000000873DFF81C02F525623FD1FE5167EAC3A55A049DE3D314BB42EE227FFED37D5080339A36013301597DAEF41FBE593A02CC513D0B55527EC2DF1050E2E8FF49C85C2", + ).unwrap()).unwrap() +} + +/// First external address of the Standard BIP44 account 0, derived from a +/// fixed seed so the UTXO lands on a real, script-round-trippable address. +fn first_external_address(seed_byte: u8) -> dashcore::Address { + use key_wallet::managed_account::address_pool::AddressPoolType; + let wallet = Wallet::from_seed_bytes( + [seed_byte; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 0); + for managed in info.all_managed_accounts() { + if !matches!( + managed.managed_account_type().to_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 mut infos: Vec<_> = pool.addresses.values().cloned().collect(); + infos.sort_by_key(|a| a.index); + return infos.first().cloned().unwrap().address; + } + } + panic!("wallet must expose a non-empty Standard BIP44 external pool"); +} + +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: 200, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + } +} + +fn one_tx_record() -> key_wallet::managed_account::transaction_record::TransactionRecord { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, Txid}; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; + let mut record = TransactionRecord::new( + Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 200, + BlockHash::from_byte_array([0x03; 32]), + 1_735_689_600, + )), + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + Vec::new(), + 150_000, + ); + record.txid = Txid::from_byte_array([0x7E; 32]); + record +} + +fn identity_entry() -> IdentityEntry { + IdentityEntry { + id: Identifier::from([0xC1; 32]), + balance: 42, + revision: 1, + identity_index: Some(0), + last_updated_balance_block_time: None, + last_synced_keys_block_time: None, + dpns_names: Vec::new(), + contested_dpns_names: Vec::new(), + status: IdentityStatus::Active, + wallet_id: None, + dashpay_profile: None, + dashpay_payments: Default::default(), + } +} + +/// Build the populated multi-wallet store at `path` via the real persister. +fn build_populated_store(path: &Path) { + let cfg = SqlitePersisterConfig::new(path); + let persister = SqlitePersister::open(cfg).expect("open persister"); + + let full: WalletId = wid(FULL_WALLET); + let empty: WalletId = wid(EMPTY_WALLET); + + // Empty wallet: registered (a `wallets` row) but never synced. + persister + .store( + empty, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: empty, + birth_height: 50, + }), + ..Default::default() + }, + ) + .expect("store empty wallet meta"); + + // Full wallet: metadata + registration + core state + identity + contact. + persister + .store( + full, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: full, + birth_height: 100, + }), + account_registrations: vec![AccountRegistrationEntry { + account_type: AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + account_xpub: test_xpub(), + }], + ..Default::default() + }, + ) + .expect("store full wallet meta + registration"); + + let addr = first_external_address(FULL_WALLET); + persister + .store( + full, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records: vec![one_tx_record()], + new_utxos: vec![utxo_at(&addr, 0, 150_000)], + last_processed_height: Some(200), + synced_height: Some(200), + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("store full wallet core state"); + + let mut identities = std::collections::BTreeMap::new(); + let ident = identity_entry(); + identities.insert(ident.id, ident); + let owner = Identifier::from([0xC1; 32]); + let recipient = Identifier::from([0xC2; 32]); + let mut sent = std::collections::BTreeMap::new(); + sent.insert( + SentContactRequestKey { + owner_id: owner, + recipient_id: recipient, + }, + ContactRequestEntry { + request: ContactRequest { + sender_id: owner, + recipient_id: recipient, + sender_key_index: 0, + recipient_key_index: 0, + account_reference: 0, + encrypted_account_label: None, + encrypted_public_key: Vec::new(), + auto_accept_proof: None, + core_height_created_at: 200, + created_at: 0, + }, + }, + ); + persister + .store( + full, + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities, + removed: Default::default(), + }), + contacts: Some(ContactChangeSet { + sent_requests: sent, + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("store full wallet identity + contact"); + + persister.flush(full).expect("flush full"); + persister.flush(empty).expect("flush empty"); +} + +/// Fixture regenerator. Ignored by default — run explicitly to rebuild the +/// committed fixture: +/// `cargo test -p platform-wallet-storage --test fixture_gen -- --ignored regenerate`. +#[test] +#[ignore = "regenerator: rewrites the committed fixture on disk"] +fn regenerate_populated_v001_fixture() { + let tmp = tempfile::tempdir().expect("tempdir"); + let src = tmp.path().join("build.db"); + build_populated_store(&src); + + // Re-open the source and take a checkpointed single-file backup so the + // committed fixture carries no side WAL/journal. + let persister = + SqlitePersister::open(SqlitePersisterConfig::new(&src)).expect("reopen built store"); + let dest = fixture_path(); + if dest.exists() { + std::fs::remove_file(&dest).expect("remove stale fixture"); + } + persister.backup_to(&dest).expect("capture fixture backup"); +} + +/// Always-run guard: the committed fixture opens, is at schema version 1, +/// and carries the seeded rows (full wallet populated, empty wallet bare). +#[test] +fn populated_v001_fixture_is_present_and_openable() { + let path = fixture_path(); + assert!( + path.exists(), + "committed fixture missing at {}; regenerate with the #[ignore] test", + path.display() + ); + + let conn = rusqlite::Connection::open_with_flags( + &path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, + ) + .expect("open fixture read-only"); + + // The fixture is a V001-only store: refinery history tops out at 1. + let max_version: i64 = conn + .query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |r| r.get(0), + ) + .expect("read schema history"); + assert_eq!( + max_version, 1, + "fixture must be at V001, not a later schema" + ); + + let full = wid(FULL_WALLET); + let empty = wid(EMPTY_WALLET); + + let wallet_count: i64 = conn + .query_row("SELECT COUNT(*) FROM wallets", [], |r| r.get(0)) + .unwrap(); + assert_eq!(wallet_count, 2, "two wallets: one full, one empty"); + + let reg_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM account_registrations WHERE wallet_id = ?1", + rusqlite::params![full.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(reg_count, 1, "full wallet has one account registration"); + + let (utxo_count, account_index): (i64, i64) = conn + .query_row( + "SELECT COUNT(*), MAX(account_index) FROM core_utxos WHERE wallet_id = ?1", + rusqlite::params![full.as_slice()], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(utxo_count, 1, "full wallet has one unspent UTXO"); + assert_eq!( + account_index, 0, + "V001 hardcodes account_index=0 — the pre-redirect writer gap" + ); + + let tx_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_transactions WHERE wallet_id = ?1", + rusqlite::params![full.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(tx_count, 1, "full wallet has one transaction record"); + + let ident_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM identities WHERE wallet_id = ?1", + rusqlite::params![full.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(ident_count, 1, "full wallet has one identity"); + + let contact_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM contacts WHERE wallet_id = ?1", + rusqlite::params![full.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(contact_count, 1, "full wallet has one contact"); + + // The empty wallet is bare: a `wallets` row and nothing else. + let empty_utxos: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1", + rusqlite::params![empty.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(empty_utxos, 0, "empty wallet has no core state"); +} diff --git a/packages/rs-platform-wallet-storage/tests/fixtures/.gitignore b/packages/rs-platform-wallet-storage/tests/fixtures/.gitignore new file mode 100644 index 0000000000..5eaeada709 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/fixtures/.gitignore @@ -0,0 +1,4 @@ +# SQLite WAL/SHM side files produced when a test opens a committed fixture. +*.db-wal +*.db-shm +*.db-journal diff --git a/packages/rs-platform-wallet-storage/tests/fixtures/populated_v001.db b/packages/rs-platform-wallet-storage/tests/fixtures/populated_v001.db new file mode 100644 index 0000000000..8b5c4eb4ab Binary files /dev/null and b/packages/rs-platform-wallet-storage/tests/fixtures/populated_v001.db differ diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs b/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs index 3927d3ee2e..7aa20715cc 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs @@ -13,7 +13,9 @@ mod common; use common::{ensure_wallet_meta, fresh_persister, wid}; use rusqlite::params; -use platform_wallet_storage::sqlite::schema::{accounts, core_state, identities, identity_keys}; +use platform_wallet_storage::sqlite::schema::{ + accounts, core_pool, core_state, identities, identity_keys, +}; use platform_wallet_storage::WalletStorageError; /// Blob larger than the 16 MiB cap: one byte over the limit is enough to @@ -104,6 +106,68 @@ fn blob_gate_core_state_load_state_rejects_oversize_chain_lock() { ); } +// ── core_pool::load_used_addresses — core_address_pool script ──────────────── + +/// An oversize `script` blob in `core_address_pool` is caught by the pre-read +/// `length(script)` gate in `core_pool::load_used_addresses` and returned as +/// `BlobTooLarge` **before** the Vec is allocated. +#[test] +fn blob_gate_core_pool_load_used_addresses_rejects_oversize_script() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xE1); + ensure_wallet_meta(&persister, &w); + + let oversize_script = oversize_blob(); + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, 'standard_bip44', 0, 0, 0, 0, ?2, 1)", + params![w.as_slice(), oversize_script.as_slice()], + ) + .expect("insert oversize pool script row"); + + let err = core_pool::load_used_addresses(&conn, &w, dashcore::Network::Testnet) + .expect_err("load_used_addresses must reject an oversize pool script blob"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge for oversize pool script, got {err:?}" + ); +} + +// ── core_state::load_used_addresses — core_utxos script ────────────────────── + +/// An oversize `script` blob in `core_utxos` is caught by the pre-read +/// `length(script)` gate in `core_state::load_used_addresses` and returned as +/// `BlobTooLarge` **before** the Vec is allocated. +#[test] +fn blob_gate_core_state_load_used_addresses_rejects_oversize_script() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xE2); + ensure_wallet_meta(&persister, &w); + + let oversize_script = oversize_blob(); + // 33-byte outpoint (txid 32 + vout 1); its own gate passes, only the + // script gate fires. + let tiny_op = vec![0u8; 33]; + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ + VALUES (?1, ?2, 0, ?3, NULL, 0, 0, NULL)", + params![w.as_slice(), tiny_op.as_slice(), oversize_script.as_slice()], + ) + .expect("insert oversize utxo script row"); + + let err = core_state::load_used_addresses(&conn, &w, dashcore::Network::Testnet) + .expect_err("load_used_addresses must reject an oversize utxo script blob"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge for oversize utxo script, got {err:?}" + ); +} + // ── platform_addrs — address column (fixed 20 bytes) ──────────────────────── /// A `platform_addresses` row whose `address` column is wider than 20 bytes 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 2b41a43aa6..e2fee57809 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -67,6 +67,11 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ "SELECT length(outpoint), outpoint, value, length(script), script, height", ), ("core_state.rs", "SELECT DISTINCT script FROM core_utxos"), + // Pool reader: verbatim used-set, a one-shot read-only scan per wallet. + ( + "core_pool.rs", + "SELECT DISTINCT script FROM core_address_pool", + ), // Full-rehydration readers — one-shot SELECTs in `load_state`. ( "accounts.rs", diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_core_pool_writer.rs b/packages/rs-platform-wallet-storage/tests/sqlite_core_pool_writer.rs new file mode 100644 index 0000000000..961a757967 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_core_pool_writer.rs @@ -0,0 +1,507 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `core_address_pool` writer + `core_utxos.account_index` attribution. +//! Covers TC-B-001 (pool rows with `used` flags), TC-B-002 +//! (real account_index, not the retired `=0` constant), TC-B-010 (idempotent +//! per-changeset pool state), TC-B-015 (`key_class` survives). + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use key_wallet::account::{AccountType, StandardAccountType}; +use key_wallet::managed_account::address_pool::AddressPoolType; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::{AddressInfo, Network, Utxo}; +use platform_wallet::changeset::{ + AccountAddressPoolEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::platform_wallet::WalletId; + +/// Real external-pool `AddressInfo`s for a wallet's Standard BIP44 account 0, +/// sorted by derivation index — genuine scripts that round-trip. +fn external_infos(seed_byte: u8) -> Vec { + let wallet = Wallet::from_seed_bytes( + [seed_byte; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 0); + for managed in info.all_managed_accounts() { + if !matches!( + managed.managed_account_type().to_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 mut infos: Vec = pool.addresses.values().cloned().collect(); + infos.sort_by_key(|a| a.index); + return infos; + } + } + panic!("wallet must expose a non-empty Standard BIP44 external pool"); +} + +fn utxo_on(info: &AddressInfo, value: u64) -> Utxo { + use dashcore::hashes::Hash; + Utxo { + outpoint: dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([info.index as u8 ^ 0x5A; 32]), + vout: 0, + }, + txout: dashcore::TxOut { + value, + script_pubkey: info.script_pubkey.clone(), + }, + address: info.address.clone(), + height: 10, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + } +} + +fn pool_entry( + account_type: AccountType, + pool_type: AddressPoolType, + addresses: Vec, +) -> AccountAddressPoolEntry { + AccountAddressPoolEntry { + account_type, + pool_type, + addresses, + } +} + +/// TC-B-001 — six pool rows with `used` set on indices {0,2,4}; the pool +/// table is a first-class row store, not a `core_utxos` derivation. +#[test] +fn tc_b_001_pool_rows_with_used_flags() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA0); + ensure_wallet_meta(&persister, &w); + + let mut infos = external_infos(0x11); + infos.truncate(6); + assert_eq!(infos.len(), 6, "need at least six derived addresses"); + for info in infos.iter_mut() { + info.used = matches!(info.index, 0 | 2 | 4); + } + let entry = pool_entry( + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + AddressPoolType::External, + infos.clone(), + ); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![entry], + ..Default::default() + }, + ) + .unwrap(); + + let conn = persister.lock_conn_for_test(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_index = 0 AND key_class = 0 AND pool_type = 0", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 6, "exactly six scoped rows"); + + for info in &infos { + let used: i64 = conn + .query_row( + "SELECT used FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_index = 0 AND key_class = 0 \ + AND pool_type = 0 AND address_index = ?2", + rusqlite::params![w.as_slice(), i64::from(info.index)], + |r| r.get(0), + ) + .unwrap(); + let expect = i64::from(matches!(info.index, 0 | 2 | 4)); + assert_eq!(used, expect, "used flag for index {}", info.index); + } +} + +/// TC-B-002 — a UTXO whose owning account is index 1 stores +/// `account_index = 1`, not the retired hardcoded 0. +#[test] +fn tc_b_002_account_index_is_real_not_zero() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA2); + ensure_wallet_meta(&persister, &w); + + let infos = external_infos(0x22); + let addr0 = infos[0].clone(); + let addr1 = infos[1].clone(); + + // Pools declaring the address' owning account: addr0 -> account 0, + // addr1 -> account 1 (non-default). + let pools = vec![ + pool_entry( + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + AddressPoolType::External, + vec![addr0.clone()], + ), + pool_entry( + AccountType::Standard { + index: 1, + standard_account_type: StandardAccountType::BIP44Account, + }, + AddressPoolType::External, + vec![addr1.clone()], + ), + ]; + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: pools, + core: Some(CoreChangeSet { + new_utxos: vec![utxo_on(&addr0, 111), utxo_on(&addr1, 222)], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let conn = persister.lock_conn_for_test(); + let account_for = |script: &[u8]| -> i64 { + conn.query_row( + "SELECT account_index FROM core_utxos WHERE wallet_id = ?1 AND script = ?2", + rusqlite::params![w.as_slice(), script], + |r| r.get(0), + ) + .unwrap() + }; + assert_eq!( + account_for(addr1.script_pubkey.as_bytes()), + 1, + "UTXO on account 1's address must store account_index = 1" + ); + assert_eq!( + account_for(addr0.script_pubkey.as_bytes()), + 0, + "UTXO on account 0's address must store account_index = 0" + ); +} + +/// A UTXO whose script matches no pool row falls back to account 0 — the +/// one-way historical-attribution default (R7), funds never dropped. +#[test] +fn utxo_without_pool_row_defaults_to_account_zero() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA3); + ensure_wallet_meta(&persister, &w); + + let infos = external_infos(0x33); + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo_on(&infos[0], 500)], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let conn = persister.lock_conn_for_test(); + let account: i64 = conn + .query_row( + "SELECT account_index FROM core_utxos WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(account, 0, "unattributed UTXO defaults to account 0"); +} + +/// TC-B-010 — a used-flag flip persists and a second no-op flush leaves the +/// pool rows unchanged; `used` is monotonic and never reverts. +#[test] +fn tc_b_010_pool_state_idempotent_and_monotonic() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA4); + ensure_wallet_meta(&persister, &w); + + let mut infos = external_infos(0x44); + infos.truncate(3); + let mk = |infos: &[AddressInfo]| { + pool_entry( + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + AddressPoolType::External, + infos.to_vec(), + ) + }; + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![mk(&infos)], + ..Default::default() + }, + ) + .unwrap(); + + // Flip index 1 to used. + let mut flipped = infos.clone(); + flipped[1].used = true; + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![mk(&flipped)], + ..Default::default() + }, + ) + .unwrap(); + + let used_of = |conn: &rusqlite::Connection, idx: u32| -> i64 { + conn.query_row( + "SELECT used FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_index = 0 AND pool_type = 0 AND address_index = ?2", + rusqlite::params![w.as_slice(), i64::from(idx)], + |r| r.get(0), + ) + .unwrap() + }; + { + let conn = persister.lock_conn_for_test(); + assert_eq!(used_of(&conn, 1), 1, "flip must persist"); + assert_eq!(used_of(&conn, 0), 0, "unrelated row unchanged"); + } + + // A stale snapshot with used=false for index 1 must NOT un-use it. + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![mk(&infos)], + ..Default::default() + }, + ) + .unwrap(); + let conn = persister.lock_conn_for_test(); + assert_eq!( + used_of(&conn, 1), + 1, + "used is monotonic — a stale snapshot never reverts it" + ); +} + +/// TC-B-015 — a non-default `key_class` round-trips into the pool row's PK. +#[test] +fn tc_b_015_key_class_survives() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA5); + ensure_wallet_meta(&persister, &w); + + let infos = external_infos(0x55); + let entry = pool_entry( + AccountType::PlatformPayment { + account: 2, + key_class: 1, + }, + AddressPoolType::External, + vec![infos[0].clone()], + ); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![entry], + ..Default::default() + }, + ) + .unwrap(); + + let conn = persister.lock_conn_for_test(); + let (account_index, key_class): (i64, i64) = conn + .query_row( + "SELECT account_index, key_class FROM core_address_pool WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(account_index, 2, "PlatformPayment account index"); + assert_eq!(key_class, 1, "non-default key_class must survive"); +} + +/// A single external `AddressInfo` at derivation index 0 for a seed, with a +/// chosen `used` flag. Two seeds yield distinct scripts so a cross-account +/// overwrite is observable. +fn index_zero_info(seed_byte: u8, used: bool) -> Vec { + let mut infos = external_infos(seed_byte); + infos.truncate(1); + infos[0].used = used; + infos +} + +/// Assert the pool rows for `(wallet, account_type)` are exactly `(script, +/// used)`, and that `total` rows exist for the wallet overall. +fn assert_pool_row( + persister: &platform_wallet_storage::SqlitePersister, + w: &WalletId, + label: &str, + want_script: &[u8], + want_used: i64, +) { + let conn = persister.lock_conn_for_test(); + let (script, used): (Vec, i64) = conn + .query_row( + "SELECT script, used FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_type = ?2", + rusqlite::params![w.as_slice(), label], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap_or_else(|e| panic!("expected exactly one row for {label}: {e}")); + assert_eq!(script, want_script, "{label} script must survive verbatim"); + assert_eq!(used, want_used, "{label} used flag must survive"); +} + +/// Two account types that both collapse to the `(account_index=0, +/// key_class=0)` sentinel — `IdentityRegistration` and `ProviderVotingKeys` — +/// must not overwrite each other's pool rows. Before the PK was widened with +/// `account_type` they upserted onto one PK tuple, silently losing one +/// account's `script` and merging `used`. +#[test] +fn distinct_account_types_sharing_index_zero_do_not_collide() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA6); + ensure_wallet_meta(&persister, &w); + + let id_reg = index_zero_info(0x61, true); + let prov = index_zero_info(0x62, false); + assert_ne!( + id_reg[0].script_pubkey, prov[0].script_pubkey, + "the two account types must carry distinct scripts to prove no overwrite" + ); + + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![ + pool_entry( + AccountType::IdentityRegistration, + AddressPoolType::External, + id_reg.clone(), + ), + pool_entry( + AccountType::ProviderVotingKeys, + AddressPoolType::External, + prov.clone(), + ), + ], + ..Default::default() + }, + ) + .unwrap(); + + assert_pool_row( + &persister, + &w, + "identity_registration", + id_reg[0].script_pubkey.as_bytes(), + 1, + ); + assert_pool_row( + &persister, + &w, + "provider_voting", + prov[0].script_pubkey.as_bytes(), + 0, + ); + let conn = persister.lock_conn_for_test(); + let total: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_address_pool WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(total, 2, "both account types must persist as separate rows"); +} + +/// `Standard { index: 0 }` and `CoinJoin { index: 0 }` also both map to +/// `(account_index=0, key_class=0)` yet are distinct accounts; the +/// `account_type` discriminator (`standard_bip44` vs `coinjoin`) must keep +/// their pool rows separate. +#[test] +fn standard_and_coinjoin_index_zero_do_not_collide() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA7); + ensure_wallet_meta(&persister, &w); + + let std0 = index_zero_info(0x71, true); + let cj0 = index_zero_info(0x72, false); + assert_ne!( + std0[0].script_pubkey, cj0[0].script_pubkey, + "the two account types must carry distinct scripts to prove no overwrite" + ); + + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![ + pool_entry( + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + AddressPoolType::External, + std0.clone(), + ), + pool_entry( + AccountType::CoinJoin { index: 0 }, + AddressPoolType::External, + cj0.clone(), + ), + ], + ..Default::default() + }, + ) + .unwrap(); + + assert_pool_row( + &persister, + &w, + "standard_bip44", + std0[0].script_pubkey.as_bytes(), + 1, + ); + assert_pool_row( + &persister, + &w, + "coinjoin", + cj0[0].script_pubkey.as_bytes(), + 0, + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs b/packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs new file mode 100644 index 0000000000..f49e880211 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs @@ -0,0 +1,410 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Migration execution against the populated-V001 fixture. +//! Covers TC-B-031 (data preserved), TC-B-032 (pre-migration auto-backup), +//! TC-B-033 (backup restorable + re-migration determinism), TC-B-034 +//! (forward-version rejection at the new max), TC-B-035 (idempotent +//! re-entry), TC-B-036 (empty wallet through migration). + +mod common; + +use std::path::{Path, PathBuf}; + +use common::{ro_conn, wid}; +use platform_wallet::changeset::PlatformWalletPersistence; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError}; +use rusqlite::Connection; + +const FULL_WALLET: u8 = 0xA1; +const EMPTY_WALLET: u8 = 0xB2; + +fn fixture_src() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("populated_v001.db") +} + +/// Copy the committed V001 fixture into `dir` so migration runs on a +/// throwaway copy, never the committed file. +fn copy_fixture(dir: &Path) -> PathBuf { + let dst = dir.join("wallet.db"); + std::fs::copy(fixture_src(), &dst).expect("copy fixture"); + dst +} + +fn schema_version(conn: &Connection) -> i64 { + conn.query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |r| r.get(0), + ) + .unwrap() +} + +fn table_exists(conn: &Connection, table: &str) -> bool { + conn.query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1", + rusqlite::params![table], + |_| Ok(()), + ) + .is_ok() +} + +fn count(conn: &Connection, sql: &str, wallet: &[u8; 32]) -> i64 { + conn.query_row(sql, rusqlite::params![wallet.as_slice()], |r| r.get(0)) + .unwrap() +} + +/// Assert the post-migration store carries the full fixture data intact. +fn assert_full_data_preserved(conn: &Connection) { + let full = wid(FULL_WALLET); + assert_eq!(schema_version(conn), 2, "must be migrated to V002"); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM wallets", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 2, + "both wallets preserved" + ); + assert_eq!( + count( + conn, + "SELECT COUNT(*) FROM account_registrations WHERE wallet_id = ?1", + &full + ), + 1 + ); + let (utxos, acct): (i64, i64) = conn + .query_row( + "SELECT COUNT(*), MAX(account_index) FROM core_utxos WHERE wallet_id = ?1", + rusqlite::params![full.as_slice()], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(utxos, 1, "UTXO preserved"); + assert_eq!( + acct, 0, + "pre-existing UTXO keeps account_index=0 (R7 one-way backfill)" + ); + assert_eq!( + count( + conn, + "SELECT COUNT(*) FROM core_transactions WHERE wallet_id = ?1", + &full + ), + 1 + ); + assert_eq!( + count( + conn, + "SELECT COUNT(*) FROM identities WHERE wallet_id = ?1", + &full + ), + 1 + ); + assert_eq!( + count( + conn, + "SELECT COUNT(*) FROM contacts WHERE wallet_id = ?1", + &full + ), + 1 + ); + // New V002 tables exist with sane defaults. + assert!(table_exists(conn, "core_address_pool")); + assert!(table_exists(conn, "meta_data_versions")); + let gen_len: i64 = conn + .query_row( + "SELECT length(generation) FROM meta_store_generation WHERE id = 0", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(gen_len, 16, "generation seeded at migration"); +} + +/// TC-B-031 — opening a populated V001 fixture with the post-redirect binary +/// migrates it and preserves every pre-existing row. +#[test] +fn tc_b_031_populated_v001_migration_preserves_data() { + let tmp = tempfile::tempdir().unwrap(); + let path = copy_fixture(tmp.path()); + { + let pre = ro_conn(&path); + assert_eq!(schema_version(&pre), 1, "fixture starts at V001"); + } + let p = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + { + let conn = p.lock_conn_for_test(); + assert_full_data_preserved(&conn); + } + // The full wallet reconstructs; the used-set falls back to the + // UTXO-derived address (no pool rows in a migrated store). + let state = p.load().unwrap(); + let full = wid(FULL_WALLET); + let slice = state.wallets.get(&full).expect("full wallet reconstructs"); + assert_eq!( + slice.used_core_addresses.len(), + 1, + "migrated store falls back to the UTXO-derived used-set" + ); +} + +/// TC-B-036 — the empty wallet inside the populated store migrates without a +/// NOT NULL violation and reads empty-but-valid. +#[test] +fn tc_b_036_empty_wallet_through_migration() { + let tmp = tempfile::tempdir().unwrap(); + let path = copy_fixture(tmp.path()); + let p = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let state = p.load().unwrap(); + let empty = wid(EMPTY_WALLET); + let slice = state + .wallets + .get(&empty) + .expect("empty wallet still surfaces post-migration"); + assert!( + slice.used_core_addresses.is_empty(), + "empty wallet is empty-but-valid, not corrupt" + ); +} + +/// TC-B-032 — a byte-faithful pre-migration auto-backup is written before the +/// schema changes are visible in the live file. +#[test] +fn tc_b_032_pre_migration_backup_created() { + let tmp = tempfile::tempdir().unwrap(); + let path = copy_fixture(tmp.path()); + let backup_dir = tmp.path().join("backups"); + let p = SqlitePersister::open( + SqlitePersisterConfig::new(&path).with_auto_backup_dir(Some(backup_dir.clone())), + ) + .unwrap(); + drop(p); + + let backup = std::fs::read_dir(&backup_dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .find(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("pre-migration-1-to-2-") && n.ends_with(".db")) + }) + .expect("pre-migration backup must exist"); + + // The backup captured the PRE-migration state: schema version 1, and no + // V002 table. + let bconn = ro_conn(&backup); + assert_eq!( + schema_version(&bconn), + 1, + "backup is the pre-migration V001 state" + ); + assert!( + !table_exists(&bconn, "core_address_pool"), + "backup must predate the V002 schema" + ); + assert_eq!( + bconn + .query_row("SELECT COUNT(*) FROM wallets", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 2, + "backup carries the original data" + ); +} + +/// TC-B-033 — the pre-migration backup restores cleanly and re-migrating it +/// reaches the identical end state as a direct migration (determinism). +#[test] +fn tc_b_033_backup_restorable_and_remigration_deterministic() { + let tmp = tempfile::tempdir().unwrap(); + let path = copy_fixture(tmp.path()); + let backup_dir = tmp.path().join("backups"); + { + let _p = SqlitePersister::open( + SqlitePersisterConfig::new(&path).with_auto_backup_dir(Some(backup_dir.clone())), + ) + .unwrap(); + } + let backup = std::fs::read_dir(&backup_dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .find(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("pre-migration-1-to-2-")) + }) + .expect("backup exists"); + + // Restore the V001 backup into a fresh dest, then reopen to re-migrate. + let dest = tmp.path().join("restored.db"); + SqlitePersister::restore_from_skip_backup(&dest, &backup).expect("restore V001 backup"); + { + let rconn = ro_conn(&dest); + assert_eq!(schema_version(&rconn), 1, "restored store is at V001"); + } + let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&dest)).unwrap(); + let conn = p2.lock_conn_for_test(); + assert_full_data_preserved(&conn); +} + +/// TC-B-034 — the forward-version gate now rejects at the NEW max (2); a +/// forged version-3 row is refused. +#[test] +fn tc_b_034_forward_version_rejected_at_new_max() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("wallet.db"); + { + let _p = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + } + { + let conn = Connection::open(&path).unwrap(); + conn.execute( + "INSERT INTO refinery_schema_history (version, name, applied_on, checksum) \ + VALUES (3, 'future', '', '0')", + [], + ) + .unwrap(); + } + match SqlitePersister::open(SqlitePersisterConfig::new(&path)) { + Err(WalletStorageError::SchemaVersionUnsupported { + found, + max_supported, + }) => { + assert_eq!(found, 3); + assert_eq!(max_supported, 2, "max must reflect the post-redirect V002"); + } + Err(other) => panic!("expected SchemaVersionUnsupported, got {other:?}"), + Ok(_) => panic!("forward-version DB must be refused"), + } +} + +/// A structural + row snapshot of the affected tables, for convergence +/// comparison between a clean migration and a recovered one. Excludes the +/// per-store random generation token (unique by design). +fn migration_snapshot(conn: &Connection) -> Vec { + let full = wid(FULL_WALLET); + vec![ + schema_version(conn), + conn.query_row("SELECT COUNT(*) FROM wallets", [], |r| r.get(0)) + .unwrap(), + count( + conn, + "SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1", + &full, + ), + count( + conn, + "SELECT COUNT(*) FROM core_transactions WHERE wallet_id = ?1", + &full, + ), + count( + conn, + "SELECT COUNT(*) FROM identities WHERE wallet_id = ?1", + &full, + ), + count( + conn, + "SELECT COUNT(*) FROM contacts WHERE wallet_id = ?1", + &full, + ), + count( + conn, + "SELECT COUNT(*) FROM account_registrations WHERE wallet_id = ?1", + &full, + ), + i64::from(table_exists(conn, "core_address_pool")), + i64::from(table_exists(conn, "meta_data_versions")), + i64::from(table_exists(conn, "meta_store_generation")), + ] +} + +/// TC-B-035 — crash mid-migrate: an interrupted V002 (partial DDL, no commit) +/// leaves the store at the last committed version (V001) with no partial +/// tables; re-opening resumes and converges byte-equal to a clean direct +/// migration. Empirically demonstrates refinery's per-migration transaction +/// guarantee (one tx per migration — no `set_grouped`/`no_transaction`). +#[test] +fn tc_b_035_interrupted_migration_recovers_to_clean_state() { + // Reference: a fresh copy migrated straight through. + let clean_dir = tempfile::tempdir().unwrap(); + let clean_path = copy_fixture(clean_dir.path()); + let clean_snapshot = { + let p = SqlitePersister::open(SqlitePersisterConfig::new(&clean_path)).unwrap(); + let conn = p.lock_conn_for_test(); + migration_snapshot(&conn) + }; + assert_eq!(clean_snapshot[0], 2, "clean migration reaches V002"); + + // Crash simulation: apply part of V002's DDL inside a transaction that is + // rolled back before commit — exactly what a crash before the migration's + // single COMMIT leaves behind (SQLite DDL is transactional). + let crash_dir = tempfile::tempdir().unwrap(); + let crash_path = copy_fixture(crash_dir.path()); + { + let conn = Connection::open(&crash_path).unwrap(); + conn.execute_batch( + "BEGIN; \ + CREATE TABLE core_address_pool ( \ + wallet_id BLOB NOT NULL, account_type TEXT NOT NULL, \ + account_index INTEGER NOT NULL, \ + key_class INTEGER NOT NULL, pool_type INTEGER NOT NULL, \ + address_index INTEGER NOT NULL, script BLOB NOT NULL, \ + used INTEGER NOT NULL); \ + ROLLBACK;", + ) + .unwrap(); + // The rolled-back DDL left no trace: still V001, no partial table. + let pre = ro_conn(&crash_path); + assert_eq!(schema_version(&pre), 1, "interrupted migrate stays at V001"); + assert!( + !table_exists(&pre, "core_address_pool"), + "partial DDL must have rolled back" + ); + } + + // Recovery: re-open runs the pending migration cleanly. + let recovered_snapshot = { + let p = SqlitePersister::open(SqlitePersisterConfig::new(&crash_path)).unwrap(); + let conn = p.lock_conn_for_test(); + migration_snapshot(&conn) + }; + assert_eq!( + recovered_snapshot, clean_snapshot, + "a store recovered from an interrupted migration must converge to the \ + same end state as a clean direct migration" + ); +} + +/// Re-entry idempotency: reopening a fully-migrated store is a no-op — no +/// further migration, and the generation token does not rotate (it only +/// rotates on migrate/restore, not a plain reopen). +#[test] +fn reopen_of_migrated_store_is_idempotent() { + let tmp = tempfile::tempdir().unwrap(); + let path = copy_fixture(tmp.path()); + let read = |conn: &Connection| -> (Vec, [u8; 16]) { + let gen: Vec = conn + .query_row( + "SELECT generation FROM meta_store_generation WHERE id = 0", + [], + |r| r.get(0), + ) + .unwrap(); + (migration_snapshot(conn), gen.try_into().unwrap()) + }; + let first = { + let p = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = p.lock_conn_for_test(); + read(&conn) + }; + let second = { + let p = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = p.lock_conn_for_test(); + read(&conn) + }; + assert_eq!(first.0[0], 2, "first open migrates to V002"); + assert_eq!(first, second, "reopen is a byte-stable no-op"); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs new file mode 100644 index 0000000000..f550d8b210 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs @@ -0,0 +1,345 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Verbatim pool-snapshot reader. Covers TC-B-020 (used-set +//! comes from `core_address_pool`, not `core_utxos` re-derivation), TC-B-023 +//! (deep-derivation window — no horizon-walk truncation), TC-B-025/007 +//! (empty wallet loads empty-but-valid), multi-wallet isolation (TC-B-026), +//! and the pool ∪ `core_utxos` used-set union (pre-pool + mixed stores). + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use dashcore::address::Payload; +use dashcore::hashes::Hash; +use dashcore::{Address, Network, PubkeyHash}; +use key_wallet::account::{AccountType, StandardAccountType}; +use key_wallet::managed_account::address_pool::AddressPoolType; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::{AddressInfo, Utxo}; +use platform_wallet::changeset::{ + AccountAddressPoolEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::platform_wallet::WalletId; + +fn external_infos(seed_byte: u8) -> Vec { + let wallet = Wallet::from_seed_bytes( + [seed_byte; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 0); + for managed in info.all_managed_accounts() { + if !matches!( + managed.managed_account_type().to_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() { + let mut infos: Vec = pool.addresses.values().cloned().collect(); + infos.sort_by_key(|a| a.index); + return infos; + } + } + } + panic!("no external pool"); +} + +fn p2pkh(byte: u8) -> Address { + Address::new( + Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([byte; 20])), + ) +} + +/// TC-B-020 — the used-set is the verbatim pool `used=1` state, computed +/// without touching `core_utxos`: no UTXO is stored, yet the used addresses +/// surface (a projection-derived reader would return an empty set). +#[test] +fn tc_b_020_used_set_from_pool_not_utxos() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x20); + ensure_wallet_meta(&persister, &w); + + let mut infos = external_infos(0x20); + infos.truncate(10); + assert_eq!(infos.len(), 10); + let used_indices = [0u32, 3, 7]; + for info in infos.iter_mut() { + info.used = used_indices.contains(&info.index); + } + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![AccountAddressPoolEntry { + account_type: AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + pool_type: AddressPoolType::External, + addresses: infos.clone(), + }], + ..Default::default() + }, + ) + .unwrap(); + + let state = persister.load().unwrap(); + let slice = state.wallets.get(&w).expect("wallet surfaces in load"); + let got: std::collections::BTreeSet = slice + .used_core_addresses + .iter() + .map(|a| a.to_string()) + .collect(); + let expected: std::collections::BTreeSet = infos + .iter() + .filter(|i| used_indices.contains(&i.index)) + .map(|i| i.address.to_string()) + .collect(); + assert_eq!(got, expected, "used-set must equal the pool's used=1 rows"); +} + +/// TC-B-023 — a wallet whose pool advanced past the old horizon-walk window +/// (used up to index 45, then 30 unused) restores its full used-set: the +/// index-45 address is present, never truncated at 30. +#[test] +fn tc_b_023_deep_derivation_window_not_truncated() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x23); + ensure_wallet_meta(&persister, &w); + { + let conn = persister.lock_conn_for_test(); + for i in 0u32..=75 { + let used = i32::from(i <= 45); + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, 'standard_bip44', 0, 0, 0, ?2, ?3, ?4)", + rusqlite::params![ + w.as_slice(), + i64::from(i), + p2pkh(i as u8).script_pubkey().as_bytes(), + used + ], + ) + .unwrap(); + } + } + + let state = persister.load().unwrap(); + let slice = state.wallets.get(&w).expect("wallet surfaces"); + assert_eq!( + slice.used_core_addresses.len(), + 46, + "indices 0..=45 are used and must all restore" + ); + let want = p2pkh(45).to_string(); + assert!( + slice + .used_core_addresses + .iter() + .any(|a| a.to_string() == want), + "the index-45 used address must survive (no gap-limit-30 truncation)" + ); +} + +/// TC-B-025/007 — an empty wallet (a `wallets` row, no pool rows, no UTXOs) +/// loads as empty-but-valid: present with an empty used-set, not corrupt. +#[test] +fn tc_b_025_empty_wallet_is_empty_but_valid() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x25); + ensure_wallet_meta(&persister, &w); + + let state = persister.load().unwrap(); + let slice = state + .wallets + .get(&w) + .expect("empty wallet must still surface"); + assert!( + slice.used_core_addresses.is_empty(), + "empty wallet has an empty used-set" + ); +} + +fn utxo_on(addr: &Address, byte: u8, value: u64) -> Utxo { + Utxo::new( + dashcore::OutPoint::new(dashcore::Txid::from_byte_array([byte; 32]), 0), + dashcore::TxOut { + value, + script_pubkey: addr.script_pubkey(), + }, + addr.clone(), + 10, + false, + ) +} + +/// A pre-pool store (UTXOs, no `core_address_pool` rows) yields the +/// reuse-guard set from the `core_utxos`-derived half of the union. +#[test] +fn pre_pool_store_yields_utxo_derived_used_set() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x26); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x99); + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo_on(&addr, 0x11, 1000)], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let state = persister.load().unwrap(); + let slice = state.wallets.get(&w).expect("wallet surfaces"); + assert_eq!(slice.used_core_addresses.len(), 1); + assert_eq!(slice.used_core_addresses[0].to_string(), addr.to_string()); +} + +/// TC-B-026 — reader multi-wallet isolation: two wallets seeded with +/// distinct, distinguishable used addresses (and balances) load such that +/// neither wallet's snapshot shows the other's — no cross-wallet leakage. +#[test] +fn tc_b_026_reader_isolates_two_wallets() { + let (persister, _tmp, _path) = fresh_persister(); + let a: WalletId = wid(0x2A); + let b: WalletId = wid(0x2B); + ensure_wallet_meta(&persister, &a); + ensure_wallet_meta(&persister, &b); + + let addr_a = p2pkh(0xA1); + let addr_b = p2pkh(0xB1); + persister + .store( + a, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo_on(&addr_a, 0x01, 111)], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + persister + .store( + b, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo_on(&addr_b, 0x02, 222)], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let state = persister.load().unwrap(); + let a_used: Vec = state.wallets[&a] + .used_core_addresses + .iter() + .map(|x| x.to_string()) + .collect(); + let b_used: Vec = state.wallets[&b] + .used_core_addresses + .iter() + .map(|x| x.to_string()) + .collect(); + assert_eq!( + a_used, + vec![addr_a.to_string()], + "A sees only its own address" + ); + assert_eq!( + b_used, + vec![addr_b.to_string()], + "B sees only its own address" + ); + assert!( + !a_used.contains(&addr_b.to_string()), + "A must not see B's address" + ); + assert!( + !b_used.contains(&addr_a.to_string()), + "B must not see A's address" + ); +} + +/// Mixed-store regression — a historical `core_utxos` address that +/// a later partial pool snapshot never enumerates must surface BOTH the +/// historical UTXO address and the pool used address. The union must never +/// let the pool set shadow the historical one (address-reuse / funds safety). +#[test] +fn mixed_store_unions_utxo_and_pool_used_sets() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x27); + ensure_wallet_meta(&persister, &w); + + // Historical UTXO on address X, written before any pool snapshot exists. + let historical = p2pkh(0xAA); + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo_on(&historical, 0x12, 500)], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + // A later pool snapshot marks a DIFFERENT address Y used and does not + // enumerate the historical address at all. + let mut infos = external_infos(0x27); + infos.truncate(1); + infos[0].used = true; + let pool_used = infos[0].address.clone(); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![AccountAddressPoolEntry { + account_type: AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + pool_type: AddressPoolType::External, + addresses: infos.clone(), + }], + ..Default::default() + }, + ) + .unwrap(); + + let state = persister.load().unwrap(); + let slice = state.wallets.get(&w).expect("wallet surfaces"); + let got: std::collections::BTreeSet = slice + .used_core_addresses + .iter() + .map(|a| a.to_string()) + .collect(); + assert!( + got.contains(&historical.to_string()), + "historical UTXO address must survive a later partial pool snapshot" + ); + assert!( + got.contains(&pool_used.to_string()), + "pool used address must be present" + ); + assert_eq!(got.len(), 2, "exactly the union of both sources, deduped"); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs new file mode 100644 index 0000000000..a472ce46da --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs @@ -0,0 +1,116 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Content-level schema-freeze guards. +//! +//! TC-B-040: pin the rendered migration SQL with a golden fingerprint so an +//! in-place DDL edit (which the identity-only fingerprint is documented not +//! to catch) breaks CI. TC-B-041: assert the retired cross-branch table +//! names never appear as SQL identifiers in the writer/reader/migration/ +//! backup SQL — the drift the content-blind fingerprint cannot catch. + +use std::path::Path; + +use platform_wallet_storage::sqlite::migrations as mig; + +/// Golden `(version, name)` fingerprint of the frozen migration set. Bump +/// deliberately only when adding/removing/renaming a migration file. +const EXPECTED_ID_FINGERPRINT: &str = + "114e07f057947594e3d098ba62169f0887c2a407feb78c7ea835a4b35d582fd9"; + +/// Golden content-level fingerprint over every migration's rendered SQL. +/// Bump deliberately only when the DDL body itself changes; an accidental +/// change (a silent table rename) must fail this test, not slip through. +const EXPECTED_SQL_FINGERPRINT: &str = + "98f2a7c86a1383fc32922551c537d1af9955428f6068afde9dd33f2a8a49d90d"; + +/// Table names that lost the cross-branch reconciliation and must never +/// resurface as SQL identifiers on this frozen (`wallets`) baseline. +const RETIRED_SQL_NAMES: &[&str] = &[ + "wallet_metadata", + "account_address_pools", + "core_derived_addresses", +]; + +/// TC-B-040 (identity) — the migration set's identity is pinned. +#[test] +fn tc_b_040_identity_fingerprint_pinned() { + assert_eq!( + hex::encode(mig::embedded_migrations_fingerprint()), + EXPECTED_ID_FINGERPRINT, + "migration set identity changed; a file was added/removed/renamed. \ + If intentional, update EXPECTED_ID_FINGERPRINT." + ); +} + +/// TC-B-040 (content) — the rendered migration SQL is pinned, closing the +/// content-blind gap the identity fingerprint documents. +#[test] +fn tc_b_040_sql_fingerprint_pinned() { + assert_eq!( + hex::encode(mig::embedded_migrations_sql_fingerprint()), + EXPECTED_SQL_FINGERPRINT, + "a migration's DDL body changed. On this frozen baseline that is a \ + schema-drift alarm (D0). If intentional, update EXPECTED_SQL_FINGERPRINT." + ); +} + +/// The retired names appear nowhere in the rendered migration SQL. +#[test] +fn tc_b_041_migration_sql_has_no_retired_names() { + for sql in mig::embedded_migrations_sql() { + for name in RETIRED_SQL_NAMES { + assert!( + !sql.contains(name), + "retired table name `{name}` present in migration SQL" + ); + } + } +} + +/// TC-B-041 — no writer/reader/migration/backup SQL string references a +/// retired table name. `wallet_metadata` / `account_address_pools` are also +/// legitimate Rust changeset fields, so the scan flags only SQL-keyword-led +/// table usage (`FROM`/`INTO`/`UPDATE`/`TABLE`/`JOIN`/`ON `), never a +/// bare `cs.` access. +#[test] +fn tc_b_041_no_retired_table_name_in_sql_strings() { + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let migrations_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations"); + let sql_keywords = ["FROM", "INTO", "UPDATE", "TABLE", "JOIN", "ON"]; + + let mut offenders = Vec::new(); + for dir in [src, migrations_dir] { + visit(&dir, &mut |path, line_no, line| { + for name in RETIRED_SQL_NAMES { + for kw in sql_keywords { + if line.contains(&format!("{kw} {name}")) { + offenders.push(format!("{}:{line_no}: {}", path.display(), line.trim())); + } + } + } + }); + } + assert!( + offenders.is_empty(), + "retired table name used in SQL: {offenders:#?}" + ); +} + +fn visit(dir: &Path, on_line: &mut impl FnMut(&Path, usize, &str)) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + visit(&p, on_line); + } else if p.extension().is_some_and(|e| e == "rs") { + let Ok(text) = std::fs::read_to_string(&p) else { + continue; + }; + for (i, line) in text.lines().enumerate() { + on_line(&p, i + 1, line); + } + } + } +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_store_generation.rs b/packages/rs-platform-wallet-storage/tests/sqlite_store_generation.rs new file mode 100644 index 0000000000..7d7c396e68 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_store_generation.rs @@ -0,0 +1,165 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Store-generation token behaviour. Covers TC-B-004 +//! (present + stable across a normal flush) and TC-B-024 (regenerated on +//! restore so a restored copy is distinguishable from its source). + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, ro_conn, wid}; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet_storage::sqlite::schema::versions; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +/// TC-B-004 — the generation is present, 16 bytes, and unchanged by a normal +/// changeset flush (it only rotates on migrate/restore). +#[test] +fn tc_b_004_generation_present_and_stable_across_flush() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0x01); + ensure_wallet_meta(&persister, &w); + + let g1 = { + let conn = persister.lock_conn_for_test(); + versions::read_generation(&conn) + .unwrap() + .expect("fresh V002 store carries a generation") + }; + assert!( + g1.iter().any(|b| *b != 0), + "generation must not be all-zero" + ); + + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(10), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let g2 = { + let conn = persister.lock_conn_for_test(); + versions::read_generation(&conn).unwrap().unwrap() + }; + assert_eq!(g1, g2, "a normal flush must not rotate the generation"); + drop(persister); + let _ = path; +} + +/// TC-B-024 — restoring from a backup rotates the generation, so a client +/// cache keyed on the pre-restore generation misses rather than serving +/// stale entries. +#[test] +fn tc_b_024_generation_rotates_on_restore() { + let (persister, tmp, path) = fresh_persister(); + let w = wid(0x02); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(5), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let g1 = { + let conn = persister.lock_conn_for_test(); + versions::read_generation(&conn).unwrap().unwrap() + }; + let backup_path = persister.backup_to(tmp.path()).unwrap(); + // The backup is a byte-copy, so it carries the same generation. + { + let bconn = ro_conn(&backup_path); + assert_eq!( + versions::read_generation(&bconn).unwrap().unwrap(), + g1, + "backup carries the source generation verbatim" + ); + } + drop(persister); + + SqlitePersister::restore_from_skip_backup(&path, &backup_path).expect("restore"); + + let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let g2 = { + let conn = p2.lock_conn_for_test(); + versions::read_generation(&conn).unwrap().unwrap() + }; + assert_ne!( + g1, g2, + "restore must rotate the generation (restored copy != source)" + ); + drop(p2); + drop(tmp); +} + +/// The generation is rotated as part of the atomic swap — folded into the +/// staged temp BEFORE the rename — not by a post-swap RW re-open on the +/// destination. Proof: right after `restore_from` returns, the destination +/// already carries the rotated token (readable via a read-only open, no RW +/// connection needed) AND has no lingering `-wal`/`-shm` siblings. The old +/// ordering rotated the token through a post-swap RW connection, which on a +/// WAL-mode DB left sibling files behind; folding it into the swap removes any +/// window where restored content is observable with the source's stale token. +#[test] +fn generation_rotated_within_atomic_swap_leaves_no_wal_siblings() { + let (persister, tmp, path) = fresh_persister(); + let w = wid(0x03); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(9), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let g_src = { + let conn = persister.lock_conn_for_test(); + versions::read_generation(&conn).unwrap().unwrap() + }; + let backup_path = persister.backup_to(tmp.path()).unwrap(); + drop(persister); + + SqlitePersister::restore_from_skip_backup(&path, &backup_path).expect("restore"); + + // No WAL/SHM siblings linger: regeneration ran on the staged temp, not via + // a post-swap RW open on the destination. + for ext in ["-wal", "-shm"] { + let sibling = std::path::PathBuf::from(format!("{}{ext}", path.display())); + assert!( + !sibling.exists(), + "restored DB must have no {ext} sibling (regen must not re-open dest RW): {sibling:?}" + ); + } + + // The rotated token is already observable via a read-only open (no RW + // connection created) and differs from the source's. + let g_dst = { + let conn = ro_conn(&path); + versions::read_generation(&conn).unwrap().unwrap() + }; + assert_ne!( + g_src, g_dst, + "restore must rotate the generation within the atomic swap" + ); + drop(tmp); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_v002_isolation.rs b/packages/rs-platform-wallet-storage/tests/sqlite_v002_isolation.rs new file mode 100644 index 0000000000..f21966880a --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_v002_isolation.rs @@ -0,0 +1,87 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Cross-wallet isolation + delete cascade for the new V002 tables +//! (`core_address_pool`, `meta_data_versions`) — TC-B-006. Two wallets with +//! fully-overlapping keys must not collide, must not leak across wallets, and +//! deleting one must leave the other's V002 rows intact. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use platform_wallet::wallet::platform_wallet::WalletId; + +fn pool_count(conn: &rusqlite::Connection, w: &WalletId) -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM core_address_pool WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap() +} + +fn versions_count(conn: &rusqlite::Connection, w: &WalletId) -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM meta_data_versions WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap() +} + +/// TC-B-006 — overlapping keys across two wallets coexist without PK +/// collision, and deleting wallet A cascades away only A's V002 rows while +/// wallet B's survive intact. +#[test] +fn tc_b_006_v002_tables_isolate_and_cascade_per_wallet() { + let (persister, _tmp, _path) = fresh_persister(); + let a: WalletId = wid(0x0A); + let b: WalletId = wid(0x0B); + ensure_wallet_meta(&persister, &a); + ensure_wallet_meta(&persister, &b); + + // Identical (account_type, account_index, key_class, pool_type, + // address_index, domain) for both wallets — only wallet_id differs. + { + let conn = persister.lock_conn_for_test(); + for w in [&a, &b] { + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, 'standard_bip44', 0, 0, 0, 0, ?2, 1)", + rusqlite::params![w.as_slice(), &[0xEEu8; 25][..]], + ) + .expect("overlapping-key pool rows must not collide across wallets"); + conn.execute( + "INSERT INTO meta_data_versions (wallet_id, domain, seq) \ + VALUES (?1, 'core', 3)", + rusqlite::params![w.as_slice()], + ) + .expect("overlapping-domain version rows must not collide across wallets"); + } + + // No cross-wallet read leakage: each wallet sees exactly its own row. + assert_eq!(pool_count(&conn, &a), 1); + assert_eq!(pool_count(&conn, &b), 1); + assert_eq!(versions_count(&conn, &a), 1); + assert_eq!(versions_count(&conn, &b), 1); + } + + // Delete wallet A — FK ON DELETE CASCADE (core_address_pool) and the + // meta_data_versions soft-cascade trigger must reap only A's rows. + persister.delete_wallet_skip_backup(a).expect("delete A"); + + let conn = persister.lock_conn_for_test(); + assert_eq!(pool_count(&conn, &a), 0, "A's pool rows cascade-deleted"); + assert_eq!( + versions_count(&conn, &a), + 0, + "A's version rows removed by the delete trigger" + ); + assert_eq!(pool_count(&conn, &b), 1, "B's pool rows survive A's delete"); + assert_eq!( + versions_count(&conn, &b), + 1, + "B's version rows survive A's delete" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_v002_migration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_v002_migration.rs new file mode 100644 index 0000000000..f06018e3ed --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_v002_migration.rs @@ -0,0 +1,198 @@ +#![allow(clippy::field_reassign_with_default)] + +//! V002 unified-migration schema tests. +//! +//! Covers TC-B-030 (fresh store migrates clean to the new target version), +//! TC-B-003 (`meta_data_versions` shape + PK), the schema half of TC-B-001 +//! (`core_address_pool` shape + PK), and the store-generation seed. + +mod common; + +use std::collections::BTreeMap; + +use common::fresh_persister; +use platform_wallet_storage::sqlite::migrations as mig; +use rusqlite::Connection; + +/// Column metadata from `PRAGMA table_info`: name → (type, notnull, pk_pos). +fn table_columns(conn: &Connection, table: &str) -> BTreeMap { + let mut stmt = conn + .prepare(&format!("PRAGMA table_info({table})")) + .expect("prepare table_info"); + let rows = stmt + .query_map([], |row| { + let name: String = row.get(1)?; + let ty: String = row.get(2)?; + let notnull: i64 = row.get(3)?; + let pk: i64 = row.get(5)?; + Ok((name, (ty, notnull != 0, pk))) + }) + .expect("query table_info"); + rows.map(|r| r.expect("row")).collect() +} + +fn table_exists(conn: &Connection, table: &str) -> bool { + conn.query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1", + rusqlite::params![table], + |_| Ok(()), + ) + .optional_exists() +} + +trait OptionalExists { + fn optional_exists(self) -> bool; +} +impl OptionalExists for rusqlite::Result<()> { + fn optional_exists(self) -> bool { + matches!(self, Ok(())) + } +} + +/// The unified migration lifts the supported schema version to 2. +#[test] +fn max_supported_version_is_two() { + assert_eq!( + mig::max_supported_version(), + 2, + "V002 must raise max_supported_version to 2" + ); +} + +/// TC-B-030 — a fresh store migrates clean to the new target version and +/// every new table exists. +#[test] +fn tc_b_030_fresh_store_migrates_to_version_two() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + let max: i64 = conn + .query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(max, 2, "fresh store must land at schema version 2"); + for table in [ + "core_address_pool", + "meta_data_versions", + "meta_store_generation", + ] { + assert!(table_exists(&conn, table), "missing table {table}"); + } +} + +/// Schema half of TC-B-001 — `core_address_pool` carries per-index rows +/// scoped by `(wallet_id, account_type, account_index, key_class, pool_type, +/// address_index)`, a stored `script`, and a `used` flag. +#[test] +fn tc_b_001_core_address_pool_shape() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + let cols = table_columns(&conn, "core_address_pool"); + + for (name, ty) in [ + ("wallet_id", "BLOB"), + ("account_type", "TEXT"), + ("account_index", "INTEGER"), + ("key_class", "INTEGER"), + ("pool_type", "INTEGER"), + ("address_index", "INTEGER"), + ("script", "BLOB"), + ("used", "INTEGER"), + ] { + let col = cols + .get(name) + .unwrap_or_else(|| panic!("core_address_pool missing column {name}")); + assert_eq!(col.0, ty, "column {name} has unexpected type"); + } + + // Composite PK includes account_type so accounts collapsing to the same + // (account_index, key_class) sentinel never overwrite each other, and + // pool_type so External/Internal pools never collide at one address_index. + let pk: BTreeMap = cols + .iter() + .filter(|(_, (_, _, pk))| *pk > 0) + .map(|(name, (_, _, pk))| (*pk, name.clone())) + .collect(); + let pk_order: Vec<&str> = pk.values().map(String::as_str).collect(); + assert_eq!( + pk_order, + vec![ + "wallet_id", + "account_type", + "account_index", + "key_class", + "pool_type", + "address_index" + ], + "core_address_pool PK must be (wallet_id, account_type, account_index, key_class, \ + pool_type, address_index)" + ); +} + +/// TC-B-003 — `meta_data_versions` is `(wallet_id BLOB, domain TEXT, seq +/// INTEGER)` with composite PK `(wallet_id, domain)`; `seq` defaults to 0. +#[test] +fn tc_b_003_meta_data_versions_shape() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + let cols = table_columns(&conn, "meta_data_versions"); + + assert_eq!(cols["wallet_id"].0, "BLOB"); + assert_eq!(cols["domain"].0, "TEXT"); + assert_eq!(cols["seq"].0, "INTEGER"); + + let pk: BTreeMap = cols + .iter() + .filter(|(_, (_, _, pk))| *pk > 0) + .map(|(name, (_, _, pk))| (*pk, name.clone())) + .collect(); + let pk_order: Vec<&str> = pk.values().map(String::as_str).collect(); + assert_eq!( + pk_order, + vec!["wallet_id", "domain"], + "meta_data_versions PK must be (wallet_id, domain)" + ); + + // A domain with no writes yet has seq default 0. + let w = [0x01u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + rusqlite::params![w.as_slice()], + ) + .unwrap(); + conn.execute( + "INSERT INTO meta_data_versions (wallet_id, domain) VALUES (?1, 'core_pool')", + rusqlite::params![w.as_slice()], + ) + .unwrap(); + let seq: i64 = conn + .query_row( + "SELECT seq FROM meta_data_versions WHERE wallet_id = ?1 AND domain = 'core_pool'", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(seq, 0, "seq must default to 0 for a fresh domain"); +} + +/// The store-generation token is seeded on migration as a non-empty +/// 16-byte blob in the single-row `meta_store_generation` table. +#[test] +fn store_generation_seeded_16_bytes() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + let gen: Vec = conn + .query_row( + "SELECT generation FROM meta_store_generation WHERE id = 0", + [], + |r| r.get(0), + ) + .expect("store generation row must exist"); + assert_eq!(gen.len(), 16, "store generation must be 16 bytes"); + assert!( + gen.iter().any(|b| *b != 0), + "generation must not be all-zero" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs b/packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs new file mode 100644 index 0000000000..00dafbd401 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs @@ -0,0 +1,427 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `meta_data_versions` bump discipline. Covers TC-B-011 +//! (bump rides the flush tx), TC-B-012 (atomic rollback — data and bump are +//! all-or-nothing), TC-B-013 (every domain maps to a bump; none silently +//! excluded), TC-B-014 (saturating seq, never wraps). + +mod common; + +use std::collections::BTreeMap; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use dpp::prelude::Identifier; +use key_wallet::account::{AccountType, StandardAccountType}; +use key_wallet::managed_account::address_pool::AddressPoolType; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::{AddressInfo, Network}; +use platform_wallet::changeset::{ + AccountAddressPoolEntry, AccountRegistrationEntry, AssetLockChangeSet, ContactChangeSet, + ContactRequestEntry, CoreChangeSet, IdentityChangeSet, IdentityEntry, IdentityKeyEntry, + IdentityKeysChangeSet, PlatformAddressBalanceEntry, PlatformAddressChangeSet, + PlatformWalletChangeSet, PlatformWalletPersistence, SentContactRequestKey, + TokenBalanceChangeSet, WalletMetadataEntry, +}; +use platform_wallet::wallet::identity::{ContactRequest, IdentityStatus}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::sqlite::schema::versions::{self, Domain}; + +fn one_external_info(seed_byte: u8) -> AddressInfo { + let wallet = Wallet::from_seed_bytes( + [seed_byte; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 0); + for managed in info.all_managed_accounts() { + if !matches!( + managed.managed_account_type().to_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() { + return pool.addresses.values().next().cloned().unwrap(); + } + } + } + panic!("no external pool"); +} + +fn std_account() -> AccountType { + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + } +} + +fn test_xpub() -> key_wallet::bip32::ExtendedPubKey { + key_wallet::bip32::ExtendedPubKey::decode(&hex::decode( + "0488B21E000000000000000000873DFF81C02F525623FD1FE5167EAC3A55A049DE3D314BB42EE227FFED37D5080339A36013301597DAEF41FBE593A02CC513D0B55527EC2DF1050E2E8FF49C85C2", + ).unwrap()).unwrap() +} + +/// A changeset that touches exactly one domain, with minimal non-empty data. +/// DB-validity is irrelevant here — `touched_domains` is a pure function. +fn single_domain_changeset(domain: Domain) -> PlatformWalletChangeSet { + let mut cs = PlatformWalletChangeSet::default(); + match domain { + Domain::Core => { + cs.core = Some(CoreChangeSet { + synced_height: Some(1), + ..Default::default() + }) + } + Domain::Identities => { + let mut m = BTreeMap::new(); + let id = Identifier::from([0x01; 32]); + m.insert(id, identity_entry(id)); + cs.identities = Some(IdentityChangeSet { + identities: m, + removed: Default::default(), + }); + } + Domain::IdentityKeys => { + let mut keys = IdentityKeysChangeSet::default(); + let id = Identifier::from([0x02; 32]); + keys.upserts.insert((id, 0), identity_key_entry(id)); + cs.identity_keys = Some(keys); + } + Domain::Contacts => { + let mut sent = BTreeMap::new(); + sent.insert( + SentContactRequestKey { + owner_id: Identifier::from([0x03; 32]), + recipient_id: Identifier::from([0x04; 32]), + }, + contact_request_entry(0x03, 0x04), + ); + cs.contacts = Some(ContactChangeSet { + sent_requests: sent, + ..Default::default() + }); + } + Domain::PlatformAddresses => { + cs.platform_addresses = Some(PlatformAddressChangeSet { + addresses: vec![PlatformAddressBalanceEntry { + wallet_id: [0; 32], + account_index: 0, + address_index: 0, + address: key_wallet::PlatformP2PKHAddress::new([0x05; 20]), + funds: dash_sdk::platform::address_sync::AddressFunds { + balance: 1, + nonce: 0, + }, + }], + ..Default::default() + }); + } + Domain::AssetLocks => { + cs.asset_locks = Some(AssetLockChangeSet::default()); + // Empty map is "empty" — seed one entry to mark it touched. + cs.asset_locks = Some(asset_lock_changeset()); + } + Domain::TokenBalances => { + let mut balances = BTreeMap::new(); + balances.insert( + (Identifier::from([0x06; 32]), Identifier::from([0x07; 32])), + 1u64, + ); + cs.token_balances = Some(TokenBalanceChangeSet { + balances, + ..Default::default() + }); + } + Domain::DashpayProfiles => { + let mut m = BTreeMap::new(); + m.insert(Identifier::from([0x08; 32]), None); + cs.dashpay_profiles = Some(m); + } + Domain::DashpayPaymentsOverlay => { + let mut inner = BTreeMap::new(); + inner.insert( + "tx".to_string(), + platform_wallet::wallet::identity::PaymentEntry::new_sent( + Identifier::from([0x0A; 32]), + 1, + None, + ), + ); + let mut m = BTreeMap::new(); + m.insert(Identifier::from([0x09; 32]), inner); + cs.dashpay_payments_overlay = Some(m); + } + Domain::WalletMetadata => { + cs.wallet_metadata = Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 1, + }); + } + Domain::AccountRegistrations => { + cs.account_registrations = vec![AccountRegistrationEntry { + account_type: std_account(), + account_xpub: test_xpub(), + }]; + } + Domain::AccountAddressPools => { + cs.account_address_pools = vec![AccountAddressPoolEntry { + account_type: std_account(), + pool_type: AddressPoolType::External, + addresses: vec![], + }]; + } + } + cs +} + +fn identity_entry(id: Identifier) -> IdentityEntry { + IdentityEntry { + id, + balance: 1, + revision: 1, + identity_index: Some(0), + last_updated_balance_block_time: None, + last_synced_keys_block_time: None, + dpns_names: Vec::new(), + contested_dpns_names: Vec::new(), + status: IdentityStatus::Active, + wallet_id: None, + dashpay_profile: None, + dashpay_payments: Default::default(), + } +} + +fn identity_key_entry(id: Identifier) -> IdentityKeyEntry { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + IdentityKeyEntry { + identity_id: id, + key_id: 0, + public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![2u8; 33]), + disabled_at: None, + }), + public_key_hash: [3u8; 20], + wallet_id: None, + derivation_indices: None, + } +} + +fn contact_request_entry(sender: u8, recipient: u8) -> ContactRequestEntry { + ContactRequestEntry { + request: ContactRequest { + sender_id: Identifier::from([sender; 32]), + recipient_id: Identifier::from([recipient; 32]), + sender_key_index: 0, + recipient_key_index: 0, + account_reference: 0, + encrypted_account_label: None, + encrypted_public_key: Vec::new(), + auto_accept_proof: None, + core_height_created_at: 0, + created_at: 0, + }, + } +} + +fn asset_lock_changeset() -> AssetLockChangeSet { + use dashcore::hashes::Hash; + use dashcore::{OutPoint, Transaction, Txid}; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use platform_wallet::changeset::AssetLockEntry; + use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; + let op = OutPoint { + txid: Txid::from_byte_array([0x0B; 32]), + vout: 0, + }; + let mut cs = AssetLockChangeSet::default(); + cs.asset_locks.insert( + op, + AssetLockEntry { + out_point: op, + transaction: Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + account_index: 0, + funding_type: AssetLockFundingType::IdentityTopUp, + identity_index: 0, + amount_duffs: 1, + status: AssetLockStatus::Built, + proof: None, + }, + ); + cs +} + +/// TC-B-013 — every domain maps to exactly its own bump; none silently +/// excluded. Each single-field changeset yields exactly its domain, and the +/// union covers `Domain::ALL`. The exhaustive destructure in +/// `touched_domains` makes a newly added field a compile error there. +#[test] +fn tc_b_013_every_domain_maps_and_isolates() { + use std::collections::BTreeSet; + let mut covered = BTreeSet::new(); + for domain in Domain::ALL { + let cs = single_domain_changeset(domain); + let touched = versions::touched_domains(&cs); + assert_eq!( + touched, + vec![domain], + "single-field changeset for {domain:?} must touch exactly that domain" + ); + covered.insert(domain.as_str()); + } + let all: BTreeSet<&str> = Domain::ALL.iter().map(|d| d.as_str()).collect(); + assert_eq!(covered, all, "all domains must be reachable"); +} + +/// TC-B-011 — a flush touching the core-pool domain commits the pool row and +/// its `meta_data_versions.seq` together (same connection, same tx). +#[test] +fn tc_b_011_bump_rides_the_flush() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xB1); + ensure_wallet_meta(&persister, &w); + + let info = one_external_info(0x11); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![AccountAddressPoolEntry { + account_type: std_account(), + pool_type: AddressPoolType::External, + addresses: vec![info.clone()], + }], + ..Default::default() + }, + ) + .unwrap(); + + let conn = persister.lock_conn_for_test(); + let pool_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_address_pool WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert!(pool_rows >= 1, "pool row must be present"); + let seq = versions::read_seq(&conn, &w, Domain::AccountAddressPools).unwrap(); + assert_eq!(seq, 1, "the domain's seq bumped in the same flush"); + // No unrelated domain bumped. + assert_eq!(versions::read_seq(&conn, &w, Domain::Core).unwrap(), 0); +} + +/// A domain bumps once per flush; two flushes → seq 2. +#[test] +fn repeated_flush_increments_seq() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xB0); + ensure_wallet_meta(&persister, &w); + for _ in 0..2 { + persister + .store(w, single_domain_changeset(Domain::WalletMetadata)) + .unwrap(); + } + let conn = persister.lock_conn_for_test(); + assert_eq!( + versions::read_seq(&conn, &w, Domain::WalletMetadata).unwrap(), + 2 + ); +} + +/// TC-B-012 — atomicity: a flush that fails partway persists neither the +/// data nor the version bump. A pool write plus a token-balance write whose +/// identity FK is absent must roll the whole tx back. +#[test] +fn tc_b_012_partial_failure_rolls_back_data_and_bump() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xB2); + ensure_wallet_meta(&persister, &w); + + let info = one_external_info(0x22); + let mut balances = BTreeMap::new(); + // No identities row for this id → token_balances FK violation mid-flush. + balances.insert( + (Identifier::from([0xEE; 32]), Identifier::from([0xEF; 32])), + 1u64, + ); + let result = persister.store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![AccountAddressPoolEntry { + account_type: std_account(), + pool_type: AddressPoolType::External, + addresses: vec![info], + }], + token_balances: Some(TokenBalanceChangeSet { + balances, + ..Default::default() + }), + ..Default::default() + }, + ); + assert!(result.is_err(), "FK violation must fail the flush"); + + let conn = persister.lock_conn_for_test(); + let pool_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_address_pool WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + let version_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM meta_data_versions WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(pool_rows, 0, "pool write must roll back with the failed tx"); + assert_eq!(version_rows, 0, "no bump may survive a rolled-back flush"); +} + +/// TC-B-014 — a seq pre-seeded to i64::MAX saturates on the next bump and +/// never wraps to a lower value (which would look like a cache rollback). +#[test] +fn tc_b_014_seq_saturates_at_i64_max() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xB4); + ensure_wallet_meta(&persister, &w); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO meta_data_versions (wallet_id, domain, seq) \ + VALUES (?1, 'wallet_metadata', 9223372036854775807)", + rusqlite::params![w.as_slice()], + ) + .unwrap(); + } + persister + .store(w, single_domain_changeset(Domain::WalletMetadata)) + .unwrap(); + let conn = persister.lock_conn_for_test(); + assert_eq!( + versions::read_seq(&conn, &w, Domain::WalletMetadata).unwrap(), + i64::MAX, + "seq must saturate, never wrap" + ); +} diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index cc8b705df9..19bff1d676 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -963,12 +963,12 @@ 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 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. + /// Full address-pool snapshots: emitted once at wallet registration and + /// on later pool extension / used-flag flips. Incremental derivations + /// also arrive via `core.addresses_derived` (the `WalletEvent` bus / FFI + /// path). The storage persister expands these into per-index + /// `core_address_pool` rows (per-index `used` state + owning account for + /// UTXO attribution); the reader restores the used-set from them verbatim. /// See [`AccountAddressPoolEntry`] for the merge policy. pub account_address_pools: Vec, /// Shielded sub-wallet deltas: per-subwallet decrypted notes,