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 33de8206989..4bcb1ae8ea1 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,65 @@ sealed class DashSdkError( class AssetLockFundingMismatch(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorAssetLockInputConflict` (native code 47). RESERVED — the + * native side has no code path that produces it today, so this class + * is never instantiated from a real result. + * + * It is the TERMINAL form of the double-spend verdict: the tracked + * asset-lock transaction spends an outpoint a different, + * already-confirmed transaction of the same wallet spent first, AND + * that spender's block is proven to be on the finalized chain. The + * proof is what is missing — chainlock contexts and the wallet's + * applied chainlock height are height-based promotion artifacts, not + * evidence of finalized ancestry — so every detection arrives as + * [AssetLockInputContested] (48) instead, chainlocked-looking + * spenders included. + * + * Kept (with its mapping arm) so the reserved code stays wired and + * hosts branching on it keep compiling. If it ever ships it keeps its + * meaning: NOT retryable, and the one code that lets a host discard + * the asset lock and rebuild it from currently-unspent inputs. Read + * nothing into its absence. The Android analog of Swift's + * `PlatformWalletError.assetLockInputConflict`. + */ + class AssetLockInputConflict(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + + /** + * `ErrorAssetLockInputContested` (native code 48). A confirmed + * transaction of this wallet already spent one of the tracked lock's + * inputs — typically a restored wallet whose rescan resurrected a + * UTXO one of its own earlier asset locks had already consumed. Peers + * drop such a double spend without replying, so the lock cannot + * confirm while that spender stands and an unbounded proof wait would + * hang. The resume still runs: the sighting bounds that wait instead + * of replacing it, so the lock was (re-)broadcast and waited on (a + * `Broadcast`-status lock was also sent on an earlier call), and this + * is what the bounded wait expired with. + * + * The ONLY double-spend verdict the native side emits, and it is + * PROVISIONAL. NO discard licence: keep the tracked lock and retry + * later (next launch, or after the next chainlock) — but note a + * chainlock does NOT upgrade this to code 47 today; what a retry can + * resolve is a reorg dropping the sibling. Repetition does not + * license a discard either: a conflict that survives session after + * session still proves nothing about finalized ancestry — the + * sighting can be a block record restored from a previous session + * whose block was reorganized out while the host was offline. Only + * code 47, or an independent finalized-ancestry proof, authorizes + * dropping the tracked state. Keeping the lock costs nothing: the + * confirmed spender is this wallet's own transaction, so the value + * lives on in it either way. Its absence is not proof of liveness — + * the native scan cannot see conflicts whose spender was already + * pruned. The Android analog of Swift's + * `PlatformWalletError.assetLockInputContested`. + */ + class AssetLockInputContested(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) { + override val isRetryable: Boolean get() = true + } + /** * `ErrorAssetLockInsufficientFunds` (native code 29). Asset-lock coin * selection came up short over the build's *permitted funding set*. @@ -582,6 +641,13 @@ sealed class DashSdkError( }.getOrNull() } ?: PlatformWallet.Generic(code, message, cause) 41 -> PlatformWallet.PlatformShieldCapacityExceeded(message, cause) + // ErrorAssetLockInputConflict — RESERVED, no native emitter yet; + // the arm stays so the code would not fall through to Generic if + // a finalized-ancestry proof ever starts raising it. + 47 -> PlatformWallet.AssetLockInputConflict(message, cause) + // ErrorAssetLockInputContested — the double-spend verdict the + // native side actually emits. + 48 -> PlatformWallet.AssetLockInputContested(message, cause) // ErrorSigningKeyUnavailable — the STRUCTURED signer // discriminator (dashpay/platform#4060 finding 7): the typed // completion code rides the whole Rust round-trip, no message diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index 94cc410285e..0889e6ba126 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -217,6 +217,65 @@ class DashSdkErrorTest { ) } + @Test + fun assetLockInputConflictCode47MapsTyped() { + // TERMINAL and RESERVED: no native path emits it today (that needs a + // finalized-ancestry proof the wallet cannot make), so this drives + // the mapping with a hand-built exception. The arm must stay wired — + // if a future emitter ships, the code must not fall through to + // Generic and leave the host unable to classify a dead lock. + val message = + "Asset lock a:0 can never confirm: it spends b:1, which was already spent by " + + "confirmed transaction c (block height Some(1234), chainlocked: true) — " + + "the lock is a double spend and no peer will relay it" + val mapped = DashSdkError.fromNative( + DashSDKException( + DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 47, + message, + ), + ) + + assertTrue( + "code 47 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 assetLockInputContestedCode48MapsTypedAndRetryable() { + // PROVISIONAL, and the ONLY double-spend verdict the native side + // emits: the wallet cannot prove the confirmed spender's block is on + // the finalized chain, so the host keeps the tracked lock and retries + // later. It must never be treated as the reserved 47's discard + // licence, and it must never fall through to Generic. + val message = + "Asset lock a:0 cannot currently confirm: it spends b:1, which confirmed " + + "transaction c (block height Some(1234)) has taken — the verdict is " + + "provisional (the wallet cannot prove the spender's finality); keep " + + "the lock and retry later" + val mapped = DashSdkError.fromNative( + DashSDKException( + DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 48, + message, + ), + ) + + assertTrue( + "code 48 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/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 5ac8c738fbe..181eefe62f2 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -114,9 +114,10 @@ These are shipped ABI. Do not renumber. | 98 | `NotFound` | Sentinel — `Option` returned as an error | | 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors | -**Next allocatable integer: 48** — 27–47 are all claimed (27, 29, 31, 34–42 -and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 -proposed by active #4356 (renumbered from 42 — see its row below); 28, 30, +**Next allocatable integer: 49** — 27–48 are all claimed (27, 29, 31, 34–42 +and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 and +48 proposed by active #4356 (47 renumbered from 42, 48 from 43 — see their +rows below); 28, 30, 32 and 33 reserved). **28, 30, 32 and 33 are RESERVED, not free**: 28 and 30 were vacated when the reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners @@ -124,7 +125,7 @@ reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners unclaimed rather than back-filled, so no number is reused within a single review cycle. Rule 1's "do not reuse a gap unless this file marks it free" applies — this file does **not** mark any of them free, so the frontier is -the only allocation source and a new code takes 48. (42 is a cautionary tale: +the only allocation source and a new code takes 49. (42 is a cautionary tale: merged #4451 minted it while active #4356 held the claim — merged ABI wins, the open PR renumbers. 46's near-miss went the other way: caught in review, renumbered before merge.) @@ -149,7 +150,8 @@ Fork-era numbers remain in the collision history, which is immutable record. | Code | Name | Owning PR | Status | | ---: | --- | --- | --- | | 28 | *(reserved — vacated)* | — | Vacated by #4185/#4256 on 2026-08-02; RESERVED, not reissuable — the next-free frontier is the only allocation source | -| 47 | `ErrorAssetLockInputConflict` | #4356 | Proposed — **47 is reserved for this active PR, but the three-layer renumber is still PENDING.** Merged #4451 took 42 for `ErrorMasternodeWithdrawalUnconfirmed` on 2026-08-22, and merged ABI wins. At the cited #4356 head `7d9be71a08`, Rust still defines and tests `ErrorAssetLockInputConflict = 42`, Swift still declares `errorAssetLockInputConflict = 42`, and Kotlin still maps and tests native 42 — #4356 must move all three layers and their tests together to 47 before it can merge. Rule 1 makes 47 unavailable to any other contributor while #4356 is active | +| 47 | `ErrorAssetLockInputConflict` | #4356 | Proposed — three-layer renumber from 42 **complete** on the branch (Rust value + pin test, Swift raw case, Kotlin arm + test all at 47). Merged #4451 had taken 42 for `ErrorMasternodeWithdrawalUnconfirmed` on 2026-08-22; merged ABI won and #4356 moved. **Reserved-with-no-emitter**: the wallet currently constructs only the provisional 48 — 47 is the terminal discard-licensing verdict, held for a future finalized-ancestry proof the SPV layer does not yet expose. The number is claimed ABI either way; Rule 1 makes 47 unavailable to any other contributor while #4356 is active | +| 48 | `ErrorAssetLockInputContested` | #4356 | Proposed — renumbered from 43 (which active #4313 holds) alongside 47's move. The provisional double-spend verdict the conflict screen always emits: the sighting BOUNDS the proof wait rather than replacing it, so the lock is still (re-)broadcast and waited on, and 48 is emitted only when that bounded wait expires with the conflict still standing; carries no discard licence. Rust value + Swift raw case + Kotlin typed arm and tests all at 48 on the branch | | 30 | *(reserved — vacated)* | — | Vacated by #4185/#4256 on 2026-08-02; RESERVED, not reissuable — the next-free frontier is the only allocation source | | 32 | *(reserved — lapsed)* | — | Owner #4310 (successor of fork-era #4247) closed without merging; RESERVED, not reissuable | | 33 | *(reserved — lapsed)* | — | Owner #4311 (successor of fork-era #4256) closed without merging; RESERVED, not reissuable | diff --git a/packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs b/packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs index a1274f5e22f..9bc819585cd 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; @@ -52,26 +53,22 @@ fn parse_outpoint(txid: *const [u8; 32], vout: u32) -> dashcore::OutPoint { /// /// `timeout_secs == 0` does **not** request an unbounded wait — it /// declines to specify one, and `resume_asset_lock` then applies the -/// recovery policy's own state-dependent default: +/// recovery policy's own default: the 180s +/// `UNCONFIRMED_BROADCAST_PROOF_TIMEOUT` (sized to comfortably cover a +/// ~2.5min ChainLock) on every arm that waits for a proof — a `Built` +/// re-broadcast whatever the broadcaster answered, a `Broadcast` row, +/// and the defensive proof-less `RecoveredFromChain` fallback alike. /// -/// - An ambiguous `Built` re-broadcast (the broadcaster reports -/// `MaybeSent` for an accepted and a rejected transaction alike), -/// a `Broadcast` row, and the defensive proof-less -/// `RecoveredFromChain` fallback all substitute the 180s -/// `UNCONFIRMED_BROADCAST_PROOF_TIMEOUT` bound (sized to -/// comfortably cover a ~2.5min ChainLock): none of them can -/// establish that the transaction is live on the network, and -/// waiting without a bound on that signal is a `Notify` loop with -/// no terminating event — under the `runtime().block_on(...)` -/// below it pins the calling host thread permanently rather than -/// merely delaying an answer. -/// - The one exception: a `Built` re-broadcast the broadcaster -/// positively ACCEPTED (`Ok`) keeps the unbounded wait — the same -/// positive-evidence wait the initial funding path performs after -/// its own successful broadcast. The proof arrives with the -/// transaction's ChainLock (~2.5min) in normal operation, but the -/// wait is not time-bounded: a caller that needs a hard upper -/// bound on this thread must pass a non-zero `timeout_secs`. +/// A resume cannot gather evidence that rules out a wait which never +/// ends. Even a positively ACCEPTED (`Ok`) re-broadcast only +/// establishes that the transaction reached the network: a sibling +/// spending the same outpoint may confirm the instant afterwards, and +/// from then on no proof for this transaction can arrive. The wait +/// cannot see that happen — it wakes on lock events and re-reads the +/// tracked funding transaction only — so an unbounded wait is a +/// `Notify` loop with no terminating event, which under the +/// `runtime().block_on(...)` below pins the calling host thread +/// permanently rather than merely delaying an answer. /// /// Expiry is non-destructive: the tracked row keeps its status, so a /// proof arriving afterwards is returned by the very next resume @@ -99,12 +96,9 @@ pub unsafe extern "C" fn asset_lock_manager_resume( let out_point = parse_outpoint(txid, vout); // `timeout_secs == 0` declines to specify a bound. `resume_asset_lock` - // reads the resulting `None` as "apply the recovery policy's - // state-dependent default": the 180s - // `UNCONFIRMED_BROADCAST_PROOF_TIMEOUT` on every proof-waiting arm - // except a `Built` re-broadcast the broadcaster positively accepted, - // which keeps the unbounded initial-funding wait. See this - // function's `# Timeouts` section. + // reads the resulting `None` as "apply the recovery policy's default": + // the 180s `UNCONFIRMED_BROADCAST_PROOF_TIMEOUT` on every proof-waiting + // arm. See this function's `# Timeouts` section. let timeout = (timeout_secs != 0).then(|| Duration::from_secs(timeout_secs)); let option = ASSET_LOCK_MANAGER_STORAGE.with_item(handle, |manager| { @@ -145,28 +139,22 @@ pub unsafe extern "C" fn asset_lock_manager_resume( /// /// Identical contract to [`asset_lock_manager_resume`], which this /// delegates to: `timeout_secs == 0` selects the recovery policy's -/// state-dependent default rather than an unbounded wait. That -/// default is the 180s `UNCONFIRMED_BROADCAST_PROOF_TIMEOUT` on every -/// arm that waits for a proof without positive evidence the -/// transaction is on the network — an ambiguous `Built` re-broadcast, -/// a `Broadcast` row, the defensive proof-less `RecoveredFromChain` -/// fallback. The one exception is a `Built` re-broadcast the -/// broadcaster positively accepted (`Ok`): that arm keeps the -/// unbounded initial-funding wait, so the thread is parked until the -/// accepted transaction's proof arrives (its ChainLock, ~2.5min in -/// normal operation) rather than for a fixed bound. Pass a non-zero -/// `timeout_secs` for a hard upper bound. +/// default rather than an unbounded wait. That default is the 180s +/// `UNCONFIRMED_BROADCAST_PROOF_TIMEOUT`, and it applies to every arm +/// that waits for a proof — a `Built` re-broadcast whatever the +/// broadcaster answered, a `Broadcast` row, the defensive proof-less +/// `RecoveredFromChain` fallback. Pass a non-zero `timeout_secs` for +/// a different upper bound. /// /// That policy is what makes this entry point safe to fan out at /// launch. The catch-up sweep starts one call per stuck lock; when -/// zero meant "wait forever" on EVERY waiting arm, a device that was -/// offline (or an SPV session that never connected) turned each of -/// those into a permanently parked worker thread. An unconnected or -/// undeliverable broadcast can only take the bounded arms now (a -/// broadcaster that never dispatched reports `Rejected` / -/// `MaybeSent`, not `Ok`), expiry simply ends the pass, leaving the -/// row tracked and resumable, and the next sweep picks up a proof -/// that landed in between straight from the record. +/// zero meant "wait forever", a device that was offline (or an SPV +/// session that never connected), and equally a lock whose outpoint a +/// sibling transaction had already taken, turned each of those into a +/// permanently parked worker thread. Every arm is bounded now: expiry +/// simply ends the pass, leaving the row tracked and resumable, and +/// the next sweep picks up a proof that landed in between straight +/// from the record. #[no_mangle] pub unsafe extern "C" fn asset_lock_manager_catch_up_blocking( handle: Handle, @@ -178,12 +166,9 @@ pub unsafe extern "C" fn asset_lock_manager_catch_up_blocking( let out_point = parse_outpoint(txid, vout); // `timeout_secs == 0` declines to specify a bound. `resume_asset_lock` - // reads the resulting `None` as "apply the recovery policy's - // state-dependent default": the 180s - // `UNCONFIRMED_BROADCAST_PROOF_TIMEOUT` on every proof-waiting arm - // except a `Built` re-broadcast the broadcaster positively accepted, - // which keeps the unbounded initial-funding wait. See this - // function's `# Timeouts` section. + // reads the resulting `None` as "apply the recovery policy's default": + // the 180s `UNCONFIRMED_BROADCAST_PROOF_TIMEOUT` on every proof-waiting + // arm. See this function's `# Timeouts` section. let timeout = (timeout_secs != 0).then(|| Duration::from_secs(timeout_secs)); tracing::info!( @@ -222,10 +207,25 @@ 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. In practice that is + // always the provisional ErrorAssetLockInputContested + // (48), which bounds the wait but keeps the lock for a + // later retry: the resume never raises the terminal + // ErrorAssetLockInputConflict (47), which stays reserved + // for a finalized-ancestry proof the wallet cannot make. + // 47 is matched anyway so the reserved code would cross + // intact rather than flattening the day it ships. + // Flattening either to ErrorWalletOperation would leave + // the host with a spinner it can never resolve. + conflict @ (PlatformWalletError::AssetLockInputConflict { .. } + | 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 7731460701e..63cb50a152c 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -286,6 +286,13 @@ 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 ErrorMasternodeWithdrawalUnconfirmed masternode withdrawal status + // 43-45 RESERVED by open dashpay/platform#4313 (shielded-invite claim) + // 46 ErrorMasternodeListUnavailable masternode list source + // 47 ErrorAssetLockInputConflict asset-lock double-spend detection + // (terminal; RESERVED, no emitter yet) + // 48 ErrorAssetLockInputContested asset-lock double-spend detection (provisional) // // 38/39/40 carry a STABLE JSON detail object in the result `message` // instead of the typed `Display` rendering — see each variant's doc for @@ -429,6 +436,74 @@ pub enum PlatformWalletFFIResultCode { /// per the error-code registry (#4318). ErrorMasternodeListUnavailable = 46, + /// Maps `PlatformWalletError::AssetLockInputConflict`. **RESERVED — + /// no wallet code path currently produces it**, so this code does not + /// cross the boundary today. + /// + /// It is the terminal form of the double-spend verdict: a tracked + /// asset-lock transaction spending an outpoint that a different, + /// already-confirmed transaction of the same wallet spent first, where + /// that spender's block is PROVEN to be on the finalized chain. That + /// proof is what is missing. The wallet can see a confirmed spender, + /// a chainlocked record context and the applied chainlock height, but + /// all of those are height-based promotion artifacts rather than + /// evidence of finalized ancestry (the SPV chainlock manager counts a + /// missing header as a passing block-hash check, so a chainlock on a + /// replacement branch can promote losing-branch records). Until the + /// SPV layer exposes an ancestry predicate, every hit — chainlocked + /// spenders included — reports + /// [`Self::ErrorAssetLockInputContested`] (48) instead. + /// + /// The code number and this variant are kept pinned so the reserved + /// slot stays stable for hosts and for the future emitter. A host must + /// read NOTHING into its absence: it is not a liveness signal, not a + /// "not final yet" signal, and not a statement about any lock. Hosts + /// that already branch on it may keep doing so — if it ever ships, it + /// keeps its meaning: the one code that authorises discarding a + /// tracked asset lock and rebuilding from currently-unspent inputs. + /// + /// Message (when it ships): the typed `Display` rendering, which names + /// the asset-lock outpoint, the conflicting input, the confirmed + /// spender's txid, and the spender's finality (always chainlocked for + /// this code). + ErrorAssetLockInputConflict = 47, + + /// Maps `PlatformWalletError::AssetLockInputContested`. The double-spend + /// screen's ONLY verdict: a confirmed transaction of this wallet + /// already spent one of the tracked lock's inputs. The resume still + /// ran — the sighting bounds the proof wait instead of replacing it, + /// so the lock was (re-)broadcast and waited on, and this is what that + /// bounded wait expired with. PROVISIONAL — the wallet cannot prove the + /// spender's block is on the finalized branch (see + /// [`Self::ErrorAssetLockInputConflict`] (47), the reserved terminal + /// form), so this is what a chainlocked-looking spender reports too. + /// + /// NOT a discard licence. The host keeps the tracked lock and retries + /// later (next launch, or after the next chainlock) — but note that a + /// chainlock does NOT upgrade this to 47 today; what a retry can + /// resolve is the other direction, a reorg dropping the sibling so the + /// next resume proceeds normally. Nor does repetition license a + /// discard: a conflict that persists across sessions still proves + /// nothing about finalized ancestry — the sighting can be a block + /// record restored from a previous session whose block was reorganized + /// out while the host was offline. Only code 47, or an independent + /// finalized-ancestry proof, authorises dropping the tracked state, + /// because a lock whose sibling sits on a losing branch can still be + /// replayed and confirm. Keeping the lock costs the host nothing: the + /// conflicting spender is this wallet's own transaction, so the value + /// lives on in the sibling either way. + /// + /// 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 + /// height, and says the verdict is provisional. + ErrorAssetLockInputContested = 48, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -717,6 +792,19 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::AssetLockFundingMismatch { .. } => { PlatformWalletFFIResultCode::ErrorAssetLockFundingMismatch } + // Double-spend verdicts. `AssetLockInputContested` is the one + // the wallet actually raises — without this arm it reached + // `ErrorUnknown` and a host could only render a spinner. The + // terminal `AssetLockInputConflict` has no emitter today (it + // needs a finalized-ancestry proof the SPV layer does not + // expose); its arm is kept so the reserved code stays wired + // for the future emitter and for direct constructions. + PlatformWalletError::AssetLockInputConflict { .. } => { + PlatformWalletFFIResultCode::ErrorAssetLockInputConflict + } + PlatformWalletError::AssetLockInputContested { .. } => { + PlatformWalletFFIResultCode::ErrorAssetLockInputContested + } // The asset-lock coin-selection shortfall (dashpay/platform#4073). // Without this arm it flattens to `ErrorUnknown` (99), hiding a // typed shortfall behind the catch-all and forcing hosts to @@ -1429,6 +1517,46 @@ mod tests { ); } + /// Code 26 is a promise about cleanup, not about the broadcaster's + /// verdict: the row was untracked and the funding reservation released, + /// so a rebuild is safe. An asset-lock build whose rejection raced a + /// concurrent resume keeps both — the guard retains the advanced row and + /// the release is skipped — and reports the unknown outcome instead. The + /// two must never collapse to one code across the boundary: a host that + /// read 26 there would rebuild from other UTXOs and create a second asset + /// lock beside a transaction the advance says reached the network. + #[test] + fn a_retained_asset_lock_row_reports_the_unknown_outcome_not_the_rejection() { + let retained: PlatformWalletFFIResult = + PlatformWalletError::TransactionBroadcastUnconfirmed( + "asset lock 0000..:0 stays tracked and reserved: the broadcast was \ + rejected, but a concurrent resume had already advanced the row past \ + Built, so the transaction may be on the network" + .to_string(), + ) + .into(); + assert_eq!( + retained.code, + PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed, + "a rejection that released nothing must reach the host as code 20" + ); + + let cleaned_up: PlatformWalletFFIResult = + PlatformWalletError::TransactionBroadcast("bad-txns-inputs-missingorspent".to_string()) + .into(); + assert_eq!( + cleaned_up.code, + PlatformWalletFFIResultCode::ErrorTransactionBroadcastRejected, + "the untracked-and-released path keeps the safe-to-retry code 26" + ); + assert_ne!( + retained.code, cleaned_up.code, + "the retained-row and released-reservation outcomes must stay \ + distinguishable at the FFI boundary — code 26 licenses the rebuild \ + that the retained row makes unsafe" + ); + } + /// `AddressNonceMismatch` maps to the dedicated `ErrorAddressNonceMismatch` /// FFI code through the blanket `From` impl (the path identity /// `top_up_from_addresses` takes via `?`/`.into()`) rather than flattening @@ -1771,6 +1899,49 @@ mod tests { ); } + /// The terminal double-spend verdict is RESERVED — no wallet path + /// constructs it today — but its slot stays pinned, so this builds the + /// error directly and checks both halves of the contract: the number + /// the Swift/Kotlin mirrors decode, and the conversion that keeps it + /// from flattening to `ErrorUnknown` if a future ancestry predicate + /// starts emitting it. The message must carry the typed `Display` — + /// including the spender's finality — since that is the only detail + /// channel the frozen `{ code, message }` ABI has. + #[test] + fn asset_lock_input_conflict_code_is_pinned_at_47() { + use dashcore::OutPoint; + + assert_eq!( + PlatformWalletFFIResultCode::ErrorAssetLockInputConflict as i32, + 47 + ); + + 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 6d3c68ab124..3b540ffbf8d 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -72,6 +72,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 @@ -3205,16 +3216,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, @@ -5050,7 +5065,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, @@ -5076,27 +5090,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 @@ -5252,6 +5245,27 @@ fn status_from_u8(b: u8) -> Result Result, PersistenceError> { @@ -5985,7 +5999,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: {}", @@ -5998,7 +6012,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: {}", @@ -6056,16 +6070,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 @@ -6143,7 +6169,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: {}", @@ -6158,7 +6184,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 6e287c7933f..c1cec289755 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -771,6 +771,25 @@ fn catch_funding_panic( /// `ErrorWalletOperation` (6) catch-all below, hiding a typed error behind /// the code every unclassified failure already uses and forcing hosts back /// to substring-matching the Display text. +/// - The double-spend verdicts ride the same typed conversion (both the +/// fresh-build and resume entry points funnel through here, and the resume +/// is where the double-spend screen actually fires). What the +/// screen emits is always `ErrorAssetLockInputContested` (48), the +/// provisional keep-and-retry verdict; the terminal +/// `ErrorAssetLockInputConflict` (47) — the code that would authorise a +/// host to discard a tracked lock — is reserved and currently has no +/// emitter, but is matched here so it stays typed if a future +/// finalized-ancestry proof starts raising it. Flattening either to +/// `ErrorWalletOperation` would strand the user on a lock the host cannot +/// classify. +/// - `AssetLockNotTracked` -> `ErrorAssetLockNotTracked` (23) and +/// `AssetLockFundingMismatch` -> `ErrorAssetLockFundingMismatch` (25), +/// matching what the non-shielded `asset_lock_manager_resume` surfaces for +/// the same two lookup failures. A host must classify "this outpoint is not +/// tracked" / "this lock belongs to a different funding slot" the same way +/// whichever entry point it came in through — both are caller-state errors +/// that no retry fixes, unlike the timeout and proof-wait failures below +/// that keep the contextual `ErrorWalletOperation`. fn map_asset_lock_funding_result( result: Result<(), PlatformWalletError>, operation: &str, @@ -778,6 +797,14 @@ fn map_asset_lock_funding_result( match result { Ok(()) => PlatformWalletFFIResult::ok(), Err(e @ PlatformWalletError::AssetLockAlreadyConsumed(_)) => e.into(), + Err( + e @ (PlatformWalletError::AssetLockNotTracked(_) + | PlatformWalletError::AssetLockFundingMismatch { .. }), + ) => e.into(), + Err( + e @ (PlatformWalletError::AssetLockInputConflict { .. } + | PlatformWalletError::AssetLockInputContested { .. }), + ) => e.into(), Err(e @ PlatformWalletError::AssetLockInsufficientFunds { .. }) => e.into(), Err(e) => PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, @@ -2392,8 +2419,16 @@ mod tests { ); } + /// The typed asset-lock outcomes keep their own codes through this + /// wrapper — both funding entry points (fresh build and resume) flatten + /// everything else to `ErrorWalletOperation`, and a host that saw the + /// flattened code could neither hold the consumption-unknown state, nor + /// tell a stale/foreign outpoint from a network failure, nor act on a + /// lock that cannot confirm while its sibling stands. #[test] fn map_asset_lock_funding_result_preserves_typed_funding_codes() { + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + let out_point = dashcore::OutPoint { txid: dashcore::Txid::all_zeros(), vout: 7, @@ -2408,6 +2443,90 @@ mod tests { ); assert!(message_of(&result).contains("Platform completion is unconfirmed")); + // The two lookup failures `asset_lock_manager_resume` reports + // typed must not arrive flattened just because the caller came in + // through the shielded entry point. + let not_tracked = map_asset_lock_funding_result( + Err(PlatformWalletError::AssetLockNotTracked(out_point)), + "shielded resume fund-from-asset-lock", + ); + assert_eq!( + not_tracked.code, + PlatformWalletFFIResultCode::ErrorAssetLockNotTracked + ); + assert!(message_of(¬_tracked).contains("is not tracked by this wallet")); + + let mismatch = map_asset_lock_funding_result( + Err(PlatformWalletError::AssetLockFundingMismatch { + out_point, + expected_funding_type: AssetLockFundingType::IdentityRegistration, + expected_identity_index: 0, + actual_funding_type: AssetLockFundingType::IdentityTopUp, + actual_identity_index: 3, + }), + "shielded resume fund-from-asset-lock", + ); + assert_eq!( + mismatch.code, + PlatformWalletFFIResultCode::ErrorAssetLockFundingMismatch + ); + assert!(message_of(&mismatch).contains("is ineligible for")); + + // The resume endpoint is where the double-spend screen fires, and + // it funnels through this same wrapper. The screen + // itself only ever raises the contested verdict below; the + // terminal one is reserved, so it is constructed directly here to + // pin that the reserved code would still cross typed. + 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 verdict the screen actually emits rides the same wrapper + // under its own code: it bounds the wait but must not surface as + // the terminal, discard-licensing 47. + let contested = map_asset_lock_funding_result( + Err(PlatformWalletError::AssetLockInputContested { + 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 fdbd641a57f..c49be7de1b7 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -456,29 +456,44 @@ pub struct UtxoRestoreEntryFFI { /// One persisted transaction record carried back at load time so the /// in-memory `transactions()` map can be selectively repopulated for -/// the small subset of records that matter for chain-lock cascade — -/// today, the funding transactions of tracked asset locks still at -/// `Built` / `Broadcast` (`statusRaw < 2`). +/// the small subset of records that matter at launch. TWO record roles +/// ride this array, and a host must supply both: +/// +/// * **Funding transactions** of tracked asset locks still at `Built` / +/// `Broadcast` (`statusRaw < 2`): their record must live in the +/// in-memory map at the moment the next chain-lock event fires, or +/// `WalletManager::apply_chain_lock` finds nothing to promote and +/// the bridge has no `chain_lock_promotions` to emit. +/// * **Settled spenders of those locks' inputs** (context `2` / `3`): +/// the double-spend screen in `resume_asset_lock` scans live +/// history — empty at load apart from this array — for a confirmed +/// transaction that already took a lock's input. A host that omits +/// these leaves startup conflict detection blind, so a doomed resume +/// expires as an untyped proof-wait timeout instead of the typed +/// contested verdict. `account_index` for a spender row is the +/// account of the TXO it spent (the lock's funding account when the +/// host cannot resolve one). +/// +/// The Rust decoder classifies each record from its own payload (an +/// asset-lock special-tx payload marks a funding record), so the two +/// roles need no tag and a spender cannot masquerade as a funding tx. /// /// Why selectively rather than wholesale: the wallet's own load path /// only bulk-restores UTXOs, not tx records, by design — most tx /// history is consumed reactively through SwiftData `@Query`s, not -/// from the in-memory map. The exception is asset locks waiting for -/// IS-lock / chain-lock proofs: their funding tx must live in the -/// in-memory map at the moment the next chain-lock event fires, or -/// `WalletManager::apply_chain_lock` finds nothing to promote and -/// the bridge has no `chain_lock_promotions` to emit. Restoring -/// these specific records closes that gap without breaking the rest -/// of the lazy-load model. +/// from the in-memory map. Restoring these specific records closes +/// the two gaps above without breaking the rest of the lazy-load +/// model. /// /// `context_raw` matches `TransactionContext` discriminants: /// 0 = Mempool, 1 = InstantSend, 2 = InBlock, 3 = InChainLockedBlock. /// Only `2` and `3` are reconstructible from these scalar fields; /// `0` / `1` need either no block info (Mempool) or an IS-lock blob /// we don't carry (InstantSend), so the Rust load path treats them -/// as `Mempool` — defensive code for an edge that shouldn't occur in -/// practice (an asset lock at `Built` / `Broadcast` has by definition -/// not yet observed IS-lock or block confirmation). +/// as `Mempool` — defensive for funding records (a `Built` / +/// `Broadcast` lock has by definition seen neither), and the reason a +/// host should only ship SETTLED spender records: an unsettled spend +/// is not evidence, and would be restored as a mempool sighting. #[repr(C)] pub struct UnresolvedAssetLockTxRecordFFI { /// Family-independent source index the funding tx spent UTXOs @@ -609,7 +624,11 @@ pub struct WalletRestoreEntryFFI { /// when the wallet has no persisted tracked locks. pub tracked_asset_locks: *const AssetLockEntryFFI, pub tracked_asset_locks_count: usize, - /// Funding tx records for tracked asset locks at `statusRaw < 2` + /// Tx records restored into the in-memory map at load: the funding + /// records of unresolved asset locks AND the settled spenders of + /// their inputs — see [`UnresolvedAssetLockTxRecordFFI`] for the + /// two-role contract. Historically documented as funding-only: + /// funding tx records for tracked asset locks at `statusRaw < 2` /// (Built / Broadcast). The Rust load path re-inserts each entry /// into the matching `standard_bip44_accounts[account_index] /// .transactions_mut()` bucket so the next incoming chain-lock @@ -653,6 +672,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/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index f009bc5d592..f9b7f491977 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -2344,6 +2344,7 @@ mod contact_watch_only_projection_tests { identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + observed_input_conflicts: Default::default(), }; let mut wm = WalletManager::::new(dashcore::Network::Testnet); let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index f6e2a4b7af5..d24412b3007 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; @@ -400,6 +400,109 @@ pub enum PlatformWalletError { actual_identity_index: u32, }, + /// **RESERVED — the wallet never constructs this variant today.** + /// + /// It describes a tracked asset-lock transaction that spends an + /// outpoint a **different, already-confirmed** transaction of this + /// same wallet spent first, where that spender's block is proven to + /// be on the FINALIZED chain. Such a lock is permanently dead: every + /// peer rejects it as a double spend at the mempool boundary and + /// therefore relays nothing, so no IS-lock and no ChainLock can ever + /// be produced for it, and the only recovery is to discard the lock + /// and build a new one from currently-unspent inputs. + /// + /// The missing piece is the finalized-ancestry proof. The wallet layer + /// can see that a spender is confirmed, and it can see chainlock + /// contexts and the applied chainlock height, but both of those are + /// artifacts of a height-based promotion rather than evidence that the + /// spender's block belongs to the branch the chainlock covers (the SPV + /// chainlock manager counts a missing header as a passing block-hash + /// check, so a chainlock landing on a replacement branch ahead of its + /// headers promotes losing-branch records). Until the SPV layer + /// exposes an ancestry predicate, no code path may raise this variant: + /// the double-spend screen reports [`Self::AssetLockInputContested`] + /// for every hit, chainlocked-looking spenders included. + /// + /// Kept in the enum — with its fields and its FFI code — so the + /// reserved code stays stable for hosts across the change and for the + /// future emitter. Hosts must read nothing into its absence: it is not + /// a liveness signal, not a "not yet final" signal, and not a + /// statement about any particular lock. + /// + /// `height` is the block height of the confirmed spender when the + /// record carries block info. The variant carries no finality flag on + /// purpose: finality IS the variant — a constructor cannot produce a + /// terminal error that renders anything but chainlocked finality. + #[error( + "Asset lock {out_point} can never confirm: it spends {input}, which was \ + already spent by confirmed transaction {spent_by} (block height \ + {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, + }, + + /// The tracked asset-lock transaction spends an outpoint a + /// **different, already-confirmed** transaction of this same wallet + /// spent first. This is the verdict the double-spend screen always + /// emits on a hit — [`Self::AssetLockInputConflict`] has no emitter. + /// + /// The typical origin is a restored wallet: a rescan repopulates the + /// UTXO set from chain data, an asset-lock build selects an input the + /// restored view still believes is unspent, and the transaction that + /// actually spent it — often one of the wallet's own earlier asset + /// locks — has been confirmed for a long time. + /// + /// While the sibling stands, peers reject the lock as a double spend + /// and an unbounded proof wait would hang (Core stopped sending BIP61 + /// `reject` by default in 0.17, so the drop is silent and looks + /// exactly like a slow network). The sighting therefore bounds the + /// wait rather than replacing it: the resume still (re-)broadcasts and + /// still waits, and this is what the bounded wait expired with — a + /// `Broadcast`-status lock was also already sent on an earlier call. + /// + /// The verdict is PROVISIONAL and carries NO licence to discard the + /// tracked lock. Keep the lock and retry later. Note what a retry can + /// and cannot do: a chainlock arriving over the sibling does NOT + /// upgrade this to the terminal variant today, because the wallet + /// cannot prove the sibling's block is on the finalized branch (see + /// [`Self::AssetLockInputConflict`]). What a retry resolves is the + /// other direction — a reorg drops the sibling and the resume proceeds + /// normally. + /// + /// A conflict that persists across sessions still proves nothing about + /// finalized ancestry: persistence is not finality, and the sighting + /// can be a block record the load path restored from a previous + /// session whose block was reorganized out while the wallet was + /// offline. So repetition never licenses a discard either — only + /// [`Self::AssetLockInputConflict`], or an independent + /// finalized-ancestry proof, authorises dropping the tracked state. + /// Discarding a lock whose sibling turns out to sit on a losing branch + /// strands the credits of a lock a peer can still replay. No funds move + /// while the lock is kept: both signed transactions are this wallet's + /// own, so the value behind `input` lives on in `spent_by`. + /// + /// Raising this error is a definite verdict about the CONFLICT; NOT + /// raising it proves nothing — see the detection helper in + /// `wallet::asset_lock::sync::recovery` for why the scan is + /// best-effort. + #[error( + "Asset lock {out_point} cannot currently confirm: it spends {input}, \ + which confirmed transaction {spent_by} (block height {height:?}) has \ + taken — the verdict is provisional (the wallet cannot prove the \ + spender's finality); keep the lock and retry later" + )] + AssetLockInputContested { + out_point: dashcore::OutPoint, + input: dashcore::OutPoint, + spent_by: dashcore::Txid, + height: Option, + }, + /// Asset-lock coin selection came up short, so a host (and ultimately the /// wallet UI) can render a precise shortfall instead of a stringly-typed /// "Insufficient funds" message (dashpay/platform#4073). diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 674ddfc118c..3588aef2b54 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -117,12 +117,20 @@ impl PlatformWalletManager

{ core_balance.locked(), ); let platform_info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), core_wallet: wallet_info, generation: Arc::clone(&generation), identity_manager: IdentityManager::from(identity_manager), tracked_asset_locks, dpns_name_states: std::collections::BTreeMap::new(), }; + // Seed the double-spend screen's session memory from the + // freshly restored state: it closes the race where SPV's + // chainlock dispatcher promotion-evicts a restored spender + // before the first catch-up resume ever reads it. + crate::wallet::asset_lock::sync::recovery::seed_observed_input_conflicts( + &platform_info, + ); if wallet_id != expected_wallet_id { load_error = Some(PlatformWalletError::WalletCreation(format!( diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 063e10939bb..71bef57d723 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -373,6 +373,7 @@ impl PlatformWalletManager

