From 63a7f8e09d17f7e6ffd51d6953d87aedbf840c08 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:31:07 +0000 Subject: [PATCH 01/26] fix(platform-wallet-storage): skip unreadable records in history repair V019 and the per-round history repair decoded stored records strictly, so one undecodable record_blob aborted the migration (blocking every open, Recovery included) and made each later write of that txid fail. History repair is best-effort accounting: unreadable stored data is now logged and skipped. Each record is repaired inside its own savepoint so a skipped one leaves no partial writes, the corrupt row is left untouched, and a later write of the same transaction replaces it. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/schema/core_history.rs | 113 ++++++++++++++---- .../src/sqlite/schema/core_state.rs | 51 ++++++-- 2 files changed, 133 insertions(+), 31 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs index dd7081c4668..c74636b2117 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs @@ -24,15 +24,17 @@ pub(super) fn preserve_known_details( incoming: &TransactionRecord, ) -> Result { let mut merged = incoming.clone(); - let Some(previous) = - core_state::get_tx_record(tx, wallet_id, &incoming.txid, &LoadCtx::strict())? - else { + let Some(previous) = prior_record(tx, wallet_id, &incoming.txid)? else { return Ok(merged); }; if previous.transaction != incoming.transaction { - return Err(WalletStorageError::blob_decode( - "same transaction id has different raw transaction bodies", - )); + // A txid commits to its body, so the stored copy is corrupt; the + // incoming record replaces it rather than wedging every later write. + tracing::warn!( + txid = %incoming.txid, + "stored transaction body disagrees with its txid; replacing it" + ); + return Ok(merged); } let mut inputs: BTreeMap<_, _> = previous .input_details @@ -61,6 +63,57 @@ pub(super) fn preserve_known_details( Ok(merged) } +/// Read a stored record as repair evidence; an unreadable one is no evidence. +/// +/// History repair is best-effort accounting and must never block opening the +/// database or storing new state, so undecodable or drifted rows are skipped. +fn prior_record( + conn: &Connection, + wallet_id: &WalletId, + txid: &Txid, +) -> Result, WalletStorageError> { + match core_state::get_tx_record(conn, wallet_id, txid, &LoadCtx::recovery()) { + Err(error) if is_unreadable(&error) => { + tracing::warn!(%txid, %error, "skipping unreadable transaction record in history repair"); + Ok(None) + } + result => result, + } +} + +/// Whether `error` reports stored bytes that cannot be decoded, not a database failure. +fn is_unreadable(error: &WalletStorageError) -> bool { + matches!( + error, + WalletStorageError::BincodeDecode { .. } + | WalletStorageError::BlobDecode { .. } + | WalletStorageError::BlobTooLarge { .. } + | WalletStorageError::HashDecode { .. } + | WalletStorageError::IntegerOverflow { .. } + ) +} + +/// Run one record's repair atomically; unreadable stored data skips it instead of failing. +fn repair_best_effort( + tx: &Transaction<'_>, + txid: &Txid, + repair: impl FnOnce() -> Result<(), WalletStorageError>, +) -> Result<(), WalletStorageError> { + tx.execute_batch("SAVEPOINT core_history_repair")?; + let result = repair(); + if result.is_err() { + tx.execute_batch("ROLLBACK TO core_history_repair")?; + } + tx.execute_batch("RELEASE core_history_repair")?; + match result { + Err(error) if is_unreadable(&error) => { + tracing::warn!(%txid, %error, "skipping history repair of unreadable stored data"); + Ok(()) + } + result => result, + } +} + /// Index raw inputs independently of when their ownership becomes known. pub(super) fn index_record( tx: &Transaction<'_>, @@ -106,7 +159,7 @@ pub(super) fn apply( } let network = network(tx, wallet_id)?; for txid in affected { - repair_record(tx, wallet_id, &txid, network)?; + repair_best_effort(tx, &txid, || repair_record(tx, wallet_id, &txid, network))?; } Ok(()) } @@ -165,8 +218,7 @@ fn repair_record( txid: &Txid, network: dashcore::Network, ) -> Result<(), WalletStorageError> { - let Some(mut record) = core_state::get_tx_record(tx, wallet_id, txid, &LoadCtx::strict())? - else { + let Some(mut record) = prior_record(tx, wallet_id, txid)? else { return Ok(()); }; let original = blob::encode(&record)?; @@ -295,20 +347,37 @@ fn repair_record( /// Backfill the input index and correct existing history in the migration transaction. pub(crate) fn migrate(tx: &Transaction<'_>) -> Result<(), WalletStorageError> { - let mut stmt = tx.prepare_cached("SELECT length(wallet_id), wallet_id, length(txid), txid FROM core_transactions WHERE record_blob IS NOT NULL")?; - let mut rows = stmt.query([])?; - while let Some(row) = rows.next()? { - blob::check_fixed_width(row.get(0)?, 32, "core_transactions.wallet_id")?; - let wallet_id: Vec = row.get(1)?; - let wallet_id = super::id32("core_transactions.wallet_id", &wallet_id)?; - blob::check_fixed_width(row.get(2)?, 32, "core_transactions.txid")?; - let txid: Vec = row.get(3)?; - let txid = Txid::from_slice(&txid)?; - if let Some(record) = core_state::get_tx_record(tx, &wallet_id, &txid, &LoadCtx::strict())? - { - index_record(tx, &wallet_id, &record)?; - repair_record(tx, &wallet_id, &txid, network(tx, &wallet_id)?)?; + // Keys are collected first: savepoint rollbacks must not race an open cursor. + let mut keys = Vec::new(); + { + let mut stmt = tx.prepare_cached("SELECT length(wallet_id), wallet_id, length(txid), txid FROM core_transactions WHERE record_blob IS NOT NULL")?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let key = (|| { + blob::check_fixed_width(row.get(0)?, 32, "core_transactions.wallet_id")?; + let wallet_id: Vec = row.get(1)?; + let wallet_id = super::id32("core_transactions.wallet_id", &wallet_id)?; + blob::check_fixed_width(row.get(2)?, 32, "core_transactions.txid")?; + let txid: Vec = row.get(3)?; + Ok::<_, WalletStorageError>((wallet_id, Txid::from_slice(&txid)?)) + })(); + match key { + Ok(key) => keys.push(key), + Err(error) if is_unreadable(&error) => { + tracing::warn!(%error, "skipping unreadable transaction key in history migration"); + } + Err(error) => return Err(error), + } } } + for (wallet_id, txid) in keys { + repair_best_effort(tx, &txid, || { + let Some(record) = prior_record(tx, &wallet_id, &txid)? else { + return Ok(()); + }; + index_record(tx, &wallet_id, &record)?; + repair_record(tx, &wallet_id, &txid, network(tx, &wallet_id)?) + })?; + } Ok(()) } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index a6b1c32c6a2..9e53e8867e3 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 @@ -1600,7 +1600,7 @@ mod tests { } #[test] - fn should_roll_back_history_migration_on_corrupt_record() { + fn should_skip_corrupt_record_during_history_migration() { let mut conn = Connection::open_in_memory().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); conn.execute_batch("DROP TABLE core_transaction_inputs; DELETE FROM refinery_schema_history WHERE version >= 19;").unwrap(); @@ -1611,23 +1611,56 @@ mod tests { ) .unwrap(); conn.execute("INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) VALUES (?1, ?2, 0, ?3)", params![&wallet_id[..], &[0u8;32][..], &[0xffu8][..]]).unwrap(); - assert!(crate::sqlite::migrations::run(&mut conn).is_err()); - let tables: i64 = conn + crate::sqlite::migrations::run(&mut conn) + .expect("one unreadable record must not block opening the database"); + let version: i64 = conn .query_row( - "SELECT count(*) FROM sqlite_master WHERE name = 'core_transaction_inputs'", + "SELECT max(version) FROM refinery_schema_history", [], |r| r.get(0), ) .unwrap(); - assert_eq!(tables, 0); - let version: i64 = conn + assert!(version >= 19); + let stored: Vec = conn .query_row( - "SELECT max(version) FROM refinery_schema_history", - [], + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1", + params![&wallet_id[..]], |r| r.get(0), ) .unwrap(); - assert_eq!(version, 18); + assert_eq!(stored, vec![0xff], "the unreadable row is left untouched"); + } + + #[test] + fn should_heal_corrupt_record_when_the_transaction_is_stored_again() { + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0xA9u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let record = transaction_record(Txid::all_zeros(), TransactionContext::Mempool); + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) VALUES (?1, ?2, 0, ?3)", + params![&wallet_id[..], AsRef::<[u8]>::as_ref(&record.txid), &[0xffu8][..]], + ) + .unwrap(); + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![record.clone()], + ..Default::default() + }, + ) + .expect("a corrupt stored copy must not wedge later writes"); + let healed = get_tx_record(&tx, &wallet_id, &record.txid, &LoadCtx::strict()) + .unwrap() + .unwrap(); + assert_eq!(healed.txid, record.txid); } #[test] From 740bbdd18af4159457721d395c750859483e1cc7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:34:29 +0000 Subject: [PATCH 02/26] fix(platform-wallet-storage): keep history repairs reversible V019 and the per-round history repair rewrote stored transaction records in place and marked owned inputs spent without saying which transaction spent them, so a wrong repair could not be undone after the one-off pre-migration backup. Before its first rewrite, a record's original blob is now copied verbatim into the append-only core_transaction_record_originals table (V019 is unreleased and edited in place), and a repair's spent mark records its spender in spent_in_txid. Automatic release after a reorg is left as a TODO pending verification of upstream reorg handling. Co-Authored-By: Claude Opus 5.5 --- .../V019__core_transaction_accounting.rs | 12 ++++- .../src/sqlite/persister.rs | 4 ++ .../src/sqlite/schema/core_history.rs | 22 ++++++++- .../src/sqlite/schema/core_state.rs | 49 +++++++++++++++++-- .../tests/sqlite_schema_pinning.rs | 2 +- 5 files changed, 81 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet-storage/migrations/V019__core_transaction_accounting.rs b/packages/rs-platform-wallet-storage/migrations/V019__core_transaction_accounting.rs index b92003e8545..d563fd867f0 100644 --- a/packages/rs-platform-wallet-storage/migrations/V019__core_transaction_accounting.rs +++ b/packages/rs-platform-wallet-storage/migrations/V019__core_transaction_accounting.rs @@ -1,4 +1,5 @@ -//! Index raw inputs so late output ownership can repair the spending history. +//! Index raw inputs so late output ownership can repair the spending history, +//! and keep each record's pre-repair blob so every repair stays reversible. pub fn migration() -> String { "CREATE TABLE core_transaction_inputs ( @@ -9,6 +10,13 @@ pub fn migration() -> String { FOREIGN KEY (wallet_id, txid) REFERENCES core_transactions(wallet_id, txid) ON DELETE CASCADE ); CREATE INDEX idx_core_transaction_inputs_outpoint - ON core_transaction_inputs(wallet_id, outpoint);" + ON core_transaction_inputs(wallet_id, outpoint); + CREATE TABLE core_transaction_record_originals ( + wallet_id BLOB NOT NULL, + txid BLOB NOT NULL, + record_blob BLOB NOT NULL, + PRIMARY KEY (wallet_id, txid), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE + );" .to_owned() } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 28623c535e2..31679e8b0fc 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -2418,6 +2418,9 @@ mod tests { "tracked_masternodes", // load_tracked_masternodes ]; const INFRASTRUCTURE: &[&str] = &["refinery_schema_history"]; + // Append-only archive of pre-repair history blobs. Never loaded: it + // exists so a wrong history repair can be undone by hand. + const RETAINED_FOR_RECOVERY: &[&str] = &["core_transaction_record_originals"]; // `load()` rehydrates these only with the `shielded` feature on, so // the classification follows the build rather than claiming one. #[cfg(feature = "shielded")] @@ -2459,6 +2462,7 @@ mod tests { && !READ_BY_A_DEDICATED_API.contains(&table.as_str()) && !LOAD_UNIMPLEMENTED_TABLES.contains(&table.as_str()) && !INFRASTRUCTURE.contains(&table.as_str()) + && !RETAINED_FOR_RECOVERY.contains(&table.as_str()) && !FEATURE_GATED.contains(&table.as_str()) && !NOT_REHYDRATED_WITHOUT_FEATURE.contains(&table.as_str()) }) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs index c74636b2117..b9cb1354098 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs @@ -242,11 +242,21 @@ fn repair_record( ); // Stale mempool rows cannot overrule a later sweep's release. if !matches!(record.context, TransactionContext::Mempool) { + // Record the spender so the mark stays attributable and + // reversible; an existing claim by another spender stands. + // TODO(release-repair-spends-after-reorg): release rows whose + // `spent_in_txid` spender is reorged out and never re-mined; + // needs verification of how upstream downgrades a stored + // record's context on reorg. tx.execute( - "UPDATE core_utxos SET spent = 1 WHERE wallet_id = ?1 AND outpoint = ?2", + "UPDATE core_utxos SET spent = 1, \ + spent_in_txid = CASE WHEN spent = 1 AND spent_in_txid IS NOT NULL \ + THEN spent_in_txid ELSE ?3 END \ + WHERE wallet_id = ?1 AND outpoint = ?2", params![ wallet_id.as_slice(), - blob::encode_outpoint(&input.previous_output)? + blob::encode_outpoint(&input.previous_output)?, + txid.as_byte_array().as_slice() ], )?; } @@ -333,6 +343,14 @@ fn repair_record( record.output_details = outputs.into_values().collect(); let repaired = blob::encode(&record)?; if repaired != original { + // Append-only: the first pre-repair blob is kept verbatim and never + // replaced, so a wrong repair can always be undone. + tx.execute( + "INSERT OR IGNORE INTO core_transaction_record_originals (wallet_id, txid, record_blob) \ + SELECT wallet_id, txid, record_blob FROM core_transactions \ + WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], + )?; tx.execute( "UPDATE core_transactions SET record_blob = ?1 WHERE wallet_id = ?2 AND txid = ?3", params![ 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 9e53e8867e3..be2acb222c5 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 @@ -1510,6 +1510,36 @@ mod tests { ) .unwrap(); assert!(spent, "late funding must not resurrect the spent coin"); + let spender: Option> = tx + .query_row( + "SELECT spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![ + &wallet_id[..], + blob::encode_outpoint(&funding.outpoint).unwrap() + ], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + spender.as_deref(), + Some(AsRef::<[u8]>::as_ref(&spending.txid)), + "a repair mark names its spender so it can be reverted" + ); + let original: Vec = tx + .query_row( + "SELECT record_blob FROM core_transaction_record_originals \ + WHERE wallet_id = ?1 AND txid = ?2", + params![&wallet_id[..], AsRef::<[u8]>::as_ref(&spending.txid)], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + blob::decode::(&original) + .unwrap() + .net_amount, + 90_000, + "the pre-repair record is kept verbatim" + ); apply( &tx, &wallet_id, @@ -1567,7 +1597,7 @@ mod tests { ], ) .unwrap(); - tx.execute_batch("DROP TABLE IF EXISTS core_transaction_inputs; DELETE FROM refinery_schema_history WHERE version >= 19;").unwrap(); + tx.execute_batch("DROP TABLE IF EXISTS core_transaction_inputs; DROP TABLE IF EXISTS core_transaction_record_originals; DELETE FROM refinery_schema_history WHERE version >= 19;").unwrap(); tx.commit().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); let repaired = get_tx_record(&conn, &wallet_id, &spending.txid, &LoadCtx::strict()) @@ -1597,13 +1627,26 @@ mod tests { .net_amount, -150_000 ); + let original: Vec = conn + .query_row( + "SELECT record_blob FROM core_transaction_record_originals \ + WHERE wallet_id = ?1 AND txid = ?2", + params![&wallet_id[..], AsRef::<[u8]>::as_ref(&spending.txid)], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + original, + blob::encode(&spending).unwrap(), + "V019 keeps the record it rewrote" + ); } #[test] fn should_skip_corrupt_record_during_history_migration() { let mut conn = Connection::open_in_memory().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); - conn.execute_batch("DROP TABLE core_transaction_inputs; DELETE FROM refinery_schema_history WHERE version >= 19;").unwrap(); + conn.execute_batch("DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; DELETE FROM refinery_schema_history WHERE version >= 19;").unwrap(); let wallet_id = [0xADu8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", @@ -1805,7 +1848,7 @@ mod tests { let wallet_id = [0xB2u8; 32]; let (contact, own) = stage_contact_only_utxo(&conn, &wallet_id); conn.execute_batch( - "DROP TABLE core_transaction_inputs; DELETE FROM refinery_schema_history WHERE version >= 19;", + "DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; DELETE FROM refinery_schema_history WHERE version >= 19;", ) .unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs index 511f1b612d0..d27c6cce39b 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs @@ -21,7 +21,7 @@ const EXPECTED_ID_FINGERPRINT: &str = /// Bump it only when ADDING a migration file; a body change on an already /// applied migration is a defect, not a golden to refresh. const EXPECTED_SQL_FINGERPRINT: &str = - "50cbaacf66de2622f65f60699115a7a154345c250659b546a1d9a0658b575a97"; + "6023660fb488d9d3a98bb069bcb9950bdf125c3e8a8264f2a3dd3bd36a0844b8"; /// The migrations merged `v4.2-dev` already ships. Refinery keys /// `refinery_schema_history` by version and validates an applied migration's From 318d874745eb57130f35a104991578b0bbf0ebae Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:37:46 +0000 Subject: [PATCH 03/26] refactor(platform-wallet-storage): freeze the V019 history repair V019 called the live per-round history repair, so any later edit to that code (or to the queried tables) would silently change what V019 does for users upgrading from V018 or earlier. Move V019's repair into the migrations-local legacy_v019 module, following the legacy_v008 precedent, with its own record reader. Only the low-level blob codec stays shared; TransactionRecord's encoding is owned upstream. A pinning test fixes V019's result on a V018-shaped database: the repaired record bytes, the preserved original, the input index and the spent marks. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/migrations.rs | 3 +- .../src/sqlite/migrations/legacy_v019.rs | 550 ++++++++++++++++++ .../src/sqlite/schema/core_history.rs | 37 -- 3 files changed, 552 insertions(+), 38 deletions(-) create mode 100644 packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs index e152a68a97a..ddd18caad25 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs @@ -10,6 +10,7 @@ use crate::sqlite::error::WalletStorageError; use refinery_core::error::WrapMigrationError; mod legacy_v008; +mod legacy_v019; // Generates a `migrations` module with `runner()`; path is relative to // the crate root. @@ -78,7 +79,7 @@ impl refinery_core::traits::sync::Transaction for MigrationTransaction<'_> { } else if query == self.pool_sql { legacy_v008::convert_pools(&self.tx)?; } else if query == self.history_sql { - super::schema::core_history::migrate(&self.tx)?; + legacy_v019::repair_history(&self.tx)?; } count += 1; } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs new file mode 100644 index 00000000000..9564fa8850f --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs @@ -0,0 +1,550 @@ +//! Frozen V019 history repair. It backfills the raw-input index and corrects +//! stored accounting for databases migrating from V018 or earlier. +//! +//! Frozen on purpose: the live per-round repair in `schema::core_history` may +//! evolve, but V019 must keep doing exactly what it did when it shipped. Only +//! the low-level blob codec is shared; `TransactionRecord`'s encoding is owned +//! upstream (key-wallet) and cannot be frozen here. Do not edit. + +use std::collections::BTreeMap; + +use dashcore::hashes::Hash; +use dashcore::{Address, OutPoint, ScriptBuf, Txid}; +use key_wallet::managed_account::transaction_record::{ + InputDetail, OutputDetail, OutputRole, TransactionDirection, TransactionRecord, +}; +use key_wallet::transaction_checking::{TransactionContext, TransactionType}; +use platform_wallet::wallet::platform_wallet::WalletId; +use rusqlite::{params, OptionalExtension, Transaction}; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::{blob, id32, wallets}; +use crate::sqlite::util::safe_cast::i64_to_u64; + +/// Read a stored record as repair evidence; an unreadable one is no evidence. +fn prior_record( + tx: &Transaction<'_>, + wallet_id: &WalletId, + txid: &Txid, +) -> Result, WalletStorageError> { + let read = (|| { + let stored: Option)>> = tx + .query_row( + "SELECT length(record_blob), record_blob FROM core_transactions \ + WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], + |row| { + Ok(match row.get::<_, Option>(0)? { + Some(len) => Some((len, row.get(1)?)), + None => None, + }) + }, + ) + .optional()?; + let Some(Some((len, payload))) = stored else { + return Ok(None); + }; + blob::check_size(len)?; + let record: TransactionRecord = blob::decode(&payload)?; + Ok(Some(record).filter(|record| record.txid == *txid)) + })(); + match read { + Err(error) if is_unreadable(&error) => { + tracing::warn!(%txid, %error, "skipping unreadable transaction record in history migration"); + Ok(None) + } + result => result, + } +} + +/// Whether `error` reports stored bytes that cannot be decoded, not a database failure. +fn is_unreadable(error: &WalletStorageError) -> bool { + matches!( + error, + WalletStorageError::BincodeDecode { .. } + | WalletStorageError::BlobDecode { .. } + | WalletStorageError::BlobTooLarge { .. } + | WalletStorageError::HashDecode { .. } + | WalletStorageError::IntegerOverflow { .. } + ) +} + +/// Run one record's repair atomically; unreadable stored data skips it instead of failing. +fn repair_best_effort( + tx: &Transaction<'_>, + txid: &Txid, + repair: impl FnOnce() -> Result<(), WalletStorageError>, +) -> Result<(), WalletStorageError> { + tx.execute_batch("SAVEPOINT core_history_repair")?; + let result = repair(); + if result.is_err() { + tx.execute_batch("ROLLBACK TO core_history_repair")?; + } + tx.execute_batch("RELEASE core_history_repair")?; + match result { + Err(error) if is_unreadable(&error) => { + tracing::warn!(%txid, %error, "skipping history repair of unreadable stored data"); + Ok(()) + } + result => result, + } +} + +/// Index raw inputs independently of when their ownership becomes known. +fn index_record( + tx: &Transaction<'_>, + wallet_id: &WalletId, + record: &TransactionRecord, +) -> Result<(), WalletStorageError> { + let mut stmt = tx.prepare_cached( + "INSERT OR IGNORE INTO core_transaction_inputs (wallet_id, txid, outpoint) VALUES (?1, ?2, ?3)", + )?; + for input in &record.transaction.input { + stmt.execute(params![ + wallet_id.as_slice(), + record.txid.as_byte_array().as_slice(), + blob::encode_outpoint(&input.previous_output)?, + ])?; + } + Ok(()) +} + +fn network( + tx: &Transaction<'_>, + wallet_id: &WalletId, +) -> Result { + let label: String = tx.query_row( + "SELECT network FROM wallets WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |r| r.get(0), + )?; + wallets::parse_network(&label) + .ok_or_else(|| WalletStorageError::blob_decode("wallets.network is unknown")) +} + +fn owned_output( + tx: &Transaction<'_>, + wallet_id: &WalletId, + outpoint: &OutPoint, + network: dashcore::Network, +) -> Result, WalletStorageError> { + let mut stmt = tx.prepare_cached("SELECT value, length(script), script FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0")?; + let mut rows = stmt.query(params![ + wallet_id.as_slice(), + blob::encode_outpoint(outpoint)? + ])?; + let Some(row) = rows.next()? else { + return Ok(None); + }; + let value = i64_to_u64("core_utxos.value", row.get(0)?)?; + blob::check_size(row.get(1)?)?; + let script: Vec = row.get(2)?; + if contact_only_script(tx, wallet_id, &script)? { + return Ok(None); + } + let address = Address::from_script(&ScriptBuf::from_bytes(script), network)?; + Ok(Some((value, address))) +} + +/// Whether `script` is tracked only by a contact's watch-only (DashPay external) chain. +fn contact_only_script( + conn: &Transaction<'_>, + wallet_id: &WalletId, + script: &[u8], +) -> Result { + Ok(conn.query_row( + "SELECT EXISTS(SELECT 1 FROM core_address_pool WHERE wallet_id = ?1 AND script = ?2) AND NOT EXISTS(SELECT 1 FROM core_address_pool WHERE wallet_id = ?1 AND script = ?2 AND account_type != 'dashpay_external')", + params![wallet_id.as_slice(), script], |r| r.get(0))?) +} + +fn repair_record( + tx: &Transaction<'_>, + wallet_id: &WalletId, + txid: &Txid, + network: dashcore::Network, +) -> Result<(), WalletStorageError> { + let Some(mut record) = prior_record(tx, wallet_id, txid)? else { + return Ok(()); + }; + let original = blob::encode(&record)?; + let mut inputs = BTreeMap::new(); + for detail in record.input_details.drain(..) { + if !contact_only_script(tx, wallet_id, detail.address.script_pubkey().as_bytes())? { + inputs.insert(detail.index, detail); + } + } + for (index, input) in record.transaction.input.iter().enumerate() { + if let Some((value, address)) = + owned_output(tx, wallet_id, &input.previous_output, network)? + { + inputs.insert( + index as u32, + InputDetail { + index: index as u32, + value, + address, + }, + ); + // Stale mempool rows cannot overrule a later sweep's release. + if !matches!(record.context, TransactionContext::Mempool) { + // Record the spender so the mark stays attributable and + // reversible; an existing claim by another spender stands. + tx.execute( + "UPDATE core_utxos SET spent = 1, \ + spent_in_txid = CASE WHEN spent = 1 AND spent_in_txid IS NOT NULL \ + THEN spent_in_txid ELSE ?3 END \ + WHERE wallet_id = ?1 AND outpoint = ?2", + params![ + wallet_id.as_slice(), + blob::encode_outpoint(&input.previous_output)?, + txid.as_byte_array().as_slice() + ], + )?; + } + } + } + let mut outputs = BTreeMap::new(); + for mut detail in record.output_details.drain(..) { + if let Some(address) = &detail.address { + if contact_only_script(tx, wallet_id, address.script_pubkey().as_bytes())? { + detail.role = OutputRole::Sent; + } + } + outputs.insert(detail.index, detail); + } + for (index, output) in record.transaction.output.iter().enumerate() { + let index = index as u32; + if let Some((_, address)) = owned_output( + tx, + wallet_id, + &OutPoint { + txid: *txid, + vout: index, + }, + network, + )? { + let role = outputs.get(&index).map_or(OutputRole::Received, |d| { + if d.role == OutputRole::Change { + OutputRole::Change + } else { + OutputRole::Received + } + }); + outputs.insert( + index, + OutputDetail { + index, + role, + address: Some(address), + value: output.value, + }, + ); + } + } + // Empty metadata is not accounting evidence (e.g. confirmation-only placeholders). + if inputs.is_empty() && outputs.is_empty() { + return Ok(()); + } + let received: i128 = outputs + .values() + .filter(|d| matches!(d.role, OutputRole::Received | OutputRole::Change)) + .map(|d| i128::from(d.value)) + .sum(); + let spent: i128 = inputs.values().map(|d| i128::from(d.value)).sum(); + record.net_amount = i64::try_from(received - spent).map_err(|_| { + WalletStorageError::blob_decode("wallet transaction net amount exceeds i64") + })?; + let has_ours = outputs + .values() + .any(|d| matches!(d.role, OutputRole::Received | OutputRole::Change)); + let has_external = record + .transaction + .output + .iter() + .enumerate() + .any(|(i, output)| { + !output.script_pubkey.is_op_return() + && !outputs.get(&(i as u32)).is_some_and(|d| { + matches!( + d.role, + OutputRole::Received | OutputRole::Change | OutputRole::Unspendable + ) + }) + }); + record.direction = if record.transaction_type == TransactionType::CoinJoin { + TransactionDirection::CoinJoin + } else if inputs.is_empty() { + TransactionDirection::Incoming + } else if !has_external && (has_ours || record.transaction_type == TransactionType::AssetLock) { + TransactionDirection::Internal + } else { + TransactionDirection::Outgoing + }; + record.input_details = inputs.into_values().collect(); + record.output_details = outputs.into_values().collect(); + let repaired = blob::encode(&record)?; + if repaired != original { + // Append-only: the first pre-repair blob is kept verbatim and never + // replaced, so a wrong repair can always be undone. + tx.execute( + "INSERT OR IGNORE INTO core_transaction_record_originals (wallet_id, txid, record_blob) \ + SELECT wallet_id, txid, record_blob FROM core_transactions \ + WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], + )?; + tx.execute( + "UPDATE core_transactions SET record_blob = ?1 WHERE wallet_id = ?2 AND txid = ?3", + params![ + repaired, + wallet_id.as_slice(), + txid.as_byte_array().as_slice() + ], + )?; + } + Ok(()) +} + +pub(super) fn repair_history(tx: &Transaction<'_>) -> Result<(), WalletStorageError> { + // Keys are collected first: savepoint rollbacks must not race an open cursor. + let mut keys = Vec::new(); + { + let mut stmt = tx.prepare_cached("SELECT length(wallet_id), wallet_id, length(txid), txid FROM core_transactions WHERE record_blob IS NOT NULL")?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let key = (|| { + blob::check_fixed_width(row.get(0)?, 32, "core_transactions.wallet_id")?; + let wallet_id: Vec = row.get(1)?; + let wallet_id = id32("core_transactions.wallet_id", &wallet_id)?; + blob::check_fixed_width(row.get(2)?, 32, "core_transactions.txid")?; + let txid: Vec = row.get(3)?; + Ok::<_, WalletStorageError>((wallet_id, Txid::from_slice(&txid)?)) + })(); + match key { + Ok(key) => keys.push(key), + Err(error) if is_unreadable(&error) => { + tracing::warn!(%error, "skipping unreadable transaction key in history migration"); + } + Err(error) => return Err(error), + } + } + } + for (wallet_id, txid) in keys { + repair_best_effort(tx, &txid, || { + let Some(record) = prior_record(tx, &wallet_id, &txid)? else { + return Ok(()); + }; + index_record(tx, &wallet_id, &record)?; + repair_record(tx, &wallet_id, &txid, network(tx, &wallet_id)?) + })?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use dashcore::address::Payload; + use dashcore::{BlockHash, PubkeyHash, Transaction as CoreTransaction, TxIn, TxOut}; + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::transaction_checking::BlockInfo; + use key_wallet::Utxo; + use platform_wallet::changeset::CoreChangeSet; + use rusqlite::Connection; + + use super::*; + use crate::sqlite::schema::core_state; + + fn address(marker: u8) -> Address { + Address::new( + dashcore::Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([marker; 20])), + ) + } + + fn utxo(outpoint: OutPoint, value: u64, address: Address) -> Utxo { + Utxo { + outpoint, + txout: TxOut { + value, + script_pubkey: address.script_pubkey(), + }, + address, + height: 100, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + } + } + + /// Pins V019's observable result on a V018-shaped database: the repaired + /// record, the preserved original, the input index and the spent marks. + #[test] + fn should_pin_v019_repair_of_a_v018_database() { + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0xC1u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let (own, change, contact, external) = (address(1), address(2), address(3), address(4)); + conn.execute( + "INSERT INTO core_address_pool (wallet_id, account_type, account_index, pool_type, address_index, script) \ + VALUES (?1, 'dashpay_external', 0, 0, 0, ?2)", + params![&wallet_id[..], contact.script_pubkey().as_bytes()], + ) + .unwrap(); + let funding = OutPoint::new(Txid::from_byte_array([0x71; 32]), 0); + let body = CoreTransaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: funding, + ..Default::default() + }], + output: vec![ + TxOut { + value: 30_000, + script_pubkey: change.script_pubkey(), + }, + TxOut { + value: 50_000, + script_pubkey: contact.script_pubkey(), + }, + TxOut { + value: 15_000, + script_pubkey: external.script_pubkey(), + }, + ], + special_transaction_payload: None, + }; + let txid = body.txid(); + // What an old build stored: the input was not known to be ours and + // the contact's output was credited as received. + let original = TransactionRecord::new( + body, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::InBlock(BlockInfo::new(101, BlockHash::all_zeros(), 7)), + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + vec![ + OutputDetail { + index: 0, + role: OutputRole::Change, + address: Some(change.clone()), + value: 30_000, + }, + OutputDetail { + index: 1, + role: OutputRole::Received, + address: Some(contact), + value: 50_000, + }, + ], + 80_000, + ); + { + let tx = conn.transaction().unwrap(); + core_state::apply( + &tx, + &wallet_id, + &CoreChangeSet { + new_utxos: vec![ + utxo(funding, 100_000, own.clone()), + utxo(OutPoint::new(txid, 0), 30_000, change.clone()), + ], + ..Default::default() + }, + ) + .unwrap(); + tx.execute( + "INSERT OR REPLACE INTO core_transactions (wallet_id, txid, height, finalized, record_blob) \ + VALUES (?1, ?2, 101, 1, ?3)", + params![ + &wallet_id[..], + txid.as_byte_array().as_slice(), + blob::encode(&original).unwrap() + ], + ) + .unwrap(); + tx.execute_batch( + "DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; \ + DELETE FROM refinery_schema_history WHERE version >= 19;", + ) + .unwrap(); + tx.commit().unwrap(); + } + + crate::sqlite::migrations::run(&mut conn).unwrap(); + + let mut expected = original.clone(); + expected.input_details = vec![InputDetail { + index: 0, + value: 100_000, + address: own, + }]; + expected.output_details = vec![ + OutputDetail { + index: 0, + role: OutputRole::Change, + address: Some(change), + value: 30_000, + }, + OutputDetail { + index: 1, + role: OutputRole::Sent, + address: Some(address(3)), + value: 50_000, + }, + ]; + expected.net_amount = -70_000; + expected.direction = TransactionDirection::Outgoing; + let read_blob = |sql: &str| -> Vec { + conn.query_row( + sql, + params![&wallet_id[..], txid.as_byte_array().as_slice()], + |r| r.get(0), + ) + .unwrap() + }; + assert_eq!( + read_blob( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2" + ), + blob::encode(&expected).unwrap() + ); + assert_eq!( + read_blob( + "SELECT record_blob FROM core_transaction_record_originals WHERE wallet_id = ?1 AND txid = ?2" + ), + blob::encode(&original).unwrap() + ); + let (spent, spender): (bool, Option>) = conn + .query_row( + "SELECT spent, spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![&wallet_id[..], blob::encode_outpoint(&funding).unwrap()], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert!(spent); + assert_eq!(spender.as_deref(), Some(txid.as_byte_array().as_slice())); + let indexed: i64 = conn + .query_row( + "SELECT count(*) FROM core_transaction_inputs WHERE wallet_id = ?1 AND txid = ?2 AND outpoint = ?3", + params![ + &wallet_id[..], + txid.as_byte_array().as_slice(), + blob::encode_outpoint(&funding).unwrap() + ], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(indexed, 1); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs index b9cb1354098..091afa8b073 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs @@ -362,40 +362,3 @@ fn repair_record( } Ok(()) } - -/// Backfill the input index and correct existing history in the migration transaction. -pub(crate) fn migrate(tx: &Transaction<'_>) -> Result<(), WalletStorageError> { - // Keys are collected first: savepoint rollbacks must not race an open cursor. - let mut keys = Vec::new(); - { - let mut stmt = tx.prepare_cached("SELECT length(wallet_id), wallet_id, length(txid), txid FROM core_transactions WHERE record_blob IS NOT NULL")?; - let mut rows = stmt.query([])?; - while let Some(row) = rows.next()? { - let key = (|| { - blob::check_fixed_width(row.get(0)?, 32, "core_transactions.wallet_id")?; - let wallet_id: Vec = row.get(1)?; - let wallet_id = super::id32("core_transactions.wallet_id", &wallet_id)?; - blob::check_fixed_width(row.get(2)?, 32, "core_transactions.txid")?; - let txid: Vec = row.get(3)?; - Ok::<_, WalletStorageError>((wallet_id, Txid::from_slice(&txid)?)) - })(); - match key { - Ok(key) => keys.push(key), - Err(error) if is_unreadable(&error) => { - tracing::warn!(%error, "skipping unreadable transaction key in history migration"); - } - Err(error) => return Err(error), - } - } - } - for (wallet_id, txid) in keys { - repair_best_effort(tx, &txid, || { - let Some(record) = prior_record(tx, &wallet_id, &txid)? else { - return Ok(()); - }; - index_record(tx, &wallet_id, &record)?; - repair_record(tx, &wallet_id, &txid, network(tx, &wallet_id)?) - })?; - } - Ok(()) -} From 283acf7012b89e36d8dcb252a69537a09b818fef Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:42:17 +0000 Subject: [PATCH 04/26] fix(platform-wallet-storage): restore unconfirmed spend reservations on load Load replayed only block-confirmed records, so a mempool or InstantSend spend never reached the account's spent set. A redelivered funding transaction (rescan, reorg re-connect, or the funding confirming after a restart) then re-credited the output that spend reserves, and the round wrote it back to SQLite as unspent. Replay unconfirmed records after the confirmed ones, parents before children, so the checker skips re-crediting reserved outputs. The persisted-unspent filter still keeps their own outputs from being credited unless persistence holds them. The regression tests now redeliver funding after reload, for confirmed and still-unconfirmed funding. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/persister.rs | 2 +- .../src/sqlite/rehydrate.rs | 59 ++++++++++++++----- .../tests/sqlite_spent_rehydration.rs | 38 +++++++++++- 3 files changed, 81 insertions(+), 18 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 31679e8b0fc..69967fa465a 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1850,7 +1850,7 @@ fn load_one_wallet( })?; } let (wallet, wallet_info) = - super::rehydrate::restore_confirmed_transactions(wallet_info, wallet, core_state.records) + super::rehydrate::restore_recorded_transactions(wallet_info, wallet, core_state.records) .map_err(PersistenceError::from)?; Ok(platform_wallet::changeset::ClientWalletStartState { wallet, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index db27b6106ce..fbf892b63be 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -264,8 +264,9 @@ pub(crate) fn restore_provider_platform_node_pool( /// Coinbase-maturity nuance re-warms on sync. `is_instantlocked` is NOT /// among them: it is rebuilt from `core_instant_locks` above, for every /// UTXO a replayed lock covers. -/// - **Transaction records**: the SQLite loader replays confirmed history -/// through the wallet checker after this projection. +/// - **Transaction records**: the SQLite loader replays recorded history +/// (confirmed, then unconfirmed) through the wallet checker after this +/// projection. /// /// # Errors /// @@ -420,8 +421,11 @@ pub fn apply_persisted_core_state( Ok(()) } -/// Restore spend and finality guards without re-crediting outputs excluded by persistence. -pub(crate) fn restore_confirmed_transactions( +/// Restore spend reservations and finality guards without re-crediting outputs excluded by persistence. +/// +/// Unconfirmed (mempool / InstantSend) spends are replayed too: without them a +/// redelivered funding transaction would re-credit an output they reserve. +pub(crate) fn restore_recorded_transactions( mut wallet_info: ManagedWalletInfo, mut wallet: Wallet, records: Vec, @@ -437,22 +441,14 @@ pub(crate) fn restore_confirmed_transactions( account.utxos.keys().map(move |outpoint| (*outpoint, owner)) }) .collect(); - let mut confirmed: Vec<_> = records - .into_iter() - .filter(|record| record.block_info().is_some()) - .collect(); - if confirmed.is_empty() { + if records.is_empty() { return Ok((wallet, wallet_info)); } - confirmed.sort_by_key(|record| { - record - .block_info() - .map(|block| (block.height(), block.position())) - }); + let replay = replay_order(records); // The checker only mutates in-memory state; no network requests or persistence. dash_async::block_on(async move { - for record in confirmed { + for record in replay { wallet_info .check_core_transaction( &record.transaction, @@ -505,6 +501,39 @@ pub(crate) fn restore_confirmed_transactions( .map_err(WalletStorageError::CoreHistoryReplay) } +/// Order records as the chain would deliver them: confirmed by block position, +/// then unconfirmed ones with every in-set parent ahead of its children. +fn replay_order(records: Vec) -> Vec { + let (mut ordered, mut pending): (Vec<_>, Vec<_>) = records + .into_iter() + .partition(|record| record.block_info().is_some()); + ordered.sort_by_key(|record| { + record + .block_info() + .map(|block| (block.height(), block.position())) + }); + // Deterministic start; parents are then pulled forward in rounds. + pending.sort_by_key(|record| record.txid); + while !pending.is_empty() { + let waiting: HashSet<_> = pending.iter().map(|record| record.txid).collect(); + let (ready, blocked): (Vec<_>, Vec<_>) = pending.into_iter().partition(|record| { + record.transaction.input.iter().all(|input| { + input.previous_output.txid == record.txid + || !waiting.contains(&input.previous_output.txid) + }) + }); + if ready.is_empty() { + // Unreachable for real transactions (txids cannot form a cycle); + // keep the rest rather than drop a reservation. + ordered.extend(blocked); + break; + } + ordered.extend(ready); + pending = blocked; + } + ordered +} + /// Account identity of a funds account, stable across replay mutations. fn funds_account_type( account: &key_wallet::managed_account::ManagedCoreFundsAccount, diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs index 46076a9bb99..3beff7ba942 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs @@ -45,6 +45,13 @@ struct Fixture { impl Fixture { async fn new(spend_context: TransactionContext) -> Self { + Self::with_funding(block(100), spend_context).await + } + + async fn with_funding( + funding_context: TransactionContext, + spend_context: TransactionContext, + ) -> Self { let mut wallet = Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); @@ -91,7 +98,7 @@ impl Fixture { special_transaction_payload: None, }; let funding_result = info - .check_core_transaction(&funding, block(100), &mut wallet, true, true) + .check_core_transaction(&funding, funding_context, &mut wallet, true, true) .await; let coins: Vec<_> = info.accounts.standard_bip44_accounts[&0] .utxos @@ -170,6 +177,19 @@ impl Fixture { assert_eq!(selection.selected[0].outpoint, self.available); } + fn assert_spent_stored(&self, expected: bool) { + let spent: bool = self + .persister + .lock_conn_for_test() + .query_row( + "SELECT spent FROM core_utxos WHERE substr(outpoint, 2, 32) = ?1 AND value = 100000", + [self.spent.txid.as_byte_array().as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(spent, expected, "stored spent flag of the reserved input"); + } + async fn redeliver(&self, wallet: &mut Wallet, info: &mut ManagedWalletInfo) { let result = info .check_core_transaction(&self.funding, block(100), wallet, true, true) @@ -235,9 +255,23 @@ async fn should_reconcile_stale_unspent_projection_against_confirmed_history() { #[tokio::test] async fn should_not_release_inputs_reserved_by_unconfirmed_spend() { let fixture = Fixture::new(TransactionContext::Mempool).await; - let (_, info) = fixture.load(); + let (mut wallet, mut info) = fixture.load(); fixture.assert_spent_excluded(&info); assert!(!info.observed_spent_outpoints().contains_key(&fixture.spent)); + fixture.redeliver(&mut wallet, &mut info).await; + fixture.assert_spent_stored(true); +} + +#[tokio::test] +async fn should_keep_unconfirmed_funding_reserved_when_it_confirms_after_reload() { + let fixture = + Fixture::with_funding(TransactionContext::Mempool, TransactionContext::Mempool).await; + let (mut wallet, mut info) = fixture.load(); + assert!(!info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&fixture.spent)); + fixture.redeliver(&mut wallet, &mut info).await; + fixture.assert_spent_stored(true); } #[tokio::test] From 05ade4e463a84bab355b5a5ebb165603ac68793e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:45:30 +0000 Subject: [PATCH 05/26] fix(platform-wallet-storage): replay load history without an async bridge The wallet checker is async only by trait shape and never awaits, yet load drove it through dash_async::block_on, which spawns a thread and runtime on current-thread runtimes, and surfaced bridge failures through a new public WalletStorageError::CoreHistoryReplay variant (a breaking change, as the enum is exhaustive). Poll the replay once instead. If a future upstream checker ever suspends, load logs an error and keeps the pre-replay projection rather than failing the wallet; a unit test pins first-poll completion so such a change fails in CI. This removes the variant and the dash-async dependency. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 - .../rs-platform-wallet-storage/Cargo.toml | 2 - .../src/sqlite/error.rs | 9 +- .../src/sqlite/persister.rs | 9 +- .../src/sqlite/rehydrate.rs | 182 +++++++++++++----- .../tests/sqlite_error_classification.rs | 2 - 6 files changed, 138 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a0b69124ee6..324364168c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5350,7 +5350,6 @@ dependencies = [ "chacha20poly1305", "chrono", "clap", - "dash-async", "dash-sdk", "dashcore", "dbus-secret-service-keyring-store", diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml index d506d239da1..fe491fcc76b 100644 --- a/packages/rs-platform-wallet-storage/Cargo.toml +++ b/packages/rs-platform-wallet-storage/Cargo.toml @@ -41,7 +41,6 @@ platform-wallet = { path = "../rs-platform-wallet", features = [ "eddsa", ], optional = true } serde = { version = "1", features = ["derive"], optional = true } -dash-async = { path = "../rs-dash-async", optional = true } key-wallet = { workspace = true, optional = true } dashcore = { workspace = true, optional = true } dpp = { path = "../rs-dpp", optional = true } @@ -226,7 +225,6 @@ sqlite = [ "dep:platform-wallet", "dep:serde", "dep:key-wallet", - "dep:dash-async", "dep:dashcore", "dep:dpp", "dep:dash-sdk", diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs index 2a7a30414b9..9f843edf7bb 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -34,10 +34,6 @@ pub enum AutoBackupOperation { /// Errors produced by the wallet-storage SQLite backend. #[derive(Debug, thiserror::Error)] pub enum WalletStorageError { - /// Confirmed Core history could not be replayed into the restored wallet. - #[error("could not restore confirmed Core history: {0}")] - CoreHistoryReplay(#[source] dash_async::AsyncError), - /// File-system I/O error reaching the database or backup files. #[error("io error")] Io(#[from] std::io::Error), @@ -710,8 +706,7 @@ impl WalletStorageError { // `ToSqlConversionFailure`, `InvalidColumnIndex`) — is a // logic bug, not a contention failure. Self::Sqlite(_) => false, - Self::CoreHistoryReplay(_) - | Self::Io(_) + Self::Io(_) | Self::Migration(_) | Self::IntegrityCheckFailed { .. } | Self::IntegrityCheckRunFailed { .. } @@ -876,7 +871,6 @@ impl WalletStorageError { | Self::UnownedIdentityHasRegistrationIndex { .. } | Self::EmptyUtxoScript { .. } | Self::EmptyPoolAddressScript { .. } - | Self::CoreHistoryReplay(_) | Self::DatabasePathIsSymlink { .. } => PersistenceErrorKind::Fatal, } } @@ -897,7 +891,6 @@ impl WalletStorageError { }, Self::Sqlite(_) => "sqlite_other", Self::FlushRetryable { .. } => "flush_retryable", - Self::CoreHistoryReplay(_) => "core_history_replay", Self::Io(_) => "io", Self::Migration(_) => "migration", Self::IntegrityCheckFailed { .. } => "integrity_check_failed", diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 69967fa465a..d7aa54e8134 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1849,9 +1849,12 @@ fn load_one_wallet( )) })?; } - let (wallet, wallet_info) = - super::rehydrate::restore_recorded_transactions(wallet_info, wallet, core_state.records) - .map_err(PersistenceError::from)?; + let mut wallet = wallet; + super::rehydrate::restore_recorded_transactions( + &mut wallet_info, + &mut wallet, + core_state.records, + ); Ok(platform_wallet::changeset::ClientWalletStartState { wallet, wallet_info, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index fbf892b63be..b2f578c74ef 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -426,10 +426,10 @@ pub fn apply_persisted_core_state( /// Unconfirmed (mempool / InstantSend) spends are replayed too: without them a /// redelivered funding transaction would re-credit an output they reserve. pub(crate) fn restore_recorded_transactions( - mut wallet_info: ManagedWalletInfo, - mut wallet: Wallet, + wallet_info: &mut ManagedWalletInfo, + wallet: &mut Wallet, records: Vec, -) -> Result<(Wallet, ManagedWalletInfo), WalletStorageError> { +) { // Where the load projection parked each unspent outpoint; its keys are // the outputs persistence still considers unspent. let placed: HashMap = wallet_info @@ -442,63 +442,85 @@ pub(crate) fn restore_recorded_transactions( }) .collect(); if records.is_empty() { - return Ok((wallet, wallet_info)); + return; } + // TODO(bound-load-history-replay): every stored record is replayed on each + // load; bounding it to records above the last chain lock needs care so + // finality and spend guards for older records are not lost. let replay = replay_order(records); - // The checker only mutates in-memory state; no network requests or persistence. - dash_async::block_on(async move { + // Kept only to undo a replay the checker suspended part-way through. + let (info_before, wallet_before) = (wallet_info.clone(), wallet.clone()); + let completed = poll_ready(async { for record in replay { wallet_info - .check_core_transaction( - &record.transaction, - record.context, - &mut wallet, - true, - false, - ) + .check_core_transaction(&record.transaction, record.context, wallet, true, false) .await; } + }) + .is_some(); + if !completed { + // Degrade to the pre-replay projection: persisted spends stay + // excluded, only redelivery guards are missing until the next sync. + tracing::error!( + wallet_id = %hex::encode(wallet_info.wallet_id), + "transaction checker suspended during load replay; restored spend guards skipped" + ); + *wallet_info = info_before; + *wallet = wallet_before; + return; + } - let spent: HashSet<_> = wallet_info - .observed_spent_outpoints() - .keys() - .copied() - .collect(); - // Replay credits an output to the account whose pool derives it. When - // that differs from the load-time fallback, the fallback copy is a - // duplicate: drop it so each outpoint lives in exactly one account. - let misplaced: HashSet<(OutPoint, AccountType)> = wallet_info - .accounts - .all_funding_accounts() - .into_iter() - .flat_map(|account| { - let owner = funds_account_type(account); - let placed = &placed; - account.utxos.keys().filter_map(move |outpoint| { - placed - .get(outpoint) - .filter(|parked| **parked != owner) - .map(|parked| (*outpoint, *parked)) - }) - }) - .collect(); - for account in wallet_info.accounts.all_funding_accounts_mut() { + let spent: HashSet<_> = wallet_info + .observed_spent_outpoints() + .keys() + .copied() + .collect(); + // Replay credits an output to the account whose pool derives it. When + // that differs from the load-time fallback, the fallback copy is a + // duplicate: drop it so each outpoint lives in exactly one account. + let misplaced: HashSet<(OutPoint, AccountType)> = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .flat_map(|account| { let owner = funds_account_type(account); - account.utxos.retain(|outpoint, _| { - placed.contains_key(outpoint) - && !spent.contains(outpoint) - && !misplaced.contains(&(*outpoint, owner)) - }); - } - // Finalize replayed records before a sync checkpoint can prune their spend guards. - if let Some(chain_lock) = wallet_info.metadata.last_applied_chain_lock.clone() { - wallet_info.apply_chain_lock(chain_lock); - } - wallet_info.update_balance(); - (wallet, wallet_info) - }) - .map_err(WalletStorageError::CoreHistoryReplay) + let placed = &placed; + account.utxos.keys().filter_map(move |outpoint| { + placed + .get(outpoint) + .filter(|parked| **parked != owner) + .map(|parked| (*outpoint, *parked)) + }) + }) + .collect(); + for account in wallet_info.accounts.all_funding_accounts_mut() { + let owner = funds_account_type(account); + account.utxos.retain(|outpoint, _| { + placed.contains_key(outpoint) + && !spent.contains(outpoint) + && !misplaced.contains(&(*outpoint, owner)) + }); + } + // Finalize replayed records before a sync checkpoint can prune their spend guards. + if let Some(chain_lock) = wallet_info.metadata.last_applied_chain_lock.clone() { + wallet_info.apply_chain_lock(chain_lock); + } + wallet_info.update_balance(); +} + +/// Poll `future` once, returning its output only if it completed without suspending. +/// +/// The wallet checker is `async` only by trait shape: it never awaits, so it +/// completes on the first poll and load needs no async runtime. A test pins +/// that; an upstream change that adds a real await fails it. +fn poll_ready(future: F) -> Option { + let mut future = std::pin::pin!(future); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + match future.as_mut().poll(&mut cx) { + std::task::Poll::Ready(output) => Some(output), + std::task::Poll::Pending => None, + } } /// Order records as the chain would deliver them: confirmed by block position, @@ -3285,4 +3307,62 @@ mod tests { "the restored UTXO must carry instant-locked status, not wait for the next sync" ); } + + /// Load replays history without an async runtime by polling the checker + /// once. If upstream ever makes it suspend, this fails in CI instead of + /// load silently skipping the restored spend guards in production. + #[test] + fn should_complete_transaction_checker_on_first_poll() { + use dashcore::hashes::Hash; + use dashcore::{OutPoint, Transaction, TxIn, TxOut, Txid}; + use key_wallet::transaction_checking::{ + BlockInfo, TransactionContext, WalletTransactionChecker, + }; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let address = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([9; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 1_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let spend = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(funding.txid(), 0), + ..Default::default() + }], + output: Vec::new(), + special_transaction_payload: None, + }; + let block = + TransactionContext::InBlock(BlockInfo::new(1, dashcore::BlockHash::all_zeros(), 1)); + for (tx, context) in [(&funding, block), (&spend, TransactionContext::Mempool)] { + let result = + poll_ready(info.check_core_transaction(tx, context, &mut wallet, true, false)); + assert!( + result.is_some_and(|r| r.is_relevant), + "the checker must complete on its first poll" + ); + } + } } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs index 935b204a2bc..36d46cb6073 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs @@ -351,7 +351,6 @@ fn samples() -> Vec { highest_used: Some(u32::MAX - 5), gap_limit: 20, }, - WalletStorageError::CoreHistoryReplay(dash_async::AsyncError::Generic("test".into())), WalletStorageError::DatabasePathIsSymlink { path: PathBuf::from("/tmp/wallet.db"), }, @@ -494,7 +493,6 @@ fn tc_p2_005_is_transient_table() { WalletStorageError::EmptyPoolAddressScript { .. } => { (false, "empty_pool_address_script") } - WalletStorageError::CoreHistoryReplay(_) => (false, "core_history_replay"), WalletStorageError::DatabasePathIsSymlink { .. } => (false, "database_path_is_symlink"), } } From bc16c1760dc4391d2efc24b2f9f0307909ef6f05 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:46:42 +0000 Subject: [PATCH 06/26] fix(swift-sdk): classify repaired direction like the Rust repair The Swift accounting reconcile and the SQLite history repair disagreed on direction: Swift reported an asset lock as internal even when an output left the wallet, and a spend with no remaining outputs as internal. Swift now uses the Rust repair's rule (internal only when nothing leaves the wallet and something stays in it, or an asset lock burns into Platform). The Rust rule moves into a pure helper, and both sides test the same case table. The upstream rust-dashcore recompute stays out of scope. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/schema/core_history.rs | 68 ++++++++++++++++--- .../Models/PersistentTransaction.swift | 5 +- .../TransactionAccountingTests.swift | 26 ++++++- 3 files changed, 88 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs index 091afa8b073..d7aeacba5ce 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs @@ -330,15 +330,12 @@ fn repair_record( ) }) }); - record.direction = if record.transaction_type == TransactionType::CoinJoin { - TransactionDirection::CoinJoin - } else if inputs.is_empty() { - TransactionDirection::Incoming - } else if !has_external && (has_ours || record.transaction_type == TransactionType::AssetLock) { - TransactionDirection::Internal - } else { - TransactionDirection::Outgoing - }; + record.direction = repaired_direction( + record.transaction_type, + !inputs.is_empty(), + has_ours, + has_external, + ); record.input_details = inputs.into_values().collect(); record.output_details = outputs.into_values().collect(); let repaired = blob::encode(&record)?; @@ -362,3 +359,56 @@ fn repair_record( } Ok(()) } + +/// Direction of a repaired record. The Swift SDK's +/// `PersistentTransaction.reconciledAccounting` applies the same rule; keep +/// both in step (each side tests the same case table). +/// +/// `has_external` counts every output that is neither ours nor an OP_RETURN +/// burn, so an asset lock is internal only when nothing leaves the wallet. +fn repaired_direction( + transaction_type: TransactionType, + spends_ours: bool, + has_ours: bool, + has_external: bool, +) -> TransactionDirection { + if transaction_type == TransactionType::CoinJoin { + TransactionDirection::CoinJoin + } else if !spends_ours { + TransactionDirection::Incoming + } else if !has_external && (has_ours || transaction_type == TransactionType::AssetLock) { + TransactionDirection::Internal + } else { + TransactionDirection::Outgoing + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Shared with the Swift SDK's `TransactionAccountingTests` direction table. + #[test] + fn should_classify_repaired_direction_like_the_swift_sdk() { + use TransactionDirection::{CoinJoin, Incoming, Internal, Outgoing}; + use TransactionType::{AssetLock, Standard}; + // (type, spends ours, has owned output, has external output, expected) + let cases = [ + (Standard, true, true, false, Internal), + (Standard, true, true, true, Outgoing), + (Standard, true, false, false, Outgoing), + (AssetLock, true, false, false, Internal), + (AssetLock, true, true, false, Internal), + (AssetLock, true, false, true, Outgoing), + (Standard, false, true, false, Incoming), + (TransactionType::CoinJoin, true, true, false, CoinJoin), + ]; + for (kind, spends_ours, has_ours, has_external, expected) in cases { + assert_eq!( + repaired_direction(kind, spends_ours, has_ours, has_external), + expected, + "{kind:?} spends_ours={spends_ours} has_ours={has_ours} has_external={has_external}" + ); + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index 0fe3efe8f2e..c6d11db2591 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -287,10 +287,13 @@ public final class PersistentTransaction { guard let received = total(ownedOutputAmounts), let spent = total(inputs.map(\.amount)) else { return nil } + // Same rule as the Rust repair (`core_history::repaired_direction`): + // internal only when nothing leaves the wallet and something stays in + // it, or an asset lock burns into Platform. Both sides test one table. let direction: UInt32 if previousDirection == 3 { direction = 3 } else if inputs.isEmpty { direction = 0 } - else if isAssetLock || allOutputsOwned { direction = 2 } + else if allOutputsOwned && (!ownedOutputAmounts.isEmpty || isAssetLock) { direction = 2 } else { direction = 1 } return (received - spent, direction) } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift index ccde14865f4..cdc6e85de00 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -31,7 +31,7 @@ final class TransactionAccountingTests: XCTestCase { inputs: [spent], ownedOutputAmounts: [99], allOutputsOwned: true, previousDirection: 0, isAssetLock: false )?.direction, 2) let lock = PersistentTransaction.reconciledAccounting( - inputs: [spent], ownedOutputAmounts: [], allOutputsOwned: false, previousDirection: 2, isAssetLock: true + inputs: [spent], ownedOutputAmounts: [], allOutputsOwned: true, previousDirection: 2, isAssetLock: true ) XCTAssertEqual(lock?.netAmount, -100) XCTAssertEqual(lock?.direction, 2) @@ -40,6 +40,30 @@ final class TransactionAccountingTests: XCTestCase { )?.direction, 3) } + /// Same case table as the Rust repair's + /// `should_classify_repaired_direction_like_the_swift_sdk`. + func testShouldClassifyDirectionLikeTheRustRepair() { + let spent = input(100) + // (spends ours, owned output amounts, all outputs owned, asset lock, previous direction, expected) + let cases: [(Bool, [UInt64], Bool, Bool, UInt32, UInt32)] = [ + (true, [99], true, false, 0, 2), + (true, [40], false, false, 0, 1), + (true, [], true, false, 0, 1), + (true, [], true, true, 0, 2), + (true, [40], true, true, 0, 2), + (true, [], false, true, 0, 1), + (false, [40], true, false, 0, 0), + (true, [99], true, false, 3, 3), + ] + for (index, (spendsOurs, owned, allOwned, isLock, previous, expected)) in cases.enumerated() { + let result = PersistentTransaction.reconciledAccounting( + inputs: spendsOurs ? [spent] : [], ownedOutputAmounts: owned, + allOutputsOwned: allOwned, previousDirection: previous, isAssetLock: isLock + ) + XCTAssertEqual(result?.direction, expected, "case \(index)") + } + } + func testShouldRejectOverflowInsteadOfWrappingHistory() { XCTAssertNil(PersistentTransaction.reconciledAccounting( inputs: [input(UInt64.max)], ownedOutputAmounts: [], allOutputsOwned: false, previousDirection: 0, isAssetLock: false From e5fc327055e218ab1ff180be1138e5920830b3c1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:47:17 +0000 Subject: [PATCH 07/26] test(swift-sdk): model asset-lock burns as OP_RETURN outputs The asset-lock fixtures used an empty-script output as the burn, which the aligned direction rule rightly treats as leaving the wallet. Use a real OP_RETURN so the fixtures match what an asset lock carries. Co-Authored-By: Claude Opus 5.5 --- .../SwiftDashSDKTests/TransactionAccountingTests.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift index cdc6e85de00..fa60ce5f72a 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -78,7 +78,8 @@ final class TransactionAccountingTests: XCTestCase { XCTAssertEqual(tx.netAmount(for: first.walletId), -100) XCTAssertEqual(tx.netAmount(for: other.walletId), -200) } - private func serializedSpend(inputs: [Data], outputValue: UInt64 = 40) -> Data { + /// `burn` makes the single output an OP_RETURN, as an asset lock's is. + private func serializedSpend(inputs: [Data], outputValue: UInt64 = 40, burn: Bool = false) -> Data { var bytes = Data([2, 0, 0, 0, UInt8(inputs.count)]) for txid in inputs { bytes.append(txid) @@ -86,7 +87,8 @@ final class TransactionAccountingTests: XCTestCase { } bytes.append(1) withUnsafeBytes(of: outputValue.littleEndian) { bytes.append(contentsOf: $0) } - bytes.append(contentsOf: [0, 0, 0, 0, 0]) + bytes.append(contentsOf: burn ? [1, 0x6a] : [0]) + bytes.append(contentsOf: [0, 0, 0, 0]) return bytes } @@ -266,7 +268,7 @@ final class TransactionAccountingTests: XCTestCase { let walletId = Data(repeating: 1, count: 32) context.insert(PersistentWallet(walletId: walletId, network: .testnet)) let spenderId = Data(repeating: 3, count: 32) - let bytes = serializedSpend(inputs: [walletId, Data(repeating: 2, count: 32)], outputValue: 0) + let bytes = serializedSpend(inputs: [walletId, Data(repeating: 2, count: 32)], outputValue: 0, burn: true) let spender = PersistentTransaction(txid: spenderId, transactionData: bytes, direction: 2, netAmount: -200) spender.transactionTypeKind = 6 let coin = input(100) @@ -291,7 +293,7 @@ final class TransactionAccountingTests: XCTestCase { let spenderId = Data(repeating: 3, count: 32) persist(handler, walletId: walletId, txid: walletId, outputs: [(walletId, 100)]) persist(handler, walletId: walletId, txid: spenderId, - bytes: serializedSpend(inputs: [walletId], outputValue: 0), kind: 6, inputTxids: [walletId]) + bytes: serializedSpend(inputs: [walletId], outputValue: 0, burn: true), kind: 6, inputTxids: [walletId]) let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spenderId }) XCTAssertEqual(row.netAmount, -100) XCTAssertEqual(row.direction, 2) From 4a30a5b3344aef2fad8444f3fe26c07e44c1c9d9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:48:29 +0000 Subject: [PATCH 08/26] fix(swift-sdk): scope per-wallet transaction amounts netAmount(for:) returned the stored scalar through its single-wallet fast path before checking for unresolved inputs, and the accounting reconcile counted an address-matched output of any local wallet, so a transfer to another local wallet whose TXO was not linked yet showed the sender only part of what it paid. Unresolved inputs now make every amount provisional, and an unlinked address-matched output counts only when it belongs to a spending wallet. Co-Authored-By: Claude Opus 5.5 --- .../Models/PersistentTransaction.swift | 3 +- .../PlatformWalletPersistenceHandler.swift | 7 ++- .../TransactionAccountingTests.swift | 52 +++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index c6d11db2591..373123fcf0e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -241,8 +241,9 @@ public final class PersistentTransaction { let wallets = Set((inputs + outputs).filter(PlatformWalletPersistenceHandler.isWalletOwnedTxo) .compactMap { PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) }) let hasUnownedTxos = (inputs + outputs).contains { !PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) } - if wallets.count == 1, wallets.contains(walletId), !hasUnownedTxos { return netAmount } + // Unresolved inputs make any amount provisional, the stored one included. guard pendingInputs.isEmpty else { return nil } + if wallets.count == 1, wallets.contains(walletId), !hasUnownedTxos { return netAmount } let walletInputs = owned(inputs) let walletOutputs = owned(outputs) guard !walletInputs.isEmpty || !walletOutputs.isEmpty else { return nil } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 354e2cf32e8..09e8d0cce81 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -6837,6 +6837,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { var amounts: [UInt64] = [] var allOutputsOwned = true let ownedVouts = Set(transaction.outputs.filter { !$0.isDeleted && Self.isWalletOwnedTxo($0) }.map(\.vout)) + // An address-matched output with no linked TXO carries no wallet of + // its own, so it counts only for the spending wallets: another local + // wallet's credit must not hide inside the sender's scalar. + let spendingWallets = Set(inputs.compactMap { Self.resolvedWalletId(of: $0) }) for (index, output) in decoded.outputs.enumerated() { // OP_RETURN burns (including asset locks) are not spendable Core outputs. if output.scriptPubkey.first == 0x6a { continue } @@ -6846,7 +6850,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let owner: PersistentCoreAddress? if let cached = roundIndex?.coreAddressesByAddress[address] { owner = cached } else { owner = try modelFetcher.fetch(descriptor, in: backgroundContext).first } - if let account = owner?.account, account.accountType != Self.dashpayExternalAccountTypeTag { + if let account = owner?.account, account.accountType != Self.dashpayExternalAccountTypeTag, + spendingWallets.contains(account.wallet.walletId) { belongs = true } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift index fa60ce5f72a..581100392cf 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -300,6 +300,58 @@ final class TransactionAccountingTests: XCTestCase { XCTAssertTrue(row.isAssetLock) } + func testShouldNotReportStoredAmountWhileInputsArePending() { + let walletId = Data(repeating: 1, count: 32) + let tx = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data(), netAmount: 40) + let change = PersistentTxo(transaction: tx, vout: 0, amount: 40, address: "", height: 1) + change.walletId = walletId + tx.outputs = [change] + XCTAssertEqual(tx.netAmount(for: walletId), 40) + tx.pendingInputs = [PersistentPendingInput( + outpoint: Data(repeating: 9, count: 36), inputIndex: 0, + spendingTxid: tx.txid, spendingTransaction: tx, walletId: walletId + )] + XCTAssertNil(tx.netAmount(for: walletId), "a missing input makes the stored amount provisional") + } + + func testShouldNotCountAnotherLocalWalletsUnlinkedOutputForTheSender() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let senderId = Data(repeating: 1, count: 32) + let receiver = PersistentWallet(walletId: Data(repeating: 2, count: 32), network: .testnet) + let receiverAccount = PersistentAccount( + wallet: receiver, accountType: 0, accountIndex: 0, accountTypeName: "Standard BIP44 Account" + ) + // P2PKH to pubkey hash 0x05 x 20 on testnet: B's address with no TXO row yet. + let receiverAddress = PersistentCoreAddress( + address: "yLmzEvw3frCPS4cyRmFFeKbt64fUPzMwFh", poolTypeTag: 0, addressIndex: 0, derivationPath: "" + ) + receiverAddress.account = receiverAccount + context.insert(PersistentWallet(walletId: senderId, network: .testnet)) + context.insert(receiver) + context.insert(receiverAccount) + context.insert(receiverAddress) + var bytes = Data([2, 0, 0, 0, 1]) + bytes.append(senderId) + bytes.append(contentsOf: [0, 0, 0, 0, 0, 255, 255, 255, 255, 1]) + withUnsafeBytes(of: UInt64(40).littleEndian) { bytes.append(contentsOf: $0) } + bytes.append(contentsOf: [25, 0x76, 0xa9, 0x14] + [UInt8](repeating: 5, count: 20) + [0x88, 0xac]) + bytes.append(contentsOf: [0, 0, 0, 0]) + let spender = PersistentTransaction( + txid: Data(repeating: 3, count: 32), transactionData: bytes, direction: 1, netAmount: -100 + ) + let coin = input(100) + coin.spendingTransaction = spender + context.insert(coin) + context.insert(spender) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spender.txid }) + XCTAssertEqual(row.netAmount, -100, "the receiving wallet's credit is not the sender's") + XCTAssertEqual(row.netAmount(for: senderId), -100) + } + func testShouldExcludePersistedContactOutputsFromOwnedAccounting() throws { let container = try DashModelContainer.createInMemory() let context = container.mainContext From 8e5bcff11919388cc7238c0bebb4e65bf39a9501 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:48:57 +0000 Subject: [PATCH 09/26] fix(swift-sdk): keep asset-lock debits when no input is linked yet The guard that keeps a funded asset lock's Core debit and fee from being overwritten by a context-only zero update required an already-linked owned input. With every prevout still pending, the synthetic update erased the debit and fee, and reconcile could not restore them. A stored negative amount is itself the proof the wallet funded the lock, so the guard now keys on it. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.swift | 3 ++- .../TransactionAccountingTests.swift | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 09e8d0cce81..e3bebdd20be 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2547,8 +2547,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let blockHashBytes = hashData(tx.block_hash) record.blockHash = blockHashBytes.allSatisfy { $0 == 0 } ? nil : blockHashBytes // A context-only recovery record has zero accounting; a funded asset lock burns Core value. + // A stored debit is itself the proof we funded it: its inputs may not be linked yet. let preserveLockAccounting = tx.transaction_type_kind == 6 && tx.net_amount == 0 && !tx.has_fee - && record.netAmount != 0 && record.inputs.contains(where: Self.isWalletOwnedTxo) + && record.netAmount < 0 if !preserveLockAccounting { record.direction = tx.direction } if let typeName = tx.transaction_type { record.transactionType = String(cString: typeName) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift index 581100392cf..688c0f7c844 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -284,6 +284,26 @@ final class TransactionAccountingTests: XCTestCase { XCTAssertEqual(row.context, 2) } + func testShouldPreserveAssetLockDebitWhenNoInputIsLinkedYet() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let spenderId = Data(repeating: 3, count: 32) + let bytes = serializedSpend(inputs: [Data(repeating: 2, count: 32)], outputValue: 0, burn: true) + let lock = PersistentTransaction(txid: spenderId, transactionData: bytes, direction: 2, netAmount: -200) + lock.transactionTypeKind = 6 + lock.fee = 7 + context.insert(lock) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + persist(handler, walletId: walletId, txid: spenderId, bytes: bytes, kind: 6) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spenderId }) + XCTAssertEqual(row.netAmount, -200, "a synthetic zero update must not erase the debit") + XCTAssertEqual(row.fee, 7) + XCTAssertEqual(row.direction, 2) + } + func testShouldRepairNoChangeAssetLockToFullCoreDebit() throws { let container = try DashModelContainer.createInMemory() let walletId = Data(repeating: 1, count: 32) From ec8b9458fa7e2983503b836ba1e894e4ea4e2453 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:49:46 +0000 Subject: [PATCH 10/26] fix(swift-sdk): never block wallet restore on load-time accounting loadWalletList's history accounting pass returned a load failure on any fetch, reconcile or save error, so a problem in display-only amounts left every wallet unrestored. It also fetched a PersistentCoreAddress per unlinked output. A failed pass now rolls back its own edits, logs a distinct event and lets the restore continue; addresses are read once into a lookup. A persisted completion marker is deferred (TODO) because it needs a SwiftData shape change. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.swift | 23 +++++++++++++++---- .../TransactionAccountingTests.swift | 12 ++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index e3bebdd20be..940fc664c30 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -6811,7 +6811,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Repair only fully resolved spends; missing prevouts are not evidence of external ownership. func reconcileTransactionAccounting( - _ transactions: [PersistentTransaction], txos: [Data: PersistentTxo]? = nil + _ transactions: [PersistentTransaction], txos: [Data: PersistentTxo]? = nil, + addresses: [String: PersistentCoreAddress]? = nil ) throws { for transaction in transactions where !transaction.isDeleted { guard let transactionNetwork = network @@ -6850,6 +6851,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let descriptor = FetchDescriptor(predicate: #Predicate { $0.address == address }) let owner: PersistentCoreAddress? if let cached = roundIndex?.coreAddressesByAddress[address] { owner = cached } + else if let addresses { owner = addresses[address] } else { owner = try modelFetcher.fetch(descriptor, in: backgroundContext).first } if let account = owner?.account, account.accountType != Self.dashpayExternalAccountTypeTag, spendingWallets.contains(account.wallet.walletId) { @@ -6918,6 +6920,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // Mid-round the context holds another round's staged writes: saving // would commit half of it and rolling back would silently drop it. // That round reconciles its own dirty rows; the next load repairs the rest. + // TODO(persist-accounting-backfill-marker): this pass re-reads all + // history on every launch; a persisted completion marker needs a + // SwiftData shape change (a new live schema version) or a side + // channel, which is a product decision. if !inChangeset { do { let walletIds = Set(wallets.map(\.walletId)) @@ -6929,15 +6935,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } let txos = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) - try reconcileTransactionAccounting(transactions, txos: Dictionary(uniqueKeysWithValues: txos.map { ($0.outpoint, $0) })) + // One read instead of one per unlinked output. + let addresses = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) + try reconcileTransactionAccounting( + transactions, + txos: Dictionary(uniqueKeysWithValues: txos.map { ($0.outpoint, $0) }), + addresses: Dictionary(addresses.map { ($0.address, $0) }, uniquingKeysWith: { first, _ in first }) + ) try backgroundContext.save() } catch { + // Display-only accounting must never block restoring wallets: + // drop this pass's edits and restore on the stored values. backgroundContext.rollback() SDKLogger.event( - "persistence_wallet_load_failed", category: .persistence, severity: .error, - fields: ["phase": .publicText("transaction_accounting")], error: error + "persistence_transaction_accounting_failed", category: .persistence, severity: .error, + fields: ["phase": .publicText("load")], error: error ) - return (nil, 0, true) } } let restorable = wallets.filter { wallet in diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift index 688c0f7c844..26b690a985c 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -165,6 +165,18 @@ final class TransactionAccountingTests: XCTestCase { XCTAssertEqual(repaired?.netAmount, -100) } + func testShouldRestoreWalletsWhenLoadTimeAccountingFails() throws { + let container = try DashModelContainer.createInMemory() + container.mainContext.insert(PersistentWallet(walletId: Data(repeating: 1, count: 32), network: .testnet)) + try container.mainContext.save() + let injector = FetchFaultInjector(faulting: PersistentCoreAddress.self) + let handler = PlatformWalletPersistenceHandler( + modelContainer: container, network: .testnet, modelFetcher: injector + ) + XCTAssertFalse(handler.loadWalletList().errored, "display accounting must not block restore") + XCTAssertTrue(injector.observedReads.contains("PersistentCoreAddress")) + } + func testShouldPreserveAccountingWhenSomePrevoutsAreMissing() throws { let container = try DashModelContainer.createInMemory() let context = container.mainContext From b71f35a469c91ef2530bcb90ca6be5c99d5191cb Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:52:55 +0000 Subject: [PATCH 11/26] chore: address low-severity PR #5150 review findings - Transaction views treat an unresolved per-wallet amount as unavailable everywhere, so the fee no longer shows beside "Amount unavailable". - A failed accounting reconcile no longer fails the persistence round and is reported as persistence_transaction_accounting_failed, not save_failed. - Name the Swift transaction-kind and direction values instead of 1/3/6. - Rename the verdict test that never covered Uncredited and add one that does; V019's confirmed-spend marking is pinned by the V019 fixture test. - Drop the hand-written Unreleased section from the generated CHANGELOG. - Mark the divergent record-coalescing helpers with a TODO. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 -- .../src/sqlite/schema/core_state.rs | 56 ++++++++++++++++++- .../src/changeset/changeset.rs | 4 ++ .../Models/PersistentTransaction.swift | 23 ++++++-- .../PlatformWalletPersistenceHandler.swift | 17 +++++- .../Core/Views/TransactionDetailView.swift | 6 +- .../Core/Views/TransactionListView.swift | 6 +- 7 files changed, 99 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 198da619b6e..d4ae8423857 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,3 @@ -## Unreleased - -### Fixed - -- **platform-wallet-storage:** Restore confirmed Core spend and finality state on SQLite load so old funding transactions cannot make already-spent outputs selectable again. - ## [4.2.0-beta.4](https://github.com/dashpay/platform/compare/v4.2.0-beta.3...v4.2.0-beta.4) (2026-09-24) 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 be2acb222c5..54967106037 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 @@ -1707,7 +1707,7 @@ mod tests { } #[test] - fn should_keep_uncredited_outputs_spent() { + fn should_mark_observed_spent_and_doomed_outputs_spent() { use platform_wallet::changeset::changeset::UtxoCreditVerdict; let mut conn = Connection::open_in_memory().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); @@ -1745,6 +1745,60 @@ mod tests { } } + #[test] + fn should_keep_prior_spent_flag_for_uncredited_outputs() { + use platform_wallet::changeset::changeset::UtxoCreditVerdict; + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0xA8u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + let tx = conn.transaction().unwrap(); + let stored_spent = |outpoint: &OutPoint| -> Option { + tx.query_row( + "SELECT spent FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![&wallet_id[..], blob::encode_outpoint(outpoint).unwrap()], + |row| row.get(0), + ) + .optional() + .unwrap() + }; + let uncredited = |utxo: Utxo| CoreChangeSet { + utxo_credit_verdicts: [(utxo.outpoint, UtxoCreditVerdict::Uncredited)].into(), + new_utxos: vec![utxo], + ..Default::default() + }; + + let fresh = sample_utxo(Txid::from_byte_array([3; 32]), 100, true); + apply(&tx, &wallet_id, &uncredited(fresh.clone())).unwrap(); + assert_eq!( + stored_spent(&fresh.outpoint), + None, + "never materialize an uncredited output" + ); + + let known = sample_utxo(Txid::from_byte_array([4; 32]), 100, true); + apply( + &tx, + &wallet_id, + &CoreChangeSet { + new_utxos: vec![known.clone()], + utxo_credit_verdicts: [(known.outpoint, UtxoCreditVerdict::Doomed)].into(), + ..Default::default() + }, + ) + .unwrap(); + apply(&tx, &wallet_id, &uncredited(known.clone())).unwrap(); + assert_eq!( + stored_spent(&known.outpoint), + Some(true), + "an uncredited replay keeps the spend" + ); + } + #[test] fn should_exclude_historical_contact_outputs_from_accounting() { use key_wallet::managed_account::transaction_record::{OutputDetail, OutputRole}; diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 0c9a8988d75..f678c407003 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -626,6 +626,10 @@ fn coalesce_newest_wins( } /// Keep the last correction per account before folding account contributions. +// TODO(unify-record-coalescing): `coalesce_newest_wins` (the `Merge` path) +// can regress a confirmed context to an older mempool slice; this helper +// keeps the highest context rank. Unify them once `Merge` semantics are +// reviewed. pub(crate) fn coalesce_account_records(records: &mut Vec) { let mut positions = BTreeMap::new(); for mut record in std::mem::take(records) { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index 373123fcf0e..f79cbbebdb2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -257,12 +257,13 @@ public final class PersistentTransaction { public func direction(for walletId: Data) -> UInt32 { let wallets = Set((inputs + outputs).filter(PlatformWalletPersistenceHandler.isWalletOwnedTxo) .compactMap { PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) }) - guard wallets.count > 1, direction != 3, transactionTypeKind != 1, !isAssetLock else { return direction } + guard wallets.count > 1, direction != CoreDirectionCode.coinJoin, + typedKind != .coinJoin, !isAssetLock else { return direction } let spendsOurs = inputs.contains { PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) && PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) == walletId } - return spendsOurs ? 1 : 0 + return spendsOurs ? CoreDirectionCode.outgoing : CoreDirectionCode.incoming } /// Format the wallet's Core value movement in DASH. @@ -292,10 +293,11 @@ public final class PersistentTransaction { // internal only when nothing leaves the wallet and something stays in // it, or an asset lock burns into Platform. Both sides test one table. let direction: UInt32 - if previousDirection == 3 { direction = 3 } - else if inputs.isEmpty { direction = 0 } - else if allOutputsOwned && (!ownedOutputAmounts.isEmpty || isAssetLock) { direction = 2 } - else { direction = 1 } + if previousDirection == CoreDirectionCode.coinJoin { direction = CoreDirectionCode.coinJoin } + else if inputs.isEmpty { direction = CoreDirectionCode.incoming } + else if allOutputsOwned && (!ownedOutputAmounts.isEmpty || isAssetLock) { + direction = CoreDirectionCode.internalTransfer + } else { direction = CoreDirectionCode.outgoing } return (received - spent, direction) } @@ -427,6 +429,15 @@ public final class PersistentTransaction { /// "pre-feature / not-populated" sentinel and is NOT a case in this /// enum — `TransactionTypeKind(rawValue: 0xFF)` returns `nil`, which /// the accessors treat as "unknown" so no branch fires falsely. +/// Wire values of `PersistentTransaction.direction`, matching `directionName` +/// and the FFI's `TransactionDirection` discriminants. +enum CoreDirectionCode { + static let incoming: UInt32 = 0 + static let outgoing: UInt32 = 1 + static let internalTransfer: UInt32 = 2 + static let coinJoin: UInt32 = 3 +} + public enum TransactionTypeKind: UInt8 { case standard = 0 case coinJoin = 1 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 940fc664c30..2893e42c4a2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2548,7 +2548,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.blockHash = blockHashBytes.allSatisfy { $0 == 0 } ? nil : blockHashBytes // A context-only recovery record has zero accounting; a funded asset lock burns Core value. // A stored debit is itself the proof we funded it: its inputs may not be linked yet. - let preserveLockAccounting = tx.transaction_type_kind == 6 && tx.net_amount == 0 && !tx.has_fee + let preserveLockAccounting = tx.transaction_type_kind == TransactionTypeKind.assetLock.rawValue + && tx.net_amount == 0 && !tx.has_fee && record.netAmount < 0 if !preserveLockAccounting { record.direction = tx.direction } if let typeName = tx.transaction_type { @@ -3428,8 +3429,19 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if roundAdvancedFinalityBoundary { collectFinalizedSweptTombstones(walletId: walletId) } + // Display-only accounting: a failure here must not fail the + // round, and is reported under its own event, not save_failed. + // Recomputed values that did land are consistent on their own. do { try reconcileTransactionAccounting(Array(accountingDirty.values)) + } catch { + SDKLogger.event( + "persistence_transaction_accounting_failed", category: .persistence, severity: .error, + fields: ["phase": .publicText("round"), "wallet_reference": .reference(walletId)], + error: error + ) + } + do { try backgroundContext.save() committedRoundGeneration &+= 1 SDKLogger.event( @@ -6863,7 +6875,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } if let accounting = PersistentTransaction.reconciledAccounting( inputs: inputs, ownedOutputAmounts: amounts, allOutputsOwned: allOutputsOwned, - previousDirection: transaction.transactionTypeKind == 1 ? 3 : transaction.direction, + previousDirection: transaction.typedKind == .coinJoin + ? CoreDirectionCode.coinJoin : transaction.direction, isAssetLock: transaction.isAssetLock ) { transaction.netAmount = accounting.netAmount diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift index 37bd50aedad..c854fd5ce4d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift @@ -4,7 +4,9 @@ import SwiftDashSDK struct TransactionDetailView: View { let transaction: PersistentTransaction var walletId: Data? = nil - private var netAmount: Int64 { walletId.flatMap { transaction.netAmount(for: $0) } ?? transaction.netAmount } + /// `nil` while this wallet's amount is unresolved — the same state the + /// amount label shows as "Amount unavailable", so fee and amount agree. + private var netAmount: Int64? { walletId.map { transaction.netAmount(for: $0) } ?? transaction.netAmount } private var direction: UInt32 { walletId.map { transaction.direction(for: $0) } ?? transaction.direction } /// Asset-lock payload funding amount, excluding the Core transaction fee. var assetLockAmountDuffs: Int64? = nil @@ -201,7 +203,7 @@ struct TransactionDetailView: View { ) } - if let fee = formattedFee, netAmount < 0 { + if let fee = formattedFee, let amount = netAmount, amount < 0 { TransactionDetailRow( label: "Network Fee", value: fee diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift index 4e0033ff220..1f34b9467e8 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift @@ -222,7 +222,9 @@ struct TransactionListView: View { struct TransactionRowView: View { let transaction: PersistentTransaction var walletId: Data? = nil - private var netAmount: Int64 { walletId.flatMap { transaction.netAmount(for: $0) } ?? transaction.netAmount } + /// `nil` while this wallet's amount is unresolved — the same state the + /// amount label shows as "Amount unavailable", so fee and amount agree. + private var netAmount: Int64? { walletId.map { transaction.netAmount(for: $0) } ?? transaction.netAmount } private var direction: UInt32 { walletId.map { transaction.direction(for: $0) } ?? transaction.direction } /// Asset-lock payload funding amount, excluding the Core transaction fee. var assetLockAmountDuffs: Int64? = nil @@ -400,7 +402,7 @@ struct TransactionRowView: View { .font(.headline) .foregroundColor(typeColor) - if let fee = transaction.fee, netAmount < 0 { + if let fee = transaction.fee, let amount = netAmount, amount < 0 { Text("Fee: \(formatFee(fee))") .font(.caption2) .foregroundColor(.secondary) From 338067889e4c01651eb1742d5ba83e09955c1834 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:59:42 +0000 Subject: [PATCH 12/26] fix(platform-wallet-storage): fail on corrupt history unless a resync restores it Rework of 63a7f8e09d, which skipped undecodable records everywhere. Normal operation is strict again: the per-round repair and preserve_known_details treat a corrupt stored record as an error. V019 (which has no recovery mode) drops an undecodable record only when a Core resync re-delivers it: the row has a block height, so a filter rescan finds it again. It then deletes the row and its input-index rows and lowers that wallet's synced_height to just below its birth height; load hands that checkpoint to SPV, which rescans from birth and re-records the transaction. Spent marks and outputs it already produced are kept until the rescan confirms them. An unconfirmed corrupt record cannot be restored that way, so the migration still fails and rolls back. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/migrations/legacy_v019.rs | 161 ++++++++++-------- .../src/sqlite/schema/core_history.rs | 58 +------ .../src/sqlite/schema/core_state.rs | 113 +++++++++--- 3 files changed, 184 insertions(+), 148 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs index 9564fa8850f..e1bf7fcd5dc 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs @@ -21,40 +21,36 @@ use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::{blob, id32, wallets}; use crate::sqlite::util::safe_cast::i64_to_u64; -/// Read a stored record as repair evidence; an unreadable one is no evidence. -fn prior_record( +/// Read a stored record; undecodable bytes are an error the caller classifies. +fn read_record( tx: &Transaction<'_>, wallet_id: &WalletId, txid: &Txid, ) -> Result, WalletStorageError> { - let read = (|| { - let stored: Option)>> = tx - .query_row( - "SELECT length(record_blob), record_blob FROM core_transactions \ - WHERE wallet_id = ?1 AND txid = ?2", - params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], - |row| { - Ok(match row.get::<_, Option>(0)? { - Some(len) => Some((len, row.get(1)?)), - None => None, - }) - }, - ) - .optional()?; - let Some(Some((len, payload))) = stored else { - return Ok(None); - }; - blob::check_size(len)?; - let record: TransactionRecord = blob::decode(&payload)?; - Ok(Some(record).filter(|record| record.txid == *txid)) - })(); - match read { - Err(error) if is_unreadable(&error) => { - tracing::warn!(%txid, %error, "skipping unreadable transaction record in history migration"); - Ok(None) - } - result => result, + let stored: Option)>> = tx + .query_row( + "SELECT length(record_blob), record_blob FROM core_transactions \ + WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], + |row| { + Ok(match row.get::<_, Option>(0)? { + Some(len) => Some((len, row.get(1)?)), + None => None, + }) + }, + ) + .optional()?; + let Some(Some((len, payload))) = stored else { + return Ok(None); + }; + blob::check_size(len)?; + let record: TransactionRecord = blob::decode(&payload)?; + if record.txid != *txid { + return Err(WalletStorageError::blob_decode( + "transaction record names another transaction", + )); } + Ok(Some(record)) } /// Whether `error` reports stored bytes that cannot be decoded, not a database failure. @@ -69,25 +65,56 @@ fn is_unreadable(error: &WalletStorageError) -> bool { ) } -/// Run one record's repair atomically; unreadable stored data skips it instead of failing. -fn repair_best_effort( +/// Drop an undecodable record that a Core resync re-delivers, and force that resync. +/// +/// Only a block-confirmed record (typed `height` set) is re-delivered by a +/// filter rescan; an unconfirmed one may never be seen again, so it keeps the +/// migration failing rather than losing it. Lowering `synced_height` to just +/// below the wallet's birth height makes the next SPV start rescan the wallet +/// from its birth, which re-records the transaction and re-applies its spends. +/// The spent marks and outputs it already produced stay as they are: +/// conservative until the rescan confirms them. +fn drop_for_resync( tx: &Transaction<'_>, + wallet_id: &WalletId, txid: &Txid, - repair: impl FnOnce() -> Result<(), WalletStorageError>, + error: WalletStorageError, ) -> Result<(), WalletStorageError> { - tx.execute_batch("SAVEPOINT core_history_repair")?; - let result = repair(); - if result.is_err() { - tx.execute_batch("ROLLBACK TO core_history_repair")?; - } - tx.execute_batch("RELEASE core_history_repair")?; - match result { - Err(error) if is_unreadable(&error) => { - tracing::warn!(%txid, %error, "skipping history repair of unreadable stored data"); - Ok(()) - } - result => result, + let height: Option = tx.query_row( + "SELECT height FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], + |row| row.get(0), + )?; + if height.is_none() { + return Err(error); } + let birth_height: i64 = tx.query_row( + "SELECT birth_height FROM wallets WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| row.get(0), + )?; + let rescan_from = (birth_height - 1).max(0); + tx.execute( + "DELETE FROM core_transaction_inputs WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], + )?; + tx.execute( + "DELETE FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], + )?; + tx.execute( + "UPDATE core_sync_state SET synced_height = MIN(COALESCE(synced_height, ?2), ?2) \ + WHERE wallet_id = ?1", + params![wallet_id.as_slice(), rescan_from], + )?; + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + %txid, + %error, + rescan_from, + "dropped an undecodable confirmed transaction record; Core history rescans from birth" + ); + Ok(()) } /// Index raw inputs independently of when their ownership becomes known. @@ -160,12 +187,11 @@ fn contact_only_script( fn repair_record( tx: &Transaction<'_>, wallet_id: &WalletId, - txid: &Txid, + mut record: TransactionRecord, network: dashcore::Network, ) -> Result<(), WalletStorageError> { - let Some(mut record) = prior_record(tx, wallet_id, txid)? else { - return Ok(()); - }; + let record_txid = record.txid; + let txid = &record_txid; let original = blob::encode(&record)?; let mut inputs = BTreeMap::new(); for detail in record.input_details.drain(..) { @@ -305,37 +331,30 @@ fn repair_record( } pub(super) fn repair_history(tx: &Transaction<'_>) -> Result<(), WalletStorageError> { - // Keys are collected first: savepoint rollbacks must not race an open cursor. + // Keys are collected first so drops never race the open cursor. let mut keys = Vec::new(); { let mut stmt = tx.prepare_cached("SELECT length(wallet_id), wallet_id, length(txid), txid FROM core_transactions WHERE record_blob IS NOT NULL")?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { - let key = (|| { - blob::check_fixed_width(row.get(0)?, 32, "core_transactions.wallet_id")?; - let wallet_id: Vec = row.get(1)?; - let wallet_id = id32("core_transactions.wallet_id", &wallet_id)?; - blob::check_fixed_width(row.get(2)?, 32, "core_transactions.txid")?; - let txid: Vec = row.get(3)?; - Ok::<_, WalletStorageError>((wallet_id, Txid::from_slice(&txid)?)) - })(); - match key { - Ok(key) => keys.push(key), - Err(error) if is_unreadable(&error) => { - tracing::warn!(%error, "skipping unreadable transaction key in history migration"); - } - Err(error) => return Err(error), - } + blob::check_fixed_width(row.get(0)?, 32, "core_transactions.wallet_id")?; + let wallet_id: Vec = row.get(1)?; + let wallet_id = id32("core_transactions.wallet_id", &wallet_id)?; + blob::check_fixed_width(row.get(2)?, 32, "core_transactions.txid")?; + let txid: Vec = row.get(3)?; + keys.push((wallet_id, Txid::from_slice(&txid)?)); } } for (wallet_id, txid) in keys { - repair_best_effort(tx, &txid, || { - let Some(record) = prior_record(tx, &wallet_id, &txid)? else { - return Ok(()); - }; - index_record(tx, &wallet_id, &record)?; - repair_record(tx, &wallet_id, &txid, network(tx, &wallet_id)?) - })?; + match read_record(tx, &wallet_id, &txid) { + Ok(Some(record)) => { + index_record(tx, &wallet_id, &record)?; + repair_record(tx, &wallet_id, record, network(tx, &wallet_id)?)?; + } + Ok(None) => {} + Err(error) if is_unreadable(&error) => drop_for_resync(tx, &wallet_id, &txid, error)?, + Err(error) => return Err(error), + } } Ok(()) } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs index d7aeacba5ce..73567111f25 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs @@ -28,13 +28,9 @@ pub(super) fn preserve_known_details( return Ok(merged); }; if previous.transaction != incoming.transaction { - // A txid commits to its body, so the stored copy is corrupt; the - // incoming record replaces it rather than wedging every later write. - tracing::warn!( - txid = %incoming.txid, - "stored transaction body disagrees with its txid; replacing it" - ); - return Ok(merged); + return Err(WalletStorageError::blob_decode( + "same transaction id has different raw transaction bodies", + )); } let mut inputs: BTreeMap<_, _> = previous .input_details @@ -63,55 +59,13 @@ pub(super) fn preserve_known_details( Ok(merged) } -/// Read a stored record as repair evidence; an unreadable one is no evidence. -/// -/// History repair is best-effort accounting and must never block opening the -/// database or storing new state, so undecodable or drifted rows are skipped. +/// Read a stored record strictly: corrupt history is an error, never skipped. fn prior_record( conn: &Connection, wallet_id: &WalletId, txid: &Txid, ) -> Result, WalletStorageError> { - match core_state::get_tx_record(conn, wallet_id, txid, &LoadCtx::recovery()) { - Err(error) if is_unreadable(&error) => { - tracing::warn!(%txid, %error, "skipping unreadable transaction record in history repair"); - Ok(None) - } - result => result, - } -} - -/// Whether `error` reports stored bytes that cannot be decoded, not a database failure. -fn is_unreadable(error: &WalletStorageError) -> bool { - matches!( - error, - WalletStorageError::BincodeDecode { .. } - | WalletStorageError::BlobDecode { .. } - | WalletStorageError::BlobTooLarge { .. } - | WalletStorageError::HashDecode { .. } - | WalletStorageError::IntegerOverflow { .. } - ) -} - -/// Run one record's repair atomically; unreadable stored data skips it instead of failing. -fn repair_best_effort( - tx: &Transaction<'_>, - txid: &Txid, - repair: impl FnOnce() -> Result<(), WalletStorageError>, -) -> Result<(), WalletStorageError> { - tx.execute_batch("SAVEPOINT core_history_repair")?; - let result = repair(); - if result.is_err() { - tx.execute_batch("ROLLBACK TO core_history_repair")?; - } - tx.execute_batch("RELEASE core_history_repair")?; - match result { - Err(error) if is_unreadable(&error) => { - tracing::warn!(%txid, %error, "skipping history repair of unreadable stored data"); - Ok(()) - } - result => result, - } + core_state::get_tx_record(conn, wallet_id, txid, &LoadCtx::strict()) } /// Index raw inputs independently of when their ownership becomes known. @@ -159,7 +113,7 @@ pub(super) fn apply( } let network = network(tx, wallet_id)?; for txid in affected { - repair_best_effort(tx, &txid, || repair_record(tx, wallet_id, &txid, network))?; + repair_record(tx, wallet_id, &txid, network)?; } Ok(()) } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 54967106037..b7af25af2c2 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 @@ -1642,20 +1642,45 @@ mod tests { ); } - #[test] - fn should_skip_corrupt_record_during_history_migration() { + /// A V018 database whose wallet 0xAD has one corrupt record at `height`. + fn v018_with_corrupt_record(height: Option) -> (Connection, [u8; 32]) { let mut conn = Connection::open_in_memory().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); conn.execute_batch("DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; DELETE FROM refinery_schema_history WHERE version >= 19;").unwrap(); let wallet_id = [0xADu8; 32]; conn.execute( - "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 100)", params![&wallet_id[..]], ) .unwrap(); - conn.execute("INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) VALUES (?1, ?2, 0, ?3)", params![&wallet_id[..], &[0u8;32][..], &[0xffu8][..]]).unwrap(); - crate::sqlite::migrations::run(&mut conn) - .expect("one unreadable record must not block opening the database"); + conn.execute( + "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) VALUES (?1, 500, 500)", + params![&wallet_id[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, height, finalized, record_blob) VALUES (?1, ?2, ?3, 0, ?4)", + params![&wallet_id[..], &[0u8; 32][..], height, &[0xffu8][..]], + ) + .unwrap(); + (conn, wallet_id) + } + + #[test] + fn should_fail_history_migration_on_corrupt_unconfirmed_record() { + let (mut conn, _) = v018_with_corrupt_record(None); + assert!( + crate::sqlite::migrations::run(&mut conn).is_err(), + "a resync cannot restore an unconfirmed record, so it must not be dropped" + ); + let tables: i64 = conn + .query_row( + "SELECT count(*) FROM sqlite_master WHERE name = 'core_transaction_inputs'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(tables, 0); let version: i64 = conn .query_row( "SELECT max(version) FROM refinery_schema_history", @@ -1663,19 +1688,58 @@ mod tests { |r| r.get(0), ) .unwrap(); - assert!(version >= 19); - let stored: Vec = conn + assert_eq!(version, 18); + } + + #[test] + fn should_drop_corrupt_confirmed_record_and_rescan_its_wallet() { + let (mut conn, wallet_id) = v018_with_corrupt_record(Some(150)); + let kept = transaction_record( + Txid::from_byte_array([0x11; 32]), + TransactionContext::InBlock(BlockInfo::new(160, BlockHash::all_zeros(), 1)), + ); + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, height, finalized, record_blob) VALUES (?1, ?2, 160, 1, ?3)", + params![&wallet_id[..], AsRef::<[u8]>::as_ref(&kept.txid), blob::encode(&kept).unwrap()], + ) + .unwrap(); + crate::sqlite::migrations::run(&mut conn) + .expect("a re-deliverable corrupt record must not block opening"); + let rows: Vec> = conn + .prepare_cached("SELECT txid FROM core_transactions WHERE wallet_id = ?1") + .unwrap() + .query_map(params![&wallet_id[..]], |r| r.get(0)) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(rows, vec![AsRef::<[u8]>::as_ref(&kept.txid).to_vec()]); + let (last_processed, synced): (i64, i64) = conn .query_row( - "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1", + "SELECT last_processed_height, synced_height FROM core_sync_state WHERE wallet_id = ?1", params![&wallet_id[..]], - |r| r.get(0), + |r| Ok((r.get(0)?, r.get(1)?)), ) .unwrap(); - assert_eq!(stored, vec![0xff], "the unreadable row is left untouched"); + assert_eq!( + synced, 99, + "the filter checkpoint rewinds to just below birth" + ); + assert_eq!( + last_processed, 500, + "the processed watermark stays monotonic" + ); + let (cs, _) = load_state( + &conn, + &wallet_id, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + assert_eq!(cs.synced_height, Some(99), "load hands the rewind to SPV"); } #[test] - fn should_heal_corrupt_record_when_the_transaction_is_stored_again() { + fn should_reject_corrupt_prior_record_when_storing_the_transaction() { let mut conn = Connection::open_in_memory().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); let wallet_id = [0xA9u8; 32]; @@ -1691,19 +1755,18 @@ mod tests { ) .unwrap(); let tx = conn.transaction().unwrap(); - apply( - &tx, - &wallet_id, - &CoreChangeSet { - records: vec![record.clone()], - ..Default::default() - }, - ) - .expect("a corrupt stored copy must not wedge later writes"); - let healed = get_tx_record(&tx, &wallet_id, &record.txid, &LoadCtx::strict()) - .unwrap() - .unwrap(); - assert_eq!(healed.txid, record.txid); + assert!( + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![record], + ..Default::default() + }, + ) + .is_err(), + "normal operation treats corrupt stored history as an error" + ); } #[test] From c44f9fe6de9c9c8b533d00e7c70307671bdbe941 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:46:43 +0000 Subject: [PATCH 13/26] refactor(platform-wallet-storage): top-level imports, split SQL, shared contact label - Import core_history, ManagedCoreFundsAccount and restore_recorded_transactions at the top instead of inline super::/fully qualified paths (coding conventions: imports at the top). - Drop the function-local UtxoCreditVerdict imports the test module already receives through `use super::*`. - Split over-long SQL literals with `\` continuations so rustfmt formats the enclosing calls again. - Bind the contact watch-only label from a DASHPAY_EXTERNAL_LABEL const shared with account_type_db_label instead of a SQL string literal, so a label change cannot silently stop contact exclusion. The frozen legacy_v019 migration keeps its literal. No behavior change. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/persister.rs | 7 +- .../src/sqlite/rehydrate.rs | 11 ++- .../src/sqlite/schema/accounts.rs | 6 +- .../src/sqlite/schema/core_history.rs | 17 +++-- .../src/sqlite/schema/core_state.rs | 67 +++++++++++++------ 5 files changed, 72 insertions(+), 36 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index d7aa54e8134..d19df704ad3 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -21,6 +21,7 @@ use crate::sqlite::error::{AutoBackupOperation, WalletStorageError}; use crate::sqlite::load_ctx::{LoadCtx, LoadDegradation, LoadSite}; use crate::sqlite::rehydrate::{ apply_persisted_core_state, build_wallet, restore_provider_platform_node_pool, + restore_recorded_transactions, }; use crate::sqlite::reports::{CommitReport, DeleteWalletReport}; use crate::sqlite::schema; @@ -1850,11 +1851,7 @@ fn load_one_wallet( })?; } let mut wallet = wallet; - super::rehydrate::restore_recorded_transactions( - &mut wallet_info, - &mut wallet, - core_state.records, - ); + restore_recorded_transactions(&mut wallet_info, &mut wallet, core_state.records); Ok(platform_wallet::changeset::ClientWalletStartState { wallet, wallet_info, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index b2f578c74ef..5517ab520a2 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -11,6 +11,7 @@ use key_wallet::account::account_collection::AccountCollection; use key_wallet::account::{Account, AccountType}; use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType}; use key_wallet::managed_account::transaction_record::TransactionRecord; +use key_wallet::managed_account::ManagedCoreFundsAccount; use key_wallet::transaction_checking::WalletTransactionChecker; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; @@ -557,9 +558,7 @@ fn replay_order(records: Vec) -> Vec { } /// Account identity of a funds account, stable across replay mutations. -fn funds_account_type( - account: &key_wallet::managed_account::ManagedCoreFundsAccount, -) -> AccountType { +fn funds_account_type(account: &ManagedCoreFundsAccount) -> AccountType { use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; account.managed_account_type().to_account_type() } @@ -599,9 +598,7 @@ fn route_to_funds_account( /// to pick one account among funding accounts that share a numeric index /// (Standard BIP44/BIP32 and CoinJoin can all sit at index 0; DashPay accounts /// all carry index 0 and differ only by the identity pair). -fn owning_account_of( - account: &key_wallet::managed_account::ManagedCoreFundsAccount, -) -> OwningAccount { +fn owning_account_of(account: &ManagedCoreFundsAccount) -> OwningAccount { use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; let at = account.managed_account_type().to_account_type(); let (user_identity_id, friend_identity_id) = accounts::account_dashpay_ids(&at); @@ -677,7 +674,7 @@ const MAX_NORMAL_CHILD_INDEX: u32 = (1u32 << 31) - 1; /// /// Never touches key material — the xpub is the keyless account public key. fn extend_pools_for_restored_addresses( - account: &mut key_wallet::managed_account::ManagedCoreFundsAccount, + account: &mut ManagedCoreFundsAccount, manifest: &[AccountRegistrationEntry], restored_addresses: &[key_wallet::Address], wallet_id: [u8; 32], diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs index 7213558f3b4..d7ab594edcd 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs @@ -653,6 +653,10 @@ pub(crate) const ACCOUNT_TYPE_LABELS: &[&str] = &[ "platform_payment", ]; +/// Database label of `AccountType::DashpayExternalAccount`; SQL that +/// singles out contact watch-only rows binds this instead of a literal. +pub(crate) const DASHPAY_EXTERNAL_LABEL: &str = "dashpay_external"; + /// Stable database label for an `AccountType` variant (the `Debug` impl is not /// a stable format; this match is the contract). An added upstream variant /// fails this match's exhaustiveness check at compile time. @@ -715,7 +719,7 @@ pub(crate) fn account_type_db_label(at: &key_wallet::account::AccountType) -> &' AccountType::ProviderOperatorKeys => "provider_operator", AccountType::ProviderPlatformKeys => "provider_platform", AccountType::DashpayReceivingFunds { .. } => "dashpay_receiving", - AccountType::DashpayExternalAccount { .. } => "dashpay_external", + AccountType::DashpayExternalAccount { .. } => DASHPAY_EXTERNAL_LABEL, AccountType::PlatformPayment { .. } => "platform_payment", } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs index 73567111f25..184dd0759df 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs @@ -12,6 +12,7 @@ use platform_wallet::changeset::CoreChangeSet; use platform_wallet::wallet::platform_wallet::WalletId; use rusqlite::{params, Connection, Transaction}; +use super::accounts::DASHPAY_EXTERNAL_LABEL; use super::{blob, core_state, wallets}; use crate::sqlite::error::WalletStorageError; use crate::sqlite::load_ctx::LoadCtx; @@ -75,7 +76,8 @@ pub(super) fn index_record( record: &TransactionRecord, ) -> Result<(), WalletStorageError> { let mut stmt = tx.prepare_cached( - "INSERT OR IGNORE INTO core_transaction_inputs (wallet_id, txid, outpoint) VALUES (?1, ?2, ?3)", + "INSERT OR IGNORE INTO core_transaction_inputs (wallet_id, txid, outpoint) \ + VALUES (?1, ?2, ?3)", )?; for input in &record.transaction.input { stmt.execute(params![ @@ -137,7 +139,10 @@ fn owned_output( outpoint: &OutPoint, network: dashcore::Network, ) -> Result, WalletStorageError> { - let mut stmt = tx.prepare_cached("SELECT value, length(script), script FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0")?; + let mut stmt = tx.prepare_cached( + "SELECT value, length(script), script FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0", + )?; let mut rows = stmt.query(params![ wallet_id.as_slice(), blob::encode_outpoint(outpoint)? @@ -162,8 +167,12 @@ pub(crate) fn contact_only_script( script: &[u8], ) -> Result { Ok(conn.query_row( - "SELECT EXISTS(SELECT 1 FROM core_address_pool WHERE wallet_id = ?1 AND script = ?2) AND NOT EXISTS(SELECT 1 FROM core_address_pool WHERE wallet_id = ?1 AND script = ?2 AND account_type != 'dashpay_external')", - params![wallet_id.as_slice(), script], |r| r.get(0))?) + "SELECT EXISTS(SELECT 1 FROM core_address_pool WHERE wallet_id = ?1 AND script = ?2) \ + AND NOT EXISTS(SELECT 1 FROM core_address_pool \ + WHERE wallet_id = ?1 AND script = ?2 AND account_type != ?3)", + params![wallet_id.as_slice(), script, DASHPAY_EXTERNAL_LABEL], + |r| r.get(0), + )?) } fn repair_record( 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 b7af25af2c2..47a2eab92f3 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 @@ -18,6 +18,7 @@ use crate::sqlite::error::WalletStorageError; use crate::sqlite::load_ctx::{LoadCtx, LoadSite}; use crate::sqlite::schema::blob; use crate::sqlite::schema::blob::impl_persistable_blob; +use crate::sqlite::schema::core_history; use crate::sqlite::schema::core_pool::{owning_account_for_script, OwningAccount}; // PUBLIC material only: core-chain state reaching `record_blob` / @@ -94,7 +95,7 @@ pub fn apply( record_blob = excluded.record_blob", )?; for incoming in &cs.records { - let record = super::core_history::preserve_known_details(tx, wallet_id, incoming)?; + let record = core_history::preserve_known_details(tx, wallet_id, incoming)?; let block_info = record.block_info(); let height = block_info.map(|b| i64::from(b.height())); let block_hash = block_info.map(|b| AsRef::<[u8]>::as_ref(&b.block_hash()).to_vec()); @@ -110,7 +111,7 @@ pub fn apply( finalized, payload, ])?; - super::core_history::index_record(tx, wallet_id, &record)?; + core_history::index_record(tx, wallet_id, &record)?; } } // `addresses_derived` is intentionally NOT persisted here — the pool @@ -247,7 +248,7 @@ pub fn apply( if heights_advanced { collect_finalized_tombstones(tx, wallet_id)?; } - super::core_history::apply(tx, wallet_id, cs)?; + core_history::apply(tx, wallet_id, cs)?; return Ok(()); } @@ -411,7 +412,7 @@ pub fn apply( if heights_advanced { collect_finalized_tombstones(tx, wallet_id)?; } - super::core_history::apply(tx, wallet_id, cs)?; + core_history::apply(tx, wallet_id, cs)?; Ok(()) } @@ -1009,7 +1010,7 @@ pub fn load_state( let script = dashcore::ScriptBuf::from_bytes(script_bytes); // A contact's watch-only output is never ours to spend, whatever // an older build recorded; the fallback would make it spendable. - if super::core_history::contact_only_script(conn, wallet_id, script.as_bytes())? { + if core_history::contact_only_script(conn, wallet_id, script.as_bytes())? { continue; } if let Some(owner) = owning_account_for_script(conn, wallet_id, script.as_bytes())? { @@ -1597,7 +1598,12 @@ mod tests { ], ) .unwrap(); - tx.execute_batch("DROP TABLE IF EXISTS core_transaction_inputs; DROP TABLE IF EXISTS core_transaction_record_originals; DELETE FROM refinery_schema_history WHERE version >= 19;").unwrap(); + tx.execute_batch( + "DROP TABLE IF EXISTS core_transaction_inputs; \ + DROP TABLE IF EXISTS core_transaction_record_originals; \ + DELETE FROM refinery_schema_history WHERE version >= 19;", + ) + .unwrap(); tx.commit().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); let repaired = get_tx_record(&conn, &wallet_id, &spending.txid, &LoadCtx::strict()) @@ -1646,7 +1652,11 @@ mod tests { fn v018_with_corrupt_record(height: Option) -> (Connection, [u8; 32]) { let mut conn = Connection::open_in_memory().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); - conn.execute_batch("DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; DELETE FROM refinery_schema_history WHERE version >= 19;").unwrap(); + conn.execute_batch( + "DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; \ + DELETE FROM refinery_schema_history WHERE version >= 19;", + ) + .unwrap(); let wallet_id = [0xADu8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 100)", @@ -1654,12 +1664,14 @@ mod tests { ) .unwrap(); conn.execute( - "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) VALUES (?1, 500, 500)", + "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) \ + VALUES (?1, 500, 500)", params![&wallet_id[..]], ) .unwrap(); conn.execute( - "INSERT INTO core_transactions (wallet_id, txid, height, finalized, record_blob) VALUES (?1, ?2, ?3, 0, ?4)", + "INSERT INTO core_transactions (wallet_id, txid, height, finalized, record_blob) \ + VALUES (?1, ?2, ?3, 0, ?4)", params![&wallet_id[..], &[0u8; 32][..], height, &[0xffu8][..]], ) .unwrap(); @@ -1699,8 +1711,13 @@ mod tests { TransactionContext::InBlock(BlockInfo::new(160, BlockHash::all_zeros(), 1)), ); conn.execute( - "INSERT INTO core_transactions (wallet_id, txid, height, finalized, record_blob) VALUES (?1, ?2, 160, 1, ?3)", - params![&wallet_id[..], AsRef::<[u8]>::as_ref(&kept.txid), blob::encode(&kept).unwrap()], + "INSERT INTO core_transactions (wallet_id, txid, height, finalized, record_blob) \ + VALUES (?1, ?2, 160, 1, ?3)", + params![ + &wallet_id[..], + AsRef::<[u8]>::as_ref(&kept.txid), + blob::encode(&kept).unwrap() + ], ) .unwrap(); crate::sqlite::migrations::run(&mut conn) @@ -1715,7 +1732,8 @@ mod tests { assert_eq!(rows, vec![AsRef::<[u8]>::as_ref(&kept.txid).to_vec()]); let (last_processed, synced): (i64, i64) = conn .query_row( - "SELECT last_processed_height, synced_height FROM core_sync_state WHERE wallet_id = ?1", + "SELECT last_processed_height, synced_height FROM core_sync_state \ + WHERE wallet_id = ?1", params![&wallet_id[..]], |r| Ok((r.get(0)?, r.get(1)?)), ) @@ -1750,8 +1768,13 @@ mod tests { .unwrap(); let record = transaction_record(Txid::all_zeros(), TransactionContext::Mempool); conn.execute( - "INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) VALUES (?1, ?2, 0, ?3)", - params![&wallet_id[..], AsRef::<[u8]>::as_ref(&record.txid), &[0xffu8][..]], + "INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) \ + VALUES (?1, ?2, 0, ?3)", + params![ + &wallet_id[..], + AsRef::<[u8]>::as_ref(&record.txid), + &[0xffu8][..] + ], ) .unwrap(); let tx = conn.transaction().unwrap(); @@ -1771,7 +1794,6 @@ mod tests { #[test] fn should_mark_observed_spent_and_doomed_outputs_spent() { - use platform_wallet::changeset::changeset::UtxoCreditVerdict; let mut conn = Connection::open_in_memory().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); let wallet_id = [0xAEu8; 32]; @@ -1810,7 +1832,6 @@ mod tests { #[test] fn should_keep_prior_spent_flag_for_uncredited_outputs() { - use platform_wallet::changeset::changeset::UtxoCreditVerdict; let mut conn = Connection::open_in_memory().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); let wallet_id = [0xA8u8; 32]; @@ -1885,7 +1906,13 @@ mod tests { }]; record.net_amount = output.value() as i64; output.outpoint.txid = record.txid; - conn.execute("INSERT INTO core_address_pool (wallet_id, account_type, account_index, pool_type, address_index, script) VALUES (?1, 'dashpay_external', 0, 0, 0, ?2)", params![&wallet_id[..], output.txout.script_pubkey.as_bytes()]).unwrap(); + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, pool_type, address_index, script) \ + VALUES (?1, 'dashpay_external', 0, 0, 0, ?2)", + params![&wallet_id[..], output.txout.script_pubkey.as_bytes()], + ) + .unwrap(); let tx = conn.transaction().unwrap(); apply( &tx, @@ -1965,13 +1992,15 @@ mod tests { let wallet_id = [0xB2u8; 32]; let (contact, own) = stage_contact_only_utxo(&conn, &wallet_id); conn.execute_batch( - "DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; DELETE FROM refinery_schema_history WHERE version >= 19;", + "DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; \ + DELETE FROM refinery_schema_history WHERE version >= 19;", ) .unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); let unspent_rows = |outpoint: &OutPoint| -> i64 { conn.query_row( - "SELECT count(*) FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2 AND spent = 0", + "SELECT count(*) FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2 AND spent = 0", params![&wallet_id[..], blob::encode_outpoint(outpoint).unwrap()], |r| r.get(0), ) From 62e52ff03bcf5f98a329d19d3b6e67f3beefe420 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:46:50 +0000 Subject: [PATCH 14/26] test(platform-wallet-storage): pin contact-only replay and same-height replay order - A script the store tracks only on a contact's watch-only chain, but that a rebuilt funds account still derives, is credited by load replay; assert that copy does not survive load (ties the load-time contact filter to the replay retain). - Same-height records without a block position replay in stored order; assert a stale unspent projection ends with the spent output excluded whichever of funding and spend replays first. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/rehydrate.rs | 113 ++++++++++++++++++ .../tests/sqlite_spent_rehydration.rs | 108 +++++++++++++++++ 2 files changed, 221 insertions(+) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index 5517ab520a2..23872199a7d 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -3362,4 +3362,117 @@ mod tests { ); } } + + /// Same-height records without a block position replay in stored order; + /// an output the load projection still parks as unspent must end up + /// excluded whichever of funding and spend replays first. + #[tokio::test] + async fn should_exclude_spent_output_for_either_same_height_replay_order() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn, TxOut, Txid}; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + use key_wallet::Utxo; + + for spend_first in [false, true] { + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default) + .unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let address = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([15; 32]), 0), + ..Default::default() + }], + output: [100_000, 20_000] + .map(|value| TxOut { + value, + script_pubkey: address.script_pubkey(), + }) + .to_vec(), + special_transaction_payload: None, + }; + let (spent, available) = ( + OutPoint::new(funding.txid(), 0), + OutPoint::new(funding.txid(), 1), + ); + let spending = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: spent, + ..Default::default() + }], + output: vec![TxOut { + value: 99_000, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let context = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([7; 32]), + 100, + )); + let mut records = info + .check_core_transaction(&funding, context.clone(), &mut wallet, true, true) + .await + .new_records; + records.extend( + info.check_core_transaction(&spending, context, &mut wallet, true, true) + .await + .new_records, + ); + assert_eq!(records.len(), 2); + assert!(records + .iter() + .all(|r| r.block_info().is_some_and(|b| b.position().is_none()))); + if spend_first { + records.reverse(); + } + + // A stale projection that still parks the spent output as unspent. + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + let account = restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap(); + for outpoint in [spent, available] { + account.utxos.insert( + outpoint, + Utxo { + outpoint, + txout: funding.output[outpoint.vout as usize].clone(), + address: address.clone(), + height: 100, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }, + ); + } + restore_recorded_transactions(&mut restored, &mut wallet, records); + + let coins = &restored.accounts.standard_bip44_accounts[&0].utxos; + assert!(!coins.contains_key(&spent), "spend_first={spend_first}"); + assert!(coins.contains_key(&available), "spend_first={spend_first}"); + assert_eq!( + restored.balance.total(), + 20_000, + "spend_first={spend_first}" + ); + } + } } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs index 3beff7ba942..e296482379c 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs @@ -492,3 +492,111 @@ async fn should_keep_replayed_output_only_in_its_owning_account() { .unwrap(); assert_eq!(stored, 3, "load must not delete stored rows"); } + +#[tokio::test] +async fn should_drop_replay_credit_for_contact_only_script() { + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let [contact, ours]: [Address; 2] = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_receive_addresses(Some(&xpub), 2, true) + .unwrap() + .try_into() + .unwrap(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([16; 32]), 0), + ..Default::default() + }], + output: vec![ + TxOut { + value: 40_000, + script_pubkey: contact.script_pubkey(), + }, + TxOut { + value: 7_000, + script_pubkey: ours.script_pubkey(), + }, + ], + special_transaction_payload: None, + }; + let contact_coin = OutPoint::new(funding.txid(), 0); + let our_coin = OutPoint::new(funding.txid(), 1); + let result = info + .check_core_transaction(&funding, block(100), &mut wallet, true, true) + .await; + let coins: Vec<_> = info.accounts.standard_bip44_accounts[&0] + .utxos + .values() + .cloned() + .collect(); + assert_eq!(coins.len(), 2); + let (persister, _dir, _) = common::fresh_persister(); + persister + .store( + wallet.wallet_id, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 0, + }), + account_registrations: wallet + .accounts + .all_accounts() + .into_iter() + .map(|account| AccountRegistrationEntry { + account_type: account.account_type, + account_xpub: account.account_xpub, + }) + .collect(), + core: Some(CoreChangeSet { + records: result.new_records, + new_utxos: coins, + last_processed_height: Some(300), + synced_height: Some(300), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + { + // The store tracks the script only on a contact's watch-only chain, + // yet the rebuilt funds account still derives it, so replay credits it. + let conn = persister.lock_conn_for_test(); + conn.execute( + "DELETE FROM core_address_pool WHERE script = ?1", + [contact.script_pubkey().as_bytes()], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, pool_type, address_index, script) \ + VALUES (?1, 'dashpay_external', 0, 0, 0, ?2)", + rusqlite::params![&wallet.wallet_id[..], contact.script_pubkey().as_bytes()], + ) + .unwrap(); + } + + let mut state = persister.load().unwrap(); + let info = state.wallets.remove(&wallet.wallet_id).unwrap().wallet_info; + assert!( + info.accounts + .all_funding_accounts() + .into_iter() + .all(|account| !account.utxos.contains_key(&contact_coin)), + "a replay-credited contact coin must not survive load" + ); + assert!(info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&our_coin)); + assert_eq!(info.balance.total(), 7_000); +} From 96e29df41f037cf3cb0678588ee9ae1490a10189 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:49:19 +0000 Subject: [PATCH 15/26] fix(swift-sdk): do not trap on a duplicate outpoint in the load-time TXO map loadWalletList built its outpoint map with Dictionary(uniqueKeysWithValues:), which traps on a duplicate key and would crash every launch before the non-fatal accounting error path. Build it with uniquingKeysWith, keeping a live row over a deleted one. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWallet/PlatformWalletPersistenceHandler.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 2893e42c4a2..51f55f49e53 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -6952,7 +6952,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let addresses = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) try reconcileTransactionAccounting( transactions, - txos: Dictionary(uniqueKeysWithValues: txos.map { ($0.outpoint, $0) }), + // Never trap on a duplicate outpoint; a live row wins over a deleted one. + txos: Dictionary(txos.map { ($0.outpoint, $0) }, uniquingKeysWith: { kept, other in + kept.isDeleted ? other : kept + }), addresses: Dictionary(addresses.map { ($0.address, $0) }, uniquingKeysWith: { first, _ in first }) ) try backgroundContext.save() From 3ff073f586b97189953d9d7e4a754527f57b1ce9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:49:28 +0000 Subject: [PATCH 16/26] refactor(swift-sdk): share accounting accessors, direction codes and styling - PersistentTransaction: build the owning-wallet set once (owningWalletIds), format both amount variants through one format(duffs:) (magnitude, so Int64.min no longer traps), and add displayNetAmount/displayDirectionCode/displayFormattedAmount(for:) so views stop hand-rolling the wallet-scope fallback. - Make CoreDirectionCode public and use it instead of raw 0/1/2/3 in directionName and the example-app views; move it off TransactionTypeKind's doc comment, which it had split from its enum. - Name the OP_RETURN check (TransactionDecoder.Output.isOpReturn). - Example app: one TransactionDirectionStyle mapping for the list row and the detail view, so an internal transfer is red with the circular-arrows icon and CoinJoin is blue with the shuffle icon in both (the detail view had drifted to blue for internal transfers). - Mark the missing round-end accounting failure-path test as a TODO. Swift was not compiled or tested in this environment. Co-Authored-By: Claude Opus 5.5 --- .../KeyWallet/TransactionDecoder.swift | 3 + .../Models/PersistentTransaction.swift | 65 ++++++++++++------- .../PlatformWalletPersistenceHandler.swift | 4 +- .../Core/Views/TransactionDetailView.swift | 30 +++------ .../Core/Views/TransactionListView.swift | 47 +++++++++----- .../TransactionAccountingTests.swift | 6 ++ 6 files changed, 93 insertions(+), 62 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift index 380deeeeff7..7224d3dc282 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift @@ -39,6 +39,9 @@ public struct DecodedTransaction: Sendable, Equatable { public let valueDuffs: UInt64 /// Raw scriptPubKey bytes. public let scriptPubkey: Data + + /// `true` for an `OP_RETURN` (0x6a) data-carrier / burn output. + var isOpReturn: Bool { scriptPubkey.first == 0x6a } } /// Transaction id in consensus (internal) byte order — reverse for diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index f79cbbebdb2..e1d43179cc1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -238,8 +238,7 @@ public final class PersistentTransaction { && seen.insert($0.outpoint).inserted } } - let wallets = Set((inputs + outputs).filter(PlatformWalletPersistenceHandler.isWalletOwnedTxo) - .compactMap { PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) }) + let wallets = owningWalletIds let hasUnownedTxos = (inputs + outputs).contains { !PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) } // Unresolved inputs make any amount provisional, the stored one included. guard pendingInputs.isEmpty else { return nil } @@ -255,9 +254,7 @@ public final class PersistentTransaction { /// Direction relative to one wallet for transactions shared by multiple local wallets. public func direction(for walletId: Data) -> UInt32 { - let wallets = Set((inputs + outputs).filter(PlatformWalletPersistenceHandler.isWalletOwnedTxo) - .compactMap { PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) }) - guard wallets.count > 1, direction != CoreDirectionCode.coinJoin, + guard owningWalletIds.count > 1, direction != CoreDirectionCode.coinJoin, typedKind != .coinJoin, !isAssetLock else { return direction } let spendsOurs = inputs.contains { PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) @@ -269,7 +266,33 @@ public final class PersistentTransaction { /// Format the wallet's Core value movement in DASH. public func formattedAmount(for walletId: Data) -> String { guard let amount = netAmount(for: walletId) else { return "Amount unavailable" } - return String(format: "%@%.8f DASH", amount >= 0 ? "+" : "-", Double(amount.magnitude) / 100_000_000) + return Self.format(duffs: amount) + } + + /// Net amount for `walletId`, or the stored scalar when no wallet scope is given. + public func displayNetAmount(for walletId: Data?) -> Int64? { + walletId.map { netAmount(for: $0) } ?? netAmount + } + + /// `CoreDirectionCode` for `walletId`, or the stored direction when no wallet scope is given. + public func displayDirectionCode(for walletId: Data?) -> UInt32 { + walletId.map { direction(for: $0) } ?? direction + } + + /// Formatted amount for `walletId`, or the stored scalar's when no wallet scope is given. + public func displayFormattedAmount(for walletId: Data?) -> String { + walletId.map { formattedAmount(for: $0) } ?? formattedAmount + } + + /// Signed DASH text for a duff amount; `magnitude` cannot trap on `Int64.min`. + static func format(duffs: Int64) -> String { + String(format: "%@%.8f DASH", duffs >= 0 ? "+" : "-", Double(duffs.magnitude) / 100_000_000) + } + + /// Local wallets owning at least one of this transaction's TXOs. + private var owningWalletIds: Set { + Set((inputs + outputs).filter(PlatformWalletPersistenceHandler.isWalletOwnedTxo) + .compactMap { PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) }) } static func reconciledAccounting( @@ -303,10 +326,10 @@ public final class PersistentTransaction { public var directionName: String { switch direction { - case 0: return "Incoming" - case 1: return "Outgoing" - case 2: return "Internal" - case 3: return "CoinJoin" + case CoreDirectionCode.incoming: return "Incoming" + case CoreDirectionCode.outgoing: return "Outgoing" + case CoreDirectionCode.internalTransfer: return "Internal" + case CoreDirectionCode.coinJoin: return "CoinJoin" default: return "Unknown" } } @@ -410,12 +433,19 @@ public final class PersistentTransaction { } public var formattedAmount: String { - let dash = Double(abs(netAmount)) / 100_000_000.0 - let sign = netAmount >= 0 ? "+" : "-" - return String(format: "%@%.8f DASH", sign, dash) + Self.format(duffs: netAmount) } } +/// Wire values of `PersistentTransaction.direction`, matching `directionName` +/// and the FFI's `TransactionDirection` discriminants. +public enum CoreDirectionCode { + public static let incoming: UInt32 = 0 + public static let outgoing: UInt32 = 1 + public static let internalTransfer: UInt32 = 2 + public static let coinJoin: UInt32 = 3 +} + /// Typed mirror of Rust's /// `key_wallet::transaction_checking::transaction_router::TransactionType`, /// pinned to the `u8` discriminants emitted by @@ -429,15 +459,6 @@ public final class PersistentTransaction { /// "pre-feature / not-populated" sentinel and is NOT a case in this /// enum — `TransactionTypeKind(rawValue: 0xFF)` returns `nil`, which /// the accessors treat as "unknown" so no branch fires falsely. -/// Wire values of `PersistentTransaction.direction`, matching `directionName` -/// and the FFI's `TransactionDirection` discriminants. -enum CoreDirectionCode { - static let incoming: UInt32 = 0 - static let outgoing: UInt32 = 1 - static let internalTransfer: UInt32 = 2 - static let coinJoin: UInt32 = 3 -} - public enum TransactionTypeKind: UInt8 { case standard = 0 case coinJoin = 1 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 51f55f49e53..22a181a0c61 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3432,6 +3432,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // Display-only accounting: a failure here must not fail the // round, and is reported under its own event, not save_failed. // Recomputed values that did land are consistent on their own. + // TODO(test-round-accounting-failure): cover this catch with a + // FetchFaultInjector test; pinning which read faults needs a Swift run. do { try reconcileTransactionAccounting(Array(accountingDirty.values)) } catch { @@ -6857,7 +6859,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let spendingWallets = Set(inputs.compactMap { Self.resolvedWalletId(of: $0) }) for (index, output) in decoded.outputs.enumerated() { // OP_RETURN burns (including asset locks) are not spendable Core outputs. - if output.scriptPubkey.first == 0x6a { continue } + if output.isOpReturn { continue } var belongs = ownedVouts.contains(UInt32(index)) if !belongs, let address = output.address { let descriptor = FetchDescriptor(predicate: #Predicate { $0.address == address }) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift index c854fd5ce4d..64c238a2f0d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift @@ -6,8 +6,8 @@ struct TransactionDetailView: View { var walletId: Data? = nil /// `nil` while this wallet's amount is unresolved — the same state the /// amount label shows as "Amount unavailable", so fee and amount agree. - private var netAmount: Int64? { walletId.map { transaction.netAmount(for: $0) } ?? transaction.netAmount } - private var direction: UInt32 { walletId.map { transaction.direction(for: $0) } ?? transaction.direction } + private var netAmount: Int64? { transaction.displayNetAmount(for: walletId) } + private var direction: UInt32 { transaction.displayDirectionCode(for: walletId) } /// Asset-lock payload funding amount, excluding the Core transaction fee. var assetLockAmountDuffs: Int64? = nil @Environment(\.dismiss) private var dismiss @@ -25,7 +25,7 @@ struct TransactionDetailView: View { if transaction.isProviderSpecial && netAmount == 0 { return nil } - return walletId.map { transaction.formattedAmount(for: $0) } ?? transaction.formattedAmount + return transaction.displayFormattedAmount(for: walletId) } private var typeDescription: String { @@ -36,11 +36,11 @@ struct TransactionDetailView: View { return transaction.displayDirection } switch direction { - case 0: + case CoreDirectionCode.incoming: return "Received" - case 1: + case CoreDirectionCode.outgoing: return "Sent" - case 3: + case CoreDirectionCode.coinJoin: return "CoinJoin" default: return "Self-Transfer" @@ -51,14 +51,7 @@ struct TransactionDetailView: View { if transaction.isAssetLock { return "lock.fill" } if transaction.isAssetUnlock { return "lock.open.fill" } if transaction.isProviderSpecial { return "server.rack" } - switch direction { - case 0: - return "arrow.down.circle.fill" - case 1: - return "arrow.up.circle.fill" - default: - return "arrow.triangle.2.circlepath" - } + return TransactionDirectionStyle.icon(for: direction) } private var typeColor: Color { @@ -68,14 +61,7 @@ struct TransactionDetailView: View { if transaction.isProviderSpecial { return .orange } - switch direction { - case 0: - return .green - case 1: - return .red - default: - return .blue - } + return TransactionDirectionStyle.color(for: direction) } private var isConfirmed: Bool { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift index 1f34b9467e8..fbd83dfe157 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift @@ -217,6 +217,31 @@ struct TransactionListView: View { } } +// MARK: - Direction Style + +/// Direction icon and colour shared by the transaction list and detail views. +enum TransactionDirectionStyle { + static func icon(for direction: UInt32) -> String { + switch direction { + case CoreDirectionCode.incoming: return "arrow.down.circle.fill" + case CoreDirectionCode.outgoing: return "arrow.up.circle.fill" + case CoreDirectionCode.internalTransfer: return "arrow.triangle.2.circlepath" + case CoreDirectionCode.coinJoin: return "shuffle.circle.fill" + default: return "questionmark.circle" + } + } + + /// Internal transfers share the outgoing colour: they still pay a fee. + static func color(for direction: UInt32) -> Color { + switch direction { + case CoreDirectionCode.incoming: return .green + case CoreDirectionCode.outgoing, CoreDirectionCode.internalTransfer: return .red + case CoreDirectionCode.coinJoin: return .blue + default: return .secondary + } + } +} + // MARK: - Transaction Row View struct TransactionRowView: View { @@ -224,8 +249,8 @@ struct TransactionRowView: View { var walletId: Data? = nil /// `nil` while this wallet's amount is unresolved — the same state the /// amount label shows as "Amount unavailable", so fee and amount agree. - private var netAmount: Int64? { walletId.map { transaction.netAmount(for: $0) } ?? transaction.netAmount } - private var direction: UInt32 { walletId.map { transaction.direction(for: $0) } ?? transaction.direction } + private var netAmount: Int64? { transaction.displayNetAmount(for: walletId) } + private var direction: UInt32 { transaction.displayDirectionCode(for: walletId) } /// Asset-lock payload funding amount, excluding the Core transaction fee. var assetLockAmountDuffs: Int64? = nil /// The DashPay payment this tx belongs to, if any — joined by `txid` in @@ -255,14 +280,7 @@ struct TransactionRowView: View { // `Internal` — the wallet just sees its own owner/voting/payout // keys in the payload — so the self-transfer arrows would lie. if transaction.isProviderSpecial { return "server.rack" } - // direction: 0=incoming, 1=outgoing, 2=internal, 3=coinJoin - switch direction { - case 0: return "arrow.down.circle.fill" - case 1: return "arrow.up.circle.fill" - case 2: return "arrow.triangle.2.circlepath" - case 3: return "shuffle.circle.fill" - default: return "questionmark.circle" - } + return TransactionDirectionStyle.icon(for: direction) } private var typeColor: Color { @@ -280,12 +298,7 @@ struct TransactionRowView: View { if transaction.isProviderSpecial { return .orange } - switch direction { - case 0: return .green - case 1, 2: return .red - case 3: return .blue - default: return .secondary - } + return TransactionDirectionStyle.color(for: direction) } /// Primary label: the contact context for a DashPay payment, else the @@ -436,6 +449,6 @@ struct TransactionRowView: View { if transaction.isProviderSpecial && netAmount == 0 { return transaction.providerSpecialName ?? transaction.transactionType } - return walletId.map { transaction.formattedAmount(for: $0) } ?? transaction.formattedAmount + return transaction.displayFormattedAmount(for: walletId) } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift index 26b690a985c..f3264cedc53 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -64,6 +64,12 @@ final class TransactionAccountingTests: XCTestCase { } } + func testShouldFormatSignedDuffsWithoutTrappingOnInt64Min() { + XCTAssertEqual(PersistentTransaction.format(duffs: 150_000_000), "+1.50000000 DASH") + XCTAssertEqual(PersistentTransaction.format(duffs: -100), "-0.00000100 DASH") + XCTAssertTrue(PersistentTransaction.format(duffs: .min).hasPrefix("-92233720368.")) + } + func testShouldRejectOverflowInsteadOfWrappingHistory() { XCTAssertNil(PersistentTransaction.reconciledAccounting( inputs: [input(UInt64.max)], ownedOutputAmounts: [], allOutputsOwned: false, previousDirection: 0, isAssetLock: false From 9450f2123967c0702c4a1d651b3d529171ba053e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:51:49 +0000 Subject: [PATCH 17/26] chore(platform-wallet-storage): mark imprecise history error variants as TODO Body conflict, unknown network and net-amount overflow in core_history reuse BlobDecode because no existing variant matches them exactly and a new variant would break exhaustive matches on WalletStorageError. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/schema/core_history.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs index 184dd0759df..6c8baf4568e 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs @@ -29,6 +29,9 @@ pub(super) fn preserve_known_details( return Ok(merged); }; if previous.transaction != incoming.transaction { + // TODO(precise-history-error-variants): body conflict, unknown network and + // net-amount overflow are reported as BlobDecode; add dedicated variants + // (with #[non_exhaustive]) in a breaking release. return Err(WalletStorageError::blob_decode( "same transaction id has different raw transaction bodies", )); From eb3feb3160f15ae2ea60026f36f3ce6a78aae4ae Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:11:58 +0000 Subject: [PATCH 18/26] feat(platform-wallet-storage)!: precise errors for history conflicts, unknown networks and amount overflow History repair reported three non-decode failures as BlobDecode. Each now has its own variant with the context an operator needs: - TransactionBodyConflict { wallet_id, txid }: a stored txid re-arrives with a different raw body. Kind: Constraint, non-transient. - UnknownWalletNetwork { wallet_id, label }: wallets.network holds a label this build does not know. Kind: Fatal, non-transient. - NetAmountOverflow { wallet_id, txid, value: i128 }: owned outputs minus owned inputs does not fit i64. Kind: Constraint, non-transient. All three are wired into is_transient, persistence_kind and error_kind_str (still wildcard-free). The frozen V019 migration keeps its own BlobDecode sites untouched. Its drop-for-resync path only inspects read_record errors, so those sites never affect stored data. BREAKING CHANGE: WalletStorageError is now #[non_exhaustive], and it gains TransactionBodyConflict, UnknownWalletNetwork and NetAmountOverflow. Matches outside the crate need a wildcard arm, and callers that matched BlobDecode for these failures must match the new variants. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/error.rs | 47 +++++ .../src/sqlite/schema/core_history.rs | 160 ++++++++++++++++-- .../tests/persistence_error_kind_mapping.rs | 41 +++++ .../tests/sqlite_error_classification.rs | 33 +++- 4 files changed, 263 insertions(+), 18 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs index 9f843edf7bb..2c64509fd18 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -32,7 +32,11 @@ pub enum AutoBackupOperation { } /// Errors produced by the wallet-storage SQLite backend. +/// +/// `#[non_exhaustive]`: new failure modes get their own variant, so matches +/// outside this crate need a wildcard arm. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum WalletStorageError { /// File-system I/O error reaching the database or backup files. #[error("io error")] @@ -418,6 +422,37 @@ pub enum WalletStorageError { blob_height: Option, }, + /// An incoming transaction record reuses a stored txid with a different + /// raw transaction body; neither copy is trusted to replace the other. + #[error( + "transaction {txid} in wallet {} arrived with a raw body that differs from the stored one", + hex::encode(wallet_id) + )] + TransactionBodyConflict { + wallet_id: [u8; 32], + txid: dashcore::Txid, + }, + + /// The `wallets.network` label is not one this build knows, so stored + /// scripts cannot be turned back into addresses. + #[error( + "wallet {} has unknown network label {label:?}", + hex::encode(wallet_id) + )] + UnknownWalletNetwork { wallet_id: [u8; 32], label: String }, + + /// A transaction's net amount (owned outputs minus owned inputs) does not + /// fit the `i64` the record stores. + #[error( + "net amount {value} of transaction {txid} in wallet {} does not fit i64", + hex::encode(wallet_id) + )] + NetAmountOverflow { + wallet_id: [u8; 32], + txid: dashcore::Txid, + value: i128, + }, + /// A blob exceeded the decode allocation cap (default 16 MiB). /// Separate from [`Self::BlobDecode`] so operators can distinguish an /// oversize blob from a structural decode failure. @@ -754,6 +789,9 @@ impl WalletStorageError { | Self::AssetLockEntryMismatch { .. } | Self::AssetLockStatusMismatch { .. } | Self::CoreTransactionEntryMismatch { .. } + | Self::TransactionBodyConflict { .. } + | Self::UnknownWalletNetwork { .. } + | Self::NetAmountOverflow { .. } | Self::BlobTooLarge { .. } | Self::IntegerOverflow { .. } | Self::RehydrationPoolMismatch { .. } @@ -799,6 +837,11 @@ impl WalletStorageError { // Typed re-mapping of an FK violation — same class as the raw // `ConstraintViolation` above, so it reports the same kind. Self::IdentityKeyWalletMismatch { .. } => PersistenceErrorKind::Constraint, + // History invariants checked in Rust on the write path: the incoming + // record contradicts stored history, so the data is wrong, not the engine. + Self::TransactionBodyConflict { .. } | Self::NetAmountOverflow { .. } => { + PersistenceErrorKind::Constraint + } // Refinery surfaces FK / constraint problems through rusqlite; // if that path leaks through here the typed variant lives in // `Self::Migration`, which we leave as `Fatal` since a @@ -858,6 +901,7 @@ impl WalletStorageError { | Self::AssetLockEntryMismatch { .. } | Self::AssetLockStatusMismatch { .. } | Self::CoreTransactionEntryMismatch { .. } + | Self::UnknownWalletNetwork { .. } | Self::BlobTooLarge { .. } | Self::IntegerOverflow { .. } | Self::RehydrationPoolMismatch { .. } @@ -939,6 +983,9 @@ impl WalletStorageError { Self::AssetLockEntryMismatch { .. } => "asset_lock_entry_mismatch", Self::AssetLockStatusMismatch { .. } => "asset_lock_status_mismatch", Self::CoreTransactionEntryMismatch { .. } => "core_transaction_entry_mismatch", + Self::TransactionBodyConflict { .. } => "transaction_body_conflict", + Self::UnknownWalletNetwork { .. } => "unknown_wallet_network", + Self::NetAmountOverflow { .. } => "net_amount_overflow", Self::BlobTooLarge { .. } => "blob_too_large", Self::IntegerOverflow { .. } => "integer_overflow", Self::RehydrationPoolMismatch { .. } => "rehydration_pool_mismatch", diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs index 6c8baf4568e..7d9a91f3ab7 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs @@ -29,12 +29,10 @@ pub(super) fn preserve_known_details( return Ok(merged); }; if previous.transaction != incoming.transaction { - // TODO(precise-history-error-variants): body conflict, unknown network and - // net-amount overflow are reported as BlobDecode; add dedicated variants - // (with #[non_exhaustive]) in a breaking release. - return Err(WalletStorageError::blob_decode( - "same transaction id has different raw transaction bodies", - )); + return Err(WalletStorageError::TransactionBodyConflict { + wallet_id: *wallet_id, + txid: incoming.txid, + }); } let mut inputs: BTreeMap<_, _> = previous .input_details @@ -132,8 +130,10 @@ fn network( params![wallet_id.as_slice()], |r| r.get(0), )?; - wallets::parse_network(&label) - .ok_or_else(|| WalletStorageError::blob_decode("wallets.network is unknown")) + wallets::parse_network(&label).ok_or_else(|| WalletStorageError::UnknownWalletNetwork { + wallet_id: *wallet_id, + label, + }) } fn owned_output( @@ -276,8 +276,11 @@ fn repair_record( .map(|d| i128::from(d.value)) .sum(); let spent: i128 = inputs.values().map(|d| i128::from(d.value)).sum(); - record.net_amount = i64::try_from(received - spent).map_err(|_| { - WalletStorageError::blob_decode("wallet transaction net amount exceeds i64") + let net = received - spent; + record.net_amount = i64::try_from(net).map_err(|_| WalletStorageError::NetAmountOverflow { + wallet_id: *wallet_id, + txid: *txid, + value: net, })?; let has_ours = outputs .values() @@ -351,8 +354,145 @@ fn repaired_direction( #[cfg(test)] mod tests { + use dashcore::address::Payload; + use dashcore::{PubkeyHash, Transaction as CoreTransaction, TxOut}; + use key_wallet::account::{AccountType, StandardAccountType}; + use super::*; + const WALLET_ID: WalletId = [0x5Au8; 32]; + + fn wallet_db(network: &str) -> Connection { + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + // The schema CHECK rejects unknown labels; a corrupt or newer file may still carry one. + conn.pragma_update(None, "ignore_check_constraints", true) + .unwrap(); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, 0)", + params![&WALLET_ID[..], network], + ) + .unwrap(); + conn + } + + fn record(output_values: &[u64], received: &[u64]) -> TransactionRecord { + let script = Address::new( + dashcore::Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([7; 20])), + ) + .script_pubkey(); + let body = CoreTransaction { + version: 1, + lock_time: 0, + input: Vec::new(), + output: output_values + .iter() + .map(|&value| TxOut { + value, + script_pubkey: script.clone(), + }) + .collect(), + special_transaction_payload: None, + }; + let details = received + .iter() + .enumerate() + .map(|(index, &value)| OutputDetail { + index: index as u32, + role: OutputRole::Received, + address: None, + value, + }) + .collect(); + TransactionRecord::new( + body, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + details, + 0, + ) + } + + fn store(conn: &Connection, record: &TransactionRecord) { + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) \ + VALUES (?1, ?2, 0, ?3)", + params![ + &WALLET_ID[..], + record.txid.as_byte_array().as_slice(), + blob::encode(record).unwrap() + ], + ) + .unwrap(); + } + + #[test] + fn should_report_a_body_conflict_for_the_same_txid_with_another_body() { + let mut conn = wallet_db("testnet"); + let stored = record(&[1_000], &[]); + store(&conn, &stored); + let mut incoming = record(&[2_000], &[]); + incoming.txid = stored.txid; + + let tx = conn.transaction().unwrap(); + let err = preserve_known_details(&tx, &WALLET_ID, &incoming).unwrap_err(); + + assert!( + matches!( + err, + WalletStorageError::TransactionBodyConflict { wallet_id, txid } + if wallet_id == WALLET_ID && txid == stored.txid + ), + "got {err:?}" + ); + } + + #[test] + fn should_report_an_unknown_wallet_network_label() { + let mut conn = wallet_db("moonnet"); + let tx = conn.transaction().unwrap(); + + let err = network(&tx, &WALLET_ID).unwrap_err(); + + assert!( + matches!( + &err, + WalletStorageError::UnknownWalletNetwork { wallet_id, label } + if *wallet_id == WALLET_ID && label == "moonnet" + ), + "got {err:?}" + ); + } + + #[test] + fn should_report_a_net_amount_that_does_not_fit_i64() { + let mut conn = wallet_db("testnet"); + let stored = record(&[u64::MAX, u64::MAX], &[u64::MAX, u64::MAX]); + store(&conn, &stored); + let tx = conn.transaction().unwrap(); + + let err = + repair_record(&tx, &WALLET_ID, &stored.txid, dashcore::Network::Testnet).unwrap_err(); + + assert!( + matches!( + err, + WalletStorageError::NetAmountOverflow { wallet_id, txid, value } + if wallet_id == WALLET_ID + && txid == stored.txid + && value == 2 * i128::from(u64::MAX) + ), + "got {err:?}" + ); + } + /// Shared with the Swift SDK's `TransactionAccountingTests` direction table. #[test] fn should_classify_repaired_direction_like_the_swift_sdk() { diff --git a/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs b/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs index 79e7ea60238..4954aae4323 100644 --- a/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs +++ b/packages/rs-platform-wallet-storage/tests/persistence_error_kind_mapping.rs @@ -14,6 +14,7 @@ use std::path::PathBuf; +use dashcore::hashes::Hash; use platform_wallet::changeset::{PersistenceError, PersistenceErrorKind}; use platform_wallet_storage::sqlite::error::{AutoBackupOperation, WalletStorageError}; use platform_wallet_storage::sqlite::util::safe_cast::SafeCastTarget; @@ -150,6 +151,39 @@ fn tc_code_004_b_identity_index_variants_map_to_constraint_kind() { } } +/// History invariants are enforced in Rust on the write path: an incoming +/// record that contradicts stored history is a data fault, not a retryable +/// or engine failure. +#[test] +fn history_integrity_variants_map_to_constraint_kind() { + let txid = dashcore::Txid::from_byte_array([0x44; 32]); + let cases: Vec<(&str, WalletStorageError)> = vec![ + ( + "TransactionBodyConflict", + WalletStorageError::TransactionBodyConflict { + wallet_id: [0xAA; 32], + txid, + }, + ), + ( + "NetAmountOverflow", + WalletStorageError::NetAmountOverflow { + wallet_id: [0xAA; 32], + txid, + value: i128::from(i64::MIN) - 1, + }, + ), + ]; + for (label, err) in cases { + assert!(!err.is_transient(), "{label}: must not be transient"); + assert_eq!( + kind_of(err), + PersistenceErrorKind::Constraint, + "{label}: trait-boundary kind must be Constraint" + ); + } +} + /// Every remaining fatal-but-not-constraint variant maps to `Fatal`. /// Spot-check enough variants to lock the table; the /// exhaustiveness is guarded by the wildcard-free invariant test. @@ -232,6 +266,13 @@ fn tc_code_004_b_fatal_variants_map_to_fatal_kind() { "BlobDecode", WalletStorageError::BlobDecode { reason: "len" }, ), + ( + "UnknownWalletNetwork", + WalletStorageError::UnknownWalletNetwork { + wallet_id: [0xAA; 32], + label: "moonnet".into(), + }, + ), ( "ForeignKeysNotEnforced", WalletStorageError::ForeignKeysNotEnforced, diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs index 36d46cb6073..5ea1f9c73df 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs @@ -4,10 +4,10 @@ //! plus the boundary mapping of `FlushRetryable` into //! `PersistenceError::Backend`. //! -//! The check is a wildcard-free `match` with one arm per variant (no -//! `_`), so a new `WalletStorageError` variant fails to compile here -//! until it is classified — mirroring the matches in `error::is_transient` -//! / `error::error_kind_str`. +//! The check is a `match` with one arm per variant. The enum is +//! `#[non_exhaustive]`, so this external test needs a panicking `_` arm; +//! the compile-time guard lives in the wildcard-free matches of +//! `error::is_transient` / `error::error_kind_str`. use std::path::PathBuf; @@ -214,6 +214,19 @@ fn samples() -> Vec { typed_height: Some(100), blob_height: Some(101), }, + WalletStorageError::TransactionBodyConflict { + wallet_id: [0x33; 32], + txid: dashcore::Txid::from_byte_array([0x44; 32]), + }, + WalletStorageError::UnknownWalletNetwork { + wallet_id: [0x33; 32], + label: "moonnet".into(), + }, + WalletStorageError::NetAmountOverflow { + wallet_id: [0x33; 32], + txid: dashcore::Txid::from_byte_array([0x44; 32]), + value: i128::from(u64::MAX) * 2, + }, WalletStorageError::BlobTooLarge { len_bytes: 32 * 1024 * 1024, limit_bytes: 16 * 1024 * 1024, @@ -363,10 +376,8 @@ fn samples() -> Vec { #[test] fn tc_p2_005_is_transient_table() { fn classify(err: &WalletStorageError) -> (bool, &'static str) { - // Every arm asserts the expected (transient, kind_str) pair - // and returns it for the outer assertion. A new variant - // landing in WalletStorageError makes this match fail to - // compile until classified. + // Every arm returns the expected (transient, kind_str) pair + // for the outer assertion. match err { // SQLite path discriminates by inner ErrorCode — split // into busy / locked / other to mirror error_kind_str. @@ -437,6 +448,11 @@ fn tc_p2_005_is_transient_table() { WalletStorageError::CoreTransactionEntryMismatch { .. } => { (false, "core_transaction_entry_mismatch") } + WalletStorageError::TransactionBodyConflict { .. } => { + (false, "transaction_body_conflict") + } + WalletStorageError::UnknownWalletNetwork { .. } => (false, "unknown_wallet_network"), + WalletStorageError::NetAmountOverflow { .. } => (false, "net_amount_overflow"), WalletStorageError::BlobTooLarge { .. } => (false, "blob_too_large"), WalletStorageError::ForeignKeysNotEnforced => (false, "foreign_keys_not_enforced"), WalletStorageError::JournalModeNotApplied { .. } => (false, "journal_mode_not_applied"), @@ -494,6 +510,7 @@ fn tc_p2_005_is_transient_table() { (false, "empty_pool_address_script") } WalletStorageError::DatabasePathIsSymlink { .. } => (false, "database_path_is_symlink"), + other => panic!("sample {other:?} has no expected classification"), } } From aa56693cdd38305169c8714f579513394519e869 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:12:32 +0000 Subject: [PATCH 19/26] test(platform-wallet-storage): explain the catch-all arm in the classification table Co-Authored-By: Claude Opus 5.5 --- .../tests/sqlite_error_classification.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs index 5ea1f9c73df..6884ea5dca3 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs @@ -510,6 +510,9 @@ fn tc_p2_005_is_transient_table() { (false, "empty_pool_address_script") } WalletStorageError::DatabasePathIsSymlink { .. } => (false, "database_path_is_symlink"), + // `WalletStorageError` is `#[non_exhaustive]`, so this external test + // crate needs a catch-all arm. Exhaustiveness is enforced in-crate by + // the wildcard-free matches in `src/sqlite/error.rs`. other => panic!("sample {other:?} has no expected classification"), } } From 9020c102d76a94f4e3d083adba4e070e881d45f4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:14:03 +0000 Subject: [PATCH 20/26] test(platform-wallet-storage): explain the wildcard arm in the error classification table Co-Authored-By: Claude Opus 5.5 --- .../tests/sqlite_error_classification.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs index 6884ea5dca3..edc232282dd 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs @@ -513,6 +513,8 @@ fn tc_p2_005_is_transient_table() { // `WalletStorageError` is `#[non_exhaustive]`, so this external test // crate needs a catch-all arm. Exhaustiveness is enforced in-crate by // the wildcard-free matches in `src/sqlite/error.rs`. + // Required: the enum is #[non_exhaustive] from this external crate. + // Exhaustiveness is enforced by the wildcard-free in-crate matches. other => panic!("sample {other:?} has no expected classification"), } } From 6e6720f3381bfc61ffac3864cf213a7debea89b7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:14:18 +0000 Subject: [PATCH 21/26] Revert "test(platform-wallet-storage): explain the wildcard arm in the error classification table" Duplicates the comment added in aa56693cdd. Co-Authored-By: Claude Opus 5.5 --- .../tests/sqlite_error_classification.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs index edc232282dd..6884ea5dca3 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs @@ -513,8 +513,6 @@ fn tc_p2_005_is_transient_table() { // `WalletStorageError` is `#[non_exhaustive]`, so this external test // crate needs a catch-all arm. Exhaustiveness is enforced in-crate by // the wildcard-free matches in `src/sqlite/error.rs`. - // Required: the enum is #[non_exhaustive] from this external crate. - // Exhaustiveness is enforced by the wildcard-free in-crate matches. other => panic!("sample {other:?} has no expected classification"), } } From c99194a68e6b97142fb3533b9e2cc9af0b63ac75 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:35:43 +0000 Subject: [PATCH 22/26] fix(platform-wallet-storage): gate V019 record length before reading the blob V019's read_record fetched record_blob into a Vec inside the same row closure that read its length, so an oversize record was materialized before blob::check_size rejected it. Read the length first and fetch the payload only once it passes, matching core_state::load_state. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/migrations/legacy_v019.rs | 93 +++++++++++++++---- 1 file changed, 73 insertions(+), 20 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs index e1bf7fcd5dc..e59d81f573a 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs @@ -15,7 +15,7 @@ use key_wallet::managed_account::transaction_record::{ }; use key_wallet::transaction_checking::{TransactionContext, TransactionType}; use platform_wallet::wallet::platform_wallet::WalletId; -use rusqlite::{params, OptionalExtension, Transaction}; +use rusqlite::{params, Transaction}; use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::{blob, id32, wallets}; @@ -27,23 +27,23 @@ fn read_record( wallet_id: &WalletId, txid: &Txid, ) -> Result, WalletStorageError> { - let stored: Option)>> = tx - .query_row( - "SELECT length(record_blob), record_blob FROM core_transactions \ - WHERE wallet_id = ?1 AND txid = ?2", - params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], - |row| { - Ok(match row.get::<_, Option>(0)? { - Some(len) => Some((len, row.get(1)?)), - None => None, - }) - }, - ) - .optional()?; - let Some(Some((len, payload))) = stored else { + let mut stmt = tx.prepare_cached( + "SELECT length(record_blob), record_blob FROM core_transactions \ + WHERE wallet_id = ?1 AND txid = ?2", + )?; + let mut rows = stmt.query(params![ + wallet_id.as_slice(), + txid.as_byte_array().as_slice() + ])?; + let Some(row) = rows.next()? else { + return Ok(None); + }; + // Gate the stored length before the payload is materialized. + let Some(len) = row.get::<_, Option>(0)? else { return Ok(None); }; blob::check_size(len)?; + let payload: Vec = row.get(1)?; let record: TransactionRecord = blob::decode(&payload)?; if record.txid != *txid { return Err(WalletStorageError::blob_decode( @@ -396,6 +396,63 @@ mod tests { } } + /// Undo V019 so the next `migrations::run` replays it over current rows. + fn rewind_to_v018(conn: &Connection) { + conn.execute_batch( + "DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; \ + DELETE FROM refinery_schema_history WHERE version >= 19;", + ) + .unwrap(); + } + + /// An oversize confirmed record fails its length gate before its payload + /// is read, and is dropped with a rescan from just below the birth height. + #[test] + fn should_drop_oversize_confirmed_record_for_resync() { + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let wallet_id = [0xC2u8; 32]; + let txid = Txid::from_byte_array([0x72; 32]); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 50)", + params![&wallet_id[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) \ + VALUES (?1, 900, 900)", + params![&wallet_id[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, height, finalized, record_blob) \ + VALUES (?1, ?2, 300, 1, zeroblob(?3))", + params![ + &wallet_id[..], + txid.as_byte_array().as_slice(), + i64::try_from(blob::BLOB_SIZE_LIMIT_BYTES + 1).unwrap() + ], + ) + .unwrap(); + rewind_to_v018(&conn); + + crate::sqlite::migrations::run(&mut conn).unwrap(); + + let (records, synced): (i64, i64) = conn + .query_row( + "SELECT (SELECT count(*) FROM core_transactions WHERE wallet_id = ?1), \ + (SELECT synced_height FROM core_sync_state WHERE wallet_id = ?1)", + params![&wallet_id[..]], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(records, 0, "the oversize record must be dropped"); + assert_eq!( + synced, 49, + "the rescan must restart just below the birth height" + ); + } + /// Pins V019's observable result on a V018-shaped database: the repaired /// record, the preserved original, the input index and the spent marks. #[test] @@ -492,11 +549,7 @@ mod tests { ], ) .unwrap(); - tx.execute_batch( - "DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; \ - DELETE FROM refinery_schema_history WHERE version >= 19;", - ) - .unwrap(); + rewind_to_v018(&tx); tx.commit().unwrap(); } From 61aefbb8051f34d403197174798e821a7e0f6adf Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:35:43 +0000 Subject: [PATCH 23/26] fix(platform-wallet-storage): replay persisted IS locks and order history by dependency A lock that arrives after its transaction was stored is persisted only in core_instant_locks; the stored record keeps its mempool context. Load marked the lock first (so later lock events dedupe) and then replayed the record as mempool, leaving it evictable and skipping the InstantSend conflict sweep. Replay now upgrades a mempool record with a persisted lock to InstantSend. replay_order sorted every confirmed record ahead of every unconfirmed one, but a parent's record can still say mempool after it confirmed (a height-only confirmation never rewrites an existing record) while its child is recorded confirmed. Order the whole set topologically, keeping chain order (height, block position, then unconfirmed; txid ties) where there is no dependency, with direct tests for the ordering invariants. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/persister.rs | 7 +- .../src/sqlite/rehydrate.rs | 360 ++++++++++++++++-- 2 files changed, 331 insertions(+), 36 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index d19df704ad3..546bc42a13c 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1851,7 +1851,12 @@ fn load_one_wallet( })?; } let mut wallet = wallet; - restore_recorded_transactions(&mut wallet_info, &mut wallet, core_state.records); + restore_recorded_transactions( + &mut wallet_info, + &mut wallet, + core_state.records, + &core_state.instant_locks_for_non_final_records, + ); Ok(platform_wallet::changeset::ClientWalletStartState { wallet, wallet_info, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index 23872199a7d..6a6f2f754d4 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -4,15 +4,16 @@ //! the manager consumes the carried snapshot directly, so no wrong-seed check //! runs here; that gate lives in the resolver-backed signing entrypoints. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; -use dashcore::OutPoint; +use dashcore::ephemerealdata::instant_lock::InstantLock; +use dashcore::{OutPoint, Txid}; use key_wallet::account::account_collection::AccountCollection; use key_wallet::account::{Account, AccountType}; use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType}; use key_wallet::managed_account::transaction_record::TransactionRecord; use key_wallet::managed_account::ManagedCoreFundsAccount; -use key_wallet::transaction_checking::WalletTransactionChecker; +use key_wallet::transaction_checking::{TransactionContext, WalletTransactionChecker}; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; @@ -426,10 +427,14 @@ pub fn apply_persisted_core_state( /// /// Unconfirmed (mempool / InstantSend) spends are replayed too: without them a /// redelivered funding transaction would re-credit an output they reserve. +/// `instant_locks` are the persisted InstantSend locks: a lock that arrived +/// after its transaction was stored never rewrote the stored record, so the +/// replay upgrades that record's mempool context itself. pub(crate) fn restore_recorded_transactions( wallet_info: &mut ManagedWalletInfo, wallet: &mut Wallet, records: Vec, + instant_locks: &BTreeMap, ) { // Where the load projection parked each unspent outpoint; its keys are // the outputs persistence still considers unspent. @@ -454,8 +459,17 @@ pub(crate) fn restore_recorded_transactions( let (info_before, wallet_before) = (wallet_info.clone(), wallet.clone()); let completed = poll_ready(async { for record in replay { + // The lock set already holds this txid (load marked the restored + // UTXOs), so a later lock event is deduplicated: the InstantSend + // context, and the conflict sweep it runs, must come from here. + let context = match (record.context, instant_locks.get(&record.txid)) { + (TransactionContext::Mempool, Some(lock)) => { + TransactionContext::InstantSend(lock.clone()) + } + (context, _) => context, + }; wallet_info - .check_core_transaction(&record.transaction, record.context, wallet, true, false) + .check_core_transaction(&record.transaction, context, wallet, true, false) .await; } }) @@ -524,37 +538,66 @@ fn poll_ready(future: F) -> Option { } } -/// Order records as the chain would deliver them: confirmed by block position, -/// then unconfirmed ones with every in-set parent ahead of its children. +/// Order records as the chain would deliver them: every in-set parent ahead of +/// its children, otherwise confirmed by block position, then unconfirmed. +/// +/// Dependencies span both partitions: a parent's stored record can still say +/// mempool after it confirmed (a height-only confirmation never rewrites an +/// existing record), while its child's record is already confirmed. fn replay_order(records: Vec) -> Vec { - let (mut ordered, mut pending): (Vec<_>, Vec<_>) = records - .into_iter() - .partition(|record| record.block_info().is_some()); - ordered.sort_by_key(|record| { - record - .block_info() - .map(|block| (block.height(), block.position())) + let mut records = records; + records.sort_by_key(|record| { + let block = record.block_info(); + ( + block.is_none(), + block.map(|block| (block.height(), block.position())), + record.txid, + ) }); - // Deterministic start; parents are then pulled forward in rounds. - pending.sort_by_key(|record| record.txid); - while !pending.is_empty() { - let waiting: HashSet<_> = pending.iter().map(|record| record.txid).collect(); - let (ready, blocked): (Vec<_>, Vec<_>) = pending.into_iter().partition(|record| { - record.transaction.input.iter().all(|input| { - input.previous_output.txid == record.txid - || !waiting.contains(&input.previous_output.txid) - }) - }); - if ready.is_empty() { - // Unreachable for real transactions (txids cannot form a cycle); - // keep the rest rather than drop a reservation. - ordered.extend(blocked); - break; + let index: HashMap = records + .iter() + .enumerate() + .map(|(position, record)| (record.txid, position)) + .collect(); + let mut children: Vec> = vec![Vec::new(); records.len()]; + let mut waiting_on: Vec = vec![0; records.len()]; + for (child, record) in records.iter().enumerate() { + let parents: BTreeSet = record + .transaction + .input + .iter() + .filter_map(|input| index.get(&input.previous_output.txid).copied()) + .filter(|parent| *parent != child) + .collect(); + waiting_on[child] = parents.len(); + for parent in parents { + children[parent].push(child); + } + } + // Sorted positions, so the smallest ready one is always next in chain order. + let mut ready: BTreeSet = (0..records.len()) + .filter(|position| waiting_on[*position] == 0) + .collect(); + let mut emitted = vec![false; records.len()]; + let mut order = Vec::with_capacity(records.len()); + while let Some(position) = ready.pop_first() { + emitted[position] = true; + order.push(position); + for &child in &children[position] { + waiting_on[child] -= 1; + if waiting_on[child] == 0 { + ready.insert(child); + } } - ordered.extend(ready); - pending = blocked; } - ordered + // Unreachable for real transactions (txids cannot form a cycle); keep the + // rest in chain order rather than drop a reservation. + order.extend((0..records.len()).filter(|position| !emitted[*position])); + let mut slots: Vec> = records.into_iter().map(Some).collect(); + order + .into_iter() + .filter_map(|position| slots[position].take()) + .collect() } /// Account identity of a funds account, stable across replay mutations. @@ -3363,9 +3406,9 @@ mod tests { } } - /// Same-height records without a block position replay in stored order; - /// an output the load projection still parks as unspent must end up - /// excluded whichever of funding and spend replays first. + /// Same-height records without a block position: an output the load + /// projection still parks as unspent must end up excluded whichever of + /// funding and spend is stored first. #[tokio::test] async fn should_exclude_spent_output_for_either_same_height_replay_order() { use dashcore::hashes::Hash; @@ -3463,7 +3506,7 @@ mod tests { }, ); } - restore_recorded_transactions(&mut restored, &mut wallet, records); + restore_recorded_transactions(&mut restored, &mut wallet, records, &Default::default()); let coins = &restored.accounts.standard_bip44_accounts[&0].utxos; assert!(!coins.contains_key(&spent), "spend_first={spend_first}"); @@ -3475,4 +3518,251 @@ mod tests { ); } } + + /// A lock that arrived after its transaction was stored lives only in + /// `core_instant_locks`; the stored record still says mempool. Replay must + /// restore the InstantSend context and run its conflict sweep, since the + /// already-marked lock deduplicates any later lock event. + #[tokio::test] + async fn should_replay_mempool_record_with_persisted_lock_as_instant_send() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::transaction_checking::BlockInfo; + + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let address = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([21; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 100_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let spend = |value| Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(funding.txid(), 0), + ..Default::default() + }], + output: vec![TxOut { + value, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let block = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([8; 32]), + 100, + )); + let mut records = info + .check_core_transaction(&funding, block, &mut wallet, true, true) + .await + .new_records; + // Both double spends as stored: unconfirmed, recorded independently. + let (mut winner, mut loser) = (spend(99_000), spend(98_000)); + for tx in [&winner, &loser] { + let mut scratch = info.clone(); + records.extend( + scratch + .check_core_transaction( + tx, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + } + assert_eq!(records.len(), 3); + // Unconfirmed siblings replay by txid: lock the later one so the + // loser is already recorded when the winner's sweep runs. + if winner.txid() < loser.txid() { + std::mem::swap(&mut winner, &mut loser); + } + let lock = InstantLock { + inputs: vec![OutPoint::new(funding.txid(), 0)], + txid: winner.txid(), + ..Default::default() + }; + let locks: BTreeMap = [(winner.txid(), lock)].into_iter().collect(); + + let record_of = |txid: Txid| records.iter().find(|r| r.txid == txid).unwrap().clone(); + + // Alone, the locked spend comes back InstantSend. + let mut alone = ManagedWalletInfo::from_wallet(&wallet, 0); + let pair = vec![record_of(funding.txid()), record_of(winner.txid())]; + restore_recorded_transactions(&mut alone, &mut wallet, pair, &locks); + assert!( + alone.accounts.standard_bip44_accounts[&0] + .transactions() + .get(&winner.txid()) + .is_some_and(|record| matches!(record.context, TransactionContext::InstantSend(_))), + "the locked record must come back InstantSend, not mempool" + ); + + // Replayed after a conflicting spend, its lock sweeps that spend. + let mut contested = ManagedWalletInfo::from_wallet(&wallet, 0); + restore_recorded_transactions(&mut contested, &mut wallet, records.clone(), &locks); + assert!( + !contested.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&loser.txid()), + "the lock's conflict sweep must drop the competing spend" + ); + } + + /// A record spending `parents` (output 0 of each); `value` keeps txids distinct. + fn replay_record( + parents: &[Txid], + value: u64, + context: TransactionContext, + ) -> TransactionRecord { + use dashcore::{Transaction, TxIn, TxOut}; + use key_wallet::account::StandardAccountType; + use key_wallet::managed_account::transaction_record::TransactionDirection; + use key_wallet::transaction_checking::TransactionType; + + let transaction = Transaction { + version: 1, + lock_time: 0, + input: parents + .iter() + .map(|parent| TxIn { + previous_output: OutPoint::new(*parent, 0), + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + TransactionRecord::new( + transaction, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + context, + TransactionType::Standard, + TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + 0, + ) + } + + fn in_block(height: u32, position: Option) -> TransactionContext { + use dashcore::hashes::Hash; + use key_wallet::transaction_checking::BlockInfo; + + let block = BlockInfo::new(height, dashcore::BlockHash::all_zeros(), height); + TransactionContext::InBlock(position.map_or(block, |p| block.with_position(p))) + } + + fn replayed_txids(records: Vec) -> Vec { + replay_order(records).into_iter().map(|r| r.txid).collect() + } + + /// An unconfirmed child whose txid sorts ahead of its parent's still + /// replays after it, so its input reserves the parent's output. + #[test] + fn should_replay_unconfirmed_parent_before_its_child() { + use dashcore::hashes::Hash; + + let parent = replay_record( + &[Txid::from_byte_array([1; 32])], + 1_000, + TransactionContext::Mempool, + ); + let child = (0..) + .map(|value| replay_record(&[parent.txid], value, TransactionContext::Mempool)) + .find(|child| child.txid < parent.txid) + .unwrap(); + let expected = vec![parent.txid, child.txid]; + + assert_eq!(replayed_txids(vec![child, parent]), expected); + } + + /// A parent whose stored record is still mempool replays ahead of a child + /// already recorded as confirmed. + #[test] + fn should_replay_mempool_parent_before_its_confirmed_child() { + use dashcore::hashes::Hash; + + let parent = replay_record( + &[Txid::from_byte_array([2; 32])], + 1_000, + TransactionContext::Mempool, + ); + let child = replay_record(&[parent.txid], 900, in_block(50, Some(3))); + let unrelated = replay_record(&[Txid::from_byte_array([3; 32])], 700, in_block(40, None)); + let expected = vec![unrelated.txid, parent.txid, child.txid]; + + assert_eq!(replayed_txids(vec![child, unrelated, parent]), expected); + } + + /// Independent records follow chain order: height, then in-block + /// position, then unconfirmed. + #[test] + fn should_order_independent_records_by_height_then_block_position() { + use dashcore::hashes::Hash; + + let funding = |marker| [Txid::from_byte_array([marker; 32])]; + let pending = replay_record(&funding(4), 1, TransactionContext::Mempool); + let late_second = replay_record(&funding(5), 2, in_block(10, Some(2))); + let late_first = replay_record(&funding(6), 3, in_block(10, Some(1))); + let early = replay_record(&funding(7), 4, in_block(9, Some(5))); + let expected = vec![early.txid, late_first.txid, late_second.txid, pending.txid]; + + assert_eq!( + replayed_txids(vec![pending, late_second, late_first, early]), + expected + ); + } + + /// Records that name each other as parents (impossible for real txids) + /// still all replay, after everything that is ready. + #[test] + fn should_keep_every_record_of_a_dependency_cycle() { + use dashcore::hashes::Hash; + + let (a, b) = ( + Txid::from_byte_array([0xAA; 32]), + Txid::from_byte_array([0xBB; 32]), + ); + let mut first = replay_record(&[b], 1, TransactionContext::Mempool); + first.txid = a; + let mut second = replay_record(&[a], 2, TransactionContext::Mempool); + second.txid = b; + let ready = replay_record( + &[Txid::from_byte_array([8; 32])], + 3, + TransactionContext::Mempool, + ); + let expected = vec![ready.txid, a, b]; + + assert_eq!(replayed_txids(vec![second, first, ready]), expected); + } } From a7ebc2fc57c2af5d00d85f81c5afe0777125537d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:38:57 +0000 Subject: [PATCH 24/26] test(platform-wallet-storage): pin same-block position order in history replay Cover the dependency-aware replay_order for confirmed spends within one block: in-block position keeps its order around a funding/spend pair, and the observed-spent path excludes the spent output whichever record is stored first, with and without stored positions. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/rehydrate.rs | 62 +++++++++++++------ 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index 6a6f2f754d4..0312498ff96 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -3406,9 +3406,9 @@ mod tests { } } - /// Same-height records without a block position: an output the load - /// projection still parks as unspent must end up excluded whichever of - /// funding and spend is stored first. + /// Same-block funding and spend, with and without in-block positions: an + /// output the load projection still parks as unspent must end up excluded, + /// and recorded as observed spent, whichever of the two is stored first. #[tokio::test] async fn should_exclude_spent_output_for_either_same_height_replay_order() { use dashcore::hashes::Hash; @@ -3416,7 +3416,10 @@ mod tests { use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; use key_wallet::Utxo; - for spend_first in [false, true] { + for (spend_first, positioned) in + [(false, false), (true, false), (false, true), (true, true)] + { + let case = format!("spend_first={spend_first} positioned={positioned}"); let mut wallet = Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default) .unwrap(); @@ -3461,24 +3464,27 @@ mod tests { }], special_transaction_payload: None, }; - let context = TransactionContext::InBlock(BlockInfo::new( - 100, - BlockHash::from_byte_array([7; 32]), - 100, - )); + let context = |position: u32| { + let block = BlockInfo::new(100, BlockHash::from_byte_array([7; 32]), 100); + TransactionContext::InBlock(if positioned { + block.with_position(position) + } else { + block + }) + }; let mut records = info - .check_core_transaction(&funding, context.clone(), &mut wallet, true, true) + .check_core_transaction(&funding, context(1), &mut wallet, true, true) .await .new_records; records.extend( - info.check_core_transaction(&spending, context, &mut wallet, true, true) + info.check_core_transaction(&spending, context(2), &mut wallet, true, true) .await .new_records, ); assert_eq!(records.len(), 2); - assert!(records - .iter() - .all(|r| r.block_info().is_some_and(|b| b.position().is_none()))); + assert!(records.iter().all(|r| r + .block_info() + .is_some_and(|b| b.position().is_some() == positioned))); if spend_first { records.reverse(); } @@ -3509,13 +3515,13 @@ mod tests { restore_recorded_transactions(&mut restored, &mut wallet, records, &Default::default()); let coins = &restored.accounts.standard_bip44_accounts[&0].utxos; - assert!(!coins.contains_key(&spent), "spend_first={spend_first}"); - assert!(coins.contains_key(&available), "spend_first={spend_first}"); - assert_eq!( - restored.balance.total(), - 20_000, - "spend_first={spend_first}" + assert!(!coins.contains_key(&spent), "{case}"); + assert!(coins.contains_key(&available), "{case}"); + assert!( + restored.observed_spent_outpoints().contains_key(&spent), + "{case}" ); + assert_eq!(restored.balance.total(), 20_000, "{case}"); } } @@ -3742,6 +3748,22 @@ mod tests { ); } + /// Within one block, in-block position decides: a spend follows the + /// funding transaction it spends, and unrelated transactions keep their + /// place around the pair. + #[test] + fn should_keep_block_position_order_for_same_block_spends() { + use dashcore::hashes::Hash; + + let parent = replay_record(&[Txid::from_byte_array([9; 32])], 10, in_block(20, Some(1))); + let child = replay_record(&[parent.txid], 9, in_block(20, Some(2))); + let before = replay_record(&[Txid::from_byte_array([10; 32])], 8, in_block(20, Some(0))); + let after = replay_record(&[Txid::from_byte_array([11; 32])], 7, in_block(20, Some(3))); + let expected = vec![before.txid, parent.txid, child.txid, after.txid]; + + assert_eq!(replayed_txids(vec![after, child, before, parent]), expected); + } + /// Records that name each other as parents (impossible for real txids) /// still all replay, after everything that is ready. #[test] From c35086d4d6bf790a73d116d2c65a1f78b018e446 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:00:18 +0000 Subject: [PATCH 25/26] fix(platform-wallet-storage): cross-check persisted IS locks before replay upgrade - Load replay upgrades a mempool record to InstantSend only when the persisted lock names that record's txid and the record's transaction really hashes to it; a mismatch is logged and the record replays in its stored context, so a misfiled lock cannot sweep legitimate history. - Track the lack of expiry for unconfirmed spend reservations (TODO(expire-unconfirmed-spend-reservations)). - Document the dependency-ordered history replay. - V019 (unreleased, edited in place): split long SQL literals so rustfmt formats the calls, report UnknownWalletNetwork / NetAmountOverflow like the runtime repair does, and name the shared live helpers in the module doc. SQL text is unchanged; the DDL fingerprint still pins. - Tests: share one V018 rewind helper, add a receive-address wallet fixture, and drop redundant local imports. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/migrations.rs | 10 + .../src/sqlite/migrations/legacy_v019.rs | 50 +++-- .../src/sqlite/rehydrate.rs | 191 ++++++++++++++---- .../src/sqlite/schema/core_state.rs | 13 +- 4 files changed, 193 insertions(+), 71 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs index ddd18caad25..77e76fc1f77 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs @@ -425,6 +425,16 @@ pub fn embedded_migrations_sql() -> Vec { .collect() } +/// Undo V019 so the next [`run`] replays it over the current rows. +#[cfg(test)] +pub(crate) fn rewind_to_v018(conn: &rusqlite::Connection) { + conn.execute_batch( + "DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; \ + DELETE FROM refinery_schema_history WHERE version >= 19;", + ) + .unwrap(); +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs index e59d81f573a..2b3554e3453 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs @@ -2,9 +2,12 @@ //! stored accounting for databases migrating from V018 or earlier. //! //! Frozen on purpose: the live per-round repair in `schema::core_history` may -//! evolve, but V019 must keep doing exactly what it did when it shipped. Only -//! the low-level blob codec is shared; `TransactionRecord`'s encoding is owned -//! upstream (key-wallet) and cannot be frozen here. Do not edit. +//! evolve, but V019 must keep doing exactly what it did when it shipped. It +//! shares only these live helpers, which must stay behaviour-stable: the blob +//! codec and its size/width gates (`blob`), `id32`, `wallets::parse_network`, +//! `i64_to_u64` and the `WalletStorageError` variants it returns. +//! `TransactionRecord`'s encoding is owned upstream (key-wallet) and cannot be +//! frozen here. Do not edit. use std::collections::BTreeMap; @@ -145,8 +148,10 @@ fn network( params![wallet_id.as_slice()], |r| r.get(0), )?; - wallets::parse_network(&label) - .ok_or_else(|| WalletStorageError::blob_decode("wallets.network is unknown")) + wallets::parse_network(&label).ok_or_else(|| WalletStorageError::UnknownWalletNetwork { + wallet_id: *wallet_id, + label, + }) } fn owned_output( @@ -155,7 +160,10 @@ fn owned_output( outpoint: &OutPoint, network: dashcore::Network, ) -> Result, WalletStorageError> { - let mut stmt = tx.prepare_cached("SELECT value, length(script), script FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0")?; + let mut stmt = tx.prepare_cached( + "SELECT value, length(script), script FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0", + )?; let mut rows = stmt.query(params![ wallet_id.as_slice(), blob::encode_outpoint(outpoint)? @@ -180,8 +188,12 @@ fn contact_only_script( script: &[u8], ) -> Result { Ok(conn.query_row( - "SELECT EXISTS(SELECT 1 FROM core_address_pool WHERE wallet_id = ?1 AND script = ?2) AND NOT EXISTS(SELECT 1 FROM core_address_pool WHERE wallet_id = ?1 AND script = ?2 AND account_type != 'dashpay_external')", - params![wallet_id.as_slice(), script], |r| r.get(0))?) + "SELECT EXISTS(SELECT 1 FROM core_address_pool WHERE wallet_id = ?1 AND script = ?2) \ + AND NOT EXISTS(SELECT 1 FROM core_address_pool \ + WHERE wallet_id = ?1 AND script = ?2 AND account_type != 'dashpay_external')", + params![wallet_id.as_slice(), script], + |r| r.get(0), + )?) } fn repair_record( @@ -277,8 +289,11 @@ fn repair_record( .map(|d| i128::from(d.value)) .sum(); let spent: i128 = inputs.values().map(|d| i128::from(d.value)).sum(); - record.net_amount = i64::try_from(received - spent).map_err(|_| { - WalletStorageError::blob_decode("wallet transaction net amount exceeds i64") + let net = received - spent; + record.net_amount = i64::try_from(net).map_err(|_| WalletStorageError::NetAmountOverflow { + wallet_id: *wallet_id, + txid: *txid, + value: net, })?; let has_ours = outputs .values() @@ -334,7 +349,10 @@ pub(super) fn repair_history(tx: &Transaction<'_>) -> Result<(), WalletStorageEr // Keys are collected first so drops never race the open cursor. let mut keys = Vec::new(); { - let mut stmt = tx.prepare_cached("SELECT length(wallet_id), wallet_id, length(txid), txid FROM core_transactions WHERE record_blob IS NOT NULL")?; + let mut stmt = tx.prepare_cached( + "SELECT length(wallet_id), wallet_id, length(txid), txid \ + FROM core_transactions WHERE record_blob IS NOT NULL", + )?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { blob::check_fixed_width(row.get(0)?, 32, "core_transactions.wallet_id")?; @@ -370,6 +388,7 @@ mod tests { use rusqlite::Connection; use super::*; + use crate::sqlite::migrations::rewind_to_v018; use crate::sqlite::schema::core_state; fn address(marker: u8) -> Address { @@ -396,15 +415,6 @@ mod tests { } } - /// Undo V019 so the next `migrations::run` replays it over current rows. - fn rewind_to_v018(conn: &Connection) { - conn.execute_batch( - "DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; \ - DELETE FROM refinery_schema_history WHERE version >= 19;", - ) - .unwrap(); - } - /// An oversize confirmed record fails its length gate before its payload /// is read, and is dropped with a rescan from just below the birth height. #[test] diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index 0312498ff96..56b800c521b 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -267,8 +267,8 @@ pub(crate) fn restore_provider_platform_node_pool( /// among them: it is rebuilt from `core_instant_locks` above, for every /// UTXO a replayed lock covers. /// - **Transaction records**: the SQLite loader replays recorded history -/// (confirmed, then unconfirmed) through the wallet checker after this -/// projection. +/// through the wallet checker after this projection, in dependency order +/// (in-set parents first, otherwise chain order). /// /// # Errors /// @@ -453,6 +453,11 @@ pub(crate) fn restore_recorded_transactions( // TODO(bound-load-history-replay): every stored record is replayed on each // load; bounding it to records above the last chain lock needs care so // finality and spend guards for older records are not lost. + // TODO(expire-unconfirmed-spend-reservations): mempool records are replayed + // on every load with no expiry, so a forged or never-mined spend of a wallet + // outpoint keeps its reservation across load and rescan; only a conflicting + // IS-locked or confirmed spend releases it. Sibling of + // TODO(release-repair-spends-after-reorg) in `core_history`. let replay = replay_order(records); // Kept only to undo a replay the checker suspended part-way through. @@ -462,7 +467,10 @@ pub(crate) fn restore_recorded_transactions( // The lock set already holds this txid (load marked the restored // UTXOs), so a later lock event is deduplicated: the InstantSend // context, and the conflict sweep it runs, must come from here. - let context = match (record.context, instant_locks.get(&record.txid)) { + let lock = instant_locks + .get(&record.txid) + .filter(|lock| lock_matches_record(lock, &record)); + let context = match (record.context, lock) { (TransactionContext::Mempool, Some(lock)) => { TransactionContext::InstantSend(lock.clone()) } @@ -524,6 +532,26 @@ pub(crate) fn restore_recorded_transactions( wallet_info.update_balance(); } +/// Whether `lock` really locks `record`, so upgrading its context is safe. +/// +/// The lock map is keyed by the stored `txid` column and a record's `txid` is +/// stored beside its transaction, so neither is proof on its own. A mismatch +/// keeps the record's stored context: the lock's conflict sweep must not drop +/// history on the strength of a lock that belongs to another transaction. +fn lock_matches_record(lock: &InstantLock, record: &TransactionRecord) -> bool { + let transaction_txid = record.transaction.txid(); + let matches = lock.txid == record.txid && transaction_txid == record.txid; + if !matches { + tracing::warn!( + record_txid = %record.txid, + transaction_txid = %transaction_txid, + lock_txid = %lock.txid, + "persisted InstantSend lock does not match its transaction record; replaying without it" + ); + } + matches +} + /// Poll `future` once, returning its output only if it completed without suspending. /// /// The wallet checker is `async` only by trait shape: it never awaits, so it @@ -3348,19 +3376,9 @@ mod tests { ); } - /// Load replays history without an async runtime by polling the checker - /// once. If upstream ever makes it suspend, this fails in CI instead of - /// load silently skipping the restored spend guards in production. - #[test] - fn should_complete_transaction_checker_on_first_poll() { - use dashcore::hashes::Hash; - use dashcore::{OutPoint, Transaction, TxIn, TxOut, Txid}; - use key_wallet::transaction_checking::{ - BlockInfo, TransactionContext, WalletTransactionChecker, - }; - use key_wallet::wallet::initialization::WalletAccountCreationOptions; - - let mut wallet = + /// A fresh random wallet and its first BIP44 receive address. + fn wallet_with_receive_address() -> (Wallet, ManagedWalletInfo, dashcore::Address) { + let wallet = Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; @@ -3371,6 +3389,19 @@ mod tests { .unwrap() .next_receive_address(Some(&xpub), true) .unwrap(); + (wallet, info, address) + } + + /// Load replays history without an async runtime by polling the checker + /// once. If upstream ever makes it suspend, this fails in CI instead of + /// load silently skipping the restored spend guards in production. + #[test] + fn should_complete_transaction_checker_on_first_poll() { + use dashcore::hashes::Hash; + use dashcore::{Transaction, TxIn, TxOut}; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, mut info, address) = wallet_with_receive_address(); let funding = Transaction { version: 1, lock_time: 0, @@ -3412,26 +3443,15 @@ mod tests { #[tokio::test] async fn should_exclude_spent_output_for_either_same_height_replay_order() { use dashcore::hashes::Hash; - use dashcore::{BlockHash, Transaction, TxIn, TxOut, Txid}; - use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::transaction_checking::BlockInfo; use key_wallet::Utxo; for (spend_first, positioned) in [(false, false), (true, false), (false, true), (true, true)] { let case = format!("spend_first={spend_first} positioned={positioned}"); - let mut wallet = - Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default) - .unwrap(); - let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); - let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; - let address = info - .accounts - .standard_bip44_accounts - .get_mut(&0) - .unwrap() - .next_receive_address(Some(&xpub), true) - .unwrap(); + let (mut wallet, mut info, address) = wallet_with_receive_address(); let funding = Transaction { version: 1, lock_time: 0, @@ -3536,17 +3556,7 @@ mod tests { use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::transaction_checking::BlockInfo; - let mut wallet = - Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); - let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); - let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; - let address = info - .accounts - .standard_bip44_accounts - .get_mut(&0) - .unwrap() - .next_receive_address(Some(&xpub), true) - .unwrap(); + let (mut wallet, mut info, address) = wallet_with_receive_address(); let funding = Transaction { version: 1, lock_time: 0, @@ -3637,6 +3647,105 @@ mod tests { ); } + /// A persisted lock is trusted only when it names the record it is keyed + /// under and that record's transaction really has that txid; otherwise the + /// record replays in its stored mempool context and no sweep runs. + #[tokio::test] + async fn should_not_upgrade_record_to_instant_send_with_mismatched_lock() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([23; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 100_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let spend = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(funding.txid(), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 99_000, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let block = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([8; 32]), + 100, + )); + let mut records = info + .check_core_transaction(&funding, block, &mut wallet, true, true) + .await + .new_records; + records.extend( + info.check_core_transaction( + &spend, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + assert_eq!(records.len(), 2); + let foreign = Txid::from_byte_array([24; 32]); + let lock_for = |txid| InstantLock { + inputs: vec![OutPoint::new(funding.txid(), 0)], + txid, + ..Default::default() + }; + let mut forged_record = records.clone(); + forged_record + .iter_mut() + .find(|record| record.txid == spend.txid()) + .unwrap() + .txid = foreign; + let cases = [ + // The lock row is keyed under the record but locks another txid. + ( + "lock txid", + records.clone(), + spend.txid(), + lock_for(foreign), + ), + // Record and lock agree, but the record's transaction is another one. + ("record txid", forged_record, foreign, lock_for(foreign)), + ]; + for (case, records, key, lock) in cases { + let locks: BTreeMap = [(key, lock)].into_iter().collect(); + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + restore_recorded_transactions(&mut restored, &mut wallet, records, &locks); + let transactions = restored.accounts.standard_bip44_accounts[&0].transactions(); + assert!( + !transactions + .values() + .any(|record| matches!(record.context, TransactionContext::InstantSend(_))), + "{case}: a mismatched lock must not upgrade any record" + ); + assert!( + transactions.contains_key(&spend.txid()), + "{case}: the spend must still replay in its stored context" + ); + } + } + /// A record spending `parents` (output 0 of each); `value` keeps txids distinct. fn replay_record( parents: &[Txid], 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 47a2eab92f3..a9ee0597b90 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 @@ -1367,6 +1367,7 @@ pub fn list_unspent_utxos( #[cfg(test)] mod tests { use super::*; + use crate::sqlite::migrations::rewind_to_v018; use dashcore::address::Payload; use dashcore::hashes::Hash; use dashcore::{BlockHash, OutPoint, PubkeyHash, Transaction, TxOut, Txid}; @@ -1652,11 +1653,7 @@ mod tests { fn v018_with_corrupt_record(height: Option) -> (Connection, [u8; 32]) { let mut conn = Connection::open_in_memory().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); - conn.execute_batch( - "DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; \ - DELETE FROM refinery_schema_history WHERE version >= 19;", - ) - .unwrap(); + rewind_to_v018(&conn); let wallet_id = [0xADu8; 32]; conn.execute( "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 100)", @@ -1991,11 +1988,7 @@ mod tests { crate::sqlite::migrations::run(&mut conn).unwrap(); let wallet_id = [0xB2u8; 32]; let (contact, own) = stage_contact_only_utxo(&conn, &wallet_id); - conn.execute_batch( - "DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; \ - DELETE FROM refinery_schema_history WHERE version >= 19;", - ) - .unwrap(); + rewind_to_v018(&conn); crate::sqlite::migrations::run(&mut conn).unwrap(); let unspent_rows = |outpoint: &OutPoint| -> i64 { conn.query_row( From 2a98ef887bf7d281759465bec206cd3ddcef431b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:00:18 +0000 Subject: [PATCH 26/26] fix(swift-sdk): reset accounting dirty set per round and tidy direction codes - Clear accountingDirty at beginChangeset and after an out-of-round save rollback, so a round never reconciles rows staged (or rolled back) by out-of-round writers; load-time accounting repair covers those rows. - Use CoreDirectionCode in the direction case table test and the storage explorer's direction filter. - Move TransactionDirectionStyle to its own file (folder-synced project, no pbxproj change). - Say "amount unavailable" for unresolved asset-lock amounts, matching PersistentTransaction.formattedAmount; fix the assetLocks query doc. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.swift | 7 ++++ .../Core/Views/TransactionDetailView.swift | 2 +- .../Views/TransactionDirectionStyle.swift | 25 ++++++++++++++ .../Core/Views/TransactionListView.swift | 34 +++---------------- .../Views/StorageModelListViews.swift | 8 ++--- .../TransactionAccountingTests.swift | 20 ++++++----- 6 files changed, 53 insertions(+), 43 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 22a181a0c61..0de8973282d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -419,6 +419,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { error: error ) backgroundContext.rollback() + // Rows staged by the failed save are gone; never reconcile them. + accountingDirty.removeAll() } } @@ -3300,6 +3302,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { self.inChangeset = true self.roundUtxoCreditVerdicts = [:] self.roundUtxoCreditTally = UtxoCreditVerdictTally() + // Out-of-round writers (heal paths, deferred backfills) stage + // entries too; they are not this round's to reconcile and may + // point at rows a failed save rolled back. Load-time accounting + // repairs whatever they touched. + self.accountingDirty.removeAll() SDKLogger.event( "persistence_changeset_started", category: .persistence, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift index 64c238a2f0d..ba2092b1f68 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift @@ -20,7 +20,7 @@ struct TransactionDetailView: View { let dash = Double(duffs) / 100_000_000.0 return String(format: "-%.8f DASH", dash) } - return "Asset Lock (amount unknown)" + return "Asset Lock (amount unavailable)" } if transaction.isProviderSpecial && netAmount == 0 { return nil diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift new file mode 100644 index 00000000000..589aa6bb7f0 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift @@ -0,0 +1,25 @@ +import SwiftUI +import SwiftDashSDK + +/// Direction icon and colour shared by the transaction list and detail views. +enum TransactionDirectionStyle { + static func icon(for direction: UInt32) -> String { + switch direction { + case CoreDirectionCode.incoming: return "arrow.down.circle.fill" + case CoreDirectionCode.outgoing: return "arrow.up.circle.fill" + case CoreDirectionCode.internalTransfer: return "arrow.triangle.2.circlepath" + case CoreDirectionCode.coinJoin: return "shuffle.circle.fill" + default: return "questionmark.circle" + } + } + + /// Internal transfers share the outgoing colour: they still pay a fee. + static func color(for direction: UInt32) -> Color { + switch direction { + case CoreDirectionCode.incoming: return .green + case CoreDirectionCode.outgoing, CoreDirectionCode.internalTransfer: return .red + case CoreDirectionCode.coinJoin: return .blue + default: return .secondary + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift index fbd83dfe157..5d84aaca7e4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift @@ -29,10 +29,9 @@ struct TransactionListView: View { @Query private var walletAccounts: [PersistentAccount] @Query private var transactionObservation: [PersistentTransaction] /// Per-wallet asset-lock rows. Used to look up the *locked* amount - /// for each asset-lock tx — `PersistentTransaction.netAmount` is - /// the wallet's input-vs-output diff, which sees the credit - /// output as "to-self" and reports ~0 for asset locks. The - /// `amountDuffs` on the asset-lock row is the actual L1 burn. + /// for each asset-lock tx: `amountDuffs` on the asset-lock row is the + /// payload funding amount, while `PersistentTransaction.netAmount` is + /// the Core debit, which includes the fee. @Query private var assetLocks: [PersistentAssetLock] /// This wallet's owning identities. The DashPay payment / contact /// join below must be scoped to these — two identities in one store @@ -217,31 +216,6 @@ struct TransactionListView: View { } } -// MARK: - Direction Style - -/// Direction icon and colour shared by the transaction list and detail views. -enum TransactionDirectionStyle { - static func icon(for direction: UInt32) -> String { - switch direction { - case CoreDirectionCode.incoming: return "arrow.down.circle.fill" - case CoreDirectionCode.outgoing: return "arrow.up.circle.fill" - case CoreDirectionCode.internalTransfer: return "arrow.triangle.2.circlepath" - case CoreDirectionCode.coinJoin: return "shuffle.circle.fill" - default: return "questionmark.circle" - } - } - - /// Internal transfers share the outgoing colour: they still pay a fee. - static func color(for direction: UInt32) -> Color { - switch direction { - case CoreDirectionCode.incoming: return .green - case CoreDirectionCode.outgoing, CoreDirectionCode.internalTransfer: return .red - case CoreDirectionCode.coinJoin: return .blue - default: return .secondary - } - } -} - // MARK: - Transaction Row View struct TransactionRowView: View { @@ -439,7 +413,7 @@ struct TransactionRowView: View { let dash = Double(duffs) / 100_000_000.0 return String(format: "-%.8f DASH", dash) } - return "Asset Lock (amount unknown)" + return "Asset Lock (amount unavailable)" } // A payload-only provider special tx moves no wallet balance; // `+0.00000000 DASH` reads as a broken zero-value receive, so diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index b4627318cf0..ec38b7dae0f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -1162,10 +1162,10 @@ struct TransactionStorageListView: View { // Direction. switch directionFilter { case .all: break - case .incoming where record.direction != 0: return false - case .outgoing where record.direction != 1: return false - case .internalTx where record.direction != 2: return false - case .coinjoin where record.direction != 3: return false + case .incoming where record.direction != CoreDirectionCode.incoming: return false + case .outgoing where record.direction != CoreDirectionCode.outgoing: return false + case .internalTx where record.direction != CoreDirectionCode.internalTransfer: return false + case .coinjoin where record.direction != CoreDirectionCode.coinJoin: return false default: break } // Type. Treat the legacy `"Standard"` placeholder (the diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift index f3264cedc53..6018b4ae68d 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -45,15 +45,19 @@ final class TransactionAccountingTests: XCTestCase { func testShouldClassifyDirectionLikeTheRustRepair() { let spent = input(100) // (spends ours, owned output amounts, all outputs owned, asset lock, previous direction, expected) + let (incoming, outgoing, internalTransfer, coinJoin) = ( + CoreDirectionCode.incoming, CoreDirectionCode.outgoing, + CoreDirectionCode.internalTransfer, CoreDirectionCode.coinJoin + ) let cases: [(Bool, [UInt64], Bool, Bool, UInt32, UInt32)] = [ - (true, [99], true, false, 0, 2), - (true, [40], false, false, 0, 1), - (true, [], true, false, 0, 1), - (true, [], true, true, 0, 2), - (true, [40], true, true, 0, 2), - (true, [], false, true, 0, 1), - (false, [40], true, false, 0, 0), - (true, [99], true, false, 3, 3), + (true, [99], true, false, incoming, internalTransfer), + (true, [40], false, false, incoming, outgoing), + (true, [], true, false, incoming, outgoing), + (true, [], true, true, incoming, internalTransfer), + (true, [40], true, true, incoming, internalTransfer), + (true, [], false, true, incoming, outgoing), + (false, [40], true, false, incoming, incoming), + (true, [99], true, false, coinJoin, coinJoin), ] for (index, (spendsOurs, owned, allOwned, isLock, previous, expected)) in cases.enumerated() { let result = PersistentTransaction.reconciledAccounting(