diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index 29986168bc..a3ac265355 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -115,6 +115,49 @@ 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) + + /** + * `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 @@ -526,6 +569,8 @@ sealed class DashSdkError( }.getOrNull() } ?: 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 37169cc094..2aeeafaeca 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -190,6 +190,64 @@ 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: true) — " + + "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 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 5b840f9d03..d00c027330 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,22 @@ 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 { + // 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 444573c5db..439daa988a 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -265,6 +265,9 @@ 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 (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 @@ -369,6 +372,64 @@ 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 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. + /// + /// 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 + /// 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 (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 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 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 @@ -621,6 +682,16 @@ 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 + } + 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. @@ -1584,6 +1655,48 @@ 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), + } + .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/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 04a4e29ea1..ee0715275d 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 @@ -2956,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, @@ -4796,7 +4811,6 @@ 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 wallet_state = ClientWalletStartState { wallet, wallet_info, @@ -4822,27 +4836,6 @@ 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. -/// -/// 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. /// 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 +4991,27 @@ fn status_from_u8(b: u8) -> Result Result, PersistenceError> { @@ -5731,7 +5745,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 +5758,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: {}", @@ -5802,16 +5816,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 @@ -5889,7 +5915,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 +5930,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) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 21d98fac4d..39dfa41caf 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -617,6 +617,15 @@ 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 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` (42) is the only code that authorises a host +/// 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, @@ -624,6 +633,10 @@ fn map_asset_lock_funding_result( match result { Ok(()) => PlatformWalletFFIResult::ok(), Err(e @ PlatformWalletError::AssetLockAlreadyConsumed(_)) => e.into(), + Err( + e @ (PlatformWalletError::AssetLockInputConflict { .. } + | PlatformWalletError::AssetLockInputContested { .. }), + ) => e.into(), Err(e) => PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, format!("{operation} failed: {e}"), @@ -1852,8 +1865,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 +1886,58 @@ 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), + }), + "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: 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-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index fdbd641a57..8eead68201 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -653,6 +653,46 @@ pub struct WalletRestoreEntryFFI { pub last_applied_chain_lock_bytes_len: 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, + } + } +} + // 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 8349eb1df2..7b16b29e8c 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,89 @@ 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: 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. + /// + /// 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 + /// `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:?}, 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, + }, + + /// 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 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. + #[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 fb3852dcef..4f1253db5b 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -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,106 @@ 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, +/// 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 +/// 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. +/// +/// **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. +/// +/// 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 +/// history, and confirmed spends of an outpoint are mutually exclusive. +/// 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 +/// 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, bool)> { + let lock_txid = lock.transaction.txid(); + let lock_inputs: BTreeSet = lock + .transaction + .input + .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(); + + // 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| { + let conflicting_input = record + .transaction + .input + .iter() + .map(|input| input.previous_output) + .find(|outpoint| lock_inputs.contains(outpoint))?; + 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)) + }) +} + impl AssetLockManager { /// Resume a tracked asset lock from whatever stage it's at. /// @@ -211,6 +315,17 @@ 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 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, @@ -219,7 +334,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 +355,76 @@ 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, 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 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, + } + }); + } + // 2. Resume from the current status. let proof = match status { AssetLockStatus::Built => { @@ -516,11 +693,19 @@ 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::{Network, OutPoint, Transaction, Txid}; + 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; 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 +724,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 +1154,416 @@ 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, + }, + ); + } + + /// 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, + /// 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, + )) + } + + fn chain_locked_at(height: u32) -> TransactionContext { + TransactionContext::InChainLockedBlock(BlockInfo::new( + height, + BlockHash::all_zeros(), + 1_700_000_000, + )) + } + + /// 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_a_contested_input_for_a_merely_in_block_spender() { + 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 currently double-spent asset lock must fail, not wait"); + 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!("expected AssetLockInputContested, got {other:?}"), + } + assert_eq!( + fixture.broadcast_count(), + 0, + "the screen must short-circuit ahead of the defensive re-broadcast" + ); + } + + /// 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, .. } => { + // The terminal variant IS the finality assertion: it is + // only constructed for a chainlock-final spender. + assert_eq!(spent_by, spender_txid); + } + 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 + /// 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, .. + } => { + assert_eq!(spent_by, spender_txid); + assert_eq!(height, Some(1_234)); + } + 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, + "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 { .. }) + | Err(PlatformWalletError::AssetLockInputContested { .. }) + ), + "a lock's own record must never condemn it under either variant, 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/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index c3e3376482..d3437f4805 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -1044,10 +1044,19 @@ public class PlatformWalletManager: ObservableObject { PlatformWalletManager.decodeOutPointForCatchUp($0.outPointHex) } guard !outpoints.isEmpty else { continue } + // 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: Void.self) { group in + await withTaskGroup(of: PlatformWalletError?.self) { group in let maxConcurrent = 4 var nextIndex = 0 + var published = false // Seed the group with up to `maxConcurrent` tasks. // Each `group.addTask` closure captures // `assetLockManager` — that retain keeps the @@ -1062,8 +1071,20 @@ 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 — + // 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 !published, let verdict = outcome { + published = true + await publishConflict(verdict) + } if nextIndex < outpoints.count { let (txid, vout) = outpoints[nextIndex] group.addTask { @@ -1090,7 +1111,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 +1128,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 +1145,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 b8b1bcde6c..3557d5b996 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1162,27 +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. - let expectedIsSpent = 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 — @@ -1369,13 +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 { - 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 { @@ -1384,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), @@ -1414,20 +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 { - 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 @@ -5461,23 +5514,89 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return (buf, written) } + /// 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 + /// 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, @@ -5497,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 @@ -5552,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 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 8528fe091d..4f84893bb2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -141,6 +141,29 @@ 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 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. @@ -238,6 +261,10 @@ 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_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: @@ -418,6 +445,28 @@ 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. 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. + 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 @@ -452,6 +501,8 @@ public enum PlatformWalletError: LocalizedError { .staleReservationToken(let m), .reservationTokenConsumed(let m), .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 @@ -561,6 +612,17 @@ public enum PlatformWalletError: LocalizedError { } else { self = .unknown(detail) } + // Code 42 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. + case .errorAssetLockInputConflict: + self = .assetLockInputConflict(detail) + case .errorAssetLockInputContested: + self = .assetLockInputContested(detail) case .notFound: self = .notFound(detail) case .errorUnknown: self = .unknown(detail) } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockInputSpendRestoreTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockInputSpendRestoreTests.swift new file mode 100644 index 0000000000..8a8a9b92a5 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockInputSpendRestoreTests.swift @@ -0,0 +1,215 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +/// 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 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 { + + 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, + spenderContext: UInt32 = 2 + ) 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: spenderContext, + blockHeight: spenderContext >= 2 ? 101 : 0, + 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 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].unresolved_asset_lock_tx_records_count) + } + + /// The ordinary case: the TXO carries its wallet id, and the confirmed + /// 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(restoredRecordCount(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( + restoredRecordCount(handler), + 1, + "a legacy TXO resolving to this wallet through its account must not be discarded" + ) + } + + /// 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) + + 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.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( + Int(row.tx_bytes_len), 10, + "the spender's consensus bytes, not the funding tx's (which the fixture sizes differently)" + ) + 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) + } +}