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 fb3ac974954..1990cacc6f8 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 @@ -166,10 +166,14 @@ sealed class DashSdkError( * 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. + * hang. The resume still attempts recovery. With a ready transport, + * the sighting bounds the proof wait and this is what that wait + * expired with. In the `Broadcast` arm, after a readiness miss and + * pre-dispatch rejection, a still-standing conflict returns + * immediately after refreshing local finality, and the + * readiness-deferred retry owns the next proof wait. A + * `Broadcast`-status lock may also represent an earlier attempt that + * sent the transaction. * * The ONLY double-spend verdict the native side emits, and it is * PROVISIONAL. NO discard licence: keep the tracked lock and retry diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index ad1fdf0b16e..be2d9f4c14e 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -153,7 +153,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | ---: | --- | --- | --- | | 28 | *(reserved — vacated)* | — | Vacated by #4185/#4256 on 2026-08-02; RESERVED, not reissuable — the next-free frontier is the only allocation source | | 47 | `ErrorAssetLockInputConflict` | #4356 | Proposed — three-layer renumber from 42 **complete** on the branch (Rust value + pin test, Swift raw case, Kotlin arm + test all at 47). Merged #4451 had taken 42 for `ErrorMasternodeWithdrawalUnconfirmed` on 2026-08-22; merged ABI won and #4356 moved. **Reserved-with-no-emitter**: the wallet currently constructs only the provisional 48 — 47 is the terminal discard-licensing verdict, held for a future finalized-ancestry proof the SPV layer does not yet expose. The number is claimed ABI either way; Rule 1 makes 47 unavailable to any other contributor while #4356 is active | -| 48 | `ErrorAssetLockInputContested` | #4356 | Proposed — renumbered from 43 (which active #4313 holds) alongside 47's move. The provisional double-spend verdict the conflict screen always emits: 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 | +| 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: with a ready transport the sighting bounds the proof wait and 48 reports its expiry; in the `Broadcast` arm, after a readiness miss and pre-dispatch rejection, a still-standing conflict returns immediately after refreshing local finality and the deferred retry owns the next proof wait. Carries no discard licence. Rust value + Swift raw case + Kotlin typed arm and tests all at 48 on the branch | | 30 | *(reserved — vacated)* | — | Vacated by #4185/#4256 on 2026-08-02; RESERVED, not reissuable — the next-free frontier is the only allocation source | | 32 | *(reserved — lapsed)* | — | Owner #4310 (successor of fork-era #4247) closed without merging; RESERVED, not reissuable | | 33 | *(reserved — lapsed)* | — | Owner #4311 (successor of fork-era #4256) closed without merging; RESERVED, not reissuable | diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 8295410e01c..d13183a3914 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -473,10 +473,12 @@ pub enum PlatformWalletFFIResultCode { /// Maps `PlatformWalletError::AssetLockInputContested`. The double-spend /// screen's ONLY verdict: a confirmed transaction of this wallet /// already spent one of the tracked lock's inputs. 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 + /// attempts recovery. With a ready transport the sighting bounds the + /// proof wait and this is what that wait expired with; in the `Broadcast` + /// arm, after a readiness miss and pre-dispatch rejection, a still-standing + /// conflict returns immediately after refreshing local finality and leaves + /// the next proof wait to the readiness-deferred retry. 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. /// diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 7b4aca99c1f..944010155d0 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -492,10 +492,13 @@ pub enum PlatformWalletError { /// 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. + /// exactly like a slow network). The resume still attempts recovery. If + /// the transport is ready, the sighting bounds the proof wait and this is + /// what that wait expired with. In the `Broadcast` arm, if readiness was + /// missed and the send was rejected before dispatch, a still-standing + /// conflict returns immediately after refreshing local finality, and the + /// readiness-deferred retry owns the next proof wait. A `Broadcast`-status + /// lock may also represent an earlier attempt that sent the transaction. /// /// The verdict is PROVISIONAL and carries NO licence to discard the /// tracked lock. Keep the lock and retry later. Note what a retry can 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 f088ed8900c..951c1e21ce4 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -878,10 +878,11 @@ impl AssetLockManager { /// Broadcast half of [`Self::create_funded_asset_lock_proof`] — steps 1–4: /// build + fund the asset-lock transaction, persist the funding account's /// address pool, track the lifecycle row, and broadcast. Returns as soon as - /// the transaction is on the wire (status `Broadcast`), BEFORE any proof - /// wait, so a caller can durably record its own bookkeeping for the funded - /// lock (e.g. the inviter-side invitation row) between the broadcast and - /// the potentially long proof wait in + /// the broadcaster accepts the transaction, with the row at `Broadcast` or + /// a later status installed by a concurrent resume, BEFORE any proof wait. + /// This lets a caller durably record its own bookkeeping for the funded lock + /// (e.g. the inviter-side invitation row) between the broadcast and the + /// potentially long proof wait in /// [`Self::wait_for_funded_asset_lock_proof`]. pub(crate) async fn broadcast_funded_asset_lock( &self, @@ -1197,12 +1198,12 @@ impl AssetLockManager { // free, and a rebuild is safe; here the 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 that has either reached the network - // already or is about to. 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. + // rebuild from other UTXOs and create a SECOND asset lock. + // The concurrent resume may have sent the transaction or + // may still be committed to dispatch. The contract that + // matches what is actually true is the unknown outcome: do + // not retry; the row and its reservation are intact, so + // resume the existing lock. // // The price is that the reservation and the fence outlive // this call: the fence ends on an observed spend, and no @@ -1239,11 +1240,20 @@ impl AssetLockManager { // inputs are still selectable here until the spend is observed. in_broadcast_pin.settle_pending_spend(); - // 4. Transition to Broadcast and queue the changeset. - let cs_broadcast = self - .advance_asset_lock_status(&out_point, AssetLockStatus::Broadcast, None) - .await?; - self.queue_asset_lock_changeset(cs_broadcast); + // 4. Transition to Broadcast only if no concurrent flow advanced the + // row while this call awaited the network. Replacing a finalized + // status here would retain its proof under the weaker status. + if let Some(cs_broadcast) = self + .advance_asset_lock_status_if( + &out_point, + |current| *current == AssetLockStatus::Built, + AssetLockStatus::Broadcast, + None, + ) + .await? + { + self.queue_asset_lock_changeset(cs_broadcast); + } Ok((path, out_point)) } @@ -2069,9 +2079,9 @@ mod tests { /// Broadcaster that simulates the racing interleave the release gate /// exists for: "during" the broadcast a concurrent `resume_asset_lock` /// advances the tracked row to `Broadcast`, then the original call still - /// comes back `Rejected`. The advanced row is positive evidence the - /// transaction reached the network, so the cleanup must keep it AND keep - /// the funding reservation. + /// comes back `Rejected`. The advanced row means another attempt owns the + /// transaction and may still deliver it, so the cleanup must keep it AND + /// keep the funding reservation. struct RejectAfterConcurrentResumeBroadcaster { wallet_manager: Arc>>, wallet_id: WalletId, @@ -2105,8 +2115,8 @@ mod tests { /// 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. + /// a second asset lock beside a transaction another attempt may deliver. + /// 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) = @@ -2187,6 +2197,107 @@ mod tests { ); } + /// Simulates a resume attaching finality while the create path is waiting + /// for its broadcast result. + struct FinalizeDuringCreateBroadcast { + wallet_manager: Arc>>, + wallet_id: WalletId, + proof: Mutex>, + } + + #[async_trait] + impl TransactionBroadcaster for FinalizeDuringCreateBroadcast { + async fn broadcast(&self, transaction: &Transaction) -> Result { + use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; + + let mut wm = self.wallet_manager.write().await; + let lock = wm + .get_wallet_info_mut(&self.wallet_id) + .expect("wallet present") + .tracked_asset_locks + .values_mut() + .next() + .expect("row tracked before broadcast"); + assert_eq!(lock.status, AssetLockStatus::Built); + let proof = dpp::prelude::AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: 1_234, + out_point: lock.out_point, + }); + lock.status = AssetLockStatus::ChainLocked; + lock.proof = Some(proof.clone()); + *self.proof.lock().expect("staged proof mutex") = Some(proof); + Ok(transaction.txid()) + } + } + + /// A successful create broadcast must not overwrite a status and proof + /// that a concurrent resume already advanced beyond `Built`. + #[tokio::test] + async fn create_broadcast_does_not_downgrade_a_concurrently_finalized_row() { + let (wallet_manager, wallet_id, _balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let broadcaster = Arc::new(FinalizeDuringCreateBroadcast { + wallet_manager: Arc::clone(&wallet_manager), + wallet_id, + proof: Mutex::new(None), + }); + let persistence = Arc::new(CapturingPersistence::default()); + let manager = AssetLockManager::new( + Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")), + Arc::clone(&wallet_manager), + wallet_id, + Arc::new(Notify::new()), + Arc::clone(&broadcaster), + WalletPersister::new( + wallet_id, + Arc::clone(&persistence) as Arc, + ), + ); + + let (_path, out_point) = manager + .broadcast_funded_asset_lock( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await + .expect("accepted create broadcast"); + let staged_proof = broadcaster + .proof + .lock() + .expect("staged proof mutex") + .clone() + .expect("proof staged during broadcast"); + + { + let wm = wallet_manager.read().await; + let lock = wm + .get_wallet_info(&wallet_id) + .expect("wallet present") + .tracked_asset_locks + .get(&out_point) + .expect("row stays tracked"); + assert_eq!(lock.status, AssetLockStatus::ChainLocked); + assert_eq!(lock.proof.as_ref(), Some(&staged_proof)); + } + + let stored = persistence + .stored + .lock() + .expect("capturing persistence mutex"); + let persisted_downgrade = stored + .iter() + .filter_map(|changeset| changeset.asset_locks.as_ref()) + .filter_map(|changeset| changeset.asset_locks.get(&out_point)) + .any(|entry| entry.status == AssetLockStatus::Broadcast && entry.proof.is_some()); + assert!( + !persisted_downgrade, + "no persisted snapshot may combine Broadcast with an attached proof" + ); + } + /// Persistence stub whose FIRST address-pool store blocks on a 2-party /// barrier until the test arrives, holding that build inside its persist /// while the other build runs. Later stores pass straight through. @@ -2483,12 +2594,12 @@ mod tests { ); } - /// The broadcast half returns as soon as the transaction is on the wire: - /// the tracked row is `Broadcast` (recoverable/resumable) and the - /// invitation funding pool was persisted AND flushed — all BEFORE any - /// proof wait (the test completing at all proves no SPV wait ran), so a - /// caller can durably record its own bookkeeping for the funded lock - /// between the broadcast and the proof wait. + /// The broadcast half returns when the broadcaster accepts the transaction: + /// the tracked row is `Broadcast` (recoverable/resumable) and the invitation + /// funding pool was persisted AND flushed — all BEFORE any proof wait (the + /// test completing at all proves no SPV wait ran), so a caller can durably + /// record its own bookkeeping for the funded lock between the broadcast and + /// the proof wait. #[tokio::test] async fn broadcast_half_leaves_broadcast_row_and_flushed_pool() { let persistence = Arc::new(CapturingPersistence::default()); 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 e53d9f8a4b5..e4fc8302234 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -284,8 +284,8 @@ impl Drop for DeferredResumeMembership { /// 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 +/// block can still reorg out, at which point a peer can relay the tracked +/// lock transaction 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 @@ -713,9 +713,10 @@ impl AssetLockManager { /// 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. + /// A suppression tells the caller only that the conflict must not replace + /// its current outcome. The row is left where it was, so the caller can + /// read a newly-arrived proof immediately or the next resume can return it + /// 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)) @@ -724,9 +725,9 @@ impl AssetLockManager { { 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" + "resume_asset_lock: local finality is present before the input \ + conflict verdict — the conflict is not the explanation and no \ + contested verdict is reported" ); return None; } @@ -777,10 +778,10 @@ impl AssetLockManager { { 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" + "resume_asset_lock: the funding transaction reached finality \ + while the input conflict verdict was being read — the \ + conflict is not the explanation and no contested verdict \ + is reported" ); return None; } @@ -799,8 +800,8 @@ impl AssetLockManager { 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" + "resume_asset_lock: the tracked lock was settled before the input \ + conflict verdict — no contested verdict is reported" ); return None; } @@ -810,8 +811,8 @@ impl AssetLockManager { %input, %spent_by, ?height, - "resume_asset_lock: the proof wait expired with the input conflict \ - still standing; reporting it as the provisional verdict" + "resume_asset_lock: the input conflict is still standing on the \ + verdict's fresh read; reporting it as the provisional verdict" ); Some(PlatformWalletError::AssetLockInputContested { out_point: *out_point, @@ -825,8 +826,10 @@ impl AssetLockManager { /// /// Looks up the tracked lock by `txid`, then: /// - /// - **`Built`**: re-broadcasts the transaction and waits for a proof. - /// - **`Broadcast`**: waits for a proof. + /// - **`Built`**: re-broadcasts the transaction, advances an attempt that + /// was not definitely rejected before dispatch to `Broadcast`, and waits + /// for a proof. + /// - **`Broadcast`**: defensively re-broadcasts and waits for a proof. /// - **`InstantSendLocked` / `ChainLocked`**: uses the existing proof /// (upgrading a stale IS-lock to a ChainLock proof if necessary). /// @@ -878,16 +881,22 @@ impl AssetLockManager { /// [`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: + /// [`first_confirmed_input_conflict`], and a hit never refuses the send + /// attempt. When the transport was ready, it caps the proof 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 re-read + /// afterwards by [`Self::input_conflict_verdict`]. In the `Broadcast` arm, + /// when readiness was missed and the send was rejected before dispatch, + /// no live transport can deliver a new proof, so a freshly re-checked + /// conflict returns immediately and the readiness-deferred retry owns the + /// next proof wait. The conflict verdict is coherent as of the snapshot + /// read by [`Self::input_conflict_verdict`]: a recoverable proof settles + /// the lock, while finality evidence without a recoverable proof can only + /// suppress the contested verdict and returns + /// [`PlatformWalletError::TransactionBroadcastUnconfirmed`]. Any contested + /// verdict is provisional as of that refreshed snapshot; a proof arriving + /// afterwards is reported by the next resume. Blocking the send attempt + /// 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 @@ -1352,7 +1361,7 @@ impl AssetLockManager { "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: {}", + earlier attempt may still be on the network: {}", out_point, UNCONFIRMED_BROADCAST_PROOF_TIMEOUT, rejection ), )); @@ -1404,10 +1413,11 @@ impl AssetLockManager { // its bound. A re-broadcast revives an evicted/undelivered // tx, so it is worth attempting before every wait. // - // Best-effort for the AMBIGUOUS verdict only: unlike the - // `Built` arm, this tx was already broadcast once (that's - // what `Broadcast` means), so it may still be in a mempool or - // already mined — in which case the network reports "already + // Best-effort for the AMBIGUOUS verdict only: `Broadcast` + // records an attempt that was not definitely rejected before + // dispatch; it does not prove network delivery. That or an + // earlier attempt may still have reached a mempool or already + // been mined — in which case the network reports "already // known" / "already in block chain", which the broadcaster // cannot distinguish from a real rejection and reports as // `MaybeSent`. We log that and proceed to `wait_for_proof` @@ -1415,11 +1425,17 @@ impl AssetLockManager { // fine. If the tx really was mined, `wait_for_proof` resolves // immediately from the SPV/persisted record. // - // A DEFINITE `Rejected` ends the resume early — but it says - // NOTHING about the row, and must not be read as one. In - // fact the row's RECORD may already hold the answer: a lock - // can sit at `Broadcast` while its transaction record - // carries an IS lock or a chain-locked context, because + // Without a standing input conflict, a DEFINITE `Rejected` + // ends the resume early — but it says NOTHING about the row, + // and must not be read as one. A standing conflict only falls + // through to the bounded proof wait when transport readiness + // succeeded; after a readiness miss there is no live source + // for a new proof, so the deferred retry owns that wait. The + // local finality and conflict reads are refreshed before the + // fast verdict, because already-queued finality can still land + // while the transport is down. A lock can sit at `Broadcast` + // while its transaction record carries an IS lock or a + // chain-locked context, because // finality that arrives with no waiter active enriches the // record without advancing the tracked status // (`LockNotifyHandler` only wakes waiters, and @@ -1439,10 +1455,9 @@ impl AssetLockManager { // exactly two places, an unstarted client and dash-spv's // zero-connected-peers check (`spv/runtime.rs`), so it means // "*this* send never left the device" — not "the transaction - // is not on the network". The ORIGINAL broadcast that put - // this row at `Broadcast` happened in an earlier process, - // possibly days ago, and its outcome is untouched by a - // re-broadcast that never dispatched. + // is not on the network". Any earlier attempt represented by + // this row may still have succeeded, and its outcome is + // untouched by a re-broadcast that never dispatched. // // So there is no untrack here. `catchUpStuckAssetLocks` runs // on every wallet load, selects `statusRaw < 2` (which @@ -1488,26 +1503,108 @@ impl AssetLockManager { local_proof = Some(proof); } Err(probe_err) => { - tracing::warn!( - outpoint = %out_point, - error = %e, - probe = %probe_err, - "resume_asset_lock: defensive re-broadcast of a \ - Broadcast-status lock was rejected before \ - dispatch and no local proof exists — this \ - attempt never left the device, which proves \ - nothing about the original broadcast; leaving \ - the row tracked at Broadcast and failing the \ - resume as an unknown outcome" - ); - return Err(PlatformWalletError::TransactionBroadcastUnconfirmed( - format!( - "asset lock {out_point} remains tracked after the \ - defensive re-broadcast was rejected before \ - dispatch; the original broadcast may still be on \ - the network: {e}" - ), - )); + if input_conflict.is_some() { + if transport_missed.load(Ordering::Relaxed) { + if let Some(contested) = + self.input_conflict_verdict(out_point).await + { + tracing::warn!( + outpoint = %out_point, + error = %e, + probe = %probe_err, + "resume_asset_lock: defensive re-broadcast was \ + rejected after transport readiness was missed; \ + the refreshed conflict still stands, so returning \ + it immediately and leaving the next proof wait to \ + the deferred retry" + ); + return Err(contested); + } + + // `input_conflict_verdict` suppresses code 48 when + // finality or a settled row became visible during its + // fresh read. Prefer the proof already attached to the + // row, then repeat the zero-duration local probe: a + // record can become final inside the verdict's own + // persister fallback, after that probe missed it. + let attached_proof = { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .and_then(|info| { + info.tracked_asset_locks.get(out_point) + }) + .and_then(|lock| lock.proof.clone()) + }; + match attached_proof { + Some(proof) => local_proof = Some(proof), + None => match self + .wait_for_proof(out_point, Some(Duration::ZERO)) + .await + { + Ok(proof) => local_proof = Some(proof), + Err( + refresh_err + @ PlatformWalletError::FinalityTimeout(_), + ) => { + tracing::warn!( + outpoint = %out_point, + error = %e, + probe = %probe_err, + refresh = %refresh_err, + "resume_asset_lock: defensive re-broadcast \ + was rejected after transport readiness was \ + missed, but the refreshed conflict no longer \ + stands and no reportable local proof is \ + available; preserving the Broadcast row and \ + returning an unknown outcome" + ); + return Err(PlatformWalletError::TransactionBroadcastUnconfirmed( + format!( + "asset lock {out_point} remains tracked after the \ + defensive re-broadcast was rejected before dispatch; \ + an earlier attempt may still be on the network: {e}" + ), + )); + } + Err(refresh_err) => return Err(refresh_err), + }, + } + } else { + tracing::warn!( + outpoint = %out_point, + error = %e, + probe = %probe_err, + "resume_asset_lock: defensive re-broadcast of a \ + Broadcast-status lock was rejected before dispatch \ + with an input conflict sighted over a ready transport; \ + entering the bounded proof wait so live synchronization \ + can settle the lock before the conflict verdict is re-read" + ); + } + } else { + tracing::warn!( + outpoint = %out_point, + error = %e, + probe = %probe_err, + "resume_asset_lock: defensive re-broadcast of a \ + Broadcast-status lock was rejected before \ + dispatch and no local proof exists — this \ + attempt never left the device, which proves \ + nothing about any earlier attempt; leaving \ + the row tracked at Broadcast and failing the \ + resume as an unknown outcome" + ); + return Err( + PlatformWalletError::TransactionBroadcastUnconfirmed( + format!( + "asset lock {out_point} remains tracked after the \ + defensive re-broadcast was rejected before \ + dispatch; an earlier attempt may still be on \ + the network: {e}" + ), + ), + ); + } } } } else { @@ -1935,7 +2032,7 @@ mod tests { } } - /// Persistence stub that mutates the wallet from inside the N-th + /// Persistence stub that mutates the wallet manager from inside the N-th /// persister-backed record lookup, placing a change at an interleaving /// no test can otherwise reach. /// @@ -1955,7 +2052,8 @@ mod tests { target_lookup: usize, lookups: std::sync::atomic::AtomicUsize, #[allow(clippy::type_complexity)] - mutate: Mutex>>, + mutate: + Mutex, WalletId) + Send>>>, } impl InterleavedPersistence { @@ -1963,7 +2061,7 @@ mod tests { wallet_manager: Arc>>, wallet_id: WalletId, target_lookup: usize, - mutate: impl FnOnce(&mut PlatformWalletInfo) + Send + 'static, + mutate: impl FnOnce(&mut WalletManager, WalletId) + Send + 'static, ) -> Self { Self { wallet_manager, @@ -2012,10 +2110,7 @@ mod tests { } std::thread::yield_now(); }; - mutate( - wm.get_wallet_info_mut(&self.wallet_id) - .expect("wallet must remain registered"), - ); + mutate(&mut wm, self.wallet_id); } } // This backend keeps no records of its own; the mutation above is @@ -3032,11 +3127,11 @@ mod tests { ); } - /// 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. + /// The same shape without a proof, over a broadcaster whose transport is + /// ready: 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; @@ -3088,9 +3183,62 @@ mod tests { .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" + "a send rejected before dispatch must leave the row at Built" ); + + // Exercise the independent defensive-Broadcast path on the second + // resume. This models a later send whose outcome was not definitely + // undispatched advancing the same retained row. + fixture.track(AssetLockStatus::Broadcast, None).await; + 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, + "the second resume must start from the defensive Broadcast arm" + ); + + let second_started = tokio::time::Instant::now(); + let second_error = fixture + .manager + .resume_asset_lock(&fixture.out_point, Some(Duration::from_millis(10))) + .await + .expect_err("the standing conflict must keep bounding later resumes"); + let second_elapsed = second_started.elapsed(); + match second_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 defensive re-broadcast must preserve the standing conflict's \ + verdict across resumes, got {other:?}" + ), + } + assert_eq!( + fixture.broadcast_count(), + 2, + "each resume still attempts its own re-broadcast before reporting the conflict" + ); + assert!( + second_elapsed >= Duration::from_millis(10), + "a ready transport with a standing conflict must still enter the bounded proof wait" + ); + // 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 @@ -3142,12 +3290,15 @@ mod tests { // miss and lookup 1 is the expiring proof wait's own; // lookup 2 is the verdict's probe, the gap under test. 2, - move |info| { + move |wm, wallet_id| { let transaction = handle .lock() .expect("built transaction slot") .clone() .expect("fixture files the transaction before resuming"); + let info = wm + .get_wallet_info_mut(&wallet_id) + .expect("wallet must remain registered"); insert_record(info, record_for(transaction, chain_locked_at(1_500))); }, )); @@ -3236,12 +3387,15 @@ mod tests { // miss and lookup 1 is the expiring proof wait's own; // lookup 2 is the verdict's probe, the gap under test. 2, - move |info| { + move |wm, wallet_id| { let transaction = handle .lock() .expect("built transaction slot") .clone() .expect("fixture files the transaction before resuming"); + let info = wm + .get_wallet_info_mut(&wallet_id) + .expect("wallet must remain registered"); insert_record(info, record_for(transaction, confirmed_at(1_200))); info.apply_chain_lock(ChainLock { block_height: 1_220, @@ -3352,7 +3506,10 @@ mod tests { // lookup 2 is the verdict's probe, the gap the settling // has to land in. 2, - |info| { + |wm, wallet_id| { + let info = wm + .get_wallet_info_mut(&wallet_id) + .expect("wallet must remain registered"); let (out_point, lock) = info .tracked_asset_locks .iter_mut() @@ -3431,12 +3588,15 @@ mod tests { // lookup 2 the expiring wait, lookup 3 the verdict's // probe — the gap the retraction has to land in. 3, - move |info| { + move |wm, wallet_id| { let transaction = handle .lock() .expect("spender slot") .clone() .expect("fixture files the spender before resuming"); + let info = wm + .get_wallet_info_mut(&wallet_id) + .expect("wallet must remain registered"); // A reorg drops the block; the record survives, // demoted, which retracts the remembered sighting. insert_record(info, record_for(transaction, TransactionContext::Mempool)); @@ -4533,9 +4693,8 @@ mod tests { /// /// With the production `SpvBroadcaster`, `Rejected` means an unstarted /// client or zero connected peers: a fact about the re-broadcast attempt, - /// not about the ORIGINAL broadcast that put the row at `Broadcast` in an - /// earlier process. Two things followed from reading it as a verdict on - /// the row. + /// not proof that every attempt represented by the `Broadcast` row failed. + /// Two things followed from reading it as a verdict on the row. /// /// The first revision untracked the row here. `catchUpStuckAssetLocks` /// resumes every `statusRaw < 2` row on each wallet load with no @@ -4546,8 +4705,8 @@ mod tests { /// The second was the error type. `TransactionBroadcast` is the FFI's /// code 26, which promises the host that Core rejected the transaction, /// its UTXO reservation was released and a rebuild is safe — while this - /// arm deliberately keeps both the row and its reservation because the - /// original may still confirm. A host honouring code 26 would rebuild + /// arm deliberately keeps both the row and its reservation because an + /// earlier attempt may still confirm. A host honouring code 26 would rebuild /// from other UTXOs and create a SECOND asset lock alongside a live one. /// The non-terminal `TransactionBroadcastUnconfirmed` (code 20) is the /// contract that matches what this arm actually knows: outcome unknown, @@ -4578,8 +4737,8 @@ mod tests { assert_eq!( tracked, Some(AssetLockStatus::Broadcast), - "a re-broadcast that never left the device says nothing about the \ - original send — the row must survive, unchanged, for a later resume" + "a re-broadcast that never left the device says nothing about any \ + earlier attempt — the row must survive, unchanged, for a later resume" ); } @@ -5135,6 +5294,300 @@ mod tests { ); } + /// Regression: an offline `Broadcast` resume with a standing conflict + /// must return after the transport-readiness wait, rejected send, and + /// fresh local verdict reads. The unavailable transport cannot deliver a + /// new proof, so the deferred retry owns the next wait for connectivity. + /// + /// A 10ms caller timeout cannot catch this delay: both the immediate path + /// and an accidental proof wait finish inside that short explicit bound. + /// Using `None` exercises the production proof-wait default, while paused + /// time makes it practical to prove that only the 15s readiness wait ran. + #[tokio::test(start_paused = true)] + async fn offline_broadcast_resume_with_a_conflict_skips_the_dead_proof_wait() { + let broadcaster = Arc::new(StartingUpBroadcaster::never_comes_up()); + let fixture = tracked_lock_at(broadcaster.clone(), AssetLockStatus::Broadcast).await; + + let (funded_input, spender_txid) = { + let mut wm = fixture.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&fixture.wallet_id) + .expect("wallet must remain registered"); + let funded_input = info + .tracked_asset_locks + .get(&fixture.out_point) + .expect("lock stays tracked") + .transaction + .input + .first() + .expect("asset lock spends at least one input") + .previous_output; + let spender = transaction_spending(funded_input); + let spender_txid = spender.txid(); + info.core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("funded fixture has BIP44 account 0") + .transactions_mut() + .insert(spender_txid, record_for(spender, confirmed_at(1_234))); + (funded_input, spender_txid) + }; + + let started = tokio::time::Instant::now(); + let error = fixture + .manager + .resume_asset_lock(&fixture.out_point, None) + .await + .expect_err("the standing conflict must explain the offline resume"); + let elapsed = started.elapsed(); + + match error { + PlatformWalletError::AssetLockInputContested { + out_point, + input, + spent_by, + height, + } => { + assert_eq!(out_point, fixture.out_point); + assert_eq!(input, funded_input); + assert_eq!(spent_by, spender_txid); + assert_eq!(height, Some(1_234)); + } + other => panic!("expected AssetLockInputContested, got {other:?}"), + } + assert_eq!( + broadcaster.readiness_budgets(), + vec![BROADCAST_TRANSPORT_READY_WAIT], + "the foreground attempt gets exactly one bounded readiness wait" + ); + assert_eq!( + elapsed, BROADCAST_TRANSPORT_READY_WAIT, + "the offline foreground resume must not add the default proof wait" + ); + let attempts = broadcaster.attempts(); + assert_eq!(attempts.len(), 1, "the foreground resume gets one send"); + assert!( + !attempts[0].1, + "the send is rejected before dispatch because readiness was missed" + ); + } + + /// Finality that lands while the post-rejection local probe is reading + /// must outrank the conflict seen before the transport wait. The probe's + /// persister fallback creates the narrow interleaving: it installs the + /// chain-locked record after the probe's in-memory miss, then returns no + /// record itself, so only a fresh read can observe the proof. + /// + /// The exact elapsed-time assertion also keeps this finality refresh from + /// restoring the default proof wait to an offline `Broadcast` resume. + #[tokio::test(start_paused = true)] + async fn offline_broadcast_resume_refreshes_finality_before_reporting_a_conflict() { + let (wallet_manager, wallet_id, _balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let transaction_slot = Arc::new(Mutex::new(None::)); + let mutation_slot = Arc::clone(&transaction_slot); + let persistence = Arc::new(InterleavedPersistence::new( + Arc::clone(&wallet_manager), + wallet_id, + // Lookup 0 is the pre-transport proof probe. Lookup 1 injects + // finality during the post-rejection probe, before the refreshed + // conflict verdict is chosen. + 1, + move |wm, wallet_id| { + let transaction = mutation_slot + .lock() + .expect("transaction slot") + .clone() + .expect("asset-lock transaction was built"); + let info = wm + .get_wallet_info_mut(&wallet_id) + .expect("wallet must remain registered"); + insert_record(info, record_for(transaction, chain_locked_at(1_500))); + }, + )); + let broadcaster = Arc::new(StartingUpBroadcaster::never_comes_up()); + let manager = AssetLockManager::new( + Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_network(Network::Testnet) + .build() + .expect("mock SDK"), + ), + Arc::clone(&wallet_manager), + wallet_id, + Arc::new(Notify::new()), + broadcaster.clone(), + WalletPersister::new( + wallet_id, + Arc::clone(&persistence) as Arc, + ), + ); + let (transaction, _path) = manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 4, + &signer, + ) + .await + .expect("build asset-lock transaction"); + let out_point = OutPoint::new(transaction.txid(), 0); + *transaction_slot.lock().expect("transaction slot") = Some(transaction.clone()); + + { + let mut wm = wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&wallet_id) + .expect("wallet must remain registered"); + insert_record( + info, + record_for( + transaction_spending(transaction.input[0].previous_output), + confirmed_at(1_234), + ), + ); + info.tracked_asset_locks.insert( + out_point, + TrackedAssetLock { + out_point, + transaction, + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 4, + amount: 1_000_000, + status: AssetLockStatus::Broadcast, + proof: None, + }, + ); + } + + let started = tokio::time::Instant::now(); + let (proof, _path) = manager + .resume_asset_lock(&out_point, None) + .await + .expect("fresh local finality must outrank the stale conflict snapshot"); + let elapsed = started.elapsed(); + + assert!( + persistence.fired(), + "finality must land during the post-rejection proof probe" + ); + match proof { + dpp::prelude::AssetLockProof::Chain(chain) => { + assert_eq!(chain.out_point, out_point); + assert_eq!(chain.core_chain_locked_height, 1_500); + } + other => panic!("expected a ChainAssetLockProof, got {other:?}"), + } + assert_eq!( + broadcaster.readiness_budgets(), + vec![BROADCAST_TRANSPORT_READY_WAIT], + "the foreground attempt gets exactly one bounded readiness wait" + ); + assert_eq!( + elapsed, BROADCAST_TRANSPORT_READY_WAIT, + "refreshing finality must not restore the default proof wait" + ); + } + + /// Removing a wallet while an offline resume is suspended invalidates + /// every claim about its tracked row. The post-rejection refresh must + /// preserve that lookup error instead of reporting an unknown broadcast + /// outcome whose contract says the row and reservation still exist. + #[tokio::test(start_paused = true)] + async fn offline_broadcast_resume_preserves_wallet_removal_during_refresh() { + let (wallet_manager, wallet_id, _balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let persistence = Arc::new(InterleavedPersistence::new( + Arc::clone(&wallet_manager), + wallet_id, + // Lookup 0 is the pre-transport proof probe. Lookup 1 removes the + // wallet during the post-rejection probe. + 1, + move |wm, wallet_id| { + wm.remove_wallet(&wallet_id) + .expect("wallet removal must win the interleaving"); + }, + )); + let broadcaster = Arc::new(StartingUpBroadcaster::never_comes_up()); + let manager = AssetLockManager::new( + Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_network(Network::Testnet) + .build() + .expect("mock SDK"), + ), + Arc::clone(&wallet_manager), + wallet_id, + Arc::new(Notify::new()), + broadcaster.clone(), + WalletPersister::new( + wallet_id, + Arc::clone(&persistence) as Arc, + ), + ); + let (transaction, _path) = manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 4, + &signer, + ) + .await + .expect("build asset-lock transaction"); + let out_point = OutPoint::new(transaction.txid(), 0); + + { + let mut wm = wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&wallet_id) + .expect("wallet must remain registered"); + insert_record( + info, + record_for( + transaction_spending(transaction.input[0].previous_output), + confirmed_at(1_234), + ), + ); + info.tracked_asset_locks.insert( + out_point, + TrackedAssetLock { + out_point, + transaction, + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 4, + amount: 1_000_000, + status: AssetLockStatus::Broadcast, + proof: None, + }, + ); + } + + let started = tokio::time::Instant::now(); + let error = manager + .resume_asset_lock(&out_point, None) + .await + .expect_err("a removed wallet must stop the resume"); + let elapsed = started.elapsed(); + + assert!( + persistence.fired(), + "the wallet-removal interleaving must run" + ); + assert!( + matches!(error, PlatformWalletError::WalletNotFound(_)), + "a removed wallet must report WalletNotFound, got {error:?}" + ); + assert_eq!( + elapsed, BROADCAST_TRANSPORT_READY_WAIT, + "wallet removal must not restore the default proof wait" + ); + } + /// A caller that named its own budget keeps it: the transport wait is /// capped by the constant, and whatever it consumes is deducted so the /// resume's total stays inside what the caller asked for. diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs index e13d3051038..cef120d06d1 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs @@ -153,8 +153,8 @@ impl AssetLockManager { /// and everything that pins its inputs must stay: /// /// 1. The row must still be [`Built`](AssetLockStatus::Built). A resume - /// that already re-broadcast advanced it, and that advance is - /// positive evidence the transaction reached the network. + /// that already re-broadcast advanced it, recording an attempt that was + /// not definitely rejected before dispatch. /// 2. No active or sticky cleanup exclusion may remain /// ([`claim_resume_dispatch`](Self::claim_resume_dispatch)). A resume /// that has snapshotted the row but not yet sent is still `Built`, and @@ -199,14 +199,14 @@ impl AssetLockManager { } // NOTE: there is deliberately no `untrack_unproven_broadcast_asset_lock` - // companion here. A `Rejected` verdict from a re-broadcast describes only - // that attempt (with the production `SpvBroadcaster`: an unstarted client - // or zero connected peers), never the ORIGINAL broadcast that moved the - // row to `Broadcast` in an earlier process — so it is not evidence that - // the transaction is absent from the network, and removing the row on it - // would delete tracking for possibly-mined asset locks during ordinary - // offline relaunches. `resume_asset_lock` surfaces the typed error and - // leaves the row untouched. + // companion here. `Broadcast` records an attempt that was not definitely + // rejected before dispatch, not that the network accepted it. A `Rejected` + // verdict from a later attempt says only that attempt did not dispatch + // (with the production `SpvBroadcaster`: an unstarted client or zero + // connected peers), so it cannot prove that no earlier attempt reached the + // network. Removing the row would therefore discard tracking for a + // possibly-mined asset lock during an ordinary offline relaunch. + // `resume_asset_lock` surfaces the typed error and leaves the row untouched. /// Mark a tracked asset lock as /// [`Consumed`](AssetLockStatus::Consumed) after a successful @@ -420,10 +420,10 @@ impl AssetLockManager { /// predicate. /// /// For a transition whose evidence is bound to a particular prior state. - /// A resume's `Built` → `Broadcast` advance records "this send - /// dispatched", which a row that a concurrent resume has meanwhile - /// carried to a proof-bearing status must not be regressed to; its - /// predicate is therefore "still `Built`". + /// A `Built` → `Broadcast` advance records an attempt that was not + /// definitely rejected before dispatch. A row that a concurrent flow has + /// meanwhile carried to a proof-bearing status must not be regressed to; + /// the predicate is therefore "still `Built`". pub(crate) async fn advance_asset_lock_status_if( &self, out_point: &OutPoint, diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs index a85a7d79cce..4bef519bd80 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs @@ -47,6 +47,8 @@ use crate::changeset::AssetLockEntry; #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum AssetLockStatus { Built, + /// A broadcast attempt was not definitely rejected before dispatch. The + /// status does not assert that a peer or the network accepted it. Broadcast, InstantSendLocked, ChainLocked, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 78f7ebf7e22..8166a4b4c5c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -204,13 +204,16 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// 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 + /// still attempts recovery. With a ready transport, the sighting bounds + /// the proof wait and this is what that wait expired with. In the + /// `Broadcast` arm, after a readiness miss and pre-dispatch rejection, a + /// still-standing conflict returns immediately after refreshing local + /// finality, and the readiness-deferred retry owns the next proof wait. A + /// `Broadcast`-status lock may also represent an earlier attempt that sent + /// the transaction. 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 @@ -579,11 +582,14 @@ public enum PlatformWalletError: LocalizedError { case assetLockInputConflict(String) /// The tracked asset lock spends an outpoint a different, /// already-confirmed transaction of this wallet spent first, so no peer - /// will relay it while that spender stands. The 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. + /// will relay it while that spender stands. The resume still attempts + /// recovery. With a ready transport, this is what the bounded proof wait + /// expired with. In the `Broadcast` arm, after a readiness miss and + /// pre-dispatch rejection, a still-standing conflict returns immediately + /// after refreshing local finality and leaves that wait to the + /// readiness-deferred retry. A `Broadcast`-status lock may also represent + /// an earlier attempt that sent it, so this is not a claim that nothing + /// 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