diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 04a4e29ea1..bb3dff2ef0 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -71,6 +71,17 @@ use dpp::prelude::Identifier; use platform_wallet::{DpnsNameInfo, IdentityManagerStartState, IdentityStatus, ManagedIdentity}; use std::ffi::CStr; +/// The persisted `TransactionContext` discriminant values shared with the +/// host mirrors (`PersistentTransaction.context` on Swift): `0` mempool, +/// `1` InstantSend, `2` in a block, `3` in a chain-locked block. Every u32 +/// `context_raw` decoder in this crate matches the confirmed contexts +/// against these constants — a new context value must be added here first, +/// so a grep for the constant names finds every decoder that has to learn +/// it. The sites deliberately differ in their defensive defaults (miss vs +/// `Mempool` vs no-evidence); see each match's comment. +pub(crate) const TX_CONTEXT_RAW_IN_BLOCK: u32 = 2; +pub(crate) const TX_CONTEXT_RAW_IN_CHAIN_LOCKED_BLOCK: u32 = 3; + /// Versioned C projection of [`PersistenceCapabilities`]. /// /// `version` identifies the stable bit assignment. `reserved` must be ignored @@ -2947,6 +2958,9 @@ impl PlatformWalletPersistence for FFIPersister { return Ok(None); } + // `context_kind` is the u8 out-param twin of the u32 + // `TX_CONTEXT_RAW_*` discriminants at the top of this file — the + // values must stay in lockstep with those constants. let context = match context_kind { 0 => TransactionContext::Mempool, 1 => { @@ -4796,12 +4810,14 @@ fn build_wallet_start_state( // was interrupted by an app kill can resume from the latest // status without rebroadcasting. let unused_asset_locks = build_unused_asset_locks(entry)?; + let asset_lock_input_spends = build_asset_lock_input_spends(entry); let wallet_state = ClientWalletStartState { wallet, wallet_info, identity_manager, unused_asset_locks, + asset_lock_input_spends, }; let platform_address_state = if per_account.is_empty() @@ -4822,27 +4838,83 @@ fn build_wallet_start_state( Ok((wallet_state, platform_address_state)) } -/// Translate the `IdentityRestoreEntryFFI` slice carried on a wallet -/// entry into the wallet-bucket portion of an -/// [`IdentityManagerStartState`]. -/// -/// Every entry on a `WalletRestoreEntryFFI` is wallet-owned by -/// definition, so the returned map is shaped for direct insertion -/// into `wallet_identities[entry.wallet_id]`. Out-of-wallet identities -/// (no associated wallet) come from a separate path that today simply -/// doesn't exist in SwiftData — see the report observation. +/// Decode the host mirror's report of which transaction took each outpoint +/// an unresolved asset lock spends. /// -/// The DPP `Identity` is reconstructed from the persisted scalars via -/// the `IdentityV0` shape — same approach -/// [`apply_identity_entry`](platform_wallet::IdentityManager::apply_identity_entry) -/// uses on the changeset replay path. Public keys are now pulled in -/// from the `keys` array on each `IdentityRestoreEntryFFI` (assembled -/// from the per-identity `PersistentPublicKey` rows on the Swift -/// side), so the restored `Identity.public_keys` map is populated at -/// load time. An identity with no persisted keys (e.g. an in-flight -/// registration whose key-persist round hasn't completed) loads with -/// an empty map and gets refreshed on the next sync round — -/// degraded-but-usable for that narrow case. +/// A malformed row is skipped rather than failing the load: the map is +/// evidence for a screen that degrades to its old behaviour without it, so a +/// bad row must not cost the user their wallet. "Malformed" here means an +/// all-zero txid on either side of the row — the shape a zero-initialised +/// struct from a host that never filled the row in would take. (The 32-byte +/// arrays themselves always parse, so this check is the row validation, not +/// the `Txid` constructor.) +fn build_asset_lock_input_spends( + entry: &WalletRestoreEntryFFI, +) -> BTreeMap { + use dashcore::hashes::Hash; + + let mut spends = BTreeMap::new(); + if entry.asset_lock_input_spends.is_null() || entry.asset_lock_input_spends_count == 0 { + return spends; + } + let rows = unsafe { + slice::from_raw_parts( + entry.asset_lock_input_spends, + entry.asset_lock_input_spends_count, + ) + }; + for row in rows { + // A fixed 32-byte array always parses as a `Txid`, so the real + // malformed-row check is content: an all-zero txid on either side is + // the shape of a row a host zero-initialised and never filled in, + // and no genuine transaction hashes to zero. + if row.prev_txid == [0u8; 32] || row.spender_txid == [0u8; 32] { + tracing::warn!( + wallet_id = %hex::encode(entry.wallet_id), + "load: skipping asset-lock input-spend row with zeroed txid bytes" + ); + continue; + } + let prev_txid = dashcore::Txid::from_slice(&row.prev_txid) + .expect("32-byte array always parses as Txid"); + let spender_txid = dashcore::Txid::from_slice(&row.spender_txid) + .expect("32-byte array always parses as Txid"); + // Match the known discriminants exactly rather than comparing by + // order: the contract defines 0..=3, and an unknown value must + // degrade to "no evidence" rather than being read as finality. The + // screen treats `in_block` as conclusive and returns a terminal code + // the host may act on by discarding the lock, so a malformed or + // forward-versioned byte manufacturing that verdict would be unsafe. + spends.insert( + dashcore::OutPoint { + txid: prev_txid, + vout: row.vout, + }, + platform_wallet::wallet::platform_wallet::RestoredSpend { + spender: spender_txid, + height: (row.spender_height != 0).then_some(row.spender_height), + in_block: matches!( + row.spender_context, + TX_CONTEXT_RAW_IN_BLOCK | TX_CONTEXT_RAW_IN_CHAIN_LOCKED_BLOCK + ), + chain_locked: row.spender_context == TX_CONTEXT_RAW_IN_CHAIN_LOCKED_BLOCK, + }, + ); + } + if !spends.is_empty() { + // "rows", not "conflicts": the host emits whatever spender the + // mirror linked, which for a healthy broadcast lock is the lock's + // own transaction — whether a row is a conflict is decided + // per-lock by the screen, not here. + tracing::info!( + wallet_id = %hex::encode(entry.wallet_id), + count = spends.len(), + "load: restored asset-lock input-spend rows" + ); + } + spends +} + /// Rebuild the `unused_asset_locks` map carried on /// [`ClientWalletStartState`] from the `tracked_asset_locks` slice the /// Swift load callback hands back. Mirrors the encoding used by @@ -4998,6 +5070,27 @@ fn status_from_u8(b: u8) -> Result Result, PersistenceError> { @@ -5731,7 +5824,7 @@ fn restore_unresolved_asset_lock_tx_records( // lock at `Built` / `Broadcast` has by definition not yet // observed IS-lock or block confirmation). let context = match rec.context_raw { - 2 => { + TX_CONTEXT_RAW_IN_BLOCK => { let block_hash = dashcore::BlockHash::from_slice(&rec.block_hash).map_err(|e| { PersistenceError::backend(format!( "load: malformed block_hash on unresolved asset-lock tx record: {}", @@ -5744,7 +5837,7 @@ fn restore_unresolved_asset_lock_tx_records( rec.block_timestamp as u32, )) } - 3 => { + TX_CONTEXT_RAW_IN_CHAIN_LOCKED_BLOCK => { let block_hash = dashcore::BlockHash::from_slice(&rec.block_hash).map_err(|e| { PersistenceError::backend(format!( "load: malformed block_hash on unresolved asset-lock tx record: {}", @@ -5889,7 +5982,7 @@ fn restore_provider_special_txs( }; let context = match rec.context_raw { - ctx @ (2 | 3) => { + ctx @ (TX_CONTEXT_RAW_IN_BLOCK | TX_CONTEXT_RAW_IN_CHAIN_LOCKED_BLOCK) => { let block_hash = dashcore::BlockHash::from_slice(&rec.block_hash).map_err(|e| { PersistenceError::backend(format!( "load: malformed block_hash on provider special tx record: {}", @@ -5904,7 +5997,7 @@ fn restore_provider_special_txs( if rec.has_block_position { info = info.with_position(rec.block_position); } - if ctx == 2 { + if ctx == TX_CONTEXT_RAW_IN_BLOCK { TransactionContext::InBlock(info) } else { TransactionContext::InChainLockedBlock(info) @@ -5958,6 +6051,44 @@ mod tests { //! exercising the in-memory mutation against synthetic input. use super::*; + use crate::wallet_restore_types::AssetLockInputSpendFFI; + + // --- asset-lock input-spend linkage decode --- + + /// The context byte decides whether persisted evidence may condemn a + /// tracked lock, so only the two known block discriminants may read as + /// final. An unknown value — corrupt row, forward-versioned host — must + /// degrade to "no evidence" rather than manufacture finality. + #[test] + fn asset_lock_input_spend_context_decodes_only_known_block_discriminants() { + for (context, expect_in_block, expect_chain_locked) in [ + (0u32, false, false), // mempool + (1, false, false), // InstantSend, replaceable + (2, true, false), // in a block + (3, true, true), // chain-locked block + (4, false, false), //unknown / forward-versioned + (u32::MAX, false, false), + ] { + let row = AssetLockInputSpendFFI { + prev_txid: [7u8; 32], + vout: 1, + spender_txid: [9u8; 32], + spender_height: 1_532_949, + spender_context: context, + }; + // The decoder reads only `wallet_id` (for the log line) and the + // spend slice, so a zeroed entry is a sound stand-in for the + // ~40 pointer fields it never touches. + let mut entry: WalletRestoreEntryFFI = unsafe { std::mem::zeroed() }; + entry.asset_lock_input_spends = &row; + entry.asset_lock_input_spends_count = 1; + + let spends = build_asset_lock_input_spends(&entry); + let spend = spends.values().next().expect("row decodes"); + assert_eq!(spend.in_block, expect_in_block, "context={context}"); + assert_eq!(spend.chain_locked, expect_chain_locked, "context={context}"); + } + } // --- persists_durably: the fail-closed durability attestation --- diff --git a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index fdbd641a57..fcd15a884b 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -516,6 +516,33 @@ pub struct UnresolvedAssetLockTxRecordFFI { pub first_seen: u64, } +/// One outpoint an unresolved asset lock spends, together with the +/// transaction the persistence mirror recorded as having spent it. +/// +/// The host emits whatever spender the mirror linked — INCLUDING the lock's +/// own transaction (the normal broadcast case) — because at emission time it +/// holds a flat outpoint set with no per-lock association. Consumers filter +/// out the lock's own txid themselves; a row is a conflict only relative to +/// a particular lock. The iOS host additionally emits only spends its mirror +/// marked settled (in-block), so `spender_context` values `0` / `1` are +/// decoded defensively but do not occur from that host today. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct AssetLockInputSpendFFI { + /// The outpoint the asset lock spends: funding txid, then index. + pub prev_txid: [u8; 32], + pub vout: u32, + /// The transaction that actually took it. + pub spender_txid: [u8; 32], + /// Height of the block holding the spender; `0` when unknown. + pub spender_height: u32, + /// The spender's `TransactionContext` discriminant, verbatim: `0` + /// mempool, `1` InstantSend, `2` in a block, `3` in a chain-locked + /// block. The host reports what it stored; deciding which of those + /// count as final is Rust's call, not the mirror's. + pub spender_context: u32, +} + /// A persisted provider special transaction (ProRegTx / ProUpServTx / /// ProUpRegTx / ProUpRevTx) staged back into the wallet at load so its /// DIP-3 payload record is resident on the provider-key accounts again. @@ -651,6 +678,29 @@ pub struct WalletRestoreEntryFFI { /// re-apply a fresh chainlock. pub last_applied_chain_lock_bytes: *const u8, pub last_applied_chain_lock_bytes_len: usize, + /// The spenders the persisted state records for the outpoints the + /// unresolved asset locks spend — the lock's own spend included, see + /// [`AssetLockInputSpendFFI`]. + /// + /// The double-spend screen in `resume_asset_lock` reads the in-memory + /// transaction history, which this load path deliberately leaves empty + /// apart from the unresolved locks themselves — so at app-launch + /// catch-up it scans nothing and cannot fire, however dead the lock is. + /// The persistence mirror does know: the funding outpoint's row carries + /// the txid that spent it. Handing those few outpoints over is what lets + /// the screen work at the only moment it matters. `null` / `0` when + /// there are none. + /// + /// ABI note: these two fields sit at the TAIL of the struct on purpose, + /// and any future addition must go below them. This struct crosses the + /// boundary as a bare pointer with no size or version tag, so appending + /// is the only layout change that keeps every earlier field at its old + /// offset; inserting mid-struct would shift the fields after it and turn + /// a stale host/library pairing into silently misread memory. (In-tree + /// builds regenerate the header in lockstep; this discipline is for the + /// pairing nobody planned.) + pub asset_lock_input_spends: *const AssetLockInputSpendFFI, + pub asset_lock_input_spends_count: usize, } // SAFETY: Pointers are Swift-owned and lifetime-scoped to the callback. diff --git a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs index 83b6d86074..42dfe83a0e 100644 --- a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs @@ -9,6 +9,7 @@ use std::collections::BTreeMap; use crate::changeset::identity_manager_start_state::IdentityManagerStartState; use crate::wallet::asset_lock::tracked::TrackedAssetLock; +use crate::wallet::platform_wallet::RestoredSpend; use dashcore::OutPoint; use key_wallet::wallet::ManagedWalletInfo; use key_wallet::Wallet; @@ -33,4 +34,11 @@ pub struct ClientWalletStartState { /// Asset locks that have not yet been consumed by an identity /// registration / top-up, keyed by account index → outpoint. pub unused_asset_locks: BTreeMap>, + /// What the host mirror recorded as the spender of each outpoint those + /// asset locks spend (the lock's own spend included — consumers filter), + /// as [`RestoredSpend`] rows. This is the evidence the double-spend + /// screen cannot obtain for itself at load time, since the in-memory + /// transaction history it reads is empty then; `RestoredSpend::in_block` + /// is the settlement gate and `chain_locked` the only finality claim. + pub asset_lock_input_spends: BTreeMap, } diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 4a4d8a9d9c..7a5f0ca072 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -67,6 +67,7 @@ impl PlatformWalletManager

