diff --git a/CHANGELOG.md b/CHANGELOG.md index 3738f94cf7c..ee85f2183c2 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.7](https://github.com/dashpay/platform/compare/v4.2.0-beta.6...v4.2.0-beta.7) (2026-09-29) diff --git a/Cargo.lock b/Cargo.lock index a593de4f7f5..bdb7d2c4477 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/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/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs index 2a7a30414b9..2c64509fd18 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -32,12 +32,12 @@ 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 { - /// 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), @@ -422,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. @@ -710,8 +741,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 { .. } @@ -759,6 +789,9 @@ impl WalletStorageError { | Self::AssetLockEntryMismatch { .. } | Self::AssetLockStatusMismatch { .. } | Self::CoreTransactionEntryMismatch { .. } + | Self::TransactionBodyConflict { .. } + | Self::UnknownWalletNetwork { .. } + | Self::NetAmountOverflow { .. } | Self::BlobTooLarge { .. } | Self::IntegerOverflow { .. } | Self::RehydrationPoolMismatch { .. } @@ -804,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 @@ -863,6 +901,7 @@ impl WalletStorageError { | Self::AssetLockEntryMismatch { .. } | Self::AssetLockStatusMismatch { .. } | Self::CoreTransactionEntryMismatch { .. } + | Self::UnknownWalletNetwork { .. } | Self::BlobTooLarge { .. } | Self::IntegerOverflow { .. } | Self::RehydrationPoolMismatch { .. } @@ -876,7 +915,6 @@ impl WalletStorageError { | Self::UnownedIdentityHasRegistrationIndex { .. } | Self::EmptyUtxoScript { .. } | Self::EmptyPoolAddressScript { .. } - | Self::CoreHistoryReplay(_) | Self::DatabasePathIsSymlink { .. } => PersistenceErrorKind::Fatal, } } @@ -897,7 +935,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", @@ -946,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/migrations.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs index e152a68a97a..77e76fc1f77 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; } @@ -424,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 new file mode 100644 index 00000000000..2b3554e3453 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs @@ -0,0 +1,632 @@ +//! 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. 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; + +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, 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; undecodable bytes are an error the caller classifies. +fn read_record( + tx: &Transaction<'_>, + wallet_id: &WalletId, + txid: &Txid, +) -> Result, WalletStorageError> { + 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( + "transaction record names another transaction", + )); + } + Ok(Some(record)) +} + +/// 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 { .. } + ) +} + +/// 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, + error: WalletStorageError, +) -> Result<(), WalletStorageError> { + 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. +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::UnknownWalletNetwork { + wallet_id: *wallet_id, + label, + }) +} + +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, + mut record: TransactionRecord, + network: dashcore::Network, +) -> Result<(), WalletStorageError> { + 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(..) { + 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(); + 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() + .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 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()? { + 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 { + 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(()) +} + +#[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::migrations::rewind_to_v018; + 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, + } + } + + /// 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] + 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(); + rewind_to_v018(&tx); + 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/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 28623c535e2..546bc42a13c 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; @@ -1849,9 +1850,13 @@ fn load_one_wallet( )) })?; } - let (wallet, wallet_info) = - super::rehydrate::restore_confirmed_transactions(wallet_info, wallet, core_state.records) - .map_err(PersistenceError::from)?; + let mut wallet = wallet; + 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, @@ -2418,6 +2423,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 +2467,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/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index db27b6106ce..56b800c521b 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -4,14 +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::transaction_checking::WalletTransactionChecker; +use key_wallet::managed_account::ManagedCoreFundsAccount; +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; @@ -264,8 +266,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 +/// through the wallet checker after this projection, in dependency order +/// (in-set parents first, otherwise chain order). /// /// # Errors /// @@ -420,12 +423,19 @@ 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( - mut wallet_info: ManagedWalletInfo, - mut wallet: Wallet, +/// 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. +/// `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, -) -> Result<(Wallet, ManagedWalletInfo), WalletStorageError> { + instant_locks: &BTreeMap, +) { // Where the load projection parked each unspent outpoint; its keys are // the outputs persistence still considers unspent. let placed: HashMap = wallet_info @@ -437,78 +447,189 @@ 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() { - return Ok((wallet, wallet_info)); + if records.is_empty() { + return; } - confirmed.sort_by_key(|record| { - record - .block_info() - .map(|block| (block.height(), block.position())) - }); - - // The checker only mutates in-memory state; no network requests or persistence. - dash_async::block_on(async move { - for record in confirmed { + // 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. + 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 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()) + } + (context, _) => context, + }; wallet_info - .check_core_transaction( - &record.transaction, - record.context, - &mut wallet, - true, - false, - ) + .check_core_transaction(&record.transaction, 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)) - }) + 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 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(); +} + +/// 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 +/// 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: 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 records = records; + records.sort_by_key(|record| { + let block = record.block_info(); + ( + block.is_none(), + block.map(|block| (block.height(), block.position())), + record.txid, + ) + }); + 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(); - 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)) - }); + waiting_on[child] = parents.len(); + for parent in parents { + children[parent].push(child); } - // 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); + } + // 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); + } } - wallet_info.update_balance(); - (wallet, wallet_info) - }) - .map_err(WalletStorageError::CoreHistoryReplay) + } + // 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. -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() } @@ -548,9 +669,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); @@ -626,7 +745,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], @@ -3256,4 +3375,525 @@ mod tests { "the restored UTXO must carry instant-locked status, not wait for the next sync" ); } + + /// 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; + let address = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .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, + 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" + ); + } + } + + /// 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; + 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, 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([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 = |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(1), &mut wallet, true, true) + .await + .new_records; + records.extend( + 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_some() == positioned))); + 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, &Default::default()); + + let coins = &restored.accounts.standard_bip44_accounts[&0].utxos; + 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}"); + } + } + + /// 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, 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([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 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], + 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 + ); + } + + /// 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] + 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); + } } 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 dd7081c4668..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 @@ -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; @@ -24,15 +25,14 @@ 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", - )); + return Err(WalletStorageError::TransactionBodyConflict { + wallet_id: *wallet_id, + txid: incoming.txid, + }); } let mut inputs: BTreeMap<_, _> = previous .input_details @@ -61,6 +61,15 @@ pub(super) fn preserve_known_details( Ok(merged) } +/// Read a stored record strictly: corrupt history is an error, never skipped. +fn prior_record( + conn: &Connection, + wallet_id: &WalletId, + txid: &Txid, +) -> Result, WalletStorageError> { + core_state::get_tx_record(conn, wallet_id, txid, &LoadCtx::strict()) +} + /// Index raw inputs independently of when their ownership becomes known. pub(super) fn index_record( tx: &Transaction<'_>, @@ -68,7 +77,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![ @@ -120,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( @@ -130,7 +142,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)? @@ -155,8 +170,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( @@ -165,8 +184,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)?; @@ -190,11 +208,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() ], )?; } @@ -248,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() @@ -268,19 +299,24 @@ 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)?; 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![ @@ -293,22 +329,192 @@ fn repair_record( Ok(()) } -/// 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)?)?; +/// 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 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() { + 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}" + ); } } - 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..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 @@ -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())? { @@ -1366,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}; @@ -1510,6 +1512,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 +1599,12 @@ 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,21 +1634,54 @@ 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_roll_back_history_migration_on_corrupt_record() { + /// 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; 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', 0)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 100)", + params![&wallet_id[..]], + ) + .unwrap(); + 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, finalized, record_blob) VALUES (?1, ?2, 0, ?3)", params![&wallet_id[..], &[0u8;32][..], &[0xffu8][..]]).unwrap(); - assert!(crate::sqlite::migrations::run(&mut conn).is_err()); + 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'", @@ -1631,8 +1701,96 @@ mod tests { } #[test] - fn should_keep_uncredited_outputs_spent() { - use platform_wallet::changeset::changeset::UtxoCreditVerdict; + 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 last_processed_height, synced_height FROM core_sync_state \ + WHERE wallet_id = ?1", + params![&wallet_id[..]], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + 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_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]; + 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(); + assert!( + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![record], + ..Default::default() + }, + ) + .is_err(), + "normal operation treats corrupt stored history as an error" + ); + } + + #[test] + fn should_mark_observed_spent_and_doomed_outputs_spent() { let mut conn = Connection::open_in_memory().unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); let wallet_id = [0xAEu8; 32]; @@ -1669,6 +1827,59 @@ mod tests { } } + #[test] + fn should_keep_prior_spent_flag_for_uncredited_outputs() { + 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}; @@ -1692,7 +1903,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, @@ -1771,14 +1988,12 @@ 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; 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( - "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), ) 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 935b204a2bc..6884ea5dca3 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, @@ -351,7 +364,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"), }, @@ -364,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. @@ -438,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,8 +509,11 @@ 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"), + // `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"), } } 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 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..e296482379c 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] @@ -458,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); +} 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/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 0fe3efe8f2e..e1d43179cc1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -238,11 +238,11 @@ 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) } - 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 } @@ -254,20 +254,45 @@ 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 != 3, transactionTypeKind != 1, !isAssetLock else { return direction } + guard owningWalletIds.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. 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( @@ -287,20 +312,24 @@ 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 { 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) } 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" } } @@ -404,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 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 354e2cf32e8..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() } } @@ -2547,8 +2549,10 @@ 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. - let preserveLockAccounting = tx.transaction_type_kind == 6 && tx.net_amount == 0 && !tx.has_fee - && record.netAmount != 0 && record.inputs.contains(where: Self.isWalletOwnedTxo) + // A stored debit is itself the proof we funded it: its inputs may not be linked yet. + 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 { record.transactionType = String(cString: typeName) @@ -3298,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, @@ -3427,8 +3436,21 @@ 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. + // 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 { + 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( @@ -6810,7 +6832,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 @@ -6837,16 +6860,22 @@ 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 } + if output.isOpReturn { continue } var belongs = ownedVouts.contains(UInt32(index)) if !belongs, let address = output.address { 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 { + if let account = owner?.account, account.accountType != Self.dashpayExternalAccountTypeTag, + spendingWallets.contains(account.wallet.walletId) { belongs = true } } @@ -6855,7 +6884,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 @@ -6912,6 +6942,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)) @@ -6923,15 +6957,25 @@ 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, + // 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() } 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/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift index 37bd50aedad..ba2092b1f68 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift @@ -4,8 +4,10 @@ import SwiftDashSDK struct TransactionDetailView: View { let transaction: PersistentTransaction var walletId: Data? = nil - private var netAmount: Int64 { walletId.flatMap { transaction.netAmount(for: $0) } ?? transaction.netAmount } - private var direction: UInt32 { walletId.map { transaction.direction(for: $0) } ?? transaction.direction } + /// `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? { 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 @@ -18,12 +20,12 @@ 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 } - return walletId.map { transaction.formattedAmount(for: $0) } ?? transaction.formattedAmount + return transaction.displayFormattedAmount(for: walletId) } private var typeDescription: String { @@ -34,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" @@ -49,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 { @@ -66,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 { @@ -201,7 +189,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/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 4e0033ff220..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 @@ -222,8 +221,10 @@ 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 } - private var direction: UInt32 { walletId.map { transaction.direction(for: $0) } ?? transaction.direction } + /// `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? { 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 @@ -253,14 +254,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 { @@ -278,12 +272,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 @@ -400,7 +389,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) @@ -424,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 @@ -434,6 +423,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/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 ccde14865f4..6018b4ae68d 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,40 @@ 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 (incoming, outgoing, internalTransfer, coinJoin) = ( + CoreDirectionCode.incoming, CoreDirectionCode.outgoing, + CoreDirectionCode.internalTransfer, CoreDirectionCode.coinJoin + ) + let cases: [(Bool, [UInt64], Bool, Bool, UInt32, UInt32)] = [ + (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( + inputs: spendsOurs ? [spent] : [], ownedOutputAmounts: owned, + allOutputsOwned: allOwned, previousDirection: previous, isAssetLock: isLock + ) + XCTAssertEqual(result?.direction, expected, "case \(index)") + } + } + + 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 @@ -54,7 +88,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) @@ -62,7 +97,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 } @@ -139,6 +175,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 @@ -242,7 +290,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) @@ -258,6 +306,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) @@ -267,13 +335,65 @@ 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) 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