From 2687ae04ce350b431535ea11f3ead599294d7009 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 10 Aug 2026 12:08:56 +0700 Subject: [PATCH 01/11] fix(platform-wallet): fail a double-spending asset lock with a typed terminal error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tracked asset lock whose funding input was already spent by a different confirmed transaction can never confirm: peers reject it as a double spend at the mempool boundary and relay nothing back, and Core has not sent BIP61 rejects by default since 0.17. `resume_asset_lock` would re-broadcast into that void and then sit in `wait_for_proof` — unbounded for the user-facing funding flows — so the app could not tell a dead lock from a slow network and had no basis to offer discarding it. Screen the `Built` and `Broadcast` arms for a confirmed transaction in the wallet's own history that spends one of the lock's inputs, and return `AssetLockInputConflict` (FFI code 41, mirrored in Swift) naming the input and the transaction that actually spent it. Settled statuses are left alone. The scan is conclusive in one direction only: a hit is a definite verdict, but under the default `keep-finalized-transactions = OFF` feature key-wallet evicts chainlocked records and keeps only their txids, so the oldest conflicts are invisible and the existing timeout stays the backstop for those. Prevention of the underlying build lives in key-wallet's spend-scan frontier gate and arrives with the next pin bump. Co-Authored-By: Claude Fable 5 --- packages/rs-platform-wallet-ffi/src/error.rs | 42 ++ packages/rs-platform-wallet/src/error.rs | 40 +- .../src/wallet/asset_lock/sync/recovery.rs | 438 +++++++++++++++++- .../PlatformWallet/PlatformWalletResult.swift | 37 ++ 4 files changed, 552 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 444573c5db..35b1737d7a 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -265,6 +265,8 @@ pub enum PlatformWalletFFIResultCode { // 38 ErrorDocumentPriceChanged DPNS username marketplace // 39 ErrorInsufficientIdentityCredits DPNS username marketplace // 40 ErrorContestedNameNotTradable DPNS username marketplace + // 41 ErrorShieldedInsufficientBalance Platform→Shielded capacity preflight + // 42 ErrorAssetLockInputConflict asset-lock double-spend detection // // 38/39/40 carry a STABLE JSON detail object in the result `message` // instead of the typed `Display` rendering — see each variant's doc for @@ -369,6 +371,39 @@ pub enum PlatformWalletFFIResultCode { /// shortfall, not a shielded-note shortfall. ErrorShieldedInsufficientBalance = 41, + /// Maps `PlatformWalletError::AssetLockInputConflict`. The tracked + /// asset-lock transaction spends an outpoint that a different, + /// already-confirmed transaction of the same wallet spent first — the + /// classic restored-wallet failure, where a rescan resurrects a UTXO + /// the wallet's own earlier asset lock had long since consumed. Such a + /// transaction is a double spend: peers drop it at the mempool + /// boundary and send nothing back (no BIP61 `reject`), so it can never + /// be mined or IS-locked and the resume's proof wait would hang + /// indefinitely. + /// + /// TERMINAL, and the only code here that authorises a host to discard + /// a tracked asset lock: this resume broadcast nothing and no retry of + /// this outpoint can ever succeed while the confirmed spender stands. + /// The remedy is to drop the lock and build a new one from + /// currently-unspent inputs — a fund-safe action either way, because + /// the conflicting spender is necessarily this wallet's own + /// transaction (only this wallet can sign its outpoints): the value + /// lives in the sibling, and even a freak reorg that removed the + /// sibling would simply return the inputs to the spendable set. + /// Contrast `ErrorTransactionBroadcastUnconfirmed`, where the tx may + /// well be alive and discarding it would strand real funds. + /// + /// Raised only on a positive detection; its ABSENCE is not a liveness + /// signal. The wallet-side scan reads confirmed records still held in + /// memory, and under the default `keep-finalized-transactions = OFF` + /// build those are pruned once chainlocked, so an old conflict can go + /// unseen and surface as the usual finality timeout instead. + /// + /// Message: the typed `Display` rendering, which names the asset-lock + /// outpoint, the conflicting input, the confirmed spender's txid, and + /// the spender's finality (chainlocked or merely in a block). + ErrorAssetLockInputConflict = 42, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -621,6 +656,13 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::AssetLockFundingMismatch { .. } => { PlatformWalletFFIResultCode::ErrorAssetLockFundingMismatch } + // Terminal double spend. Distinct from every other asset-lock + // code because it is the one that tells a host the lock is dead + // rather than pending: without it this reached `ErrorUnknown`, + // which no host may act on destructively. + PlatformWalletError::AssetLockInputConflict { .. } => { + PlatformWalletFFIResultCode::ErrorAssetLockInputConflict + } // A quiesce/drain barrier that did not complete within budget // (clear/reset paths). The host must fail closed: keep its // callback context alive and skip any paired persistence wipe. diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 8349eb1df2..f648abb395 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -2,7 +2,7 @@ use dpp::address_funds::PlatformAddress; use dpp::consensus::state::address_funds::AddressInvalidNonceError; use dpp::fee::Credits; use dpp::identifier::Identifier; -use dpp::prelude::AddressNonce; +use dpp::prelude::{AddressNonce, CoreBlockHeight}; use key_wallet::account::StandardAccountType; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; @@ -282,6 +282,44 @@ pub enum PlatformWalletError { actual_identity_index: u32, }, + /// The tracked asset-lock transaction spends an outpoint that a + /// **different, already-confirmed** transaction of this same wallet + /// spent first. The lock is permanently dead: every peer rejects it + /// as a double spend at the mempool boundary and therefore relays + /// nothing, so no IS-lock and no ChainLock can ever be produced for + /// it. Peers do not answer with BIP61 `reject` (Core stopped sending + /// those by default in 0.17), so the drop is silent — without this + /// variant the condition is indistinguishable from "the network is + /// slow", and the wallet's proof wait (unbounded for the user-facing + /// funding flows) simply never returns. + /// + /// The typical origin is a restored wallet: a rescan repopulates the + /// UTXO set from chain data, an asset-lock build selects an input the + /// restored view still believes is unspent, and the transaction that + /// actually spent it — often one of the wallet's own earlier asset + /// locks — has been confirmed for a long time. + /// + /// Terminal, not retryable: the funds behind `input` are gone into + /// `spent_by`, so the only recovery is to discard this lock and build + /// a new one from currently-unspent inputs. `height` is the block + /// height of the confirmed spender when the record carries block info. + /// + /// Raising this error is a definite verdict; NOT raising it proves + /// nothing — see the detection helper in + /// `wallet::asset_lock::sync::recovery` for why the scan is + /// best-effort. + #[error( + "Asset lock {out_point} can never confirm: it spends {input}, which was \ + already spent by confirmed transaction {spent_by} (block height \ + {height:?}) — the lock is a double spend and no peer will relay it" + )] + AssetLockInputConflict { + out_point: dashcore::OutPoint, + input: dashcore::OutPoint, + spent_by: dashcore::Txid, + height: Option, + }, + #[error("SDK error: {0}")] Sdk(#[from] dash_sdk::Error), 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 fb3852dcef..205b6045e4 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 @@ -5,16 +5,20 @@ //! and re-deriving private keys. use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; +use std::collections::BTreeSet; use std::time::Duration; use dashcore::Address as DashAddress; -use dashcore::OutPoint; +use dashcore::{OutPoint, Txid}; +use dpp::prelude::CoreBlockHeight; use key_wallet::bip32::DerivationPath; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use crate::changeset::changeset::AssetLockChangeSet; use crate::error::PlatformWalletError; +use crate::wallet::platform_wallet::PlatformWalletInfo; use super::super::manager::AssetLockManager; use super::super::tracked::{AssetLockStatus, TrackedAssetLock}; @@ -188,6 +192,62 @@ impl AssetLockManager { // Resumable asset lock // --------------------------------------------------------------------------- +/// Find the first outpoint of `lock`'s transaction that some **other, +/// confirmed** transaction of this wallet already spent, returning +/// `(conflicting_input, spending_txid, spender_height)`. +/// +/// A hit means the asset lock is a double spend of a settled outpoint. +/// Peers reject such a transaction at the mempool boundary and relay +/// nothing back — Core has not sent BIP61 `reject` messages by default +/// since 0.17 — so the lock can neither be mined nor IS-locked, and a +/// proof wait on it never terminates. Callers turn a hit into +/// [`PlatformWalletError::AssetLockInputConflict`] instead of +/// (re-)broadcasting into that void. +/// +/// **Best-effort in one direction only.** A hit is conclusive: the +/// spender is a confirmed transaction sitting in this wallet's own +/// history, and confirmed spends of an outpoint are mutually exclusive. +/// A miss proves nothing. Under the default +/// `keep-finalized-transactions = OFF` feature, key-wallet evicts the +/// full `TransactionRecord` once a chainlock buries it and retains only +/// the txid, so precisely the oldest — and therefore most likely — +/// conflicts are invisible here. A lock that clears this scan may still +/// be a double spend, and the existing timeout path remains its only +/// backstop. Do not restructure callers to treat "no conflict" as proof +/// of liveness. +/// +/// Confirmation is required rather than mere presence: an unconfirmed +/// sibling that spends the same outpoint is a competing candidate, not a +/// verdict. Either transaction can still win, and the tracked lock is +/// often the one the user actually wants to push through, so a mempool +/// record must not condemn it. +fn first_confirmed_input_conflict( + info: &PlatformWalletInfo, + lock: &TrackedAssetLock, +) -> Option<(OutPoint, Txid, Option)> { + let lock_txid = lock.transaction.txid(); + let lock_inputs: BTreeSet = lock + .transaction + .input + .iter() + .map(|input| input.previous_output) + .collect(); + + info.core_wallet + .transaction_history() + .into_iter() + .filter(|record| record.txid != lock_txid && record.is_confirmed()) + .find_map(|record| { + let conflicting_input = record + .transaction + .input + .iter() + .map(|input| input.previous_output) + .find(|outpoint| lock_inputs.contains(outpoint))?; + Some((conflicting_input, record.txid, record.height())) + }) +} + impl AssetLockManager { /// Resume a tracked asset lock from whatever stage it's at. /// @@ -211,6 +271,13 @@ impl AssetLockManager { /// still needs a proof (`Built` / `Broadcast`): `None` waits /// **indefinitely** for finality. For `InstantSendLocked` / `ChainLocked` /// the proof already exists and no wait happens, so the value is moot. + /// + /// A `Built` / `Broadcast` lock is first screened by + /// [`first_confirmed_input_conflict`]; a hit short-circuits to + /// [`PlatformWalletError::AssetLockInputConflict`] without broadcasting + /// or waiting, because such a lock is a double spend that no peer will + /// relay. That screen is one-sided — read its docs before treating a + /// clean pass as evidence the lock is alive. pub async fn resume_asset_lock( &self, out_point: &OutPoint, @@ -219,7 +286,7 @@ impl AssetLockManager { tracing::info!(outpoint = %out_point, ?timeout, "resume_asset_lock: entered"); // 1. Look up the tracked lock — snapshot the fields we need. - let (tx, status, existing_proof, account_index) = { + let (tx, status, existing_proof, account_index, input_conflict) = { let wm = self.wallet_manager.read().await; let info = wm .get_wallet_info(&self.wallet_id) @@ -240,14 +307,56 @@ impl AssetLockManager { account_index = lock.account_index, "resume_asset_lock: lock looked up" ); + // Only the two proof-less statuses are candidates. A lock + // carrying an IS/Chain proof, a `RecoveredFromChain` entry + // (reconstructed from a record the chain itself accepted), and + // a `Consumed` tombstone are all settled by evidence stronger + // than this scan; re-classifying one of them as a double spend + // on the strength of an unrelated history record would + // invalidate a lock the network already honoured. + let input_conflict = match lock.status { + AssetLockStatus::Built | AssetLockStatus::Broadcast => { + first_confirmed_input_conflict(info, lock) + } + AssetLockStatus::InstantSendLocked + | AssetLockStatus::ChainLocked + | AssetLockStatus::RecoveredFromChain + | AssetLockStatus::Consumed => None, + }; ( lock.transaction.clone(), lock.status.clone(), lock.proof.clone(), lock.account_index, + input_conflict, ) }; + // Fail before the `Built` / `Broadcast` arms reach their + // (re-)broadcast and their proof wait: the transaction is a double + // spend of a settled outpoint, so the broadcast is discarded + // without a reply and the wait — unbounded for the user-facing + // funding flows — would never return. The typed error is what lets + // a host offer to discard the lock instead of showing a spinner + // forever. + if let Some((input, spent_by, height)) = input_conflict { + tracing::warn!( + outpoint = %out_point, + %input, + %spent_by, + ?height, + "resume_asset_lock: asset lock double-spends an outpoint \ + already consumed by a confirmed transaction; it can never \ + confirm" + ); + return Err(PlatformWalletError::AssetLockInputConflict { + out_point: *out_point, + input, + spent_by, + height, + }); + } + // 2. Resume from the current status. let proof = match status { AssetLockStatus::Built => { @@ -517,10 +626,15 @@ mod tests { use async_trait::async_trait; use dashcore::hashes::Hash; - use dashcore::{Network, OutPoint, Transaction, Txid}; + use dashcore::{BlockHash, Network, OutPoint, Transaction, TxIn, Txid}; use key_wallet::account::account_collection::AccountCollection; use key_wallet::account::account_type::StandardAccountType; use key_wallet::account::{Account, AccountType}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; use key_wallet_manager::WalletManager; @@ -539,7 +653,7 @@ mod tests { use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; use crate::wallet::persister::WalletPersister; - use crate::wallet::platform_wallet::PlatformWalletInfo; + use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; use crate::AssetLockFundingType; /// Persistence stub that records every stored changeset so the test @@ -969,4 +1083,320 @@ mod tests { "re-derived credit-output path must match the build-time path" ); } + + // ----------------------------------------------------------------- + // Input-conflict screen (double-spent asset locks) + // ----------------------------------------------------------------- + + /// Everything the input-conflict tests need: a funded wallet, a built + /// asset-lock transaction over its spendable UTXO, its outpoint, and a + /// manager whose broadcaster records every send so a test can prove + /// the screen fired *before* the (re-)broadcast rather than after it. + struct ConflictFixture { + wallet_manager: Arc>>, + wallet_id: WalletId, + manager: AssetLockManager, + broadcaster: Arc, + transaction: Transaction, + out_point: OutPoint, + } + + impl ConflictFixture { + async fn new() -> Self { + let (wallet_manager, wallet_id, _generation, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let broadcaster = Arc::new(RecordingBroadcaster::default()); + let sdk = Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_network(Network::Testnet) + .build() + .expect("mock sdk"), + ); + let manager = AssetLockManager::new( + sdk, + Arc::clone(&wallet_manager), + wallet_id, + Arc::new(Notify::new()), + Arc::clone(&broadcaster), + WalletPersister::new(wallet_id, Arc::new(RecordingPersistence::default())), + ); + let (transaction, _path) = manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 4, + &signer, + ) + .await + .expect("build asset lock"); + let out_point = OutPoint::new(transaction.txid(), 0); + Self { + wallet_manager, + wallet_id, + manager, + broadcaster, + transaction, + out_point, + } + } + + /// The single outpoint the asset-lock transaction spends — the one + /// a rescan-resurrected UTXO would have handed it a second time. + fn funded_input(&self) -> OutPoint { + self.transaction + .input + .first() + .expect("asset lock spends at least one input") + .previous_output + } + + async fn track( + &self, + status: AssetLockStatus, + proof: Option, + ) { + let mut wm = self.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&self.wallet_id) + .expect("wallet must remain registered"); + info.tracked_asset_locks.insert( + self.out_point, + TrackedAssetLock { + out_point: self.out_point, + transaction: self.transaction.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 4, + amount: 1_000_000, + status, + proof, + }, + ); + } + + /// 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, + /// evict it again under the default `keep-finalized-transactions` + /// build — the scan under test reads `transaction_history()`, so + /// the record has to actually be there. + async fn file_record(&self, record: TransactionRecord) { + 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 + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("funded fixture has BIP44 account 0") + .transactions_mut() + .insert(record.txid, record); + } + + fn broadcast_count(&self) -> usize { + self.broadcaster + .transactions + .lock() + .expect("recording broadcaster mutex") + .len() + } + } + + /// Wrap `transaction` as a history record filed against BIP44 account 0. + fn record_for(transaction: Transaction, context: TransactionContext) -> TransactionRecord { + TransactionRecord::new( + transaction, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + context, + TransactionType::Standard, + TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + 0, + ) + } + + /// A distinct transaction that spends `spends`. Its txid falls out of + /// the inputs, so it never collides with the asset lock's own. + fn transaction_spending(spends: OutPoint) -> Transaction { + Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: spends, + ..Default::default() + }], + output: Vec::new(), + special_transaction_payload: None, + } + } + + fn confirmed_at(height: u32) -> TransactionContext { + TransactionContext::InBlock(BlockInfo::new( + height, + BlockHash::all_zeros(), + 1_700_000_000, + )) + } + + /// The incident this screen exists for: a restored wallet re-spends an + /// outpoint one of its own earlier, already-confirmed transactions + /// consumed long ago. Peers drop the double spend without a reply, so + /// the pre-existing behaviour — re-broadcast, then wait, unbounded for + /// the user-facing funding flows — could never terminate. The resume + /// must fail with the typed terminal error and must not touch the + /// network on the way out. + #[tokio::test] + async fn broadcast_resume_reports_input_conflict_when_a_confirmed_tx_spent_the_input() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + let spender = transaction_spending(fixture.funded_input()); + let spender_txid = spender.txid(); + fixture + .file_record(record_for(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 { + out_point, + input, + spent_by, + height, + } => { + assert_eq!(out_point, fixture.out_point); + assert_eq!(input, fixture.funded_input()); + assert_eq!(spent_by, spender_txid); + assert_eq!(height, Some(1_234)); + } + other => panic!("expected AssetLockInputConflict, got {other:?}"), + } + assert_eq!( + fixture.broadcast_count(), + 0, + "the screen must short-circuit ahead of the defensive re-broadcast" + ); + } + + /// An unconfirmed sibling spending the same outpoint is a competing + /// candidate, not a verdict — either transaction can still win, and + /// condemning the tracked lock on a mempool record would discard a + /// perfectly live funding attempt. The resume must take its normal + /// course (re-broadcast, then wait) instead. + #[tokio::test] + async fn broadcast_resume_ignores_an_unconfirmed_spend_of_the_same_input() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + fixture + .file_record(record_for( + transaction_spending(fixture.funded_input()), + TransactionContext::Mempool, + )) + .await; + + let error = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("no proof event should arrive within the deadline"); + assert!( + !matches!(error, PlatformWalletError::AssetLockInputConflict { .. }), + "an unconfirmed conflict must not condemn the lock, got {error:?}" + ); + assert_eq!( + fixture.broadcast_count(), + 1, + "the resume must still reach its defensive re-broadcast" + ); + } + + /// The asset-lock transaction is itself filed in wallet history once + /// it is seen on chain, and it necessarily spends every outpoint it + /// spends. Matching on the outpoints alone would therefore make every + /// confirmed lock report itself as its own double spend; the txid + /// guard is what prevents that. + #[tokio::test] + async fn resume_does_not_treat_the_locks_own_confirmed_record_as_a_conflict() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + fixture + .file_record(record_for(fixture.transaction.clone(), confirmed_at(1_234))) + .await; + + let outcome = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await; + assert!( + !matches!( + outcome, + Err(PlatformWalletError::AssetLockInputConflict { .. }) + ), + "a lock's own record must never condemn it, got {outcome:?}" + ); + } + + /// Settled locks are decided by evidence the screen has no standing to + /// overturn: a `Consumed` tombstone records a completed Platform spend, + /// and a proof-carrying lock holds finality the network already granted. + /// Both must return exactly what they returned before the screen + /// existed, even with a confirmed conflicting record sitting in history + /// — and neither may broadcast. + #[tokio::test] + async fn settled_locks_keep_their_outcome_despite_a_confirmed_conflicting_record() { + let fixture = ConflictFixture::new().await; + fixture + .file_record(record_for( + transaction_spending(fixture.funded_input()), + confirmed_at(1_234), + )) + .await; + + let chain_proof = dpp::prelude::AssetLockProof::Chain( + dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof { + core_chain_locked_height: 1_234, + out_point: fixture.out_point, + }, + ); + fixture + .track(AssetLockStatus::ChainLocked, Some(chain_proof.clone())) + .await; + let (resumed_proof, _path) = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect("a chain-locked lock resumes from its own proof"); + assert_eq!(resumed_proof, chain_proof); + + fixture.track(AssetLockStatus::Consumed, None).await; + let consumed = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("a consumed lock must stay terminal"); + assert!( + matches!( + consumed, + PlatformWalletError::AssetLockAlreadyConsumed(actual) if actual == fixture.out_point + ), + "expected AssetLockAlreadyConsumed, got {consumed:?}" + ); + assert_eq!( + fixture.broadcast_count(), + 0, + "settled locks never re-enter the broadcast path" + ); + } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 8528fe091d..ba3bb1c9e2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -141,6 +141,21 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// amount plus input 0's retained fee reserve. Refresh the shield /// preflight and ask the user to confirm the new capacity. case errorShieldedInsufficientBalance = 41 + /// The tracked asset-lock transaction spends an outpoint that a different, + /// already-confirmed transaction of the same wallet spent first — typically + /// a restored wallet whose rescan resurrected a UTXO one of its own earlier + /// asset locks had already consumed. Peers drop such a double spend without + /// replying, so the lock can never confirm and its proof wait would hang. + /// The conflict screen stops the current resume before it broadcasts again + /// or enters the proof wait (a `Broadcast`-status lock was sent on an + /// earlier call). TERMINAL: this is the one code that lets a host offer to + /// discard the asset lock and rebuild it from currently-unspent inputs — a + /// fund-safe action, because the confirmed spender is this wallet's own + /// transaction, so the value either stays in the sibling or (after a freak + /// reorg) returns to the spendable set. Its absence is not proof of + /// liveness — the Rust-side scan cannot see conflicts whose spender was + /// already pruned. + case errorAssetLockInputConflict = 42 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the /// wallet-was-REMOVED case here. @@ -238,6 +253,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorContestedNameNotTradable case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_INSUFFICIENT_BALANCE: self = .errorShieldedInsufficientBalance + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ASSET_LOCK_INPUT_CONFLICT: + self = .errorAssetLockInputConflict case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -418,6 +435,16 @@ public enum PlatformWalletError: LocalizedError { /// `endsAtMs == 0` means the vote's end time was unavailable — show it /// as unknown rather than as "ends at the epoch". case contestedNameNotTradable(label: String, endsAtMs: UInt64) + /// The tracked asset lock spends an outpoint a different, + /// already-confirmed transaction spent first, so it is a double spend no + /// peer will relay and it can never confirm. Nothing was broadcast and + /// nothing is in flight. TERMINAL: unlike `transactionBroadcastUnconfirmed` + /// — where the transaction may well be alive and discarding it would + /// strand real funds — this is the one asset-lock error that lets a host + /// offer to discard the lock and rebuild it from currently-unspent inputs. + /// The message names the lock's outpoint, the conflicting input, and the + /// confirmed spender, so a host can say *which* lock died. + case assetLockInputConflict(String) /// The named thing does not exist. For the deferred payment calls this is /// the wallet-was-REMOVED case: the token's wallet (or the wallet a payment /// was just signed against) is no longer registered in the manager, so there @@ -452,6 +479,7 @@ public enum PlatformWalletError: LocalizedError { .staleReservationToken(let m), .reservationTokenConsumed(let m), .reservationWalletMismatch(let m), .notForSale(let m), + .assetLockInputConflict(let m), .notFound(let m), .unknown(let m): return m // The three value-carrying marketplace rejections compose their @@ -561,6 +589,15 @@ public enum PlatformWalletError: LocalizedError { } else { self = .unknown(detail) } + // Code 41 carries the typed `Display` rendering, not a JSON detail + // object: it already names the asset-lock outpoint, the conflicting + // input, and the confirmed spender's txid, and reads as a sentence, so + // it passes through like the other prose-message codes. The terminal + // "discard and rebuild" verdict is the CODE's meaning, not the + // string's — hosts must key their discard affordance off the case, not + // off text matching. + case .errorAssetLockInputConflict: + self = .assetLockInputConflict(detail) case .notFound: self = .notFound(detail) case .errorUnknown: self = .unknown(detail) } From 7d9be71a08e367471a007772ee175b7087732b70 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 11 Aug 2026 18:24:56 +0700 Subject: [PATCH 02/11] fix(platform-wallet): report spender finality and carry the conflict code through every endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups. The chain-lock blocker is resolved by rationale rather than by gating: under the default keep-finalized-transactions=OFF build, apply_chain_lock evicts a record the moment a chainlock buries it, so restricting the verdict to is_chain_locked() records would leave the screen firing only in tests. The verdict stays on any confirmed sibling, and that is fund-safe: the conflicting spender is necessarily this wallet's own transaction (only this wallet can sign its outpoints), so discarding the conflicted lock strands nothing — after even a freak reorg the inputs return to the spendable set. The docs on the variant, the detection helper, and both host mirrors now carry this reasoning. - AssetLockInputConflict gains spender_chain_locked, computed from the record's context or the wallet's last_applied_chain_lock watermark (promotion is what evicts a record, so a surviving record is usually still InBlock after the boundary passed it); hosts can phrase their confidence accordingly, and a new fixture pins the chainlocked case. - The catch-up and shielded funding endpoints no longer flatten the conflict to ErrorWalletOperation: asset_lock_manager_catch_up_blocking and map_asset_lock_funding_result preserve code 42 (the catch-up pass is exactly where a restored wallet's dead lock surfaces). - Kotlin gains the typed PlatformWallet.AssetLockInputConflict arm for code 42 with a conversion test; the FFI code is pinned at 42 by test (41 was claimed by the shielded capacity preflight while this PR was open); stale Swift doc claims corrected. Co-Authored-By: Claude Fable 5 --- .../dashsdk/errors/DashSdkError.kt | 24 ++++ .../dashsdk/errors/DashSdkErrorTest.kt | 29 ++++ .../src/asset_lock/sync.rs | 17 ++- packages/rs-platform-wallet-ffi/src/error.rs | 43 ++++++ .../src/shielded_send.rs | 44 +++++- packages/rs-platform-wallet/src/error.rs | 19 ++- .../src/wallet/asset_lock/sync/recovery.rs | 127 ++++++++++++++++-- .../PlatformWallet/PlatformWalletResult.swift | 31 +++-- 8 files changed, 302 insertions(+), 32 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index 29986168bc..43cb9a8a13 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -115,6 +115,29 @@ sealed class DashSdkError( class AssetLockFundingMismatch(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorAssetLockInputConflict` (native code 42). The tracked + * asset-lock transaction spends an outpoint that a different, + * already-confirmed transaction of the same wallet spent first — + * typically a restored wallet whose rescan resurrected a UTXO one of + * its own earlier asset locks had already consumed. Peers drop such a + * double spend without replying, so the lock can never confirm and its + * proof wait would hang. The conflict screen stops the current resume + * before it broadcasts again or enters the proof wait (a + * `Broadcast`-status lock was sent on an earlier call). + * + * TERMINAL and NOT retryable: this is the one code that lets a host + * offer to discard the asset lock and rebuild it from currently-unspent + * inputs — a fund-safe action, because the confirmed spender is this + * wallet's own transaction, so the value either stays in the sibling + * or (after a freak reorg) returns to the spendable set. Its absence is + * not proof of liveness: the Rust-side scan cannot see conflicts whose + * spender was already pruned. The Android analog of Swift's + * `PlatformWalletError.assetLockInputConflict`. + */ + class AssetLockInputConflict(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + /** * `ErrorShieldedNoRecordedAnchor` (native code 19). A shielded spend * could not be built against a Platform-recorded anchor because the @@ -526,6 +549,7 @@ sealed class DashSdkError( }.getOrNull() } ?: PlatformWallet.Generic(code, message, cause) 41 -> PlatformWallet.PlatformShieldCapacityExceeded(message, cause) + 42 -> PlatformWallet.AssetLockInputConflict(message, cause) // ErrorAssetLockInputConflict // ErrorSigningKeyUnavailable — the STRUCTURED signer // discriminator (dashpay/platform#4060 finding 7): the typed // completion code rides the whole Rust round-trip, no message diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index 37169cc094..06751821bc 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -190,6 +190,35 @@ class DashSdkErrorTest { ) } + @Test + fun assetLockInputConflictCode42MapsTyped() { + // TERMINAL: the one platform-wallet code that authorises a host to + // discard a tracked asset lock (fund-safe — the confirmed spender is + // the wallet's own transaction). It must never fall through to + // Generic, or the host is left waiting on a lock that can never + // confirm. + val message = + "Asset lock a:0 can never confirm: it spends b:1, which was already spent by " + + "confirmed transaction c (block height Some(1234), chainlocked: false) — " + + "the lock is a double spend and no peer will relay it" + val mapped = DashSdkError.fromNative( + DashSDKException( + DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 42, + message, + ), + ) + + assertTrue( + "code 42 must not fall through to Generic", + mapped is DashSdkError.PlatformWallet.AssetLockInputConflict, + ) + assertEquals(message, mapped.message) + assertFalse( + "AssetLockInputConflict is terminal — rebuild from unspent inputs, do not retry", + mapped.isRetryable, + ) + } + @Test fun signingKeyUnavailableCode31MapsTyped() { // The STRUCTURED discriminator (dashpay/platform#4060 finding 7): diff --git a/packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs b/packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs index 5b840f9d03..1a6cdbfc51 100644 --- a/packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs +++ b/packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs @@ -4,6 +4,7 @@ use crate::error::*; use crate::handle::*; use crate::runtime::runtime; use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; +use platform_wallet::PlatformWalletError; use std::ffi::CString; use std::os::raw::c_char; use std::time::Duration; @@ -146,10 +147,18 @@ pub unsafe extern "C" fn asset_lock_manager_catch_up_blocking( error = %e, "asset_lock_manager_catch_up_blocking: resume_asset_lock failed" ); - PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorWalletOperation, - format!("{}", e), - ) + match e { + // Terminal double spend: route through the typed conversion + // so the host still receives ErrorAssetLockInputConflict + // (42) — the one code that authorises discarding a tracked + // lock. Flattening it to ErrorWalletOperation here would + // leave the host with a spinner it can never resolve. + conflict @ PlatformWalletError::AssetLockInputConflict { .. } => conflict.into(), + other => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("{}", other), + ), + } } } } diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 35b1737d7a..a2868ca1ca 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -1626,6 +1626,49 @@ mod tests { ); } + /// The terminal double-spend verdict is the one code a host may act on + /// destructively (discard the tracked lock), so both halves of the + /// contract are pinned: the number the Swift/Kotlin mirrors decode, and + /// the conversion that keeps it from flattening to `ErrorUnknown`. The + /// message must carry the typed `Display` — including the spender's + /// finality — since that is the only detail channel the frozen + /// `{ code, message }` ABI has. + #[test] + fn asset_lock_input_conflict_code_is_pinned_at_42() { + use dashcore::OutPoint; + + assert_eq!( + PlatformWalletFFIResultCode::ErrorAssetLockInputConflict as i32, + 42 + ); + + let out_point = OutPoint::null(); + let result: PlatformWalletFFIResult = PlatformWalletError::AssetLockInputConflict { + out_point, + input: OutPoint { + txid: out_point.txid, + vout: 3, + }, + spent_by: out_point.txid, + height: Some(1_234), + spender_chain_locked: true, + } + .into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorAssetLockInputConflict + ); + let message = message_of(&result); + assert!( + message.contains("can never confirm"), + "the typed Display must survive the conversion: {message}" + ); + assert!( + message.contains("chainlocked: true"), + "the spender's finality must reach the host: {message}" + ); + } + /// `MessageSigningFailed` is intentionally unmapped: its causes are /// internal invariant breaks, which should read as a bug rather than as a /// key-repair prompt, so it falls through to ErrorUnknown carrying the diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 21d98fac4d..6a7d096b03 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -617,6 +617,13 @@ fn map_spend_result( /// boundary while keeping every other funding failure on the existing generic /// error path. The wallet retains nonterminal consumption-unknown state; the /// host must not interpret this code as authenticated completion. +/// +/// The terminal double-spend report rides the same typed conversion (both the +/// fresh-build and resume entry points funnel through here, and the resume is +/// where the pre-broadcast conflict screen actually fires). Its +/// `ErrorAssetLockInputConflict` (42) is the only code that authorises a host +/// to discard a tracked lock, so flattening it to `ErrorWalletOperation` would +/// strand the user on a lock that can never confirm. fn map_asset_lock_funding_result( result: Result<(), PlatformWalletError>, operation: &str, @@ -624,6 +631,7 @@ fn map_asset_lock_funding_result( match result { Ok(()) => PlatformWalletFFIResult::ok(), Err(e @ PlatformWalletError::AssetLockAlreadyConsumed(_)) => e.into(), + Err(e @ PlatformWalletError::AssetLockInputConflict { .. }) => e.into(), Err(e) => PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, format!("{operation} failed: {e}"), @@ -1852,8 +1860,13 @@ mod tests { ); } + /// The two terminal asset-lock verdicts keep their own codes through + /// this wrapper — both funding entry points (fresh build and resume) + /// flatten everything else to `ErrorWalletOperation`, and a host that + /// saw the flattened code could neither hold the consumption-unknown + /// state nor offer to discard a lock that can never confirm. #[test] - fn map_asset_lock_funding_result_preserves_already_consumed_code_only() { + fn map_asset_lock_funding_result_preserves_terminal_asset_lock_codes() { let out_point = dashcore::OutPoint { txid: dashcore::Txid::all_zeros(), vout: 7, @@ -1868,6 +1881,35 @@ mod tests { ); assert!(message_of(&result).contains("Platform completion is unconfirmed")); + // The resume endpoint is where the pre-broadcast conflict screen + // fires, and it funnels through this same wrapper. + let conflict = map_asset_lock_funding_result( + Err(PlatformWalletError::AssetLockInputConflict { + out_point, + input: dashcore::OutPoint { + txid: dashcore::Txid::all_zeros(), + vout: 3, + }, + spent_by: dashcore::Txid::all_zeros(), + height: Some(1_234), + spender_chain_locked: false, + }), + "shielded resume fund-from-asset-lock", + ); + assert_eq!( + conflict.code, + PlatformWalletFFIResultCode::ErrorAssetLockInputConflict + ); + let conflict_message = message_of(&conflict); + assert!( + conflict_message.contains("can never confirm"), + "the typed Display must survive the wrapper: {conflict_message}" + ); + assert!( + conflict_message.contains("chainlocked: false"), + "the spender's finality must reach the host: {conflict_message}" + ); + let unrelated = map_asset_lock_funding_result( Err(PlatformWalletError::ShieldedNoUnspentNotes), "shielded fund-from-asset-lock", diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index f648abb395..6449b81fb4 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -302,7 +302,20 @@ pub enum PlatformWalletError { /// Terminal, not retryable: the funds behind `input` are gone into /// `spent_by`, so the only recovery is to discard this lock and build /// a new one from currently-unspent inputs. `height` is the block - /// height of the confirmed spender when the record carries block info. + /// height of the confirmed spender when the record carries block info, + /// and `spender_chain_locked` reports whether that spender has reached + /// ChainLock finality — hosts show it as confidence, never as a gate. + /// + /// A merely-`InBlock` spender is enough to condemn the lock, and the + /// verdict stays fund-safe even then. Confirmed spends of one outpoint + /// are mutually exclusive, and the spender is necessarily this wallet's + /// OWN transaction — only this wallet can sign its outpoints — so the + /// value is never lost by discarding the conflicted lock: it either + /// lives on in the sibling, or, in the freak case where a reorg unmines + /// the sibling, the inputs simply return to this wallet's spendable set + /// and fund a fresh lock. Waiting for `spender_chain_locked` before + /// reporting would buy no safety and would in practice never fire (see + /// the detection helper). /// /// Raising this error is a definite verdict; NOT raising it proves /// nothing — see the detection helper in @@ -311,13 +324,15 @@ pub enum PlatformWalletError { #[error( "Asset lock {out_point} can never confirm: it spends {input}, which was \ already spent by confirmed transaction {spent_by} (block height \ - {height:?}) — the lock is a double spend and no peer will relay it" + {height:?}, chainlocked: {spender_chain_locked}) — the lock is a \ + double spend and no peer will relay it" )] AssetLockInputConflict { out_point: dashcore::OutPoint, input: dashcore::OutPoint, spent_by: dashcore::Txid, height: Option, + spender_chain_locked: bool, }, #[error("SDK error: {0}")] 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 205b6045e4..1f406c84fc 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 @@ -194,7 +194,8 @@ impl AssetLockManager { /// Find the first outpoint of `lock`'s transaction that some **other, /// confirmed** transaction of this wallet already spent, returning -/// `(conflicting_input, spending_txid, spender_height)`. +/// `(conflicting_input, spending_txid, spender_height, +/// spender_chain_locked)`. /// /// A hit means the asset lock is a double spend of a settled outpoint. /// Peers reject such a transaction at the mempool boundary and relay @@ -204,17 +205,34 @@ impl AssetLockManager { /// [`PlatformWalletError::AssetLockInputConflict`] instead of /// (re-)broadcasting into that void. /// +/// **The gate is `is_confirmed()`, deliberately not `is_chain_locked()`.** +/// Under the default `keep-finalized-transactions = OFF` build, +/// `apply_chain_lock` evicts a record the moment a chainlock buries it and +/// retains only the txid, so a chainlocked spender essentially never +/// appears in `transaction_history()` at all: demanding ChainLock finality +/// here would make the whole screen dead code in production while leaving +/// the very failure it exists for — an old, long-settled spender — reported +/// as an unbounded proof wait. +/// +/// Condemning the lock on a merely-`InBlock` sibling is fund-safe. That +/// sibling is necessarily one of this wallet's own transactions (nobody +/// else can sign this wallet's outpoints), so the value it carries is +/// already the wallet's; discarding the conflicted lock strands nothing. +/// Even in the freak case where a reorg unmines the sibling, the inputs +/// return to this wallet's spendable set and fund a fresh lock — whereas +/// the conflicted lock itself would still be unrelayable for as long as +/// the sibling stood. `spender_chain_locked` is reported alongside the hit +/// purely so a host can express confidence in what it shows the user; it +/// is not a gate on raising the error. +/// /// **Best-effort in one direction only.** A hit is conclusive: the /// spender is a confirmed transaction sitting in this wallet's own /// history, and confirmed spends of an outpoint are mutually exclusive. -/// A miss proves nothing. Under the default -/// `keep-finalized-transactions = OFF` feature, key-wallet evicts the -/// full `TransactionRecord` once a chainlock buries it and retains only -/// the txid, so precisely the oldest — and therefore most likely — -/// conflicts are invisible here. A lock that clears this scan may still -/// be a double spend, and the existing timeout path remains its only -/// backstop. Do not restructure callers to treat "no conflict" as proof -/// of liveness. +/// A miss proves nothing — for the same eviction reason above, precisely +/// the oldest and therefore most likely conflicts are invisible here. A +/// lock that clears this scan may still be a double spend, and the +/// existing timeout path remains its only backstop. Do not restructure +/// callers to treat "no conflict" as proof of liveness. /// /// Confirmation is required rather than mere presence: an unconfirmed /// sibling that spends the same outpoint is a competing candidate, not a @@ -224,7 +242,7 @@ impl AssetLockManager { fn first_confirmed_input_conflict( info: &PlatformWalletInfo, lock: &TrackedAssetLock, -) -> Option<(OutPoint, Txid, Option)> { +) -> Option<(OutPoint, Txid, Option, bool)> { let lock_txid = lock.transaction.txid(); let lock_inputs: BTreeSet = lock .transaction @@ -232,6 +250,15 @@ fn first_confirmed_input_conflict( .iter() .map(|input| input.previous_output) .collect(); + // A record surviving in history is usually still `InBlock` even when + // the wallet's chainlock boundary has moved past its height — the + // promotion is what evicts it. Consulting the boundary as well as the + // record's own context is what keeps the reported finality honest for + // the window between the two. + let chain_locked_height = info + .core_wallet + .last_applied_chain_lock() + .map(|chain_lock| chain_lock.block_height); info.core_wallet .transaction_history() @@ -244,7 +271,12 @@ fn first_confirmed_input_conflict( .iter() .map(|input| input.previous_output) .find(|outpoint| lock_inputs.contains(outpoint))?; - Some((conflicting_input, record.txid, record.height())) + let height = record.height(); + let spender_chain_locked = record.context.is_chain_locked() + || chain_locked_height + .zip(height) + .is_some_and(|(boundary, spender_height)| spender_height <= boundary); + Some((conflicting_input, record.txid, height, spender_chain_locked)) }) } @@ -339,12 +371,13 @@ impl AssetLockManager { // funding flows — would never return. The typed error is what lets // a host offer to discard the lock instead of showing a spinner // forever. - if let Some((input, spent_by, height)) = input_conflict { + if let Some((input, spent_by, height, spender_chain_locked)) = input_conflict { tracing::warn!( outpoint = %out_point, %input, %spent_by, ?height, + spender_chain_locked, "resume_asset_lock: asset lock double-spends an outpoint \ already consumed by a confirmed transaction; it can never \ confirm" @@ -354,6 +387,7 @@ impl AssetLockManager { input, spent_by, height, + spender_chain_locked, }); } @@ -1244,6 +1278,14 @@ mod tests { )) } + fn chain_locked_at(height: u32) -> TransactionContext { + TransactionContext::InChainLockedBlock(BlockInfo::new( + height, + BlockHash::all_zeros(), + 1_700_000_000, + )) + } + /// The incident this screen exists for: a restored wallet re-spends an /// outpoint one of its own earlier, already-confirmed transactions /// consumed long ago. Peers drop the double spend without a reply, so @@ -1251,6 +1293,13 @@ mod tests { /// the user-facing funding flows — could never terminate. The resume /// must fail with the typed terminal error and must not touch the /// network on the way out. + /// + /// 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 + /// evicted from history, so a chainlock gate would never fire. The + /// error is raised all the same, reporting the weaker finality rather + /// than withholding the verdict. #[tokio::test] async fn broadcast_resume_reports_input_conflict_when_a_confirmed_tx_spent_the_input() { let fixture = ConflictFixture::new().await; @@ -1273,14 +1322,68 @@ mod tests { input, spent_by, height, + spender_chain_locked, } => { assert_eq!(out_point, fixture.out_point); assert_eq!(input, fixture.funded_input()); assert_eq!(spent_by, spender_txid); assert_eq!(height, Some(1_234)); + assert!( + !spender_chain_locked, + "an InBlock spender under no applied chainlock must \ + report the weaker finality, not claim ChainLock" + ); + } + other => panic!("expected AssetLockInputConflict, got {other:?}"), + } + assert_eq!( + fixture.broadcast_count(), + 0, + "the screen must short-circuit ahead of the defensive re-broadcast" + ); + } + + /// The same verdict with the strongest available evidence behind it: a + /// spender sitting in a chain-locked block. Hosts render the difference + /// as confidence, so the flag has to travel out with the error rather + /// than being re-derived from the message. + #[tokio::test] + async fn input_conflict_reports_a_chain_locked_spender_as_chain_locked() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + let spender = transaction_spending(fixture.funded_input()); + let spender_txid = spender.txid(); + fixture + .file_record(record_for(spender, chain_locked_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"); + let rendered = error.to_string(); + match error { + PlatformWalletError::AssetLockInputConflict { + spent_by, + height, + spender_chain_locked, + .. + } => { + assert_eq!(spent_by, spender_txid); + assert_eq!(height, Some(1_234)); + assert!( + spender_chain_locked, + "an InChainLockedBlock spender must report ChainLock finality" + ); } other => panic!("expected AssetLockInputConflict, got {other:?}"), } + assert!( + rendered.contains("chainlocked: true"), + "the rendered Display must carry the spender's finality: {rendered}" + ); assert_eq!( fixture.broadcast_count(), 0, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index ba3bb1c9e2..3076037c5c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -437,13 +437,18 @@ public enum PlatformWalletError: LocalizedError { case contestedNameNotTradable(label: String, endsAtMs: UInt64) /// The tracked asset lock spends an outpoint a different, /// already-confirmed transaction spent first, so it is a double spend no - /// peer will relay and it can never confirm. Nothing was broadcast and - /// nothing is in flight. TERMINAL: unlike `transactionBroadcastUnconfirmed` - /// — where the transaction may well be alive and discarding it would - /// strand real funds — this is the one asset-lock error that lets a host - /// offer to discard the lock and rebuild it from currently-unspent inputs. - /// The message names the lock's outpoint, the conflicting input, and the - /// confirmed spender, so a host can say *which* lock died. + /// peer will relay and it can never confirm. The screen stops the current + /// resume before it broadcasts again or enters the proof wait — a + /// `Broadcast`-status lock was already sent on an earlier call, so this is + /// not a claim that nothing ever reached the network. TERMINAL: unlike + /// `transactionBroadcastUnconfirmed` — where the transaction may well be + /// alive and discarding it would strand real funds — this is the one + /// asset-lock error that lets a host offer to discard the lock and rebuild + /// it from currently-unspent inputs, because the confirmed spender is this + /// wallet's own transaction and the value therefore stays reachable either + /// way. The message names the lock's outpoint, the conflicting input, the + /// confirmed spender, and whether that spender is chainlocked, so a host + /// can say *which* lock died and how firmly. case assetLockInputConflict(String) /// The named thing does not exist. For the deferred payment calls this is /// the wallet-was-REMOVED case: the token's wallet (or the wallet a payment @@ -589,13 +594,13 @@ public enum PlatformWalletError: LocalizedError { } else { self = .unknown(detail) } - // Code 41 carries the typed `Display` rendering, not a JSON detail + // Code 42 carries the typed `Display` rendering, not a JSON detail // object: it already names the asset-lock outpoint, the conflicting - // input, and the confirmed spender's txid, and reads as a sentence, so - // it passes through like the other prose-message codes. The terminal - // "discard and rebuild" verdict is the CODE's meaning, not the - // string's — hosts must key their discard affordance off the case, not - // off text matching. + // input, the confirmed spender's txid and that spender's finality, and + // reads as a sentence, so it passes through like the other + // prose-message codes. The terminal "discard and rebuild" verdict is + // the CODE's meaning, not the string's — hosts must key their discard + // affordance off the case, not off text matching. case .errorAssetLockInputConflict: self = .assetLockInputConflict(detail) case .notFound: self = .notFound(detail) From 9c955dce5a0a92c72c7cf374701e161eb426e001 Mon Sep 17 00:00:00 2001 From: romchornyi Date: Wed, 19 Aug 2026 11:13:47 +0300 Subject: [PATCH 03/11] fix(platform-wallet): give the conflict screen a source that survives the load (#4404) Co-authored-by: Roman <51091564+jeanpierreroma@users.noreply.github.com> Co-authored-by: Claude Opus 5 Co-authored-by: Quantum Explorer --- .../rs-platform-wallet-ffi/src/persistence.rs | 179 ++++++++++-- .../src/wallet_restore_types.rs | 50 ++++ .../changeset/client_wallet_start_state.rs | 8 + .../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 | 263 +++++++++++++++++- .../identity/network/contact_requests.rs | 1 + .../src/wallet/platform_wallet.rs | 43 ++- .../src/wallet/platform_wallet_traits.rs | 2 + .../rs-unified-sdk-jni/src/persistence.rs | 5 + .../PlatformWalletPersistenceHandler.swift | 205 +++++++++++++- .../AssetLockInputSpendRestoreTests.swift | 202 ++++++++++++++ 15 files changed, 940 insertions(+), 29 deletions(-) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockInputSpendRestoreTests.swift 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") + } +} From aaed39dc4152c0db07378e10d394e3c98de55cf0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 19 Aug 2026 15:32:23 +0700 Subject: [PATCH 04/11] fix(platform-wallet): reserve the terminal conflict verdict for a chainlocked spender MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conflict screen previously raised one terminal error for any confirmed spender, and the contracts on every surface authorized discarding the tracked lock on it — but an ordinary block can be reorganized out, at which point the sibling no longer spends the input, a peer can replay the already-broadcast lock, and it can confirm; discarding the tracking state on that evidence would strand the confirmed lock's credits. The finality of the spender now decides which verdict is raised, never whether one is: a chainlocked spender (record context, the live boundary promotion, or a restored row's own observed chainlock) still raises the terminal AssetLockInputConflict, the one code that licenses a discard; a merely-in-block spender raises the new provisional AssetLockInputContested (FFI code 43, Swift assetLockInputContested, Kotlin AssetLockInputContested with isRetryable), which equally stops the doomed broadcast-and-wait but tells the host to keep the lock and retry — the next chainlock either upgrades the verdict or the reorg clears the conflict. Both variants ride the existing typed conversions through the catch-up and shielded funding surfaces. Co-Authored-By: Claude Fable 5 --- .../dashsdk/errors/DashSdkError.kt | 21 +++ .../dashsdk/errors/DashSdkErrorTest.kt | 29 ++++ .../src/asset_lock/sync.rs | 16 +- packages/rs-platform-wallet-ffi/src/error.rs | 49 ++++-- .../src/shielded_send.rs | 43 ++++- packages/rs-platform-wallet/src/error.rs | 61 +++++-- .../src/wallet/asset_lock/sync/recovery.rs | 159 ++++++++++++------ .../PlatformWallet/PlatformWalletResult.swift | 20 +++ 8 files changed, 305 insertions(+), 93 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index 43cb9a8a13..a3ac265355 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -138,6 +138,26 @@ sealed class DashSdkError( class AssetLockInputConflict(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorAssetLockInputContested` (native code 43). The provisional + * sibling of [AssetLockInputConflict]: a confirmed transaction of + * this wallet already spent one of the tracked lock's inputs, so + * the resume stopped before broadcasting into a wait that cannot + * return — but that spender sits in an ordinary block a + * reorganization can still drop, so the verdict is NOT final. + * + * NO discard licence: keep the tracked lock and retry later (next + * launch, or after the next chainlock). The situation resolves + * itself — the sibling gets chainlock-buried and the next resume + * reports the terminal code 42, or a reorg drops the sibling and + * the next resume proceeds normally. The Android analog of Swift's + * `PlatformWalletError.assetLockInputContested`. + */ + class AssetLockInputContested(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) { + override val isRetryable: Boolean get() = true + } + /** * `ErrorShieldedNoRecordedAnchor` (native code 19). A shielded spend * could not be built against a Platform-recorded anchor because the @@ -550,6 +570,7 @@ sealed class DashSdkError( } ?: PlatformWallet.Generic(code, message, cause) 41 -> PlatformWallet.PlatformShieldCapacityExceeded(message, cause) 42 -> PlatformWallet.AssetLockInputConflict(message, cause) // ErrorAssetLockInputConflict + 43 -> PlatformWallet.AssetLockInputContested(message, cause) // ErrorAssetLockInputContested // ErrorSigningKeyUnavailable — the STRUCTURED signer // discriminator (dashpay/platform#4060 finding 7): the typed // completion code rides the whole Rust round-trip, no message diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index 06751821bc..31e6161da5 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -219,6 +219,35 @@ class DashSdkErrorTest { ) } + @Test + fun assetLockInputContestedCode43MapsTypedAndRetryable() { + // PROVISIONAL: the confirmed spender is not yet chainlocked, so its + // block can still reorg away. The host keeps the tracked lock and + // retries later — it must never treat this as the terminal 42's + // discard licence, and it must never fall through to Generic. + val message = + "Asset lock a:0 cannot currently confirm: it spends b:1, which confirmed " + + "transaction c (block height Some(1234)) has taken — but that spender is " + + "not yet chainlocked, so the verdict is provisional; keep the lock and " + + "retry after the next chainlock" + val mapped = DashSdkError.fromNative( + DashSDKException( + DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 43, + message, + ), + ) + + assertTrue( + "code 43 must not fall through to Generic", + mapped is DashSdkError.PlatformWallet.AssetLockInputContested, + ) + assertEquals(message, mapped.message) + assertTrue( + "AssetLockInputContested is provisional — keep the lock and retry later", + mapped.isRetryable, + ) + } + @Test fun signingKeyUnavailableCode31MapsTyped() { // The STRUCTURED discriminator (dashpay/platform#4060 finding 7): diff --git a/packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs b/packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs index 1a6cdbfc51..d00c027330 100644 --- a/packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs +++ b/packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs @@ -148,12 +148,16 @@ pub unsafe extern "C" fn asset_lock_manager_catch_up_blocking( "asset_lock_manager_catch_up_blocking: resume_asset_lock failed" ); match e { - // Terminal double spend: route through the typed conversion - // so the host still receives ErrorAssetLockInputConflict - // (42) — the one code that authorises discarding a tracked - // lock. Flattening it to ErrorWalletOperation here would - // leave the host with a spinner it can never resolve. - conflict @ PlatformWalletError::AssetLockInputConflict { .. } => conflict.into(), + // Double-spend verdicts route through the typed conversion + // so the host receives the real code: terminal + // ErrorAssetLockInputConflict (42) — the one code that + // authorises discarding a tracked lock — or the + // provisional ErrorAssetLockInputContested (43), which + // stops the wait but keeps the lock for a later retry. + // Flattening either to ErrorWalletOperation would leave + // the host with a spinner it can never resolve. + conflict @ (PlatformWalletError::AssetLockInputConflict { .. } + | PlatformWalletError::AssetLockInputContested { .. }) => conflict.into(), other => PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, format!("{}", other), diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index a2868ca1ca..206c5669f1 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -382,16 +382,20 @@ pub enum PlatformWalletFFIResultCode { /// indefinitely. /// /// TERMINAL, and the only code here that authorises a host to discard - /// a tracked asset lock: this resume broadcast nothing and no retry of - /// this outpoint can ever succeed while the confirmed spender stands. - /// The remedy is to drop the lock and build a new one from - /// currently-unspent inputs — a fund-safe action either way, because - /// the conflicting spender is necessarily this wallet's own - /// transaction (only this wallet can sign its outpoints): the value - /// lives in the sibling, and even a freak reorg that removed the - /// sibling would simply return the inputs to the spendable set. - /// Contrast `ErrorTransactionBroadcastUnconfirmed`, where the tx may - /// well be alive and discarding it would strand real funds. + /// a tracked asset lock: this resume broadcast nothing, and the + /// spender that took the input has reached ChainLock finality — its + /// block can never be reorganised away, so no retry of this outpoint + /// can ever succeed. The remedy is to drop the lock and build a new + /// one from currently-unspent inputs — fund-safe, because the + /// conflicting spender is necessarily this wallet's own transaction + /// (only this wallet can sign its outpoints): the value lives on in + /// the sibling. Contrast `ErrorTransactionBroadcastUnconfirmed`, where + /// the tx may well be alive and discarding it would strand real funds. + /// + /// A confirmed-but-not-chainlocked spender reports + /// [`Self::ErrorAssetLockInputContested`] (43) instead — same + /// stopped-wait, NO discard licence — so this code's finality claim + /// is structural, not advisory. /// /// Raised only on a positive detection; its ABSENCE is not a liveness /// signal. The wallet-side scan reads confirmed records still held in @@ -401,9 +405,29 @@ pub enum PlatformWalletFFIResultCode { /// /// Message: the typed `Display` rendering, which names the asset-lock /// outpoint, the conflicting input, the confirmed spender's txid, and - /// the spender's finality (chainlocked or merely in a block). + /// the spender's finality (always chainlocked for this code). ErrorAssetLockInputConflict = 42, + /// Maps `PlatformWalletError::AssetLockInputContested`. Same detection + /// as [`Self::ErrorAssetLockInputConflict`] — a confirmed transaction + /// of this wallet already spent one of the tracked lock's inputs, so + /// the resume stopped before broadcasting into a wait that cannot + /// return — but the spender sits in an ordinary block a + /// reorganisation can still drop, so the verdict is PROVISIONAL. + /// + /// NOT a discard licence. The host keeps the tracked lock and retries + /// later (next launch, or after the next chainlock). The situation + /// resolves itself: either the sibling gets buried by a chainlock and + /// the next resume reports the terminal 42, or a reorg drops the + /// sibling and the next resume proceeds normally. Discarding tracking + /// state on this code risks stranding a lock that a replayed + /// broadcast could still confirm. + /// + /// Message: the typed `Display` rendering, which names the asset-lock + /// outpoint, the conflicting input, the confirmed spender's txid and + /// height, and says the verdict is provisional. + ErrorAssetLockInputContested = 43, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -663,6 +687,9 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::AssetLockInputConflict { .. } => { PlatformWalletFFIResultCode::ErrorAssetLockInputConflict } + PlatformWalletError::AssetLockInputContested { .. } => { + PlatformWalletFFIResultCode::ErrorAssetLockInputContested + } // A quiesce/drain barrier that did not complete within budget // (clear/reset paths). The host must fail closed: keep its // callback context alive and skip any paired persistence wipe. diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 6a7d096b03..982678abda 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -618,12 +618,14 @@ fn map_spend_result( /// error path. The wallet retains nonterminal consumption-unknown state; the /// host must not interpret this code as authenticated completion. /// -/// The terminal double-spend report rides the same typed conversion (both the +/// The double-spend verdicts ride the same typed conversion (both the /// fresh-build and resume entry points funnel through here, and the resume is -/// where the pre-broadcast conflict screen actually fires). Its +/// where the pre-broadcast conflict screen actually fires). /// `ErrorAssetLockInputConflict` (42) is the only code that authorises a host -/// to discard a tracked lock, so flattening it to `ErrorWalletOperation` would -/// strand the user on a lock that can never confirm. +/// to discard a tracked lock, and `ErrorAssetLockInputContested` (43) is its +/// provisional keep-and-retry sibling; flattening either to +/// `ErrorWalletOperation` would strand the user on a lock the host cannot +/// classify. fn map_asset_lock_funding_result( result: Result<(), PlatformWalletError>, operation: &str, @@ -631,7 +633,10 @@ fn map_asset_lock_funding_result( match result { Ok(()) => PlatformWalletFFIResult::ok(), Err(e @ PlatformWalletError::AssetLockAlreadyConsumed(_)) => e.into(), - Err(e @ PlatformWalletError::AssetLockInputConflict { .. }) => e.into(), + Err( + e @ (PlatformWalletError::AssetLockInputConflict { .. } + | PlatformWalletError::AssetLockInputContested { .. }), + ) => e.into(), Err(e) => PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, format!("{operation} failed: {e}"), @@ -1892,7 +1897,7 @@ mod tests { }, spent_by: dashcore::Txid::all_zeros(), height: Some(1_234), - spender_chain_locked: false, + spender_chain_locked: true, }), "shielded resume fund-from-asset-lock", ); @@ -1906,10 +1911,34 @@ mod tests { "the typed Display must survive the wrapper: {conflict_message}" ); assert!( - conflict_message.contains("chainlocked: false"), + conflict_message.contains("chainlocked: true"), "the spender's finality must reach the host: {conflict_message}" ); + // The provisional sibling rides the same wrapper under its own code: + // a merely-in-block spender stops the wait but must not surface as + // the terminal, discard-licensing 42. + let contested = map_asset_lock_funding_result( + Err(PlatformWalletError::AssetLockInputContested { + out_point, + input: dashcore::OutPoint { + txid: dashcore::Txid::all_zeros(), + vout: 3, + }, + spent_by: dashcore::Txid::all_zeros(), + height: Some(1_234), + }), + "shielded resume fund-from-asset-lock", + ); + assert_eq!( + contested.code, + PlatformWalletFFIResultCode::ErrorAssetLockInputContested + ); + assert!( + message_of(&contested).contains("provisional"), + "the contested Display must say the verdict is provisional" + ); + let unrelated = map_asset_lock_funding_result( Err(PlatformWalletError::ShieldedNoUnspentNotes), "shielded fund-from-asset-lock", diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 6449b81fb4..26a26bb544 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -299,23 +299,24 @@ pub enum PlatformWalletError { /// actually spent it — often one of the wallet's own earlier asset /// locks — has been confirmed for a long time. /// - /// Terminal, not retryable: the funds behind `input` are gone into - /// `spent_by`, so the only recovery is to discard this lock and build - /// a new one from currently-unspent inputs. `height` is the block - /// height of the confirmed spender when the record carries block info, - /// and `spender_chain_locked` reports whether that spender has reached - /// ChainLock finality — hosts show it as confidence, never as a gate. + /// Terminal, not retryable: this variant is raised only when the + /// spender has reached ChainLock finality — its block can never be + /// reorganised away — so the funds behind `input` are definitively + /// gone into `spent_by`, and the only recovery is to discard this lock + /// and build a new one from currently-unspent inputs. `height` is the + /// block height of the confirmed spender when the record carries block + /// info; `spender_chain_locked` is retained for message/ABI stability + /// and is always `true` here. /// - /// A merely-`InBlock` spender is enough to condemn the lock, and the - /// verdict stays fund-safe even then. Confirmed spends of one outpoint - /// are mutually exclusive, and the spender is necessarily this wallet's - /// OWN transaction — only this wallet can sign its outpoints — so the - /// value is never lost by discarding the conflicted lock: it either - /// lives on in the sibling, or, in the freak case where a reorg unmines - /// the sibling, the inputs simply return to this wallet's spendable set - /// and fund a fresh lock. Waiting for `spender_chain_locked` before - /// reporting would buy no safety and would in practice never fire (see - /// the detection helper). + /// A confirmed-but-not-yet-chainlocked spender raises + /// [`Self::AssetLockInputContested`] instead: it equally stops the + /// doomed broadcast-and-wait, but it does NOT authorise discarding the + /// tracked lock, because an ordinary block can still be reorganised + /// out — at which point the sibling no longer spends the input, a peer + /// can replay the already-broadcast lock, and it can confirm. Deleting + /// the tracking state on that evidence would strand the confirmed + /// lock's credits. Splitting the verdict is what keeps this variant's + /// discard licence sound. /// /// Raising this error is a definite verdict; NOT raising it proves /// nothing — see the detection helper in @@ -335,6 +336,34 @@ pub enum PlatformWalletError { spender_chain_locked: bool, }, + /// As [`Self::AssetLockInputConflict`], but the confirmed spender has + /// NOT reached ChainLock finality: it sits in an ordinary block that a + /// reorganisation can still drop. + /// + /// The immediate consequence is the same — while the sibling stands, + /// peers reject the lock as a double spend and a proof wait would hang + /// unboundedly, so the resume stops here without broadcasting or + /// waiting. The verdict, however, is provisional, and this variant + /// carries NO licence to discard the tracked lock. The host keeps the + /// lock and retries later; the situation resolves itself in one of two + /// ways: the sibling reaches a chainlock and the next resume reports + /// the terminal [`Self::AssetLockInputConflict`], or a reorg drops the + /// sibling and the next resume proceeds normally. Both signed + /// transactions are this wallet's own, so no outcome loses funds — + /// but only the chainlocked verdict makes *discarding state* safe. + #[error( + "Asset lock {out_point} cannot currently confirm: it spends {input}, \ + which confirmed transaction {spent_by} (block height {height:?}) has \ + taken — but that spender is not yet chainlocked, so the verdict is \ + provisional; keep the lock and retry after the next chainlock" + )] + AssetLockInputContested { + out_point: dashcore::OutPoint, + input: dashcore::OutPoint, + spent_by: dashcore::Txid, + height: Option, + }, + #[error("SDK error: {0}")] Sdk(#[from] dash_sdk::Error), 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 d62cd2bf31..f594ded9a9 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 @@ -214,16 +214,19 @@ impl AssetLockManager { /// the very failure it exists for — an old, long-settled spender — reported /// as an unbounded proof wait. /// -/// Condemning the lock on a merely-`InBlock` sibling is fund-safe. That -/// sibling is necessarily one of this wallet's own transactions (nobody -/// else can sign this wallet's outpoints), so the value it carries is -/// already the wallet's; discarding the conflicted lock strands nothing. -/// Even in the freak case where a reorg unmines the sibling, the inputs -/// return to this wallet's spendable set and fund a fresh lock — whereas -/// the conflicted lock itself would still be unrelayable for as long as -/// the sibling stood. `spender_chain_locked` is reported alongside the hit -/// purely so a host can express confidence in what it shows the user; it -/// is not a gate on raising the error. +/// Reporting the conflict on a merely-`InBlock` sibling is fund-safe: +/// that sibling is necessarily one of this wallet's own transactions +/// (nobody else can sign this wallet's outpoints), so the value it +/// carries is already the wallet's, and stopping the doomed wait costs +/// nothing — the lock is unrelayable for as long as the sibling stands. +/// What an in-block sibling does NOT justify is *discarding* the tracked +/// lock: its block can still reorg out, at which point a peer can replay +/// the already-broadcast lock and it can confirm — with its tracking +/// state gone, the confirmed lock's credits would be stranded. +/// `spender_chain_locked` therefore selects WHICH error the caller +/// raises — the terminal, discard-licensing conflict for a chainlocked +/// spender, the provisional keep-and-retry contested variant otherwise — +/// it is never a gate on raising one at all. /// /// **Best-effort in one direction only.** A hit is conclusive: the /// spender is a confirmed transaction sitting in this wallet's own @@ -344,11 +347,15 @@ impl AssetLockManager { /// the proof already exists and no wait happens, so the value is moot. /// /// A `Built` / `Broadcast` lock is first screened by - /// [`first_confirmed_input_conflict`]; a hit short-circuits to - /// [`PlatformWalletError::AssetLockInputConflict`] without broadcasting - /// or waiting, because such a lock is a double spend that no peer will - /// relay. That screen is one-sided — read its docs before treating a - /// clean pass as evidence the lock is alive. + /// [`first_confirmed_input_conflict`]; a hit short-circuits without + /// broadcasting or waiting, because such a lock is a double spend that + /// no peer will relay while the spender stands. A chainlocked spender + /// raises the terminal + /// [`PlatformWalletError::AssetLockInputConflict`]; a merely-in-block + /// one raises the provisional + /// [`PlatformWalletError::AssetLockInputContested`], which keeps the + /// lock tracked for a later retry. The screen is one-sided — read its + /// docs before treating a clean pass as evidence the lock is alive. pub async fn resume_asset_lock( &self, out_point: &OutPoint, @@ -418,15 +425,34 @@ impl AssetLockManager { ?height, spender_chain_locked, "resume_asset_lock: asset lock double-spends an outpoint \ - already consumed by a confirmed transaction; it can never \ - confirm" + already consumed by a confirmed transaction; it cannot \ + confirm while that spender stands" ); - return Err(PlatformWalletError::AssetLockInputConflict { - out_point: *out_point, - input, - spent_by, - height, - spender_chain_locked, + // The finality of the spender decides WHICH verdict, not + // whether one is raised. A chainlocked spender can never be + // reorganised away, so the terminal variant — the one that + // licenses the host to discard the tracked lock — is sound. + // A merely-in-block spender stops the doomed wait all the + // same, but its block can still drop in a reorg (and a peer + // can then replay the already-broadcast lock), so the + // contested variant keeps the lock tracked for a later + // retry: the next chainlock either buries the sibling and + // upgrades the verdict, or the reorg clears the conflict. + return Err(if spender_chain_locked { + PlatformWalletError::AssetLockInputConflict { + out_point: *out_point, + input, + spent_by, + height, + spender_chain_locked, + } + } else { + PlatformWalletError::AssetLockInputContested { + out_point: *out_point, + input, + spent_by, + height, + } }); } @@ -1442,20 +1468,15 @@ mod tests { .await .expect_err("a double-spent asset lock must fail, not wait"); match error { - PlatformWalletError::AssetLockInputConflict { - spent_by, - spender_chain_locked, - .. - } => { + PlatformWalletError::AssetLockInputContested { spent_by, .. } => { 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" - ); + // The contested variant IS the assertion: a snapshot height + // must not claim chainlock finality — the boundary only + // proves finality for a block the live chain is known to + // contain — so no restored row may produce the terminal, + // discard-licensing conflict from the boundary fallback. } - other => panic!("expected AssetLockInputConflict, got {other:?}"), + other => panic!("expected AssetLockInputContested, got {other:?}"), } } @@ -1485,11 +1506,11 @@ mod tests { .await .expect_err("a double-spent asset lock must fail, not wait"); match error { - PlatformWalletError::AssetLockInputConflict { spent_by, .. } => assert_eq!( + PlatformWalletError::AssetLockInputContested { 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:?}"), + other => panic!("expected AssetLockInputContested, got {other:?}"), } } @@ -1551,14 +1572,13 @@ mod tests { } } - /// 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 - /// evicted from history, so a chainlock gate would never fire. The - /// error is raised all the same, reporting the weaker finality rather - /// than withholding the verdict. + /// The spender here is merely `InBlock` with no applied chainlock + /// covering it, so the verdict is provisional: the resume still stops + /// before broadcasting or waiting, but through the contested variant, + /// which carries no licence to discard the tracked lock — that block + /// can still reorg out and the lock become viable again. #[tokio::test] - async fn broadcast_resume_reports_input_conflict_when_a_confirmed_tx_spent_the_input() { + async fn broadcast_resume_reports_a_contested_input_for_a_merely_in_block_spender() { let fixture = ConflictFixture::new().await; fixture.track(AssetLockStatus::Broadcast, None).await; @@ -1572,26 +1592,20 @@ mod tests { .manager .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) .await - .expect_err("a double-spent asset lock must fail, not wait"); + .expect_err("a currently double-spent asset lock must fail, not wait"); match error { - PlatformWalletError::AssetLockInputConflict { + PlatformWalletError::AssetLockInputContested { out_point, input, spent_by, height, - spender_chain_locked, } => { assert_eq!(out_point, fixture.out_point); assert_eq!(input, fixture.funded_input()); assert_eq!(spent_by, spender_txid); assert_eq!(height, Some(1_234)); - assert!( - !spender_chain_locked, - "an InBlock spender under no applied chainlock must \ - report the weaker finality, not claim ChainLock" - ); } - other => panic!("expected AssetLockInputConflict, got {other:?}"), + other => panic!("expected AssetLockInputContested, got {other:?}"), } assert_eq!( fixture.broadcast_count(), @@ -1600,6 +1614,45 @@ mod tests { ); } + /// A live in-block record sitting at or below the applied chainlock + /// boundary IS final — the record's presence in live history attests + /// the block survived to be buried — so the boundary promotion holds + /// for live evidence and the verdict is the terminal, discard-licensing + /// conflict. (The restored snapshot deliberately gets no such + /// promotion; see `restored_spend_below_the_chainlock_boundary_stays_unpromoted`.) + #[tokio::test] + async fn a_live_spender_below_the_boundary_reports_the_terminal_conflict() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + let spender = transaction_spending(fixture.funded_input()); + let spender_txid = spender.txid(); + fixture + .file_record(record_for(spender, confirmed_at(1_234))) + .await; + fixture.set_chain_lock_boundary(1_300).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); + assert!( + spender_chain_locked, + "a live record below the applied boundary is chainlock-final" + ); + } + other => panic!("expected the terminal AssetLockInputConflict, got {other:?}"), + } + } + /// The same verdict with the strongest available evidence behind it: a /// spender sitting in a chain-locked block. Hosts render the difference /// as confidence, so the flag has to travel out with the error rather diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 3076037c5c..4f84893bb2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -156,6 +156,14 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// liveness — the Rust-side scan cannot see conflicts whose spender was /// already pruned. case errorAssetLockInputConflict = 42 + /// The provisional sibling of `errorAssetLockInputConflict`: a confirmed + /// transaction of this wallet already spent one of the tracked lock's + /// inputs, so the resume stopped before broadcasting into a wait that + /// cannot return — but that spender sits in an ordinary block a reorg can + /// still drop, so the verdict is NOT final. No discard licence: keep the + /// lock tracked and retry later; the next chainlock either upgrades this + /// to the terminal 42 or the conflict disappears with the reorg. + case errorAssetLockInputContested = 43 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the /// wallet-was-REMOVED case here. @@ -255,6 +263,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorShieldedInsufficientBalance case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ASSET_LOCK_INPUT_CONFLICT: self = .errorAssetLockInputConflict + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ASSET_LOCK_INPUT_CONTESTED: + self = .errorAssetLockInputContested case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -450,6 +460,13 @@ public enum PlatformWalletError: LocalizedError { /// confirmed spender, and whether that spender is chainlocked, so a host /// can say *which* lock died and how firmly. case assetLockInputConflict(String) + /// The keep-and-retry sibling of `assetLockInputConflict`: the confirmed + /// spender is not yet chainlocked, so its block can still reorg away and + /// the verdict is provisional. The resume stopped (no broadcast, no + /// wait), but the tracked lock must NOT be discarded on this error — + /// retry on a later launch or after the next chainlock, when it either + /// upgrades to the terminal `assetLockInputConflict` or resolves clean. + case assetLockInputContested(String) /// The named thing does not exist. For the deferred payment calls this is /// the wallet-was-REMOVED case: the token's wallet (or the wallet a payment /// was just signed against) is no longer registered in the manager, so there @@ -485,6 +502,7 @@ public enum PlatformWalletError: LocalizedError { .reservationWalletMismatch(let m), .notForSale(let m), .assetLockInputConflict(let m), + .assetLockInputContested(let m), .notFound(let m), .unknown(let m): return m // The three value-carrying marketplace rejections compose their @@ -603,6 +621,8 @@ public enum PlatformWalletError: LocalizedError { // affordance off the case, not off text matching. case .errorAssetLockInputConflict: self = .assetLockInputConflict(detail) + case .errorAssetLockInputContested: + self = .assetLockInputContested(detail) case .notFound: self = .notFound(detail) case .errorUnknown: self = .unknown(detail) } From 679e860989ad0ab715cf2d447787776f912e6975 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 19 Aug 2026 16:06:35 +0700 Subject: [PATCH 05/11] fix(swift-sdk): make every isSpent writer monotonic, and tidy the review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two sibling writers (upsertUtxo's drain resolution and markUtxoSpent) still assigned isSpent from the incoming spender's context, so a later mempool-context resolution could downgrade a flag an in-block spend already set — evaporating the conflict evidence the load path restores from isSpent rows. Both now use the same monotonic rule as resolveInputOutpoint. Also from review: code 43 joins the registry comment next to 42; WalletRestoreEntryFFI gains a field-naming Default impl so the test stand-in stops being mem::zeroed (which would become silent UB the day a validity-niche field joins the ABI struct); and the broadcast wording on both conflict codes now says explicitly that the current resume performs no additional broadcast — a Broadcast-status lock was sent on an earlier call. Co-Authored-By: Claude Fable 5 --- packages/rs-platform-wallet-ffi/src/error.rs | 8 ++-- .../rs-platform-wallet-ffi/src/persistence.rs | 6 +-- .../src/wallet_restore_types.rs | 42 +++++++++++++++++++ packages/rs-platform-wallet/src/error.rs | 5 ++- .../PlatformWalletPersistenceHandler.swift | 9 +++- 5 files changed, 60 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 206c5669f1..8dc413737a 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -266,7 +266,8 @@ pub enum PlatformWalletFFIResultCode { // 39 ErrorInsufficientIdentityCredits DPNS username marketplace // 40 ErrorContestedNameNotTradable DPNS username marketplace // 41 ErrorShieldedInsufficientBalance Platform→Shielded capacity preflight - // 42 ErrorAssetLockInputConflict asset-lock double-spend detection + // 42 ErrorAssetLockInputConflict asset-lock double-spend detection (terminal) + // 43 ErrorAssetLockInputContested asset-lock double-spend detection (provisional) // // 38/39/40 carry a STABLE JSON detail object in the result `message` // instead of the typed `Display` rendering — see each variant's doc for @@ -382,7 +383,8 @@ pub enum PlatformWalletFFIResultCode { /// indefinitely. /// /// TERMINAL, and the only code here that authorises a host to discard - /// a tracked asset lock: this resume broadcast nothing, and the + /// a tracked asset lock: this resume performed no additional broadcast + /// (a `Broadcast`-status lock was sent on an earlier call), and the /// spender that took the input has reached ChainLock finality — its /// block can never be reorganised away, so no retry of this outpoint /// can ever succeed. The remedy is to drop the lock and build a new @@ -411,7 +413,7 @@ pub enum PlatformWalletFFIResultCode { /// Maps `PlatformWalletError::AssetLockInputContested`. Same detection /// as [`Self::ErrorAssetLockInputConflict`] — a confirmed transaction /// of this wallet already spent one of the tracked lock's inputs, so - /// the resume stopped before broadcasting into a wait that cannot + /// the resume stopped without a further broadcast or a wait that cannot /// return — but the spender sits in an ordinary block a /// reorganisation can still drop, so the verdict is PROVISIONAL. /// diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index bb3dff2ef0..411eab7876 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -6077,9 +6077,9 @@ mod tests { 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() }; + // spend slice; `Default` names every field, so the compiler + // re-checks this stand-in whenever the ABI struct grows. + let mut entry = WalletRestoreEntryFFI::default(); entry.asset_lock_input_spends = &row; entry.asset_lock_input_spends_count = 1; 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 fcd15a884b..c889ea1280 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -703,6 +703,48 @@ pub struct WalletRestoreEntryFFI { pub asset_lock_input_spends_count: usize, } +/// Every field named explicitly so that adding a field to this ABI struct +/// is a compile error here rather than a silently-widened `mem::zeroed()` +/// in test code: the all-zero bit pattern is valid for today's pointers, +/// integers and `FFINetwork`, but stops being valid the moment a field +/// with a validity niche (a `NonNull`, a reference, a gap-ful enum) joins +/// the struct — and that regression would otherwise be silent UB. +impl Default for WalletRestoreEntryFFI { + fn default() -> Self { + Self { + wallet_id: [0u8; 32], + network: crate::types::FFINetwork::Testnet, + accounts: std::ptr::null(), + accounts_count: 0, + platform_address_balances: std::ptr::null(), + platform_address_balances_count: 0, + platform_sync_height: 0, + platform_sync_timestamp: 0, + platform_last_known_recent_block: 0, + identities: std::ptr::null(), + identities_count: 0, + birth_height: 0, + synced_height: 0, + last_processed_height: 0, + last_synced: 0, + utxos: std::ptr::null(), + utxos_count: 0, + tracked_asset_locks: std::ptr::null(), + tracked_asset_locks_count: 0, + unresolved_asset_lock_tx_records: std::ptr::null(), + unresolved_asset_lock_tx_records_count: 0, + provider_special_txs: std::ptr::null(), + provider_special_txs_count: 0, + core_address_pools: std::ptr::null(), + core_address_pools_count: 0, + last_applied_chain_lock_bytes: std::ptr::null(), + last_applied_chain_lock_bytes_len: 0, + asset_lock_input_spends: std::ptr::null(), + asset_lock_input_spends_count: 0, + } + } +} + // SAFETY: Pointers are Swift-owned and lifetime-scoped to the callback. // Sending the struct across threads without being used is fine; any // use must happen within the callback window. diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 26a26bb544..86ee4a5e84 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -342,8 +342,9 @@ pub enum PlatformWalletError { /// /// The immediate consequence is the same — while the sibling stands, /// peers reject the lock as a double spend and a proof wait would hang - /// unboundedly, so the resume stops here without broadcasting or - /// waiting. The verdict, however, is provisional, and this variant + /// unboundedly, so the resume stops here without a further broadcast + /// or wait (a `Broadcast`-status lock was already sent on an earlier + /// call). The verdict, however, is provisional, and this variant /// carries NO licence to discard the tracked lock. The host keeps the /// lock and retries later; the situation resolves itself in one of two /// ways: the sibling reaches a chainlock and the next resume reports diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 62ab306325..6faba95ba6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1384,7 +1384,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.spendingTransaction = spending } if let spending = resolvedSpending { - record.isSpent = Self.spendIsInBlock(spending) + // Monotonic — same rule as `resolveInputOutpoint`: a later + // mempool-context resolution must not downgrade a flag an + // in-block spend already set, or the conflict evidence the + // load path restores from `isSpent` rows evaporates. + record.isSpent = record.isSpent || Self.spendIsInBlock(spending) } record.lastUpdated = Date() for row in pendingRows { @@ -1436,7 +1440,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // and set it then. Writing `false` here would flap a // previously-true `isSpent` on every reordered emit. if let spending = spendingTx { - txo.isSpent = Self.spendIsInBlock(spending) + // Monotonic — same rule as `resolveInputOutpoint`. + txo.isSpent = txo.isSpent || Self.spendIsInBlock(spending) } txo.lastUpdated = Date() // The spend signal landed both via the legacy From ea40dd63a76846fb60f2bb2997af9adf288865e8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 19 Aug 2026 16:23:56 +0700 Subject: [PATCH 06/11] fix(platform-wallet-ffi): initialize the test entry as a struct literal clippy's field_reassign_with_default rejects assigning onto a Default::default() binding under the workspace lane's -D warnings; the functional-update literal keeps the compiler-enforced every-field property the Default impl exists for. Co-Authored-By: Claude Fable 5 --- packages/rs-platform-wallet-ffi/src/persistence.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 411eab7876..4d8cb6561e 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -6079,9 +6079,11 @@ mod tests { // The decoder reads only `wallet_id` (for the log line) and the // spend slice; `Default` names every field, so the compiler // re-checks this stand-in whenever the ABI struct grows. - let mut entry = WalletRestoreEntryFFI::default(); - entry.asset_lock_input_spends = &row; - entry.asset_lock_input_spends_count = 1; + let entry = WalletRestoreEntryFFI { + asset_lock_input_spends: &row, + asset_lock_input_spends_count: 1, + ..Default::default() + }; let spends = build_asset_lock_input_spends(&entry); let spend = spends.values().next().expect("row decodes"); From 67319942513d350866cb38bbc3a7636283cdb15b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 19 Aug 2026 17:56:27 +0700 Subject: [PATCH 07/11] fix(platform-wallet): restore the spender records themselves and retire the snapshot side-channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conflict screen's load-time evidence becomes ordinary transaction records: the Swift builder now emits the settled spenders of the unresolved locks' inputs through the existing unresolved_asset_lock_tx_records channel, and the screen reads one source of truth — live history. That dissolves three review blockers at once: the provisional verdict can now actually resolve (apply_chain_lock promotes the restored record on the next chainlock, a reorg re-observation demotes it), the WalletRestoreEntryFFI layout returns to its released shape (no array-element widening, so the stride hazard is gone), and the AssetLockInputSpendFFI decoder, RestoredSpend map, map-first precedence, and every zero-init site are deleted rather than patched. The record channel classifies restored transactions from their own payload now instead of hard-tagging AssetLock, so a restored spender cannot masquerade as a funding record. Spentness and spender linkage now move as one finality-aware state: reconcileSpendObservation replaces the blanket monotonic flag at all three writers — re-observing the linked spender follows its context both directions (a reorg demotion is chain truth), a different in-block spender takes link and flag together, and a mempool competitor never displaces confirmed evidence. Also from review: the terminal AssetLockInputConflict variant drops its redundant finality boolean (finality IS the variant; the Display hardcodes chainlocked: true); the self-conflict regression rejects both verdict variants; and the automatic Swift catch-up wraps its FFI result (fixing a message leak), returns the typed double-spend verdicts from the task group, and publishes the first one to lastError so a host UI can offer discard-and-rebuild (42) or explain the retry (43) instead of silently discarding both. Co-Authored-By: Claude Fable 5 --- .../dashsdk/errors/DashSdkErrorTest.kt | 2 +- packages/rs-platform-wallet-ffi/src/error.rs | 1 - .../rs-platform-wallet-ffi/src/persistence.rs | 144 +----- .../src/shielded_send.rs | 1 - .../src/wallet_restore_types.rs | 52 --- .../changeset/client_wallet_start_state.rs | 8 - packages/rs-platform-wallet/src/error.rs | 10 +- .../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 | 276 +----------- .../identity/network/contact_requests.rs | 1 - .../src/wallet/platform_wallet.rs | 43 +- .../src/wallet/platform_wallet_traits.rs | 2 - .../rs-unified-sdk-jni/src/persistence.rs | 5 - .../PlatformWalletManager.swift | 50 ++- .../PlatformWalletPersistenceHandler.swift | 412 ++++++++---------- .../AssetLockInputSpendRestoreTests.swift | 85 ++-- 20 files changed, 304 insertions(+), 799 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index 31e6161da5..2aeeafaeca 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -199,7 +199,7 @@ class DashSdkErrorTest { // confirm. val message = "Asset lock a:0 can never confirm: it spends b:1, which was already spent by " + - "confirmed transaction c (block height Some(1234), chainlocked: false) — " + + "confirmed transaction c (block height Some(1234), chainlocked: true) — " + "the lock is a double spend and no peer will relay it" val mapped = DashSdkError.fromNative( DashSDKException( diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 8dc413737a..439daa988a 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -1680,7 +1680,6 @@ mod tests { }, spent_by: out_point.txid, height: Some(1_234), - spender_chain_locked: true, } .into(); assert_eq!( diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 4d8cb6561e..cbcc1e3161 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -4810,14 +4810,11 @@ 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() @@ -4838,83 +4835,6 @@ fn build_wallet_start_state( Ok((wallet_state, platform_address_state)) } -/// 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. "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 @@ -5895,16 +5815,28 @@ fn restore_unresolved_asset_lock_tx_records( }; let account_type = account.managed_account_type().to_account_type(); + // Classify from the transaction itself, the way the upstream + // router does: an `AssetLockPayloadType` special-tx payload IS + // the definition of an asset lock. This array carries both the + // locks' own funding transactions and the confirmed spenders of + // their inputs (the conflict screen's evidence), and tagging an + // ordinary spender as an asset lock would feed phantom entries + // to anything keying off `transaction_type`. + let transaction_type = if matches!( + tx.special_transaction_payload, + Some( + dashcore::transaction::special_transaction::TransactionPayload::AssetLockPayloadType(_) + ) + ) { + TransactionType::AssetLock + } else { + TransactionType::Standard + }; let record = TransactionRecord::new( tx, account_type, context, - // Funding transactions ARE asset locks by definition — - // the upstream router classifies them via the - // `AssetLockPayloadType` special-tx payload. Use the - // same tag here so any downstream code keying off - // `transaction_type` sees the canonical value. - TransactionType::AssetLock, + transaction_type, // The funding flow always starts from our own UTXOs // and writes one credit output to ourselves; per // `TransactionDirection::Internal`'s docstring, a @@ -6051,46 +5983,6 @@ 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; `Default` names every field, so the compiler - // re-checks this stand-in whenever the ABI struct grows. - let entry = WalletRestoreEntryFFI { - asset_lock_input_spends: &row, - asset_lock_input_spends_count: 1, - ..Default::default() - }; - - 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/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 982678abda..39dfa41caf 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -1897,7 +1897,6 @@ mod tests { }, spent_by: dashcore::Txid::all_zeros(), height: Some(1_234), - spender_chain_locked: true, }), "shielded resume fund-from-asset-lock", ); 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 c889ea1280..8eead68201 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -516,33 +516,6 @@ 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. @@ -678,29 +651,6 @@ 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, } /// Every field named explicitly so that adding a field to this ABI struct @@ -739,8 +689,6 @@ impl Default for WalletRestoreEntryFFI { core_address_pools_count: 0, last_applied_chain_lock_bytes: std::ptr::null(), last_applied_chain_lock_bytes_len: 0, - asset_lock_input_spends: std::ptr::null(), - asset_lock_input_spends_count: 0, } } } 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 42dfe83a0e..83b6d86074 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,6 @@ 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; @@ -34,11 +33,4 @@ 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/error.rs b/packages/rs-platform-wallet/src/error.rs index 86ee4a5e84..7b16b29e8c 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -305,8 +305,9 @@ pub enum PlatformWalletError { /// gone into `spent_by`, and the only recovery is to discard this lock /// and build a new one from currently-unspent inputs. `height` is the /// block height of the confirmed spender when the record carries block - /// info; `spender_chain_locked` is retained for message/ABI stability - /// and is always `true` here. + /// info. The variant carries no finality flag on purpose: finality IS + /// the variant — a constructor cannot produce a terminal error that + /// renders anything but chainlocked finality. /// /// A confirmed-but-not-yet-chainlocked spender raises /// [`Self::AssetLockInputContested`] instead: it equally stops the @@ -325,15 +326,14 @@ pub enum PlatformWalletError { #[error( "Asset lock {out_point} can never confirm: it spends {input}, which was \ already spent by confirmed transaction {spent_by} (block height \ - {height:?}, chainlocked: {spender_chain_locked}) — the lock is a \ - double spend and no peer will relay it" + {height:?}, chainlocked: true) — the lock is a double spend and no \ + peer will relay it" )] AssetLockInputConflict { out_point: dashcore::OutPoint, input: dashcore::OutPoint, spent_by: dashcore::Txid, height: Option, - spender_chain_locked: bool, }, /// As [`Self::AssetLockInputConflict`], but the confirmed spender has diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 7a5f0ca072..4a4d8a9d9c 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -67,7 +67,6 @@ impl PlatformWalletManager

{ wallet_info, identity_manager, unused_asset_locks, - asset_lock_input_spends, } = wallet_state; // Flatten the (account → outpoint → lock) map into the flat @@ -100,7 +99,6 @@ 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(), }; @@ -272,7 +270,6 @@ 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 515f809439..c9eafee286 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -363,7 +363,6 @@ 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 1577fec724..31c7abdf44 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -254,7 +254,6 @@ 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(), }; @@ -325,7 +324,6 @@ 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); @@ -428,7 +426,6 @@ 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); @@ -505,7 +502,6 @@ 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(), }; @@ -678,7 +674,6 @@ 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 b215c9b6b6..4390740640 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -426,7 +426,6 @@ 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 e3d2081bba..7afdd62c26 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,7 +595,6 @@ 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 f594ded9a9..4f1253db5b 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 @@ -265,10 +265,15 @@ fn first_confirmed_input_conflict( 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 + // One source of truth: live transaction history. The load path restores + // the relevant spender records into it (see the unresolved-record + // restore in the FFI persister), so the same records serve app-launch + // catch-up and the live session — and the same machinery keeps them + // honest: `apply_chain_lock` promotes them when a chainlock buries + // their block, and a reorg re-observation demotes them. An earlier + // revision carried a separate load-time snapshot map instead; it could + // neither promote nor demote, so its verdicts could not resolve. + history .iter() .filter(|record| record.txid != lock_txid && record.is_confirmed()) .find_map(|record| { @@ -285,41 +290,6 @@ 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 { @@ -444,7 +414,6 @@ impl AssetLockManager { input, spent_by, height, - spender_chain_locked, } } else { PlatformWalletError::AssetLockInputContested { @@ -1139,7 +1108,6 @@ 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); @@ -1278,34 +1246,6 @@ 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. @@ -1398,180 +1338,6 @@ mod tests { )) } - /// The incident this screen exists for: a restored wallet re-spends an - /// outpoint one of its own earlier, already-confirmed transactions - /// consumed long ago. Peers drop the double spend without a reply, so - /// the pre-existing behaviour — re-broadcast, then wait, unbounded for - /// the user-facing funding flows — could never terminate. The resume - /// 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::AssetLockInputContested { spent_by, .. } => { - assert_eq!(spent_by, spender_txid, "the conflict itself still fires"); - // The contested variant IS the assertion: a snapshot height - // must not claim chainlock finality — the boundary only - // proves finality for a block the live chain is known to - // contain — so no restored row may produce the terminal, - // discard-licensing conflict from the boundary fallback. - } - other => panic!("expected AssetLockInputContested, 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::AssetLockInputContested { spent_by, .. } => assert_eq!( - spent_by, live_txid, - "the live record, not the load-time snapshot, names the spender" - ), - other => panic!("expected AssetLockInputContested, 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` with no applied chainlock /// covering it, so the verdict is provisional: the resume still stops /// before broadcasting or waiting, but through the contested variant, @@ -1638,16 +1404,10 @@ mod tests { .await .expect_err("a double-spent asset lock must fail, not wait"); match error { - PlatformWalletError::AssetLockInputConflict { - spent_by, - spender_chain_locked, - .. - } => { + PlatformWalletError::AssetLockInputConflict { spent_by, .. } => { + // The terminal variant IS the finality assertion: it is + // only constructed for a chainlock-final spender. assert_eq!(spent_by, spender_txid); - assert!( - spender_chain_locked, - "a live record below the applied boundary is chainlock-final" - ); } other => panic!("expected the terminal AssetLockInputConflict, got {other:?}"), } @@ -1676,17 +1436,10 @@ mod tests { let rendered = error.to_string(); match error { PlatformWalletError::AssetLockInputConflict { - spent_by, - height, - spender_chain_locked, - .. + spent_by, height, .. } => { assert_eq!(spent_by, spender_txid); assert_eq!(height, Some(1_234)); - assert!( - spender_chain_locked, - "an InChainLockedBlock spender must report ChainLock finality" - ); } other => panic!("expected AssetLockInputConflict, got {other:?}"), } @@ -1756,8 +1509,9 @@ mod tests { !matches!( outcome, Err(PlatformWalletError::AssetLockInputConflict { .. }) + | Err(PlatformWalletError::AssetLockInputContested { .. }) ), - "a lock's own record must never condemn it, got {outcome:?}" + "a lock's own record must never condemn it under either variant, got {outcome:?}" ); } 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 c487276c70..c99018792c 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 @@ -3827,7 +3827,6 @@ 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 bf83a3898d..b6d29e67c4 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -4,8 +4,7 @@ use std::collections::BTreeMap; use std::ops::{Deref, DerefMut}; use std::sync::Arc; -use dashcore::prelude::CoreBlockHeight; -use dashcore::{OutPoint, Txid}; +use dashcore::OutPoint; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; #[cfg(feature = "shielded")] @@ -229,32 +228,6 @@ 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` @@ -284,20 +257,6 @@ 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 36a6aa4afe..b4a2f7d05b 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -40,7 +40,6 @@ 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(), } } @@ -54,7 +53,6 @@ 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 fcf56ffc35..917d26094d 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -2368,11 +2368,6 @@ 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/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index c3e3376482..93acf96457 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -1044,10 +1044,14 @@ public class PlatformWalletManager: ObservableObject { PlatformWalletManager.decodeOutPointForCatchUp($0.outPointHex) } guard !outpoints.isEmpty else { continue } - Task.detached(priority: .background) { - await withTaskGroup(of: Void.self) { group in + Task.detached(priority: .background) { [weak self] in + let conflict = await withTaskGroup( + of: PlatformWalletError?.self, + returning: PlatformWalletError?.self + ) { group in let maxConcurrent = 4 var nextIndex = 0 + var firstConflict: PlatformWalletError? // Seed the group with up to `maxConcurrent` tasks. // Each `group.addTask` closure captures // `assetLockManager` — that retain keeps the @@ -1062,8 +1066,12 @@ public class PlatformWalletManager: ObservableObject { } nextIndex += 1 } - // As each finishes, queue the next pending entry. - while await group.next() != nil { + // As each finishes, queue the next pending entry; + // keep the first double-spend verdict for the host. + while let outcome = await group.next() { + if firstConflict == nil, let verdict = outcome { + firstConflict = verdict + } if nextIndex < outpoints.count { let (txid, vout) = outpoints[nextIndex] group.addTask { @@ -1072,6 +1080,14 @@ public class PlatformWalletManager: ObservableObject { nextIndex += 1 } } + return firstConflict + } + // Publish the verdict where hosts already observe + // failures. `lastError` is the manager's one public + // error surface; a UI that offers discard (42) or + // explains the pending retry (43) reads it from here. + if let conflict { + await MainActor.run { self?.lastError = conflict } } } } @@ -1090,7 +1106,12 @@ public class PlatformWalletManager: ObservableObject { /// `@MainActor`-isolated by default and the detached task body /// runs off the main actor — the FFI call is synchronous and /// reads no `PlatformWalletManager` state. - nonisolated private static func runCatchUp(assetLockManager: ManagedAssetLockManager, txid: Data, vout: UInt32) { + /// Returns the typed double-spend verdict when the catch-up hits one + /// (terminal `assetLockInputConflict` / provisional + /// `assetLockInputContested`) — the one outcome a host must see so its + /// UI can offer discard-and-rebuild or explain the retry — and `nil` + /// for every expected failure. + nonisolated private static func runCatchUp(assetLockManager: ManagedAssetLockManager, txid: Data, vout: UInt32) -> PlatformWalletError? { // Build the txid tuple inline so the Task body captures only // Sendable values. var txidTuple: FFIByteTuple32 = @@ -1102,9 +1123,13 @@ public class PlatformWalletManager: ObservableObject { } } // Five-minute ceiling matches the `wait_for_proof` deadline - // the production resume path uses. - let result = asset_lock_manager_catch_up_blocking( - assetLockManager.handle, &txidTuple, vout, 300 + // the production resume path uses. Wrapping the raw struct in + // `PlatformWalletResult` frees the Rust-owned message when the + // wrapper deinits — the raw struct must never be dropped bare. + let result = PlatformWalletResult( + asset_lock_manager_catch_up_blocking( + assetLockManager.handle, &txidTuple, vout, 300 + ) ) // Timeouts and proof-wait failures (catch-up // `errorWalletOperation`) are expected during normal @@ -1115,13 +1140,18 @@ public class PlatformWalletManager: ObservableObject { // valid for the duration of this call. If it surfaces, log it // loudly via NSLog so an operator running without `tracing` // capture still sees the programmer error. - let code = PlatformWalletResultCode(ffi: result.code) - if code == .errorInvalidHandle { + switch result.code { + case .errorInvalidHandle: NSLog( "[catch-up] asset_lock_manager_catch_up_blocking returned errorInvalidHandle for outpoint %@:%u — handle invalid despite task-owned wrapper retain", txid.map { String(format: "%02x", $0) }.joined(), vout ) + return nil + case .errorAssetLockInputConflict, .errorAssetLockInputContested: + return PlatformWalletError(result: result) + default: + return nil } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 52d88881ae..3557d5b996 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1162,36 +1162,28 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { predicate: #Predicate { $0.outpoint == outpoint } ) if let txo = try? backgroundContext.fetch(txoDescriptor).first { - // `isSpent` only flips once the spending tx is in a block - // (see `spendIsInBlock`'s doc) — a mempool sighting - // alone links the spending relationship but keeps the - // row in the unspent set so a `restartWalletManager()` - // load can hand the TXO back to Rust for the post-restart - // catch-up classifier to recognise as ours. The next - // upsert of this same tx with a confirmed context flips - // `isSpent` then. - // - // 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) + // Flag and link move together — see + // `reconcileSpendObservation` for the finality rule. + let verdict = Self.reconcileSpendObservation( + currentSpenderTxid: txo.spendingTransaction?.txid, + currentIsSpent: txo.isSpent, + incoming: spendingTransaction, + incomingTxid: spendingTxid + ) let linkageChanged = - txo.isSpent != expectedIsSpent - || txo.spendingTransaction?.txid != spendingTxid - || txo.spendingInputIndex != inputIndex + txo.isSpent != verdict.isSpent + || (verdict.adoptLink && txo.spendingTransaction?.txid != spendingTxid) + || (verdict.adoptLink && txo.spendingInputIndex != inputIndex) if linkageChanged { - txo.isSpent = expectedIsSpent - if txo.spendingTransaction?.txid != spendingTxid { - txo.spendingTransaction = spendingTransaction + txo.isSpent = verdict.isSpent + if verdict.adoptLink { + if txo.spendingTransaction?.txid != spendingTxid { + txo.spendingTransaction = spendingTransaction + } + // Capture the canonical vin index so the detail + // view can render inputs in serialized order. + txo.spendingInputIndex = inputIndex } - // Capture the canonical vin index so the detail - // view can render inputs in serialized order. - txo.spendingInputIndex = inputIndex txo.lastUpdated = Date() } // A pending entry from an earlier write is now stale — @@ -1378,17 +1370,24 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // in `resolveInputOutpoint` — the only path that creates // pending rows captures the index from FFI's // `input_outpoints` slice, which mirrors `tx.input.iter()`. - record.spendingInputIndex = chosen.inputIndex - if let spending = resolvedSpending, - record.spendingTransaction?.txid != spending.txid { - record.spendingTransaction = spending - } if let spending = resolvedSpending { - // Monotonic — same rule as `resolveInputOutpoint`: a later - // mempool-context resolution must not downgrade a flag an - // in-block spend already set, or the conflict evidence the - // load path restores from `isSpent` rows evaporates. - record.isSpent = record.isSpent || Self.spendIsInBlock(spending) + // Flag and link move together — see + // `reconcileSpendObservation` for the finality rule. + let verdict = Self.reconcileSpendObservation( + currentSpenderTxid: record.spendingTransaction?.txid, + currentIsSpent: record.isSpent, + incoming: spending, + incomingTxid: spending.txid + ) + record.isSpent = verdict.isSpent + if verdict.adoptLink { + if record.spendingTransaction?.txid != spending.txid { + record.spendingTransaction = spending + } + record.spendingInputIndex = chosen.inputIndex + } + } else { + record.spendingInputIndex = chosen.inputIndex } record.lastUpdated = Date() for row in pendingRows { @@ -1397,6 +1396,41 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + /// The one rule every spend-linkage writer follows, so `isSpent` and + /// `spendingTransaction` move as a single finality-aware state instead + /// of a monotonic flag beside a last-writer-wins link (which could + /// diverge: a mempool competitor replacing a confirmed link under a + /// stuck-true flag, or a reorg demotion never lowering it). + /// + /// - Re-observation of the LINKED spender follows its context in both + /// directions: a demotion is chain truth — key-wallet emits + /// `InBlock` → `Mempool` context updates on a reorg — and keeping a + /// stale flag would wedge the coin out of the restore set. + /// - A DIFFERENT in-block spender takes the link and the flag: its + /// claim is chain-attested and mutually exclusive with the old one. + /// - A mempool competitor never displaces confirmed evidence: link and + /// flag both stay. + /// - When nothing confirmed is at stake, the newest observation wins + /// the link and the flag stays down. + private static func reconcileSpendObservation( + currentSpenderTxid: Data?, + currentIsSpent: Bool, + incoming: PersistentTransaction, + incomingTxid: Data + ) -> (adoptLink: Bool, isSpent: Bool) { + let incomingInBlock = spendIsInBlock(incoming) + if currentSpenderTxid == incomingTxid { + return (adoptLink: true, isSpent: incomingInBlock) + } + if incomingInBlock { + return (adoptLink: true, isSpent: true) + } + if currentIsSpent { + return (adoptLink: false, isSpent: true) + } + return (adoptLink: true, isSpent: false) + } + private func markUtxoSpent(_ entry: SpentOutPointFFI) { let outpoint = PersistentTxo.makeOutpoint( txid: hashData(entry.outpoint.txid), @@ -1427,21 +1461,26 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { predicate: #Predicate { $0.txid == spendingTxid } ) spendingTx = try? backgroundContext.fetch(txDescriptor).first - if let spending = spendingTx { - txo.spendingTransaction = spending - } } } - // Gate the `isSpent` flip on the spending tx being in a - // block — same rule as `resolveInputOutpoint`. When the - // spending tx isn't resolved this flush, leave `isSpent` - // alone instead of writing `false`: the next upsert round - // carrying the spending tx will run `resolveInputOutpoint` - // and set it then. Writing `false` here would flap a - // previously-true `isSpent` on every reordered emit. + // When the spending tx isn't resolved this flush, leave the row + // alone instead of writing `false`: the next upsert round carrying + // the spending tx will run `resolveInputOutpoint` and settle it + // then. Writing `false` here would flap a previously-true + // `isSpent` on every reordered emit. if let spending = spendingTx { - // Monotonic — same rule as `resolveInputOutpoint`. - txo.isSpent = txo.isSpent || Self.spendIsInBlock(spending) + // Flag and link move together — see + // `reconcileSpendObservation` for the finality rule. + let verdict = Self.reconcileSpendObservation( + currentSpenderTxid: txo.spendingTransaction?.txid, + currentIsSpent: txo.isSpent, + incoming: spending, + incomingTxid: spendingTxid + ) + txo.isSpent = verdict.isSpent + if verdict.adoptLink, txo.spendingTransaction?.txid != spendingTxid { + txo.spendingTransaction = spending + } } txo.lastUpdated = Date() // The spend signal landed both via the legacy @@ -5099,19 +5138,6 @@ 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 @@ -5488,121 +5514,6 @@ 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 @@ -5662,22 +5573,30 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } /// Build the per-wallet `UnresolvedAssetLockTxRecordFFI` array - /// for the load callback. One entry per `PersistentAssetLock` row + /// 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 + /// matching `PersistentTransaction` row, plus one entry for each + /// settled spender of those locks' inputs. 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 + /// transaction into the matching account's in-memory + /// `transactions()` map. That serves two consumers with one + /// mechanism: the next chain-lock event can promote the funding + /// records via `apply_chain_lock`, and the double-spend screen in + /// `resume_asset_lock` — which reads live history, empty at load + /// apart from this array — can see a confirmed sibling that + /// already took a lock's input. Restoring the spenders as ordinary + /// records rather than a snapshot keeps the evidence live: + /// promotion and reorg demotion both reach it, so a provisional + /// conflict verdict can actually resolve. 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 + /// reconstruct a transaction without its consensus bytes, so /// projecting an empty row would just bloat the FFI surface. private func buildUnresolvedAssetLockTxRecordBuffer( walletId: Data, @@ -5697,50 +5616,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return (nil, 0) } - // Pre-query the matching `PersistentTransaction` rows. - // `PersistentAssetLock.outPointHex` carries the txid in - // display order; `PersistentTransaction.txid` is wire order - // — the same flip `decodeOutPointHex` already performs. - let buf = UnsafeMutablePointer.allocate( - capacity: locks.count - ) - var written = 0 - for lock in locks { - guard let outpoint = decodeOutPointHex(lock.outPointHex) else { - continue - } - let txid = outpoint.prefix(32) - let txidData = Data(txid) - let txDescriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == txidData } - ) - guard let txRow = try? backgroundContext.fetch(txDescriptor).first else { - // No matching tx — Rust can't reconstruct the - // funding body without its consensus bytes. Skip. - continue - } + // Project one `PersistentTransaction` row into an FFI entry, + // staging its consensus bytes on the allocation (freed by + // `LoadAllocation.release()` after Rust returns). A stub row + // whose real upsert never arrived has no bytes and is skipped. + func recordEntry( + for txRow: PersistentTransaction, accountIndex: UInt32 + ) -> UnresolvedAssetLockTxRecordFFI? { let txBytes = txRow.transactionData - guard !txBytes.isEmpty else { - // A stub row whose real upsert never arrived; - // skip rather than emit an undecodable buffer. - continue - } - - // Allocate the consensus-bytes buffer. Lifetime is - // owned by `allocation.scalarBuffers`, freed by - // `LoadAllocation.release()` after Rust returns. + guard !txBytes.isEmpty else { return nil } let txBuf = UnsafeMutablePointer.allocate(capacity: txBytes.count) txBytes.copyBytes(to: txBuf, count: txBytes.count) allocation.scalarBuffers.append((txBuf, txBytes.count)) - var entry = UnresolvedAssetLockTxRecordFFI() - // Use the row's persisted `accountIndexRaw` — the Rust - // side looks up `standard_bip44_accounts.get(&account_index)` - // and silently drops the restore if the account doesn't - // exist, so passing the actual funding account is - // load-bearing for any wallet that funded an asset lock - // from a non-zero BIP44 account index. - entry.account_index = UInt32(bitPattern: lock.accountIndexRaw) + entry.account_index = accountIndex entry.tx_bytes = txBuf entry.tx_bytes_len = UInt(txBytes.count) entry.context_raw = txRow.context @@ -5752,15 +5641,72 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } entry.block_timestamp = UInt64(txRow.blockTimestamp) entry.first_seen = txRow.firstSeen - buf[written] = entry - written += 1 + return entry } - if written == 0 { - buf.deallocate() - return (nil, 0) + + var entries: [UnresolvedAssetLockTxRecordFFI] = [] + var emittedTxids = Set() + + for lock in locks { + guard let outpoint = decodeOutPointHex(lock.outPointHex) else { + continue + } + // `PersistentAssetLock.outPointHex` carries the txid in + // display order; `PersistentTransaction.txid` is wire order + // — the flip `decodeOutPointHex` already performs. + let txidData = Data(outpoint.prefix(32)) + guard !emittedTxids.contains(txidData) else { continue } + let txDescriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == txidData } + ) + // Use the row's persisted `accountIndexRaw` — the Rust + // side routes by this index and silently drops the restore + // if the account doesn't exist, so passing the actual + // funding account is load-bearing for any wallet that + // funded an asset lock from a non-zero account index. + guard let txRow = try? backgroundContext.fetch(txDescriptor).first, + let entry = recordEntry( + for: txRow, + accountIndex: UInt32(bitPattern: lock.accountIndexRaw) + ) + else { continue } + entries.append(entry) + emittedTxids.insert(txidData) + } + + // The settled spenders of the locks' inputs ride the same array. + // Scope: settled only (`context >= 2`) — the same minimum-surface + // rule as `statusRaw < 2` above; an unsettled sighting can still + // be replaced and the screen deliberately ignores it, so shipping + // it would widen the restore for nothing. Which contexts count as + // final stays Rust's call; this only bounds the payload. + for key in unresolvedAssetLockInputs(walletId: walletId) { + var txoDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == key } + ) + txoDescriptor.fetchLimit = 1 + txoDescriptor.relationshipKeyPathsForPrefetching = [\.spendingTransaction] + guard let txo = try? backgroundContext.fetch(txoDescriptor).first, + Self.resolvedWalletId(of: txo) == walletId, + let spender = txo.spendingTransaction, + spender.context >= 2, + !emittedTxids.contains(spender.txid) + else { continue } + let accountIndex = txo.account?.accountIndex ?? 0 + guard let entry = recordEntry(for: spender, accountIndex: accountIndex) else { + continue + } + entries.append(entry) + emittedTxids.insert(spender.txid) } - allocation.unresolvedAssetLockTxRecordArrays.append((buf, written)) - return (buf, written) + + guard !entries.isEmpty else { return (nil, 0) } + let buf = UnsafeMutablePointer.allocate( + capacity: entries.count + ) + buf.initialize(from: entries, count: entries.count) + allocation.unresolvedAssetLockTxRecordArrays.append((buf, entries.count)) + return (buf, entries.count) } /// Stage this wallet's persisted provider special transactions @@ -6607,10 +6553,6 @@ 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 @@ -6687,10 +6629,6 @@ 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 index 00ae347111..8a8a9b92a5 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockInputSpendRestoreTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockInputSpendRestoreTests.swift @@ -3,14 +3,17 @@ 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. +/// Coverage for the spender half of the asset-lock record restore: the +/// settled spender of an unresolved lock's input rides +/// `unresolved_asset_lock_tx_records`, the same array that restores the +/// locks' own funding records, and Rust re-inserts it into live +/// transaction history where the conflict screen scans it. /// -/// 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. +/// At app launch that history is otherwise 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. Restoring +/// the spender as an ordinary record (not a snapshot) keeps the evidence +/// live: chainlock promotion and reorg demotion both reach it. @MainActor final class AssetLockInputSpendRestoreTests: XCTestCase { @@ -63,7 +66,11 @@ final class AssetLockInputSpendRestoreTests: XCTestCase { /// 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 { + private func seed( + in container: ModelContainer, + legacyTxoWalletId: Bool, + spenderContext: UInt32 = 2 + ) throws { let context = ModelContext(container) let wallet = PersistentWallet(walletId: walletId, network: .testnet) context.insert(wallet) @@ -93,8 +100,8 @@ final class AssetLockInputSpendRestoreTests: XCTestCase { let spender = PersistentTransaction( txid: spenderTxid, transactionData: Data(repeating: 0x05, count: 10), - context: 2, - blockHeight: 101, + context: spenderContext, + blockHeight: spenderContext >= 2 ? 101 : 0, netAmount: -100_000 ) context.insert(spender) @@ -127,24 +134,27 @@ final class AssetLockInputSpendRestoreTests: XCTestCase { 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 { + /// Drive the real load path and report how many unresolved-lock tx + /// records the wallet's restore entry carries. In these fixtures the + /// lock's own txid has no `PersistentTransaction` row, so every entry + /// counted here is a restored spender record. + private func restoredRecordCount(_ 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) + return Int(entries[0].unresolved_asset_lock_tx_records_count) } /// The ordinary case: the TXO carries its wallet id, and the confirmed - /// spender is reported so the conflict screen can act at startup. + /// spender's record is restored so the conflict screen's history scan + /// can act at startup. func testConfirmedSpenderOfALockInputIsRestored() throws { let (handler, container) = try makeHandler() try seed(in: container, legacyTxoWalletId: false) - XCTAssertEqual(restoredInputSpendCount(handler), 1) + XCTAssertEqual(restoredRecordCount(handler), 1) } /// The same coin on a row migrated from the older schema, where @@ -157,18 +167,17 @@ final class AssetLockInputSpendRestoreTests: XCTestCase { try seed(in: container, legacyTxoWalletId: true) XCTAssertEqual( - restoredInputSpendCount(handler), + restoredRecordCount(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 { + /// The record payload is the cross-language contract, and a count + /// assertion alone would let a wrong-source copy — bytes from the wrong + /// transaction, a context read off the funding tx — ship green. Read + /// the emitted entry back and pin its fields to the spender's values. + func testRestoredSpenderRecordCarriesTheExactPayload() throws { let (handler, container) = try makeHandler() try seed(in: container, legacyTxoWalletId: false) @@ -180,23 +189,27 @@ final class AssetLockInputSpendRestoreTests: XCTestCase { 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 { + XCTAssertEqual(Int(entry.unresolved_asset_lock_tx_records_count), 1) + guard let rows = entry.unresolved_asset_lock_tx_records 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" + Int(row.tx_bytes_len), 10, + "the spender's consensus bytes, not the funding tx's (which the fixture sizes differently)" ) - XCTAssertEqual(row.spender_height, 101, "the spender's persisted block height") - XCTAssertEqual(row.spender_context, 2, "the persisted context, verbatim") + XCTAssertEqual(row.context_raw, 2, "the spender's persisted context, verbatim") + XCTAssertEqual(row.block_height, 101, "the spender's persisted block height") + } + + /// A mempool-context spender is deliberately NOT restored: it can still + /// be replaced, the screen ignores it, and shipping it would widen the + /// restore surface for nothing — the same minimum-surface rule as the + /// `statusRaw < 2` lock filter. + func testAMempoolSpenderIsNotRestored() throws { + let (handler, container) = try makeHandler() + try seed(in: container, legacyTxoWalletId: false, spenderContext: 0) + + XCTAssertEqual(restoredRecordCount(handler), 0) } } From 15a1cb6aeabe72560306408d0948c8439a046ad0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 19 Aug 2026 18:12:17 +0700 Subject: [PATCH 08/11] fix(swift-sdk): publish the catch-up verdict the moment its task returns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit withTaskGroup only completes after every scheduled catch-up drains, and a sibling can legitimately sit in its 300-second proof wait — the host must not wait on that to learn a lock is dead. The first double-spend verdict now publishes to lastError inside the drain loop; the remaining tasks keep draining. Also from review: the u8 context_kind decoder's block arms now compare against the TX_CONTEXT_RAW constants under guards instead of literals kept in lockstep by comment. Co-Authored-By: Claude Fable 5 --- .../rs-platform-wallet-ffi/src/persistence.rs | 27 ++++++++-------- .../PlatformWalletManager.swift | 31 +++++++++---------- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index cbcc1e3161..ee0715275d 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -2958,9 +2958,6 @@ 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 => { @@ -2970,16 +2967,20 @@ impl PlatformWalletPersistence for FFIPersister { // proof from the live event stream. return Ok(None); } - 2 => TransactionContext::InBlock(BlockInfo::new( - block_height, - dashcore::BlockHash::from_byte_array(block_hash), - block_timestamp, - )), - 3 => TransactionContext::InChainLockedBlock(BlockInfo::new( - block_height, - dashcore::BlockHash::from_byte_array(block_hash), - block_timestamp, - )), + k if u32::from(k) == TX_CONTEXT_RAW_IN_BLOCK => { + TransactionContext::InBlock(BlockInfo::new( + block_height, + dashcore::BlockHash::from_byte_array(block_hash), + block_timestamp, + )) + } + k if u32::from(k) == TX_CONTEXT_RAW_IN_CHAIN_LOCKED_BLOCK => { + TransactionContext::InChainLockedBlock(BlockInfo::new( + block_height, + dashcore::BlockHash::from_byte_array(block_hash), + block_timestamp, + )) + } unknown => { tracing::debug!( txid = %txid, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 93acf96457..773a5c4b63 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -1045,13 +1045,10 @@ public class PlatformWalletManager: ObservableObject { } guard !outpoints.isEmpty else { continue } Task.detached(priority: .background) { [weak self] in - let conflict = await withTaskGroup( - of: PlatformWalletError?.self, - returning: PlatformWalletError?.self - ) { group in + await withTaskGroup(of: PlatformWalletError?.self) { group in let maxConcurrent = 4 var nextIndex = 0 - var firstConflict: PlatformWalletError? + var published = false // Seed the group with up to `maxConcurrent` tasks. // Each `group.addTask` closure captures // `assetLockManager` — that retain keeps the @@ -1066,11 +1063,19 @@ public class PlatformWalletManager: ObservableObject { } nextIndex += 1 } - // As each finishes, queue the next pending entry; - // keep the first double-spend verdict for the host. + // As each finishes, queue the next pending entry — + // and publish the FIRST double-spend verdict the + // moment its own task returns. A sibling catch-up + // can legitimately sit in its 300s proof wait, and + // the host must not wait on that drain to learn a + // lock is dead. `lastError` is the manager's one + // public error surface; a UI that offers discard + // (42) or explains the pending retry (43) reads it + // from here. while let outcome = await group.next() { - if firstConflict == nil, let verdict = outcome { - firstConflict = verdict + if !published, let verdict = outcome { + published = true + await MainActor.run { self?.lastError = verdict } } if nextIndex < outpoints.count { let (txid, vout) = outpoints[nextIndex] @@ -1080,14 +1085,6 @@ public class PlatformWalletManager: ObservableObject { nextIndex += 1 } } - return firstConflict - } - // Publish the verdict where hosts already observe - // failures. `lastError` is the manager's one public - // error surface; a UI that offers discard (42) or - // explains the pending retry (43) reads it from here. - if let conflict { - await MainActor.run { self?.lastError = conflict } } } } From a896e1fe97682a27a7b5b0ca221e7f513ebaadf5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 19 Aug 2026 18:35:33 +0700 Subject: [PATCH 09/11] fix(swift-sdk): hand the catch-up task a MainActor publish closure, not self The strict-concurrency lane rejects sending the MainActor-isolated manager into the detached task; a @MainActor @Sendable closure is the only piece of self the task needs, and capturing it keeps the task's captures Sendable. Verified with -strict-concurrency=complete locally. Co-Authored-By: Claude Fable 5 --- .../PlatformWallet/PlatformWalletManager.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 773a5c4b63..d3437f4805 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -1044,7 +1044,15 @@ public class PlatformWalletManager: ObservableObject { PlatformWalletManager.decodeOutPointForCatchUp($0.outPointHex) } guard !outpoints.isEmpty else { continue } - Task.detached(priority: .background) { [weak self] in + // A `@MainActor` closure is the only piece of `self` the + // detached task needs: it hops back to the main actor to + // publish, and capturing it (rather than `self`) keeps the + // task's captures Sendable under strict concurrency. + let publishConflict: @MainActor @Sendable (PlatformWalletError) -> Void = { + [weak self] verdict in + self?.lastError = verdict + } + Task.detached(priority: .background) { await withTaskGroup(of: PlatformWalletError?.self) { group in let maxConcurrent = 4 var nextIndex = 0 @@ -1075,7 +1083,7 @@ public class PlatformWalletManager: ObservableObject { while let outcome = await group.next() { if !published, let verdict = outcome { published = true - await MainActor.run { self?.lastError = verdict } + await publishConflict(verdict) } if nextIndex < outpoints.count { let (txid, vout) = outpoints[nextIndex] From db2d890bba776e8c69947d862fdead663595a81c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 19 Aug 2026 21:00:57 +0700 Subject: [PATCH 10/11] fix(platform-wallet): survive the promotion-eviction and reconcile every deferred spend observation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two persistence-lifecycle blockers from review. First: apply_chain_lock EVICTS a record from history the moment a chainlock buries it — exactly the moment a provisional conflict becomes terminal — so a same-session retry after the chainlock found neither verdict and fell back into the proof wait. The screen now keeps session-scoped memory of every in-block spend it observes (ObservedInputConflict on PlatformWalletInfo): a remembered spender that has LEFT history under a covering boundary upgrades to the terminal verdict (promotion-eviction is the only path that removes a record — a reorg demotes in place), a spender re-observed unconfirmed retracts the memory, and an eviction without a covering boundary stays provisional rather than inventing finality. Never persisted, never restored; a poisoned mutex degrades to no memory. Second: the Swift deferred-input drain picked only the newest pending row before deleting them all, so a mempool competitor recorded after a confirmed spender erased the confirmed evidence with the rows. The drain now reconciles EVERY pending observation through the finality-aware rule, which makes application order irrelevant by construction — confirmed evidence wins and is never displaced by a mempool observation. Also from review: the restore ABI contract now documents both record roles the array carries (funding records and settled spenders), the account-index requirement for spender rows, and why hosts ship settled spends only. Co-Authored-By: Claude Fable 5 --- .../src/wallet_restore_types.rs | 46 ++-- .../rs-platform-wallet/src/manager/load.rs | 1 + .../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 | 198 +++++++++++++++++- .../identity/network/contact_requests.rs | 1 + .../src/wallet/platform_wallet.rs | 30 ++- .../src/wallet/platform_wallet_traits.rs | 2 + .../PlatformWalletPersistenceHandler.swift | 69 +++--- 11 files changed, 304 insertions(+), 51 deletions(-) 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 8eead68201..59f271c870 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -456,29 +456,43 @@ pub struct UtxoRestoreEntryFFI { /// One persisted transaction record carried back at load time so the /// in-memory `transactions()` map can be selectively repopulated for -/// the small subset of records that matter for chain-lock cascade — -/// today, the funding transactions of tracked asset locks still at -/// `Built` / `Broadcast` (`statusRaw < 2`). +/// the small subset of records that matter at launch. TWO record roles +/// ride this array, and a host must supply both: +/// +/// * **Funding transactions** of tracked asset locks still at `Built` / +/// `Broadcast` (`statusRaw < 2`): their record must live in the +/// in-memory map at the moment the next chain-lock event fires, or +/// `WalletManager::apply_chain_lock` finds nothing to promote and +/// the bridge has no `chain_lock_promotions` to emit. +/// * **Settled spenders of those locks' inputs** (context `2` / `3`): +/// the double-spend screen in `resume_asset_lock` scans live +/// history — empty at load apart from this array — for a confirmed +/// transaction that already took a lock's input. A host that omits +/// these leaves startup conflict detection blind and the resume in +/// its full proof wait. `account_index` for a spender row is the +/// account of the TXO it spent (the lock's funding account when the +/// host cannot resolve one). +/// +/// The Rust decoder classifies each record from its own payload (an +/// asset-lock special-tx payload marks a funding record), so the two +/// roles need no tag and a spender cannot masquerade as a funding tx. /// /// Why selectively rather than wholesale: the wallet's own load path /// only bulk-restores UTXOs, not tx records, by design — most tx /// history is consumed reactively through SwiftData `@Query`s, not -/// from the in-memory map. The exception is asset locks waiting for -/// IS-lock / chain-lock proofs: their funding tx must live in the -/// in-memory map at the moment the next chain-lock event fires, or -/// `WalletManager::apply_chain_lock` finds nothing to promote and -/// the bridge has no `chain_lock_promotions` to emit. Restoring -/// these specific records closes that gap without breaking the rest -/// of the lazy-load model. +/// from the in-memory map. Restoring these specific records closes +/// the two gaps above without breaking the rest of the lazy-load +/// model. /// /// `context_raw` matches `TransactionContext` discriminants: /// 0 = Mempool, 1 = InstantSend, 2 = InBlock, 3 = InChainLockedBlock. /// Only `2` and `3` are reconstructible from these scalar fields; /// `0` / `1` need either no block info (Mempool) or an IS-lock blob /// we don't carry (InstantSend), so the Rust load path treats them -/// as `Mempool` — defensive code for an edge that shouldn't occur in -/// practice (an asset lock at `Built` / `Broadcast` has by definition -/// not yet observed IS-lock or block confirmation). +/// as `Mempool` — defensive for funding records (a `Built` / +/// `Broadcast` lock has by definition seen neither), and the reason a +/// host should only ship SETTLED spender records: an unsettled spend +/// is not evidence, and would be restored as a mempool sighting. #[repr(C)] pub struct UnresolvedAssetLockTxRecordFFI { /// Family-independent source index the funding tx spent UTXOs @@ -609,7 +623,11 @@ pub struct WalletRestoreEntryFFI { /// when the wallet has no persisted tracked locks. pub tracked_asset_locks: *const AssetLockEntryFFI, pub tracked_asset_locks_count: usize, - /// Funding tx records for tracked asset locks at `statusRaw < 2` + /// Tx records restored into the in-memory map at load: the funding + /// records of unresolved asset locks AND the settled spenders of + /// their inputs — see [`UnresolvedAssetLockTxRecordFFI`] for the + /// two-role contract. Historically documented as funding-only: + /// funding tx records for tracked asset locks at `statusRaw < 2` /// (Built / Broadcast). The Rust load path re-inserts each entry /// into the matching `standard_bip44_accounts[account_index] /// .transactions_mut()` bucket so the next incoming chain-lock diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 4a4d8a9d9c..c359a0ff89 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -95,6 +95,7 @@ impl PlatformWalletManager

{ core_balance.locked(), ); let platform_info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), core_wallet: wallet_info, generation: Arc::clone(&generation), identity_manager: IdentityManager::from(identity_manager), diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index c9eafee286..6eb5300b1f 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -359,6 +359,7 @@ impl PlatformWalletManager

