From 2687ae04ce350b431535ea11f3ead599294d7009 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 10 Aug 2026 12:08:56 +0700 Subject: [PATCH 01/15] 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 444573c5dbc..35b1737d7a3 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 8349eb1df21..f648abb395b 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 fb3852dcef1..205b6045e4a 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 8528fe091dd..ba3bb1c9e23 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/15] 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 29986168bc0..43cb9a8a135 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 37169cc094c..06751821bcd 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 5b840f9d039..1a6cdbfc512 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 35b1737d7a3..a2868ca1caf 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 21d98fac4db..6a7d096b03c 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 f648abb395b..6449b81fb41 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 205b6045e4a..1f406c84fcc 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 ba3bb1c9e23..3076037c5ca 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/15] 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 04a4e29ea1d..bb3dff2ef07 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 fdbd641a57f..fcd15a884b3 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 83b6d860742..42dfe83a0e2 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 4a4d8a9d9ce..7a5f0ca0726 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -67,6 +67,7 @@ impl PlatformWalletManager

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

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

{ generation: Arc::clone(&generation), identity_manager: crate::wallet::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: std::collections::BTreeMap::new(), }; diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 31c7abdf446..1577fec724b 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -254,6 +254,7 @@ pub(crate) async fn funded_wallet_manager_with_outputs( generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: BTreeMap::new(), }; @@ -324,6 +325,7 @@ pub(crate) async fn funded_wallet_manager_dual_standard( generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: BTreeMap::new(), }; let mut wm = WalletManager::::new(Network::Testnet); @@ -426,6 +428,7 @@ pub(crate) async fn funded_wallet_manager_with_contact( generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: BTreeMap::new(), }; let mut wm = WalletManager::::new(Network::Testnet); @@ -502,6 +505,7 @@ pub(crate) async fn funded_coinjoin_wallet_manager() -> ( generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: BTreeMap::new(), }; @@ -674,6 +678,7 @@ pub(crate) async fn mnemonic_wallet_manager( generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: BTreeMap::new(), }; diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 4390740640c..b215c9b6b69 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -426,6 +426,7 @@ mod tests { generation: std::sync::Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: BTreeMap::new(), } } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs index 7afdd62c26e..e3d2081bbab 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs @@ -595,6 +595,7 @@ mod tests { generation: std::sync::Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: BTreeMap::new(), }; assert_eq!( diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index 1f406c84fcc..d62cd2bf317 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 ec6eda0072d..a0b06a65113 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -3586,6 +3586,7 @@ mod sweep_tests { generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: BTreeMap::new(), } } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index b6d29e67c40..bf83a3898dc 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 b4a2f7d05b0..36a6aa4afe0 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -40,6 +40,7 @@ impl WalletInfoInterface for PlatformWalletInfo { generation: std::sync::Arc::new(super::core::WalletGeneration::new()), identity_manager: super::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: std::collections::BTreeMap::new(), } } @@ -53,6 +54,7 @@ impl WalletInfoInterface for PlatformWalletInfo { generation: std::sync::Arc::new(super::core::WalletGeneration::new()), identity_manager: super::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), + restored_asset_lock_input_spends: Default::default(), dpns_name_states: std::collections::BTreeMap::new(), } } diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 917d26094df..fcf56ffc357 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -2368,6 +2368,11 @@ fn build_wallet_restore_entry( tracked_asset_locks_count: 0, unresolved_asset_lock_tx_records: ptr::null(), unresolved_asset_lock_tx_records_count: 0, + // Not staged on this host yet: the Kotlin persister has no + // equivalent of the Swift spend-linkage query, so the conflict + // screen keeps its previous transaction-history behaviour here. + asset_lock_input_spends: ptr::null(), + asset_lock_input_spends_count: 0, core_address_pools: ptr::null(), core_address_pools_count: 0, last_applied_chain_lock_bytes: ptr::null(), diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 06b5289ed8e..62ab3063257 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 00000000000..00ae3471113 --- /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/15] 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 43cb9a8a135..a3ac2653553 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 06751821bcd..31e6161da54 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 1a6cdbfc512..d00c0273308 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 a2868ca1caf..206c5669f19 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 6a7d096b03c..982678abdaa 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 6449b81fb41..26a26bb5449 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 d62cd2bf317..f594ded9a9a 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 3076037c5ca..4f84893bb21 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/15] 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 206c5669f19..8dc413737a3 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 bb3dff2ef07..411eab78760 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 fcd15a884b3..c889ea12805 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 26a26bb5449..86ee4a5e841 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 62ab3063257..6faba95ba64 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/15] 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 411eab78760..4d8cb6561e6 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/15] 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 31e6161da54..2aeeafaeca3 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 8dc413737a3..439daa988a1 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 4d8cb6561e6..cbcc1e3161a 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 982678abdaa..39dfa41caff 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 c889ea12805..8eead68201b 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 42dfe83a0e2..83b6d860742 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 86ee4a5e841..7b16b29e8c0 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 7a5f0ca0726..4a4d8a9d9ce 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 515f809439f..c9eafee286b 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 1577fec724b..31c7abdf446 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 b215c9b6b69..4390740640c 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 e3d2081bbab..7afdd62c26e 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 f594ded9a9a..4f1253db5b1 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 c487276c700..c99018792cf 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 bf83a3898dc..b6d29e67c40 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 36a6aa4afe0..b4a2f7d05b0 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 fcf56ffc357..917d26094df 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 c3e33764823..93acf964574 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 52d88881ae8..3557d5b9967 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 00ae3471113..8a8a9b92a5e 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/15] 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 cbcc1e3161a..ee0715275d6 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 93acf964574..773a5c4b630 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/15] 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 773a5c4b630..d3437f4805f 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/15] 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 8eead68201b..59f271c870b 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 4a4d8a9d9ce..c359a0ff897 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 c9eafee286b..6eb5300b1f2 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 31c7abdf446..503acffe3f4 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 4390740640c..0b213330ecc 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 7afdd62c26e..64b6414c07b 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 4f1253db5b1..fd128abeee3 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 c99018792cf..68e7e2328cb 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 b6d29e67c40..4287b72657e 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 b4a2f7d05b0..49ed828d228 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 3557d5b9967..f8e82e150f8 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/15] 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 c359a0ff897..683a72ffd7f 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 6eb5300b1f2..4f1f1a8b028 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 503acffe3f4..f36896e1ec5 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 0b213330ecc..691c902f835 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 1818013686e..0b754997006 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 64b6414c07b..7728bc75c09 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 fd128abeee3..83456268af6 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 68e7e2328cb..4951987d180 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 4287b72657e..80e481e7314 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 49ed828d228..f397539cc92 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(), } } From 0fc084587ac0f63680c1756f6c6fd296f4cd5c78 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 28 Aug 2026 22:56:41 +0200 Subject: [PATCH 12/15] fix(platform-wallet): the conflict screen emits only the provisional verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the two remaining finality blockers by adopting the reviewer's own fallback: keep the verdict provisional wherever finalized ancestry cannot be proven — which, in this layer, is everywhere. Every terminal promotion path rested on height-only evidence: a chainlocked context is a key-wallet promotion artifact (apply_chain_lock promotes InBlock records at or below the boundary without comparing ancestry, and a replacement-branch chainlock can arrive before its headers), the last_applied_chain_lock zips were height comparisons, and the promotion-eviction inference on the session memory inherited the same flaw — with the keep-finalized-transactions feature additionally height-mutating stale restored records past the restored guard. - resume_asset_lock now always raises AssetLockInputContested (48): the screen still stops the doomed broadcast-and-proof-wait, but never licenses a discard. AssetLockInputConflict (47) stays ABI-reserved with no emitter, held for a future finalized-ancestry predicate from the SPV layer; the registry records both claims and moves the frontier to 49. - first_confirmed_input_conflict drops the finality tuple element, the boundary reads, and the restored gating; ObservedInputConflict loses its restored flag (classification no longer differentiates provenance); restored_record_txids stays populated but unconsumed. - The contested Display no longer claims the spender is 'not yet chainlocked' or promises a chainlock upgrade — the wallet asserts nothing about finality and the retry guidance says so. - map_asset_lock_funding_result also preserves AssetLockNotTracked (23) and AssetLockFundingMismatch (25), matching the resume endpoint. - Docs across Rust/FFI/Swift/Kotlin rewritten to the new contract; terminal-upgrade tests become provisional-outcome tests; the self-conflict and mempool-sibling regressions now exclude both variants. Also restores a KDoc opener the base merge swallowed in DashSdkError.kt (compile fix). Co-Authored-By: Claude Fable 5 --- .../dashsdk/errors/DashSdkError.kt | 76 +++-- .../dashsdk/errors/DashSdkErrorTest.kt | 29 +- .../ERROR_CODE_REGISTRY.md | 12 +- .../src/asset_lock/sync.rs | 13 +- packages/rs-platform-wallet-ffi/src/error.rs | 124 ++++--- .../src/shielded_send.rs | 76 ++++- packages/rs-platform-wallet/src/error.rs | 125 ++++--- .../rs-platform-wallet/src/manager/load.rs | 12 +- .../src/wallet/asset_lock/sync/recovery.rs | 319 ++++++++---------- .../src/wallet/platform_wallet.rs | 52 ++- .../PlatformWalletManager.swift | 21 +- .../PlatformWallet/PlatformWalletResult.swift | 106 +++--- 12 files changed, 522 insertions(+), 443 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 6bd76257125..95b7d59491d 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 @@ -116,48 +116,59 @@ sealed class DashSdkError( PlatformWallet(message, cause) /** - * `ErrorAssetLockInputConflict` (native code 47). 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). + * `ErrorAssetLockInputConflict` (native code 47). RESERVED — the + * native side has no code path that produces it today, so this class + * is never instantiated from a real result. * - * 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 + * It is the TERMINAL form of the double-spend verdict: the tracked + * asset-lock transaction spends an outpoint a different, + * already-confirmed transaction of the same wallet spent first, AND + * that spender's block is proven to be on the finalized chain. The + * proof is what is missing — chainlock contexts and the wallet's + * applied chainlock height are height-based promotion artifacts, not + * evidence of finalized ancestry — so every detection arrives as + * [AssetLockInputContested] (48) instead, chainlocked-looking + * spenders included. + * + * Kept (with its mapping arm) so the reserved code stays wired and + * hosts branching on it keep compiling. If it ever ships it keeps its + * meaning: NOT retryable, and the one code that lets a host discard + * the asset lock and rebuild it from currently-unspent inputs. Read + * nothing into its absence. The Android analog of Swift's * `PlatformWalletError.assetLockInputConflict`. */ class AssetLockInputConflict(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) /** - * `ErrorAssetLockInputContested` (native code 48). 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. + * `ErrorAssetLockInputContested` (native code 48). A confirmed + * transaction of this wallet already spent one of the tracked lock's + * inputs — 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 cannot + * confirm while that spender stands and its proof wait would hang; + * the screen stops the resume before it broadcasts again or enters + * the wait (a `Broadcast`-status lock was sent on an earlier call). * - * 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 47, or a reorg drops the sibling and - * the next resume proceeds normally. The Android analog of Swift's - * `PlatformWalletError.assetLockInputContested`. + * The ONLY double-spend verdict the native side emits, and it is + * PROVISIONAL. NO discard licence: keep the tracked lock and retry + * later (next launch, or after the next chainlock) — but note a + * chainlock does NOT upgrade this to code 47 today; what a retry can + * resolve is a reorg dropping the sibling. A conflict that survives + * session after session is in practice permanent, and a host may + * reasonably stop retrying and offer the user a discard: that is a + * host/user policy call this error does not make, and it is fund-safe + * either way because the confirmed spender is this wallet's own + * transaction. Its absence is not proof of liveness — the native scan + * cannot see conflicts whose spender was already pruned. The Android + * analog of Swift's `PlatformWalletError.assetLockInputContested`. */ class AssetLockInputContested(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) { override val isRetryable: Boolean get() = true } + /** * `ErrorAssetLockInsufficientFunds` (native code 29). Asset-lock coin * selection came up short over the build's *permitted funding set*. * What that set is depends on the funding form: an exact-amount build @@ -614,8 +625,13 @@ sealed class DashSdkError( }.getOrNull() } ?: PlatformWallet.Generic(code, message, cause) 41 -> PlatformWallet.PlatformShieldCapacityExceeded(message, cause) - 47 -> PlatformWallet.AssetLockInputConflict(message, cause) // ErrorAssetLockInputConflict - 48 -> PlatformWallet.AssetLockInputContested(message, cause) // ErrorAssetLockInputContested + // ErrorAssetLockInputConflict — RESERVED, no native emitter yet; + // the arm stays so the code would not fall through to Generic if + // a finalized-ancestry proof ever starts raising it. + 47 -> PlatformWallet.AssetLockInputConflict(message, cause) + // ErrorAssetLockInputContested — the double-spend verdict the + // native side actually emits. + 48 -> PlatformWallet.AssetLockInputContested(message, cause) // 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 a4a9fc6bbf4..0889e6ba126 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 @@ -218,12 +218,12 @@ 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. + fun assetLockInputConflictCode47MapsTyped() { + // TERMINAL and RESERVED: no native path emits it today (that needs a + // finalized-ancestry proof the wallet cannot make), so this drives + // the mapping with a hand-built exception. The arm must stay wired — + // if a future emitter ships, the code must not fall through to + // Generic and leave the host unable to classify a dead lock. 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: true) — " + @@ -247,16 +247,17 @@ 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. + fun assetLockInputContestedCode48MapsTypedAndRetryable() { + // PROVISIONAL, and the ONLY double-spend verdict the native side + // emits: the wallet cannot prove the confirmed spender's block is on + // the finalized chain, so the host keeps the tracked lock and retries + // later. It must never be treated as the reserved 47'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" + "transaction c (block height Some(1234)) has taken — the verdict is " + + "provisional (the wallet cannot prove the spender's finality); keep " + + "the lock and retry later" val mapped = DashSdkError.fromNative( DashSDKException( DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 48, diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 5ac8c738fbe..c0d5be2a915 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -114,9 +114,10 @@ These are shipped ABI. Do not renumber. | 98 | `NotFound` | Sentinel — `Option` returned as an error | | 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors | -**Next allocatable integer: 48** — 27–47 are all claimed (27, 29, 31, 34–42 -and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 -proposed by active #4356 (renumbered from 42 — see its row below); 28, 30, +**Next allocatable integer: 49** — 27–48 are all claimed (27, 29, 31, 34–42 +and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 and +48 proposed by active #4356 (47 renumbered from 42, 48 from 43 — see their +rows below); 28, 30, 32 and 33 reserved). **28, 30, 32 and 33 are RESERVED, not free**: 28 and 30 were vacated when the reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners @@ -124,7 +125,7 @@ reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners unclaimed rather than back-filled, so no number is reused within a single review cycle. Rule 1's "do not reuse a gap unless this file marks it free" applies — this file does **not** mark any of them free, so the frontier is -the only allocation source and a new code takes 48. (42 is a cautionary tale: +the only allocation source and a new code takes 49. (42 is a cautionary tale: merged #4451 minted it while active #4356 held the claim — merged ABI wins, the open PR renumbers. 46's near-miss went the other way: caught in review, renumbered before merge.) @@ -149,7 +150,8 @@ Fork-era numbers remain in the collision history, which is immutable record. | Code | Name | Owning PR | Status | | ---: | --- | --- | --- | | 28 | *(reserved — vacated)* | — | Vacated by #4185/#4256 on 2026-08-02; RESERVED, not reissuable — the next-free frontier is the only allocation source | -| 47 | `ErrorAssetLockInputConflict` | #4356 | Proposed — **47 is reserved for this active PR, but the three-layer renumber is still PENDING.** Merged #4451 took 42 for `ErrorMasternodeWithdrawalUnconfirmed` on 2026-08-22, and merged ABI wins. At the cited #4356 head `7d9be71a08`, Rust still defines and tests `ErrorAssetLockInputConflict = 42`, Swift still declares `errorAssetLockInputConflict = 42`, and Kotlin still maps and tests native 42 — #4356 must move all three layers and their tests together to 47 before it can merge. Rule 1 makes 47 unavailable to any other contributor while #4356 is active | +| 47 | `ErrorAssetLockInputConflict` | #4356 | Proposed — three-layer renumber from 42 **complete** on the branch (Rust value + pin test, Swift raw case, Kotlin arm + test all at 47). Merged #4451 had taken 42 for `ErrorMasternodeWithdrawalUnconfirmed` on 2026-08-22; merged ABI won and #4356 moved. **Reserved-with-no-emitter**: the wallet currently constructs only the provisional 48 — 47 is the terminal discard-licensing verdict, held for a future finalized-ancestry proof the SPV layer does not yet expose. The number is claimed ABI either way; Rule 1 makes 47 unavailable to any other contributor while #4356 is active | +| 48 | `ErrorAssetLockInputContested` | #4356 | Proposed — renumbered from 43 (which active #4313 holds) alongside 47's move. The provisional double-spend verdict the conflict screen always emits: stops the doomed broadcast-and-wait, carries no discard licence. Rust value + Swift raw case + Kotlin typed arm and tests all at 48 on the branch | | 30 | *(reserved — vacated)* | — | Vacated by #4185/#4256 on 2026-08-02; RESERVED, not reissuable — the next-free frontier is the only allocation source | | 32 | *(reserved — lapsed)* | — | Owner #4310 (successor of fork-era #4247) closed without merging; RESERVED, not reissuable | | 33 | *(reserved — lapsed)* | — | Owner #4311 (successor of fork-era #4256) closed without merging; RESERVED, not reissuable | 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 aa057352e7b..e37407399e7 100644 --- a/packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs +++ b/packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs @@ -225,11 +225,14 @@ pub unsafe extern "C" fn asset_lock_manager_catch_up_blocking( ); match e { // Double-spend verdicts route through the typed conversion - // so the host receives the real code: terminal - // ErrorAssetLockInputConflict (47) — the one code that - // authorises discarding a tracked lock — or the - // provisional ErrorAssetLockInputContested (48), which - // stops the wait but keeps the lock for a later retry. + // so the host receives the real code. In practice that is + // always the provisional ErrorAssetLockInputContested + // (48), which stops the wait but keeps the lock for a + // later retry: the resume never raises the terminal + // ErrorAssetLockInputConflict (47), which stays reserved + // for a finalized-ancestry proof the wallet cannot make. + // 47 is matched anyway so the reserved code would cross + // intact rather than flattening the day it ships. // Flattening either to ErrorWalletOperation would leave // the host with a spinner it can never resolve. conflict @ (PlatformWalletError::AssetLockInputConflict { .. } diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 66762a14429..4de1c57fedf 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -290,7 +290,8 @@ pub enum PlatformWalletFFIResultCode { // 42 ErrorMasternodeWithdrawalUnconfirmed masternode withdrawal status // 43-45 RESERVED by open dashpay/platform#4313 (shielded-invite claim) // 46 ErrorMasternodeListUnavailable masternode list source - // 47 ErrorAssetLockInputConflict asset-lock double-spend detection (terminal) + // 47 ErrorAssetLockInputConflict asset-lock double-spend detection + // (terminal; RESERVED, no emitter yet) // 48 ErrorAssetLockInputContested asset-lock double-spend detection (provisional) // // 38/39/40 carry a STABLE JSON detail object in the result `message` @@ -418,32 +419,59 @@ pub enum PlatformWalletFFIResultCode { /// per the error-code registry (#4318). ErrorMasternodeListUnavailable = 46, - /// 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. + /// Maps `PlatformWalletError::AssetLockInputConflict`. **RESERVED — + /// no wallet code path currently produces it**, so this code does not + /// cross the boundary today. /// - /// TERMINAL, and the only code here that authorises a host to discard - /// 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 - /// 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. + /// It is the terminal form of the double-spend verdict: a tracked + /// asset-lock transaction spending an outpoint that a different, + /// already-confirmed transaction of the same wallet spent first, where + /// that spender's block is PROVEN to be on the finalized chain. That + /// proof is what is missing. The wallet can see a confirmed spender, + /// a chainlocked record context and the applied chainlock height, but + /// all of those are height-based promotion artifacts rather than + /// evidence of finalized ancestry (the SPV chainlock manager counts a + /// missing header as a passing block-hash check, so a chainlock on a + /// replacement branch can promote losing-branch records). Until the + /// SPV layer exposes an ancestry predicate, every hit — chainlocked + /// spenders included — reports + /// [`Self::ErrorAssetLockInputContested`] (48) instead. /// - /// A confirmed-but-not-chainlocked spender reports - /// [`Self::ErrorAssetLockInputContested`] (48) instead — same - /// stopped-wait, NO discard licence — so this code's finality claim - /// is structural, not advisory. + /// The code number and this variant are kept pinned so the reserved + /// slot stays stable for hosts and for the future emitter. A host must + /// read NOTHING into its absence: it is not a liveness signal, not a + /// "not final yet" signal, and not a statement about any lock. Hosts + /// that already branch on it may keep doing so — if it ever ships, it + /// keeps its meaning: the one code that authorises discarding a + /// tracked asset lock and rebuilding from currently-unspent inputs. + /// + /// Message (when it ships): the typed `Display` rendering, which names + /// the asset-lock outpoint, the conflicting input, the confirmed + /// spender's txid, and the spender's finality (always chainlocked for + /// this code). + ErrorAssetLockInputConflict = 47, + + /// Maps `PlatformWalletError::AssetLockInputContested`. The double-spend + /// screen's ONLY verdict: a confirmed transaction of this wallet + /// already spent one of the tracked lock's inputs, so the resume + /// stopped without a further broadcast or a wait that cannot return. + /// PROVISIONAL — the wallet cannot prove the spender's block is on the + /// finalized branch (see [`Self::ErrorAssetLockInputConflict`] (47), + /// the reserved terminal form), so this is what a chainlocked-looking + /// spender reports too. + /// + /// NOT a discard licence. The host keeps the tracked lock and retries + /// later (next launch, or after the next chainlock) — but note that a + /// chainlock does NOT upgrade this to 47 today; what a retry can + /// resolve is the other direction, a reorg dropping the sibling so the + /// next resume proceeds normally. A conflict that persists across + /// sessions is in practice permanent, and a host may reasonably stop + /// retrying and offer the user a discard — that is host policy, which + /// this code does not grant and does not withhold funds for: the + /// conflicting spender is this wallet's own transaction, so the value + /// lives on in the sibling either way. What the SDK will not do is + /// authorise the discard on this evidence, because a lock whose + /// sibling sits on a losing branch can still be replayed and confirm. /// /// Raised only on a positive detection; its ABSENCE is not a liveness /// signal. The wallet-side scan reads confirmed records still held in @@ -452,26 +480,6 @@ pub enum PlatformWalletFFIResultCode { /// 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 (always chainlocked for this code). - ErrorAssetLockInputConflict = 47, - - /// 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 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. - /// - /// 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 47, 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 = 48, @@ -736,10 +744,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. + // Double-spend verdicts. `AssetLockInputContested` is the one + // the wallet actually raises — without this arm it reached + // `ErrorUnknown` and a host could only render a spinner. The + // terminal `AssetLockInputConflict` has no emitter today (it + // needs a finalized-ancestry proof the SPV layer does not + // expose); its arm is kept so the reserved code stays wired + // for the future emitter and for direct constructions. PlatformWalletError::AssetLockInputConflict { .. } => { PlatformWalletFFIResultCode::ErrorAssetLockInputConflict } @@ -1772,13 +1783,14 @@ 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. + /// The terminal double-spend verdict is RESERVED — no wallet path + /// constructs it today — but its slot stays pinned, so this builds the + /// error directly and checks both halves of the contract: the number + /// the Swift/Kotlin mirrors decode, and the conversion that keeps it + /// from flattening to `ErrorUnknown` if a future ancestry predicate + /// starts emitting it. 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_47() { use dashcore::OutPoint; diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index a74f233590a..fe923d9a124 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -773,12 +773,23 @@ fn catch_funding_panic( /// to substring-matching the Display text. /// - 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). -/// `ErrorAssetLockInputConflict` (47) is the only code that authorises a -/// host to discard a tracked lock, and `ErrorAssetLockInputContested` (48) -/// is its provisional keep-and-retry sibling; flattening either to +/// is where the pre-broadcast conflict screen actually fires). What the +/// screen emits is always `ErrorAssetLockInputContested` (48), the +/// provisional keep-and-retry verdict; the terminal +/// `ErrorAssetLockInputConflict` (47) — the code that would authorise a +/// host to discard a tracked lock — is reserved and currently has no +/// emitter, but is matched here so it stays typed if a future +/// finalized-ancestry proof starts raising it. Flattening either to /// `ErrorWalletOperation` would strand the user on a lock the host cannot /// classify. +/// - `AssetLockNotTracked` -> `ErrorAssetLockNotTracked` (23) and +/// `AssetLockFundingMismatch` -> `ErrorAssetLockFundingMismatch` (25), +/// matching what the non-shielded `asset_lock_manager_resume` surfaces for +/// the same two lookup failures. A host must classify "this outpoint is not +/// tracked" / "this lock belongs to a different funding slot" the same way +/// whichever entry point it came in through — both are caller-state errors +/// that no retry fixes, unlike the timeout and proof-wait failures below +/// that keep the contextual `ErrorWalletOperation`. fn map_asset_lock_funding_result( result: Result<(), PlatformWalletError>, operation: &str, @@ -786,6 +797,10 @@ fn map_asset_lock_funding_result( match result { Ok(()) => PlatformWalletFFIResult::ok(), Err(e @ PlatformWalletError::AssetLockAlreadyConsumed(_)) => e.into(), + Err( + e @ (PlatformWalletError::AssetLockNotTracked(_) + | PlatformWalletError::AssetLockFundingMismatch { .. }), + ) => e.into(), Err( e @ (PlatformWalletError::AssetLockInputConflict { .. } | PlatformWalletError::AssetLockInputContested { .. }), @@ -2404,13 +2419,16 @@ 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. + /// The typed asset-lock outcomes 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 + /// tell a stale/foreign outpoint from a network failure, nor act on a + /// lock that cannot confirm while its sibling stands. #[test] fn map_asset_lock_funding_result_preserves_typed_funding_codes() { + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + let out_point = dashcore::OutPoint { txid: dashcore::Txid::all_zeros(), vout: 7, @@ -2425,8 +2443,40 @@ mod tests { ); assert!(message_of(&result).contains("Platform completion is unconfirmed")); + // The two lookup failures `asset_lock_manager_resume` reports + // typed must not arrive flattened just because the caller came in + // through the shielded entry point. + let not_tracked = map_asset_lock_funding_result( + Err(PlatformWalletError::AssetLockNotTracked(out_point)), + "shielded resume fund-from-asset-lock", + ); + assert_eq!( + not_tracked.code, + PlatformWalletFFIResultCode::ErrorAssetLockNotTracked + ); + assert!(message_of(¬_tracked).contains("is not tracked by this wallet")); + + let mismatch = map_asset_lock_funding_result( + Err(PlatformWalletError::AssetLockFundingMismatch { + out_point, + expected_funding_type: AssetLockFundingType::IdentityRegistration, + expected_identity_index: 0, + actual_funding_type: AssetLockFundingType::IdentityTopUp, + actual_identity_index: 3, + }), + "shielded resume fund-from-asset-lock", + ); + assert_eq!( + mismatch.code, + PlatformWalletFFIResultCode::ErrorAssetLockFundingMismatch + ); + assert!(message_of(&mismatch).contains("is ineligible for")); + // The resume endpoint is where the pre-broadcast conflict screen - // fires, and it funnels through this same wrapper. + // fires, and it funnels through this same wrapper. The screen + // itself only ever raises the contested verdict below; the + // terminal one is reserved, so it is constructed directly here to + // pin that the reserved code would still cross typed. let conflict = map_asset_lock_funding_result( Err(PlatformWalletError::AssetLockInputConflict { out_point, @@ -2453,9 +2503,9 @@ mod tests { "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. + // The verdict the screen actually emits rides the same wrapper + // under its own code: it stops the wait but must not surface as + // the terminal, discard-licensing 47. let contested = map_asset_lock_funding_result( Err(PlatformWalletError::AssetLockInputContested { out_point, diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index e276c2d704c..fe140676a49 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -282,47 +282,39 @@ 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. + /// **RESERVED — the wallet never constructs this variant today.** /// - /// 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. + /// It describes a tracked asset-lock transaction that spends an + /// outpoint a **different, already-confirmed** transaction of this + /// same wallet spent first, where that spender's block is proven to + /// be on the FINALIZED chain. Such a 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, and the only recovery is to discard the lock + /// and build a new one from currently-unspent inputs. /// - /// 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. 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. + /// The missing piece is the finalized-ancestry proof. The wallet layer + /// can see that a spender is confirmed, and it can see chainlock + /// contexts and the applied chainlock height, but both of those are + /// artifacts of a height-based promotion rather than evidence that the + /// spender's block belongs to the branch the chainlock covers (the SPV + /// chainlock manager counts a missing header as a passing block-hash + /// check, so a chainlock landing on a replacement branch ahead of its + /// headers promotes losing-branch records). Until the SPV layer + /// exposes an ancestry predicate, no code path may raise this variant: + /// the double-spend screen reports [`Self::AssetLockInputContested`] + /// for every hit, chainlocked-looking spenders included. /// - /// 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. + /// Kept in the enum — with its fields and its FFI code — so the + /// reserved code stays stable for hosts across the change and for the + /// future emitter. Hosts must read nothing into its absence: it is not + /// a liveness signal, not a "not yet final" signal, and not a + /// statement about any particular lock. /// - /// 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. + /// `height` is the block height of the confirmed spender when the + /// record carries block 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. #[error( "Asset lock {out_point} can never confirm: it spends {input}, which was \ already spent by confirmed transaction {spent_by} (block height \ @@ -336,27 +328,52 @@ pub enum PlatformWalletError { height: Option, }, - /// 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 tracked asset-lock transaction spends an outpoint a + /// **different, already-confirmed** transaction of this same wallet + /// spent first. This is the verdict the double-spend screen always + /// emits on a hit — [`Self::AssetLockInputConflict`] has no emitter. + /// + /// 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. /// - /// 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 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 - /// 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. + /// While the sibling stands, peers reject the lock as a double spend + /// and a proof wait would hang unboundedly (Core stopped sending BIP61 + /// `reject` by default in 0.17, so the drop is silent and looks + /// exactly like a slow network), so the resume stops here without a + /// further broadcast or wait — a `Broadcast`-status lock was already + /// sent on an earlier call. + /// + /// The verdict is PROVISIONAL and carries NO licence to discard the + /// tracked lock. Keep the lock and retry later. Note what a retry can + /// and cannot do: a chainlock arriving over the sibling does NOT + /// upgrade this to the terminal variant today, because the wallet + /// cannot prove the sibling's block is on the finalized branch (see + /// [`Self::AssetLockInputConflict`]). What a retry resolves is the + /// other direction — a reorg drops the sibling and the resume proceeds + /// normally. + /// + /// In practice a conflict that persists across sessions is permanent, + /// and a host may well decide to stop retrying and drop the lock. That + /// is a host/user policy call, not something this error licenses: the + /// SDK does not authorise discarding tracked state on evidence this + /// weak. Either way no funds are lost — both signed transactions are + /// this wallet's own, so the value behind `input` lives on in + /// `spent_by` — but discarding a lock whose sibling turns out to sit + /// on a losing branch strands the credits of a lock a peer can still + /// replay. + /// + /// Raising this error is a definite verdict about the CONFLICT; 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} 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" + taken — the verdict is provisional (the wallet cannot prove the \ + spender's finality); keep the lock and retry later" )] AssetLockInputContested { out_point: dashcore::OutPoint, diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 4e32da3d2f0..12cc75208e3 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -110,12 +110,12 @@ impl PlatformWalletManager

{ }; // 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. + // restore has run. Recording those txids preserves that + // provenance for the session (the double-spend screen no + // longer classifies on it; see `restored_record_txids`), 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 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 05595f77077..4947a7a0ba3 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 @@ -195,15 +195,14 @@ 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, -/// spender_chain_locked)`. +/// `(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 +/// [`PlatformWalletError::AssetLockInputContested`] instead of /// (re-)broadcasting into that void. /// /// **The gate is `is_confirmed()`, deliberately not `is_chain_locked()`.** @@ -215,19 +214,30 @@ impl AssetLockManager { /// the very failure it exists for — an old, long-settled spender — reported /// as an unbounded proof wait. /// -/// Reporting the conflict on a merely-`InBlock` sibling is fund-safe: +/// **The verdict is always provisional, so no finality travels out of +/// here.** Reporting the conflict on a confirmed 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. +/// What no evidence reachable here justifies is *discarding* the tracked +/// lock: the sibling's 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. +/// +/// A terminal verdict would need proof that the spender's block is an +/// ancestor of the FINALIZED chain, and the wallet layer cannot produce +/// one. A record's `is_chain_locked()` context and the wallet's applied +/// chainlock height are both promotion artifacts, not ancestry proofs: +/// promotion is height-based, and the pinned SPV chainlock manager +/// counts a missing header as a passing block-hash check, so a chainlock +/// arriving on a replacement branch ahead of its headers promotes +/// records that sit on the losing branch. Provenance does not rescue the +/// check either — under `keep-finalized-transactions` the key wallet +/// height-mutates a stale RESTORED `InBlock` record straight to +/// `InChainLockedBlock`, so a restored-record guard is bypassed by the +/// same flaw. Until SPV exposes a finalized-ancestry predicate, this +/// helper reports the conflict and nothing about its finality. /// /// **Best-effort in one direction only.** A hit is conclusive: the /// spender is a confirmed transaction sitting in this wallet's own @@ -246,7 +256,7 @@ impl AssetLockManager { fn first_confirmed_input_conflict( info: &PlatformWalletInfo, lock: &TrackedAssetLock, -) -> Option<(OutPoint, Txid, Option, bool)> { +) -> Option<(OutPoint, Txid, Option)> { let lock_txid = lock.transaction.txid(); let lock_inputs: BTreeSet = lock .transaction @@ -254,15 +264,6 @@ 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); let history = info.core_wallet.transaction_history(); @@ -284,63 +285,38 @@ fn first_confirmed_input_conflict( .iter() .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() - || (!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, - )) + Some((conflicting_input, record.txid, record.height())) }) { // 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, spender_chain_locked, restored) = hit; + // `apply_chain_lock` removes the record this scan just read, and a + // retry would otherwise find nothing at all and fall back into the + // proof wait. The session memory below keeps the conflict visible + // across that disappearance. 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, - restored, - }, + crate::wallet::platform_wallet::ObservedInputConflict { spender, height: h }, ); } - return Some((input, spender, height, spender_chain_locked)); + return Some((input, spender, height)); } - // No live record — consult the session memory. Three cases per + // No live record — consult the session memory. Two 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. + // remembered in-block spend still stands and still makes the proof + // wait pointless. The eviction attests a height-based promotion, + // not finalized ancestry, so the verdict it feeds stays the + // provisional one — as it does everywhere else here. let Ok(mut cache) = info.observed_input_conflicts.lock() else { return None; }; @@ -349,12 +325,11 @@ fn first_confirmed_input_conflict( 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 + // a lock's own spend of its input is not a conflict with itself, + // full stop. 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 47 licenses a discard, and discarding the - // chainlocked winner would strand its credits. + // against it. if observed.spender == lock_txid { continue; } @@ -369,18 +344,7 @@ fn first_confirmed_input_conflict( // scan's hit above; nothing to add here either way. continue; } - // 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, - Some(observed.height), - spender_chain_locked, - )); + return Some((*input, observed.spender, Some(observed.height))); } None } @@ -393,9 +357,9 @@ fn first_confirmed_input_conflict( /// 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. +/// that window. Seeding decides only whether the screen fires at all: +/// entries seeded here surface as the same provisional verdict every +/// other sighting does. pub(crate) fn seed_observed_input_conflicts(info: &PlatformWalletInfo) { let Ok(mut cache) = info.observed_input_conflicts.lock() else { return; @@ -429,7 +393,6 @@ pub(crate) fn seed_observed_input_conflicts(info: &PlatformWalletInfo) { crate::wallet::platform_wallet::ObservedInputConflict { spender: record.txid, height, - restored: true, }, ); } @@ -476,12 +439,18 @@ impl AssetLockManager { /// A `Built` / `Broadcast` lock is first screened by /// [`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 + /// no peer will relay while the spender stands. The hit ALWAYS raises + /// the provisional [`PlatformWalletError::AssetLockInputContested`], + /// which keeps the lock tracked for a later retry: stopping the doomed + /// broadcast-and-wait is all this evidence supports. Proving the + /// spender's block is on the finalized branch would take an ancestry + /// predicate the wallet does not have — chainlock contexts and applied + /// chainlock heights are promotion artifacts, not ancestry proofs — so + /// the terminal [`PlatformWalletError::AssetLockInputConflict`] is + /// never constructed here. A conflict that persists across sessions is + /// in practice permanent, but acting on that (discarding the tracked + /// lock) is host and user policy; the SDK does not license it + /// unilaterally on this evidence. 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, @@ -542,43 +511,32 @@ impl AssetLockManager { // 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, spender_chain_locked)) = input_conflict { + // a host explain the stalled funding attempt instead of showing a + // spinner forever. + if let Some((input, spent_by, height)) = 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 cannot \ confirm while that spender stands" ); - // 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, - } - } else { - PlatformWalletError::AssetLockInputContested { - out_point: *out_point, - input, - spent_by, - height, - } + // One verdict, always provisional. The screen proves the + // broadcast-and-wait is doomed while the spender stands; it + // cannot prove the spender's block is on the finalized branch + // (see `first_confirmed_input_conflict` for why every finality + // signal reachable from here is a promotion artifact). The + // terminal variant is the one that licenses discarding tracked + // state, and discarding a lock whose sibling turns out to sit + // on a losing branch would strand the credits of a lock a peer + // can still replay — so it is never raised here. + return Err(PlatformWalletError::AssetLockInputContested { + out_point: *out_point, + input, + spent_by, + height, }); } @@ -1653,7 +1611,7 @@ mod tests { /// 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) { + async fn remember_conflict(&self, spender: Txid, height: u32) { let wm = self.manager.wallet_manager.read().await; let info = wm .get_wallet_info(&self.wallet_id) @@ -1663,11 +1621,7 @@ mod tests { .expect("test cache") .insert( self.funded_input(), - crate::wallet::platform_wallet::ObservedInputConflict { - spender, - height, - restored, - }, + crate::wallet::platform_wallet::ObservedInputConflict { spender, height }, ); } @@ -1745,11 +1699,10 @@ mod tests { )) } - /// 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. + /// The base case: a confirmed sibling spending the lock's input stops + /// the resume before it broadcasts or waits, and it does so through the + /// contested variant — the screen's one verdict, which carries no + /// licence to discard the tracked lock. #[tokio::test] async fn broadcast_resume_reports_a_contested_input_for_a_merely_in_block_spender() { let fixture = ConflictFixture::new().await; @@ -1787,11 +1740,13 @@ 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. + /// Restored provenance changes nothing, in either direction. It used + /// to gate a height-only promotion to the terminal verdict; that + /// promotion is gone (and the guard was bypassable anyway — under + /// `keep-finalized-transactions` the key wallet height-mutates a stale + /// restored `InBlock` record straight to `InChainLockedBlock`), so a + /// restored spender under a covering boundary reports exactly what + /// every other sighting does. #[tokio::test] async fn a_restored_spender_below_the_boundary_stays_provisional() { let fixture = ConflictFixture::new().await; @@ -1812,21 +1767,22 @@ mod tests { .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:?}" + "a restored spender under the boundary must stay provisional, 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. + /// The same on the memory path: a remembered sighting whose record has + /// left history under a covering boundary still reports the + /// provisional verdict, never the discard-licensing terminal one. The + /// eviction attests a height-based promotion, not that the spender's + /// block is on the finalized branch. #[tokio::test] - async fn a_restored_spender_evicted_under_the_boundary_stays_provisional() { + async fn a_remembered_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.remember_conflict(spender_txid, 1_234).await; fixture.set_chain_lock_boundary(1_300).await; let error = fixture @@ -1836,22 +1792,22 @@ mod tests { .expect_err("the remembered conflict still stops the wait"); assert!( matches!(error, PlatformWalletError::AssetLockInputContested { .. }), - "restored provenance must not upgrade on the boundary, got {error:?}" + "a remembered sighting 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. + /// not read the memory as evidence against it — a lock's own spend is + /// not a conflict with itself. #[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) + .remember_conflict(fixture.transaction.txid(), 1_234) .await; fixture.set_chain_lock_boundary(1_300).await; @@ -1873,7 +1829,7 @@ mod tests { /// 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. + /// reported, like every other sighting, as the provisional verdict. #[tokio::test] async fn seeding_survives_a_pre_resume_promotion_eviction() { let fixture = ConflictFixture::new().await; @@ -1910,12 +1866,14 @@ 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. + /// `apply_chain_lock` removes its record from history. The screen's + /// session memory must carry the conflict across that disappearance + /// instead of letting the resume fall back into the proof wait — and it + /// must carry it as the SAME provisional verdict, because a + /// height-based promotion is not proof that the spender's block is on + /// the finalized branch. #[tokio::test] - async fn a_chainlock_evicted_spender_upgrades_the_remembered_verdict_to_terminal() { + async fn a_chainlock_evicted_spender_keeps_the_remembered_verdict_provisional() { let fixture = ConflictFixture::new().await; fixture.track(AssetLockStatus::Broadcast, None).await; @@ -1925,7 +1883,7 @@ mod tests { .file_record(record_for(spender, confirmed_at(1_234))) .await; - // First resume: provisional, and the screen remembers the sighting. + // First resume: the screen reports and remembers the sighting. let first = fixture .manager .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) @@ -1933,7 +1891,7 @@ mod tests { .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 screen's one verdict is provisional, got {first:?}" ); // The chainlock lands: boundary moves past the spender's height and @@ -1947,10 +1905,10 @@ mod tests { .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"); + PlatformWalletError::AssetLockInputContested { spent_by, .. } => { + assert_eq!(spent_by, spender_txid, "the remembered spender, unchanged"); } - other => panic!("expected the terminal AssetLockInputConflict, got {other:?}"), + other => panic!("expected AssetLockInputContested, got {other:?}"), } } @@ -1996,9 +1954,10 @@ mod tests { ); } - /// 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. + /// The memory outlives the record with no applied chainlock in sight: + /// the conflict is still reported, still provisionally. The boundary + /// is not consulted at all — with or without one, the screen has the + /// same evidence and gives the same answer. #[tokio::test] async fn an_evicted_spender_without_a_covering_boundary_stays_provisional() { let fixture = ConflictFixture::new().await; @@ -2026,14 +1985,14 @@ 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`.) + /// The applied chainlock watermark is NOT an ancestry proof, so a live + /// in-block record sitting at or below it earns no promotion. The + /// watermark is height-based, and the SPV chainlock manager counts a + /// missing header as a passing block-hash check, so a chainlock landing + /// on a replacement branch ahead of its headers can move it past a + /// record that sits on the losing branch. The verdict stays contested. #[tokio::test] - async fn a_live_spender_below_the_boundary_reports_the_terminal_conflict() { + async fn a_live_spender_below_the_boundary_stays_contested() { let fixture = ConflictFixture::new().await; fixture.track(AssetLockStatus::Broadcast, None).await; @@ -2050,21 +2009,21 @@ mod tests { .await .expect_err("a double-spent asset lock must fail, not wait"); match error { - PlatformWalletError::AssetLockInputConflict { spent_by, .. } => { - // The terminal variant IS the finality assertion: it is - // only constructed for a chainlock-final spender. + PlatformWalletError::AssetLockInputContested { spent_by, .. } => { assert_eq!(spent_by, spender_txid); } - other => panic!("expected the terminal AssetLockInputConflict, got {other:?}"), + other => panic!("expected AssetLockInputContested, 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 - /// than being re-derived from the message. + /// The strongest evidence the wallet can hold — a spender whose own + /// record carries a chain-locked context — still buys no upgrade. That + /// context is set by the same height-based promotion, so it attests a + /// chainlock at the record's height, not that the record's block is on + /// the branch the chainlock covers. The terminal variant has no + /// emitter; the resume reports the provisional one here too. #[tokio::test] - async fn input_conflict_reports_a_chain_locked_spender_as_chain_locked() { + async fn a_chain_locked_spender_still_reports_only_the_contested_verdict() { let fixture = ConflictFixture::new().await; fixture.track(AssetLockStatus::Broadcast, None).await; @@ -2081,17 +2040,17 @@ mod tests { .expect_err("a double-spent asset lock must fail, not wait"); let rendered = error.to_string(); match error { - PlatformWalletError::AssetLockInputConflict { + PlatformWalletError::AssetLockInputContested { spent_by, height, .. } => { assert_eq!(spent_by, spender_txid); assert_eq!(height, Some(1_234)); } - other => panic!("expected AssetLockInputConflict, got {other:?}"), + other => panic!("expected AssetLockInputContested, got {other:?}"), } assert!( - rendered.contains("chainlocked: true"), - "the rendered Display must carry the spender's finality: {rendered}" + rendered.contains("provisional"), + "the rendered Display must say the verdict is provisional: {rendered}" ); assert_eq!( fixture.broadcast_count(), @@ -2123,7 +2082,11 @@ mod tests { .await .expect_err("no proof event should arrive within the deadline"); assert!( - !matches!(error, PlatformWalletError::AssetLockInputConflict { .. }), + !matches!( + error, + PlatformWalletError::AssetLockInputConflict { .. } + | PlatformWalletError::AssetLockInputContested { .. } + ), "an unconfirmed conflict must not condemn the lock, got {error:?}" ); assert_eq!( diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index e71c0954b95..2961d4a83d7 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -229,38 +229,29 @@ 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. +/// One confirmed 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. +/// chainlock buries it (default `keep-finalized-transactions = OFF`), and +/// a retry after that eviction would otherwise find nothing and fall back +/// into the proof wait the screen exists to prevent. The screen writes +/// entries when it observes a confirmed spender, retracts them when live +/// history re-observes that spender unconfirmed (a reorg demotes the +/// record in place), and keeps reporting an entry whose record has LEFT +/// history: promotion-eviction is the only path that removes a record. +/// +/// The entry carries no finality: an eviction attests a height-based +/// promotion, not that the spender's block is on the finalized branch, so +/// every verdict the screen builds from this memory is the provisional +/// one. See `wallet::asset_lock::sync::recovery`. #[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, - /// 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. @@ -298,10 +289,17 @@ pub struct PlatformWalletInfo { /// "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. + /// in-memory history, captured once at load. Session state, never + /// persisted. + /// + /// The double-spend screen no longer reads this: it used to withhold + /// height-only chainlock promotion from restored records, but that + /// guard was unsound (under `keep-finalized-transactions` the key + /// wallet height-mutates a stale restored `InBlock` record straight to + /// `InChainLockedBlock`, bypassing it) and the screen now emits one + /// provenance-independent verdict. Kept as load-path provenance for a + /// future consumer that can pair it with a real finalized-ancestry + /// predicate; classification must not be rebuilt on it alone. pub restored_record_txids: BTreeSet, /// DPNS name states with sale price (username marketplace), keyed by /// domain document id. Session-lifetime working set for the diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index e53cd7db14e..a2166bcb0f6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -1835,10 +1835,11 @@ public class PlatformWalletManager: ObservableObject { // 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 - // (47) or explains the pending retry (48) reads it - // from here. + // lock is stuck. `lastError` is the manager's one + // public error surface; a UI that explains the + // stalled lock and its pending retry (48 — the only + // verdict emitted; 47 stays reserved) reads it from + // here. while let outcome = await group.next() { if !published, let verdict = outcome { published = true @@ -1870,11 +1871,13 @@ 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. - /// 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. + /// Returns the typed double-spend verdict when the catch-up hits one — + /// the one outcome a host must see so its UI can explain why the lock + /// is stuck instead of spinning — and `nil` for every expected failure. + /// In practice that verdict is always the provisional + /// `assetLockInputContested`; the terminal `assetLockInputConflict` is + /// reserved with no emitter and is matched so it would surface intact + /// if that ever changes. 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. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index dc8617af54a..9c273c76f02 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -185,28 +185,32 @@ 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. + /// RESERVED — the Rust side has no code path that produces this today, so + /// it does not currently cross the boundary. It is the TERMINAL form of + /// the double-spend verdict: the tracked asset-lock transaction spends an + /// outpoint a different, already-confirmed transaction of the same wallet + /// spent first, AND that spender's block is proven to be on the finalized + /// chain. The proof is what is missing — chainlock contexts and the + /// wallet's applied chainlock height are height-based promotion artifacts, + /// not evidence of finalized ancestry — so every detection reports + /// `errorAssetLockInputContested` (48) instead, chainlocked-looking + /// spenders included. Kept pinned so the slot stays stable for hosts and + /// for the future emitter, which would carry the same meaning: the one + /// code that lets a host discard the asset lock and rebuild from + /// currently-unspent inputs. Read nothing into its absence. case errorAssetLockInputConflict = 47 - /// 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. + /// A confirmed transaction of this wallet already spent one of the tracked + /// lock's inputs — 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 cannot confirm while + /// that spender stands and its proof wait would hang; the screen stops the + /// resume before it broadcasts again or enters the wait (a + /// `Broadcast`-status lock was sent on an earlier call). This is the ONLY + /// double-spend code the SDK emits, and it is PROVISIONAL: no discard + /// licence, keep the lock tracked and retry later. A later chainlock does + /// not upgrade it to 47 today; what a retry can resolve is a reorg dropping + /// the sibling. Its absence is not proof of liveness — the Rust-side scan + /// cannot see conflicts whose spender was already pruned. case errorAssetLockInputContested = 48 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the @@ -513,27 +517,36 @@ 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) + /// RESERVED, and never produced today: the TERMINAL double-spend verdict, + /// which would additionally attest that the confirmed spender's block is + /// on the finalized chain. The wallet cannot prove that (chainlock + /// contexts and the applied chainlock height are height-based promotion + /// artifacts, not ancestry proofs), so every detection arrives as + /// `assetLockInputContested`. The case is kept so the FFI code stays + /// mapped and hosts that already branch on it keep compiling; if it ever + /// ships it means what it always meant — 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 discard the lock and rebuild it from + /// currently-unspent inputs. The message names the lock's outpoint, the + /// conflicting input, the confirmed spender, and that spender's finality. + case assetLockInputConflict(String) /// 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. The screen stops the current + /// already-confirmed transaction of this wallet spent first, so no peer + /// will relay it while that spender stands. 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 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. + /// not a claim that nothing ever reached the network. + /// + /// The only double-spend verdict the SDK emits, and PROVISIONAL: the + /// tracked lock must NOT be discarded on this error. Retry on a later + /// launch; a chainlock over the sibling does not upgrade this to + /// `assetLockInputConflict` today, while a reorg that drops the sibling + /// clears it. A conflict that survives session after session is in + /// practice permanent, and a host may decide to stop retrying and offer + /// the user a discard — that is a host/user policy call this error does + /// not make, and it is fund-safe either way because the confirmed spender + /// is this wallet's own transaction. 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 @@ -687,13 +700,14 @@ public enum PlatformWalletError: LocalizedError { } else { self = .unknown(detail) } - // Code 47 carries the typed `Display` rendering, not a JSON detail - // object: it already names the asset-lock outpoint, the conflicting - // 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. + // Both double-spend codes carry the typed `Display` rendering, not a + // JSON detail object: it already names the asset-lock outpoint, the + // conflicting input, the confirmed spender's txid and that spender's + // finality, and reads as a sentence, so they pass through like the + // other prose-message codes. Which verdict was reached is the CODE's + // meaning, not the string's — hosts must branch on the case, not on + // text matching. In practice only 48 arrives; 47 is reserved and has + // no emitter, and is mapped here so it stays typed if that changes. case .errorAssetLockInputConflict: self = .assetLockInputConflict(detail) case .errorAssetLockInputContested: From 7cdb0b588adfd1416a129f752be658ac27ae71fc Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sat, 29 Aug 2026 20:00:55 +0700 Subject: [PATCH 13/15] fix(platform-wallet): a conflict sighting bounds the resume, it never refuses it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The double-spend screen used to short-circuit `resume_asset_lock` before the (re-)broadcast and the proof wait. It cannot: part of the history it reads is rebuilt at load from persisted rows, and such a record is never checked against the active chain. A wallet offline while the spender's block was reorganized out restores the sighting anyway, and nothing repairs it — key-wallet demotes a record only when that transaction is re-observed, and a transaction absent from both the replacement chain and every mempool never is. A pre-emptive refusal therefore returned code 48 on every resume and every launch for a lock that was free to confirm, with no broadcast and no proof recovery ever attempted (thepastaclaw finding b7293e96fa31). The sighting now bounds the wait instead of replacing it: it withdraws the unbounded wait (it is not evidence the transaction reached the network) and caps a caller's longer budget at UNCONFIRMED_BROADCAST_PROOF_TIMEOUT, since a lock is unrelayable while the spender stands. The verdict is read afterwards, off whatever live synchronization left behind — a proof that arrives settles the lock outright, a conflict the wait did not clear becomes the provisional AssetLockInputContested, and one live history retracted meanwhile leaves the pre-existing outcome untouched. A proof already in the record resolves on `wait_for_proof`'s first pass, so the recovery path costs nothing. Test would have caught this in CI: `a_standing_conflict_never_costs_the_lock_a_proof_that_has_arrived` — a lock whose own funding record is chain-locked, resumed with a confirmed sibling in history: ✖ before the fix (AssetLockInputContested), ✔ after (the ChainLock proof). Two existing conflict tests now assert `broadcast_count() == 1` where they asserted 0. Documentation, for consistency (finding d9c2daea4be6): the four SDK surfaces said code 48 was provisional and must not be discarded while also inviting a host to offer a discard after repeated sightings and calling either choice fund-safe. Persistence is not finality — the sighting can be exactly the restored record above — so repetition licenses nothing; only the terminal 47, or an independent finalized-ancestry proof, may authorize a discard. Applied to PlatformWalletResult.swift, DashSdkError.kt, rs-platform-wallet/error.rs and rs-platform-wallet-ffi/error.rs, together with the stale "stops the resume before it broadcasts" wording everywhere it appeared. `PlatformWalletInfo::restored_record_txids` is deleted (finding 80755917d541): it lost its last production reader when the terminal verdict went away, and the load path still walked all of history to fill it while every constructor had to initialize a publicly mutable set nothing enforced. Its provenance-only test helper goes with it, along with the test that only exercised that helper and now duplicates `a_live_spender_below_the_boundary_stays_contested`. Withdrawing the pre-emptive refusal made the `Built` arm's pre-existing rejection arm reachable after a sighting, and that arm was terminal. The production `SpvBroadcaster` answers `Rejected` when it is not connected, so an app-launch catch-up over a restored row returned it before the local record was ever consulted: an IS/CL proof already sitting in history could not win, and a genuinely standing conflict never received the bound this commit exists to give it. The error was worse than the delay. `Rejected` converts to `TransactionBroadcast`, the FFI's code 26, whose contract is that Core rejected the transaction, the inputs' reservation was released and a rebuild is safe — but only the initial build path untracks and releases; the resume keeps both, so a host honouring 26 would have built a SECOND asset lock beside a possibly-live one. The rejection is now attempt-local, exactly as it already was one arm below: it says "*this* send never left the device", never that an earlier one failed — a row sits at `Built` after a successful broadcast too. So the record is probed once with a zero-duration wait and a proof there completes the resume offline; failing that, with no sighting the row and its reservation are kept and the resume ends as `TransactionBroadcastUnconfirmed`, and with a sighting the bounded wait is entered, since the sighting bounds the wait rather than replacing it and its verdict is only readable afterwards. The status advance stays with a send that actually dispatched, so an undispatched attempt leaves the row at `Built` for the next resume to re-send. The verdict re-read gets the same precedence rule the wait has. `wait_for_proof` re-reads the record at the top of each iteration and then selects between the notification and the deadline, so finality becoming visible while the deadline branch wins is invisible there; a concurrent resume under a longer budget can equally have attached the proof and advanced the row while a shorter one expires. The sibling is still in history either way, so the scan alone answered "contested" for a lock that was already final. `input_conflict_verdict` now probes the local record once and then builds its whole decision from ONE wallet snapshot — the funding record's own finality, the tracked row's proof and status, and the sibling scan. Any of the three suppresses the verdict and leaves the caller's own error intact; the row is untouched, so the next resume returns the proof from the record on the first pass. Reading them separately left the race open. Finality that lands after the probe's own in-memory lookup enriches the RECORD without advancing the row — `LockNotifyHandler` wakes waiters, it does not write a status — so a re-read that consulted only the row saw `Broadcast` with no proof, found the sibling still in history, and published code 48 for a locally final lock. The new `record_holds_local_finality` answers the record question from inside the verdict's own guard, so the three answers describe one instant. Code 26 is a promise about cleanup, not a relay of the broadcaster's verdict, and the initial build path was making it without keeping it. When a concurrent `resume_asset_lock` advances the row past `Built` inside the rejection window, the untrack guard fires, the row and its funding reservation are deliberately kept — and the raw `e.into()` still returned `TransactionBroadcast`, telling the host the row was gone, the inputs were free and a rebuild was safe. A host honouring that rebuilt from other UTXOs and put a SECOND asset lock beside a transaction the advance says reached the network. The error now follows the cleanup: 26 only when the row was actually untracked AND the reservation released, and the retained-row branch reports the unknown outcome instead. A caller-selected timeout survives the rejected-`Built` expiry too. The undispatched translation ran before the existing `timeout.is_some()` preservation, so a resume whose initial sighting retracted mid-wait had its explicit bound answered with `TransactionBroadcastUnconfirmed` quoting the fixed 180-second policy cap. Every re-typing on that arm exists to stop an UNBOUNDED wait hanging on a signal that cannot arrive, and a caller that named a deadline never had that problem — the shielded seed pool reads `FinalityTimeout` as a pacing signal and resumes the lock later. The check now comes first, matching what the `Broadcast` arm already did in the identical row-retained, reservation-held state. `ERROR_CODE_REGISTRY.md`'s code-48 row said the screen "stops the doomed broadcast-and-wait". It is the allocation and host-contract record for the ABI code, so it now says what the code does: the sighting bounds the proof wait, and 48 is emitted only when that bounded wait expires with the conflict still standing. The no-discard statement is unchanged. Test would have caught this in CI — five tests, ✖ before these fixes, ✔ after: `a_rejected_rebroadcast_of_a_conflicted_built_lock_still_takes_an_arrived_proof` (✖ TransactionBroadcast, ✔ the ChainLock proof and a row advanced to ChainLocked), `..._reports_the_contested_verdict` (✖ TransactionBroadcast, ✔ AssetLockInputContested after one re-broadcast attempt, row still Built), `a_concurrent_resume_that_settled_the_lock_suppresses_the_contested_verdict` (✖ AssetLockInputContested, ✔ FinalityTimeout), and `built_resume_still_fails_on_a_definite_rejection`, renamed `built_resume_of_a_rejected_rebroadcast_reports_an_unknown_outcome` for the contract it now pins (✖ TransactionBroadcast, ✔ TransactionBroadcastUnconfirmed with the row still tracked at Built). Three further tests, ✖ before these fixes, ✔ after: `rejected_broadcast_racing_concurrent_resume_keeps_row_and_reservation` (✖ TransactionBroadcast, ✔ TransactionBroadcastUnconfirmed — the row, the absent deletion and the held reservation were already asserted; only the contract was wrong), `a_rejected_built_rebroadcast_keeps_an_explicit_timeout_ as_finality_timeout` (✖ TransactionBroadcastUnconfirmed quoting 180s against a 10ms bound, ✔ FinalityTimeout) and `finality_landing_between_the_probe_and_ the_snapshot_outranks_the_conflict` (✖ AssetLockInputContested, ✔ FinalityTimeout with the row still at Broadcast). The last two drive `resume_asset_lock` end to end through a persistence stub that mutates the wallet from inside the verdict probe's own persister lookup — the one interleaving that is otherwise unreachable, since no wallet guard is held across it. The conflicted-`Built` tests now also attempt a REBUILD, which is the only direct proof the funding reservation is still held: the fixture's whole balance rides on the single UTXO the lock spends. The verdict's finality check reads the account's finalized-txid set as well as the record, because the chainlock promotion that grants finality is also what takes the record away: under the default `keep-finalized-transactions = OFF` build `apply_chain_lock` drops the record it just promoted and keeps only its txid. A chainlock landing after the zero-duration probe therefore left nothing for the record lookup to find, and a sibling the same chainlock had not buried still produced code 48 for a locally final lock. A fourth test, ✖ before that change, ✔ after: `a_chainlock_evicting_the_funding_record_mid_verdict_outranks_the_conflict` (✖ AssetLockInputContested, ✔ FinalityTimeout with the row still at Broadcast). The wallet's own `apply_chain_lock` performs the promotion and the eviction from inside the same verdict probe, and the sibling sits one block above the chainlock height so that pass leaves it standing — without that, there would be no conflict left to suppress. Verified: cargo test -p platform-wallet --features shielded (966 pass; the one failure, shield_input_selection_tests::regression_reports_max_from_usable_ suffix_not_total_account_balance, reproduces unchanged on this branch's head and is unrelated), -p platform-wallet-ffi --features shielded (320 + 26 + 6 pass), cargo clippy --all-targets -D warnings on both crates, cargo fmt --check. Swift and Kotlin changes are comment-only. Co-Authored-By: Claude Opus 5 --- .../dashsdk/errors/DashSdkError.kt | 28 +- .../ERROR_CODE_REGISTRY.md | 2 +- .../src/asset_lock/sync.rs | 2 +- packages/rs-platform-wallet-ffi/src/error.rs | 69 +- .../src/shielded_send.rs | 8 +- .../src/wallet_restore_types.rs | 5 +- packages/rs-platform-wallet/src/error.rs | 29 +- .../rs-platform-wallet/src/manager/load.rs | 22 +- .../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/build.rs | 49 +- .../src/wallet/asset_lock/sync/proof.rs | 65 +- .../wallet/asset_lock/sync/reconstruction.rs | 1 - .../src/wallet/asset_lock/sync/recovery.rs | 1299 +++++++++++++++-- .../identity/network/contact_requests.rs | 1 - .../src/wallet/platform_wallet.rs | 15 +- .../src/wallet/platform_wallet_traits.rs | 2 - .../PlatformWalletManager.swift | 22 +- .../PlatformWallet/PlatformWalletResult.swift | 46 +- 20 files changed, 1432 insertions(+), 240 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 95b7d59491d..ec549a2936d 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 @@ -146,22 +146,28 @@ sealed class DashSdkError( * inputs — 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 cannot - * confirm while that spender stands and its proof wait would hang; - * the screen stops the resume before it broadcasts again or enters - * the wait (a `Broadcast`-status lock was sent on an earlier call). + * confirm while that spender stands and an unbounded proof wait would + * hang. The resume still runs: the sighting bounds that wait instead + * of replacing it, so the lock was (re-)broadcast and waited on (a + * `Broadcast`-status lock was also sent on an earlier call), and this + * is what the bounded wait expired with. * * The ONLY double-spend verdict the native side emits, and it is * PROVISIONAL. NO discard licence: keep the tracked lock and retry * later (next launch, or after the next chainlock) — but note a * chainlock does NOT upgrade this to code 47 today; what a retry can - * resolve is a reorg dropping the sibling. A conflict that survives - * session after session is in practice permanent, and a host may - * reasonably stop retrying and offer the user a discard: that is a - * host/user policy call this error does not make, and it is fund-safe - * either way because the confirmed spender is this wallet's own - * transaction. Its absence is not proof of liveness — the native scan - * cannot see conflicts whose spender was already pruned. The Android - * analog of Swift's `PlatformWalletError.assetLockInputContested`. + * resolve is a reorg dropping the sibling. Repetition does not + * license a discard either: a conflict that survives session after + * session still proves nothing about finalized ancestry — the + * sighting can be a block record restored from a previous session + * whose block was reorganized out while the host was offline. Only + * code 47, or an independent finalized-ancestry proof, authorizes + * dropping the tracked state. Keeping the lock costs nothing: the + * confirmed spender is this wallet's own transaction, so the value + * lives on in it either way. Its absence is not proof of liveness — + * the native scan cannot see conflicts whose spender was already + * pruned. The Android analog of Swift's + * `PlatformWalletError.assetLockInputContested`. */ class AssetLockInputContested(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) { diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index c0d5be2a915..181eefe62f2 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -151,7 +151,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | ---: | --- | --- | --- | | 28 | *(reserved — vacated)* | — | Vacated by #4185/#4256 on 2026-08-02; RESERVED, not reissuable — the next-free frontier is the only allocation source | | 47 | `ErrorAssetLockInputConflict` | #4356 | Proposed — three-layer renumber from 42 **complete** on the branch (Rust value + pin test, Swift raw case, Kotlin arm + test all at 47). Merged #4451 had taken 42 for `ErrorMasternodeWithdrawalUnconfirmed` on 2026-08-22; merged ABI won and #4356 moved. **Reserved-with-no-emitter**: the wallet currently constructs only the provisional 48 — 47 is the terminal discard-licensing verdict, held for a future finalized-ancestry proof the SPV layer does not yet expose. The number is claimed ABI either way; Rule 1 makes 47 unavailable to any other contributor while #4356 is active | -| 48 | `ErrorAssetLockInputContested` | #4356 | Proposed — renumbered from 43 (which active #4313 holds) alongside 47's move. The provisional double-spend verdict the conflict screen always emits: stops the doomed broadcast-and-wait, carries no discard licence. Rust value + Swift raw case + Kotlin typed arm and tests all at 48 on the branch | +| 48 | `ErrorAssetLockInputContested` | #4356 | Proposed — renumbered from 43 (which active #4313 holds) alongside 47's move. The provisional double-spend verdict the conflict screen always emits: the sighting BOUNDS the proof wait rather than replacing it, so the lock is still (re-)broadcast and waited on, and 48 is emitted only when that bounded wait expires with the conflict still standing; carries no discard licence. Rust value + Swift raw case + Kotlin typed arm and tests all at 48 on the branch | | 30 | *(reserved — vacated)* | — | Vacated by #4185/#4256 on 2026-08-02; RESERVED, not reissuable — the next-free frontier is the only allocation source | | 32 | *(reserved — lapsed)* | — | Owner #4310 (successor of fork-era #4247) closed without merging; RESERVED, not reissuable | | 33 | *(reserved — lapsed)* | — | Owner #4311 (successor of fork-era #4256) closed without merging; RESERVED, not reissuable | 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 e37407399e7..cbfa08d6540 100644 --- a/packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs +++ b/packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs @@ -227,7 +227,7 @@ pub unsafe extern "C" fn asset_lock_manager_catch_up_blocking( // Double-spend verdicts route through the typed conversion // so the host receives the real code. In practice that is // always the provisional ErrorAssetLockInputContested - // (48), which stops the wait but keeps the lock for a + // (48), which bounds the wait but keeps the lock for a // later retry: the resume never raises the terminal // ErrorAssetLockInputConflict (47), which stays reserved // for a finalized-ancestry proof the wallet cannot make. diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 4de1c57fedf..96a1cfe729f 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -453,25 +453,28 @@ pub enum PlatformWalletFFIResultCode { /// Maps `PlatformWalletError::AssetLockInputContested`. The double-spend /// screen's ONLY verdict: a confirmed transaction of this wallet - /// already spent one of the tracked lock's inputs, so the resume - /// stopped without a further broadcast or a wait that cannot return. - /// PROVISIONAL — the wallet cannot prove the spender's block is on the - /// finalized branch (see [`Self::ErrorAssetLockInputConflict`] (47), - /// the reserved terminal form), so this is what a chainlocked-looking - /// spender reports too. + /// already spent one of the tracked lock's inputs. The resume still + /// ran — the sighting bounds the proof wait instead of replacing it, + /// so the lock was (re-)broadcast and waited on, and this is what that + /// bounded wait expired with. PROVISIONAL — the wallet cannot prove the + /// spender's block is on the finalized branch (see + /// [`Self::ErrorAssetLockInputConflict`] (47), the reserved terminal + /// form), so this is what a chainlocked-looking spender reports too. /// /// NOT a discard licence. The host keeps the tracked lock and retries /// later (next launch, or after the next chainlock) — but note that a /// chainlock does NOT upgrade this to 47 today; what a retry can /// resolve is the other direction, a reorg dropping the sibling so the - /// next resume proceeds normally. A conflict that persists across - /// sessions is in practice permanent, and a host may reasonably stop - /// retrying and offer the user a discard — that is host policy, which - /// this code does not grant and does not withhold funds for: the + /// next resume proceeds normally. Nor does repetition license a + /// discard: a conflict that persists across sessions still proves + /// nothing about finalized ancestry — the sighting can be a block + /// record restored from a previous session whose block was reorganized + /// out while the host was offline. Only code 47, or an independent + /// finalized-ancestry proof, authorises dropping the tracked state, + /// because a lock whose sibling sits on a losing branch can still be + /// replayed and confirm. Keeping the lock costs the host nothing: the /// conflicting spender is this wallet's own transaction, so the value - /// lives on in the sibling either way. What the SDK will not do is - /// authorise the discard on this evidence, because a lock whose - /// sibling sits on a losing branch can still be replayed and confirm. + /// lives on in the sibling either way. /// /// Raised only on a positive detection; its ABSENCE is not a liveness /// signal. The wallet-side scan reads confirmed records still held in @@ -1441,6 +1444,46 @@ mod tests { assert_eq!(msg, rendered, "Display payload must survive verbatim"); } + /// Code 26 is a promise about cleanup, not about the broadcaster's + /// verdict: the row was untracked and the funding reservation released, + /// so a rebuild is safe. An asset-lock build whose rejection raced a + /// concurrent resume keeps both — the guard retains the advanced row and + /// the release is skipped — and reports the unknown outcome instead. The + /// two must never collapse to one code across the boundary: a host that + /// read 26 there would rebuild from other UTXOs and create a second asset + /// lock beside a transaction the advance says reached the network. + #[test] + fn a_retained_asset_lock_row_reports_the_unknown_outcome_not_the_rejection() { + let retained: PlatformWalletFFIResult = + PlatformWalletError::TransactionBroadcastUnconfirmed( + "asset lock 0000..:0 stays tracked and reserved: the broadcast was \ + rejected, but a concurrent resume had already advanced the row past \ + Built, so the transaction may be on the network" + .to_string(), + ) + .into(); + assert_eq!( + retained.code, + PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed, + "a rejection that released nothing must reach the host as code 20" + ); + + let cleaned_up: PlatformWalletFFIResult = + PlatformWalletError::TransactionBroadcast("bad-txns-inputs-missingorspent".to_string()) + .into(); + assert_eq!( + cleaned_up.code, + PlatformWalletFFIResultCode::ErrorTransactionBroadcastRejected, + "the untracked-and-released path keeps the safe-to-retry code 26" + ); + assert_ne!( + retained.code, cleaned_up.code, + "the retained-row and released-reservation outcomes must stay \ + distinguishable at the FFI boundary — code 26 licenses the rebuild \ + that the retained row makes unsafe" + ); + } + /// `AddressNonceMismatch` maps to the dedicated `ErrorAddressNonceMismatch` /// FFI code through the blanket `From` impl (the path identity /// `top_up_from_addresses` takes via `?`/`.into()`) rather than flattening diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index fe923d9a124..c1cec289755 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -773,7 +773,7 @@ fn catch_funding_panic( /// to substring-matching the Display text. /// - 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). What the +/// is where the double-spend screen actually fires). What the /// screen emits is always `ErrorAssetLockInputContested` (48), the /// provisional keep-and-retry verdict; the terminal /// `ErrorAssetLockInputConflict` (47) — the code that would authorise a @@ -2472,8 +2472,8 @@ mod tests { ); assert!(message_of(&mismatch).contains("is ineligible for")); - // The resume endpoint is where the pre-broadcast conflict screen - // fires, and it funnels through this same wrapper. The screen + // The resume endpoint is where the double-spend screen fires, and + // it funnels through this same wrapper. The screen // itself only ever raises the contested verdict below; the // terminal one is reserved, so it is constructed directly here to // pin that the reserved code would still cross typed. @@ -2504,7 +2504,7 @@ mod tests { ); // The verdict the screen actually emits rides the same wrapper - // under its own code: it stops the wait but must not surface as + // under its own code: it bounds the wait but must not surface as // the terminal, discard-licensing 47. let contested = map_asset_lock_funding_result( Err(PlatformWalletError::AssetLockInputContested { 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 59f271c870b..c49be7de1b7 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -468,8 +468,9 @@ pub struct UtxoRestoreEntryFFI { /// 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 +/// these leaves startup conflict detection blind, so a doomed resume +/// expires as an untyped proof-wait timeout instead of the typed +/// contested verdict. `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). /// diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index fe140676a49..e524f0425b6 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -340,11 +340,12 @@ pub enum PlatformWalletError { /// locks — has been confirmed for a long time. /// /// While the sibling stands, peers reject the lock as a double spend - /// and a proof wait would hang unboundedly (Core stopped sending BIP61 + /// and an unbounded proof wait would hang (Core stopped sending BIP61 /// `reject` by default in 0.17, so the drop is silent and looks - /// exactly like a slow network), so the resume stops here without a - /// further broadcast or wait — a `Broadcast`-status lock was already - /// sent on an earlier call. + /// exactly like a slow network). The sighting therefore bounds the + /// wait rather than replacing it: the resume still (re-)broadcasts and + /// still waits, and this is what the bounded wait expired with — a + /// `Broadcast`-status lock was also already sent on an earlier call. /// /// The verdict is PROVISIONAL and carries NO licence to discard the /// tracked lock. Keep the lock and retry later. Note what a retry can @@ -355,15 +356,17 @@ pub enum PlatformWalletError { /// other direction — a reorg drops the sibling and the resume proceeds /// normally. /// - /// In practice a conflict that persists across sessions is permanent, - /// and a host may well decide to stop retrying and drop the lock. That - /// is a host/user policy call, not something this error licenses: the - /// SDK does not authorise discarding tracked state on evidence this - /// weak. Either way no funds are lost — both signed transactions are - /// this wallet's own, so the value behind `input` lives on in - /// `spent_by` — but discarding a lock whose sibling turns out to sit - /// on a losing branch strands the credits of a lock a peer can still - /// replay. + /// A conflict that persists across sessions still proves nothing about + /// finalized ancestry: persistence is not finality, and the sighting + /// can be a block record the load path restored from a previous + /// session whose block was reorganized out while the wallet was + /// offline. So repetition never licenses a discard either — only + /// [`Self::AssetLockInputConflict`], or an independent + /// finalized-ancestry proof, authorises dropping the tracked state. + /// Discarding a lock whose sibling turns out to sit on a losing branch + /// strands the credits of a lock a peer can still replay. No funds move + /// while the lock is kept: both signed transactions are this wallet's + /// own, so the value behind `input` lives on in `spent_by`. /// /// Raising this error is a definite verdict about the CONFLICT; NOT /// raising it proves nothing — see the detection helper in diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 12cc75208e3..8bd88d8359d 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -99,30 +99,18 @@ impl PlatformWalletManager

{ core_balance.immature(), core_balance.locked(), ); - let mut platform_info = PlatformWalletInfo { + let 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 preserves that - // provenance for the session (the double-spend screen no - // longer classifies on it; see `restored_record_txids`), 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(); + // Seed the double-spend screen's session memory from the + // freshly restored state: it closes the race where SPV's + // chainlock dispatcher promotion-evicts a restored spender + // before the first catch-up resume ever reads it. crate::wallet::asset_lock::sync::recovery::seed_observed_input_conflicts( &platform_info, ); diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 4f1f1a8b028..6eb5300b1f2 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -360,7 +360,6 @@ 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 f36896e1ec5..503acffe3f4 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -251,7 +251,6 @@ 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(), @@ -323,7 +322,6 @@ 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(), @@ -427,7 +425,6 @@ 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(), @@ -505,7 +502,6 @@ 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(), @@ -679,7 +675,6 @@ 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 691c902f835..0b213330ecc 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -427,7 +427,6 @@ 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/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index a8084c1fa21..169697fb426 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -960,6 +960,12 @@ impl AssetLockManager { // transaction — so at no point is the row resumable while its // inputs are re-spendable. A `MaybeSent` failure keeps both the // reservation and the resumable row. + // + // The error type follows the cleanup, never the broadcaster's + // verdict alone: the definite-rejection contract is only reported + // when the row was actually untracked AND its reservation + // released, because that contract is precisely the promise that + // both happened. if let Err(e) = self.broadcaster.broadcast(&tx).await { if matches!(e, crate::broadcaster::BroadcastError::Rejected { .. }) { let cs_untrack = self.untrack_asset_lock(&out_point).await; @@ -981,6 +987,34 @@ impl AssetLockManager { reservation_token, ) .await; + } else { + // The cleanup did not run, so the definite-rejection + // contract does not hold. `TransactionBroadcast` promises + // the caller that the row is gone, the inputs are free, + // and a rebuild is safe; here the advanced row is still + // tracked and resumable and its inputs are still + // reserved, so a caller honouring that promise would + // rebuild from other UTXOs and create a SECOND asset lock + // beside a transaction the advance says reached the + // network. The contract that matches what is actually + // true is the unknown outcome: do not retry, the row and + // its reservation are intact, resume the existing lock. + tracing::warn!( + %txid, + error = %e, + "asset lock broadcast was rejected, but a concurrent resume had \ + already advanced the row past Built; keeping the row and its \ + funding reservation and reporting an unknown outcome rather than \ + a definite rejection" + ); + return Err(PlatformWalletError::TransactionBroadcastUnconfirmed( + format!( + "asset lock {out_point} stays tracked and reserved: the \ + broadcast was rejected, but a concurrent resume had already \ + advanced the row past Built, so the transaction may be on \ + the network: {e}" + ), + )); } } return Err(e.into()); @@ -1684,6 +1718,12 @@ mod tests { /// window, the cleanup must keep the row (guard) AND keep the funding /// reservation (release gate) — otherwise the still-tracked transaction /// would be resumable while its inputs are re-spendable. + /// + /// The error must say the same thing the cleanup did. The definite + /// rejection promises a released reservation and a safe rebuild, and + /// neither holds on this branch: a caller acting on that promise builds + /// a second asset lock beside a transaction the advance says reached the + /// network. Only the unknown outcome describes what actually happened. #[tokio::test] async fn rejected_broadcast_racing_concurrent_resume_keeps_row_and_reservation() { let (wallet_manager, wallet_id, _balance, signer) = @@ -1717,8 +1757,13 @@ mod tests { ) .await; assert!( - matches!(result, Err(PlatformWalletError::TransactionBroadcast(_))), - "rejection should still surface, got {result:?}" + matches!( + result, + Err(PlatformWalletError::TransactionBroadcastUnconfirmed(_)) + ), + "a rejection whose cleanup released nothing must surface as the \ + unknown outcome, never as the definite rejection that promises a \ + released reservation and a safe rebuild, got {result:?}" ); // The concurrently-advanced row survives the cleanup… diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs index 3eb3d83b1cb..6d4b674d965 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs @@ -68,6 +68,17 @@ pub(in crate::wallet::asset_lock) fn funding_tx_record( account_index: u32, txid: &Txid, ) -> Option { + funding_accounts(accounts, account_index) + .find_map(|account| account.transactions().get(txid).cloned()) +} + +/// The account families a lock funded from `account_index` can have filed +/// its funding transaction under, in the order [`funding_tx_record`] +/// documents. +fn funding_accounts( + accounts: &key_wallet::account::ManagedAccountCollection, + account_index: u32, +) -> impl Iterator { let at_index = [ accounts.standard_bip44_accounts.get(&account_index), accounts.standard_bip32_accounts.get(&account_index), @@ -77,7 +88,59 @@ pub(in crate::wallet::asset_lock) fn funding_tx_record( .into_iter() .flatten() .chain(accounts.dashpay_receival_accounts.values()) - .find_map(|account| account.transactions().get(txid).cloned()) +} + +/// Whether any account family that could hold the funding transaction +/// reports `txid` as chainlock-finalized. +/// +/// This is the same finality question [`record_holds_local_finality`] asks, +/// for the record that is no longer there to ask it of. Under the default +/// `keep-finalized-transactions` configuration a chainlock promotion drops +/// the promoted record and keeps only its txid in the account's finalized +/// set, so from that moment on a lookup by record cannot see a finality the +/// wallet has already recorded — the txid set is the only place it survives. +/// Searched over the same families, in the same order, as +/// [`funding_tx_record`]. +pub(in crate::wallet::asset_lock) fn funding_tx_is_finalized( + accounts: &key_wallet::account::ManagedAccountCollection, + account_index: u32, + txid: &Txid, +) -> bool { + funding_accounts(accounts, account_index).any(|account| account.transaction_is_finalized(txid)) +} + +/// Whether `record` on its own already establishes local finality for a +/// funding transaction — the three record shapes [`AssetLockManager::wait_for_proof`] +/// turns into a proof, reduced to a yes/no. +/// +/// It exists so a caller holding a wallet read guard can ask the finality +/// question inside its own snapshot instead of taking a second read. The +/// answer must be read together with the rest of a decision that depends on +/// it; splitting the two reads lets finality land in between and be missed. +/// +/// `wallet_chain_lock_height` and `networks_match` come from the same +/// snapshot as `record`. They serve only the third shape — a record whose +/// own context is not yet promoted but whose block the wallet's applied +/// chainlock already buries — and carry the same chain-id refusal as the +/// proof builder: a `last_applied_chain_lock` persisted from a different +/// network says nothing about this record's block. +pub(in crate::wallet::asset_lock) fn record_holds_local_finality( + record: &TransactionRecord, + wallet_chain_lock_height: Option, + networks_match: bool, +) -> bool { + use key_wallet::transaction_checking::TransactionContext; + match &record.context { + TransactionContext::InstantSend(_) => true, + TransactionContext::InChainLockedBlock(_) => record.height().is_some(), + _ => { + networks_match + && matches!( + (wallet_chain_lock_height, record.height()), + (Some(chain_lock), Some(height)) if chain_lock >= height + ) + } + } } /// Variant of [`record_or_persister`] that swallows persister errors 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 7728bc75c09..64b6414c07b 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,7 +592,6 @@ 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 4947a7a0ba3..1da98ff46f7 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 @@ -200,10 +200,24 @@ impl AssetLockManager { /// 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::AssetLockInputContested`] instead of -/// (re-)broadcasting into that void. +/// since 0.17 — so while the spender stands the lock can neither be mined +/// nor IS-locked, and an unbounded proof wait on it never terminates. +/// Callers use a hit to BOUND that wait and, if it expires with the +/// conflict still standing, to report +/// [`PlatformWalletError::AssetLockInputContested`]. +/// +/// **A hit does not refuse the resume**, and must not be made to. Part of +/// the history this reads is rebuilt at load from persisted rows, and such +/// a record is never checked against the active chain: a wallet offline +/// while the spender's block was reorganized out restores the sighting all +/// the same, and nothing repairs it — key-wallet demotes a record only +/// when that transaction is re-observed, and a transaction absent from +/// both the replacement chain and every mempool never is. Short-circuiting +/// ahead of the (re-)broadcast and the proof wait on that evidence would +/// hand back the same verdict on every resume and every launch for a lock +/// that can in fact still confirm. Running the wait keeps the recovery +/// path open and gives live synchronization the window in which it can +/// retract the sighting. /// /// **The gate is `is_confirmed()`, deliberately not `is_chain_locked()`.** /// Under the default `keep-finalized-transactions = OFF` build, @@ -218,12 +232,13 @@ impl AssetLockManager { /// here.** Reporting the conflict on a confirmed 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 +/// carries is already the wallet's, and bounding the doomed wait costs /// nothing — the lock is unrelayable for as long as the sibling stands. -/// What no evidence reachable here justifies is *discarding* the tracked -/// lock: the sibling's 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. +/// What no evidence reachable here justifies is *refusing* the resume +/// outright (see above) or *discarding* the tracked lock: the sibling's +/// 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. /// /// A terminal verdict would need proof that the spender's block is an /// ancestor of the FINALIZED chain, and the wallet layer cannot produce @@ -349,6 +364,25 @@ fn first_confirmed_input_conflict( None } +/// The deadline a proof wait runs under once +/// [`first_confirmed_input_conflict`] has reported a sighting. +/// +/// The sighting cannot refuse the wait (see that function), but it does cap +/// it at [`UNCONFIRMED_BROADCAST_PROOF_TIMEOUT`] — shortening a caller's +/// longer budget as well as replacing an unbounded one. While the spender +/// stands the lock is unrelayable, so a caller's extra minutes only delay +/// the verdict a host needs in order to explain the stalled funding +/// attempt. Nothing is given up on the recovery path the cap exists to keep +/// open: a proof that has already arrived resolves on `wait_for_proof`'s +/// first pass, straight from the record, before any deadline is consulted. +fn conflict_capped_proof_wait(timeout: Option) -> Option { + Some( + timeout.map_or(UNCONFIRMED_BROADCAST_PROOF_TIMEOUT, |caller| { + caller.min(UNCONFIRMED_BROADCAST_PROOF_TIMEOUT) + }), + ) +} + /// Seed the double-spend screen's session memory from freshly restored /// state, before any resume runs. /// @@ -400,6 +434,160 @@ pub(crate) fn seed_observed_input_conflicts(info: &PlatformWalletInfo) { } impl AssetLockManager { + /// Re-run the double-spend screen once a proof wait has expired, and + /// render a conflict that still stands as the verdict explaining that + /// expiry. + /// + /// Reading the screen AFTER the wait rather than before it is what + /// keeps a restored sighting from condemning a lock forever. Restored + /// block records enter history from persisted rows without ever being + /// checked against the active chain, and no event demotes one whose + /// block was reorganized out while the wallet was offline; a resume + /// that refused to broadcast or wait on that evidence would report the + /// same conflict on every launch for the rest of the lock's life. + /// Running the wait first gives live synchronization its window: a + /// proof that arrives settles the lock and this is never reached, and + /// a sighting live history has retracted meanwhile leaves the caller's + /// pre-existing outcome alone. + /// + /// The verdict is always the provisional + /// [`PlatformWalletError::AssetLockInputContested`] — see + /// [`first_confirmed_input_conflict`] for why nothing reachable here + /// can prove the spender's block is on the finalized branch. + /// + /// A proof still outranks the sighting at this point, exactly as it + /// does during the wait. The wait's expiry is a deadline race, not a + /// statement about the lock: `wait_for_proof` re-reads the record at + /// the top of each iteration and then selects between the notification + /// and the deadline, so finality becoming visible while the deadline + /// branch wins arrives one instant too late to be seen there — and a + /// concurrent resume under a longer budget can equally have attached + /// the proof and advanced the row while this one was expiring. Either + /// way a sibling is still sitting in history, so the scan alone would + /// answer "contested" for a lock that is already settled. A + /// zero-duration proof probe runs first (a single local + /// record/persister check — the same one the rejected-re-broadcast + /// paths use, and no network wait), because only it can reach the + /// persister for a record the in-memory map has evicted. + /// + /// Everything the verdict is then built from is read from ONE wallet + /// snapshot: the funding transaction's own finality — its record, or the + /// finalized-txid set a promotion that evicted the record leaves behind — + /// the tracked row's proof and status, and the sibling scan. Splitting + /// those reads is what let the race back in — finality landing after the + /// probe's own lookup but before a later read left a locally final lock + /// reported as contested, because the later read consulted only the row, + /// which a record-only finality never advances. Holding one guard makes + /// the three answers describe the same instant. + /// + /// Every suppression leaves the caller's error alone rather than + /// replacing it: the row is left where it was, so the next resume + /// returns the proof from the record on `wait_for_proof`'s first pass. + async fn input_conflict_verdict(&self, out_point: &OutPoint) -> Option { + if self + .wait_for_proof(out_point, Some(Duration::ZERO)) + .await + .is_ok() + { + tracing::info!( + outpoint = %out_point, + "resume_asset_lock: the proof wait expired, but the local record \ + already holds finality — the input conflict is not the \ + explanation and no contested verdict is reported" + ); + return None; + } + + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id)?; + let lock = info.tracked_asset_locks.get(out_point)?; + // Finality that landed during the probe. The record can carry it + // while the row still says `Broadcast` with no proof attached — + // `LockNotifyHandler` wakes waiters without advancing the status — + // so the row check below cannot stand in for this one. + // + // Asked of the record AND of the account's finalized-txid set, + // because the promotion that grants finality is also what takes the + // record away: under the default `keep-finalized-transactions` + // configuration a chainlocked record is evicted and only its txid + // retained, so a chainlock landing between the probe and this + // snapshot leaves nothing for a record lookup to find. Reading only + // the record there condemned a locally final lock on the strength of + // a sibling the promotion had not touched. + { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + let wallet_chain_lock_height = info + .core_wallet + .metadata + .last_applied_chain_lock + .as_ref() + .map(|chain_lock| chain_lock.block_height); + let networks_match = info.network() == self.sdk.network; + let record_is_final = super::proof::funding_tx_record( + &info.core_wallet.accounts, + lock.account_index, + &out_point.txid, + ) + .is_some_and(|record| { + super::proof::record_holds_local_finality( + &record, + wallet_chain_lock_height, + networks_match, + ) + }); + if record_is_final + || super::proof::funding_tx_is_finalized( + &info.core_wallet.accounts, + lock.account_index, + &out_point.txid, + ) + { + tracing::info!( + outpoint = %out_point, + "resume_asset_lock: the proof wait expired, but the funding \ + transaction reached finality while it was being read — the \ + input conflict is not the explanation and no contested \ + verdict is reported" + ); + return None; + } + } + // The screen speaks only for the two proof-less statuses + // (`resume_asset_lock` screens on exactly those), and a row carrying + // a proof is settled by evidence this scan cannot outrank — see the + // status match in `resume_asset_lock`. + if lock.proof.is_some() + || !matches!( + lock.status, + AssetLockStatus::Built | AssetLockStatus::Broadcast + ) + { + tracing::info!( + outpoint = %out_point, + status = ?lock.status, + has_proof = lock.proof.is_some(), + "resume_asset_lock: the proof wait expired, but the tracked lock \ + has since been settled — no contested verdict is reported" + ); + return None; + } + let (input, spent_by, height) = first_confirmed_input_conflict(info, lock)?; + tracing::warn!( + outpoint = %out_point, + %input, + %spent_by, + ?height, + "resume_asset_lock: the proof wait expired with the input conflict \ + still standing; reporting it as the provisional verdict" + ); + Some(PlatformWalletError::AssetLockInputContested { + out_point: *out_point, + input, + spent_by, + height, + }) + } + /// Resume a tracked asset lock from whatever stage it's at. /// /// Looks up the tracked lock by `txid`, then: @@ -436,19 +624,27 @@ impl AssetLockManager { /// record; on the `Broadcast` arm it is reported as /// [`PlatformWalletError::TransactionBroadcastUnconfirmed`]. /// - /// A `Built` / `Broadcast` lock is first screened by - /// [`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. The hit ALWAYS raises - /// the provisional [`PlatformWalletError::AssetLockInputContested`], - /// which keeps the lock tracked for a later retry: stopping the doomed - /// broadcast-and-wait is all this evidence supports. Proving the - /// spender's block is on the finalized branch would take an ancestry - /// predicate the wallet does not have — chainlock contexts and applied - /// chainlock heights are promotion artifacts, not ancestry proofs — so - /// the terminal [`PlatformWalletError::AssetLockInputConflict`] is - /// never constructed here. A conflict that persists across sessions is - /// in practice permanent, but acting on that (discarding the tracked + /// A `Built` / `Broadcast` lock is screened by + /// [`first_confirmed_input_conflict`], and a hit never refuses the + /// resume. It withdraws the unbounded wait — a double spend no peer + /// relays is not evidence the transaction is on the network — and the + /// verdict is read afterwards by [`Self::input_conflict_verdict`]: a + /// proof that arrives during the bounded wait settles the lock + /// normally, and a conflict the wait did not clear is reported as the + /// provisional [`PlatformWalletError::AssetLockInputContested`], which + /// keeps the lock tracked for a later retry. Blocking the + /// broadcast-and-wait outright is what this evidence does NOT support: + /// the screen also reads records the load path rebuilt from persisted + /// rows, which no event can demote once their block has been + /// reorganized out behind an offline wallet, so a pre-emptive refusal + /// would return the same verdict on every launch for a lock that is + /// free to confirm. Proving the spender's block is on the finalized + /// branch would take an ancestry predicate the wallet does not have — + /// chainlock contexts and applied chainlock heights are promotion + /// artifacts, not ancestry proofs — so the terminal + /// [`PlatformWalletError::AssetLockInputConflict`] is never + /// constructed here. A conflict that persists across sessions is in + /// practice permanent, but acting on that (discarding the tracked /// lock) is host and user policy; the SDK does not license it /// unilaterally on this evidence. The screen is one-sided — read its /// docs before treating a clean pass as evidence the lock is alive. @@ -506,38 +702,36 @@ impl AssetLockManager { ) }; - // 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 explain the stalled funding attempt instead of showing a - // spinner forever. + // A sighting does NOT stop the resume. It cannot: the screen reads + // records the load path rebuilt from persisted rows, and a block + // record restored that way has never been checked against the + // active chain. A wallet that was offline while the spender's block + // was reorganized out restores the sighting anyway, and nothing + // repairs it — key-wallet demotes a record only when that same + // transaction is re-observed, and a transaction absent from both + // the replacement chain and every mempool is never observed again. + // Refusing the (re-)broadcast and the proof wait on that evidence + // would return the same verdict on every resume and every launch + // for a lock that is in fact free to confirm. + // + // So the sighting only bounds the wait (below): it withdraws the + // unbounded one, because it is not evidence that the transaction is + // on the network. The verdict is read afterwards, from whatever + // live synchronization left behind while the wait ran — a proof + // that arrives settles the lock outright, and a conflict the wait + // did not clear becomes the error explaining the expiry. 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 cannot \ - confirm while that spender stands" + "resume_asset_lock: asset lock double-spends an outpoint a \ + confirmed transaction of this wallet already consumed; \ + resuming under a bounded wait rather than refusing, since \ + the sighting may be restored evidence no live event can \ + retract" ); - // One verdict, always provisional. The screen proves the - // broadcast-and-wait is doomed while the spender stands; it - // cannot prove the spender's block is on the finalized branch - // (see `first_confirmed_input_conflict` for why every finality - // signal reachable from here is a promotion artifact). The - // terminal variant is the one that licenses discarding tracked - // state, and discarding a lock whose sibling turns out to sit - // on a losing branch would strand the credits of a lock a peer - // can still replay — so it is never raised here. - return Err(PlatformWalletError::AssetLockInputContested { - out_point: *out_point, - input, - spent_by, - height, - }); } // 2. Resume from the current status. @@ -545,7 +739,8 @@ impl AssetLockManager { AssetLockStatus::Built => { // Re-broadcast and wait for proof. // - // Only a DEFINITE rejection stops the resume. `MaybeSent` + // No verdict this broadcaster can return ends the resume by + // itself. `MaybeSent` // means the outcome is unknown — and for a lock stuck at // `Built` that is the expected answer when the app died // between a successful broadcast and this status advance: @@ -568,8 +763,39 @@ impl AssetLockManager { // failure into a permanent hang. Bound it, and translate the // expiry back into the `TransactionBroadcastUnconfirmed` the // caller used to get immediately. - let maybe_sent_reason = match self.broadcaster.broadcast(&tx).await { - Ok(_) => None, + // + // A DEFINITE `Rejected` is scoped to the attempt that + // produced it, exactly as on the `Broadcast` arm below: with + // the production `SpvBroadcaster` it is reachable only from + // an unstarted client and dash-spv's zero-connected-peers + // check, so it means "*this* send never left the device". It + // is not a statement about the row. A lock sits at `Built` + // after a SUCCESSFUL broadcast too — the app killed between + // the send and the status advance is the very case this arm + // exists for — so the original may be in a mempool or already + // mined, and the record may already carry its proof. The + // rejection is therefore handled here rather than returned: + // probe the local record once without waiting, and where a + // conflict was sighted go on to the bounded wait, which is + // the only path that can produce the sighting's verdict. + // + // What must NOT happen is the raw conversion. `Rejected` + // becomes `TransactionBroadcast`, the FFI's definite- + // rejection code 26, whose contract is that Core rejected the + // transaction, the inputs' reservation was released, and a + // rebuild is safe. Only the initial build path performs that + // untrack-and-release; the resume keeps both the row and its + // reservation, so a host honouring code 26 here would rebuild + // from other UTXOs and create a SECOND asset lock beside a + // possibly-live one. The non-terminal + // `TransactionBroadcastUnconfirmed` is the contract that + // matches what this arm actually knows: outcome unknown, + // inputs still reserved, do not retry. + let mut local_proof = None; + let mut maybe_sent_reason = None; + let mut undispatched = None; + match self.broadcaster.broadcast(&tx).await { + Ok(_) => {} Err(BroadcastError::MaybeSent { reason }) => { tracing::warn!( outpoint = %out_point, @@ -579,45 +805,153 @@ impl AssetLockManager { have rejected it — the broadcaster cannot tell); advancing to \ Broadcast and waiting for proof under a bounded timeout" ); - Some(reason) + maybe_sent_reason = Some(reason); } - Err(rejected) => return Err(rejected.into()), - }; - let cs = self - .advance_asset_lock_status(out_point, AssetLockStatus::Broadcast, None) - .await?; - self.queue_asset_lock_changeset(cs); - let proof = match (&maybe_sent_reason, timeout) { - // Ambiguous re-broadcast AND an unbounded wait: the only - // combination that can hang forever. Substitute the bound - // and translate its expiry back into the broadcast error - // the caller used to get immediately. - // - // Callers that passed their own timeout are left exactly - // as they were, `FinalityTimeout` and all — the shielded - // seed pool treats that error as a pacing signal and - // resumes the lock later, so re-typing it would break a - // working flow to fix an unrelated one. - (Some(reason), None) => { - match self - .wait_for_proof(out_point, Some(UNCONFIRMED_BROADCAST_PROOF_TIMEOUT)) - .await - { - Ok(proof) => proof, - Err(PlatformWalletError::FinalityTimeout(_)) => { + Err(rejected @ BroadcastError::Rejected { .. }) => { + match self.wait_for_proof(out_point, Some(Duration::ZERO)).await { + Ok(proof) => { + tracing::info!( + outpoint = %out_point, + error = %rejected, + "resume_asset_lock: re-broadcast of a Built lock was \ + rejected before dispatch, but the local record already \ + holds finality — completing the resume from the local \ + proof" + ); + local_proof = Some(proof); + } + Err(probe_err) => { + // No proof, and this attempt never left the + // device. Without a sighting there is nothing + // this call can still learn — the `Broadcast` + // arm returns the same unknown-outcome error + // here, and for the same reason. + if input_conflict.is_none() { + tracing::warn!( + outpoint = %out_point, + error = %rejected, + probe = %probe_err, + "resume_asset_lock: re-broadcast of a Built lock \ + was rejected before dispatch and no local proof \ + exists — this attempt proves nothing about an \ + earlier send; leaving the row tracked at Built \ + and failing the resume as an unknown outcome" + ); + return Err( + PlatformWalletError::TransactionBroadcastUnconfirmed( + format!( + "asset lock {out_point} remains tracked at \ + Built after the re-broadcast was rejected \ + before dispatch; an earlier broadcast may \ + still be on the network: {rejected}" + ), + ), + ); + } + tracing::warn!( + outpoint = %out_point, + error = %rejected, + probe = %probe_err, + "resume_asset_lock: re-broadcast of a Built lock was \ + rejected before dispatch with an input conflict \ + sighted; entering the bounded proof wait, since the \ + sighting bounds the wait rather than replacing it and \ + its verdict is only readable afterwards" + ); + undispatched = Some(rejected.to_string()); + } + } + } + } + let proof = if let Some(proof) = local_proof { + proof + } else { + // The status advance belongs to a send that actually + // dispatched. An attempt rejected before dispatch leaves + // the row exactly where it was, so the next resume + // re-sends the transaction instead of dropping into the + // `Broadcast` arm's wait for a send that never happened. + if undispatched.is_none() { + let cs = self + .advance_asset_lock_status(out_point, AssetLockStatus::Broadcast, None) + .await?; + self.queue_asset_lock_changeset(cs); + } + // An ambiguous re-broadcast and a conflict sighting both + // deny this call positive evidence that the transaction is + // on the network, and an unbounded wait without that + // evidence pins the host thread rather than merely delaying + // an answer. A clean broadcast with no sighting keeps the + // caller's `None` exactly as before. + let bounded = if input_conflict.is_some() { + conflict_capped_proof_wait(timeout) + } else if maybe_sent_reason.is_some() { + timeout.or(Some(UNCONFIRMED_BROADCAST_PROOF_TIMEOUT)) + } else { + timeout + }; + match self.wait_for_proof(out_point, bounded).await { + Ok(proof) => proof, + Err(expiry @ PlatformWalletError::FinalityTimeout(_)) => { + // The wait has now given live synchronization its + // window, so the screen is re-read and what it says + // NOW decides. A conflict it still reports is the + // honest explanation for the expiry; one it has + // retracted meanwhile leaves the pre-existing + // outcome untouched. + if let Some(contested) = self.input_conflict_verdict(out_point).await { + return Err(contested); + } + // A caller who chose its own bound is left exactly + // as it was, `FinalityTimeout` and all. Every + // re-typing below exists to keep an UNBOUNDED wait + // from hanging on a signal that cannot arrive, and + // that reason is absent the moment the caller + // named a deadline: the shielded seed pool reads + // `FinalityTimeout` as a pacing signal and resumes + // the lock later, so substituting a do-not-retry + // error for the bound it asked for would break a + // working flow to fix an unrelated one. The check + // comes FIRST because both translations below are + // reachable under an explicit timeout. + if timeout.is_some() { + return Err(expiry); + } + // The wait ran on a send that never dispatched, so + // the outcome of any earlier one is still unknown + // and the row is still tracked and reserved. That + // is the unknown-outcome contract, never the + // definite-rejection code the raw conversion would + // have produced. + if let Some(rejection) = undispatched { return Err(PlatformWalletError::TransactionBroadcastUnconfirmed( format!( + "asset lock {} remains tracked at Built after the \ + re-broadcast was rejected before dispatch and no \ + InstantSend/ChainLock proof arrived within {:?}; an \ + earlier broadcast may still be on the network: {}", + out_point, UNCONFIRMED_BROADCAST_PROOF_TIMEOUT, rejection + ), + )); + } + // Ambiguous re-broadcast AND an unbounded wait: the + // only combination that can hang forever. Its expiry + // is translated back into the broadcast error the + // caller used to get immediately. + return Err(match &maybe_sent_reason { + Some(reason) => { + PlatformWalletError::TransactionBroadcastUnconfirmed(format!( "asset lock {} was re-broadcast with an unknown \ outcome and no InstantSend/ChainLock proof arrived \ within {:?}: {}", out_point, UNCONFIRMED_BROADCAST_PROOF_TIMEOUT, reason - ), - )) - } - Err(e) => return Err(e), + )) + } + None => expiry, + }); } + Err(e) => return Err(e), } - _ => self.wait_for_proof(out_point, timeout).await?, }; self.validate_or_upgrade_proof(proof, account_index, out_point) .await? @@ -775,10 +1109,24 @@ impl AssetLockManager { let proof = if let Some(proof) = local_proof { proof } else { - let bounded = timeout.or(Some(UNCONFIRMED_BROADCAST_PROOF_TIMEOUT)); + let bounded = if input_conflict.is_some() { + conflict_capped_proof_wait(timeout) + } else { + timeout.or(Some(UNCONFIRMED_BROADCAST_PROOF_TIMEOUT)) + }; match self.wait_for_proof(out_point, bounded).await { Ok(proof) => proof, - Err(PlatformWalletError::FinalityTimeout(_)) if timeout.is_none() => { + Err(expiry @ PlatformWalletError::FinalityTimeout(_)) => { + // Same reading as the `Built` arm: the wait gave + // live synchronization its window, so the screen + // is re-read afterwards and a conflict that still + // stands explains the expiry. + if let Some(contested) = self.input_conflict_verdict(out_point).await { + return Err(contested); + } + if timeout.is_some() { + return Err(expiry); + } let reason = format!( "asset lock {} is tracked as broadcast but no \ InstantSend/ChainLock proof arrived within {:?}; the \ @@ -1052,9 +1400,15 @@ mod tests { /// Captures the exact transaction passed to the resumed `Built` branch. /// Recovery must never rebuild a replacement transaction/outpoint. + /// + /// `reject` reproduces the production `SpvBroadcaster` before it is + /// connected: the send is recorded (it was attempted) and then refused + /// with the DEFINITE `Rejected`, which on that broadcaster means only + /// that this attempt never left the device. #[derive(Default)] struct RecordingBroadcaster { transactions: Mutex>, + reject: bool, } #[async_trait] @@ -1064,6 +1418,11 @@ mod tests { .lock() .expect("recording broadcaster mutex") .push(transaction.clone()); + if self.reject { + return Err(BroadcastError::Rejected { + reason: "simulated pre-send rejection".to_string(), + }); + } Ok(transaction.txid()) } } @@ -1093,6 +1452,108 @@ mod tests { } } + /// Persistence stub that mutates the wallet from inside the N-th + /// persister-backed record lookup, placing a change at an interleaving + /// no test can otherwise reach. + /// + /// `wait_for_proof` reads the in-memory record under the wallet lock, + /// DROPS that guard, and only then falls back to the persister — so a + /// mutation applied here lands strictly after the probe's own read and + /// strictly before whatever the caller reads next. That is the exact gap + /// the verdict has to survive: finality (or a retraction) that becomes + /// visible between the probe and the snapshot the verdict is built from. + /// + /// The lookup is synchronous, so the wallet is taken with `try_write` — + /// sound precisely because no wallet guard is held across it. + struct InterleavedPersistence { + wallet_manager: Arc>>, + wallet_id: WalletId, + /// Zero-based index of the persister lookup to mutate on. + target_lookup: usize, + lookups: std::sync::atomic::AtomicUsize, + #[allow(clippy::type_complexity)] + mutate: Mutex>>, + } + + impl InterleavedPersistence { + fn new( + wallet_manager: Arc>>, + wallet_id: WalletId, + target_lookup: usize, + mutate: impl FnOnce(&mut PlatformWalletInfo) + Send + 'static, + ) -> Self { + Self { + wallet_manager, + wallet_id, + target_lookup, + lookups: std::sync::atomic::AtomicUsize::new(0), + mutate: Mutex::new(Some(Box::new(mutate))), + } + } + + fn fired(&self) -> bool { + self.mutate.lock().expect("interleave mutex").is_none() + } + } + + impl PlatformWalletPersistence for InterleavedPersistence { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + + fn get_core_tx_record( + &self, + _wallet_id: WalletId, + _txid: &Txid, + ) -> Result, PersistenceError> { + let lookup = self + .lookups + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if lookup == self.target_lookup { + if let Some(mutate) = self.mutate.lock().expect("interleave mutex").take() { + let mut wm = loop { + if let Ok(guard) = self.wallet_manager.try_write() { + break guard; + } + std::thread::yield_now(); + }; + mutate( + wm.get_wallet_info_mut(&self.wallet_id) + .expect("wallet must remain registered"), + ); + } + } + // This backend keeps no records of its own; the mutation above is + // its whole purpose. + Ok(None) + } + } + + /// File `record` into the wallet's BIP44 account 0 — the synchronous + /// twin of `ConflictFixture::file_record`, for use from inside + /// [`InterleavedPersistence`]. + fn insert_record(info: &mut PlatformWalletInfo, record: TransactionRecord) { + info.core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("funded fixture has BIP44 account 0") + .transactions_mut() + .insert(record.txid, record); + } + #[tokio::test] async fn built_resume_rebroadcasts_original_and_typed_failures_do_not_broadcast() { let (wallet_manager, wallet_id, _balance, signer) = @@ -1309,21 +1770,42 @@ mod tests { ); } - /// A DEFINITE rejection is the opposite case and must keep failing the - /// resume: nothing is on the network, so no proof can ever arrive, and - /// the lock stays at `Built` for a later retry to re-send. + /// A DEFINITE rejection also fails the resume, but as an UNKNOWN + /// outcome — never as the definite-rejection contract. + /// + /// `Rejected` is scoped to the attempt that produced it: the production + /// `SpvBroadcaster` reaches it only from an unstarted client and the + /// zero-connected-peers check, so it means "this send never left the + /// device". A row sits at `Built` after a SUCCESSFUL broadcast too (the + /// app killed between the send and the status advance), so an earlier + /// send may be in a mempool or already mined. `TransactionBroadcast` — + /// the FFI's code 26 — would tell the host that Core rejected the + /// transaction, that its inputs' reservation was released and that a + /// rebuild is safe; the resume releases nothing and keeps the row, so a + /// host honouring that would build a SECOND asset lock beside a + /// possibly-live one. Only the initial build path, which does untrack + /// and release, may emit 26. #[tokio::test] - async fn built_resume_still_fails_on_a_definite_rejection() { + async fn built_resume_of_a_rejected_rebroadcast_reports_an_unknown_outcome() { let (error, status) = resume_built_lock_with(Arc::new(AlwaysRejectedBroadcaster)).await; assert!( - matches!(error, PlatformWalletError::TransactionBroadcast(_)), - "a definite rejection must surface as a broadcast failure: {error:?}" + !matches!(error, PlatformWalletError::TransactionBroadcast(_)), + "a re-broadcast that never left the device is not evidence that an earlier \ + send was rejected, so it must not claim the definite-rejection contract \ + while the row and its reservation are kept: {error:?}" + ); + assert!( + matches!( + error, + PlatformWalletError::TransactionBroadcastUnconfirmed(_) + ), + "a rejected re-broadcast must fail the resume as an unknown outcome: {error:?}" ); assert_eq!( status, AssetLockStatus::Built, - "a tx that never entered the network must stay resumable at Built" + "a send that never dispatched must leave the row resumable at Built" ); } @@ -1421,7 +1903,6 @@ 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(), @@ -1488,13 +1969,49 @@ mod tests { broadcaster: Arc, transaction: Transaction, out_point: OutPoint, + /// Kept so a test can attempt a REBUILD: the wallet's whole balance + /// rides on the one UTXO this fixture's transaction spends, so a + /// rebuild that fails at input selection is direct proof the funding + /// reservation is still held. + signer: crate::test_support::WalletSigner, } impl ConflictFixture { async fn new() -> Self { + Self::with_broadcaster(RecordingBroadcaster::default()).await + } + + /// The same fixture whose (re-)broadcast is refused before dispatch, + /// the way an app-launch catch-up resume meets an SPV client that + /// has not connected yet. + async fn rejecting() -> Self { + Self::with_broadcaster(RecordingBroadcaster { + reject: true, + ..Default::default() + }) + .await + } + + async fn with_broadcaster(broadcaster: RecordingBroadcaster) -> Self { + Self::with_broadcaster_and_persistence(broadcaster, |_, _| { + Arc::new(RecordingPersistence::default()) + }) + .await + } + + /// The fixture wired to a caller-supplied persistence backend, built + /// from the wallet handle so an interleaving stub can reach back into + /// the wallet it is going to mutate. + async fn with_broadcaster_and_persistence( + broadcaster: RecordingBroadcaster, + persistence: impl FnOnce( + Arc>>, + WalletId, + ) -> Arc, + ) -> Self { let (wallet_manager, wallet_id, _generation, signer) = funded_wallet_manager(StandardAccountType::BIP44Account).await; - let broadcaster = Arc::new(RecordingBroadcaster::default()); + let broadcaster = Arc::new(broadcaster); let sdk = Arc::new( dash_sdk::SdkBuilder::new_mock() .with_network(Network::Testnet) @@ -1507,7 +2024,10 @@ mod tests { wallet_id, Arc::new(Notify::new()), Arc::clone(&broadcaster), - WalletPersister::new(wallet_id, Arc::new(RecordingPersistence::default())), + WalletPersister::new( + wallet_id, + persistence(Arc::clone(&wallet_manager), wallet_id), + ), ); let (transaction, _path) = manager .build_asset_lock_transaction( @@ -1527,9 +2047,26 @@ mod tests { broadcaster, transaction, out_point, + signer, } } + /// Attempt a fresh asset-lock build over the same wallet. The funded + /// fixture holds exactly one spendable UTXO, so this can only succeed + /// once that UTXO's reservation has been released. + async fn rebuild(&self) -> Result<(), PlatformWalletError> { + self.manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 5, + &self.signer, + ) + .await + .map(|_| ()) + } + /// 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 { @@ -1599,16 +2136,6 @@ 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) { @@ -1699,10 +2226,10 @@ mod tests { )) } - /// The base case: a confirmed sibling spending the lock's input stops - /// the resume before it broadcasts or waits, and it does so through the - /// contested variant — the screen's one verdict, which carries no - /// licence to discard the tracked lock. + /// The base case: a confirmed sibling spending the lock's input makes + /// the resume end in the contested variant — the screen's one verdict, + /// which carries no licence to discard the tracked lock. It arrives + /// after the resume has run its course, not instead of it. #[tokio::test] async fn broadcast_resume_reports_a_contested_input_for_a_merely_in_block_spender() { let fixture = ConflictFixture::new().await; @@ -1735,39 +2262,565 @@ mod tests { } assert_eq!( fixture.broadcast_count(), - 0, - "the screen must short-circuit ahead of the defensive re-broadcast" + 1, + "the screen must not refuse the defensive re-broadcast — the \ + sighting may be restored evidence nothing can retract" ); } - /// Restored provenance changes nothing, in either direction. It used - /// to gate a height-only promotion to the terminal verdict; that - /// promotion is gone (and the guard was bypassable anyway — under - /// `keep-finalized-transactions` the key wallet height-mutates a stale - /// restored `InBlock` record straight to `InChainLockedBlock`), so a - /// restored spender under a covering boundary reports exactly what - /// every other sighting does. + /// Regression: a conflict sighting must never cost the lock a proof + /// that has already arrived. + /// + /// The screen reads history the load path rebuilt from persisted rows, + /// and such a record is never checked against the active chain — a + /// wallet offline while the spender's block was reorganized out + /// restores the sighting all the same, and no later event demotes a + /// transaction that is absent from both the replacement chain and every + /// mempool. Refusing to broadcast or wait on that evidence returned the + /// contested verdict on every resume and every launch for a lock whose + /// own funding transaction was sitting in history chain-locked, ready + /// to settle. The resume must run and take the proof. #[tokio::test] - async fn a_restored_spender_below_the_boundary_stays_provisional() { + async fn a_standing_conflict_never_costs_the_lock_a_proof_that_has_arrived() { let fixture = ConflictFixture::new().await; fixture.track(AssetLockStatus::Broadcast, None).await; + // The unreconciled sighting. + fixture + .file_record(record_for( + transaction_spending(fixture.funded_input()), + confirmed_at(1_234), + )) + .await; + // ... and the lock's own funding transaction, chain-locked: the + // proof `wait_for_proof` resolves from without touching the network. + fixture + .file_record(record_for( + fixture.transaction.clone(), + chain_locked_at(1_500), + )) + .await; + + let (proof, _path) = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect("a lock whose own record is chain-locked must resume despite the sighting"); + match proof { + dpp::prelude::AssetLockProof::Chain(chain) => { + assert_eq!(chain.out_point, fixture.out_point); + assert_eq!(chain.core_chain_locked_height, 1_500); + } + other => panic!("expected a ChainAssetLockProof, got {other:?}"), + } + } + + /// Regression: a `Built` row whose re-broadcast is refused before it + /// dispatches must still take a proof that has already arrived. + /// + /// This is the launch catch-up shape: `catchUpStuckAssetLocks` resumes + /// a restored row before SPV connects, so the re-broadcast draws the + /// DEFINITE `Rejected` (unstarted client / zero peers), while history + /// carries both a restored spender of the lock's input and the lock's + /// own chain-locked funding record. Returning the rejection there + /// skipped the record entirely — an already-final lock failed on every + /// launch until connectivity returned, and it failed as the FFI's code + /// 26, whose released-reservation contract this path does not honour. + #[tokio::test] + async fn a_rejected_rebroadcast_of_a_conflicted_built_lock_still_takes_an_arrived_proof() { + let fixture = ConflictFixture::rejecting().await; + fixture.track(AssetLockStatus::Built, None).await; + + // The unreconciled sighting... + fixture + .file_record(record_for( + transaction_spending(fixture.funded_input()), + confirmed_at(1_234), + )) + .await; + // ... and the lock's own funding transaction, chain-locked. + fixture + .file_record(record_for( + fixture.transaction.clone(), + chain_locked_at(1_500), + )) + .await; + + let (proof, _path) = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect( + "a locally-proven lock must resume despite a rejected re-broadcast and a \ + standing sighting", + ); + match proof { + dpp::prelude::AssetLockProof::Chain(chain) => { + assert_eq!(chain.core_chain_locked_height, 1_500); + } + other => panic!("expected a ChainAssetLockProof, got {other:?}"), + } + assert_eq!( + fixture + .wallet_manager + .read() + .await + .get_wallet_info(&fixture.wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&fixture.out_point) + .expect("lock stays tracked") + .status, + AssetLockStatus::ChainLocked, + "the resume must advance the row exactly as a waited-for proof would" + ); + // Completing from a local proof releases nothing either: the lock is + // settled and its inputs stay spent by it, so a rebuild must still + // find no candidates. + let rebuild = fixture.rebuild().await; + assert!( + matches!( + rebuild, + Err(PlatformWalletError::AssetLockInsufficientFunds { available: 0, .. }) + ), + "a settled lock must keep its funding reservation, got {rebuild:?}" + ); + } + + /// The same shape without a proof: the rejection must not pre-empt the + /// bounded wait the sighting exists to bound, and its expiry must be + /// reported as the provisional contested verdict — never as the + /// definite-rejection code 26, which promises a released reservation + /// this path does not release. + #[tokio::test] + async fn a_rejected_rebroadcast_of_a_conflicted_built_lock_reports_the_contested_verdict() { + let fixture = ConflictFixture::rejecting().await; + fixture.track(AssetLockStatus::Built, 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"); + .expect_err("no proof exists, so the bounded wait must expire"); + match error { + PlatformWalletError::AssetLockInputContested { + 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!( + "a rejected re-broadcast must not pre-empt the sighting's bounded wait, \ + and must never surface the released-reservation contract, got {other:?}" + ), + } + assert_eq!( + fixture.broadcast_count(), + 1, + "the re-broadcast is still attempted — the sighting bounds the resume, it \ + never refuses it" + ); + assert_eq!( + fixture + .wallet_manager + .read() + .await + .get_wallet_info(&fixture.wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&fixture.out_point) + .expect("lock stays tracked") + .status, + AssetLockStatus::Built, + "a send that never dispatched must not advance the row — the next resume \ + re-sends rather than waiting on a broadcast that never happened" + ); + // The retained status is only half the invariant. A row that is + // resumable while its inputs are re-spendable is exactly the state + // the release gate exists to prevent, and only a rebuild attempt can + // prove the reservation is still held: the fixture's whole balance + // rides on the one UTXO this lock spends, so a released reservation + // would let this build succeed and put a second asset lock on the + // wire beside the first. + let rebuild = fixture.rebuild().await; assert!( - matches!(error, PlatformWalletError::AssetLockInputContested { .. }), - "a restored spender under the boundary must stay provisional, got {error:?}" + matches!( + rebuild, + Err(PlatformWalletError::AssetLockInsufficientFunds { available: 0, .. }) + ), + "the funding reservation must still be held after a rejected re-broadcast, \ + leaving a rebuild with zero spendable candidates, got {rebuild:?}" + ); + } + + /// Regression: finality that lands BETWEEN the verdict's proof probe and + /// the snapshot the verdict is built from must still outrank the + /// standing sighting. + /// + /// This is the narrowest interleaving the decision has to survive, and + /// the whole resume is driven through the public entry point to reach + /// it. The funding record is filed from inside the probe's own persister + /// lookup — after that probe has already read the in-memory map and + /// missed, before anything else is read. Finality arriving that way + /// never touches the tracked row (`LockNotifyHandler` wakes waiters + /// without advancing a status), so the row still says `Broadcast` with + /// no proof and the sibling is still sitting in history: reading the + /// record and the row in two separate snapshots reported a locally final + /// lock as contested. + #[tokio::test] + async fn finality_landing_between_the_probe_and_the_snapshot_outranks_the_conflict() { + let funding_tx = Mutex::new(None); + let interleave = Mutex::new(None); + let fixture = ConflictFixture::with_broadcaster_and_persistence( + RecordingBroadcaster::default(), + |wallet_manager, wallet_id| { + // The lock's own transaction is only known once the fixture + // has built it, so the stub reads it back out of the shared + // slot the fixture fills in below. + let built = Arc::new(Mutex::new(None::)); + let handle = Arc::clone(&built); + let stub = Arc::new(InterleavedPersistence::new( + wallet_manager, + wallet_id, + // Lookup 0 is the expiring proof wait's own miss; lookup + // 1 is the verdict's probe, the gap under test. + 1, + move |info| { + let transaction = handle + .lock() + .expect("built transaction slot") + .clone() + .expect("fixture files the transaction before resuming"); + insert_record(info, record_for(transaction, chain_locked_at(1_500))); + }, + )); + *funding_tx.lock().expect("slot") = Some(built); + *interleave.lock().expect("slot") = Some(Arc::clone(&stub)); + stub as Arc + }, + ) + .await; + let funding_tx = funding_tx.lock().expect("slot").take().expect("slot set"); + let interleave = interleave.lock().expect("slot").take().expect("slot set"); + *funding_tx.lock().expect("built transaction slot") = Some(fixture.transaction.clone()); + + fixture.track(AssetLockStatus::Broadcast, None).await; + fixture + .file_record(record_for( + transaction_spending(fixture.funded_input()), + confirmed_at(1_234), + )) + .await; + + let error = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("the proof arrives too late for this wait to return it"); + assert!( + interleave.fired(), + "the test proves nothing unless the finality actually landed inside the \ + verdict's probe" + ); + assert!( + matches!(error, PlatformWalletError::FinalityTimeout(_)), + "a lock whose own record reached finality during the probe must not be \ + reported as contested on the strength of a sibling still sitting in \ + history — the caller keeps its expiry and the next resume returns the \ + proof, got {error:?}" + ); + assert_eq!( + fixture + .wallet_manager + .read() + .await + .get_wallet_info(&fixture.wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&fixture.out_point) + .expect("lock stays tracked") + .status, + AssetLockStatus::Broadcast, + "suppressing the verdict must leave the row exactly where it was" + ); + } + + /// Regression: a ChainLock that lands in that same gap must outrank the + /// sighting even though the promotion it performs takes the funding + /// record away. + /// + /// Promotion is EVICTION under the default + /// `keep-finalized-transactions = OFF` build: `apply_chain_lock` drops + /// the record it has just promoted and keeps only its txid in the + /// account's finalized set. A snapshot that asked the record alone + /// therefore questioned the one place finality no longer lives, and + /// condemned a locally final lock on the strength of a sibling the same + /// chainlock never buried. The chainlock here is applied for real — + /// the funding transaction is filed in a block below the lock height and + /// promoted by the wallet's own pass — so the eviction is the wallet's, + /// not the test's. The sibling sits in a HIGHER block on purpose: the + /// same pass must leave it standing, or there would be no conflict left + /// to suppress and the test would pass on any code. + #[tokio::test] + async fn a_chainlock_evicting_the_funding_record_mid_verdict_outranks_the_conflict() { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let funding_tx = Mutex::new(None); + let interleave = Mutex::new(None); + let fixture = ConflictFixture::with_broadcaster_and_persistence( + RecordingBroadcaster::default(), + |wallet_manager, wallet_id| { + let built = Arc::new(Mutex::new(None::)); + let handle = Arc::clone(&built); + let stub = Arc::new(InterleavedPersistence::new( + wallet_manager, + wallet_id, + // Lookup 0 is the expiring proof wait's own miss; lookup + // 1 is the verdict's probe, the gap under test. + 1, + move |info| { + let transaction = handle + .lock() + .expect("built transaction slot") + .clone() + .expect("fixture files the transaction before resuming"); + insert_record(info, record_for(transaction, confirmed_at(1_200))); + info.apply_chain_lock(ChainLock { + block_height: 1_220, + block_hash: BlockHash::all_zeros(), + signature: BLSSignature::from([0u8; 96]), + }); + }, + )); + *funding_tx.lock().expect("slot") = Some(built); + *interleave.lock().expect("slot") = Some(Arc::clone(&stub)); + stub as Arc + }, + ) + .await; + let funding_tx = funding_tx.lock().expect("slot").take().expect("slot set"); + let interleave = interleave.lock().expect("slot").take().expect("slot set"); + *funding_tx.lock().expect("built transaction slot") = Some(fixture.transaction.clone()); + + fixture.track(AssetLockStatus::Broadcast, None).await; + fixture + .file_record(record_for( + transaction_spending(fixture.funded_input()), + confirmed_at(1_234), + )) + .await; + + let error = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("the chainlock arrives too late for this wait to return a proof"); + assert!( + interleave.fired(), + "the test proves nothing unless the chainlock actually landed inside the \ + verdict's probe" + ); + { + let wm = fixture.wallet_manager.read().await; + let info = wm + .get_wallet_info(&fixture.wallet_id) + .expect("wallet must remain registered"); + let account = info + .core_wallet + .accounts + .standard_bip44_accounts + .get(&0) + .expect("funded fixture has BIP44 account 0"); + #[cfg(not(feature = "keep-finalized-transactions"))] + assert!( + !account.transactions().contains_key(&fixture.out_point.txid), + "the interleaving under test is promotion-EVICTION: a record still \ + sitting in the map would leave the resident-record check able to \ + answer, and the eviction path untested" + ); + assert!( + account.transaction_is_finalized(&fixture.out_point.txid), + "the finalized-txid set is where the promotion leaves the finality, \ + and the only trace of it the verdict can still read" + ); + assert!( + info.core_wallet + .transaction_history() + .iter() + .any(|record| record.txid != fixture.out_point.txid && record.is_confirmed()), + "the sibling must survive the same chainlock, or there is no \ + contested verdict left for the finality to suppress" + ); + } + assert!( + matches!(error, PlatformWalletError::FinalityTimeout(_)), + "a lock whose funding transaction was chainlocked during the probe must \ + not be reported as contested because the promotion evicted the record \ + that said so — the caller keeps its expiry and the next resume returns \ + the proof, got {error:?}" + ); + assert_eq!( + fixture + .wallet_manager + .read() + .await + .get_wallet_info(&fixture.wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&fixture.out_point) + .expect("lock stays tracked") + .status, + AssetLockStatus::Broadcast, + "suppressing the verdict must leave the row exactly where it was" + ); + } + + /// The same precedence against a concurrent resume rather than against + /// the record: two overlapping resumes run under different budgets, and + /// the longer one attaches the proof and advances the row while the + /// shorter one is expiring. Driven end to end, with the settling landing + /// in the same probe-to-snapshot gap. + #[tokio::test] + async fn a_concurrent_resume_that_settled_the_lock_suppresses_the_contested_verdict() { + let interleave = Mutex::new(None); + let fixture = ConflictFixture::with_broadcaster_and_persistence( + RecordingBroadcaster::default(), + |wallet_manager, wallet_id| { + let stub = Arc::new(InterleavedPersistence::new( + wallet_manager, + wallet_id, + 1, + |info| { + let (out_point, lock) = info + .tracked_asset_locks + .iter_mut() + .next() + .map(|(out_point, lock)| (*out_point, lock)) + .expect("the lock under resume is tracked"); + lock.status = AssetLockStatus::ChainLocked; + lock.proof = Some(dpp::prelude::AssetLockProof::Chain( + dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof { + core_chain_locked_height: 1_500, + out_point, + }, + )); + }, + )); + *interleave.lock().expect("slot") = Some(Arc::clone(&stub)); + stub as Arc + }, + ) + .await; + let interleave = interleave.lock().expect("slot").take().expect("slot set"); + + fixture.track(AssetLockStatus::Broadcast, None).await; + fixture + .file_record(record_for( + transaction_spending(fixture.funded_input()), + confirmed_at(1_234), + )) + .await; + + let error = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("this resume's own wait found no proof before its deadline"); + assert!( + interleave.fired(), + "the concurrent resume must actually have settled the row mid-verdict" + ); + assert!( + matches!(error, PlatformWalletError::FinalityTimeout(_)), + "a row another resume already settled must not be re-condemned by the \ + screen, got {error:?}" + ); + } + + /// Regression: a caller that named its own deadline keeps + /// `FinalityTimeout` on the rejected-`Built` path too. + /// + /// The re-typings on that path exist to stop an UNBOUNDED wait hanging + /// on a signal that cannot arrive; a caller that supplied a bound never + /// had that problem. The shielded seed pool reads `FinalityTimeout` as a + /// pacing signal and resumes the lock later, so handing it a + /// do-not-retry error instead — and one quoting the 180-second policy + /// cap rather than the bound it asked for — silently drops the lock out + /// of that flow. The conflict retracts mid-verdict so the contested + /// verdict is out of the way and the undispatched translation is the + /// only thing left that could win. + #[tokio::test] + async fn a_rejected_built_rebroadcast_keeps_an_explicit_timeout_as_finality_timeout() { + let spender = Mutex::new(None); + let interleave = Mutex::new(None); + let fixture = ConflictFixture::with_broadcaster_and_persistence( + RecordingBroadcaster { + reject: true, + ..Default::default() + }, + |wallet_manager, wallet_id| { + let demoted = Arc::new(Mutex::new(None::)); + let handle = Arc::clone(&demoted); + let stub = Arc::new(InterleavedPersistence::new( + wallet_manager, + wallet_id, + // Lookup 0 is the rejection's own local-proof probe, + // lookup 1 the expiring wait, lookup 2 the verdict's + // probe — the gap the retraction has to land in. + 2, + move |info| { + let transaction = handle + .lock() + .expect("spender slot") + .clone() + .expect("fixture files the spender before resuming"); + // A reorg drops the block; the record survives, + // demoted, which retracts the remembered sighting. + insert_record(info, record_for(transaction, TransactionContext::Mempool)); + }, + )); + *spender.lock().expect("slot") = Some(demoted); + *interleave.lock().expect("slot") = Some(Arc::clone(&stub)); + stub as Arc + }, + ) + .await; + let spender_slot = spender.lock().expect("slot").take().expect("slot set"); + let interleave = interleave.lock().expect("slot").take().expect("slot set"); + + fixture.track(AssetLockStatus::Built, None).await; + let spender = transaction_spending(fixture.funded_input()); + *spender_slot.lock().expect("spender slot") = Some(spender.clone()); + 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("no proof exists, so the caller's bounded wait must expire"); + assert!( + interleave.fired(), + "the conflict must actually have retracted mid-verdict, or the contested \ + verdict would be doing the work this test is about" + ); + assert!( + matches!(error, PlatformWalletError::FinalityTimeout(_)), + "a caller-selected timeout must expire as FinalityTimeout, not be retyped \ + into the unknown-outcome contract that quotes the policy cap it never \ + asked for, got {error:?}" ); } @@ -2054,8 +3107,8 @@ mod tests { ); assert_eq!( fixture.broadcast_count(), - 0, - "the screen must short-circuit ahead of the defensive re-broadcast" + 1, + "not even a chain-locked-looking spender may refuse the resume" ); } 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 7c818baef9a..6b703822361 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,7 +3828,6 @@ 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 2961d4a83d7..c1ae06ba180 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, BTreeSet}; +use std::collections::BTreeMap; use std::ops::{Deref, DerefMut}; use std::sync::Arc; @@ -288,19 +288,6 @@ 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. Session state, never - /// persisted. - /// - /// The double-spend screen no longer reads this: it used to withhold - /// height-only chainlock promotion from restored records, but that - /// guard was unsound (under `keep-finalized-transactions` the key - /// wallet height-mutates a stale restored `InBlock` record straight to - /// `InChainLockedBlock`, bypassing it) and the screen now emits one - /// provenance-independent verdict. Kept as load-path provenance for a - /// future consumer that can pair it with a real finalized-ancestry - /// predicate; classification must not be rebuilt on it alone. - 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 f397539cc92..49ed828d228 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -41,7 +41,6 @@ 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(), } } @@ -56,7 +55,6 @@ 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(), } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index a2166bcb0f6..4d882e2ae55 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -1833,13 +1833,21 @@ public class PlatformWalletManager: ObservableObject { // 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 stuck. `lastError` is the manager's one - // public error surface; a UI that explains the - // stalled lock and its pending retry (48 — the only - // verdict emitted; 47 stays reserved) reads it from - // here. + // can legitimately sit in its proof wait, and the + // host must not wait on that drain to learn a lock + // is stuck. `lastError` is the manager's one public + // error surface; a UI that explains the stalled lock + // and its pending retry (48 — the only verdict + // emitted; 47 stays reserved) reads it from here. + // + // The verdict arrives at the END of its own lock's + // bounded wait, not ahead of it: Rust deliberately + // does not refuse a resume on a conflict sighting, + // because the sighting can be a restored block record + // that no live event will ever retract, and refusing + // would strand a lock that is free to confirm. The + // wait it runs under is capped below this call's 300s + // ceiling for exactly that case. while let outcome = await group.next() { if !published, let verdict = outcome { published = true diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 9c273c76f02..5b07dcbda8c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -203,14 +203,18 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// lock's inputs — 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 cannot confirm while - /// that spender stands and its proof wait would hang; the screen stops the - /// resume before it broadcasts again or enters the wait (a - /// `Broadcast`-status lock was sent on an earlier call). This is the ONLY - /// double-spend code the SDK emits, and it is PROVISIONAL: no discard - /// licence, keep the lock tracked and retry later. A later chainlock does - /// not upgrade it to 47 today; what a retry can resolve is a reorg dropping - /// the sibling. Its absence is not proof of liveness — the Rust-side scan - /// cannot see conflicts whose spender was already pruned. + /// that spender stands and an unbounded proof wait would hang. The resume + /// still runs — the sighting bounds that wait rather than replacing it, so + /// the lock was (re-)broadcast and waited on (a `Broadcast`-status lock + /// was also sent on an earlier call) — and this is what the bounded wait + /// expired with. This is the ONLY double-spend code the SDK emits, and it + /// is PROVISIONAL: no discard licence, keep the lock tracked and retry + /// later. A later chainlock does not upgrade it to 47 today; what a retry + /// can resolve is a reorg dropping the sibling. Repetition licenses + /// nothing either — a conflict that persists across sessions still does + /// not prove finalized ancestry. Its absence is not proof of liveness — + /// the Rust-side scan cannot see conflicts whose spender was already + /// pruned. case errorAssetLockInputContested = 48 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the @@ -533,20 +537,22 @@ public enum PlatformWalletError: LocalizedError { case assetLockInputConflict(String) /// The tracked asset lock spends an outpoint a different, /// already-confirmed transaction of this wallet spent first, so no peer - /// will relay it while that spender stands. 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. + /// will relay it while that spender stands. The resume still ran — it + /// re-broadcast and waited for a proof under a bounded timeout, and this + /// is what the wait expired with. A `Broadcast`-status lock was also + /// already sent on an earlier call, so this is not a claim that nothing + /// ever reached the network. /// /// The only double-spend verdict the SDK emits, and PROVISIONAL: the - /// tracked lock must NOT be discarded on this error. Retry on a later - /// launch; a chainlock over the sibling does not upgrade this to - /// `assetLockInputConflict` today, while a reorg that drops the sibling - /// clears it. A conflict that survives session after session is in - /// practice permanent, and a host may decide to stop retrying and offer - /// the user a discard — that is a host/user policy call this error does - /// not make, and it is fund-safe either way because the confirmed spender - /// is this wallet's own transaction. + /// tracked lock must NOT be discarded on this error. A conflict that + /// persists across sessions still does not prove finalized ancestry — + /// the sighting can even be a block record restored from a previous + /// session whose block was reorganized out while the wallet was offline. + /// Keep the tracked lock and continue treating this result as retryable; + /// only `assetLockInputConflict`, or an independent finalized-ancestry + /// proof, may authorize discarding it. No funds move either way: the + /// confirmed spender is this wallet's own transaction, so the value + /// behind the contested input lives on in it. 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 From 9d5c0e20a7e48621f7c66712a878ff49591aa310 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sun, 30 Aug 2026 23:01:27 +0700 Subject: [PATCH 14/15] fix(platform-wallet): initialize observed_input_conflicts in the folded-row test The #4438 test initializer merged from v4.2-dev predates this branch's observed_input_conflicts field; the PR merge target did not compile. Co-Authored-By: Claude Fable 5 --- packages/rs-platform-wallet/src/changeset/core_bridge.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 1e5092a3a12..8ae94aabe99 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -2309,6 +2309,7 @@ mod contact_watch_only_projection_tests { identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + observed_input_conflicts: Default::default(), }; let mut wm = WalletManager::::new(dashcore::Network::Testnet); let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); From 717510c72b10b6289f501273020a44ec7b01ee40 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 31 Aug 2026 08:07:27 +0700 Subject: [PATCH 15/15] fix(platform-wallet): bound every resumed asset lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A successful `Built` resume could wait forever. The double-spend screen runs once, before the re-broadcast. A sibling that is only in a mempool at that instant is not a verdict — either transaction can still win — so `input_conflict` is `None` and, with a production `timeout == None`, the proof wait ran unbounded. If that sibling confirmed afterwards the tracked lock became impossible to confirm, and nothing inside the wait could notice: `wait_for_proof` wakes on lock events and re-reads only the tracked funding transaction, never re-running the screen. Under the FFI's `runtime().block_on(...)` that is a permanently pinned host thread. Every proof-waiting arm now runs under the 180s `UNCONFIRMED_BROADCAST_PROOF_TIMEOUT` when the caller declines to name a bound; a sighting still shortens it. An accepted re-broadcast establishes that the transaction reached the network, never that it can still confirm, so it no longer buys an unbounded wait. The bound costs nothing: the row is left at `Broadcast`, so a proof landing after the expiry is returned by the very next resume straight from the record. The expiry is reported as the non-terminal `TransactionBroadcastUnconfirmed`, the same contract the `Broadcast` arm returns from the identical position. FFI docs updated. Reorg demotion is documented, not attempted. The screen's session-memory branch has always assumed that an unconfirmed re-observation demotes a record whose block was reorged away. Nothing performs that demotion: key-wallet's `check_core_transaction` returns early for a transaction it already holds when the incoming context is unconfirmed, so an `InBlock` record reads as confirmed for the rest of the wallet's life and a lost spender keeps reporting a conflict (code 48) on every resume and every launch. An earlier revision of this commit reconciled that at the `PlatformWalletInfo::check_core_transaction` seam. Review found the seam is the wrong home for it. A plain-mempool demotion never reaches durable persistence — the manager emits updated records only alongside an InstantSend lock, so the host's mirror restores the stale `InBlock` state at the next launch. The SPV broadcaster injects its own defensive re-broadcast into the local mempool pipeline, which re-enters as a plain mempool sighting and would demote a still-canonical record, costing it the height a later chainlock promotes by. And a record demoted alone desyncs from the received UTXOs' confirmed flags and the balances derived from them: record, UTXO and balance have to move together, which only key-wallet owns. That reconciliation is reverted here, and the delegation at the seam is byte-for- byte what it was before. What lands instead is the truth, in the two places that asserted the opposite. The screen's comments now say the demotion does not happen, and that a reorged-out sibling leaves the provisional verdict standing — bounded by the resume's proof-wait backstop, not freed by a retraction. The reorg test drives the real checker and pins that the record is NOT demoted and the verdict stands. It fails the day key-wallet starts demoting, which is when it should be rewritten into the retraction assertion it replaces. Tests, red before the corresponding change and green after: a_sibling_confirming_after_the_snapshot_still_ends_the_resume ✖ Elapsed (the wait outlived a 600s virtual-time bound) → ✔ contested an_accepted_rebroadcast_still_ends_a_boundless_resume ✖ Elapsed → ✔ TransactionBroadcastUnconfirmed, row still Broadcast a_reorged_out_spender_still_contests_the_lock (replaces a_reorg_demoted_spender_retracts_the_remembered_verdict) ✖ Some((false, None)) against the reverted seam reconciliation → ✔ Some((true, Some(1234))): the record keeps the block the chain dropped, and the resume still reports it That last one is a pin of current behaviour, so its red/green runs the other way: it fails against the demoting code it replaces and passes against what ships. The test it replaces hand-filed a demoted record instead of driving `check_core_transaction`, which is why it passed against a pipeline that never demotes. Verified: platform-wallet --lib (909 default / 1078 shielded, one pre-existing unrelated failure in shield_input_selection_tests present on the unmodified branch), platform-wallet-ffi (305 default / 326 shielded), clippy -D warnings and rustfmt clean on both crates in both feature combinations. Co-Authored-By: Claude Opus 5 --- .../src/asset_lock/sync.rs | 84 ++-- .../src/wallet/asset_lock/sync/recovery.rs | 400 ++++++++++++++---- 2 files changed, 346 insertions(+), 138 deletions(-) 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 cbfa08d6540..9bc819585cd 100644 --- a/packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs +++ b/packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs @@ -53,26 +53,22 @@ fn parse_outpoint(txid: *const [u8; 32], vout: u32) -> dashcore::OutPoint { /// /// `timeout_secs == 0` does **not** request an unbounded wait — it /// declines to specify one, and `resume_asset_lock` then applies the -/// recovery policy's own state-dependent default: +/// recovery policy's own default: the 180s +/// `UNCONFIRMED_BROADCAST_PROOF_TIMEOUT` (sized to comfortably cover a +/// ~2.5min ChainLock) on every arm that waits for a proof — a `Built` +/// re-broadcast whatever the broadcaster answered, a `Broadcast` row, +/// and the defensive proof-less `RecoveredFromChain` fallback alike. /// -/// - An ambiguous `Built` re-broadcast (the broadcaster reports -/// `MaybeSent` for an accepted and a rejected transaction alike), -/// a `Broadcast` row, and the defensive proof-less -/// `RecoveredFromChain` fallback all substitute the 180s -/// `UNCONFIRMED_BROADCAST_PROOF_TIMEOUT` bound (sized to -/// comfortably cover a ~2.5min ChainLock): none of them can -/// establish that the transaction is live on the network, and -/// waiting without a bound on that signal is a `Notify` loop with -/// no terminating event — under the `runtime().block_on(...)` -/// below it pins the calling host thread permanently rather than -/// merely delaying an answer. -/// - The one exception: a `Built` re-broadcast the broadcaster -/// positively ACCEPTED (`Ok`) keeps the unbounded wait — the same -/// positive-evidence wait the initial funding path performs after -/// its own successful broadcast. The proof arrives with the -/// transaction's ChainLock (~2.5min) in normal operation, but the -/// wait is not time-bounded: a caller that needs a hard upper -/// bound on this thread must pass a non-zero `timeout_secs`. +/// A resume cannot gather evidence that rules out a wait which never +/// ends. Even a positively ACCEPTED (`Ok`) re-broadcast only +/// establishes that the transaction reached the network: a sibling +/// spending the same outpoint may confirm the instant afterwards, and +/// from then on no proof for this transaction can arrive. The wait +/// cannot see that happen — it wakes on lock events and re-reads the +/// tracked funding transaction only — so an unbounded wait is a +/// `Notify` loop with no terminating event, which under the +/// `runtime().block_on(...)` below pins the calling host thread +/// permanently rather than merely delaying an answer. /// /// Expiry is non-destructive: the tracked row keeps its status, so a /// proof arriving afterwards is returned by the very next resume @@ -100,12 +96,9 @@ pub unsafe extern "C" fn asset_lock_manager_resume( let out_point = parse_outpoint(txid, vout); // `timeout_secs == 0` declines to specify a bound. `resume_asset_lock` - // reads the resulting `None` as "apply the recovery policy's - // state-dependent default": the 180s - // `UNCONFIRMED_BROADCAST_PROOF_TIMEOUT` on every proof-waiting arm - // except a `Built` re-broadcast the broadcaster positively accepted, - // which keeps the unbounded initial-funding wait. See this - // function's `# Timeouts` section. + // reads the resulting `None` as "apply the recovery policy's default": + // the 180s `UNCONFIRMED_BROADCAST_PROOF_TIMEOUT` on every proof-waiting + // arm. See this function's `# Timeouts` section. let timeout = (timeout_secs != 0).then(|| Duration::from_secs(timeout_secs)); let option = ASSET_LOCK_MANAGER_STORAGE.with_item(handle, |manager| { @@ -146,28 +139,22 @@ pub unsafe extern "C" fn asset_lock_manager_resume( /// /// Identical contract to [`asset_lock_manager_resume`], which this /// delegates to: `timeout_secs == 0` selects the recovery policy's -/// state-dependent default rather than an unbounded wait. That -/// default is the 180s `UNCONFIRMED_BROADCAST_PROOF_TIMEOUT` on every -/// arm that waits for a proof without positive evidence the -/// transaction is on the network — an ambiguous `Built` re-broadcast, -/// a `Broadcast` row, the defensive proof-less `RecoveredFromChain` -/// fallback. The one exception is a `Built` re-broadcast the -/// broadcaster positively accepted (`Ok`): that arm keeps the -/// unbounded initial-funding wait, so the thread is parked until the -/// accepted transaction's proof arrives (its ChainLock, ~2.5min in -/// normal operation) rather than for a fixed bound. Pass a non-zero -/// `timeout_secs` for a hard upper bound. +/// default rather than an unbounded wait. That default is the 180s +/// `UNCONFIRMED_BROADCAST_PROOF_TIMEOUT`, and it applies to every arm +/// that waits for a proof — a `Built` re-broadcast whatever the +/// broadcaster answered, a `Broadcast` row, the defensive proof-less +/// `RecoveredFromChain` fallback. Pass a non-zero `timeout_secs` for +/// a different upper bound. /// /// That policy is what makes this entry point safe to fan out at /// launch. The catch-up sweep starts one call per stuck lock; when -/// zero meant "wait forever" on EVERY waiting arm, a device that was -/// offline (or an SPV session that never connected) turned each of -/// those into a permanently parked worker thread. An unconnected or -/// undeliverable broadcast can only take the bounded arms now (a -/// broadcaster that never dispatched reports `Rejected` / -/// `MaybeSent`, not `Ok`), expiry simply ends the pass, leaving the -/// row tracked and resumable, and the next sweep picks up a proof -/// that landed in between straight from the record. +/// zero meant "wait forever", a device that was offline (or an SPV +/// session that never connected), and equally a lock whose outpoint a +/// sibling transaction had already taken, turned each of those into a +/// permanently parked worker thread. Every arm is bounded now: expiry +/// simply ends the pass, leaving the row tracked and resumable, and +/// the next sweep picks up a proof that landed in between straight +/// from the record. #[no_mangle] pub unsafe extern "C" fn asset_lock_manager_catch_up_blocking( handle: Handle, @@ -179,12 +166,9 @@ pub unsafe extern "C" fn asset_lock_manager_catch_up_blocking( let out_point = parse_outpoint(txid, vout); // `timeout_secs == 0` declines to specify a bound. `resume_asset_lock` - // reads the resulting `None` as "apply the recovery policy's - // state-dependent default": the 180s - // `UNCONFIRMED_BROADCAST_PROOF_TIMEOUT` on every proof-waiting arm - // except a `Built` re-broadcast the broadcaster positively accepted, - // which keeps the unbounded initial-funding wait. See this - // function's `# Timeouts` section. + // reads the resulting `None` as "apply the recovery policy's default": + // the 180s `UNCONFIRMED_BROADCAST_PROOF_TIMEOUT` on every proof-waiting + // arm. See this function's `# Timeouts` section. let timeout = (timeout_secs != 0).then(|| Duration::from_secs(timeout_secs)); tracing::info!( 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 1da98ff46f7..e0a88a2e6f0 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 @@ -210,14 +210,15 @@ impl AssetLockManager { /// the history this reads is rebuilt at load from persisted rows, and such /// a record is never checked against the active chain: a wallet offline /// while the spender's block was reorganized out restores the sighting all -/// the same, and nothing repairs it — key-wallet demotes a record only -/// when that transaction is re-observed, and a transaction absent from -/// both the replacement chain and every mempool never is. Short-circuiting +/// the same, and nothing repairs it — key-wallet keeps an existing +/// confirmed record even when the transaction is re-observed unconfirmed +/// (reconciling record, UTXOs and balances together is key-wallet-boundary +/// work that has not landed). Short-circuiting /// ahead of the (re-)broadcast and the proof wait on that evidence would /// hand back the same verdict on every resume and every launch for a lock /// that can in fact still confirm. Running the wait keeps the recovery -/// path open and gives live synchronization the window in which it can -/// retract the sighting. +/// path open and gives an arriving proof its window to settle the lock +/// outright. /// /// **The gate is `is_confirmed()`, deliberately not `is_chain_locked()`.** /// Under the default `keep-finalized-transactions = OFF` build, @@ -285,11 +286,13 @@ fn first_confirmed_input_conflict( // 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 - // 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. + // catch-up and the live session — and `apply_chain_lock` keeps them + // honest in the strengthening direction, promoting a record when a + // chainlock buries its block. Retraction is the direction nothing + // performs yet; see the memory branch below. An earlier revision + // carried a separate load-time snapshot map instead, holding copies + // that not even a chainlock could promote, so its verdicts could not + // resolve at all. if let Some(hit) = history .iter() .filter(|record| record.txid != lock_txid && record.is_confirmed()) @@ -332,6 +335,16 @@ fn first_confirmed_input_conflict( // wait pointless. The eviction attests a height-based promotion, // not finalized ancestry, so the verdict it feeds stays the // provisional one — as it does everywhere else here. + // + // The first case is aspirational today: no live pipeline performs that + // demotion. The wallet's transaction checker only ever strengthens a + // record's context, so a record filed `InBlock` still reads as + // confirmed once its block is reorged away, and demoting it here alone + // would desync it from the received UTXOs and balances that only the + // key-wallet boundary owning all three can move with it. Until that + // reconciliation exists upstream, a reorged-out sibling leaves the + // provisional verdict standing — the lock it contests is bounded by + // the resume's proof-wait backstop, not freed by a retraction. let Ok(mut cache) = info.observed_input_conflicts.lock() else { return None; }; @@ -368,11 +381,10 @@ fn first_confirmed_input_conflict( /// [`first_confirmed_input_conflict`] has reported a sighting. /// /// The sighting cannot refuse the wait (see that function), but it does cap -/// it at [`UNCONFIRMED_BROADCAST_PROOF_TIMEOUT`] — shortening a caller's -/// longer budget as well as replacing an unbounded one. While the spender -/// stands the lock is unrelayable, so a caller's extra minutes only delay -/// the verdict a host needs in order to explain the stalled funding -/// attempt. Nothing is given up on the recovery path the cap exists to keep +/// it at [`UNCONFIRMED_BROADCAST_PROOF_TIMEOUT`], shortening a caller's +/// longer budget down to the policy's own. While the spender stands the +/// lock is unrelayable, so a caller's extra minutes only delay the verdict +/// a host needs in order to explain the stalled funding attempt. Nothing is given up on the recovery path the cap exists to keep /// open: a proof that has already arrived resolves on `wait_for_proof`'s /// first pass, straight from the record, before any deadline is consulted. fn conflict_capped_proof_wait(timeout: Option) -> Option { @@ -445,10 +457,9 @@ impl AssetLockManager { /// block was reorganized out while the wallet was offline; a resume /// that refused to broadcast or wait on that evidence would report the /// same conflict on every launch for the rest of the lock's life. - /// Running the wait first gives live synchronization its window: a - /// proof that arrives settles the lock and this is never reached, and - /// a sighting live history has retracted meanwhile leaves the caller's - /// pre-existing outcome alone. + /// Running the wait first gives an arriving proof its window: a proof + /// settles the lock and this is never reached; a sighting that still + /// stands yields only this provisional verdict, never a terminal one. /// /// The verdict is always the provisional /// [`PlatformWalletError::AssetLockInputContested`] — see @@ -612,25 +623,32 @@ impl AssetLockManager { /// `ChainLocked` the proof already exists and no wait happens, so the /// value is moot. /// - /// `None` requests an unbounded wait, and gets one **only** where this - /// call obtained positive evidence the transaction is on the network: - /// the `Built` arm whose re-broadcast returned `Ok`. Every other - /// proof-waiting path substitutes - /// [`UNCONFIRMED_BROADCAST_PROOF_TIMEOUT`], because the alternative is a - /// `Notify` loop that never terminates under the FFI's - /// `runtime().block_on(...)` — a permanently pinned host thread rather - /// than a late answer. Expiry leaves the tracked row untouched, so the - /// next resume picks up a proof that arrives later straight from the - /// record; on the `Broadcast` arm it is reported as + /// `None` does not request an unbounded wait — it declines to name a + /// bound, and every proof-waiting path then substitutes + /// [`UNCONFIRMED_BROADCAST_PROOF_TIMEOUT`]. No evidence a resume can + /// gather rules out a wait that can never end: even a re-broadcast the + /// broadcaster positively accepted only establishes that the + /// transaction reached the network, and a sibling spending the same + /// outpoint may confirm the instant afterwards, at which point no + /// proof for this transaction can ever arrive. The wait itself cannot + /// see that happen — it wakes on lock events and re-reads the tracked + /// funding transaction only — so an unbounded one is a `Notify` loop + /// with no terminating event, which under the FFI's + /// `runtime().block_on(...)` pins a host thread permanently rather + /// than merely delaying an answer. Expiry leaves the tracked row + /// untouched, so the next resume picks up a proof that arrives later + /// straight from the record; on the `Built` and `Broadcast` arms it is + /// reported as /// [`PlatformWalletError::TransactionBroadcastUnconfirmed`]. /// /// A `Built` / `Broadcast` lock is screened by /// [`first_confirmed_input_conflict`], and a hit never refuses the - /// resume. It withdraws the unbounded wait — a double spend no peer - /// relays is not evidence the transaction is on the network — and the - /// verdict is read afterwards by [`Self::input_conflict_verdict`]: a - /// proof that arrives during the bounded wait settles the lock - /// normally, and a conflict the wait did not clear is reported as the + /// resume. It caps the wait at the policy's own bound — a caller's + /// longer budget only delays a verdict a lock no peer will relay cannot + /// escape — and the verdict is read afterwards by + /// [`Self::input_conflict_verdict`]: a proof that arrives during the + /// bounded wait settles the lock normally, and a conflict the wait did + /// not clear is reported as the /// provisional [`PlatformWalletError::AssetLockInputContested`], which /// keeps the lock tracked for a later retry. Blocking the /// broadcast-and-wait outright is what this evidence does NOT support: @@ -707,19 +725,20 @@ impl AssetLockManager { // record restored that way has never been checked against the // active chain. A wallet that was offline while the spender's block // was reorganized out restores the sighting anyway, and nothing - // repairs it — key-wallet demotes a record only when that same - // transaction is re-observed, and a transaction absent from both - // the replacement chain and every mempool is never observed again. + // repairs it — key-wallet keeps an existing confirmed record even + // when that transaction is re-observed unconfirmed, so the stale + // sighting stands until record/UTXO/balance reconciliation lands + // at the key-wallet boundary that owns all three. // Refusing the (re-)broadcast and the proof wait on that evidence // would return the same verdict on every resume and every launch // for a lock that is in fact free to confirm. // - // So the sighting only bounds the wait (below): it withdraws the - // unbounded one, because it is not evidence that the transaction is - // on the network. The verdict is read afterwards, from whatever - // live synchronization left behind while the wait ran — a proof - // that arrives settles the lock outright, and a conflict the wait - // did not clear becomes the error explaining the expiry. + // So the sighting only caps the wait (below), shortening whatever + // budget the resume would otherwise have run under. The verdict is + // read afterwards, from whatever live synchronization left behind + // while the wait ran — a proof that arrives settles the lock + // outright, and a conflict the wait did not clear becomes the error + // explaining the expiry. if let Some((input, spent_by, height)) = input_conflict { tracing::warn!( outpoint = %out_point, @@ -757,12 +776,9 @@ impl AssetLockManager { // classifies every failure that way by construction, and the // SPV broadcaster only reaches `Rejected` on `NotConnected`. // So the advance above cannot be read as evidence the tx is - // live, and the proof wait that follows it must not be the - // unbounded one — at the `resume_asset_lock(.., None)` - // production call sites that would turn a prompt broadcast - // failure into a permanent hang. Bound it, and translate the - // expiry back into the `TransactionBroadcastUnconfirmed` the - // caller used to get immediately. + // live, and the expiry of the bounded wait that follows it is + // translated back into the `TransactionBroadcastUnconfirmed` + // the caller used to get immediately. // // A DEFINITE `Rejected` is scoped to the attempt that // produced it, exactly as on the `Broadcast` arm below: with @@ -877,18 +893,24 @@ impl AssetLockManager { .await?; self.queue_asset_lock_changeset(cs); } - // An ambiguous re-broadcast and a conflict sighting both - // deny this call positive evidence that the transaction is - // on the network, and an unbounded wait without that - // evidence pins the host thread rather than merely delaying - // an answer. A clean broadcast with no sighting keeps the - // caller's `None` exactly as before. + // Every resumed lock waits under a deadline, and a + // sighting only shortens it. An accepted re-broadcast is + // evidence the transaction reached the network, never + // evidence it can still confirm — a sibling spending the + // same outpoint may confirm at any point after the + // pre-broadcast screen ran, and from that moment no proof + // for this transaction can ever arrive. Nothing inside the + // wait would notice: it wakes on lock events and re-reads + // the tracked funding transaction only, so an unbounded + // wait started before that sibling confirmed never ends. + // The bound costs nothing — the row is left at `Broadcast`, + // so a proof that lands after the expiry is returned by the + // very next resume, straight from the record, without + // waiting at all. let bounded = if input_conflict.is_some() { conflict_capped_proof_wait(timeout) - } else if maybe_sent_reason.is_some() { - timeout.or(Some(UNCONFIRMED_BROADCAST_PROOF_TIMEOUT)) } else { - timeout + timeout.or(Some(UNCONFIRMED_BROADCAST_PROOF_TIMEOUT)) }; match self.wait_for_proof(out_point, bounded).await { Ok(proof) => proof, @@ -934,21 +956,29 @@ impl AssetLockManager { ), )); } - // Ambiguous re-broadcast AND an unbounded wait: the - // only combination that can hang forever. Its expiry - // is translated back into the broadcast error the - // caller used to get immediately. - return Err(match &maybe_sent_reason { - Some(reason) => { - PlatformWalletError::TransactionBroadcastUnconfirmed(format!( + // The caller declined to name a bound, so the + // expiry is this policy's own and says nothing + // about the row: the transaction was dispatched, + // it is still tracked and reserved, and only the + // proof is missing. That is the unknown-outcome + // contract, the same one the `Broadcast` arm + // returns from the identical position. + return Err(PlatformWalletError::TransactionBroadcastUnconfirmed( + match &maybe_sent_reason { + Some(reason) => format!( "asset lock {} was re-broadcast with an unknown \ outcome and no InstantSend/ChainLock proof arrived \ within {:?}: {}", out_point, UNCONFIRMED_BROADCAST_PROOF_TIMEOUT, reason - )) - } - None => expiry, - }); + ), + None => format!( + "asset lock {} was re-broadcast but no \ + InstantSend/ChainLock proof arrived within {:?}; \ + the lock remains tracked and resumable", + out_point, UNCONFIRMED_BROADCAST_PROOF_TIMEOUT + ), + }, + )); } Err(e) => return Err(e), } @@ -1370,6 +1400,7 @@ mod tests { TransactionDirection, TransactionRecord, }; use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; use key_wallet_manager::WalletManager; @@ -1974,6 +2005,10 @@ mod tests { /// rebuild that fails at input selection is direct proof the funding /// reservation is still held. signer: crate::test_support::WalletSigner, + /// The handle `SpvEventForwarder` fires on IS/ChainLock events, so + /// a test can wake an in-flight proof wait the way the live wallet + /// does. + lock_notify: Arc, } impl ConflictFixture { @@ -2018,11 +2053,12 @@ mod tests { .build() .expect("mock sdk"), ); + let lock_notify = Arc::new(Notify::new()); let manager = AssetLockManager::new( sdk, Arc::clone(&wallet_manager), wallet_id, - Arc::new(Notify::new()), + Arc::clone(&lock_notify), Arc::clone(&broadcaster), WalletPersister::new( wallet_id, @@ -2048,6 +2084,7 @@ mod tests { transaction, out_point, signer, + lock_notify, } } @@ -2136,6 +2173,26 @@ mod tests { .insert(record.txid, record); } + /// Route `tx` through the wallet's real transaction pipeline at + /// `context` — the same `check_core_transaction` path SPV drives, + /// relevance routing, record bookkeeping and all. `file_record` + /// bypasses that machinery, so only this can exercise how the + /// wallet reacts to an observation rather than to a record. + async fn observe(&self, tx: &Transaction, context: TransactionContext) { + let mut wm = self.wallet_manager.write().await; + wm.check_transaction_in_all_wallets(tx, context, true, true) + .await; + } + + /// Move the wallet's view of the chain to `height`, the way + /// processing a block does. + async fn advance_tip(&self, height: CoreBlockHeight) { + let mut wm = self.wallet_manager.write().await; + wm.get_wallet_info_mut(&self.wallet_id) + .expect("wallet must remain registered") + .update_last_processed_height(height); + } + /// 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) { @@ -2965,18 +3022,36 @@ mod tests { } } - /// 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. + /// KNOWN LIMITATION, pinned on purpose: an `InBlock` spender record + /// survives a later unconfirmed re-observation, so it keeps contesting + /// the lock. + /// + /// Both sightings here go through the wallet's real transaction + /// checker, the only path the live app ever drives: the spender is + /// observed in a block, the wallet tip advances past that height, and + /// the same transaction is then observed again with a plain mempool + /// context. The checker accepts a context change only in the + /// strengthening direction and returns early for a transaction it + /// already holds whenever the incoming context is unconfirmed, so the + /// record stays `InBlock` and the screen goes on reading it as a live + /// conflict. (This models the observations a reorg would produce, not + /// a full reorg: no block is removed and no replacement chain is + /// processed here.) + /// + /// The repair does not belong at this seam. A record demoted on its + /// own desyncs from the received UTXOs' confirmed flags and from the + /// balances derived from them, so record, UTXO and balance have to + /// move together at the key-wallet boundary that owns all three. Until + /// they do, a stale sighting does not free the lock; the resume's own + /// proof-wait backstop bounds it instead. #[tokio::test] - async fn a_reorg_demoted_spender_retracts_the_remembered_verdict() { + async fn an_in_block_spender_record_survives_an_unconfirmed_reobservation() { 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 spender_txid = spender.txid(); + fixture.observe(&spender, confirmed_at(1_234)).await; let first = fixture .manager .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) @@ -2987,23 +3062,172 @@ mod tests { PlatformWalletError::AssetLockInputContested { .. } )); - // The reorg drops the block; the record survives, demoted. - fixture - .file_record(record_for(spender, TransactionContext::Mempool)) - .await; + // The tip advances past the spender's block and the spender is + // observed again with only a mempool context — the observation + // sequence a reorg would produce. + fixture.advance_tip(1_240).await; + fixture.observe(&spender, TransactionContext::Mempool).await; - let second = fixture + let still_confirmed = { + let wm = fixture.wallet_manager.read().await; + wm.get_wallet_info(&fixture.wallet_id) + .expect("wallet must remain registered") + .core_wallet + .transaction_history() + .into_iter() + .find(|record| record.txid == spender_txid) + .map(|record| (record.is_confirmed(), record.height())) + }; + assert_eq!( + still_confirmed, + Some((true, Some(1_234))), + "the unconfirmed re-observation does not reach the record: it is still \ + filed in the block the chain dropped" + ); + + match 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"); + .expect_err("the stale confirmation still condemns the lock") + { + PlatformWalletError::AssetLockInputContested { + spent_by, height, .. + } => { + assert_eq!(spent_by, spender_txid); + assert_eq!( + height, + Some(1_234), + "the verdict still quotes the reorged-away block" + ); + } + other => panic!("expected the standing AssetLockInputContested, got {other:?}"), + } + } + + /// A sibling that confirms only AFTER the pre-broadcast screen ran + /// must still end the resume. + /// + /// The screen runs once, before the broadcast, and a mempool sibling + /// is not a verdict there — either transaction can still win — so the + /// wait starts with no conflict recorded and, from a caller that + /// declined to name a bound, no deadline of the caller's own. Nothing + /// inside the wait can notice the sibling turning confirmed: a lock + /// notification only sends it back to re-read the tracked funding + /// transaction, which is exactly what can no longer confirm. The + /// backstop every resumed lock runs under is the only thing that ends + /// it, and the verdict is then re-read from live history — provisional, + /// because a merely-in-block spender proves nothing about finality. + /// + /// Time is virtual: the backstop is 180s, so a real-clock version of + /// this test would be unrunnable, and the unbounded wait it pins would + /// hang the suite rather than fail it. The outer bound makes the hang + /// an assertion failure instead. + #[tokio::test(start_paused = true)] + async fn a_sibling_confirming_after_the_snapshot_still_ends_the_resume() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Built, None).await; + + // Unconfirmed when the screen looks: a competing candidate, not a + // conflict, so the resume enters the wait with no caller-provided + // bound and no conflict cap — the recovery backstop applies. + let spender = transaction_spending(fixture.funded_input()); + let spender_txid = spender.txid(); + fixture.observe(&spender, TransactionContext::Mempool).await; + + // The sibling confirms while the wait is running, and the lock + // notification that accompanies a block wakes the waiter. + let confirming = { + let wallet_manager = Arc::clone(&fixture.wallet_manager); + let wallet_id = fixture.wallet_id; + let lock_notify = Arc::clone(&fixture.lock_notify); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(10)).await; + { + let mut wm = wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&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() + .get_mut(&spender_txid) + .expect("the mempool sighting filed a record") + .update_context(confirmed_at(1_234)); + } + lock_notify.notify_waiters(); + }) + }; + + let outcome = tokio::time::timeout( + Duration::from_secs(600), + fixture.manager.resume_asset_lock(&fixture.out_point, None), + ) + .await + .expect( + "a resumed Built lock must run under a finite backstop; without one \ + the wait outlives any bound a caller could impose", + ); + confirming.await.expect("confirming task"); + + match outcome.expect_err("no proof exists, so the resume cannot succeed") { + PlatformWalletError::AssetLockInputContested { + 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 AssetLockInputContested, got {other:?}"), + } + } + + /// The backstop stands on its own: with no sibling anywhere and an + /// accepted re-broadcast, a caller that declined to name a bound still + /// gets an answer instead of a parked thread. Acceptance says the + /// transaction reached the network, never that it can still confirm, + /// and the row is left at `Broadcast` so a proof arriving afterwards is + /// returned by the very next resume. + #[tokio::test(start_paused = true)] + async fn an_accepted_rebroadcast_still_ends_a_boundless_resume() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Built, None).await; + + let outcome = tokio::time::timeout( + Duration::from_secs(600), + fixture.manager.resume_asset_lock(&fixture.out_point, None), + ) + .await + .expect("an accepted re-broadcast must not license an unbounded wait"); + assert!( - !matches!( - second, - PlatformWalletError::AssetLockInputConflict { .. } - | PlatformWalletError::AssetLockInputContested { .. } + matches!( + outcome, + Err(PlatformWalletError::TransactionBroadcastUnconfirmed(_)) ), - "a demoted spender must retract the remembered verdict, got {second:?}" + "expected the unknown-outcome contract, got {outcome:?}" + ); + let status = { + let wm = fixture.wallet_manager.read().await; + wm.get_wallet_info(&fixture.wallet_id) + .expect("wallet must remain registered") + .tracked_asset_locks + .get(&fixture.out_point) + .expect("the row must survive the expiry") + .status + .clone() + }; + assert_eq!( + status, + AssetLockStatus::Broadcast, + "the dispatched send advanced the row, and the expiry must leave it there" ); }