From 029de665789e4ec98f0c6bc0ca92555dcc8ea8d2 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:46:57 +0300 Subject: [PATCH 1/6] fix(platform-wallet): give the conflict screen a source that survives the load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen reads `core_wallet.transaction_history()`, which the FFI load path deliberately leaves empty apart from the unresolved locks' own funding records. `catchUpStuckAssetLocks` runs at app launch, before block sync repopulates anything — so at the one moment the screen runs it has nothing to scan, and a lock that is provably a double spend sails past it. Measured on a testnet device: a lock at `Built` spending an outpoint that a chain-locked transaction had taken at height 1532949 produced `history_len=1`, and that one record was the lock's own funding tx, which `record.txid != lock_txid` filters out. Zero candidates. The resume then re-broadcast into the void and sat in `wait_for_proof` for the full 300s — the behaviour this screen exists to prevent. The existing tests pass because they populate the history first, so the gap is specific to the load path. The host mirror already knows: the persisted row for a spent outpoint records which transaction took it, with height and context. Carry that across the FFI into `restored_asset_lock_input_spends` and let the screen consult it before falling back to the history scan. Only a spender that reached a block settles an outpoint — a mempool sighting can still be replaced — and that judgement is made Rust-side from the context the host passes through verbatim. In-session behaviour is unchanged; the fallback scan still runs and still wins when the history is populated. Co-Authored-By: Claude Opus 5 --- .../rs-platform-wallet-ffi/src/persistence.rs | 63 ++++++++++++++ .../src/wallet_restore_types.rs | 39 +++++++++ .../changeset/client_wallet_start_state.rs | 9 ++ .../rs-platform-wallet/src/manager/load.rs | 3 + .../src/manager/wallet_lifecycle.rs | 1 + .../rs-platform-wallet/src/test_support.rs | 5 ++ .../rs-platform-wallet/src/wallet/apply.rs | 1 + .../wallet/asset_lock/sync/reconstruction.rs | 1 + .../src/wallet/asset_lock/sync/recovery.rs | 14 ++++ .../identity/network/contact_requests.rs | 1 + .../src/wallet/platform_wallet.rs | 35 +++++++- .../src/wallet/platform_wallet_traits.rs | 2 + .../PlatformWalletPersistenceHandler.swift | 82 +++++++++++++++++++ 13 files changed, 255 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 04a4e29ea1d..914cbbcbde8 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -6,6 +6,7 @@ //! SwiftData on iOS). use bincode::config; +use dashcore::prelude::CoreBlockHeight; use key_wallet::account::account_collection::AccountCollection; use key_wallet::account::{Account, AccountType, BLSAccount, EdDSAAccount, StandardAccountType}; use key_wallet::bip32::DerivationPath; @@ -4796,12 +4797,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() @@ -4843,6 +4846,66 @@ fn build_wallet_start_state( /// 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. +/// Decode the host mirror's report of which transaction took each outpoint +/// an unresolved asset lock spends. +/// +/// 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. +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 { + let (Ok(prev_txid), Ok(spender_txid)) = ( + dashcore::Txid::from_slice(&row.prev_txid), + dashcore::Txid::from_slice(&row.spender_txid), + ) else { + tracing::warn!( + wallet_id = %hex::encode(entry.wallet_id), + "load: skipping asset-lock input-spend row with malformed txid bytes" + ); + continue; + }; + // Both facts are kept, and the readers apply their own bar: the + // double-spend screen needs a spender that reached a block, the + // abandon cascade needs any spender at all. + const CONTEXT_IN_BLOCK: u32 = 2; + const CONTEXT_IN_CHAIN_LOCKED_BLOCK: u32 = 3; + 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: row.spender_context >= CONTEXT_IN_BLOCK, + chain_locked: row.spender_context >= CONTEXT_IN_CHAIN_LOCKED_BLOCK, + }, + ); + } + if !spends.is_empty() { + tracing::info!( + wallet_id = %hex::encode(entry.wallet_id), + count = spends.len(), + "load: restored asset-lock input-spend conflicts" + ); + } + 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 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 fdbd641a57f..5ab4aee5785 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,30 @@ 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. +/// +/// Emitted only when that spender is a *different* transaction from the lock +/// itself: the lock spending its own input is the normal case and carries no +/// information. A spender that reached a block can never be undone, which is +/// what makes the lock provably dead rather than merely unlucky. +#[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. @@ -625,6 +649,21 @@ pub struct WalletRestoreEntryFFI { /// unresolved asset locks. pub unresolved_asset_lock_tx_records: *const UnresolvedAssetLockTxRecordFFI, pub unresolved_asset_lock_tx_records_count: usize, + /// Outpoints an unresolved asset lock spends that the persisted state + /// already knows were taken by a *different* transaction. + /// + /// 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. + /// + /// Only conflicts are listed — an outpoint spent by the lock's own + /// transaction is not one. `null` / `0` when there are none. + pub asset_lock_input_spends: *const AssetLockInputSpendFFI, + pub asset_lock_input_spends_count: usize, /// Persisted provider special transactions (ProRegTx / ProUpServTx / /// ProUpRegTx / ProUpRevTx) re-staged onto the wallet's provider-key /// accounts so rust-dashcore #876 retention keeps them resident and 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 83b6d860742..ca613345d13 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,7 +9,10 @@ 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::prelude::CoreBlockHeight; use dashcore::OutPoint; +use dashcore::Txid; use key_wallet::wallet::ManagedWalletInfo; use key_wallet::Wallet; @@ -33,4 +36,10 @@ 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>, + /// Outpoints those asset locks spend that the host mirror reports were + /// taken by a *different* transaction — the evidence the double-spend + /// screen cannot obtain for itself at load time, since the in-memory + /// transaction history it reads is empty then. Values are + /// `(spender txid, spender height, spender is chain-locked)`. + 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 4a4d8a9d9ce..7a5f0ca0726 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 c9eafee286b..515f809439f 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 31c7abdf446..1577fec724b 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 4390740640c..b215c9b6b69 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 7afdd62c26e..e3d2081bbab 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 1f406c84fcc..10740c6be4e 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,6 +260,19 @@ fn first_confirmed_input_conflict( .last_applied_chain_lock() .map(|chain_lock| chain_lock.block_height); + // The persistence mirror's answer, restored at load. This is the only + // source that works at app-launch catch-up: `transaction_history()` is + // empty then except for the unresolved locks themselves, so the scan + // below has nothing to find however dead the lock is. The mirror knows + // because it recorded which transaction took the outpoint. + if let Some((input, spend)) = lock_inputs.iter().find_map(|input| { + info.restored_asset_lock_input_spends + .get_key_value(input) + .filter(|(_, spend)| spend.spender != lock_txid && spend.in_block) + }) { + return Some((*input, spend.spender, spend.height, spend.chain_locked)); + } + info.core_wallet .transaction_history() .into_iter() @@ -1071,6 +1084,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); 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 ec6eda0072d..a0b06a65113 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 b6d29e67c40..0890f649c16 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")] @@ -235,6 +236,26 @@ fn plan_shield_inputs( /// /// The per-generation state (lock-free balance + lifecycle gate) is stored as /// `Arc`; `Arc::ptr_eq` on it is this wallet's generation identity. +/// What the host mirror recorded about the transaction that spent an +/// outpoint, restored at load. +/// +/// Serves two readers with different bars. The double-spend screen needs +/// proof the outpoint is *settled*, so it looks only at `in_block` spenders — +/// a mempool spend can still be replaced. The abandon cascade needs the +/// opposite: any spender at all, precisely because the ones it is chasing +/// never reached a block. Both facts come from the same row. +#[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 that block is chain-locked. + pub chain_locked: bool, +} + pub struct PlatformWalletInfo { /// Core wallet metadata, accounts, UTXOs, balances. /// Delegates `WalletInfoInterface` methods. @@ -257,6 +278,18 @@ pub struct PlatformWalletInfo { pub(crate) generation: Arc, pub identity_manager: IdentityManager, pub tracked_asset_locks: BTreeMap, + /// Outpoints a tracked asset lock spends that the persistence mirror + /// reports were taken by a *different* transaction, keyed by outpoint. + /// + /// Restored at load only. 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. The host mirror does know which transaction took the + /// outpoint, and this is where that answer arrives. + /// + /// Values are `(spender txid, spender height, spender is chain-locked)`. + 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 b4a2f7d05b0..36a6aa4afe0 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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 06b5289ed8e..7a750563dab 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -5023,6 +5023,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 @@ -5417,6 +5430,67 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// transaction table) are skipped — the Rust side has no way to /// reconstruct the funding tx without its consensus bytes, so /// projecting an empty row would just bloat the FFI surface. + /// Cap on spend-linkage rows handed to Rust. The set is a wallet's + /// spent TXOs, which stays small for the wallets this screen matters + /// for; the cap only exists so a pathological history cannot balloon + /// the load callback. + private static let assetLockInputSpendCap = 4096 + + /// Report which transaction the mirror recorded as spending each output + /// of this wallet, whether or not that spender ever reached a block. + /// + /// Rust knows the outpoints its locks spend and filters this set down to + /// them; it cannot know the spender, because the in-memory transaction + /// history it would normally consult is empty at load. The spender's + /// context is passed through verbatim; which contexts count as final is + /// Rust's decision. + private func buildAssetLockInputSpendBuffer( + walletId: Data, + allocation: LoadAllocation + ) -> (UnsafeMutablePointer?, Int) { + // Deliberately not filtered on `isSpent`. A row keeps that flag + // false until its spender reaches a block, so the outputs of a + // transaction that never got there — exactly the chain the abandon + // cascade has to follow — sit in the unspent set with their linkage + // recorded and nothing else pointing at them. + var descriptor = FetchDescriptor( + predicate: #Predicate { txo in + txo.walletId == walletId && txo.spendingTransaction != nil + } + ) + descriptor.relationshipKeyPathsForPrefetching = [\.spendingTransaction] + descriptor.fetchLimit = Self.assetLockInputSpendCap + guard let spent = try? backgroundContext.fetch(descriptor), !spent.isEmpty else { + return (nil, 0) + } + + let buf = UnsafeMutablePointer.allocate(capacity: spent.count) + allocation.assetLockInputSpendBuffers.append((buf, spent.count)) + var written = 0 + for txo in spent { + guard let spender = txo.spendingTransaction else { continue } + + guard txo.txid.count == 32, spender.txid.count == 32 else { continue } + var row = AssetLockInputSpendFFI() + txo.txid.withUnsafeBytes { src in + Swift.withUnsafeMutableBytes(of: &row.prev_txid) { dst in + dst.copyMemory(from: src) + } + } + row.vout = txo.vout + 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 + buf[written] = row + written += 1 + } + return written == 0 ? (nil, 0) : (buf, written) + } + private func buildUnresolvedAssetLockTxRecordBuffer( walletId: Data, allocation: LoadAllocation @@ -6345,6 +6419,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 +6499,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() From f2afe51639fc8295d2ac9976c7d6abef8f5c66d4 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:28:15 +0300 Subject: [PATCH 2/6] fix(jni): initialize the new asset-lock spend-linkage fields `WalletRestoreEntryFFI` gained two fields and the Kotlin JNI host builds that struct too, so the Android build broke on E0063. Null them: the Kotlin persister has no equivalent of the Swift spend-linkage query yet, so the conflict screen keeps its previous transaction-history behaviour on that host. Co-Authored-By: Claude Opus 5 --- packages/rs-unified-sdk-jni/src/persistence.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 917d26094df..fcf56ffc357 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(), From 48afef9f7028920e28a28c69fb1d97202a4834a3 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:15:01 +0300 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20unsaf?= =?UTF-8?q?e=20predicate,=20unknown=20contexts,=20missing=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The spend-linkage query could crash the process.** It filtered on `spendingTransaction != nil`, and `PersistentTxo.isSpent` exists specifically because chasing that optional relationship in a predicate drops SwiftData onto a nested-optional codepath that crashes — which `try?` cannot recover from, during wallet load. Filter on the scalar instead. Nothing is lost: `isSpent` flips under the same in-block condition the conflict screen requires of a spender, so every row the screen can act on is still included. **Unknown context bytes read as final.** `>= 2` and `>= 3` classified every malformed or forward-versioned value, `u32::MAX` included, as both confirmed and chain-locked. The screen treats `in_block` as conclusive and returns a terminal code the host may act on by discarding the lock, so manufacturing that verdict from a corrupt row is unsafe. Match the known discriminants exactly; anything else degrades to no evidence. **Coverage for the path this PR adds.** Three tests: the restored linkage condemning a lock with an empty history (the app-launch shape), a non-final spender and the lock's own txid both declining to condemn it, and the decoder's context boundary across 0..=3 plus unknown values. Co-Authored-By: Claude Opus 5 --- .../rs-platform-wallet-ffi/src/persistence.rs | 57 +++++++++++-- .../src/wallet/asset_lock/sync/recovery.rs | 85 +++++++++++++++++++ .../PlatformWalletPersistenceHandler.swift | 14 +-- 3 files changed, 143 insertions(+), 13 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 914cbbcbde8..1a41d05ffc5 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -58,8 +58,8 @@ use crate::platform_address_types::AddressBalanceEntryFFI; use crate::token_persistence::{TokenBalanceRemovalFFI, TokenBalanceUpsertFFI}; use crate::wallet_registration_persistence::AccountAddressPoolFFI; use crate::wallet_restore_types::{ - AccountSpecFFI, AccountTypeTagFFI, ContactProfileRestoreEntryFFI, IdentityKeyRestoreFFI, - IdentityRestoreEntryFFI, LoadWalletListFreeFn, PaymentRestoreEntryFFI, + AccountSpecFFI, AccountTypeTagFFI, AssetLockInputSpendFFI, ContactProfileRestoreEntryFFI, + IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, LoadWalletListFreeFn, PaymentRestoreEntryFFI, ProviderSpecialTxRestoreEntryFFI, StandardAccountTypeTagFFI, UnresolvedAssetLockTxRecordFFI, UtxoRestoreEntryFFI, WalletRestoreEntryFFI, }; @@ -4878,9 +4878,12 @@ fn build_asset_lock_input_spends( ); continue; }; - // Both facts are kept, and the readers apply their own bar: the - // double-spend screen needs a spender that reached a block, the - // abandon cascade needs any spender at all. + // 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. const CONTEXT_IN_BLOCK: u32 = 2; const CONTEXT_IN_CHAIN_LOCKED_BLOCK: u32 = 3; spends.insert( @@ -4891,8 +4894,11 @@ fn build_asset_lock_input_spends( platform_wallet::wallet::platform_wallet::RestoredSpend { spender: spender_txid, height: (row.spender_height != 0).then_some(row.spender_height), - in_block: row.spender_context >= CONTEXT_IN_BLOCK, - chain_locked: row.spender_context >= CONTEXT_IN_CHAIN_LOCKED_BLOCK, + in_block: matches!( + row.spender_context, + CONTEXT_IN_BLOCK | CONTEXT_IN_CHAIN_LOCKED_BLOCK + ), + chain_locked: row.spender_context == CONTEXT_IN_CHAIN_LOCKED_BLOCK, }, ); } @@ -6022,6 +6028,43 @@ mod tests { use super::*; + // --- 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 --- unsafe extern "C" fn noop_begin(_ctx: *mut c_void, _wallet_id: *const u8) -> i32 { 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 10740c6be4e..830665b6949 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 @@ -1223,6 +1223,25 @@ 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) { + 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: in_block, + }, + ); + } + /// 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, @@ -1308,6 +1327,72 @@ 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 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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 7a750563dab..2f7281cbb28 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -5448,14 +5448,16 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { walletId: Data, allocation: LoadAllocation ) -> (UnsafeMutablePointer?, Int) { - // Deliberately not filtered on `isSpent`. A row keeps that flag - // false until its spender reaches a block, so the outputs of a - // transaction that never got there — exactly the chain the abandon - // cascade has to follow — sit in the unspent set with their linkage - // recorded and nothing else pointing at them. + // Filter on the scalar column, never on `spendingTransaction`: + // chasing that optional relationship in a predicate drops SwiftData + // onto a nested-optional codepath that crashes the process, which + // `try?` cannot recover from — see `PersistentTxo.isSpent`, which + // exists for exactly this reason. No rows are lost here: `isSpent` + // flips under the same in-block condition the conflict screen + // requires of a spender, so every row it can act on is included. var descriptor = FetchDescriptor( predicate: #Predicate { txo in - txo.walletId == walletId && txo.spendingTransaction != nil + txo.walletId == walletId && txo.isSpent == true } ) descriptor.relationshipKeyPathsForPrefetching = [\.spendingTransaction] From 84eddfc98f051bcf505f07830ed61edb9b2daa3c Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:05:49 +0300 Subject: [PATCH 4/6] fix(swift-sdk): scope the spend-linkage query to the locks' own inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The load callback fetched the wallet's confirmed spent TXOs, capped at 4096, and left Rust to intersect them with the unresolved locks' input outpoints. Nothing orders that set, so a wallet with more spent history than the cap could return a page missing the very outpoint the conflict screen needs — startup back to no evidence and a full proof wait, the failure this branch exists to fix. Resolve the outpoints first, from the locks' persisted funding transactions, and fetch exactly those: one point lookup each on the unique `outpoint` key, so the cap has nothing left to protect. `PersistentTransaction.inputs` cannot answer this, being the inverse of `spendingTransaction` — for the case that matters, an outpoint taken by a different transaction, it names the winner and omits the lock's edge. Every other condition now runs in Swift on the fetched row rather than in the predicate: `isSpent` still gates the row, `spendingTransaction` is read but never chased in a predicate, and no captured collection has to survive SwiftData's translation on the load path. Also register the FFI buffer for the number of rows actually written. It was registered for the fetched count while rows without a spender or with a malformed txid were skipped, so `release()` would deinitialize uninitialized memory. --- .../PlatformWalletPersistenceHandler.swift | 180 ++++++++++++------ 1 file changed, 122 insertions(+), 58 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 2f7281cbb28..80889b51a07 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -5412,67 +5412,64 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return (buf, written) } - /// 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 - /// matching `PersistentTransaction` row. Returns `(nil, 0)` when - /// there are no eligible rows. + /// Report which transaction the mirror recorded as spending the inputs of + /// this wallet's unresolved asset locks, whether or not that spender ever + /// reached a block. /// - /// The Rust side reads each row and re-inserts the decoded - /// transaction into the matching BIP44 account's in-memory - /// `transactions()` map so the next chain-lock event can promote - /// it via `apply_chain_lock`. See - /// `restore_unresolved_asset_lock_tx_records` for the Rust-side - /// contract. - /// - /// Rows with no matching `PersistentTransaction` (e.g. an - /// orphaned asset-lock row whose tx never made it into the - /// transaction table) are skipped — the Rust side has no way to - /// reconstruct the funding tx without its consensus bytes, so - /// projecting an empty row would just bloat the FFI surface. - /// Cap on spend-linkage rows handed to Rust. The set is a wallet's - /// spent TXOs, which stays small for the wallets this screen matters - /// for; the cap only exists so a pathological history cannot balloon - /// the load callback. - private static let assetLockInputSpendCap = 4096 - - /// Report which transaction the mirror recorded as spending each output - /// of this wallet, whether or not that spender ever reached a block. - /// - /// Rust knows the outpoints its locks spend and filters this set down to - /// them; it cannot know the spender, because the in-memory transaction - /// history it would normally consult is empty at load. The spender's - /// context is passed through verbatim; which contexts count as final is - /// Rust's decision. + /// 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; which contexts + /// count as final is Rust's decision. private func buildAssetLockInputSpendBuffer( walletId: Data, allocation: LoadAllocation ) -> (UnsafeMutablePointer?, Int) { - // Filter on the scalar column, never on `spendingTransaction`: - // chasing that optional relationship in a predicate drops SwiftData - // onto a nested-optional codepath that crashes the process, which - // `try?` cannot recover from — see `PersistentTxo.isSpent`, which - // exists for exactly this reason. No rows are lost here: `isSpent` - // flips under the same in-block condition the conflict screen - // requires of a spender, so every row it can act on is included. - var descriptor = FetchDescriptor( - predicate: #Predicate { txo in - txo.walletId == walletId && txo.isSpent == true - } - ) - descriptor.relationshipKeyPathsForPrefetching = [\.spendingTransaction] - descriptor.fetchLimit = Self.assetLockInputSpendCap - guard let spent = try? backgroundContext.fetch(descriptor), !spent.isEmpty else { - return (nil, 0) - } - - let buf = UnsafeMutablePointer.allocate(capacity: spent.count) - allocation.assetLockInputSpendBuffers.append((buf, spent.count)) - var written = 0 - for txo in spent { - guard let spender = txo.spendingTransaction else { continue } + // 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] + guard let txo = try? backgroundContext.fetch(descriptor).first, + txo.walletId == walletId, + txo.isSpent, + let spender = txo.spendingTransaction, + txo.txid.count == 32, + spender.txid.count == 32 + else { continue } - guard txo.txid.count == 32, spender.txid.count == 32 else { continue } var row = AssetLockInputSpendFFI() txo.txid.withUnsafeBytes { src in Swift.withUnsafeMutableBytes(of: &row.prev_txid) { dst in @@ -5487,12 +5484,79 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } row.spender_height = spender.blockHeight row.spender_context = spender.context - buf[written] = row - written += 1 + rows.append(row) } - return written == 0 ? (nil, 0) : (buf, written) + 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 their persisted funding transactions. + /// Deduplicated, since two locks built from the same UTXO name the same + /// outpoint and the caller does one fetch per element. + /// + /// The relationship cannot answer this: `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 [] + } + guard let network = walletNetwork(walletId: walletId) else { return [] } + + var outpoints: [Data] = [] + var seen = Set() + for lock in locks { + guard let outpoint = decodeOutPointHex(lock.outPointHex) else { continue } + let txidData = Data(outpoint.prefix(32)) + var txDescriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == txidData } + ) + txDescriptor.fetchLimit = 1 + guard let txRow = try? backgroundContext.fetch(txDescriptor).first, + !txRow.transactionData.isEmpty, + let decoded = try? TransactionDecoder.decode(txRow.transactionData, 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 + /// matching `PersistentTransaction` row. Returns `(nil, 0)` when + /// there are no eligible rows. + /// + /// The Rust side reads each row and re-inserts the decoded + /// transaction into the matching BIP44 account's in-memory + /// `transactions()` map so the next chain-lock event can promote + /// it via `apply_chain_lock`. See + /// `restore_unresolved_asset_lock_tx_records` for the Rust-side + /// contract. + /// + /// Rows with no matching `PersistentTransaction` (e.g. an + /// orphaned asset-lock row whose tx never made it into the + /// transaction table) are skipped — the Rust side has no way to + /// reconstruct the funding tx without its consensus bytes, so + /// projecting an empty row would just bloat the FFI surface. private func buildUnresolvedAssetLockTxRecordBuffer( walletId: Data, allocation: LoadAllocation From 1fcf166ac49382b7db0e152c8c123d63ea8a3691 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:05:50 +0300 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20lock-?= =?UTF-8?q?owned=20tx=20bytes,=20restored=20chainlock=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings on 84eddfc. `unresolvedAssetLockInputs` decoded the funding transaction out of a `PersistentTransaction` row, and skipped the lock when that row was missing. A Built / Broadcast asset lock whose own transaction never reached the transaction table is exactly the state this path exists for — its input can still have been taken by a confirmed spender — so the skip left the restored conflict map blind and startup back in the proof wait this branch is fixing. Decode `PersistentAssetLock.transactionBytes` instead: the authoritative copy, the same bytes `buildAssetLockRestoreBuffer` hands Rust, and one fetch fewer. `first_confirmed_input_conflict` reported a restored spender's finality from `spend.chain_locked` alone. A persisted row keeps the context it was stored with, and the promotion that would flip it to chain-locked is precisely what the restore path never runs — so a spender mined below a chainlock the wallet applied later read as not final, understating the evidence the host acts on. Apply the same boundary fallback the history scan a few lines down already uses. --- .../src/wallet/asset_lock/sync/recovery.rs | 72 ++++++++++++++++++- .../PlatformWalletPersistenceHandler.swift | 39 +++++----- 2 files changed, 93 insertions(+), 18 deletions(-) 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 830665b6949..cad6891b538 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 @@ -270,7 +270,16 @@ fn first_confirmed_input_conflict( .get_key_value(input) .filter(|(_, spend)| spend.spender != lock_txid && spend.in_block) }) { - return Some((*input, spend.spender, spend.height, spend.chain_locked)); + // Same boundary fallback as the history scan below: a restored row + // stays at its persisted context, so a spender mined below a + // chainlock the wallet applied later reads as merely `InBlock`. + // Reporting that as not-chain-locked understates the finality the + // host is asked to act on. + let spender_chain_locked = spend.chain_locked + || chain_locked_height + .zip(spend.height) + .is_some_and(|(boundary, spender_height)| spender_height <= boundary); + return Some((*input, spend.spender, spend.height, spender_chain_locked)); } info.core_wallet @@ -672,7 +681,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; @@ -1227,6 +1239,15 @@ mod tests { /// 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) @@ -1237,11 +1258,26 @@ mod tests { spender, height: in_block.then_some(1_532_949), in_block, - chain_locked: 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, @@ -1364,6 +1400,38 @@ mod tests { ); } + /// A restored spender mined below a chainlock the wallet applied later + /// is final, even though its persisted row still says `InBlock`: the + /// promotion that would have flipped the row is exactly what the + /// restore path never ran. Reporting `spender_chain_locked: false` + /// there understates the finality the host acts on — the history scan + /// already consults the boundary, and the restored branch must match. + #[tokio::test] + async fn restored_spend_below_the_chainlock_boundary_reports_final() { + 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 { + spender_chain_locked, + .. + } => assert!( + spender_chain_locked, + "a spender at or below the applied chainlock is final" + ), + other => panic!("expected AssetLockInputConflict, got {other:?}"), + } + } + /// 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. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 80889b51a07..98769dadc74 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -5495,14 +5495,25 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } /// The 36-byte outpoints spent by this wallet's unresolved asset locks - /// (`statusRaw < 2`), decoded from their persisted funding transactions. - /// Deduplicated, since two locks built from the same UTXO name the same - /// outpoint and the caller does one fetch per element. + /// (`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 relationship cannot answer this: `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. + /// 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 @@ -5517,15 +5528,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { var outpoints: [Data] = [] var seen = Set() for lock in locks { - guard let outpoint = decodeOutPointHex(lock.outPointHex) else { continue } - let txidData = Data(outpoint.prefix(32)) - var txDescriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == txidData } - ) - txDescriptor.fetchLimit = 1 - guard let txRow = try? backgroundContext.fetch(txDescriptor).first, - !txRow.transactionData.isEmpty, - let decoded = try? TransactionDecoder.decode(txRow.transactionData, network: network) + guard !lock.transactionBytes.isEmpty, + let decoded = try? TransactionDecoder.decode( + lock.transactionBytes, + network: network + ) else { continue } for input in decoded.inputs { From f962c0600885865ddefedc22045ff5e83d784233 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:56:46 +0300 Subject: [PATCH 6/6] fix(swift-sdk): resolve TXO ownership the way the restore path already does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spend-linkage lookup compared `PersistentTxo.walletId` raw. That column is documented as empty on rows written before it existed, and the spend-reconciliation path sets `isSpent` and the spender link without backfilling it — so the comparison discarded exactly the legacy rows a confirmed conflicting spender is recorded on. The restored conflict map came back empty and startup went into the full proof wait this branch exists to prevent. Resolve through `resolvedWalletId`, the fallback `loadWalletList` already uses for the same reason. That is narrower than accepting any empty value: a legacy row whose account belongs to a different wallet is still rejected. Adds the first coverage for this path — the confirmed spender is restored both when the TXO carries its wallet id and when it does not. The second test was confirmed to fail against the raw comparison. --- .../PlatformWalletPersistenceHandler.swift | 10 +- .../AssetLockInputSpendRestoreTests.swift | 162 ++++++++++++++++++ 2 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockInputSpendRestoreTests.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 98769dadc74..9d9c4a38e13 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -5462,8 +5462,16 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) 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, - txo.walletId == walletId, + Self.resolvedWalletId(of: txo) == walletId, txo.isSpent, let spender = txo.spendingTransaction, txo.txid.count == 32, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockInputSpendRestoreTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockInputSpendRestoreTests.swift new file mode 100644 index 00000000000..4fcf3c4c5af --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockInputSpendRestoreTests.swift @@ -0,0 +1,162 @@ +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. + private func outPointHex(txid: Data, vout: UInt32) -> String { + let display = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(display):\(vout)" + } + + /// 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" + ) + } +}