From 59dc06d0e22beb3507f49d6834a229a04a82f042 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:54:58 +0000 Subject: [PATCH 1/2] fix(wallet-storage)!: reconcile historical AssetUnlock identities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt canonical history records while retaining validated legacy identity evidence in the immutable archive and preserving physical metadata anchors. Resolve historical prevouts for accounting without rewriting signed inputs, coins, claims or proof keys. Keep physical backend reads separate from the public alias projection and roll back incompatible confirmed contexts. Cover migration and redelivery with old-writer fixtures, including a real SIGHASH_ALL consumer, plus alias families, rollback and Recovery policy. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../src/sqlite/error.rs | 18 +- .../src/sqlite/load_ctx.rs | 6 + .../src/sqlite/persister.rs | 14 +- .../src/sqlite/schema/core_history.rs | 153 +++- .../src/sqlite/schema/core_history_aliases.rs | 370 ++++++++ .../src/sqlite/schema/core_state.rs | 4 +- .../src/sqlite/schema/mod.rs | 1 + .../fixtures/v019_asset_unlock_v2_signed.txt | 2 + .../tests/integration/main.rs | 1 + .../integration/sqlite_history_aliases.rs | 828 ++++++++++++++++++ .../src/changeset/changeset.rs | 19 +- .../rs-platform-wallet/src/changeset/mod.rs | 21 +- 12 files changed, 1384 insertions(+), 53 deletions(-) create mode 100644 packages/rs-platform-wallet-storage/src/sqlite/schema/core_history_aliases.rs create mode 100644 packages/rs-platform-wallet-storage/tests/fixtures/v019_asset_unlock_v2_signed.txt create mode 100644 packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs index 029476658ed..ade3e9489b6 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -422,6 +422,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. @@ -790,6 +800,7 @@ impl WalletStorageError { | Self::AssetLockEntryMismatch { .. } | Self::AssetLockStatusMismatch { .. } | Self::CoreTransactionEntryMismatch { .. } + | Self::HistoryAliasContextConflict { .. } | Self::TransactionBodyConflict { .. } | Self::UnknownWalletNetwork { .. } | Self::NetAmountOverflow { .. } @@ -840,9 +851,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 @@ -984,6 +995,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 0a20a39d8ab..3eea6cc4768 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1619,13 +1619,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 @@ -1719,9 +1723,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 5269eeff9b8..d2d5b0e78f9 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 @@ -13,7 +13,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; @@ -24,10 +24,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() { @@ -36,10 +46,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 { @@ -47,7 +58,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 { @@ -103,15 +115,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() { @@ -144,6 +180,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]; @@ -265,23 +341,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 @@ -304,6 +366,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..520b268dd99 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history_aliases.rs @@ -0,0 +1,370 @@ +//! 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::hashes::Hash; +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 035118ff694..4337ef009a9 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_history_aliases.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs new file mode 100644 index 00000000000..d06ac9c0009 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs @@ -0,0 +1,828 @@ +//! 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::hashes::Hash; +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 >= 20;", + ) + .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, 19); + } + 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, 20); + } + 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 50015a539e2..b7f3112fbaf 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 11aefd51976..358a46efd83 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -34,16 +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, TokenBalanceChangeSet, WalletMetadataEntry, + 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, + TokenBalanceChangeSet, WalletMetadataEntry, }; pub use client_start_state::ClientStartState; pub use client_wallet_start_state::ClientWalletStartState; From 268a84fee2154d9dc719b639ac2bac7f8ea316aa Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:41:50 +0000 Subject: [PATCH 2/2] fix(platform-wallet-storage): drop Hash trait imports unused after the rust-dashcore bump The rust-dashcore update inherited from v5.1-dev makes the byte-array constructors and accessors inherent on the hash newtypes, so these `dashcore::hashes::Hash` imports fail clippy with -D warnings. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/schema/core_history_aliases.rs | 1 - .../tests/integration/sqlite_history_aliases.rs | 1 - 2 files changed, 2 deletions(-) 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 index 520b268dd99..d11e3103933 100644 --- 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 @@ -6,7 +6,6 @@ use std::collections::BTreeSet; -use dashcore::hashes::Hash; use dashcore::transaction::special_transaction::TransactionPayload; use dashcore::{OutPoint, Txid}; use key_wallet::managed_account::transaction_record::TransactionRecord; 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 index d06ac9c0009..a449924dab8 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs @@ -4,7 +4,6 @@ //! signature preservation is tested independently of chain/quorum validity. use crate::common::{ensure_wallet_meta, fresh_persister, wid}; -use dashcore::hashes::Hash; use dashcore::sighash::SighashCache; use dashcore::{consensus, Address, Network, OutPoint, Txid}; use dpp::secp256k1::{ecdsa, ecdsa::Signature, Message, PublicKey};