{ .unwrap_or(wallet.wallet_id); let platform_info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), core_wallet: wallet_info, generation: Arc::clone(&generation), identity_manager: crate::wallet::identity::IdentityManager::new(), diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index cf63544023d..a9dbddba98c 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -250,6 +250,7 @@ pub(crate) async fn funded_wallet_manager_with_outputs( let generation = Arc::new(WalletGeneration::new()); let info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), core_wallet: ctx.managed_wallet, generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), @@ -378,6 +379,7 @@ pub(crate) async fn funded_wallet_manager_dual_standard( }; let generation = Arc::new(WalletGeneration::new()); let info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), core_wallet: ctx.managed_wallet, generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), @@ -480,6 +482,7 @@ pub(crate) async fn funded_wallet_manager_with_contact( }; let generation = Arc::new(WalletGeneration::new()); let info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), core_wallet: ctx.managed_wallet, generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), @@ -556,6 +559,7 @@ pub(crate) async fn funded_coinjoin_wallet_manager() -> ( let generation = Arc::new(WalletGeneration::new()); let info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), core_wallet: ctx.managed_wallet, generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), @@ -760,6 +764,7 @@ pub(crate) async fn mnemonic_wallet_manager( wallet: wallet.clone(), }; let info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), core_wallet: managed_wallet, generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 8e36af5768d..58fe2b063fc 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -439,6 +439,7 @@ mod tests { generation: std::sync::Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + observed_input_conflicts: Default::default(), dpns_name_states: BTreeMap::new(), } } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 7c0f5dc5767..3b24cec9b40 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -1125,13 +1125,16 @@ impl AssetLockManager { // inputs are re-spendable. A `MaybeSent` failure keeps both the // reservation and the resumable row. // - // The in-broadcast fence is held ACROSS this await — that is what it - // is for — and settled on the way out. It follows the reservation - // exactly: freed only where the reservation is freed (a definitive - // rejection whose `Built` row was actually removed), and otherwise - // left as a pending-spend fence until the wallet observes the spend. - // A cancellation or unwind inside `broadcast` reaches neither arm and - // settles as pending through `InBroadcastPin::drop`. + // The reported error type and the in-broadcast fence both follow the + // cleanup, never the broadcaster's verdict alone — they are decided + // by the one predicate. The definite-rejection contract is reported + // only when the row was actually untracked AND its reservation + // released, because that contract is precisely the promise that both + // happened; the fence — held ACROSS this await, which is what it is + // for — is freed on exactly that same condition and left as a + // pending-spend fence everywhere else, until the wallet observes the + // spend. A cancellation or unwind inside `broadcast` reaches no arm + // at all and settles as pending through `InBroadcastPin::drop`. let broadcast_outcome = self.broadcaster.broadcast(&tx).await; if let Err(e) = broadcast_outcome { if matches!(e, crate::broadcaster::BroadcastError::Rejected { .. }) { @@ -1186,6 +1189,33 @@ impl AssetLockManager { // the transaction reached the network after all. The // reservation stays held, and so must the fence. in_broadcast_pin.settle_pending_spend(); + // The cleanup did not run, so the definite-rejection + // contract does not hold either. `TransactionBroadcast` + // promises the caller that the row is gone, the inputs are + // free, and a rebuild is safe; here the advanced row is + // still tracked and resumable and its inputs are still + // reserved and fenced, so a caller honouring that promise + // would rebuild from other UTXOs and create a SECOND asset + // lock beside a transaction the advance says reached the + // network. The contract that matches what is actually true + // is the unknown outcome: do not retry, the row and its + // reservation are intact, resume the existing lock. + tracing::warn!( + %txid, + error = %e, + "asset lock broadcast was rejected, but a concurrent resume had \ + already advanced the row past Built; keeping the row and its \ + funding reservation and reporting an unknown outcome rather than \ + a definite rejection" + ); + return Err(PlatformWalletError::TransactionBroadcastUnconfirmed( + format!( + "asset lock {out_point} stays tracked and reserved: the \ + broadcast was rejected, but a concurrent resume had already \ + advanced the row past Built, so the transaction may be on \ + the network: {e}" + ), + )); } } else { // Ambiguous `MaybeSent`: the transaction may be on the network. @@ -2050,6 +2080,12 @@ mod tests { /// window, the cleanup must keep the row (guard) AND keep the funding /// reservation (release gate) — otherwise the still-tracked transaction /// would be resumable while its inputs are re-spendable. + /// + /// The error must say the same thing the cleanup did. The definite + /// rejection promises a released reservation and a safe rebuild, and + /// neither holds on this branch: a caller acting on that promise builds + /// a second asset lock beside a transaction the advance says reached the + /// network. Only the unknown outcome describes what actually happened. #[tokio::test] async fn rejected_broadcast_racing_concurrent_resume_keeps_row_and_reservation() { let (wallet_manager, wallet_id, _balance, signer) = @@ -2083,8 +2119,13 @@ mod tests { ) .await; assert!( - matches!(result, Err(PlatformWalletError::TransactionBroadcast(_))), - "rejection should still surface, got {result:?}" + matches!( + result, + Err(PlatformWalletError::TransactionBroadcastUnconfirmed(_)) + ), + "a rejection whose cleanup released nothing must surface as the \ + unknown outcome, never as the definite rejection that promises a \ + released reservation and a safe rebuild, got {result:?}" ); // The concurrently-advanced row survives the cleanup… diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/mod.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/mod.rs index 1818013686e..0b754997006 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/mod.rs @@ -6,5 +6,5 @@ mod proof; pub(crate) mod reconstruction; -mod recovery; +pub(crate) mod recovery; mod tracking; diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs index 3eb3d83b1cb..6d4b674d965 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs @@ -68,6 +68,17 @@ pub(in crate::wallet::asset_lock) fn funding_tx_record( account_index: u32, txid: &Txid, ) -> Option { + funding_accounts(accounts, account_index) + .find_map(|account| account.transactions().get(txid).cloned()) +} + +/// The account families a lock funded from `account_index` can have filed +/// its funding transaction under, in the order [`funding_tx_record`] +/// documents. +fn funding_accounts( + accounts: &key_wallet::account::ManagedAccountCollection, + account_index: u32, +) -> impl Iterator { let at_index = [ accounts.standard_bip44_accounts.get(&account_index), accounts.standard_bip32_accounts.get(&account_index), @@ -77,7 +88,59 @@ pub(in crate::wallet::asset_lock) fn funding_tx_record( .into_iter() .flatten() .chain(accounts.dashpay_receival_accounts.values()) - .find_map(|account| account.transactions().get(txid).cloned()) +} + +/// Whether any account family that could hold the funding transaction +/// reports `txid` as chainlock-finalized. +/// +/// This is the same finality question [`record_holds_local_finality`] asks, +/// for the record that is no longer there to ask it of. Under the default +/// `keep-finalized-transactions` configuration a chainlock promotion drops +/// the promoted record and keeps only its txid in the account's finalized +/// set, so from that moment on a lookup by record cannot see a finality the +/// wallet has already recorded — the txid set is the only place it survives. +/// Searched over the same families, in the same order, as +/// [`funding_tx_record`]. +pub(in crate::wallet::asset_lock) fn funding_tx_is_finalized( + accounts: &key_wallet::account::ManagedAccountCollection, + account_index: u32, + txid: &Txid, +) -> bool { + funding_accounts(accounts, account_index).any(|account| account.transaction_is_finalized(txid)) +} + +/// Whether `record` on its own already establishes local finality for a +/// funding transaction — the three record shapes [`AssetLockManager::wait_for_proof`] +/// turns into a proof, reduced to a yes/no. +/// +/// It exists so a caller holding a wallet read guard can ask the finality +/// question inside its own snapshot instead of taking a second read. The +/// answer must be read together with the rest of a decision that depends on +/// it; splitting the two reads lets finality land in between and be missed. +/// +/// `wallet_chain_lock_height` and `networks_match` come from the same +/// snapshot as `record`. They serve only the third shape — a record whose +/// own context is not yet promoted but whose block the wallet's applied +/// chainlock already buries — and carry the same chain-id refusal as the +/// proof builder: a `last_applied_chain_lock` persisted from a different +/// network says nothing about this record's block. +pub(in crate::wallet::asset_lock) fn record_holds_local_finality( + record: &TransactionRecord, + wallet_chain_lock_height: Option, + networks_match: bool, +) -> bool { + use key_wallet::transaction_checking::TransactionContext; + match &record.context { + TransactionContext::InstantSend(_) => true, + TransactionContext::InChainLockedBlock(_) => record.height().is_some(), + _ => { + networks_match + && matches!( + (wallet_chain_lock_height, record.height()), + (Some(chain_lock), Some(height)) if chain_lock >= height + ) + } + } } /// Variant of [`record_or_persister`] that swallows persister errors diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs index 7afdd62c26e..64b6414c07b 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs @@ -591,6 +591,7 @@ mod tests { .insert(7, account); let info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), core_wallet: ctx.managed_wallet, generation: std::sync::Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index 8b47ca7c1c2..e0a88a2e6f0 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -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::orchestration::UNCONFIRMED_BROADCAST_PROOF_TIMEOUT; @@ -189,7 +193,412 @@ impl AssetLockManager { // Resumable asset lock // --------------------------------------------------------------------------- +/// Find the first outpoint of `lock`'s transaction that some **other, +/// confirmed** transaction of this wallet already spent, returning +/// `(conflicting_input, spending_txid, spender_height)`. +/// +/// A hit means the asset lock is a double spend of a settled outpoint. +/// Peers reject such a transaction at the mempool boundary and relay +/// nothing back — Core has not sent BIP61 `reject` messages by default +/// since 0.17 — so while the spender stands the lock can neither be mined +/// nor IS-locked, and an unbounded proof wait on it never terminates. +/// Callers use a hit to BOUND that wait and, if it expires with the +/// conflict still standing, to report +/// [`PlatformWalletError::AssetLockInputContested`]. +/// +/// **A hit does not refuse the resume**, and must not be made to. Part of +/// the history this reads is rebuilt at load from persisted rows, and such +/// a record is never checked against the active chain: a wallet offline +/// while the spender's block was reorganized out restores the sighting all +/// the same, and nothing repairs it — key-wallet keeps an existing +/// confirmed record even when the transaction is re-observed unconfirmed +/// (reconciling record, UTXOs and balances together is key-wallet-boundary +/// work that has not landed). Short-circuiting +/// ahead of the (re-)broadcast and the proof wait on that evidence would +/// hand back the same verdict on every resume and every launch for a lock +/// that can in fact still confirm. Running the wait keeps the recovery +/// path open and gives an arriving proof its window to settle the lock +/// outright. +/// +/// **The gate is `is_confirmed()`, deliberately not `is_chain_locked()`.** +/// Under the default `keep-finalized-transactions = OFF` build, +/// `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. +/// +/// **The verdict is always provisional, so no finality travels out of +/// here.** Reporting the conflict on a confirmed sibling is fund-safe: +/// that sibling is necessarily one of this wallet's own transactions +/// (nobody else can sign this wallet's outpoints), so the value it +/// carries is already the wallet's, and bounding the doomed wait costs +/// nothing — the lock is unrelayable for as long as the sibling stands. +/// What no evidence reachable here justifies is *refusing* the resume +/// outright (see above) or *discarding* the tracked lock: the sibling's +/// block can still reorg out, at which point a peer can replay the +/// already-broadcast lock and it can confirm — with its tracking state +/// gone, the confirmed lock's credits would be stranded. +/// +/// A terminal verdict would need proof that the spender's block is an +/// ancestor of the FINALIZED chain, and the wallet layer cannot produce +/// one. A record's `is_chain_locked()` context and the wallet's applied +/// chainlock height are both promotion artifacts, not ancestry proofs: +/// promotion is height-based, and the pinned SPV chainlock manager +/// counts a missing header as a passing block-hash check, so a chainlock +/// arriving on a replacement branch ahead of its headers promotes +/// records that sit on the losing branch. Provenance does not rescue the +/// check either — under `keep-finalized-transactions` the key wallet +/// height-mutates a stale RESTORED `InBlock` record straight to +/// `InChainLockedBlock`, so a restored-record guard is bypassed by the +/// same flaw. Until SPV exposes a finalized-ancestry predicate, this +/// helper reports the conflict and nothing about its finality. +/// +/// **Best-effort in one direction only.** A hit is conclusive: the +/// spender is a confirmed transaction sitting in this wallet's own +/// 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)> { + let lock_txid = lock.transaction.txid(); + let lock_inputs: BTreeSet = lock + .transaction + .input + .iter() + .map(|input| input.previous_output) + .collect(); + + let history = info.core_wallet.transaction_history(); + + // The source of truth: live transaction history. The load path restores + // the relevant spender records into it (see the unresolved-record + // restore in the FFI persister), so the same records serve app-launch + // catch-up and the live session — and `apply_chain_lock` keeps them + // honest in the strengthening direction, promoting a record when a + // chainlock buries its block. Retraction is the direction nothing + // performs yet; see the memory branch below. An earlier revision + // carried a separate load-time snapshot map instead, holding copies + // that not even a chainlock could promote, so its verdicts could not + // resolve at all. + if let Some(hit) = history + .iter() + .filter(|record| record.txid != lock_txid && record.is_confirmed()) + .find_map(|record| { + let conflicting_input = record + .transaction + .input + .iter() + .map(|input| input.previous_output) + .find(|outpoint| lock_inputs.contains(outpoint))?; + Some((conflicting_input, record.txid, record.height())) + }) + { + // Remember the observation before returning it. Promotion is also + // EVICTION under the default `keep-finalized-transactions = OFF` + // build: the moment a chainlock buries the spender's block, + // `apply_chain_lock` removes the record this scan just read, and a + // retry would otherwise find nothing at all and fall back into the + // proof wait. The session memory below keeps the conflict visible + // across that disappearance. A poisoned mutex degrades to no + // memory, never a failure. + let (input, spender, height) = hit; + if let (Some(h), Ok(mut cache)) = (height, info.observed_input_conflicts.lock()) { + cache.insert( + input, + crate::wallet::platform_wallet::ObservedInputConflict { spender, height: h }, + ); + } + return Some((input, spender, height)); + } + + // No live record — consult the session memory. Two cases per + // remembered input: + // * the remembered spender is back in history UNCONFIRMED: its block + // was reorged away and the record demoted in place — the memory is + // stale, retract it; + // * the spender has LEFT history: promotion-eviction is the only path + // that removes a record (a reorg demotes, nothing deletes), so the + // remembered in-block spend still stands and still makes the proof + // wait pointless. The eviction attests a height-based promotion, + // not finalized ancestry, so the verdict it feeds stays the + // provisional one — as it does everywhere else here. + // + // The first case is aspirational today: no live pipeline performs that + // demotion. The wallet's transaction checker only ever strengthens a + // record's context, so a record filed `InBlock` still reads as + // confirmed once its block is reorged away, and demoting it here alone + // would desync it from the received UTXOs and balances that only the + // key-wallet boundary owning all three can move with it. Until that + // reconciliation exists upstream, a reorged-out sibling leaves the + // provisional verdict standing — the lock it contests is bounded by + // the resume's proof-wait backstop, not freed by a retraction. + let Ok(mut cache) = info.observed_input_conflicts.lock() else { + return None; + }; + for input in &lock_inputs { + let Some(observed) = cache.get(input).copied() else { + continue; + }; + // Same invariant as the live scan's `record.txid != lock_txid`: + // a lock's own spend of its input is not a conflict with itself, + // full stop. Two tracked locks sharing an input can cross-remember + // each other, and after the winner's record is promotion-evicted a + // resume of the WINNER must not read the memory as evidence + // against it. + if observed.spender == lock_txid { + continue; + } + if let Some(record) = history + .iter() + .find(|record| record.txid == observed.spender) + { + if !record.is_confirmed() { + cache.remove(input); + } + // A confirmed record for this spender would have been the + // scan's hit above; nothing to add here either way. + continue; + } + return Some((*input, observed.spender, Some(observed.height))); + } + None +} + +/// The deadline a proof wait runs under once +/// [`first_confirmed_input_conflict`] has reported a sighting. +/// +/// The sighting cannot refuse the wait (see that function), but it does cap +/// it at [`UNCONFIRMED_BROADCAST_PROOF_TIMEOUT`], shortening a caller's +/// longer budget down to the policy's own. While the spender stands the +/// lock is unrelayable, so a caller's extra minutes only delay the verdict +/// a host needs in order to explain the stalled funding attempt. Nothing is given up on the recovery path the cap exists to keep +/// open: a proof that has already arrived resolves on `wait_for_proof`'s +/// first pass, straight from the record, before any deadline is consulted. +fn conflict_capped_proof_wait(timeout: Option) -> Option { + Some( + timeout.map_or(UNCONFIRMED_BROADCAST_PROOF_TIMEOUT, |caller| { + caller.min(UNCONFIRMED_BROADCAST_PROOF_TIMEOUT) + }), + ) +} + +/// Seed the double-spend screen's session memory from freshly restored +/// state, before any resume runs. +/// +/// The screen normally learns conflicts by reading them from history — but +/// SPV's chainlock dispatcher can win the race to the wallet lock and +/// promotion-evict a restored spender record before the first catch-up +/// resume ever reads it, leaving neither a record nor a memory: the silent +/// proof-wait hang all of this exists to prevent. Seeding at load closes +/// that window. Seeding decides only whether the screen fires at all: +/// entries seeded here surface as the same provisional verdict every +/// other sighting does. +pub(crate) fn seed_observed_input_conflicts(info: &PlatformWalletInfo) { + let Ok(mut cache) = info.observed_input_conflicts.lock() else { + return; + }; + let history = info.core_wallet.transaction_history(); + for lock in info.tracked_asset_locks.values() { + if !matches!( + lock.status, + AssetLockStatus::Built | AssetLockStatus::Broadcast + ) { + continue; + } + let lock_txid = lock.transaction.txid(); + for input in lock.transaction.input.iter().map(|i| i.previous_output) { + let Some(record) = history.iter().find(|record| { + record.txid != lock_txid + && record.is_confirmed() + && record + .transaction + .input + .iter() + .any(|i| i.previous_output == input) + }) else { + continue; + }; + let Some(height) = record.height() else { + continue; + }; + cache.insert( + input, + crate::wallet::platform_wallet::ObservedInputConflict { + spender: record.txid, + height, + }, + ); + } + } +} + impl AssetLockManager { + /// Re-run the double-spend screen once a proof wait has expired, and + /// render a conflict that still stands as the verdict explaining that + /// expiry. + /// + /// Reading the screen AFTER the wait rather than before it is what + /// keeps a restored sighting from condemning a lock forever. Restored + /// block records enter history from persisted rows without ever being + /// checked against the active chain, and no event demotes one whose + /// block was reorganized out while the wallet was offline; a resume + /// that refused to broadcast or wait on that evidence would report the + /// same conflict on every launch for the rest of the lock's life. + /// Running the wait first gives an arriving proof its window: a proof + /// settles the lock and this is never reached; a sighting that still + /// stands yields only this provisional verdict, never a terminal one. + /// + /// The verdict is always the provisional + /// [`PlatformWalletError::AssetLockInputContested`] — see + /// [`first_confirmed_input_conflict`] for why nothing reachable here + /// can prove the spender's block is on the finalized branch. + /// + /// A proof still outranks the sighting at this point, exactly as it + /// does during the wait. The wait's expiry is a deadline race, not a + /// statement about the lock: `wait_for_proof` re-reads the record at + /// the top of each iteration and then selects between the notification + /// and the deadline, so finality becoming visible while the deadline + /// branch wins arrives one instant too late to be seen there — and a + /// concurrent resume under a longer budget can equally have attached + /// the proof and advanced the row while this one was expiring. Either + /// way a sibling is still sitting in history, so the scan alone would + /// answer "contested" for a lock that is already settled. A + /// zero-duration proof probe runs first (a single local + /// record/persister check — the same one the rejected-re-broadcast + /// paths use, and no network wait), because only it can reach the + /// persister for a record the in-memory map has evicted. + /// + /// Everything the verdict is then built from is read from ONE wallet + /// snapshot: the funding transaction's own finality — its record, or the + /// finalized-txid set a promotion that evicted the record leaves behind — + /// the tracked row's proof and status, and the sibling scan. Splitting + /// those reads is what let the race back in — finality landing after the + /// probe's own lookup but before a later read left a locally final lock + /// reported as contested, because the later read consulted only the row, + /// which a record-only finality never advances. Holding one guard makes + /// the three answers describe the same instant. + /// + /// Every suppression leaves the caller's error alone rather than + /// replacing it: the row is left where it was, so the next resume + /// returns the proof from the record on `wait_for_proof`'s first pass. + async fn input_conflict_verdict(&self, out_point: &OutPoint) -> Option { + if self + .wait_for_proof(out_point, Some(Duration::ZERO)) + .await + .is_ok() + { + tracing::info!( + outpoint = %out_point, + "resume_asset_lock: the proof wait expired, but the local record \ + already holds finality — the input conflict is not the \ + explanation and no contested verdict is reported" + ); + return None; + } + + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id)?; + let lock = info.tracked_asset_locks.get(out_point)?; + // Finality that landed during the probe. The record can carry it + // while the row still says `Broadcast` with no proof attached — + // `LockNotifyHandler` wakes waiters without advancing the status — + // so the row check below cannot stand in for this one. + // + // Asked of the record AND of the account's finalized-txid set, + // because the promotion that grants finality is also what takes the + // record away: under the default `keep-finalized-transactions` + // configuration a chainlocked record is evicted and only its txid + // retained, so a chainlock landing between the probe and this + // snapshot leaves nothing for a record lookup to find. Reading only + // the record there condemned a locally final lock on the strength of + // a sibling the promotion had not touched. + { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + let wallet_chain_lock_height = info + .core_wallet + .metadata + .last_applied_chain_lock + .as_ref() + .map(|chain_lock| chain_lock.block_height); + let networks_match = info.network() == self.sdk.network; + let record_is_final = super::proof::funding_tx_record( + &info.core_wallet.accounts, + lock.account_index, + &out_point.txid, + ) + .is_some_and(|record| { + super::proof::record_holds_local_finality( + &record, + wallet_chain_lock_height, + networks_match, + ) + }); + if record_is_final + || super::proof::funding_tx_is_finalized( + &info.core_wallet.accounts, + lock.account_index, + &out_point.txid, + ) + { + tracing::info!( + outpoint = %out_point, + "resume_asset_lock: the proof wait expired, but the funding \ + transaction reached finality while it was being read — the \ + input conflict is not the explanation and no contested \ + verdict is reported" + ); + return None; + } + } + // The screen speaks only for the two proof-less statuses + // (`resume_asset_lock` screens on exactly those), and a row carrying + // a proof is settled by evidence this scan cannot outrank — see the + // status match in `resume_asset_lock`. + if lock.proof.is_some() + || !matches!( + lock.status, + AssetLockStatus::Built | AssetLockStatus::Broadcast + ) + { + tracing::info!( + outpoint = %out_point, + status = ?lock.status, + has_proof = lock.proof.is_some(), + "resume_asset_lock: the proof wait expired, but the tracked lock \ + has since been settled — no contested verdict is reported" + ); + return None; + } + let (input, spent_by, height) = first_confirmed_input_conflict(info, lock)?; + tracing::warn!( + outpoint = %out_point, + %input, + %spent_by, + ?height, + "resume_asset_lock: the proof wait expired with the input conflict \ + still standing; reporting it as the provisional verdict" + ); + Some(PlatformWalletError::AssetLockInputContested { + out_point: *out_point, + input, + spent_by, + height, + }) + } + /// Resume a tracked asset lock from whatever stage it's at. /// /// Looks up the tracked lock by `txid`, then: @@ -214,17 +623,49 @@ impl AssetLockManager { /// `ChainLocked` the proof already exists and no wait happens, so the /// value is moot. /// - /// `None` requests an unbounded wait, and gets one **only** where this - /// call obtained positive evidence the transaction is on the network: - /// the `Built` arm whose re-broadcast returned `Ok`. Every other - /// proof-waiting path substitutes - /// [`UNCONFIRMED_BROADCAST_PROOF_TIMEOUT`], because the alternative is a - /// `Notify` loop that never terminates under the FFI's - /// `runtime().block_on(...)` — a permanently pinned host thread rather - /// than a late answer. Expiry leaves the tracked row untouched, so the - /// next resume picks up a proof that arrives later straight from the - /// record; on the `Broadcast` arm it is reported as + /// `None` does not request an unbounded wait — it declines to name a + /// bound, and every proof-waiting path then substitutes + /// [`UNCONFIRMED_BROADCAST_PROOF_TIMEOUT`]. No evidence a resume can + /// gather rules out a wait that can never end: even a re-broadcast the + /// broadcaster positively accepted only establishes that the + /// transaction reached the network, and a sibling spending the same + /// outpoint may confirm the instant afterwards, at which point no + /// proof for this transaction can ever arrive. The wait itself cannot + /// see that happen — it wakes on lock events and re-reads the tracked + /// funding transaction only — so an unbounded one is a `Notify` loop + /// with no terminating event, which under the FFI's + /// `runtime().block_on(...)` pins a host thread permanently rather + /// than merely delaying an answer. Expiry leaves the tracked row + /// untouched, so the next resume picks up a proof that arrives later + /// straight from the record; on the `Built` and `Broadcast` arms it is + /// reported as /// [`PlatformWalletError::TransactionBroadcastUnconfirmed`]. + /// + /// A `Built` / `Broadcast` lock is screened by + /// [`first_confirmed_input_conflict`], and a hit never refuses the + /// resume. It caps the wait at the policy's own bound — a caller's + /// longer budget only delays a verdict a lock no peer will relay cannot + /// escape — and the verdict is read afterwards by + /// [`Self::input_conflict_verdict`]: a proof that arrives during the + /// bounded wait settles the lock normally, and a conflict the wait did + /// not clear is reported as the + /// provisional [`PlatformWalletError::AssetLockInputContested`], which + /// keeps the lock tracked for a later retry. Blocking the + /// broadcast-and-wait outright is what this evidence does NOT support: + /// the screen also reads records the load path rebuilt from persisted + /// rows, which no event can demote once their block has been + /// reorganized out behind an offline wallet, so a pre-emptive refusal + /// would return the same verdict on every launch for a lock that is + /// free to confirm. Proving the spender's block is on the finalized + /// branch would take an ancestry predicate the wallet does not have — + /// chainlock contexts and applied chainlock heights are promotion + /// artifacts, not ancestry proofs — so the terminal + /// [`PlatformWalletError::AssetLockInputConflict`] is never + /// constructed here. A conflict that persists across sessions is in + /// practice permanent, but acting on that (discarding the tracked + /// lock) is host and user policy; the SDK does not license it + /// unilaterally on this evidence. The screen is one-sided — read its + /// docs before treating a clean pass as evidence the lock is alive. pub async fn resume_asset_lock( &self, out_point: &OutPoint, @@ -233,7 +674,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) @@ -254,20 +695,71 @@ 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, ) }; + // A sighting does NOT stop the resume. It cannot: the screen reads + // records the load path rebuilt from persisted rows, and a block + // record restored that way has never been checked against the + // active chain. A wallet that was offline while the spender's block + // was reorganized out restores the sighting anyway, and nothing + // repairs it — key-wallet keeps an existing confirmed record even + // when that transaction is re-observed unconfirmed, so the stale + // sighting stands until record/UTXO/balance reconciliation lands + // at the key-wallet boundary that owns all three. + // Refusing the (re-)broadcast and the proof wait on that evidence + // would return the same verdict on every resume and every launch + // for a lock that is in fact free to confirm. + // + // So the sighting only caps the wait (below), shortening whatever + // budget the resume would otherwise have run under. The verdict is + // read afterwards, from whatever live synchronization left behind + // while the wait ran — a proof that arrives settles the lock + // outright, and a conflict the wait did not clear becomes the error + // explaining the expiry. + if let Some((input, spent_by, height)) = input_conflict { + tracing::warn!( + outpoint = %out_point, + %input, + %spent_by, + ?height, + "resume_asset_lock: asset lock double-spends an outpoint a \ + confirmed transaction of this wallet already consumed; \ + resuming under a bounded wait rather than refusing, since \ + the sighting may be restored evidence no live event can \ + retract" + ); + } + // 2. Resume from the current status. let proof = match status { AssetLockStatus::Built => { // Re-broadcast and wait for proof. // - // Only a DEFINITE rejection stops the resume. `MaybeSent` + // No verdict this broadcaster can return ends the resume by + // itself. `MaybeSent` // means the outcome is unknown — and for a lock stuck at // `Built` that is the expected answer when the app died // between a successful broadcast and this status advance: @@ -284,14 +776,42 @@ impl AssetLockManager { // classifies every failure that way by construction, and the // SPV broadcaster only reaches `Rejected` on `NotConnected`. // So the advance above cannot be read as evidence the tx is - // live, and the proof wait that follows it must not be the - // unbounded one — at the `resume_asset_lock(.., None)` - // production call sites that would turn a prompt broadcast - // failure into a permanent hang. Bound it, and translate the - // expiry back into the `TransactionBroadcastUnconfirmed` the - // caller used to get immediately. - let maybe_sent_reason = match self.broadcaster.broadcast(&tx).await { - Ok(_) => None, + // live, and the expiry of the bounded wait that follows it is + // translated back into the `TransactionBroadcastUnconfirmed` + // the caller used to get immediately. + // + // A DEFINITE `Rejected` is scoped to the attempt that + // produced it, exactly as on the `Broadcast` arm below: with + // the production `SpvBroadcaster` it is reachable only from + // an unstarted client and dash-spv's zero-connected-peers + // check, so it means "*this* send never left the device". It + // is not a statement about the row. A lock sits at `Built` + // after a SUCCESSFUL broadcast too — the app killed between + // the send and the status advance is the very case this arm + // exists for — so the original may be in a mempool or already + // mined, and the record may already carry its proof. The + // rejection is therefore handled here rather than returned: + // probe the local record once without waiting, and where a + // conflict was sighted go on to the bounded wait, which is + // the only path that can produce the sighting's verdict. + // + // What must NOT happen is the raw conversion. `Rejected` + // becomes `TransactionBroadcast`, the FFI's definite- + // rejection code 26, whose contract is that Core rejected the + // transaction, the inputs' reservation was released, and a + // rebuild is safe. Only the initial build path performs that + // untrack-and-release; the resume keeps both the row and its + // reservation, so a host honouring code 26 here would rebuild + // from other UTXOs and create a SECOND asset lock beside a + // possibly-live one. The non-terminal + // `TransactionBroadcastUnconfirmed` is the contract that + // matches what this arm actually knows: outcome unknown, + // inputs still reserved, do not retry. + let mut local_proof = None; + let mut maybe_sent_reason = None; + let mut undispatched = None; + match self.broadcaster.broadcast(&tx).await { + Ok(_) => {} Err(BroadcastError::MaybeSent { reason }) => { tracing::warn!( outpoint = %out_point, @@ -301,45 +821,167 @@ impl AssetLockManager { have rejected it — the broadcaster cannot tell); advancing to \ Broadcast and waiting for proof under a bounded timeout" ); - Some(reason) + maybe_sent_reason = Some(reason); } - Err(rejected) => return Err(rejected.into()), - }; - let cs = self - .advance_asset_lock_status(out_point, AssetLockStatus::Broadcast, None) - .await?; - self.queue_asset_lock_changeset(cs); - let proof = match (&maybe_sent_reason, timeout) { - // Ambiguous re-broadcast AND an unbounded wait: the only - // combination that can hang forever. Substitute the bound - // and translate its expiry back into the broadcast error - // the caller used to get immediately. - // - // Callers that passed their own timeout are left exactly - // as they were, `FinalityTimeout` and all — the shielded - // seed pool treats that error as a pacing signal and - // resumes the lock later, so re-typing it would break a - // working flow to fix an unrelated one. - (Some(reason), None) => { - match self - .wait_for_proof(out_point, Some(UNCONFIRMED_BROADCAST_PROOF_TIMEOUT)) - .await - { - Ok(proof) => proof, - Err(PlatformWalletError::FinalityTimeout(_)) => { + Err(rejected @ BroadcastError::Rejected { .. }) => { + match self.wait_for_proof(out_point, Some(Duration::ZERO)).await { + Ok(proof) => { + tracing::info!( + outpoint = %out_point, + error = %rejected, + "resume_asset_lock: re-broadcast of a Built lock was \ + rejected before dispatch, but the local record already \ + holds finality — completing the resume from the local \ + proof" + ); + local_proof = Some(proof); + } + Err(probe_err) => { + // No proof, and this attempt never left the + // device. Without a sighting there is nothing + // this call can still learn — the `Broadcast` + // arm returns the same unknown-outcome error + // here, and for the same reason. + if input_conflict.is_none() { + tracing::warn!( + outpoint = %out_point, + error = %rejected, + probe = %probe_err, + "resume_asset_lock: re-broadcast of a Built lock \ + was rejected before dispatch and no local proof \ + exists — this attempt proves nothing about an \ + earlier send; leaving the row tracked at Built \ + and failing the resume as an unknown outcome" + ); + return Err( + PlatformWalletError::TransactionBroadcastUnconfirmed( + format!( + "asset lock {out_point} remains tracked at \ + Built after the re-broadcast was rejected \ + before dispatch; an earlier broadcast may \ + still be on the network: {rejected}" + ), + ), + ); + } + tracing::warn!( + outpoint = %out_point, + error = %rejected, + probe = %probe_err, + "resume_asset_lock: re-broadcast of a Built lock was \ + rejected before dispatch with an input conflict \ + sighted; entering the bounded proof wait, since the \ + sighting bounds the wait rather than replacing it and \ + its verdict is only readable afterwards" + ); + undispatched = Some(rejected.to_string()); + } + } + } + } + let proof = if let Some(proof) = local_proof { + proof + } else { + // The status advance belongs to a send that actually + // dispatched. An attempt rejected before dispatch leaves + // the row exactly where it was, so the next resume + // re-sends the transaction instead of dropping into the + // `Broadcast` arm's wait for a send that never happened. + if undispatched.is_none() { + let cs = self + .advance_asset_lock_status(out_point, AssetLockStatus::Broadcast, None) + .await?; + self.queue_asset_lock_changeset(cs); + } + // Every resumed lock waits under a deadline, and a + // sighting only shortens it. An accepted re-broadcast is + // evidence the transaction reached the network, never + // evidence it can still confirm — a sibling spending the + // same outpoint may confirm at any point after the + // pre-broadcast screen ran, and from that moment no proof + // for this transaction can ever arrive. Nothing inside the + // wait would notice: it wakes on lock events and re-reads + // the tracked funding transaction only, so an unbounded + // wait started before that sibling confirmed never ends. + // The bound costs nothing — the row is left at `Broadcast`, + // so a proof that lands after the expiry is returned by the + // very next resume, straight from the record, without + // waiting at all. + let bounded = if input_conflict.is_some() { + conflict_capped_proof_wait(timeout) + } else { + timeout.or(Some(UNCONFIRMED_BROADCAST_PROOF_TIMEOUT)) + }; + match self.wait_for_proof(out_point, bounded).await { + Ok(proof) => proof, + Err(expiry @ PlatformWalletError::FinalityTimeout(_)) => { + // The wait has now given live synchronization its + // window, so the screen is re-read and what it says + // NOW decides. A conflict it still reports is the + // honest explanation for the expiry; one it has + // retracted meanwhile leaves the pre-existing + // outcome untouched. + if let Some(contested) = self.input_conflict_verdict(out_point).await { + return Err(contested); + } + // A caller who chose its own bound is left exactly + // as it was, `FinalityTimeout` and all. Every + // re-typing below exists to keep an UNBOUNDED wait + // from hanging on a signal that cannot arrive, and + // that reason is absent the moment the caller + // named a deadline: the shielded seed pool reads + // `FinalityTimeout` as a pacing signal and resumes + // the lock later, so substituting a do-not-retry + // error for the bound it asked for would break a + // working flow to fix an unrelated one. The check + // comes FIRST because both translations below are + // reachable under an explicit timeout. + if timeout.is_some() { + return Err(expiry); + } + // The wait ran on a send that never dispatched, so + // the outcome of any earlier one is still unknown + // and the row is still tracked and reserved. That + // is the unknown-outcome contract, never the + // definite-rejection code the raw conversion would + // have produced. + if let Some(rejection) = undispatched { return Err(PlatformWalletError::TransactionBroadcastUnconfirmed( format!( + "asset lock {} remains tracked at Built after the \ + re-broadcast was rejected before dispatch and no \ + InstantSend/ChainLock proof arrived within {:?}; an \ + earlier broadcast may still be on the network: {}", + out_point, UNCONFIRMED_BROADCAST_PROOF_TIMEOUT, rejection + ), + )); + } + // The caller declined to name a bound, so the + // expiry is this policy's own and says nothing + // about the row: the transaction was dispatched, + // it is still tracked and reserved, and only the + // proof is missing. That is the unknown-outcome + // contract, the same one the `Broadcast` arm + // returns from the identical position. + return Err(PlatformWalletError::TransactionBroadcastUnconfirmed( + match &maybe_sent_reason { + Some(reason) => format!( "asset lock {} was re-broadcast with an unknown \ outcome and no InstantSend/ChainLock proof arrived \ within {:?}: {}", out_point, UNCONFIRMED_BROADCAST_PROOF_TIMEOUT, reason ), - )) - } - Err(e) => return Err(e), + None => format!( + "asset lock {} was re-broadcast but no \ + InstantSend/ChainLock proof arrived within {:?}; \ + the lock remains tracked and resumable", + out_point, UNCONFIRMED_BROADCAST_PROOF_TIMEOUT + ), + }, + )); } + Err(e) => return Err(e), } - _ => self.wait_for_proof(out_point, timeout).await?, }; self.validate_or_upgrade_proof(proof, account_index, out_point) .await? @@ -497,10 +1139,24 @@ impl AssetLockManager { let proof = if let Some(proof) = local_proof { proof } else { - let bounded = timeout.or(Some(UNCONFIRMED_BROADCAST_PROOF_TIMEOUT)); + let bounded = if input_conflict.is_some() { + conflict_capped_proof_wait(timeout) + } else { + timeout.or(Some(UNCONFIRMED_BROADCAST_PROOF_TIMEOUT)) + }; match self.wait_for_proof(out_point, bounded).await { Ok(proof) => proof, - Err(PlatformWalletError::FinalityTimeout(_)) if timeout.is_none() => { + Err(expiry @ PlatformWalletError::FinalityTimeout(_)) => { + // Same reading as the `Built` arm: the wait gave + // live synchronization its window, so the screen + // is re-read afterwards and a conflict that still + // stands explains the expiry. + if let Some(contested) = self.input_conflict_verdict(out_point).await { + return Err(contested); + } + if timeout.is_some() { + return Err(expiry); + } let reason = format!( "asset lock {} is tracked as broadcast but no \ InstantSend/ChainLock proof arrived within {:?}; the \ @@ -731,11 +1387,20 @@ 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::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; use key_wallet_manager::WalletManager; @@ -754,7 +1419,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 @@ -766,9 +1431,15 @@ mod tests { /// Captures the exact transaction passed to the resumed `Built` branch. /// Recovery must never rebuild a replacement transaction/outpoint. + /// + /// `reject` reproduces the production `SpvBroadcaster` before it is + /// connected: the send is recorded (it was attempted) and then refused + /// with the DEFINITE `Rejected`, which on that broadcaster means only + /// that this attempt never left the device. #[derive(Default)] struct RecordingBroadcaster { transactions: Mutex>, + reject: bool, } #[async_trait] @@ -778,6 +1449,11 @@ mod tests { .lock() .expect("recording broadcaster mutex") .push(transaction.clone()); + if self.reject { + return Err(BroadcastError::Rejected { + reason: "simulated pre-send rejection".to_string(), + }); + } Ok(transaction.txid()) } } @@ -807,6 +1483,108 @@ mod tests { } } + /// Persistence stub that mutates the wallet from inside the N-th + /// persister-backed record lookup, placing a change at an interleaving + /// no test can otherwise reach. + /// + /// `wait_for_proof` reads the in-memory record under the wallet lock, + /// DROPS that guard, and only then falls back to the persister — so a + /// mutation applied here lands strictly after the probe's own read and + /// strictly before whatever the caller reads next. That is the exact gap + /// the verdict has to survive: finality (or a retraction) that becomes + /// visible between the probe and the snapshot the verdict is built from. + /// + /// The lookup is synchronous, so the wallet is taken with `try_write` — + /// sound precisely because no wallet guard is held across it. + struct InterleavedPersistence { + wallet_manager: Arc>>, + wallet_id: WalletId, + /// Zero-based index of the persister lookup to mutate on. + target_lookup: usize, + lookups: std::sync::atomic::AtomicUsize, + #[allow(clippy::type_complexity)] + mutate: Mutex>>, + } + + impl InterleavedPersistence { + fn new( + wallet_manager: Arc>>, + wallet_id: WalletId, + target_lookup: usize, + mutate: impl FnOnce(&mut PlatformWalletInfo) + Send + 'static, + ) -> Self { + Self { + wallet_manager, + wallet_id, + target_lookup, + lookups: std::sync::atomic::AtomicUsize::new(0), + mutate: Mutex::new(Some(Box::new(mutate))), + } + } + + fn fired(&self) -> bool { + self.mutate.lock().expect("interleave mutex").is_none() + } + } + + impl PlatformWalletPersistence for InterleavedPersistence { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + + fn get_core_tx_record( + &self, + _wallet_id: WalletId, + _txid: &Txid, + ) -> Result, PersistenceError> { + let lookup = self + .lookups + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if lookup == self.target_lookup { + if let Some(mutate) = self.mutate.lock().expect("interleave mutex").take() { + let mut wm = loop { + if let Ok(guard) = self.wallet_manager.try_write() { + break guard; + } + std::thread::yield_now(); + }; + mutate( + wm.get_wallet_info_mut(&self.wallet_id) + .expect("wallet must remain registered"), + ); + } + } + // This backend keeps no records of its own; the mutation above is + // its whole purpose. + Ok(None) + } + } + + /// File `record` into the wallet's BIP44 account 0 — the synchronous + /// twin of `ConflictFixture::file_record`, for use from inside + /// [`InterleavedPersistence`]. + fn insert_record(info: &mut PlatformWalletInfo, record: TransactionRecord) { + info.core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("funded fixture has BIP44 account 0") + .transactions_mut() + .insert(record.txid, record); + } + #[tokio::test] async fn built_resume_rebroadcasts_original_and_typed_failures_do_not_broadcast() { let (wallet_manager, wallet_id, _balance, signer) = @@ -1023,21 +1801,42 @@ mod tests { ); } - /// A DEFINITE rejection is the opposite case and must keep failing the - /// resume: nothing is on the network, so no proof can ever arrive, and - /// the lock stays at `Built` for a later retry to re-send. + /// A DEFINITE rejection also fails the resume, but as an UNKNOWN + /// outcome — never as the definite-rejection contract. + /// + /// `Rejected` is scoped to the attempt that produced it: the production + /// `SpvBroadcaster` reaches it only from an unstarted client and the + /// zero-connected-peers check, so it means "this send never left the + /// device". A row sits at `Built` after a SUCCESSFUL broadcast too (the + /// app killed between the send and the status advance), so an earlier + /// send may be in a mempool or already mined. `TransactionBroadcast` — + /// the FFI's code 26 — would tell the host that Core rejected the + /// transaction, that its inputs' reservation was released and that a + /// rebuild is safe; the resume releases nothing and keeps the row, so a + /// host honouring that would build a SECOND asset lock beside a + /// possibly-live one. Only the initial build path, which does untrack + /// and release, may emit 26. #[tokio::test] - async fn built_resume_still_fails_on_a_definite_rejection() { + async fn built_resume_of_a_rejected_rebroadcast_reports_an_unknown_outcome() { let (error, status) = resume_built_lock_with(Arc::new(AlwaysRejectedBroadcaster)).await; assert!( - matches!(error, PlatformWalletError::TransactionBroadcast(_)), - "a definite rejection must surface as a broadcast failure: {error:?}" + !matches!(error, PlatformWalletError::TransactionBroadcast(_)), + "a re-broadcast that never left the device is not evidence that an earlier \ + send was rejected, so it must not claim the definite-rejection contract \ + while the row and its reservation are kept: {error:?}" + ); + assert!( + matches!( + error, + PlatformWalletError::TransactionBroadcastUnconfirmed(_) + ), + "a rejected re-broadcast must fail the resume as an unknown outcome: {error:?}" ); assert_eq!( status, AssetLockStatus::Built, - "a tx that never entered the network must stay resumable at Built" + "a send that never dispatched must leave the row resumable at Built" ); } @@ -1134,6 +1933,7 @@ mod tests { } let restored_wallet = Wallet::new_external_signable(Network::Testnet, wallet_id, accounts); let mut restored_info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), core_wallet: ManagedWalletInfo::from_wallet(&restored_wallet, 0), generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), @@ -1185,6 +1985,1474 @@ mod tests { ); } + // ----------------------------------------------------------------- + // 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, + /// Kept so a test can attempt a REBUILD: the wallet's whole balance + /// rides on the one UTXO this fixture's transaction spends, so a + /// rebuild that fails at input selection is direct proof the funding + /// reservation is still held. + signer: crate::test_support::WalletSigner, + /// The handle `SpvEventForwarder` fires on IS/ChainLock events, so + /// a test can wake an in-flight proof wait the way the live wallet + /// does. + lock_notify: Arc, + } + + impl ConflictFixture { + async fn new() -> Self { + Self::with_broadcaster(RecordingBroadcaster::default()).await + } + + /// The same fixture whose (re-)broadcast is refused before dispatch, + /// the way an app-launch catch-up resume meets an SPV client that + /// has not connected yet. + async fn rejecting() -> Self { + Self::with_broadcaster(RecordingBroadcaster { + reject: true, + ..Default::default() + }) + .await + } + + async fn with_broadcaster(broadcaster: RecordingBroadcaster) -> Self { + Self::with_broadcaster_and_persistence(broadcaster, |_, _| { + Arc::new(RecordingPersistence::default()) + }) + .await + } + + /// The fixture wired to a caller-supplied persistence backend, built + /// from the wallet handle so an interleaving stub can reach back into + /// the wallet it is going to mutate. + async fn with_broadcaster_and_persistence( + broadcaster: RecordingBroadcaster, + persistence: impl FnOnce( + Arc>>, + WalletId, + ) -> Arc, + ) -> Self { + let (wallet_manager, wallet_id, _generation, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let broadcaster = Arc::new(broadcaster); + let sdk = Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_network(Network::Testnet) + .build() + .expect("mock sdk"), + ); + let lock_notify = Arc::new(Notify::new()); + let manager = AssetLockManager::new( + sdk, + Arc::clone(&wallet_manager), + wallet_id, + Arc::clone(&lock_notify), + Arc::clone(&broadcaster), + WalletPersister::new( + wallet_id, + persistence(Arc::clone(&wallet_manager), wallet_id), + ), + ); + 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, + signer, + lock_notify, + } + } + + /// Attempt a fresh asset-lock build over the same wallet. The funded + /// fixture holds exactly one spendable UTXO, so this can only succeed + /// once that UTXO's reservation has been released. + async fn rebuild(&self) -> Result<(), PlatformWalletError> { + self.manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 5, + &self.signer, + ) + .await + .map(|_| ()) + } + + /// The single outpoint the asset-lock transaction spends — the one + /// a rescan-resurrected UTXO would have handed it a second time. + fn funded_input(&self) -> OutPoint { + 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); + } + + /// Route `tx` through the wallet's real transaction pipeline at + /// `context` — the same `check_core_transaction` path SPV drives, + /// relevance routing, record bookkeeping and all. `file_record` + /// bypasses that machinery, so only this can exercise how the + /// wallet reacts to an observation rather than to a record. + async fn observe(&self, tx: &Transaction, context: TransactionContext) { + let mut wm = self.wallet_manager.write().await; + wm.check_transaction_in_all_wallets(tx, context, true, true) + .await; + } + + /// Move the wallet's view of the chain to `height`, the way + /// processing a block does. + async fn advance_tip(&self, height: CoreBlockHeight) { + let mut wm = self.wallet_manager.write().await; + wm.get_wallet_info_mut(&self.wallet_id) + .expect("wallet must remain registered") + .update_last_processed_height(height); + } + + /// Prime the screen's session memory directly, the way the load + /// seeder or a prior resume would. + async fn remember_conflict(&self, spender: Txid, height: u32) { + let wm = self.manager.wallet_manager.read().await; + let info = wm + .get_wallet_info(&self.wallet_id) + .expect("wallet must remain registered"); + info.observed_input_conflicts + .lock() + .expect("test cache") + .insert( + self.funded_input(), + crate::wallet::platform_wallet::ObservedInputConflict { spender, height }, + ); + } + + /// Remove `txid`'s record from the wallet's BIP44 account, the way + /// `apply_chain_lock`'s promotion-eviction does under the default + /// `keep-finalized-transactions = OFF` build. + async fn evict_record(&self, txid: Txid) { + let mut wm = self.manager.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&self.wallet_id) + .expect("wallet must remain registered"); + info.core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("funded fixture has BIP44 account 0") + .transactions_mut() + .remove(&txid); + } + + fn broadcast_count(&self) -> usize { + self.broadcaster + .transactions + .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 base case: a confirmed sibling spending the lock's input makes + /// the resume end in the contested variant — the screen's one verdict, + /// which carries no licence to discard the tracked lock. It arrives + /// after the resume has run its course, not instead of it. + #[tokio::test] + async fn broadcast_resume_reports_a_contested_input_for_a_merely_in_block_spender() { + let fixture = ConflictFixture::new().await; + 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(), + 1, + "the screen must not refuse the defensive re-broadcast — the \ + sighting may be restored evidence nothing can retract" + ); + } + + /// Regression: a conflict sighting must never cost the lock a proof + /// that has already arrived. + /// + /// The screen reads history the load path rebuilt from persisted rows, + /// and such a record is never checked against the active chain — a + /// wallet offline while the spender's block was reorganized out + /// restores the sighting all the same, and no later event demotes a + /// transaction that is absent from both the replacement chain and every + /// mempool. Refusing to broadcast or wait on that evidence returned the + /// contested verdict on every resume and every launch for a lock whose + /// own funding transaction was sitting in history chain-locked, ready + /// to settle. The resume must run and take the proof. + #[tokio::test] + async fn a_standing_conflict_never_costs_the_lock_a_proof_that_has_arrived() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + // The unreconciled sighting. + fixture + .file_record(record_for( + transaction_spending(fixture.funded_input()), + confirmed_at(1_234), + )) + .await; + // ... and the lock's own funding transaction, chain-locked: the + // proof `wait_for_proof` resolves from without touching the network. + fixture + .file_record(record_for( + fixture.transaction.clone(), + chain_locked_at(1_500), + )) + .await; + + let (proof, _path) = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect("a lock whose own record is chain-locked must resume despite the sighting"); + match proof { + dpp::prelude::AssetLockProof::Chain(chain) => { + assert_eq!(chain.out_point, fixture.out_point); + assert_eq!(chain.core_chain_locked_height, 1_500); + } + other => panic!("expected a ChainAssetLockProof, got {other:?}"), + } + } + + /// Regression: a `Built` row whose re-broadcast is refused before it + /// dispatches must still take a proof that has already arrived. + /// + /// This is the launch catch-up shape: `catchUpStuckAssetLocks` resumes + /// a restored row before SPV connects, so the re-broadcast draws the + /// DEFINITE `Rejected` (unstarted client / zero peers), while history + /// carries both a restored spender of the lock's input and the lock's + /// own chain-locked funding record. Returning the rejection there + /// skipped the record entirely — an already-final lock failed on every + /// launch until connectivity returned, and it failed as the FFI's code + /// 26, whose released-reservation contract this path does not honour. + #[tokio::test] + async fn a_rejected_rebroadcast_of_a_conflicted_built_lock_still_takes_an_arrived_proof() { + let fixture = ConflictFixture::rejecting().await; + fixture.track(AssetLockStatus::Built, None).await; + + // The unreconciled sighting... + fixture + .file_record(record_for( + transaction_spending(fixture.funded_input()), + confirmed_at(1_234), + )) + .await; + // ... and the lock's own funding transaction, chain-locked. + fixture + .file_record(record_for( + fixture.transaction.clone(), + chain_locked_at(1_500), + )) + .await; + + let (proof, _path) = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect( + "a locally-proven lock must resume despite a rejected re-broadcast and a \ + standing sighting", + ); + match proof { + dpp::prelude::AssetLockProof::Chain(chain) => { + assert_eq!(chain.core_chain_locked_height, 1_500); + } + other => panic!("expected a ChainAssetLockProof, got {other:?}"), + } + assert_eq!( + fixture + .wallet_manager + .read() + .await + .get_wallet_info(&fixture.wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&fixture.out_point) + .expect("lock stays tracked") + .status, + AssetLockStatus::ChainLocked, + "the resume must advance the row exactly as a waited-for proof would" + ); + // Completing from a local proof releases nothing either: the lock is + // settled and its inputs stay spent by it, so a rebuild must still + // find no candidates. + let rebuild = fixture.rebuild().await; + assert!( + matches!( + rebuild, + Err(PlatformWalletError::AssetLockInsufficientFunds { available: 0, .. }) + ), + "a settled lock must keep its funding reservation, got {rebuild:?}" + ); + } + + /// The same shape without a proof: the rejection must not pre-empt the + /// bounded wait the sighting exists to bound, and its expiry must be + /// reported as the provisional contested verdict — never as the + /// definite-rejection code 26, which promises a released reservation + /// this path does not release. + #[tokio::test] + async fn a_rejected_rebroadcast_of_a_conflicted_built_lock_reports_the_contested_verdict() { + let fixture = ConflictFixture::rejecting().await; + fixture.track(AssetLockStatus::Built, None).await; + + let spender = transaction_spending(fixture.funded_input()); + let spender_txid = spender.txid(); + fixture + .file_record(record_for(spender, confirmed_at(1_234))) + .await; + + let error = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("no proof exists, so the bounded wait must expire"); + match error { + PlatformWalletError::AssetLockInputContested { + out_point, + input, + spent_by, + height, + } => { + assert_eq!(out_point, fixture.out_point); + assert_eq!(input, fixture.funded_input()); + assert_eq!(spent_by, spender_txid); + assert_eq!(height, Some(1_234)); + } + other => panic!( + "a rejected re-broadcast must not pre-empt the sighting's bounded wait, \ + and must never surface the released-reservation contract, got {other:?}" + ), + } + assert_eq!( + fixture.broadcast_count(), + 1, + "the re-broadcast is still attempted — the sighting bounds the resume, it \ + never refuses it" + ); + assert_eq!( + fixture + .wallet_manager + .read() + .await + .get_wallet_info(&fixture.wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&fixture.out_point) + .expect("lock stays tracked") + .status, + AssetLockStatus::Built, + "a send that never dispatched must not advance the row — the next resume \ + re-sends rather than waiting on a broadcast that never happened" + ); + // The retained status is only half the invariant. A row that is + // resumable while its inputs are re-spendable is exactly the state + // the release gate exists to prevent, and only a rebuild attempt can + // prove the reservation is still held: the fixture's whole balance + // rides on the one UTXO this lock spends, so a released reservation + // would let this build succeed and put a second asset lock on the + // wire beside the first. + let rebuild = fixture.rebuild().await; + assert!( + matches!( + rebuild, + Err(PlatformWalletError::AssetLockInsufficientFunds { available: 0, .. }) + ), + "the funding reservation must still be held after a rejected re-broadcast, \ + leaving a rebuild with zero spendable candidates, got {rebuild:?}" + ); + } + + /// Regression: finality that lands BETWEEN the verdict's proof probe and + /// the snapshot the verdict is built from must still outrank the + /// standing sighting. + /// + /// This is the narrowest interleaving the decision has to survive, and + /// the whole resume is driven through the public entry point to reach + /// it. The funding record is filed from inside the probe's own persister + /// lookup — after that probe has already read the in-memory map and + /// missed, before anything else is read. Finality arriving that way + /// never touches the tracked row (`LockNotifyHandler` wakes waiters + /// without advancing a status), so the row still says `Broadcast` with + /// no proof and the sibling is still sitting in history: reading the + /// record and the row in two separate snapshots reported a locally final + /// lock as contested. + #[tokio::test] + async fn finality_landing_between_the_probe_and_the_snapshot_outranks_the_conflict() { + let funding_tx = Mutex::new(None); + let interleave = Mutex::new(None); + let fixture = ConflictFixture::with_broadcaster_and_persistence( + RecordingBroadcaster::default(), + |wallet_manager, wallet_id| { + // The lock's own transaction is only known once the fixture + // has built it, so the stub reads it back out of the shared + // slot the fixture fills in below. + let built = Arc::new(Mutex::new(None::)); + let handle = Arc::clone(&built); + let stub = Arc::new(InterleavedPersistence::new( + wallet_manager, + wallet_id, + // Lookup 0 is the expiring proof wait's own miss; lookup + // 1 is the verdict's probe, the gap under test. + 1, + move |info| { + let transaction = handle + .lock() + .expect("built transaction slot") + .clone() + .expect("fixture files the transaction before resuming"); + insert_record(info, record_for(transaction, chain_locked_at(1_500))); + }, + )); + *funding_tx.lock().expect("slot") = Some(built); + *interleave.lock().expect("slot") = Some(Arc::clone(&stub)); + stub as Arc + }, + ) + .await; + let funding_tx = funding_tx.lock().expect("slot").take().expect("slot set"); + let interleave = interleave.lock().expect("slot").take().expect("slot set"); + *funding_tx.lock().expect("built transaction slot") = Some(fixture.transaction.clone()); + + fixture.track(AssetLockStatus::Broadcast, None).await; + fixture + .file_record(record_for( + transaction_spending(fixture.funded_input()), + confirmed_at(1_234), + )) + .await; + + let error = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("the proof arrives too late for this wait to return it"); + assert!( + interleave.fired(), + "the test proves nothing unless the finality actually landed inside the \ + verdict's probe" + ); + assert!( + matches!(error, PlatformWalletError::FinalityTimeout(_)), + "a lock whose own record reached finality during the probe must not be \ + reported as contested on the strength of a sibling still sitting in \ + history — the caller keeps its expiry and the next resume returns the \ + proof, got {error:?}" + ); + assert_eq!( + fixture + .wallet_manager + .read() + .await + .get_wallet_info(&fixture.wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&fixture.out_point) + .expect("lock stays tracked") + .status, + AssetLockStatus::Broadcast, + "suppressing the verdict must leave the row exactly where it was" + ); + } + + /// Regression: a ChainLock that lands in that same gap must outrank the + /// sighting even though the promotion it performs takes the funding + /// record away. + /// + /// Promotion is EVICTION under the default + /// `keep-finalized-transactions = OFF` build: `apply_chain_lock` drops + /// the record it has just promoted and keeps only its txid in the + /// account's finalized set. A snapshot that asked the record alone + /// therefore questioned the one place finality no longer lives, and + /// condemned a locally final lock on the strength of a sibling the same + /// chainlock never buried. The chainlock here is applied for real — + /// the funding transaction is filed in a block below the lock height and + /// promoted by the wallet's own pass — so the eviction is the wallet's, + /// not the test's. The sibling sits in a HIGHER block on purpose: the + /// same pass must leave it standing, or there would be no conflict left + /// to suppress and the test would pass on any code. + #[tokio::test] + async fn a_chainlock_evicting_the_funding_record_mid_verdict_outranks_the_conflict() { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let funding_tx = Mutex::new(None); + let interleave = Mutex::new(None); + let fixture = ConflictFixture::with_broadcaster_and_persistence( + RecordingBroadcaster::default(), + |wallet_manager, wallet_id| { + let built = Arc::new(Mutex::new(None::)); + let handle = Arc::clone(&built); + let stub = Arc::new(InterleavedPersistence::new( + wallet_manager, + wallet_id, + // Lookup 0 is the expiring proof wait's own miss; lookup + // 1 is the verdict's probe, the gap under test. + 1, + move |info| { + let transaction = handle + .lock() + .expect("built transaction slot") + .clone() + .expect("fixture files the transaction before resuming"); + insert_record(info, record_for(transaction, confirmed_at(1_200))); + info.apply_chain_lock(ChainLock { + block_height: 1_220, + block_hash: BlockHash::all_zeros(), + signature: BLSSignature::from([0u8; 96]), + }); + }, + )); + *funding_tx.lock().expect("slot") = Some(built); + *interleave.lock().expect("slot") = Some(Arc::clone(&stub)); + stub as Arc + }, + ) + .await; + let funding_tx = funding_tx.lock().expect("slot").take().expect("slot set"); + let interleave = interleave.lock().expect("slot").take().expect("slot set"); + *funding_tx.lock().expect("built transaction slot") = Some(fixture.transaction.clone()); + + fixture.track(AssetLockStatus::Broadcast, None).await; + fixture + .file_record(record_for( + transaction_spending(fixture.funded_input()), + confirmed_at(1_234), + )) + .await; + + let error = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("the chainlock arrives too late for this wait to return a proof"); + assert!( + interleave.fired(), + "the test proves nothing unless the chainlock actually landed inside the \ + verdict's probe" + ); + { + let wm = fixture.wallet_manager.read().await; + let info = wm + .get_wallet_info(&fixture.wallet_id) + .expect("wallet must remain registered"); + let account = info + .core_wallet + .accounts + .standard_bip44_accounts + .get(&0) + .expect("funded fixture has BIP44 account 0"); + #[cfg(not(feature = "keep-finalized-transactions"))] + assert!( + !account.transactions().contains_key(&fixture.out_point.txid), + "the interleaving under test is promotion-EVICTION: a record still \ + sitting in the map would leave the resident-record check able to \ + answer, and the eviction path untested" + ); + assert!( + account.transaction_is_finalized(&fixture.out_point.txid), + "the finalized-txid set is where the promotion leaves the finality, \ + and the only trace of it the verdict can still read" + ); + assert!( + info.core_wallet + .transaction_history() + .iter() + .any(|record| record.txid != fixture.out_point.txid && record.is_confirmed()), + "the sibling must survive the same chainlock, or there is no \ + contested verdict left for the finality to suppress" + ); + } + assert!( + matches!(error, PlatformWalletError::FinalityTimeout(_)), + "a lock whose funding transaction was chainlocked during the probe must \ + not be reported as contested because the promotion evicted the record \ + that said so — the caller keeps its expiry and the next resume returns \ + the proof, got {error:?}" + ); + assert_eq!( + fixture + .wallet_manager + .read() + .await + .get_wallet_info(&fixture.wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&fixture.out_point) + .expect("lock stays tracked") + .status, + AssetLockStatus::Broadcast, + "suppressing the verdict must leave the row exactly where it was" + ); + } + + /// The same precedence against a concurrent resume rather than against + /// the record: two overlapping resumes run under different budgets, and + /// the longer one attaches the proof and advances the row while the + /// shorter one is expiring. Driven end to end, with the settling landing + /// in the same probe-to-snapshot gap. + #[tokio::test] + async fn a_concurrent_resume_that_settled_the_lock_suppresses_the_contested_verdict() { + let interleave = Mutex::new(None); + let fixture = ConflictFixture::with_broadcaster_and_persistence( + RecordingBroadcaster::default(), + |wallet_manager, wallet_id| { + let stub = Arc::new(InterleavedPersistence::new( + wallet_manager, + wallet_id, + 1, + |info| { + let (out_point, lock) = info + .tracked_asset_locks + .iter_mut() + .next() + .map(|(out_point, lock)| (*out_point, lock)) + .expect("the lock under resume is tracked"); + lock.status = AssetLockStatus::ChainLocked; + lock.proof = Some(dpp::prelude::AssetLockProof::Chain( + dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof { + core_chain_locked_height: 1_500, + out_point, + }, + )); + }, + )); + *interleave.lock().expect("slot") = Some(Arc::clone(&stub)); + stub as Arc + }, + ) + .await; + let interleave = interleave.lock().expect("slot").take().expect("slot set"); + + fixture.track(AssetLockStatus::Broadcast, None).await; + fixture + .file_record(record_for( + transaction_spending(fixture.funded_input()), + confirmed_at(1_234), + )) + .await; + + let error = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("this resume's own wait found no proof before its deadline"); + assert!( + interleave.fired(), + "the concurrent resume must actually have settled the row mid-verdict" + ); + assert!( + matches!(error, PlatformWalletError::FinalityTimeout(_)), + "a row another resume already settled must not be re-condemned by the \ + screen, got {error:?}" + ); + } + + /// Regression: a caller that named its own deadline keeps + /// `FinalityTimeout` on the rejected-`Built` path too. + /// + /// The re-typings on that path exist to stop an UNBOUNDED wait hanging + /// on a signal that cannot arrive; a caller that supplied a bound never + /// had that problem. The shielded seed pool reads `FinalityTimeout` as a + /// pacing signal and resumes the lock later, so handing it a + /// do-not-retry error instead — and one quoting the 180-second policy + /// cap rather than the bound it asked for — silently drops the lock out + /// of that flow. The conflict retracts mid-verdict so the contested + /// verdict is out of the way and the undispatched translation is the + /// only thing left that could win. + #[tokio::test] + async fn a_rejected_built_rebroadcast_keeps_an_explicit_timeout_as_finality_timeout() { + let spender = Mutex::new(None); + let interleave = Mutex::new(None); + let fixture = ConflictFixture::with_broadcaster_and_persistence( + RecordingBroadcaster { + reject: true, + ..Default::default() + }, + |wallet_manager, wallet_id| { + let demoted = Arc::new(Mutex::new(None::)); + let handle = Arc::clone(&demoted); + let stub = Arc::new(InterleavedPersistence::new( + wallet_manager, + wallet_id, + // Lookup 0 is the rejection's own local-proof probe, + // lookup 1 the expiring wait, lookup 2 the verdict's + // probe — the gap the retraction has to land in. + 2, + move |info| { + let transaction = handle + .lock() + .expect("spender slot") + .clone() + .expect("fixture files the spender before resuming"); + // A reorg drops the block; the record survives, + // demoted, which retracts the remembered sighting. + insert_record(info, record_for(transaction, TransactionContext::Mempool)); + }, + )); + *spender.lock().expect("slot") = Some(demoted); + *interleave.lock().expect("slot") = Some(Arc::clone(&stub)); + stub as Arc + }, + ) + .await; + let spender_slot = spender.lock().expect("slot").take().expect("slot set"); + let interleave = interleave.lock().expect("slot").take().expect("slot set"); + + fixture.track(AssetLockStatus::Built, None).await; + let spender = transaction_spending(fixture.funded_input()); + *spender_slot.lock().expect("spender slot") = Some(spender.clone()); + fixture + .file_record(record_for(spender, confirmed_at(1_234))) + .await; + + let error = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("no proof exists, so the caller's bounded wait must expire"); + assert!( + interleave.fired(), + "the conflict must actually have retracted mid-verdict, or the contested \ + verdict would be doing the work this test is about" + ); + assert!( + matches!(error, PlatformWalletError::FinalityTimeout(_)), + "a caller-selected timeout must expire as FinalityTimeout, not be retyped \ + into the unknown-outcome contract that quotes the policy cap it never \ + asked for, got {error:?}" + ); + } + + /// The same on the memory path: a remembered sighting whose record has + /// left history under a covering boundary still reports the + /// provisional verdict, never the discard-licensing terminal one. The + /// eviction attests a height-based promotion, not that the spender's + /// block is on the finalized branch. + #[tokio::test] + async fn a_remembered_spender_evicted_under_the_boundary_stays_provisional() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + let spender_txid = transaction_spending(fixture.funded_input()).txid(); + fixture.remember_conflict(spender_txid, 1_234).await; + fixture.set_chain_lock_boundary(1_300).await; + + let error = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("the remembered conflict still stops the wait"); + assert!( + matches!(error, PlatformWalletError::AssetLockInputContested { .. }), + "a remembered sighting must not upgrade on the boundary, got {error:?}" + ); + } + + /// The memory must never condemn a lock with its own txid: two tracked + /// locks sharing an input cross-remember each other, and after the + /// winner's record is promotion-evicted a resume of the WINNER must + /// not read the memory as evidence against it — a lock's own spend is + /// not a conflict with itself. + #[tokio::test] + async fn remembered_evidence_never_condemns_the_lock_itself() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + fixture + .remember_conflict(fixture.transaction.txid(), 1_234) + .await; + fixture.set_chain_lock_boundary(1_300).await; + + let error = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("no proof means the resume runs and then times out"); + assert!( + !matches!( + error, + PlatformWalletError::AssetLockInputConflict { .. } + | PlatformWalletError::AssetLockInputContested { .. } + ), + "a lock's own remembered spend must never condemn it, got {error:?}" + ); + } + + /// The load-time seeder primes the memory before any resume runs, so + /// a chainlock dispatcher that promotion-evicts the restored spender + /// before the first catch-up still leaves the screen with evidence — + /// reported, like every other sighting, as the provisional verdict. + #[tokio::test] + async fn seeding_survives_a_pre_resume_promotion_eviction() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + let spender = transaction_spending(fixture.funded_input()); + let spender_txid = spender.txid(); + fixture + .file_record(record_for(spender, confirmed_at(1_234))) + .await; + // The load path's seeding pass, then the dispatcher's promotion + // eviction — all before the first resume. + { + let wm = fixture.manager.wallet_manager.read().await; + let info = wm + .get_wallet_info(&fixture.wallet_id) + .expect("wallet must remain registered"); + super::seed_observed_input_conflicts(info); + } + fixture.evict_record(spender_txid).await; + fixture.set_chain_lock_boundary(1_300).await; + + let error = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("the seeded conflict must stop the wait"); + match error { + PlatformWalletError::AssetLockInputContested { spent_by, .. } => { + assert_eq!(spent_by, spender_txid, "the seeded spender, provisionally"); + } + other => panic!("expected AssetLockInputContested, got {other:?}"), + } + } + + /// Promotion is eviction: once a chainlock buries the spender's block, + /// `apply_chain_lock` removes its record from history. The screen's + /// session memory must carry the conflict across that disappearance + /// instead of letting the resume fall back into the proof wait — and it + /// must carry it as the SAME provisional verdict, because a + /// height-based promotion is not proof that the spender's block is on + /// the finalized branch. + #[tokio::test] + async fn a_chainlock_evicted_spender_keeps_the_remembered_verdict_provisional() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + let spender = transaction_spending(fixture.funded_input()); + let spender_txid = spender.txid(); + fixture + .file_record(record_for(spender, confirmed_at(1_234))) + .await; + + // First resume: the screen reports and remembers the sighting. + let first = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("a currently double-spent asset lock must fail, not wait"); + assert!( + matches!(first, PlatformWalletError::AssetLockInputContested { .. }), + "the screen's one verdict is provisional, got {first:?}" + ); + + // The chainlock lands: boundary moves past the spender's height and + // the promotion evicts its record. + fixture.evict_record(spender_txid).await; + fixture.set_chain_lock_boundary(1_300).await; + + let second = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("a chainlock-settled double spend must fail, not wait"); + match second { + PlatformWalletError::AssetLockInputContested { spent_by, .. } => { + assert_eq!(spent_by, spender_txid, "the remembered spender, unchanged"); + } + other => panic!("expected AssetLockInputContested, got {other:?}"), + } + } + + /// KNOWN LIMITATION, pinned on purpose: an `InBlock` spender record + /// survives a later unconfirmed re-observation, so it keeps contesting + /// the lock. + /// + /// Both sightings here go through the wallet's real transaction + /// checker, the only path the live app ever drives: the spender is + /// observed in a block, the wallet tip advances past that height, and + /// the same transaction is then observed again with a plain mempool + /// context. The checker accepts a context change only in the + /// strengthening direction and returns early for a transaction it + /// already holds whenever the incoming context is unconfirmed, so the + /// record stays `InBlock` and the screen goes on reading it as a live + /// conflict. (This models the observations a reorg would produce, not + /// a full reorg: no block is removed and no replacement chain is + /// processed here.) + /// + /// The repair does not belong at this seam. A record demoted on its + /// own desyncs from the received UTXOs' confirmed flags and from the + /// balances derived from them, so record, UTXO and balance have to + /// move together at the key-wallet boundary that owns all three. Until + /// they do, a stale sighting does not free the lock; the resume's own + /// proof-wait backstop bounds it instead. + #[tokio::test] + async fn an_in_block_spender_record_survives_an_unconfirmed_reobservation() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + let spender = transaction_spending(fixture.funded_input()); + let spender_txid = spender.txid(); + fixture.observe(&spender, confirmed_at(1_234)).await; + let first = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("a currently double-spent asset lock must fail, not wait"); + assert!(matches!( + first, + PlatformWalletError::AssetLockInputContested { .. } + )); + + // The tip advances past the spender's block and the spender is + // observed again with only a mempool context — the observation + // sequence a reorg would produce. + fixture.advance_tip(1_240).await; + fixture.observe(&spender, TransactionContext::Mempool).await; + + let still_confirmed = { + let wm = fixture.wallet_manager.read().await; + wm.get_wallet_info(&fixture.wallet_id) + .expect("wallet must remain registered") + .core_wallet + .transaction_history() + .into_iter() + .find(|record| record.txid == spender_txid) + .map(|record| (record.is_confirmed(), record.height())) + }; + assert_eq!( + still_confirmed, + Some((true, Some(1_234))), + "the unconfirmed re-observation does not reach the record: it is still \ + filed in the block the chain dropped" + ); + + match fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("the stale confirmation still condemns the lock") + { + PlatformWalletError::AssetLockInputContested { + spent_by, height, .. + } => { + assert_eq!(spent_by, spender_txid); + assert_eq!( + height, + Some(1_234), + "the verdict still quotes the reorged-away block" + ); + } + other => panic!("expected the standing AssetLockInputContested, got {other:?}"), + } + } + + /// A sibling that confirms only AFTER the pre-broadcast screen ran + /// must still end the resume. + /// + /// The screen runs once, before the broadcast, and a mempool sibling + /// is not a verdict there — either transaction can still win — so the + /// wait starts with no conflict recorded and, from a caller that + /// declined to name a bound, no deadline of the caller's own. Nothing + /// inside the wait can notice the sibling turning confirmed: a lock + /// notification only sends it back to re-read the tracked funding + /// transaction, which is exactly what can no longer confirm. The + /// backstop every resumed lock runs under is the only thing that ends + /// it, and the verdict is then re-read from live history — provisional, + /// because a merely-in-block spender proves nothing about finality. + /// + /// Time is virtual: the backstop is 180s, so a real-clock version of + /// this test would be unrunnable, and the unbounded wait it pins would + /// hang the suite rather than fail it. The outer bound makes the hang + /// an assertion failure instead. + #[tokio::test(start_paused = true)] + async fn a_sibling_confirming_after_the_snapshot_still_ends_the_resume() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Built, None).await; + + // Unconfirmed when the screen looks: a competing candidate, not a + // conflict, so the resume enters the wait with no caller-provided + // bound and no conflict cap — the recovery backstop applies. + let spender = transaction_spending(fixture.funded_input()); + let spender_txid = spender.txid(); + fixture.observe(&spender, TransactionContext::Mempool).await; + + // The sibling confirms while the wait is running, and the lock + // notification that accompanies a block wakes the waiter. + let confirming = { + let wallet_manager = Arc::clone(&fixture.wallet_manager); + let wallet_id = fixture.wallet_id; + let lock_notify = Arc::clone(&fixture.lock_notify); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(10)).await; + { + let mut wm = wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&wallet_id) + .expect("wallet must remain registered"); + info.core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("funded fixture has BIP44 account 0") + .transactions_mut() + .get_mut(&spender_txid) + .expect("the mempool sighting filed a record") + .update_context(confirmed_at(1_234)); + } + lock_notify.notify_waiters(); + }) + }; + + let outcome = tokio::time::timeout( + Duration::from_secs(600), + fixture.manager.resume_asset_lock(&fixture.out_point, None), + ) + .await + .expect( + "a resumed Built lock must run under a finite backstop; without one \ + the wait outlives any bound a caller could impose", + ); + confirming.await.expect("confirming task"); + + match outcome.expect_err("no proof exists, so the resume cannot succeed") { + PlatformWalletError::AssetLockInputContested { + out_point, + input, + spent_by, + height, + } => { + assert_eq!(out_point, fixture.out_point); + assert_eq!(input, fixture.funded_input()); + assert_eq!(spent_by, spender_txid); + assert_eq!(height, Some(1_234)); + } + other => panic!("expected AssetLockInputContested, got {other:?}"), + } + } + + /// The backstop stands on its own: with no sibling anywhere and an + /// accepted re-broadcast, a caller that declined to name a bound still + /// gets an answer instead of a parked thread. Acceptance says the + /// transaction reached the network, never that it can still confirm, + /// and the row is left at `Broadcast` so a proof arriving afterwards is + /// returned by the very next resume. + #[tokio::test(start_paused = true)] + async fn an_accepted_rebroadcast_still_ends_a_boundless_resume() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Built, None).await; + + let outcome = tokio::time::timeout( + Duration::from_secs(600), + fixture.manager.resume_asset_lock(&fixture.out_point, None), + ) + .await + .expect("an accepted re-broadcast must not license an unbounded wait"); + + assert!( + matches!( + outcome, + Err(PlatformWalletError::TransactionBroadcastUnconfirmed(_)) + ), + "expected the unknown-outcome contract, got {outcome:?}" + ); + let status = { + let wm = fixture.wallet_manager.read().await; + wm.get_wallet_info(&fixture.wallet_id) + .expect("wallet must remain registered") + .tracked_asset_locks + .get(&fixture.out_point) + .expect("the row must survive the expiry") + .status + .clone() + }; + assert_eq!( + status, + AssetLockStatus::Broadcast, + "the dispatched send advanced the row, and the expiry must leave it there" + ); + } + + /// The memory outlives the record with no applied chainlock in sight: + /// the conflict is still reported, still provisionally. The boundary + /// is not consulted at all — with or without one, the screen has the + /// same evidence and gives the same answer. + #[tokio::test] + async fn an_evicted_spender_without_a_covering_boundary_stays_provisional() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + let spender = transaction_spending(fixture.funded_input()); + let spender_txid = spender.txid(); + fixture + .file_record(record_for(spender, confirmed_at(1_234))) + .await; + let _ = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await; + fixture.evict_record(spender_txid).await; + + let second = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("the remembered conflict still stops the wait"); + assert!( + matches!(second, PlatformWalletError::AssetLockInputContested { .. }), + "no boundary, no terminal claim, got {second:?}" + ); + } + + /// The applied chainlock watermark is NOT an ancestry proof, so a live + /// in-block record sitting at or below it earns no promotion. The + /// watermark is height-based, and the SPV chainlock manager counts a + /// missing header as a passing block-hash check, so a chainlock landing + /// on a replacement branch ahead of its headers can move it past a + /// record that sits on the losing branch. The verdict stays contested. + #[tokio::test] + async fn a_live_spender_below_the_boundary_stays_contested() { + 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::AssetLockInputContested { spent_by, .. } => { + assert_eq!(spent_by, spender_txid); + } + other => panic!("expected AssetLockInputContested, got {other:?}"), + } + } + + /// The strongest evidence the wallet can hold — a spender whose own + /// record carries a chain-locked context — still buys no upgrade. That + /// context is set by the same height-based promotion, so it attests a + /// chainlock at the record's height, not that the record's block is on + /// the branch the chainlock covers. The terminal variant has no + /// emitter; the resume reports the provisional one here too. + #[tokio::test] + async fn a_chain_locked_spender_still_reports_only_the_contested_verdict() { + let fixture = ConflictFixture::new().await; + fixture.track(AssetLockStatus::Broadcast, None).await; + + let spender = transaction_spending(fixture.funded_input()); + let spender_txid = spender.txid(); + fixture + .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::AssetLockInputContested { + spent_by, height, .. + } => { + assert_eq!(spent_by, spender_txid); + assert_eq!(height, Some(1_234)); + } + other => panic!("expected AssetLockInputContested, got {other:?}"), + } + assert!( + rendered.contains("provisional"), + "the rendered Display must say the verdict is provisional: {rendered}" + ); + assert_eq!( + fixture.broadcast_count(), + 1, + "not even a chain-locked-looking spender may refuse the resume" + ); + } + + /// 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 { .. } + | PlatformWalletError::AssetLockInputContested { .. } + ), + "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" + ); + } + /// Builds a tracked lock at `status` on a funded wallet and resumes it /// through `broadcaster` with the given `timeout`, returning the resume /// error and the lock's tracked state afterwards (`None` = untracked). diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index e763aba8e5c..a9a6fa64e6c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -4185,6 +4185,7 @@ mod sweep_tests { generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + observed_input_conflicts: Default::default(), dpns_name_states: BTreeMap::new(), } } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index aa97d44a223..c1ae06ba180 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -4,7 +4,8 @@ use std::collections::BTreeMap; use std::ops::{Deref, DerefMut}; use std::sync::Arc; -use dashcore::OutPoint; +use dashcore::{OutPoint, Txid}; +use dpp::prelude::CoreBlockHeight; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; #[cfg(feature = "shielded")] @@ -228,6 +229,31 @@ fn plan_shield_inputs( }) } +/// One confirmed spend of a tracked asset lock's input, as the +/// double-spend screen last saw it in live transaction history. +/// +/// Session-scoped memory, never persisted and never restored: it exists +/// because `apply_chain_lock` EVICTS a record from history the moment a +/// chainlock buries it (default `keep-finalized-transactions = OFF`), and +/// a retry after that eviction would otherwise find nothing and fall back +/// into the proof wait the screen exists to prevent. The screen writes +/// entries when it observes a confirmed spender, retracts them when live +/// history re-observes that spender unconfirmed (a reorg demotes the +/// record in place), and keeps reporting an entry whose record has LEFT +/// history: promotion-eviction is the only path that removes a record. +/// +/// The entry carries no finality: an eviction attests a height-based +/// promotion, not that the spender's block is on the finalized branch, so +/// every verdict the screen builds from this memory is the provisional +/// one. See `wallet::asset_lock::sync::recovery`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ObservedInputConflict { + /// The confirmed transaction the screen saw spending the input. + pub spender: Txid, + /// The block height it was seen at. + pub height: CoreBlockHeight, +} + /// Consolidated mutable state for a platform wallet. /// /// Lives inside `WalletManager.wallet_infos`. The `Wallet` @@ -257,6 +283,11 @@ pub struct PlatformWalletInfo { pub(crate) generation: Arc, pub identity_manager: IdentityManager, pub tracked_asset_locks: BTreeMap, + /// Session-scoped double-spend evidence for tracked asset locks — see + /// [`ObservedInputConflict`]. Interior mutability because the screen + /// runs under the manager's read lock; a poisoned mutex degrades to + /// "no memory" rather than failing a resume. + pub observed_input_conflicts: std::sync::Mutex>, /// DPNS name states with sale price (username marketplace), keyed by /// domain document id. Session-lifetime working set for the /// marketplace sync/orchestration ops; the durable copy is the diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs index b4a2f7d05b0..49ed828d228 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -40,6 +40,7 @@ impl WalletInfoInterface for PlatformWalletInfo { generation: std::sync::Arc::new(super::core::WalletGeneration::new()), identity_manager: super::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), + observed_input_conflicts: Default::default(), dpns_name_states: std::collections::BTreeMap::new(), } } @@ -53,6 +54,7 @@ impl WalletInfoInterface for PlatformWalletInfo { generation: std::sync::Arc::new(super::core::WalletGeneration::new()), identity_manager: super::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), + observed_input_conflicts: Default::default(), dpns_name_states: std::collections::BTreeMap::new(), } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index ac8caa60ba7..4d882e2ae55 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -1803,10 +1803,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 @@ -1821,8 +1830,29 @@ 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 proof wait, and the + // host must not wait on that drain to learn a lock + // is stuck. `lastError` is the manager's one public + // error surface; a UI that explains the stalled lock + // and its pending retry (48 — the only verdict + // emitted; 47 stays reserved) reads it from here. + // + // The verdict arrives at the END of its own lock's + // bounded wait, not ahead of it: Rust deliberately + // does not refuse a resume on a conflict sighting, + // because the sighting can be a restored block record + // that no live event will ever retract, and refusing + // would strand a lock that is free to confirm. The + // wait it runs under is capped below this call's 300s + // ceiling for exactly that case. + while let outcome = await group.next() { + if !published, let verdict = outcome { + published = true + await publishConflict(verdict) + } if nextIndex < outpoints.count { let (txid, vout) = outpoints[nextIndex] group.addTask { @@ -1849,7 +1879,14 @@ 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 — + /// the one outcome a host must see so its UI can explain why the lock + /// is stuck instead of spinning — and `nil` for every expected failure. + /// In practice that verdict is always the provisional + /// `assetLockInputContested`; the terminal `assetLockInputConflict` is + /// reserved with no emitter and is matched so it would surface intact + /// if that ever changes. + nonisolated private static func runCatchUp(assetLockManager: ManagedAssetLockManager, txid: Data, vout: UInt32) -> PlatformWalletError? { // Build the txid tuple inline so the Task body captures only // Sendable values. var txidTuple: FFIByteTuple32 = @@ -1861,9 +1898,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 @@ -1874,13 +1915,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 6aae1f9cd0c..2edd0b3ac82 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1171,27 +1171,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 — @@ -1348,43 +1349,55 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) if let pendingRows = try? backgroundContext.fetch(pendingDescriptor), !pendingRows.isEmpty { - // Pick the freshest pending entry — under normal sync - // there's only one, but a chain reorg or double-spend - // observation could leave multiple. Newest wins so the - // visible spendingTransaction matches the most recent - // observation; the rest are dropped. - let chosen = pendingRows.max(by: { $0.createdAt < $1.createdAt }) ?? pendingRows[0] - - // Resolve the spending tx (prefer the relationship; fall - // back to a txid lookup if the row wasn't faulted in). - // We need its `context` to gate `isSpent` — same rule as - // `resolveInputOutpoint`: mempool sighting links the - // spendingTransaction but doesn't flip `isSpent` until - // the spending tx is in a block. - let resolvedSpending: PersistentTransaction? - if let spending = chosen.spendingTransaction { - resolvedSpending = spending - } else { - let spendingTxid = chosen.spendingTxid - let txDescriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == spendingTxid } + // Reconcile EVERY deferred observation, not just the newest — + // the rows are about to be deleted, and picking one would let + // a mempool competitor recorded after a confirmed spender + // erase that confirmed evidence with the rows. Applying the + // finality-aware rule per row makes the order irrelevant by + // construction: confirmed evidence wins and is never + // displaced by a mempool observation, so the oldest-first + // pass below converges to the same state any order would. + var adoptedAny = false + for pending in pendingRows.sorted(by: { $0.createdAt < $1.createdAt }) { + // Resolve the spending tx (prefer the relationship; fall + // back to a txid lookup if the row wasn't faulted in). + let resolvedSpending: PersistentTransaction? + if let spending = pending.spendingTransaction { + resolvedSpending = spending + } else { + let spendingTxid = pending.spendingTxid + let txDescriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == spendingTxid } + ) + resolvedSpending = try? backgroundContext.fetch(txDescriptor).first + } + guard let spending = resolvedSpending else { continue } + // Flag and link move together — see + // `reconcileSpendObservation` for the finality rule. + let verdict = Self.reconcileSpendObservation( + currentSpenderTxid: record.spendingTransaction?.txid, + currentIsSpent: record.isSpent, + incoming: spending, + incomingTxid: spending.txid ) - resolvedSpending = try? backgroundContext.fetch(txDescriptor).first - } - - // Carry the vin index forward so the spending tx's - // detail view can render its inputs in the canonical - // serialized order. Same source as the linkage write - // in `resolveInputOutpoint` — the only path that creates - // pending rows captures the index from FFI's - // `input_outpoints` slice, which mirrors `tx.input.iter()`. - record.spendingInputIndex = chosen.inputIndex - if let spending = resolvedSpending, - record.spendingTransaction?.txid != spending.txid { - record.spendingTransaction = spending + record.isSpent = verdict.isSpent + if verdict.adoptLink { + if record.spendingTransaction?.txid != spending.txid { + record.spendingTransaction = spending + } + // The vin index rides with the adopted claim so the + // spending tx's detail view renders inputs in the + // canonical serialized order. + record.spendingInputIndex = pending.inputIndex + adoptedAny = true + } } - if let spending = resolvedSpending { - record.isSpent = Self.spendIsInBlock(spending) + if !adoptedAny, let newest = pendingRows.max(by: { $0.createdAt < $1.createdAt }) { + // No row resolved a spending tx this flush: carry the + // newest claim's vin index forward the way the old + // single-row path did; the linkage itself catches up on + // the next flush that carries the spending tx. + record.spendingInputIndex = newest.inputIndex } record.lastUpdated = Date() for row in pendingRows { @@ -1393,6 +1406,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), @@ -1423,20 +1471,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 @@ -5474,23 +5528,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, @@ -5510,50 +5630,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 @@ -5565,15 +5655,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 59c81eda119..5b07dcbda8c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -185,6 +185,37 @@ 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 + /// RESERVED — the Rust side has no code path that produces this today, so + /// it does not currently cross the boundary. It is the TERMINAL form of + /// the double-spend verdict: the tracked asset-lock transaction spends an + /// outpoint a different, already-confirmed transaction of the same wallet + /// spent first, AND that spender's block is proven to be on the finalized + /// chain. The proof is what is missing — chainlock contexts and the + /// wallet's applied chainlock height are height-based promotion artifacts, + /// not evidence of finalized ancestry — so every detection reports + /// `errorAssetLockInputContested` (48) instead, chainlocked-looking + /// spenders included. Kept pinned so the slot stays stable for hosts and + /// for the future emitter, which would carry the same meaning: the one + /// code that lets a host discard the asset lock and rebuild from + /// currently-unspent inputs. Read nothing into its absence. + case errorAssetLockInputConflict = 47 + /// A confirmed transaction of this wallet already spent one of the tracked + /// lock's inputs — typically a restored wallet whose rescan resurrected a + /// UTXO one of its own earlier asset locks had already consumed. Peers drop + /// such a double spend without replying, so the lock cannot confirm while + /// that spender stands and an unbounded proof wait would hang. The resume + /// still runs — the sighting bounds that wait rather than replacing it, so + /// the lock was (re-)broadcast and waited on (a `Broadcast`-status lock + /// was also sent on an earlier call) — and this is what the bounded wait + /// expired with. This is the ONLY double-spend code the SDK emits, and it + /// is PROVISIONAL: no discard licence, keep the lock tracked and retry + /// later. A later chainlock does not upgrade it to 47 today; what a retry + /// can resolve is a reorg dropping the sibling. Repetition licenses + /// nothing either — a conflict that persists across sessions still does + /// not prove finalized ancestry. Its absence is not proof of liveness — + /// the Rust-side scan cannot see conflicts whose spender was already + /// pruned. + case errorAssetLockInputContested = 48 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the /// wallet-was-REMOVED case here. @@ -288,6 +319,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: @@ -486,6 +521,39 @@ public enum PlatformWalletError: LocalizedError { /// `endsAtMs == 0` means the vote's end time was unavailable — show it /// as unknown rather than as "ends at the epoch". case contestedNameNotTradable(label: String, endsAtMs: UInt64) + /// RESERVED, and never produced today: the TERMINAL double-spend verdict, + /// which would additionally attest that the confirmed spender's block is + /// on the finalized chain. The wallet cannot prove that (chainlock + /// contexts and the applied chainlock height are height-based promotion + /// artifacts, not ancestry proofs), so every detection arrives as + /// `assetLockInputContested`. The case is kept so the FFI code stays + /// mapped and hosts that already branch on it keep compiling; if it ever + /// ships it means what it always meant — unlike + /// `transactionBroadcastUnconfirmed`, where the transaction may well be + /// alive and discarding it would strand real funds, this is the one + /// asset-lock error that lets a host discard the lock and rebuild it from + /// currently-unspent inputs. The message names the lock's outpoint, the + /// conflicting input, the confirmed spender, and that spender's finality. + case assetLockInputConflict(String) + /// The tracked asset lock spends an outpoint a different, + /// already-confirmed transaction of this wallet spent first, so no peer + /// will relay it while that spender stands. The resume still ran — it + /// re-broadcast and waited for a proof under a bounded timeout, and this + /// is what the wait expired with. A `Broadcast`-status lock was also + /// already sent on an earlier call, so this is not a claim that nothing + /// ever reached the network. + /// + /// The only double-spend verdict the SDK emits, and PROVISIONAL: the + /// tracked lock must NOT be discarded on this error. A conflict that + /// persists across sessions still does not prove finalized ancestry — + /// the sighting can even be a block record restored from a previous + /// session whose block was reorganized out while the wallet was offline. + /// Keep the tracked lock and continue treating this result as retryable; + /// only `assetLockInputConflict`, or an independent finalized-ancestry + /// proof, may authorize discarding it. No funds move either way: the + /// confirmed spender is this wallet's own transaction, so the value + /// behind the contested input lives on in it. + case assetLockInputContested(String) /// The named thing does not exist. For the deferred payment calls this is /// the wallet-was-REMOVED case: the token's wallet (or the wallet a payment /// was just signed against) is no longer registered in the manager, so there @@ -522,6 +590,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 @@ -636,6 +706,18 @@ public enum PlatformWalletError: LocalizedError { } else { self = .unknown(detail) } + // Both double-spend codes carry the typed `Display` rendering, not a + // JSON detail object: it already names the asset-lock outpoint, the + // conflicting input, the confirmed spender's txid and that spender's + // finality, and reads as a sentence, so they pass through like the + // other prose-message codes. Which verdict was reached is the CODE's + // meaning, not the string's — hosts must branch on the case, not on + // text matching. In practice only 48 arrives; 47 is reserved and has + // no emitter, and is mapped here so it stays typed if that changes. + case .errorAssetLockInputConflict: + self = .assetLockInputConflict(detail) + case .errorAssetLockInputContested: + 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 00000000000..8a8a9b92a5e --- /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) + } +}