{ wallet_info, identity_manager, unused_asset_locks, + asset_lock_input_spends, } = wallet_state; // Flatten the (account → outpoint → lock) map into the flat @@ -99,6 +100,7 @@ impl PlatformWalletManager

{ generation: Arc::clone(&generation), identity_manager: IdentityManager::from(identity_manager), tracked_asset_locks, + restored_asset_lock_input_spends: asset_lock_input_spends, dpns_name_states: std::collections::BTreeMap::new(), }; @@ -270,6 +272,7 @@ mod idempotent_load_tests { wallet_info: self.managed.clone(), identity_manager: IdentityManagerStartState::default(), unused_asset_locks: BTreeMap::new(), + asset_lock_input_spends: Default::default(), }, ); Ok(ClientStartState { diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index c9eafee286..515f809439 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -363,6 +363,7 @@ impl PlatformWalletManager

{ generation: Arc::clone(&generation), identity_manager: crate::wallet::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: std::collections::BTreeMap::new(), }; diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 31c7abdf44..1577fec724 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -254,6 +254,7 @@ pub(crate) async fn funded_wallet_manager_with_outputs( generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: BTreeMap::new(), }; @@ -324,6 +325,7 @@ pub(crate) async fn funded_wallet_manager_dual_standard( generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: BTreeMap::new(), }; let mut wm = WalletManager::::new(Network::Testnet); @@ -426,6 +428,7 @@ pub(crate) async fn funded_wallet_manager_with_contact( generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: BTreeMap::new(), }; let mut wm = WalletManager::::new(Network::Testnet); @@ -502,6 +505,7 @@ pub(crate) async fn funded_coinjoin_wallet_manager() -> ( generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: BTreeMap::new(), }; @@ -674,6 +678,7 @@ pub(crate) async fn mnemonic_wallet_manager( generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: BTreeMap::new(), }; diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 4390740640..b215c9b6b6 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -426,6 +426,7 @@ mod tests { generation: std::sync::Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: BTreeMap::new(), } } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs index 7afdd62c26..e3d2081bba 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs @@ -595,6 +595,7 @@ mod tests { generation: std::sync::Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: BTreeMap::new(), }; assert_eq!( diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index 1f406c84fc..d62cd2bf31 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -260,9 +260,13 @@ fn first_confirmed_input_conflict( .last_applied_chain_lock() .map(|chain_lock| chain_lock.block_height); - info.core_wallet - .transaction_history() - .into_iter() + let history = info.core_wallet.transaction_history(); + + // Live history first. Records promote and demote in-session, so when + // one is present it is the freshest evidence there is; the restored map + // below is a load-time snapshot and must never outrank it. + if let Some(hit) = history + .iter() .filter(|record| record.txid != lock_txid && record.is_confirmed()) .find_map(|record| { let conflicting_input = record @@ -278,6 +282,41 @@ fn first_confirmed_input_conflict( .is_some_and(|(boundary, spender_height)| spender_height <= boundary); Some((conflicting_input, record.txid, height, spender_chain_locked)) }) + { + return Some(hit); + } + + // The persistence mirror's answer, restored at load. Two gaps only this + // source covers: app-launch catch-up, when `transaction_history()` holds + // nothing but the unresolved locks' own records, and a chainlocked + // spender that `apply_chain_lock` already evicted from history. It is a + // snapshot — nothing demotes a row after a reorg — so it runs second, + // and a row whose spender the live history has since re-observed + // WITHOUT confirmation is treated as stale and skipped: the live record + // is the same transaction seen more recently, and it says "not settled". + // A spender absent from history entirely is indistinguishable from the + // load blind spot this map exists for, so such a row is trusted; that + // residual mis-verdict window closes only when the mirror learns to + // demote spend links on reorg. + lock_inputs.iter().find_map(|input| { + let (input, spend) = info + .restored_asset_lock_input_spends + .get_key_value(input) + .filter(|(_, spend)| spend.spender != lock_txid && spend.in_block)?; + let contradicted = history + .iter() + .any(|record| record.txid == spend.spender && !record.is_confirmed()); + if contradicted { + return None; + } + // No chainlock-boundary fallback here, unlike the live scan above: + // the boundary only proves finality for a transaction known to sit + // in the surviving chain at that height, which a live record + // attests and a persisted snapshot does not — the recorded height + // may name a block a reorg has since dropped. Only the mirror's own + // observed chainlock context may claim that confidence tier. + Some((*input, spend.spender, spend.height, spend.chain_locked)) + }) } impl AssetLockManager { @@ -659,7 +698,10 @@ mod tests { use std::time::Duration; use async_trait::async_trait; + use dashcore::bls_sig_utils::BLSSignature; + use dashcore::ephemerealdata::chain_lock::ChainLock; use dashcore::hashes::Hash; + use dashcore::prelude::CoreBlockHeight; use dashcore::{BlockHash, Network, OutPoint, Transaction, TxIn, Txid}; use key_wallet::account::account_collection::AccountCollection; use key_wallet::account::account_type::StandardAccountType; @@ -1071,6 +1113,7 @@ mod tests { generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: BTreeMap::new(), }; let out_point = OutPoint::new(tx.txid(), 0); @@ -1209,6 +1252,49 @@ mod tests { ); } + /// Install a restored spend-linkage row for the lock's funded input, + /// the way the FFI load path does — the only source available at + /// app-launch catch-up, when `transaction_history()` is empty. + async fn restore_spend(&self, spender: Txid, in_block: bool) { + self.restore_spend_with(spender, in_block, in_block).await + } + + /// As [`Self::restore_spend`], but with the persisted row's + /// chainlock flag chosen independently of `in_block` — the state a + /// spender mined before a chainlock the wallet applied later is + /// restored in, since the promotion that would have set the flag + /// never ran against the stored row. + async fn restore_spend_with(&self, spender: Txid, in_block: bool, chain_locked: bool) { + let mut wm = self.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&self.wallet_id) + .expect("wallet must remain registered"); + info.restored_asset_lock_input_spends.insert( + self.funded_input(), + crate::wallet::platform_wallet::RestoredSpend { + spender, + height: in_block.then_some(1_532_949), + in_block, + chain_locked, + }, + ); + } + + /// Park the wallet's applied-chainlock watermark at `height` + /// without running the promotion pass, so restored rows keep the + /// pre-chainlock context they were persisted with. + async fn set_chain_lock_boundary(&self, height: CoreBlockHeight) { + let mut wm = self.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&self.wallet_id) + .expect("wallet must remain registered"); + info.core_wallet.metadata.last_applied_chain_lock = Some(ChainLock { + block_height: height, + block_hash: BlockHash::all_zeros(), + signature: BLSSignature::from([0u8; 96]), + }); + } + /// File `record` in the wallet's BIP44 account by direct map /// insertion. Going through the detection pipeline instead would /// route the record by relevance and, for a chainlocked context, @@ -1294,6 +1380,177 @@ mod tests { /// must fail with the typed terminal error and must not touch the /// network on the way out. /// + /// At app-launch catch-up `transaction_history()` is empty — the load + /// path restores only the unresolved locks' own funding records — so the + /// restored spend linkage is the sole evidence available. A confirmed + /// spender there must condemn the lock exactly as a history record does. + #[tokio::test] + async fn restored_spend_linkage_reports_the_conflict_with_an_empty_history() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + let spender_txid = transaction_spending(fixture.funded_input()).txid(); + fixture.restore_spend(spender_txid, true).await; + + let error = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("a double-spent asset lock must fail, not wait"); + match error { + PlatformWalletError::AssetLockInputConflict { + input, + spent_by, + spender_chain_locked, + .. + } => { + assert_eq!(input, fixture.funded_input()); + assert_eq!(spent_by, spender_txid); + assert!(spender_chain_locked); + } + other => panic!("expected AssetLockInputConflict, got {other:?}"), + } + assert_eq!( + fixture.broadcast_count(), + 0, + "the screen must fire before the re-broadcast" + ); + } + + /// A restored spender mined below a chainlock the wallet applied later + /// still reports `spender_chain_locked: false`. The live history scan + /// may promote a record against the boundary because a live record + /// attests the transaction sits in the surviving chain at that height; + /// a persisted snapshot attests only that a block held it when the row + /// was written — a reorg may have dropped that block before the + /// chainlock landed, and nothing ever demotes the row. The conflict is + /// still reported (the screen fires either way); only the chainlock + /// confidence tier is withheld, so a host that auto-discards solely on + /// `spender_chain_locked` cannot be steered by a stale snapshot. + #[tokio::test] + async fn restored_spend_below_the_chainlock_boundary_stays_unpromoted() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + let spender_txid = transaction_spending(fixture.funded_input()).txid(); + fixture.restore_spend_with(spender_txid, true, false).await; + fixture.set_chain_lock_boundary(1_532_950).await; + + let error = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("a double-spent asset lock must fail, not wait"); + match error { + PlatformWalletError::AssetLockInputConflict { + spent_by, + spender_chain_locked, + .. + } => { + assert_eq!(spent_by, spender_txid, "the conflict itself still fires"); + assert!( + !spender_chain_locked, + "a snapshot height must not claim chainlock finality: the \ + boundary only proves finality for a block the live chain \ + is known to contain" + ); + } + other => panic!("expected AssetLockInputConflict, got {other:?}"), + } + } + + /// Live history outranks the restored snapshot. Records promote and + /// demote in-session; the snapshot cannot, so when both sources speak + /// for the same input the fresher one must win — here they name + /// different spenders, and the reported conflict is the history + /// record's. + #[tokio::test] + async fn live_history_outranks_the_restored_snapshot() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + let stale_spender = transaction_spending(fixture.funded_input()).txid(); + fixture.restore_spend(stale_spender, true).await; + + let mut live_spender = transaction_spending(fixture.funded_input()); + live_spender.lock_time = 1; // distinct txid, same spent outpoint + let live_txid = live_spender.txid(); + fixture + .file_record(record_for(live_spender, confirmed_at(1_234))) + .await; + + let error = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("a double-spent asset lock must fail, not wait"); + match error { + PlatformWalletError::AssetLockInputConflict { spent_by, .. } => assert_eq!( + spent_by, live_txid, + "the live record, not the load-time snapshot, names the spender" + ), + other => panic!("expected AssetLockInputConflict, got {other:?}"), + } + } + + /// A restored row whose spender the live history has since re-observed + /// WITHOUT confirmation is stale — the same transaction seen more + /// recently says "not settled" — and must not condemn the lock. This is + /// the reorg shape: the spender's block was dropped, the wallet + /// re-observed it in the mempool, and only the snapshot still calls it + /// settled. + #[tokio::test] + async fn a_live_unconfirmed_sighting_retracts_the_restored_verdict() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + let spender = transaction_spending(fixture.funded_input()); + let spender_txid = spender.txid(); + fixture.restore_spend(spender_txid, true).await; + fixture + .file_record(record_for(spender, TransactionContext::Mempool)) + .await; + + let error = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("no proof means the resume runs and then times out"); + assert!( + !matches!(error, PlatformWalletError::AssetLockInputConflict { .. }), + "a demoted live sighting must retract the snapshot verdict, got {error:?}" + ); + } + + /// A restored spender that never reached a block proves nothing — a + /// mempool sighting can still be replaced — and the lock's own txid is + /// not a conflict with itself. Neither may condemn the lock. + #[tokio::test] + async fn restored_spend_linkage_ignores_a_non_final_spender_and_the_lock_itself() { + for (spender_is_the_lock, in_block) in [(false, false), (true, true)] { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + let spender_txid = if spender_is_the_lock { + fixture.transaction.txid() + } else { + transaction_spending(fixture.funded_input()).txid() + }; + fixture.restore_spend(spender_txid, in_block).await; + + let error = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("no proof means the resume runs and then times out"); + assert!( + !matches!(error, PlatformWalletError::AssetLockInputConflict { .. }), + "spender_is_the_lock={spender_is_the_lock} in_block={in_block}: \ + got {error:?}" + ); + } + } + /// The spender here is merely `InBlock`, which is the shape the screen /// actually meets in production: under the default /// `keep-finalized-transactions = OFF` build a chainlocked record is diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index ec6eda0072..a0b06a6511 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -3586,6 +3586,7 @@ mod sweep_tests { generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: BTreeMap::new(), } } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index b6d29e67c4..bf83a3898d 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -4,7 +4,8 @@ use std::collections::BTreeMap; use std::ops::{Deref, DerefMut}; use std::sync::Arc; -use dashcore::OutPoint; +use dashcore::prelude::CoreBlockHeight; +use dashcore::{OutPoint, Txid}; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; #[cfg(feature = "shielded")] @@ -228,6 +229,32 @@ fn plan_shield_inputs( }) } +/// What the host mirror recorded about the transaction that spent an +/// outpoint, restored at load. +/// +/// Its one consumer is the double-spend screen in `resume_asset_lock`, which +/// needs proof the outpoint is *settled* and so acts only on `in_block` +/// spenders — a mempool spend can still be replaced. The iOS host currently +/// emits only in-block spends (its builder is gated on the mirror's own +/// settled flag), so rows with `in_block: false` are decoded defensively but +/// do not occur in practice; any future reader that needs unsettled spends +/// must first widen the host-side gate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RestoredSpend { + /// The transaction the mirror recorded as spending the outpoint. + pub spender: Txid, + /// Height of the block holding it, when it reached one. + pub height: Option, + /// Whether it reached a block at all — the bar for "settled". + pub in_block: bool, + /// Whether the mirror itself observed that block chain-locked. This is + /// the only basis on which restored evidence may claim chainlock + /// finality — the screen deliberately does not promote a persisted + /// height against the live boundary, because a snapshot height cannot + /// prove the block survived to be buried by it. + pub chain_locked: bool, +} + /// Consolidated mutable state for a platform wallet. /// /// Lives inside `WalletManager.wallet_infos`. The `Wallet` @@ -257,6 +284,20 @@ pub struct PlatformWalletInfo { pub(crate) generation: Arc, pub identity_manager: IdentityManager, pub tracked_asset_locks: BTreeMap, + /// What the persistence mirror recorded as the spender of each outpoint + /// a tracked asset lock spends, keyed by outpoint. Includes the lock's + /// own spend of its inputs — the host emits whatever the mirror linked, + /// and consumers filter out the lock's own txid themselves. + /// + /// Restored at load only, and consulted strictly AFTER the live history + /// scan: the double-spend screen in `resume_asset_lock` normally reads + /// `core_wallet.transaction_history()`, but the FFI load path leaves + /// that map empty apart from the unresolved locks themselves, so at + /// app-launch catch-up — the one moment the screen runs — it has + /// nothing to scan. This snapshot fills that blind spot (and the + /// chainlocked-spender eviction gap); live records outrank it whenever + /// they exist, because nothing demotes these rows after a reorg. + pub restored_asset_lock_input_spends: BTreeMap, /// DPNS name states with sale price (username marketplace), keyed by /// domain document id. Session-lifetime working set for the /// marketplace sync/orchestration ops; the durable copy is the diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs index b4a2f7d05b..36a6aa4afe 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -40,6 +40,7 @@ impl WalletInfoInterface for PlatformWalletInfo { generation: std::sync::Arc::new(super::core::WalletGeneration::new()), identity_manager: super::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: std::collections::BTreeMap::new(), } } @@ -53,6 +54,7 @@ impl WalletInfoInterface for PlatformWalletInfo { generation: std::sync::Arc::new(super::core::WalletGeneration::new()), identity_manager: super::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: std::collections::BTreeMap::new(), } } diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 917d26094d..fcf56ffc35 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -2368,6 +2368,11 @@ fn build_wallet_restore_entry( tracked_asset_locks_count: 0, unresolved_asset_lock_tx_records: ptr::null(), unresolved_asset_lock_tx_records_count: 0, + // Not staged on this host yet: the Kotlin persister has no + // equivalent of the Swift spend-linkage query, so the conflict + // screen keeps its previous transaction-history behaviour here. + asset_lock_input_spends: ptr::null(), + asset_lock_input_spends_count: 0, core_address_pools: ptr::null(), core_address_pools_count: 0, last_applied_chain_lock_bytes: ptr::null(), diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 06b5289ed8..62ab306325 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1170,7 +1170,16 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // catch-up classifier to recognise as ours. The next // upsert of this same tx with a confirmed context flips // `isSpent` then. - let expectedIsSpent = Self.spendIsInBlock(spendingTransaction) + // + // Monotonic on purpose (mirrors the same guard on the + // sweep-persistence branch, so the merge is a no-op): a + // later mempool sighting of a DIFFERENT spender must not + // downgrade a flag an in-block spend already set — that + // stomp would also blank the spend-linkage evidence the + // asset-lock conflict screen restores at the next launch. + // Nothing upstream ever demotes a confirmed spend, so a + // true here is never stale. + let expectedIsSpent = txo.isSpent || Self.spendIsInBlock(spendingTransaction) let linkageChanged = txo.isSpent != expectedIsSpent || txo.spendingTransaction?.txid != spendingTxid @@ -5023,6 +5032,19 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { entry.unresolved_asset_lock_tx_records = unresolvedBuf.map { UnsafePointer($0) } entry.unresolved_asset_lock_tx_records_count = UInt(unresolvedCount) + // Which transaction took each output this wallet spent in a + // block. Rust filters this down to the outpoints its unresolved + // locks spend and uses it to screen them for a double spend — + // evidence it cannot obtain for itself at load, since the + // transaction history that screen normally reads is empty then. + let (inputSpendBuf, inputSpendCount) = + buildAssetLockInputSpendBuffer( + walletId: w.walletId, + allocation: allocation + ) + entry.asset_lock_input_spends = inputSpendBuf.map { UnsafePointer($0) } + entry.asset_lock_input_spends_count = UInt(inputSpendCount) + // Provider special transactions (ProRegTx / ProUpServTx / // ProUpRegTx / ProUpRevTx) re-staged onto the provider-key // accounts so #876 retention keeps them and the masternode @@ -5399,6 +5421,179 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return (buf, written) } + /// Report which transaction the mirror recorded as spending the inputs of + /// this wallet's unresolved asset locks. Emits settled spends only: the + /// loop below is gated on `isSpent`, which this mirror flips exclusively + /// for in-block spenders, so mempool / InstantSend sightings never cross + /// here — a consumer that needs unsettled spends must widen this gate + /// first. Within that set the spender is whatever the mirror linked, + /// the lock's own transaction included; Rust filters per lock. + /// + /// Rust knows which outpoints its locks spend but not who took them: the + /// in-memory transaction history it would normally consult is empty at + /// load. The spender's context is passed through verbatim; how much + /// finality each emitted context carries is Rust's decision. + private func buildAssetLockInputSpendBuffer( + walletId: Data, + allocation: LoadAllocation + ) -> (UnsafeMutablePointer?, Int) { + // Resolve the outpoints of interest first — the inputs of the + // unresolved asset locks — and query only those. Fetching the + // wallet's spent TXOs and capping the result would be wrong: nothing + // orders that set, so a wallet with more history than the cap could + // return a page that excludes the very outpoint the screen needs, and + // startup would be back to no evidence and a full proof wait. + let lockInputs = unresolvedAssetLockInputs(walletId: walletId) + guard !lockInputs.isEmpty else { return (nil, 0) } + + // One point lookup per outpoint, rather than one query with the whole + // set inlined: `outpoint` is the unique key, so each fetch is an index + // hit, and equality is the one predicate shape this file already + // relies on everywhere. A captured-collection `contains` would have to + // survive SwiftData's own translation, and this query runs on the load + // path where a translation failure is not something `try?` can catch. + // + // Everything else is decided in Swift, on the fetched row — never in + // the predicate. In particular `spendingTransaction` is read here and + // not chased in a predicate: that drops SwiftData onto a + // nested-optional codepath that crashes the process (see + // `PersistentTxo.isSpent`, which exists for exactly this reason). + // `isSpent` is likewise checked in Swift; it flips under the same + // in-block condition the conflict screen requires of a spender, so it + // stays as the guard, just on this side of the fetch. + // + // Rows are collected into an array first: a row with no spender or a + // malformed txid is skipped, so the count is not known until the loop + // ends — and registering the buffer for a count larger than the + // initialized prefix would have `release()` deinitialize uninitialized + // memory, which is UB. + var rows: [AssetLockInputSpendFFI] = [] + rows.reserveCapacity(lockInputs.count) + for key in lockInputs { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == key } + ) + descriptor.fetchLimit = 1 + descriptor.relationshipKeyPathsForPrefetching = [\.spendingTransaction] + // Ownership goes through `resolvedWalletId`, not the raw column: + // `PersistentTxo.walletId` is empty on rows written before it + // existed, and the spend-reconciliation path sets `isSpent` and + // the spender link without backfilling it. Comparing the column + // directly discards exactly the legacy rows a confirmed + // conflicting spender is recorded on, leaving the restored map + // empty and startup back in the full proof wait. This is the same + // fallback `loadWalletList` already uses. + guard let txo = try? backgroundContext.fetch(descriptor).first else { continue } + guard Self.resolvedWalletId(of: txo) == walletId else { + // Ownership can miss for a same-seed twin wallet entry (the + // outpoint-unique row belongs to the sibling) or a fully + // orphaned legacy row. Evidence found-but-discarded must at + // least be diagnosable, since the cost is the full proof + // wait this path exists to remove. + SDKLogger.log( + "load: asset-lock input-spend row skipped on ownership; " + + "row resolves to a different wallet") + continue + } + guard txo.isSpent, + let spender = txo.spendingTransaction, + spender.txid.count == 32 + else { continue } + + // The row's identity comes from `key` — the 36-byte outpoint the + // fetch matched on — not from the fetched row's computed `txid` + // property, whose primary source is the `transaction` + // relationship. On a corrupt row the two can diverge, and Rust + // keys the lock's inputs by exactly this outpoint: deriving the + // fields from anything else would turn the keyed exact match + // back into a guess. + let keyBytes = [UInt8](key) + guard keyBytes.count == 36 else { continue } + var row = AssetLockInputSpendFFI() + keyBytes[0..<32].withUnsafeBytes { src in + Swift.withUnsafeMutableBytes(of: &row.prev_txid) { dst in + dst.copyMemory(from: src) + } + } + row.vout = UInt32(keyBytes[32]) + | (UInt32(keyBytes[33]) << 8) + | (UInt32(keyBytes[34]) << 16) + | (UInt32(keyBytes[35]) << 24) + spender.txid.withUnsafeBytes { src in + Swift.withUnsafeMutableBytes(of: &row.spender_txid) { dst in + dst.copyMemory(from: src) + } + } + row.spender_height = spender.blockHeight + row.spender_context = spender.context + rows.append(row) + } + guard !rows.isEmpty else { return (nil, 0) } + + let buf = UnsafeMutablePointer.allocate(capacity: rows.count) + buf.initialize(from: rows, count: rows.count) + allocation.assetLockInputSpendBuffers.append((buf, rows.count)) + return (buf, rows.count) + } + + /// The 36-byte outpoints spent by this wallet's unresolved asset locks + /// (`statusRaw < 2`), decoded from the funding transaction each lock row + /// carries. Deduplicated, since two locks built from the same UTXO name + /// the same outpoint and the caller does one fetch per element. + /// + /// The bytes come from `PersistentAssetLock.transactionBytes`, not from a + /// `PersistentTransaction` row: a Built / Broadcast lock whose own + /// transaction never reached the transaction table is precisely the state + /// this path exists for, and its input can still have been taken by a + /// confirmed spender. Requiring the row would skip that lock and leave + /// the restored conflict map blind — the startup proof-wait this branch + /// is fixing. The lock row is also the authoritative copy: it is what + /// `buildAssetLockRestoreBuffer` hands Rust, and a row without those + /// bytes is dropped there as broken. + /// + /// The relationship cannot answer this either: `PersistentTransaction. + /// inputs` is the inverse of `PersistentTxo.spendingTransaction`, so for + /// exactly the case that matters — the outpoint taken by a *different* + /// transaction — it points at the winner and the lock's own edge is + /// absent. + private func unresolvedAssetLockInputs(walletId: Data) -> [Data] { + let descriptor = FetchDescriptor( + predicate: #Predicate { entry in + entry.walletId == walletId && entry.statusRaw < 2 + } + ) + guard let locks = try? backgroundContext.fetch(descriptor), !locks.isEmpty else { + return [] + } + // The decoder's network argument only shapes the address rendering, + // which this caller discards — the outpoints decode identically on + // any network. A legacy wallet row whose network was never resolved + // must not lose its conflict evidence over a cosmetic parameter, so + // default rather than bail (the sibling load-path builders tolerate + // a nil network the same way). + let network = walletNetwork(walletId: walletId) ?? .testnet + + var outpoints: [Data] = [] + var seen = Set() + for lock in locks { + guard !lock.transactionBytes.isEmpty, + let decoded = try? TransactionDecoder.decode( + lock.transactionBytes, + network: network + ) + else { continue } + + for input in decoded.inputs { + guard input.prevTxid.count == 32 else { continue } + let key = PersistentTxo.makeOutpoint(txid: input.prevTxid, vout: input.prevVout) + if seen.insert(key).inserted { + outpoints.append(key) + } + } + } + return outpoints + } + /// Build the per-wallet `UnresolvedAssetLockTxRecordFFI` array /// for the load callback. One entry per `PersistentAssetLock` row /// at `statusRaw < 2` (Built / Broadcast) whose funding tx has a @@ -6345,6 +6540,10 @@ private final class LoadAllocation { /// so the next chain-lock event can cascade-promote them. The /// `tx_bytes` buffer each row references lives in `scalarBuffers`. var unresolvedAssetLockTxRecordArrays: [(UnsafeMutablePointer, Int)] = [] + /// `AssetLockInputSpendFFI` arrays per wallet — which transaction took + /// each output this wallet spent, so Rust can screen an unresolved asset + /// lock for a double spend at load time. + var assetLockInputSpendBuffers: [(UnsafeMutablePointer, Int)] = [] /// Per-wallet `ProviderSpecialTxRestoreEntryFFI` arrays — provider /// special txs re-staged so #876 retention keeps them resident after a /// restart. The `tx_bytes` buffer each row references lives in @@ -6421,6 +6620,10 @@ private final class LoadAllocation { ptr.deinitialize(count: count) ptr.deallocate() } + for (ptr, count) in assetLockInputSpendBuffers { + ptr.deinitialize(count: count) + ptr.deallocate() + } for (ptr, count) in unresolvedAssetLockTxRecordArrays { ptr.deinitialize(count: count) ptr.deallocate() diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockInputSpendRestoreTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockInputSpendRestoreTests.swift new file mode 100644 index 0000000000..00ae347111 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockInputSpendRestoreTests.swift @@ -0,0 +1,202 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +/// Coverage for the spend-linkage half of the asset-lock restore: +/// `asset_lock_input_spends`, the evidence the conflict screen runs on at +/// app-launch catch-up. +/// +/// At that moment the wallet's in-memory transaction history is empty, so a +/// lock whose input a different, confirmed transaction already took has no +/// other way to be recognised as dead — it sits in the full proof wait +/// instead. The rows restored here are the only source that works. +@MainActor +final class AssetLockInputSpendRestoreTests: XCTestCase { + + private let walletId = Data(repeating: 0x01, count: 32) + /// The coin the tracked asset lock spends, and that a different + /// transaction is recorded as having taken. + private let fundingTxid = Data(repeating: 0x41, count: 32) + private let fundingVout: UInt32 = 0 + private let lockTxid = Data(repeating: 0x42, count: 32) + private let spenderTxid = Data(repeating: 0x43, count: 32) + + private func makeHandler() throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + return (handler, container) + } + + /// Serialize a transaction spending `input`, in the form + /// `TransactionDecoder` parses: a plain (non-special) version-2 + /// transaction with one empty-script input and one empty-script output. + private func serializedSpend(of input: (txid: Data, vout: UInt32)) -> Data { + var bytes = Data() + bytes.append(contentsOf: withUnsafeBytes(of: UInt32(2).littleEndian) { Data($0) }) + bytes.append(0x01) // one input + bytes.append(input.txid) + bytes.append(contentsOf: withUnsafeBytes(of: input.vout.littleEndian) { Data($0) }) + bytes.append(0x00) // empty scriptSig + bytes.append(contentsOf: [0xff, 0xff, 0xff, 0xff]) // sequence + bytes.append(0x01) // one output + bytes.append(contentsOf: withUnsafeBytes(of: UInt64(1_000).littleEndian) { Data($0) }) + bytes.append(0x00) // empty scriptPubKey + bytes.append(contentsOf: [0x00, 0x00, 0x00, 0x00]) // locktime + return bytes + } + + /// `:`, the form + /// `PersistentAssetLock.outPointHex` stores — produced through the SDK's + /// own encoder so the fixture cannot drift from the format the load path + /// actually reads. + private func outPointHex(txid: Data, vout: UInt32) -> String { + var raw = Data(txid) + withUnsafeBytes(of: vout.littleEndian) { raw.append(contentsOf: $0) } + return PersistentAssetLock.encodeOutPoint(rawBytes: raw) + } + + /// Seed an unresolved asset lock spending the funding coin, plus a + /// different confirmed transaction recorded as that coin's spender. + /// + /// `legacyTxoWalletId` is the whole point of the fixture: rows written + /// before `PersistentTxo.walletId` existed carry an empty value, and the + /// spend-reconciliation path sets `isSpent` and the spender link without + /// backfilling it. + private func seed(in container: ModelContainer, legacyTxoWalletId: Bool) throws { + let context = ModelContext(container) + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + context.insert(wallet) + let account = PersistentAccount( + wallet: wallet, + accountType: 0, + accountIndex: 0, + accountTypeName: "Standard" + ) + // A wallet only reaches the restore path with at least one account + // carrying an xpub — that is what Rust rebuilds the watch-only + // wallet from. + account.accountExtendedPubKeyBytes = Data(repeating: 0x30, count: 78) + context.insert(account) + + // The transaction that created the coin, and the coin itself. + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 100_000 + ) + context.insert(funding) + + // A different transaction, confirmed, recorded as having taken it. + let spender = PersistentTransaction( + txid: spenderTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 2, + blockHeight: 101, + netAmount: -100_000 + ) + context.insert(spender) + + let coin = PersistentTxo( + transaction: funding, + vout: fundingVout, + amount: 100_000, + address: "yFundAddr", + height: 100 + ) + coin.account = account + coin.walletId = legacyTxoWalletId ? Data() : walletId + coin.isSpent = true + coin.spendingTransaction = spender + context.insert(coin) + + // The tracked lock: Built (statusRaw 0), spending the funding coin. + let lock = PersistentAssetLock( + outPointHex: outPointHex(txid: lockTxid, vout: 0), + walletId: walletId, + transactionBytes: serializedSpend(of: (txid: fundingTxid, vout: fundingVout)), + fundingTypeRaw: 0, + identityIndexRaw: 0, + amountDuffs: 100_000, + statusRaw: 0 + ) + context.insert(lock) + + try context.save() + } + + /// Drive the real load path and report how many spend-linkage rows the + /// wallet's restore entry carries. + private func restoredInputSpendCount(_ handler: PlatformWalletPersistenceHandler) -> Int { + let loaded = handler.loadWalletList() + XCTAssertFalse(loaded.errored, "the load must not fail") + XCTAssertGreaterThan(loaded.count, 0, "the wallet must produce a restore entry") + guard let entries = loaded.entries, loaded.count > 0 else { return -1 } + defer { handler.loadWalletListFree(entries: UnsafeRawPointer(entries)) } + return Int(entries[0].asset_lock_input_spends_count) + } + + /// The ordinary case: the TXO carries its wallet id, and the confirmed + /// spender is reported so the conflict screen can act at startup. + func testConfirmedSpenderOfALockInputIsRestored() throws { + let (handler, container) = try makeHandler() + try seed(in: container, legacyTxoWalletId: false) + + XCTAssertEqual(restoredInputSpendCount(handler), 1) + } + + /// The same coin on a row migrated from the older schema, where + /// `walletId` was never backfilled. Comparing that column raw discards + /// exactly these rows, which leaves the restored conflict map empty and + /// sends startup back into the full proof wait this path exists to + /// prevent — so ownership has to resolve through the account instead. + func testConfirmedSpenderIsRestoredForALegacyTxoWithNoWalletId() throws { + let (handler, container) = try makeHandler() + try seed(in: container, legacyTxoWalletId: true) + + XCTAssertEqual( + restoredInputSpendCount(handler), + 1, + "a legacy TXO resolving to this wallet through its account must not be discarded" + ) + } + + /// The row payload is the one cross-language contract this feature adds, + /// and a count assertion alone would let a wrong-source copy — swapped + /// txids, a context read off the wrong transaction — ship green. Read + /// the emitted row back and pin every field to the fixture's distinct + /// values. + func testRestoredSpendRowCarriesTheExactPayload() throws { + let (handler, container) = try makeHandler() + try seed(in: container, legacyTxoWalletId: false) + + let loaded = handler.loadWalletList() + XCTAssertFalse(loaded.errored, "the load must not fail") + guard let entries = loaded.entries, loaded.count > 0 else { + return XCTFail("the wallet must produce a restore entry") + } + defer { handler.loadWalletListFree(entries: UnsafeRawPointer(entries)) } + + let entry = entries[0] + XCTAssertEqual(Int(entry.asset_lock_input_spends_count), 1) + guard let rows = entry.asset_lock_input_spends else { + return XCTFail("a count of 1 must come with a row pointer") + } + let row = rows[0] + XCTAssertEqual( + withUnsafeBytes(of: row.prev_txid) { Data($0) }, + fundingTxid, + "prev_txid is the outpoint the lock spends, raw txid order" + ) + XCTAssertEqual(row.vout, fundingVout) + XCTAssertEqual( + withUnsafeBytes(of: row.spender_txid) { Data($0) }, + spenderTxid, + "spender_txid is the transaction the mirror linked, not the funding tx" + ) + XCTAssertEqual(row.spender_height, 101, "the spender's persisted block height") + XCTAssertEqual(row.spender_context, 2, "the persisted context, verbatim") + } +}