{ .unwrap_or(wallet.wallet_id); let platform_info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), core_wallet: wallet_info, generation: Arc::clone(&generation), identity_manager: crate::wallet::identity::IdentityManager::new(), diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 31c7abdf44..503acffe3f 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -250,6 +250,7 @@ pub(crate) async fn funded_wallet_manager_with_outputs( let generation = Arc::new(WalletGeneration::new()); let info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), core_wallet: ctx.managed_wallet, generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), @@ -320,6 +321,7 @@ pub(crate) async fn funded_wallet_manager_dual_standard( }; let generation = Arc::new(WalletGeneration::new()); let info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), core_wallet: ctx.managed_wallet, generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), @@ -422,6 +424,7 @@ pub(crate) async fn funded_wallet_manager_with_contact( }; let generation = Arc::new(WalletGeneration::new()); let info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), core_wallet: ctx.managed_wallet, generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), @@ -498,6 +501,7 @@ pub(crate) async fn funded_coinjoin_wallet_manager() -> ( let generation = Arc::new(WalletGeneration::new()); let info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), core_wallet: ctx.managed_wallet, generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), @@ -670,6 +674,7 @@ pub(crate) async fn mnemonic_wallet_manager( wallet: wallet.clone(), }; let info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), core_wallet: managed_wallet, generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 4390740640..0b213330ec 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(), + observed_input_conflicts: 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..64b6414c07 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 @@ -591,6 +591,7 @@ mod tests { .insert(7, account); let info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), core_wallet: ctx.managed_wallet, generation: std::sync::Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), 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 4f1253db5b..fd128abeee 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 @@ -265,7 +265,7 @@ fn first_confirmed_input_conflict( let history = info.core_wallet.transaction_history(); - // One source of truth: live transaction history. The load path restores + // The source of truth: live transaction history. The load path restores // the relevant spender records into it (see the unresolved-record // restore in the FFI persister), so the same records serve app-launch // catch-up and the live session — and the same machinery keeps them @@ -273,7 +273,7 @@ fn first_confirmed_input_conflict( // their block, and a reorg re-observation demotes them. An earlier // revision carried a separate load-time snapshot map instead; it could // neither promote nor demote, so its verdicts could not resolve. - history + if let Some(hit) = history .iter() .filter(|record| record.txid != lock_txid && record.is_confirmed()) .find_map(|record| { @@ -290,6 +290,65 @@ fn first_confirmed_input_conflict( .is_some_and(|(boundary, spender_height)| spender_height <= boundary); Some((conflicting_input, record.txid, height, spender_chain_locked)) }) + { + // Remember the observation before returning it. Promotion is also + // EVICTION under the default `keep-finalized-transactions = OFF` + // build: the moment a chainlock buries the spender's block, + // `apply_chain_lock` removes the record this scan just read — which + // is exactly the moment the provisional verdict becomes terminal, + // and a retry would otherwise find nothing at all. The session + // memory below converts that disappearance into the terminal + // verdict. A poisoned mutex degrades to no memory, never a failure. + let (input, spender, height, _) = hit; + if let (Some(h), Ok(mut cache)) = (height, info.observed_input_conflicts.lock()) { + cache.insert( + input, + crate::wallet::platform_wallet::ObservedInputConflict { spender, height: h }, + ); + } + return Some(hit); + } + + // No live record — consult the session memory. Three cases per + // remembered input: + // * the remembered spender is back in history UNCONFIRMED: its block + // was reorged away and the record demoted in place — the memory is + // stale, retract it; + // * the spender has LEFT history: promotion-eviction is the only path + // that removes a record (a reorg demotes, nothing deletes), so the + // remembered in-block spend was buried by a chainlock — terminal, + // provided the applied boundary actually covers the remembered + // height; + // * eviction without a covering boundary should be impossible — stay + // on the provisional verdict rather than inventing finality. + let Ok(mut cache) = info.observed_input_conflicts.lock() else { + return None; + }; + for input in &lock_inputs { + let Some(observed) = cache.get(input).copied() else { + continue; + }; + if let Some(record) = history + .iter() + .find(|record| record.txid == observed.spender) + { + if !record.is_confirmed() { + cache.remove(input); + } + // A confirmed record for this spender would have been the + // scan's hit above; nothing to add here either way. + continue; + } + let spender_chain_locked = + chain_locked_height.is_some_and(|boundary| observed.height <= boundary); + return Some(( + *input, + observed.spender, + Some(observed.height), + spender_chain_locked, + )); + } + None } impl AssetLockManager { @@ -1104,6 +1163,7 @@ mod tests { } let restored_wallet = Wallet::new_external_signable(Network::Testnet, wallet_id, accounts); let mut restored_info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), core_wallet: ManagedWalletInfo::from_wallet(&restored_wallet, 0), generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), @@ -1281,6 +1341,23 @@ mod tests { .insert(record.txid, record); } + /// Remove `txid`'s record from the wallet's BIP44 account, the way + /// `apply_chain_lock`'s promotion-eviction does under the default + /// `keep-finalized-transactions = OFF` build. + async fn evict_record(&self, txid: Txid) { + let mut wm = self.manager.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&self.wallet_id) + .expect("wallet must remain registered"); + info.core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("funded fixture has BIP44 account 0") + .transactions_mut() + .remove(&txid); + } + fn broadcast_count(&self) -> usize { self.broadcaster .transactions @@ -1380,6 +1457,123 @@ mod tests { ); } + /// Promotion is eviction: once a chainlock buries the spender's block, + /// `apply_chain_lock` removes its record from history — at exactly the + /// moment the verdict becomes terminal. The screen's session memory + /// must convert that disappearance into the terminal conflict instead + /// of letting the resume fall back into the proof wait. + #[tokio::test] + async fn a_chainlock_evicted_spender_upgrades_the_remembered_verdict_to_terminal() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + let spender = transaction_spending(fixture.funded_input()); + let spender_txid = spender.txid(); + fixture + .file_record(record_for(spender, confirmed_at(1_234))) + .await; + + // First resume: provisional, and the screen remembers the sighting. + let first = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("a currently double-spent asset lock must fail, not wait"); + assert!( + matches!(first, PlatformWalletError::AssetLockInputContested { .. }), + "before the chainlock the verdict is provisional, got {first:?}" + ); + + // The chainlock lands: boundary moves past the spender's height and + // the promotion evicts its record. + fixture.evict_record(spender_txid).await; + fixture.set_chain_lock_boundary(1_300).await; + + let second = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("a chainlock-settled double spend must fail, not wait"); + match second { + PlatformWalletError::AssetLockInputConflict { spent_by, .. } => { + assert_eq!(spent_by, spender_txid, "the remembered spender, upgraded"); + } + other => panic!("expected the terminal AssetLockInputConflict, got {other:?}"), + } + } + + /// The memory retracts: a reorg demotes the spender's record in place, + /// and re-observing it unconfirmed must clear the remembered verdict — + /// the lock is viable again and the resume takes its normal course. + #[tokio::test] + async fn a_reorg_demoted_spender_retracts_the_remembered_verdict() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + let spender = transaction_spending(fixture.funded_input()); + fixture + .file_record(record_for(spender.clone(), confirmed_at(1_234))) + .await; + let first = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("a currently double-spent asset lock must fail, not wait"); + assert!(matches!( + first, + PlatformWalletError::AssetLockInputContested { .. } + )); + + // The reorg drops the block; the record survives, demoted. + fixture + .file_record(record_for(spender, TransactionContext::Mempool)) + .await; + + let second = 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!( + second, + PlatformWalletError::AssetLockInputConflict { .. } + | PlatformWalletError::AssetLockInputContested { .. } + ), + "a demoted spender must retract the remembered verdict, got {second:?}" + ); + } + + /// Eviction without a covering boundary should be impossible; if it + /// ever happens, the screen stays on the provisional verdict rather + /// than inventing chainlock finality it cannot attest. + #[tokio::test] + async fn an_evicted_spender_without_a_covering_boundary_stays_provisional() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + let spender = transaction_spending(fixture.funded_input()); + let spender_txid = spender.txid(); + fixture + .file_record(record_for(spender, confirmed_at(1_234))) + .await; + let _ = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await; + fixture.evict_record(spender_txid).await; + + let second = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("the remembered conflict still stops the wait"); + assert!( + matches!(second, PlatformWalletError::AssetLockInputContested { .. }), + "no boundary, no terminal claim, got {second:?}" + ); + } + /// A live in-block record sitting at or below the applied chainlock /// boundary IS final — the record's presence in live history attests /// the block survived to be buried — so the boundary promotion holds 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 c99018792c..68e7e2328c 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 @@ -3827,6 +3827,7 @@ mod sweep_tests { generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + observed_input_conflicts: 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..4287b72657 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::{OutPoint, Txid}; +use dpp::prelude::CoreBlockHeight; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; #[cfg(feature = "shielded")] @@ -228,6 +229,28 @@ fn plan_shield_inputs( }) } +/// One in-block spend of a tracked asset lock's input, as the double-spend +/// screen last saw it in live transaction history. +/// +/// Session-scoped memory, never persisted and never restored: it exists +/// because `apply_chain_lock` EVICTS a record from history the moment a +/// chainlock buries it (default `keep-finalized-transactions = OFF`), which +/// is precisely the moment a provisional conflict becomes terminal — a +/// retry after the chainlock would otherwise find nothing and fall back +/// into the proof wait. The screen writes entries when it observes an +/// in-block spender, retracts them when live history re-observes that +/// spender unconfirmed (a reorg demotes the record in place), and converts +/// an entry whose record has LEFT history under a covering boundary into +/// the terminal verdict: promotion-eviction is the only path that removes +/// a record, so the disappearance itself attests the chainlock. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ObservedInputConflict { + /// The confirmed transaction the screen saw spending the input. + pub spender: Txid, + /// The block height it was seen at. + pub height: CoreBlockHeight, +} + /// Consolidated mutable state for a platform wallet. /// /// Lives inside `WalletManager.wallet_infos`. The `Wallet` @@ -257,6 +280,11 @@ pub struct PlatformWalletInfo { pub(crate) generation: Arc, pub identity_manager: IdentityManager, pub tracked_asset_locks: BTreeMap, + /// Session-scoped double-spend evidence for tracked asset locks — see + /// [`ObservedInputConflict`]. Interior mutability because the screen + /// runs under the manager's read lock; a poisoned mutex degrades to + /// "no memory" rather than failing a resume. + pub observed_input_conflicts: std::sync::Mutex>, /// 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..49ed828d22 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(), + observed_input_conflicts: 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(), + observed_input_conflicts: 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 3557d5b996..f8e82e150f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1340,37 +1340,29 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) if let pendingRows = try? backgroundContext.fetch(pendingDescriptor), !pendingRows.isEmpty { - // Pick the freshest pending entry — under normal sync - // there's only one, but a chain reorg or double-spend - // observation could leave multiple. Newest wins so the - // visible spendingTransaction matches the most recent - // observation; the rest are dropped. - let chosen = pendingRows.max(by: { $0.createdAt < $1.createdAt }) ?? pendingRows[0] - - // Resolve the spending tx (prefer the relationship; fall - // back to a txid lookup if the row wasn't faulted in). - // We need its `context` to gate `isSpent` — same rule as - // `resolveInputOutpoint`: mempool sighting links the - // spendingTransaction but doesn't flip `isSpent` until - // the spending tx is in a block. - let resolvedSpending: PersistentTransaction? - if let spending = chosen.spendingTransaction { - resolvedSpending = spending - } else { - let spendingTxid = chosen.spendingTxid - let txDescriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == spendingTxid } - ) - resolvedSpending = try? backgroundContext.fetch(txDescriptor).first - } - - // Carry the vin index forward so the spending tx's - // detail view can render its inputs in the canonical - // serialized order. Same source as the linkage write - // in `resolveInputOutpoint` — the only path that creates - // pending rows captures the index from FFI's - // `input_outpoints` slice, which mirrors `tx.input.iter()`. - if let spending = resolvedSpending { + // Reconcile EVERY deferred observation, not just the newest — + // the rows are about to be deleted, and picking one would let + // a mempool competitor recorded after a confirmed spender + // erase that confirmed evidence with the rows. Applying the + // finality-aware rule per row makes the order irrelevant by + // construction: confirmed evidence wins and is never + // displaced by a mempool observation, so the oldest-first + // pass below converges to the same state any order would. + var adoptedAny = false + for pending in pendingRows.sorted(by: { $0.createdAt < $1.createdAt }) { + // Resolve the spending tx (prefer the relationship; fall + // back to a txid lookup if the row wasn't faulted in). + let resolvedSpending: PersistentTransaction? + if let spending = pending.spendingTransaction { + resolvedSpending = spending + } else { + let spendingTxid = pending.spendingTxid + let txDescriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == spendingTxid } + ) + resolvedSpending = try? backgroundContext.fetch(txDescriptor).first + } + guard let spending = resolvedSpending else { continue } // Flag and link move together — see // `reconcileSpendObservation` for the finality rule. let verdict = Self.reconcileSpendObservation( @@ -1384,10 +1376,19 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if record.spendingTransaction?.txid != spending.txid { record.spendingTransaction = spending } - record.spendingInputIndex = chosen.inputIndex + // The vin index rides with the adopted claim so the + // spending tx's detail view renders inputs in the + // canonical serialized order. + record.spendingInputIndex = pending.inputIndex + adoptedAny = true } - } else { - record.spendingInputIndex = chosen.inputIndex + } + if !adoptedAny, let newest = pendingRows.max(by: { $0.createdAt < $1.createdAt }) { + // No row resolved a spending tx this flush: carry the + // newest claim's vin index forward the way the old + // single-row path did; the linkage itself catches up on + // the next flush that carries the spending tx. + record.spendingInputIndex = newest.inputIndex } record.lastUpdated = Date() for row in pendingRows { From 8b65c416893cf3dbc9429a238b623b0576298baa Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 19 Aug 2026 21:58:03 +0700 Subject: [PATCH 11/11] fix(platform-wallet): restored evidence stays provisional, seeds early, and never names the lock itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review blockers in the session memory. Provenance: a restored in-block record's block was never shown to be on the chain the chainlock covers — the wallet can restore a record whose block a reorg dropped while it was offline, and a later chainlock at or above the old height on the replacement chain satisfies any height-only check. Restored records (everything in history at load, captured as restored_record_txids) are now withheld from the live scan's boundary promotion, and their memory entries carry restored provenance that keeps every later verdict provisional; only live-session observations may upgrade on the boundary, and a mirror-observed chainlocked context remains final on its own. Seeding: the screen only learned conflicts by reading them, so a chainlock dispatcher winning the wallet lock could promotion-evict the restored spender before the first catch-up resume ever ran — neither record nor memory, silent proof wait again. The load path now seeds the memory from the restored state before any resume, with restored provenance. Self-exclusion: the memory fallback now skips an entry naming the lock's own txid, mirroring the live scan's invariant — two locks sharing an input cross-remember each other, and a resume of the chainlocked winner after its record's eviction must not discard the winner on its own spend. Four new tests pin the provenance ceiling (present and evicted), the self-exclusion, and the seed-then-evict launch race. Co-Authored-By: Claude Fable 5 --- .../rs-platform-wallet/src/manager/load.rs | 21 +- .../src/manager/wallet_lifecycle.rs | 1 + .../rs-platform-wallet/src/test_support.rs | 5 + .../rs-platform-wallet/src/wallet/apply.rs | 1 + .../src/wallet/asset_lock/sync/mod.rs | 2 +- .../wallet/asset_lock/sync/reconstruction.rs | 1 + .../src/wallet/asset_lock/sync/recovery.rs | 255 +++++++++++++++++- .../identity/network/contact_requests.rs | 1 + .../src/wallet/platform_wallet.rs | 20 +- .../src/wallet/platform_wallet_traits.rs | 2 + 10 files changed, 297 insertions(+), 12 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index c359a0ff89..683a72ffd7 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -94,14 +94,33 @@ impl PlatformWalletManager

{ core_balance.immature(), core_balance.locked(), ); - let platform_info = PlatformWalletInfo { + let mut platform_info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + restored_record_txids: Default::default(), core_wallet: wallet_info, generation: Arc::clone(&generation), identity_manager: IdentityManager::from(identity_manager), tracked_asset_locks, dpns_name_states: std::collections::BTreeMap::new(), }; + // Everything in history at this point WAS restored — the load + // path starts from an empty map and only the selective record + // restore has run. Recording those txids lets the double-spend + // screen withhold height-only chainlock promotion from them + // (a restored block was never shown to be on the finalized + // chain), and seeding the screen's session memory here closes + // the race where SPV's chainlock dispatcher promotion-evicts a + // restored spender before the first catch-up resume reads it. + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + platform_info.restored_record_txids = platform_info + .core_wallet + .transaction_history() + .iter() + .map(|record| record.txid) + .collect(); + crate::wallet::asset_lock::sync::recovery::seed_observed_input_conflicts( + &platform_info, + ); // Canonical id recomputed from the wallet's own key material. // Computed up front — before `insert_wallet` consumes `wallet` — diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 6eb5300b1f..4f1f1a8b02 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -360,6 +360,7 @@ impl PlatformWalletManager

{ let platform_info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + restored_record_txids: Default::default(), core_wallet: wallet_info, generation: Arc::clone(&generation), identity_manager: crate::wallet::identity::IdentityManager::new(), diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 503acffe3f..f36896e1ec 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -251,6 +251,7 @@ pub(crate) async fn funded_wallet_manager_with_outputs( let generation = Arc::new(WalletGeneration::new()); let info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + restored_record_txids: Default::default(), core_wallet: ctx.managed_wallet, generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), @@ -322,6 +323,7 @@ pub(crate) async fn funded_wallet_manager_dual_standard( let generation = Arc::new(WalletGeneration::new()); let info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + restored_record_txids: Default::default(), core_wallet: ctx.managed_wallet, generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), @@ -425,6 +427,7 @@ pub(crate) async fn funded_wallet_manager_with_contact( let generation = Arc::new(WalletGeneration::new()); let info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + restored_record_txids: Default::default(), core_wallet: ctx.managed_wallet, generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), @@ -502,6 +505,7 @@ pub(crate) async fn funded_coinjoin_wallet_manager() -> ( let generation = Arc::new(WalletGeneration::new()); let info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + restored_record_txids: Default::default(), core_wallet: ctx.managed_wallet, generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), @@ -675,6 +679,7 @@ pub(crate) async fn mnemonic_wallet_manager( }; let info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + restored_record_txids: Default::default(), core_wallet: managed_wallet, generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 0b213330ec..691c902f83 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -427,6 +427,7 @@ mod tests { identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), observed_input_conflicts: Default::default(), + restored_record_txids: Default::default(), dpns_name_states: BTreeMap::new(), } } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/mod.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/mod.rs index 1818013686..0b75499700 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/mod.rs @@ -6,5 +6,5 @@ mod proof; pub(crate) mod reconstruction; -mod recovery; +pub(crate) mod recovery; mod tracking; 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 64b6414c07..7728bc75c0 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 @@ -592,6 +592,7 @@ mod tests { let info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + restored_record_txids: Default::default(), core_wallet: ctx.managed_wallet, generation: std::sync::Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), 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 fd128abeee..83456268af 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 @@ -284,11 +284,26 @@ fn first_confirmed_input_conflict( .map(|input| input.previous_output) .find(|outpoint| lock_inputs.contains(outpoint))?; let height = record.height(); + // The boundary promotion is height-only, so it is withheld + // from RESTORED records: their block was never shown to be on + // the chain the chainlock covers (the wallet can restore a + // record whose block a reorg dropped while it was offline). + // A record this session observed live is promotable — a reorg + // would re-observe and demote it — and a restored record with + // a mirror-observed chainlocked context is final on its own. + let restored = info.restored_record_txids.contains(&record.txid); let spender_chain_locked = record.context.is_chain_locked() - || chain_locked_height - .zip(height) - .is_some_and(|(boundary, spender_height)| spender_height <= boundary); - Some((conflicting_input, record.txid, height, spender_chain_locked)) + || (!restored + && chain_locked_height + .zip(height) + .is_some_and(|(boundary, spender_height)| spender_height <= boundary)); + Some(( + conflicting_input, + record.txid, + height, + spender_chain_locked, + restored, + )) }) { // Remember the observation before returning it. Promotion is also @@ -299,14 +314,18 @@ fn first_confirmed_input_conflict( // and a retry would otherwise find nothing at all. The session // memory below converts that disappearance into the terminal // verdict. A poisoned mutex degrades to no memory, never a failure. - let (input, spender, height, _) = hit; + let (input, spender, height, spender_chain_locked, restored) = hit; if let (Some(h), Ok(mut cache)) = (height, info.observed_input_conflicts.lock()) { cache.insert( input, - crate::wallet::platform_wallet::ObservedInputConflict { spender, height: h }, + crate::wallet::platform_wallet::ObservedInputConflict { + spender, + height: h, + restored, + }, ); } - return Some(hit); + return Some((input, spender, height, spender_chain_locked)); } // No live record — consult the session memory. Three cases per @@ -328,6 +347,16 @@ fn first_confirmed_input_conflict( let Some(observed) = cache.get(input).copied() else { continue; }; + // Same invariant as the live scan's `record.txid != lock_txid`: + // a lock's own spend of its input is not a conflict with itself. + // Two tracked locks sharing an input can cross-remember each + // other, and after the winner's record is promotion-evicted a + // resume of the WINNER must not read the memory as evidence + // against it — code 42 licenses a discard, and discarding the + // chainlocked winner would strand its credits. + if observed.spender == lock_txid { + continue; + } if let Some(record) = history .iter() .find(|record| record.txid == observed.spender) @@ -339,8 +368,12 @@ fn first_confirmed_input_conflict( // scan's hit above; nothing to add here either way. continue; } - let spender_chain_locked = - chain_locked_height.is_some_and(|boundary| observed.height <= boundary); + // Restored provenance never upgrades on a height-only boundary — + // see `ObservedInputConflict::restored`. Live provenance may: the + // record was seen in-block on this session's chain, and + // promotion-eviction is the only path that removes it. + let spender_chain_locked = !observed.restored + && chain_locked_height.is_some_and(|boundary| observed.height <= boundary); return Some(( *input, observed.spender, @@ -351,6 +384,57 @@ fn first_confirmed_input_conflict( None } +/// Seed the double-spend screen's session memory from freshly restored +/// state, before any resume runs. +/// +/// The screen normally learns conflicts by reading them from history — but +/// SPV's chainlock dispatcher can win the race to the wallet lock and +/// promotion-evict a restored spender record before the first catch-up +/// resume ever reads it, leaving neither a record nor a memory: the silent +/// proof-wait hang all of this exists to prevent. Seeding at load closes +/// that window. Entries seeded here carry restored provenance, so a later +/// eviction can only ever surface them as the provisional verdict; the +/// terminal claim still requires evidence verified on the live chain. +pub(crate) fn seed_observed_input_conflicts(info: &PlatformWalletInfo) { + let Ok(mut cache) = info.observed_input_conflicts.lock() else { + return; + }; + let history = info.core_wallet.transaction_history(); + for lock in info.tracked_asset_locks.values() { + if !matches!( + lock.status, + AssetLockStatus::Built | AssetLockStatus::Broadcast + ) { + continue; + } + let lock_txid = lock.transaction.txid(); + for input in lock.transaction.input.iter().map(|i| i.previous_output) { + let Some(record) = history.iter().find(|record| { + record.txid != lock_txid + && record.is_confirmed() + && record + .transaction + .input + .iter() + .any(|i| i.previous_output == input) + }) else { + continue; + }; + let Some(height) = record.height() else { + continue; + }; + cache.insert( + input, + crate::wallet::platform_wallet::ObservedInputConflict { + spender: record.txid, + height, + restored: true, + }, + ); + } + } +} + impl AssetLockManager { /// Resume a tracked asset lock from whatever stage it's at. /// @@ -1164,6 +1248,7 @@ mod tests { let restored_wallet = Wallet::new_external_signable(Network::Testnet, wallet_id, accounts); let mut restored_info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + restored_record_txids: Default::default(), core_wallet: ManagedWalletInfo::from_wallet(&restored_wallet, 0), generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), @@ -1341,6 +1426,36 @@ mod tests { .insert(record.txid, record); } + /// Mark `txid` as a RESTORED record, the way the load path does for + /// everything in history at load time. + async fn mark_restored(&self, txid: Txid) { + let mut wm = self.manager.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&self.wallet_id) + .expect("wallet must remain registered"); + info.restored_record_txids.insert(txid); + } + + /// Prime the screen's session memory directly, the way the load + /// seeder or a prior resume would. + async fn remember_conflict(&self, spender: Txid, height: u32, restored: bool) { + let wm = self.manager.wallet_manager.read().await; + let info = wm + .get_wallet_info(&self.wallet_id) + .expect("wallet must remain registered"); + info.observed_input_conflicts + .lock() + .expect("test cache") + .insert( + self.funded_input(), + crate::wallet::platform_wallet::ObservedInputConflict { + spender, + height, + restored, + }, + ); + } + /// Remove `txid`'s record from the wallet's BIP44 account, the way /// `apply_chain_lock`'s promotion-eviction does under the default /// `keep-finalized-transactions = OFF` build. @@ -1457,6 +1572,128 @@ mod tests { ); } + /// A RESTORED in-block spender never earns the terminal verdict from a + /// height-only boundary: its block was never shown to be on the chain + /// the chainlock covers (the wallet can restore a record whose block a + /// reorg dropped while it was offline). The conflict still fires — + /// provisionally. + #[tokio::test] + async fn a_restored_spender_below_the_boundary_stays_provisional() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + let spender = transaction_spending(fixture.funded_input()); + let spender_txid = spender.txid(); + fixture + .file_record(record_for(spender, confirmed_at(1_234))) + .await; + fixture.mark_restored(spender_txid).await; + fixture.set_chain_lock_boundary(1_300).await; + + let error = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("a currently double-spent asset lock must fail, not wait"); + assert!( + matches!(error, PlatformWalletError::AssetLockInputContested { .. }), + "restored evidence must stay provisional under a height-only boundary, got {error:?}" + ); + } + + /// The same provenance rule survives promotion-eviction: a seeded / + /// remembered RESTORED sighting whose record has left history under a + /// covering boundary still reports the provisional verdict, never the + /// discard-licensing terminal one. + #[tokio::test] + async fn a_restored_spender_evicted_under_the_boundary_stays_provisional() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + let spender_txid = transaction_spending(fixture.funded_input()).txid(); + fixture.remember_conflict(spender_txid, 1_234, true).await; + fixture.set_chain_lock_boundary(1_300).await; + + let error = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("the remembered conflict still stops the wait"); + assert!( + matches!(error, PlatformWalletError::AssetLockInputContested { .. }), + "restored provenance must not upgrade on the boundary, got {error:?}" + ); + } + + /// The memory must never condemn a lock with its own txid: two tracked + /// locks sharing an input cross-remember each other, and after the + /// winner's record is promotion-evicted a resume of the WINNER must + /// not read the memory as evidence against it — discarding the + /// chainlocked winner would strand its credits. + #[tokio::test] + async fn remembered_evidence_never_condemns_the_lock_itself() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + fixture + .remember_conflict(fixture.transaction.txid(), 1_234, false) + .await; + fixture.set_chain_lock_boundary(1_300).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 { .. } + | PlatformWalletError::AssetLockInputContested { .. } + ), + "a lock's own remembered spend must never condemn it, got {error:?}" + ); + } + + /// The load-time seeder primes the memory before any resume runs, so + /// a chainlock dispatcher that promotion-evicts the restored spender + /// before the first catch-up still leaves the screen with evidence — + /// provisional evidence, per its restored provenance. + #[tokio::test] + async fn seeding_survives_a_pre_resume_promotion_eviction() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + let spender = transaction_spending(fixture.funded_input()); + let spender_txid = spender.txid(); + fixture + .file_record(record_for(spender, confirmed_at(1_234))) + .await; + // The load path's seeding pass, then the dispatcher's promotion + // eviction — all before the first resume. + { + let wm = fixture.manager.wallet_manager.read().await; + let info = wm + .get_wallet_info(&fixture.wallet_id) + .expect("wallet must remain registered"); + super::seed_observed_input_conflicts(info); + } + fixture.evict_record(spender_txid).await; + fixture.set_chain_lock_boundary(1_300).await; + + let error = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("the seeded conflict must stop the wait"); + match error { + PlatformWalletError::AssetLockInputContested { spent_by, .. } => { + assert_eq!(spent_by, spender_txid, "the seeded spender, provisionally"); + } + other => panic!("expected AssetLockInputContested, got {other:?}"), + } + } + /// Promotion is eviction: once a chainlock buries the spender's block, /// `apply_chain_lock` removes its record from history — at exactly the /// moment the verdict becomes terminal. The screen's session memory 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 68e7e2328c..4951987d18 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 @@ -3828,6 +3828,7 @@ mod sweep_tests { identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), observed_input_conflicts: Default::default(), + restored_record_txids: 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 4287b72657..80e481e731 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1,6 +1,6 @@ //! The main PlatformWallet struct combining core, identity (+DashPay), and platform sub-wallets. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::ops::{Deref, DerefMut}; use std::sync::Arc; @@ -249,6 +249,18 @@ pub struct ObservedInputConflict { pub spender: Txid, /// The block height it was seen at. pub height: CoreBlockHeight, + /// Provenance: `true` when the sighting traces to a RESTORED record + /// rather than one this session observed on the live chain. Restored + /// evidence never claims chainlock finality from a height-only + /// boundary — the wallet can persist a spender in an ordinary block, + /// sit offline through the reorg that drops it, and restore the stale + /// record; a later chainlock at or above the old height on the + /// REPLACEMENT chain would satisfy a height check without the + /// recorded block ever having been on the finalized chain. Restored + /// entries therefore stay provisional until the evidence is verified + /// live (or the mirror itself restores a chainlocked context next + /// session). + pub restored: bool, } /// Consolidated mutable state for a platform wallet. @@ -285,6 +297,12 @@ pub struct PlatformWalletInfo { /// runs under the manager's read lock; a poisoned mutex degrades to /// "no memory" rather than failing a resume. pub observed_input_conflicts: std::sync::Mutex>, + /// Txids of the transaction records the load path restored into the + /// in-memory history, captured once at load. The double-spend screen + /// consults this to withhold height-only chainlock promotion from + /// restored records (see [`ObservedInputConflict::restored`]). Session + /// state, never persisted. + pub restored_record_txids: BTreeSet, /// 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 49ed828d22..f397539cc9 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -41,6 +41,7 @@ impl WalletInfoInterface for PlatformWalletInfo { identity_manager: super::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), observed_input_conflicts: Default::default(), + restored_record_txids: Default::default(), dpns_name_states: std::collections::BTreeMap::new(), } } @@ -55,6 +56,7 @@ impl WalletInfoInterface for PlatformWalletInfo { identity_manager: super::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), observed_input_conflicts: Default::default(), + restored_record_txids: Default::default(), dpns_name_states: std::collections::BTreeMap::new(), } }