diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs index 42fee7af8c8..1484ce781e9 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -418,6 +418,16 @@ pub enum WalletStorageError { blob_height: Option, }, + /// Aliases of one canonical transaction disagree about their confirmed block. + #[error( + "history aliases for {txid} in wallet {} have conflicting contexts", + hex::encode(wallet_id) + )] + HistoryAliasContextConflict { + wallet_id: [u8; 32], + txid: dashcore::Txid, + }, + /// An incoming transaction record reuses a stored txid with a body whose /// txid-committed content differs (witness-only differences are not a /// conflict); neither copy is trusted to replace the other. @@ -786,6 +796,7 @@ impl WalletStorageError { | Self::AssetLockEntryMismatch { .. } | Self::AssetLockStatusMismatch { .. } | Self::CoreTransactionEntryMismatch { .. } + | Self::HistoryAliasContextConflict { .. } | Self::TransactionBodyConflict { .. } | Self::UnknownWalletNetwork { .. } | Self::NetAmountOverflow { .. } @@ -836,9 +847,9 @@ impl WalletStorageError { 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 - } + Self::HistoryAliasContextConflict { .. } + | 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 @@ -980,6 +991,7 @@ impl WalletStorageError { Self::AssetLockEntryMismatch { .. } => "asset_lock_entry_mismatch", Self::AssetLockStatusMismatch { .. } => "asset_lock_status_mismatch", Self::CoreTransactionEntryMismatch { .. } => "core_transaction_entry_mismatch", + Self::HistoryAliasContextConflict { .. } => "history_alias_context_conflict", Self::TransactionBodyConflict { .. } => "transaction_body_conflict", Self::UnknownWalletNetwork { .. } => "unknown_wallet_network", Self::NetAmountOverflow { .. } => "net_amount_overflow", diff --git a/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs b/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs index 025b15007ef..f37ae520a5f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs @@ -329,6 +329,12 @@ impl LoadCtx { } } + /// Recheck an already-counted snapshot under the same policy without + /// adding its inconsistencies to the load's original tally again. + pub(crate) fn for_recheck(&self) -> Self { + Self::new(self.policy) + } + /// Context that aborts the load on any inconsistency. A production /// load takes its policy from the config; this is the shorthand for a /// caller driving a reader directly. diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 5e4d971170d..bfabc8965e8 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1620,13 +1620,17 @@ impl PlatformWalletPersistence for SqlitePersister { Option, PersistenceError, > { - let conn = self.conn().map_err(PersistenceError::from)?; + let mut conn = self.conn().map_err(PersistenceError::from)?; // Tally deliberately dropped — see `last_load_degradation`. The // policy still decides whether drift is fatal, and `tolerate` still // logs it. let ctx = LoadCtx::new(self.config.load_policy); - schema::core_state::get_tx_record(&conn, &wallet_id, txid, &ctx) - .map_err(PersistenceError::from) + // Alias evidence and its active canonical record must belong to one + // SQLite snapshot, including when another connection writes the file. + let tx = conn.transaction().map_err(WalletStorageError::from)?; + let record = schema::core_history_aliases::get_record(&tx, &wallet_id, txid, &ctx)?; + tx.commit().map_err(WalletStorageError::from)?; + Ok(record) } /// Served from the `dpns_name_states` table this persister already @@ -1720,9 +1724,11 @@ fn load_wallet_snapshot( let account_manifest = schema::accounts::load_state(conn, &wallet_id, ctx).map_err(PersistenceError::from)?; - let (core_state, utxo_accounts) = + let (mut core_state, utxo_accounts) = schema::core_state::load_state(conn, &wallet_id, network, ctx) .map_err(PersistenceError::from)?; + schema::core_history_aliases::project_records(conn, &wallet_id, &mut core_state.records, ctx) + .map_err(PersistenceError::from)?; // Pre-keyed rehydration: each `ManagedIdentity` leaves the loader // already carrying its own public keys + contact state (matching // the FFI persister), so signing works immediately post-load 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 d3279b72e33..8d4219fab61 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,7 +12,7 @@ use platform_wallet::wallet::platform_wallet::WalletId; use rusqlite::{params, Connection, OptionalExtension, Transaction}; use super::accounts::DASHPAY_EXTERNAL_LABEL; -use super::{blob, core_state, wallets}; +use super::{blob, core_history_aliases, core_state, wallets}; use crate::sqlite::error::WalletStorageError; use crate::sqlite::load_ctx::LoadCtx; use crate::sqlite::util::safe_cast::i64_to_u64; @@ -23,10 +23,20 @@ pub(super) fn preserve_known_details( wallet_id: &WalletId, incoming: &TransactionRecord, ) -> Result { - let mut merged = incoming.clone(); + let merged = incoming.clone(); let Some(previous) = prior_record(tx, wallet_id, &incoming.txid)? else { return Ok(merged); }; + merge_known_details(wallet_id, &previous, incoming) +} + +/// Merge accounting evidence without assigning identity or finality policy. +pub(super) fn merge_known_details( + wallet_id: &WalletId, + previous: &TransactionRecord, + incoming: &TransactionRecord, +) -> Result { + let mut merged = incoming.clone(); // Compare only txid-committed content: a peer may attach BIP144 witnesses // to a known txid, and that must neither conflict nor replace the stored body. if previous.transaction.txid() != incoming.transaction.txid() { @@ -35,10 +45,11 @@ pub(super) fn preserve_known_details( txid: incoming.txid, }); } - merged.transaction = previous.transaction; + merged.transaction = previous.transaction.clone(); let mut inputs: BTreeMap<_, _> = previous .input_details - .into_iter() + .iter() + .cloned() .map(|d| (d.index, d)) .collect(); for detail in &incoming.input_details { @@ -46,7 +57,8 @@ pub(super) fn preserve_known_details( } let mut outputs: BTreeMap<_, _> = previous .output_details - .into_iter() + .iter() + .cloned() .map(|d| (d.index, d)) .collect(); for detail in &incoming.output_details { @@ -102,15 +114,39 @@ pub(crate) fn apply( let mut consumers = tx.prepare_cached( "SELECT txid FROM core_transaction_inputs WHERE wallet_id = ?1 AND outpoint = ?2", )?; - for utxo in cs.new_utxos.iter().chain(&cs.spent_utxos) { - affected.insert(utxo.outpoint.txid); - let mut rows = consumers.query(params![ - wallet_id.as_slice(), - blob::encode_outpoint(&utxo.outpoint)? - ])?; - while let Some(row) = rows.next()? { - let bytes: Vec = row.get(0)?; - affected.insert(Txid::from_slice(&bytes)?); + let mut funding: Vec<_> = cs + .new_utxos + .iter() + .chain(&cs.spent_utxos) + .map(|utxo| utxo.outpoint) + .collect(); + // Adoption can connect already-stored coins to consumers without a new + // coin delivery. Ordinary records keep their existing scheduling policy. + for record in cs + .records + .iter() + .filter(|record| core_history_aliases::is_unlock(record)) + { + funding.extend( + record + .transaction + .output + .iter() + .enumerate() + .map(|(index, _)| OutPoint::new(record.txid, index as u32)), + ); + } + for changed in funding { + for outpoint in core_history_aliases::outpoints(tx, wallet_id, &changed)? { + affected.insert(outpoint.txid); + let mut rows = consumers.query(params![ + wallet_id.as_slice(), + blob::encode_outpoint(&outpoint)? + ])?; + while let Some(row) = rows.next()? { + let bytes: Vec = row.get(0)?; + affected.insert(Txid::from_slice(&bytes)?); + } } } if affected.is_empty() { @@ -143,6 +179,46 @@ fn owned_output( wallet_id: &WalletId, outpoint: &OutPoint, network: dashcore::Network, +) -> Result, WalletStorageError> { + // Exact physical evidence keeps its existing semantics. Only a missing + // exact coin can use a validated active history family's equivalent key. + if let Some(output) = physical_owned_output(tx, wallet_id, outpoint, network)? { + return Ok(Some(output)); + } + let equivalents = core_history_aliases::outpoints(tx, wallet_id, outpoint)?; + for alternative in equivalents.into_iter().filter(|other| other != outpoint) { + if let Some((value, address)) = physical_owned_output(tx, wallet_id, &alternative, network)? + { + let record = core_history_aliases::get_record( + tx, + wallet_id, + &outpoint.txid, + &LoadCtx::strict(), + )? + .ok_or_else(|| { + WalletStorageError::blob_decode( + "active history alias disappeared during accounting", + ) + })?; + let output = record.transaction.output.get(outpoint.vout as usize); + if !output.is_some_and(|output| { + output.value == value && output.script_pubkey == address.script_pubkey() + }) { + return Err(WalletStorageError::blob_decode( + "alias accounting output disagrees with canonical transaction body", + )); + } + return Ok(Some((value, address))); + } + } + Ok(None) +} + +fn physical_owned_output( + tx: &Transaction<'_>, + wallet_id: &WalletId, + outpoint: &OutPoint, + network: dashcore::Network, ) -> Result, WalletStorageError> { let encoded = blob::encode_outpoint(outpoint)?; let key = params![wallet_id.as_slice(), encoded]; @@ -264,23 +340,9 @@ fn repair_record( } record.input_details = inputs.into_values().collect(); record.output_details = outputs.into_values().collect(); - // The live projection computes the same accounting, so repair never - // flips a row it just wrote; only the overflow policy differs. - let (net, direction) = wallet_accounting(&record); - record.net_amount = match i64::try_from(net) { - Ok(net) => net, - // Nothing authenticates a mempool transaction: this is peer-supplied data, - // not corruption. Keep its stored accounting until representable evidence arrives. - Err(_) if matches!(record.context, TransactionContext::Mempool) => return Ok(()), - Err(_) => { - return Err(WalletStorageError::NetAmountOverflow { - wallet_id: *wallet_id, - txid: *txid, - value: net, - }) - } - }; - record.direction = direction; + if !recompute_accounting(wallet_id, &mut record)? { + return Ok(()); + } let repaired = blob::encode(&record)?; if repaired != original { // Append-only: the first pre-repair blob is kept verbatim and never @@ -303,6 +365,35 @@ fn repair_record( Ok(()) } +/// Apply the existing history accounting policy to a merged snapshot. Returns +/// false when absent or unrepresentable mempool evidence cannot replace it. +pub(super) fn recompute_accounting( + wallet_id: &WalletId, + record: &mut TransactionRecord, +) -> Result { + if record.input_details.is_empty() && record.output_details.is_empty() { + return Ok(false); + } + // The live projection computes the same accounting, so repair never + // flips a row it just wrote; only the overflow policy differs. + let (net, direction) = wallet_accounting(record); + record.net_amount = match i64::try_from(net) { + Ok(net) => net, + // Nothing authenticates a mempool transaction: this is peer-supplied data, + // not corruption. Keep its stored accounting until representable evidence arrives. + Err(_) if matches!(record.context, TransactionContext::Mempool) => return Ok(false), + Err(_) => { + return Err(WalletStorageError::NetAmountOverflow { + wallet_id: *wallet_id, + txid: record.txid, + value: net, + }) + } + }; + record.direction = direction; + Ok(true) +} + #[cfg(test)] mod tests { use dashcore::TxOut; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history_aliases.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history_aliases.rs new file mode 100644 index 00000000000..d11e3103933 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history_aliases.rs @@ -0,0 +1,369 @@ +//! Historical AssetUnlock identities, confined to history projection and accounting. +//! +//! The original-record archive proves identity only: it never supplies ownership, +//! context or a resurrected record. An active canonical record is required. No +//! financial outpoint, claimant, proof or confirmation column is rewritten here. + +use std::collections::BTreeSet; + +use dashcore::transaction::special_transaction::TransactionPayload; +use dashcore::{OutPoint, Txid}; +use key_wallet::managed_account::transaction_record::TransactionRecord; +use platform_wallet::changeset::most_advanced_transaction_context; +use platform_wallet::wallet::platform_wallet::WalletId; +use rusqlite::{params, Connection, OptionalExtension, Transaction}; + +use super::{blob, core_history, core_state}; +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::load_ctx::LoadCtx; + +pub(super) fn is_unlock(record: &TransactionRecord) -> bool { + (record.transaction.version as i16) >= 3 + && matches!(&record.transaction.special_transaction_payload, + Some(TransactionPayload::AssetUnlockPayloadType(payload)) if payload.base.version >= 2) +} + +fn validate(key: &Txid, payload: &[u8]) -> Result { + let record = core_state::decode_transaction_record(payload)?; + if record.txid != *key { + return Err(WalletStorageError::blob_decode( + "historical identity evidence disagrees with its physical key", + )); + } + Ok(record) +} + +fn archived( + conn: &Connection, + wallet: &WalletId, + key: &Txid, +) -> Result, WalletStorageError> { + let len: Option = conn.query_row( + "SELECT length(record_blob) FROM core_transaction_record_originals WHERE wallet_id = ?1 AND txid = ?2", + params![wallet.as_slice(), key.as_byte_array().as_slice()], |row| row.get(0), + ).optional()?; + let Some(len) = len else { return Ok(None) }; + blob::check_size(len)?; + let bytes: Vec = conn.query_row( + "SELECT record_blob FROM core_transaction_record_originals WHERE wallet_id = ?1 AND txid = ?2", + params![wallet.as_slice(), key.as_byte_array().as_slice()], |row| row.get(0), + )?; + validate(key, &bytes).map(Some) +} + +struct Family { + record: TransactionRecord, + aliases: BTreeSet, + physical_aliases: Vec, +} + +impl Family { + /// Exceptional-path wallet scan: no schema change or unauthenticated alias + /// table. Current blobs supply metadata; archived blobs supply only IDs. + fn load( + conn: &Connection, + wallet: &WalletId, + canonical: &TransactionRecord, + ctx: &LoadCtx, + ) -> Result { + let canonical_id = canonical.transaction.txid(); + let mut family = Self { + record: canonical.clone(), + aliases: BTreeSet::from([canonical_id]), + physical_aliases: Vec::new(), + }; + // Query lengths first; no blob is materialized before its size gate. + let mut stmt = conn.prepare_cached( + "SELECT length(txid), txid, length(record_blob) FROM core_transactions WHERE wallet_id = ?1 AND record_blob IS NOT NULL ORDER BY txid", + )?; + let mut rows = stmt.query(params![wallet.as_slice()])?; + while let Some(row) = rows.next()? { + blob::check_fixed_width(row.get(0)?, 32, "core_transactions.txid")?; + let key = Txid::from_slice(&row.get::<_, Vec>(1)?)?; + blob::check_size(row.get(2)?)?; + if key == canonical_id { + continue; + } + // The physical reader validates the body, key and context columns; + // public alias projection must never recursively call itself here. + let Some(record) = core_state::get_tx_record(conn, wallet, &key, ctx)? else { + continue; + }; + if is_unlock(&record) && record.transaction.txid() == canonical_id { + family.merge(wallet, &record)?; + family.aliases.insert(key); + family.physical_aliases.push(key); + } + } + let mut stmt = conn.prepare_cached( + "SELECT length(txid), txid FROM core_transaction_record_originals WHERE wallet_id = ?1 ORDER BY txid", + )?; + let mut rows = stmt.query(params![wallet.as_slice()])?; + while let Some(row) = rows.next()? { + blob::check_fixed_width(row.get(0)?, 32, "core_transaction_record_originals.txid")?; + let key = Txid::from_slice(&row.get::<_, Vec>(1)?)?; + let Some(record) = archived(conn, wallet, &key)? else { + continue; + }; + if is_unlock(&record) && record.transaction.txid() == canonical_id { + family.aliases.insert(key); + } + } + if !family.physical_aliases.is_empty() { + // A canonical row already present before adoption is also active + // evidence. With no legacy sibling, ordinary upsert policy applies. + if let Some(previous) = core_state::get_tx_record(conn, wallet, &canonical_id, ctx)? { + family.merge(wallet, &previous)?; + } + core_history::recompute_accounting(wallet, &mut family.record)?; + } + Ok(family) + } + + fn merge( + &mut self, + wallet: &WalletId, + previous: &TransactionRecord, + ) -> Result<(), WalletStorageError> { + if let (Some(old), Some(new)) = (previous.block_info(), self.record.block_info()) { + if old.height() != new.height() + || old.block_hash() != new.block_hash() + || old.timestamp() != new.timestamp() + || matches!((old.position(), new.position()), (Some(a), Some(b)) if a != b) + { + return Err(WalletStorageError::HistoryAliasContextConflict { + wallet_id: *wallet, + txid: self.record.txid, + }); + } + } + let context = + most_advanced_transaction_context(&self.record.context, &previous.context).clone(); + let body = self.record.transaction.clone(); + self.record = core_history::merge_known_details(wallet, previous, &self.record)?; + self.record.transaction = body; + self.record.context = context; + if self.record.fee.is_none() { + self.record.fee = previous.fee; + } + if self.record.label.is_empty() { + self.record.label.clone_from(&previous.label); + } + Ok(()) + } +} + +/// Adopt only validated current writes. Legacy-only reads remain physically keyed. +pub(super) fn adopt( + tx: &Transaction<'_>, + wallet: &WalletId, + incoming: &TransactionRecord, +) -> Result { + let canonical = core_history::preserve_known_details(tx, wallet, incoming)?; + if !is_unlock(&canonical) { + return Ok(canonical); + } + let family = Family::load(tx, wallet, &canonical, &LoadCtx::strict())?; + for alias in &family.physical_aliases { + // Append before clearing. Existing archive entries were validated above; + // the first original stays byte-for-byte immutable. + 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.as_slice(), alias.as_byte_array().as_slice()], + )?; + // Keep the physical anchor: DELETE would clear durable claimant links + // through V014's trigger. Height/proof/finality metadata is untouched. + tx.execute( + "UPDATE core_transactions SET record_blob = NULL WHERE wallet_id = ?1 AND txid = ?2", + params![wallet.as_slice(), alias.as_byte_array().as_slice()], + )?; + tx.execute( + "DELETE FROM core_transaction_inputs WHERE wallet_id = ?1 AND txid = ?2", + params![wallet.as_slice(), alias.as_byte_array().as_slice()], + )?; + } + Ok(family.record) +} + +/// Public history lookup. An archive entry alone can never resurrect history. +pub(crate) fn get_record( + conn: &Connection, + wallet: &WalletId, + key: &Txid, + ctx: &LoadCtx, +) -> Result, WalletStorageError> { + let physical = core_state::get_tx_record(conn, wallet, key, ctx)?; + let evidence = match &physical { + Some(record) => Some(record.clone()), + None => archived(conn, wallet, key)?, + }; + let Some(evidence) = evidence else { + return Ok(physical); + }; + if !is_unlock(&evidence) { + return Ok(physical); + } + let canonical_id = evidence.transaction.txid(); + let Some(canonical) = core_state::get_tx_record(conn, wallet, &canonical_id, ctx)? else { + return Ok(physical); + }; + Family::load(conn, wallet, &canonical, ctx).map(|family| Some(family.record)) +} + +/// Deduplicate only adopted families; backend readers keep physical row identities. +pub(crate) fn project_records( + conn: &Connection, + wallet: &WalletId, + records: &mut Vec, + ctx: &LoadCtx, +) -> Result<(), WalletStorageError> { + let recheck = ctx.for_recheck(); + let canonical: Vec<_> = records + .iter() + .filter(|record| is_unlock(record) && record.txid == record.transaction.txid()) + .cloned() + .collect(); + for record in canonical { + let family = Family::load(conn, wallet, &record, &recheck)?; + records.retain(|record| { + record.txid == family.record.txid || !family.aliases.contains(&record.txid) + }); + if let Some(slot) = records + .iter_mut() + .find(|record| record.txid == family.record.txid) + { + *slot = family.record; + } + } + Ok(()) +} + +/// Equivalent accounting lookup keys, never rewritten financial outpoints. +pub(super) fn outpoints( + conn: &Connection, + wallet: &WalletId, + outpoint: &OutPoint, +) -> Result, WalletStorageError> { + let Some(record) = get_record(conn, wallet, &outpoint.txid, &LoadCtx::strict())? else { + return Ok(vec![*outpoint]); + }; + if !is_unlock(&record) || record.txid != record.transaction.txid() { + return Ok(vec![*outpoint]); + } + let family = Family::load(conn, wallet, &record, &LoadCtx::strict())?; + Ok(std::iter::once(*outpoint) + .chain( + family + .aliases + .into_iter() + .filter(|id| *id != outpoint.txid) + .map(|id| OutPoint::new(id, outpoint.vout)), + ) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sqlite::migrations; + use crate::LoadPolicy; + + #[test] + fn should_project_existing_alias_duplicates_without_mutating_physical_history() { + let wallet = [0x65; 32]; + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run_for_open(&mut conn, LoadPolicy::Strict).unwrap(); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![wallet.as_slice()], + ) + .unwrap(); + let legacy: TransactionRecord = blob::decode( + &hex::decode( + include_str!("../../../tests/fixtures/v019_asset_unlock_v2.txt") + .lines() + .next() + .unwrap() + .split_whitespace() + .nth(2) + .unwrap(), + ) + .unwrap(), + ) + .unwrap(); + let mut canonical = legacy.clone(); + canonical.txid = canonical.transaction.txid(); + canonical.output_details.clear(); + canonical.net_amount = 0; + for record in [&legacy, &canonical] { + conn.execute("INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) VALUES (?1, ?2, 0, ?3)", params![wallet.as_slice(), record.txid.as_byte_array().as_slice(), blob::encode(record).unwrap()]).unwrap(); + } + let (mut raw, _) = core_state::load_state( + &conn, + &wallet, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + assert_eq!( + raw.records.len(), + 2, + "backend readers keep exact physical identities" + ); + project_records(&conn, &wallet, &mut raw.records, &LoadCtx::strict()).unwrap(); + assert_eq!(raw.records.len(), 1); + assert_eq!(raw.records[0].txid, canonical.txid); + assert_eq!(raw.records[0].output_details.len(), 1); + assert_eq!(raw.records[0].net_amount, 150_000); + for id in [legacy.txid, canonical.txid] { + let projected = get_record(&conn, &wallet, &id, &LoadCtx::strict()) + .unwrap() + .unwrap(); + assert_eq!( + blob::encode(&projected).unwrap(), + blob::encode(&raw.records[0]).unwrap() + ); + } + let (physical, _) = core_state::load_state( + &conn, + &wallet, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + assert_eq!(physical.records.len(), 2); + assert_eq!( + core_state::get_tx_record(&conn, &wallet, &canonical.txid, &LoadCtx::strict()) + .unwrap() + .unwrap() + .output_details + .len(), + 0 + ); + assert_eq!( + conn.query_row::( + "SELECT count(*) FROM core_transaction_record_originals", + [], + |row| row.get(0) + ) + .unwrap(), + 0 + ); + conn.execute( + "UPDATE core_transactions SET height = 999 WHERE txid = ?1", + params![legacy.txid.as_byte_array().as_slice()], + ) + .unwrap(); + let recovery = LoadCtx::recovery(); + let (mut raw, _) = + core_state::load_state(&conn, &wallet, dashcore::Network::Testnet, &recovery).unwrap(); + let before = recovery.degradation().total; + assert_eq!(before, 1); + project_records(&conn, &wallet, &mut raw.records, &recovery).unwrap(); + assert_eq!( + recovery.degradation().total, + before, + "projection must not count a previously reported physical-row drift again" + ); + } +} 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 0d0180d937e..727412f2c77 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,7 +18,7 @@ use crate::sqlite::error::WalletStorageError; use crate::sqlite::load_ctx::{LoadCtx, LoadSite}; use crate::sqlite::schema::blob::impl_persistable_blob; use crate::sqlite::schema::core_pool::{owning_account_for_script, OwningAccount}; -use crate::sqlite::schema::{blob, core_history}; +use crate::sqlite::schema::{blob, core_history, core_history_aliases}; // PUBLIC material only: core-chain state reaching `record_blob` / // `islock_blob` (transaction records + InstantLocks are public chain data). @@ -95,7 +95,7 @@ pub fn apply( )?; for incoming in &cs.records { ensure_current_transaction_identity(incoming)?; - let record = core_history::preserve_known_details(tx, wallet_id, incoming)?; + let record = core_history_aliases::adopt(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()); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs index ad4bdbb387b..8f03fe15847 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs @@ -11,6 +11,7 @@ pub mod asset_locks; pub mod blob; pub mod contacts; pub(crate) mod core_history; +pub(crate) mod core_history_aliases; pub mod core_pool; pub mod core_state; pub mod dashpay; diff --git a/packages/rs-platform-wallet-storage/tests/fixtures/v019_asset_unlock_v2_signed.txt b/packages/rs-platform-wallet-storage/tests/fixtures/v019_asset_unlock_v2_signed.txt new file mode 100644 index 00000000000..ab1eb86ec91 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/fixtures/v019_asset_unlock_v2_signed.txt @@ -0,0 +1,2 @@ +asset_unlock_v2 d2bcf5e3734674383b4a2d3b82073a55732c390d9f6e830eb4249c1f96be1928 03000001fcf04902001976a9143a573b102166c333339a24746760b144c3e2fcb988ac01080247fbe803c82031313131313131313131313131313131313131313131313131313131313131316042424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424220d2bcf5e3734674383b4a2d3b82073a55732c390d9f6e830eb4249c1f96be1928000000000700000100000122795264765945327a47534873687751376a6765555034553850396a5a4c7136337a63fcf0490200fce09304000000 +asset_unlock_v2_spend 19cf59a88628808e9019bd69ce57a5e057228cdc88f37830ddd77002ac65f86f 02000120d2bcf5e3734674383b4a2d3b82073a55732c390d9f6e830eb4249c1f96be1928006b483045022100f71f93ce5c42c6b3f4e4ae5078ac40dd28922ff38fb452e1e0cde866c0042f3402201a0d18b9d059b1686ae4259ed3fb82edae66fcbf0f351df4749321654c791db0012102e4d24944ed947bc34ed14ea4b2e063aa27ebd8da658577a263f55a631c7f656afcffffffff0001fc0846020000002019cf59a88628808e9019bd69ce57a5e057228cdc88f37830ddd77002ac65f86f0000000000010100fcf049020022795264765945327a47534873687751376a6765555034553850396a5a4c7136337a6300fcdf9304000000 diff --git a/packages/rs-platform-wallet-storage/tests/integration/main.rs b/packages/rs-platform-wallet-storage/tests/integration/main.rs index 0f0a6c6f051..2adf540122a 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/main.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/main.rs @@ -44,6 +44,7 @@ mod sqlite_error_classification; mod sqlite_fk_changeset_ordering; mod sqlite_foreign_db_rejection; mod sqlite_foreign_keys; +mod sqlite_history_aliases; mod sqlite_identity_hard_delete; mod sqlite_identity_index_concurrency; mod sqlite_identity_index_uniqueness; diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_error_classification.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_error_classification.rs index 289aecb600f..b474d4946c4 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_error_classification.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_error_classification.rs @@ -213,6 +213,10 @@ fn samples() -> Vec { typed_height: Some(100), blob_height: Some(101), }, + WalletStorageError::HistoryAliasContextConflict { + wallet_id: [0x33; 32], + txid: dashcore::Txid::from_byte_array([0x44; 32]), + }, WalletStorageError::TransactionBodyConflict { wallet_id: [0x33; 32], txid: dashcore::Txid::from_byte_array([0x44; 32]), @@ -449,6 +453,9 @@ fn tc_p2_005_is_transient_table() { WalletStorageError::CoreTransactionEntryMismatch { .. } => { (false, "core_transaction_entry_mismatch") } + WalletStorageError::HistoryAliasContextConflict { .. } => { + (false, "history_alias_context_conflict") + } WalletStorageError::TransactionBodyConflict { .. } => { (false, "transaction_body_conflict") } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs new file mode 100644 index 00000000000..4bfd7e2bac7 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs @@ -0,0 +1,827 @@ +//! Canonical redelivery of a migrated historical AssetUnlock identity. +//! Both literal fixtures were emitted by d631aaca3632cc713143476e363c9a9aca038d2e. +//! The signed fixture uses the public test key [0x19; 32] and SIGHASH_ALL; +//! signature preservation is tested independently of chain/quorum validity. + +use crate::common::{ensure_wallet_meta, fresh_persister, wid}; +use dashcore::sighash::SighashCache; +use dashcore::{consensus, Address, Network, OutPoint, Txid}; +use dpp::secp256k1::{ecdsa, ecdsa::Signature, Message, PublicKey}; +use key_wallet::managed_account::transaction_record::TransactionRecord; +use key_wallet::Utxo; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet_storage::sqlite::schema::blob; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; +use rusqlite::params; + +fn migrated_history() -> ( + SqlitePersister, + tempfile::TempDir, + TransactionRecord, + TransactionRecord, +) { + migrated_fixture(include_str!("../fixtures/v019_asset_unlock_v2.txt")) +} + +fn migrated_fixture( + fixture: &str, +) -> ( + SqlitePersister, + tempfile::TempDir, + TransactionRecord, + TransactionRecord, +) { + let (persister, temp, path) = fresh_persister(); + let wallet = wid(0x79); + ensure_wallet_meta(&persister, &wallet); + let mut records = Vec::new(); + { + let conn = persister.lock_conn_for_test(); + conn.execute_batch( + "DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; \ + DELETE FROM refinery_schema_history WHERE version >= 21;", + ) + .unwrap(); + for line in fixture.lines() { + let fields: Vec<_> = line.split_whitespace().collect(); + let bytes = hex::decode(fields[2]).unwrap(); + let mut record: TransactionRecord = blob::decode(&bytes).unwrap(); + assert_eq!( + record.txid, + Txid::from_slice(&hex::decode(fields[1]).unwrap()).unwrap() + ); + // Remove only the test copy's accounting evidence: raw consumer bytes stay literal. + if !record.transaction.input.is_empty() { + record.input_details.clear(); + record.net_amount = 0; + } + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) VALUES (?1, ?2, 0, ?3)", + params![wallet.as_slice(), record.txid.as_byte_array().as_slice(), blob::encode(&record).unwrap()], + ).unwrap(); + records.push(record); + } + let version: i64 = conn + .query_row( + "SELECT max(version) FROM refinery_schema_history", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(version, 20); + } + drop(persister); + let migrated = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + { + let conn = migrated.lock_conn_for_test(); + let version: i64 = conn + .query_row( + "SELECT max(version) FROM refinery_schema_history", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(version, 21); + } + let consumer = records.pop().unwrap(); + let funding = records.pop().unwrap(); + assert_ne!(funding.txid, funding.transaction.txid()); + assert_eq!( + consumer.transaction.input[0].previous_output, + OutPoint::new(funding.txid, 0) + ); + (migrated, temp, funding, consumer) +} + +fn canonical_redelivery(persister: &SqlitePersister, funding: &TransactionRecord, with_coin: bool) { + let mut canonical = funding.clone(); + canonical.txid = canonical.transaction.txid(); + canonical.output_details.clear(); + let new_utxos = if with_coin { + let output = canonical.transaction.output[0].clone(); + vec![Utxo { + outpoint: OutPoint::new(canonical.txid, 0), + address: Address::from_script(&output.script_pubkey, Network::Testnet).unwrap(), + txout: output, + height: 0, + is_coinbase: false, + is_confirmed: false, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }] + } else { + vec![] + }; + persister + .store( + wid(0x79), + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records: vec![canonical], + new_utxos, + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); +} + +fn assert_consumer_bytes_and_raw_index_unchanged( + persister: &SqlitePersister, + original: &TransactionRecord, +) { + let loaded = persister + .get_core_tx_record(wid(0x79), &original.txid) + .unwrap() + .unwrap(); + assert_eq!( + consensus::serialize(&loaded.transaction), + consensus::serialize(&original.transaction) + ); + let conn = persister.lock_conn_for_test(); + let indexed: Vec = conn + .query_row( + "SELECT outpoint FROM core_transaction_inputs WHERE wallet_id = ?1 AND txid = ?2", + params![ + wid(0x79).as_slice(), + original.txid.as_byte_array().as_slice() + ], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + blob::decode_outpoint(&indexed).unwrap(), + original.transaction.input[0].previous_output + ); +} + +#[test] +fn should_keep_one_history_record_after_migration_and_canonical_redelivery() { + let (persister, _temp, funding, consumer) = migrated_history(); + canonical_redelivery(&persister, &funding, false); + assert_consumer_bytes_and_raw_index_unchanged(&persister, &consumer); + let conn = persister.lock_conn_for_test(); + let count: i64 = conn.query_row( + "SELECT count(*) FROM core_transactions WHERE wallet_id = ?1 AND txid IN (?2, ?3) AND record_blob IS NOT NULL", + params![wid(0x79).as_slice(), funding.txid.as_byte_array().as_slice(), funding.transaction.txid().as_byte_array().as_slice()], + |row| row.get(0), + ).unwrap(); + assert_eq!( + count, 1, + "one body must not become two history records through a hash-policy upgrade" + ); +} + +#[test] +fn should_repair_a_legacy_raw_prevout_when_canonical_funding_arrives() { + let (persister, _temp, funding, consumer) = migrated_history(); + canonical_redelivery(&persister, &funding, true); + assert_consumer_bytes_and_raw_index_unchanged(&persister, &consumer); + let loaded = persister + .get_core_tx_record(wid(0x79), &consumer.txid) + .unwrap() + .unwrap(); + assert_eq!( + loaded.net_amount, -150_000, + "canonical funding must reach the raw legacy prevout's accounting" + ); + assert_eq!(loaded.input_details.len(), 1); +} + +#[test] +fn should_merge_owned_details_when_alias_and_canonical_history_already_coexist() { + let (persister, _temp, funding, consumer) = migrated_history(); + let mut partial = funding.clone(); + partial.txid = partial.transaction.txid(); + partial.output_details.clear(); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) VALUES (?1, ?2, 0, ?3)", + params![wid(0x79).as_slice(), partial.txid.as_byte_array().as_slice(), blob::encode(&partial).unwrap()], + ).unwrap(); + } + canonical_redelivery(&persister, &funding, false); + assert_consumer_bytes_and_raw_index_unchanged(&persister, &consumer); + let loaded = persister + .get_core_tx_record(wid(0x79), &partial.txid) + .unwrap() + .unwrap(); + assert_eq!( + loaded.output_details.len(), + 1, + "known ownership must survive alias reconciliation" + ); +} + +fn seed_record(persister: &SqlitePersister, wallet: [u8; 32], record: &TransactionRecord) { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT OR REPLACE INTO core_transactions (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![wallet.as_slice(), record.txid.as_byte_array().as_slice(), record.block_info().map(|b| b.height()), record.block_info().map(|b| b.block_hash().to_byte_array().to_vec()), record.block_info().map(|b| b.timestamp()), record.block_info().is_some(), blob::encode(record).unwrap()], + ).unwrap(); +} + +fn confirmed() -> key_wallet::transaction_checking::TransactionContext { + key_wallet::transaction_checking::TransactionContext::InBlock( + key_wallet::transaction_checking::BlockInfo::new( + 42, + dashcore::BlockHash::from_byte_array([42; 32]), + 420, + ), + ) +} + +#[test] +fn should_preserve_active_confirmed_alias_context_on_late_mempool_adoption() { + let (persister, _temp, mut funding, _) = migrated_history(); + funding.context = confirmed(); + funding.fee = Some(1_000); + funding.label = "old funding".to_owned(); + seed_record(&persister, wid(0x79), &funding); + let mut late = funding.clone(); + late.context = key_wallet::transaction_checking::TransactionContext::Mempool; + late.fee = None; + late.label.clear(); + canonical_redelivery(&persister, &late, false); + let loaded = persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap(); + assert_eq!(loaded.context, funding.context); + assert_eq!(loaded.fee, funding.fee); + assert_eq!(loaded.label, funding.label); +} + +#[test] +fn should_keep_chainlocked_alias_context_when_canonical_snapshot_is_only_in_block() { + let (persister, _temp, mut funding, _) = migrated_history(); + let block = *confirmed().block_info().unwrap(); + funding.context = + key_wallet::transaction_checking::TransactionContext::InChainLockedBlock(block); + seed_record(&persister, wid(0x79), &funding); + let mut late = funding.clone(); + late.context = key_wallet::transaction_checking::TransactionContext::InBlock(block); + canonical_redelivery(&persister, &late, false); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .context, + funding.context + ); +} + +fn snapshot(persister: &SqlitePersister, table: &str) -> Vec> { + let conn = persister.lock_conn_for_test(); + let mut stmt = conn + .prepare(&format!("SELECT * FROM {table} ORDER BY 1, 2")) + .unwrap(); + let columns = stmt.column_count(); + stmt.query_map([], |row| (0..columns).map(|i| row.get(i)).collect()) + .unwrap() + .collect::>() + .unwrap() +} + +#[test] +fn should_resolve_multiple_legacy_instances_without_mutating_archived_originals() { + let (persister, temp, funding, consumer) = migrated_history(); + let mut second = funding.clone(); + if let Some( + dashcore::transaction::special_transaction::TransactionPayload::AssetUnlockPayloadType( + payload, + ), + ) = &mut second.transaction.special_transaction_payload + { + payload.quorum_sig = [0x43; 96].into(); + } + second.txid = second.transaction.instance_hash(); + assert_ne!(second.txid, funding.txid); + assert_eq!(second.transaction.txid(), funding.transaction.txid()); + seed_record(&persister, wid(0x79), &second); + canonical_redelivery(&persister, &funding, false); + let archive = snapshot(&persister, "core_transaction_record_originals"); + let rows = snapshot(&persister, "core_transactions"); + canonical_redelivery(&persister, &second, false); + assert_eq!( + snapshot(&persister, "core_transaction_record_originals"), + archive + ); + assert_eq!(snapshot(&persister, "core_transactions"), rows); + drop(persister); + let persister = + SqlitePersister::open(SqlitePersisterConfig::new(temp.path().join("wallet.db"))).unwrap(); + for id in [funding.txid, second.txid, funding.transaction.txid()] { + let record = persister + .get_core_tx_record(wid(0x79), &id) + .unwrap() + .unwrap(); + assert_eq!(record.txid, funding.transaction.txid()); + assert_eq!(record.transaction, funding.transaction); + assert_eq!(record.output_details.len(), 1); + } + assert_consumer_bytes_and_raw_index_unchanged(&persister, &consumer); +} + +#[test] +fn should_preserve_claims_and_physical_metadata_anchors_during_history_adoption() { + let (persister, _temp, funding, _) = migrated_history(); + let output = funding.transaction.output[0].clone(); + let utxo = Utxo { + outpoint: OutPoint::new(funding.txid, 0), + address: Address::from_script(&output.script_pubkey, Network::Testnet).unwrap(), + txout: output, + height: 0, + is_coinbase: false, + is_confirmed: false, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + persister + .store( + wid(0x79), + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "UPDATE core_utxos SET spent = 1, spent_in_txid = ?1, winner_mined_height = 17", + params![funding.txid.as_byte_array().as_slice()], + ) + .unwrap(); + } + let before = snapshot(&persister, "core_utxos"); + canonical_redelivery(&persister, &funding, false); + assert_eq!(snapshot(&persister, "core_utxos"), before); + let conn = persister.lock_conn_for_test(); + let anchor: (Option>, Option, i64) = conn.query_row( + "SELECT record_blob, height, finalized FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wid(0x79).as_slice(), funding.txid.as_byte_array().as_slice()], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ).unwrap(); + assert_eq!(anchor, (None, None, 0)); +} + +#[test] +fn should_not_resurrect_history_or_metadata_from_archive_after_canonical_removal() { + let (persister, _temp, funding, _) = migrated_history(); + canonical_redelivery(&persister, &funding, false); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "DELETE FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![ + wid(0x79).as_slice(), + funding.transaction.txid().as_byte_array().as_slice() + ], + ) + .unwrap(); + } + assert!(persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .is_none()); + assert!(persister + .get_core_tx_record(wid(0x79), &funding.transaction.txid()) + .unwrap() + .is_none()); + canonical_redelivery(&persister, &funding, false); + assert!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .output_details + .is_empty(), + "archive proves identity, never ownership" + ); +} + +#[test] +fn should_rollback_conflicting_confirmed_alias_contexts() { + let (persister, _temp, mut funding, _) = migrated_history(); + funding.context = confirmed(); + seed_record(&persister, wid(0x79), &funding); + let before = snapshot(&persister, "core_transactions"); + let archive = snapshot(&persister, "core_transaction_record_originals"); + let mut incoming = funding.clone(); + incoming.txid = incoming.transaction.txid(); + incoming.context = key_wallet::transaction_checking::TransactionContext::InBlock( + key_wallet::transaction_checking::BlockInfo::new( + 43, + dashcore::BlockHash::from_byte_array([43; 32]), + 430, + ), + ); + let error = persister + .store( + wid(0x79), + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records: vec![incoming], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap_err(); + let platform_wallet::changeset::PersistenceError::Backend { source, .. } = error else { + panic!("backend error expected") + }; + assert!(matches!( + source.downcast_ref::(), + Some(platform_wallet_storage::WalletStorageError::HistoryAliasContextConflict { .. }) + )); + assert_eq!(snapshot(&persister, "core_transactions"), before); + assert_eq!( + snapshot(&persister, "core_transaction_record_originals"), + archive + ); +} + +#[test] +fn should_reject_corrupt_alias_evidence_in_strict_and_recovery_without_writes() { + let (persister, temp, funding, _) = migrated_history(); + canonical_redelivery(&persister, &funding, false); + { + let conn = persister.lock_conn_for_test(); + let mut tampered = funding.clone(); + tampered.transaction.output[0].value += 1; + conn.execute("UPDATE core_transaction_record_originals SET record_blob = ?1 WHERE wallet_id = ?2 AND txid = ?3", params![blob::encode(&tampered).unwrap(), wid(0x79).as_slice(), funding.txid.as_byte_array().as_slice()]).unwrap(); + } + assert!(persister + .get_core_tx_record(wid(0x79), &funding.txid) + .is_err()); + assert!(persister + .get_core_tx_record(wid(0x79), &funding.transaction.txid()) + .is_err()); + let before = snapshot(&persister, "core_transactions"); + let mut incoming = funding.clone(); + incoming.txid = incoming.transaction.txid(); + assert!(persister + .store( + wid(0x79), + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records: vec![incoming], + ..Default::default() + }), + ..Default::default() + } + ) + .is_err()); + assert_eq!(snapshot(&persister, "core_transactions"), before); + drop(persister); + let recovery = SqlitePersister::open( + SqlitePersisterConfig::new(temp.path().join("wallet.db")) + .with_load_policy(platform_wallet_storage::LoadPolicy::Recovery), + ) + .unwrap(); + assert!(recovery + .get_core_tx_record(wid(0x79), &funding.txid) + .is_err()); + assert!(recovery + .get_core_tx_record(wid(0x79), &funding.transaction.txid()) + .is_err()); + assert_eq!(snapshot(&recovery, "core_transactions"), before); +} + +#[test] +fn should_keep_alias_projection_wallet_scoped_and_legacy_only_identity_unchanged() { + let (persister, _temp, funding, _) = migrated_history(); + let other = wid(0x80); + ensure_wallet_meta(&persister, &other); + seed_record(&persister, other, &funding); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .txid, + funding.txid + ); + assert!(persister + .get_core_tx_record(wid(0x79), &funding.transaction.txid()) + .unwrap() + .is_none()); + canonical_redelivery(&persister, &funding, false); + assert_eq!( + persister + .get_core_tx_record(other, &funding.txid) + .unwrap() + .unwrap() + .txid, + funding.txid + ); + assert!(persister + .get_core_tx_record(other, &funding.transaction.txid()) + .unwrap() + .is_none()); +} + +fn assert_signed_consumer(funding: &TransactionRecord, consumer: &TransactionRecord) { + let pushes: Vec<_> = consumer.transaction.input[0] + .script_sig + .instructions() + .map(|instruction| { + instruction + .unwrap() + .push_bytes() + .unwrap() + .as_bytes() + .to_vec() + }) + .collect(); + assert_eq!(pushes.len(), 2); + assert_eq!(*pushes[0].last().unwrap(), 1, "SIGHASH_ALL"); + let signature = Signature::from_der(&pushes[0][..pushes[0].len() - 1]).unwrap(); + let public = PublicKey::from_slice(&pushes[1]).unwrap(); + let script = Address::p2pkh( + &dashcore::PublicKey::from_slice(&pushes[1]).unwrap(), + Network::Testnet, + ) + .script_pubkey(); + assert_eq!(script, funding.transaction.output[0].script_pubkey); + let hash = SighashCache::new(&consumer.transaction) + .legacy_signature_hash(0, &script, 1) + .unwrap(); + ecdsa::verify( + &signature, + Message::from_digest(hash.to_byte_array()), + &public, + ) + .unwrap(); + let mut rekeyed = consumer.transaction.clone(); + rekeyed.input[0].previous_output.txid = funding.transaction.txid(); + let changed_hash = SighashCache::new(&rekeyed) + .legacy_signature_hash(0, &script, 1) + .unwrap(); + assert!( + ecdsa::verify( + &signature, + Message::from_digest(changed_hash.to_byte_array()), + &public + ) + .is_err(), + "rewriting the legacy prevout would invalidate this signature" + ); +} + +#[test] +fn should_preserve_known_key_signed_consumer_through_migration_and_redelivery() { + let fixture = include_str!("../fixtures/v019_asset_unlock_v2_signed.txt"); + let originals: Vec = fixture + .lines() + .map(|line| { + blob::decode(&hex::decode(line.split_whitespace().nth(2).unwrap()).unwrap()).unwrap() + }) + .collect(); + assert_signed_consumer(&originals[0], &originals[1]); + let (persister, _temp, funding, consumer) = migrated_fixture(fixture); + canonical_redelivery(&persister, &funding, true); + let loaded = persister + .get_core_tx_record(wid(0x79), &consumer.txid) + .unwrap() + .unwrap(); + assert_signed_consumer(&funding, &loaded); + assert_consumer_bytes_and_raw_index_unchanged(&persister, &consumer); + assert_eq!( + consensus::serialize(&loaded.transaction), + consensus::serialize(&originals[1].transaction) + ); + assert_eq!(loaded.net_amount, -150_000); +} + +#[test] +fn should_preserve_active_canonical_context_while_adopting_existing_legacy_sibling() { + let (persister, _temp, funding, _) = migrated_history(); + let mut canonical = funding.clone(); + canonical.txid = canonical.transaction.txid(); + canonical.context = confirmed(); + seed_record(&persister, wid(0x79), &canonical); + canonical_redelivery(&persister, &funding, false); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .context, + canonical.context + ); +} + +#[test] +fn should_accept_same_block_promotion_with_newly_known_position() { + let (persister, _temp, mut funding, _) = migrated_history(); + funding.context = confirmed(); + seed_record(&persister, wid(0x79), &funding); + let mut canonical = funding.clone(); + canonical.txid = canonical.transaction.txid(); + canonical.context = key_wallet::transaction_checking::TransactionContext::InChainLockedBlock( + confirmed().block_info().unwrap().with_position(7), + ); + canonical_redelivery(&persister, &canonical, false); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .context, + canonical.context + ); +} + +#[test] +fn should_not_promote_later_canonical_context_from_archived_or_blobless_alias() { + let (persister, _temp, mut funding, _) = migrated_history(); + funding.context = confirmed(); + seed_record(&persister, wid(0x79), &funding); + canonical_redelivery(&persister, &funding, false); + funding.context = key_wallet::transaction_checking::TransactionContext::Mempool; + canonical_redelivery(&persister, &funding, false); + let loaded = persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap(); + assert_eq!(loaded.context, key_wallet::transaction_checking::TransactionContext::Mempool, + "ordinary canonical upsert keeps its existing policy; archive/anchors never promote context"); +} + +#[test] +fn should_repair_existing_canonical_coin_consumers_when_alias_is_adopted_without_coin_redelivery() { + let (persister, _temp, funding, consumer) = migrated_history(); + let output = funding.transaction.output[0].clone(); + let coin = Utxo { + outpoint: OutPoint::new(funding.transaction.txid(), 0), + address: Address::from_script(&output.script_pubkey, Network::Testnet).unwrap(), + txout: output, + height: 0, + is_coinbase: false, + is_confirmed: false, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + persister + .store( + wid(0x79), + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![coin], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &consumer.txid) + .unwrap() + .unwrap() + .net_amount, + 0 + ); + let coins = snapshot(&persister, "core_utxos"); + canonical_redelivery(&persister, &funding, false); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &consumer.txid) + .unwrap() + .unwrap() + .net_amount, + -150_000 + ); + assert_eq!(snapshot(&persister, "core_utxos"), coins); + assert_consumer_bytes_and_raw_index_unchanged(&persister, &consumer); +} + +#[test] +fn should_preserve_unrepresentable_mempool_accounting_during_alias_adoption() { + let (persister, _temp, mut funding, _) = migrated_history(); + funding.transaction.output[0].value = u64::MAX; + funding.output_details[0].value = u64::MAX; + funding.txid = funding.transaction.instance_hash(); + funding.net_amount = 123; + seed_record(&persister, wid(0x79), &funding); + canonical_redelivery(&persister, &funding, false); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .net_amount, + 123 + ); +} + +#[test] +fn should_not_recompute_alias_accounting_without_owned_details() { + let (persister, _temp, mut funding, _) = migrated_history(); + funding.output_details.clear(); + funding.net_amount = 123; + seed_record(&persister, wid(0x79), &funding); + canonical_redelivery(&persister, &funding, false); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .net_amount, + 123 + ); +} + +#[test] +fn should_rollback_archiving_and_anchor_changes_when_canonical_upsert_fails_then_retry() { + let (persister, _temp, funding, consumer) = migrated_history(); + let before = snapshot(&persister, "core_transactions"); + let archive = snapshot(&persister, "core_transaction_record_originals"); + { + let conn = persister.lock_conn_for_test(); + conn.execute_batch("CREATE TEMP TRIGGER reject_canonical_insert BEFORE INSERT ON core_transactions BEGIN SELECT RAISE(ABORT, 'injected canonical upsert failure'); END;").unwrap(); + } + let mut canonical = funding.clone(); + canonical.txid = canonical.transaction.txid(); + assert!(persister + .store( + wid(0x79), + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records: vec![canonical], + ..Default::default() + }), + ..Default::default() + } + ) + .is_err()); + assert_eq!(snapshot(&persister, "core_transactions"), before); + assert_eq!( + snapshot(&persister, "core_transaction_record_originals"), + archive + ); + assert_consumer_bytes_and_raw_index_unchanged(&persister, &consumer); + persister + .lock_conn_for_test() + .execute_batch("DROP TRIGGER reject_canonical_insert;") + .unwrap(); + canonical_redelivery(&persister, &funding, false); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .txid, + funding.transaction.txid() + ); +} + +#[test] +fn should_preserve_recovery_height_drift_policy_during_alias_projection() { + let (persister, temp, funding, _) = migrated_history(); + let mut canonical = funding.clone(); + canonical.txid = canonical.transaction.txid(); + seed_record(&persister, wid(0x79), &canonical); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "UPDATE core_transactions SET height = 999 WHERE wallet_id = ?1 AND txid = ?2", + params![ + wid(0x79).as_slice(), + funding.txid.as_byte_array().as_slice() + ], + ) + .unwrap(); + } + assert!(persister + .get_core_tx_record(wid(0x79), &canonical.txid) + .is_err()); + let before = snapshot(&persister, "core_transactions"); + drop(persister); + let recovery = SqlitePersister::open( + SqlitePersisterConfig::new(temp.path().join("wallet.db")) + .with_load_policy(platform_wallet_storage::LoadPolicy::Recovery), + ) + .unwrap(); + let loaded = recovery + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap(); + assert_eq!(loaded.txid, canonical.txid); + assert_eq!(loaded.context, funding.context); + assert_eq!(snapshot(&recovery, "core_transactions"), before); +} diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index dc3b871742a..bbb9c19affb 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -571,9 +571,7 @@ fn fold_groups(records: &mut Vec) { // The funding slice is not necessarily the newest one — a // group can pair a stale `Mempool` sighting with the // confirmed snapshot of the same transaction. - if context_rank(&r.context) > context_rank(&merged.context) { - merged.context = r.context.clone(); - } + merged.context = most_advanced_transaction_context(&merged.context, &r.context).clone(); } // The base record's own index was skipped by the `i != base_pos` // guard above; drop every group member except the fold's output @@ -667,6 +665,21 @@ pub(crate) fn coalesce_account_records(records: &mut Vec) { } } +/// Select the more advanced observation of the same transaction, retaining +/// `current` on a tie. This merges metadata only; it does not verify finality or +/// resolve conflicting blocks. Callers must establish transaction identity and +/// compatible block observations before using it. +pub fn most_advanced_transaction_context<'a>( + current: &'a key_wallet::transaction_checking::TransactionContext, + incoming: &'a key_wallet::transaction_checking::TransactionContext, +) -> &'a key_wallet::transaction_checking::TransactionContext { + if context_rank(incoming) > context_rank(current) { + incoming + } else { + current + } +} + /// Rank a [`TransactionContext`](key_wallet::transaction_checking::TransactionContext) /// by how far along the confirmation lifecycle the observation is. /// Used by [`fold_same_txid_records`] so a fold never regresses a diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index c4e5429ffc7..02a637bc2b0 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -34,17 +34,17 @@ mod wallet_accounting; pub(crate) use changeset::account_address_pool_entries; pub use changeset::{ - upsert_pending_contact_crypto, AccountAddressPoolEntry, AccountRegistrationEntry, - AssetLockChangeSet, AssetLockEntry, ContactChangeSet, ContactRequestEntry, CoreChangeSet, - DpnsNameSaleStatus, DpnsNameStateChangeSet, DpnsNameStateEntry, HighestUsedIndexes, - IdentityChangeSet, IdentityEntry, IdentityKeyDerivationIndices, IdentityKeyEntry, - IdentityKeysChangeSet, InvitationChangeSet, InvitationEntry, InvitationStatus, - KeyDerivationBreadcrumb, KeyWithBreadcrumb, PendingContactCrypto, PendingContactCryptoKey, - PendingContactCryptoKind, PendingContactCryptoOp, PlatformAddressBalanceEntry, - PlatformAddressChangeSet, PlatformWalletChangeSet, ProviderKeyAccountEntry, - ProviderKeyExtendedPubKey, ProviderPlatformNodePubKey, ReceivedContactRequestKey, - SentContactRequestKey, ShieldedRecipientRaw, TokenBalanceChangeSet, WalletMetadataEntry, - SHIELDED_RECIPIENT_SIZE, + most_advanced_transaction_context, upsert_pending_contact_crypto, AccountAddressPoolEntry, + AccountRegistrationEntry, AssetLockChangeSet, AssetLockEntry, ContactChangeSet, + ContactRequestEntry, CoreChangeSet, DpnsNameSaleStatus, DpnsNameStateChangeSet, + DpnsNameStateEntry, HighestUsedIndexes, IdentityChangeSet, IdentityEntry, + IdentityKeyDerivationIndices, IdentityKeyEntry, IdentityKeysChangeSet, InvitationChangeSet, + InvitationEntry, InvitationStatus, KeyDerivationBreadcrumb, KeyWithBreadcrumb, + PendingContactCrypto, PendingContactCryptoKey, PendingContactCryptoKind, + PendingContactCryptoOp, PlatformAddressBalanceEntry, PlatformAddressChangeSet, + PlatformWalletChangeSet, ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, + ProviderPlatformNodePubKey, ReceivedContactRequestKey, SentContactRequestKey, + ShieldedRecipientRaw, TokenBalanceChangeSet, WalletMetadataEntry, SHIELDED_RECIPIENT_SIZE, }; pub use client_start_state::ClientStartState; pub use client_wallet_start_state::ClientWalletStartState;