From 74b5be39ce5132f3f0b391c5c4aad73ba55ceaa3 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 8 Sep 2026 00:55:08 +0700 Subject: [PATCH 1/4] fix(platform-wallet): close asset-lock resume broadcast race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote Built rows before resume broadcasts through a shared compare-and-set. Preserve concurrently advanced status and proof in both resume and create paths, and keep rejected attempts tracked at Broadcast without releasing their inputs. Test would have caught this in CI: - rejected_create_while_resume_broadcasts_keeps_row_and_reservation: ✖ before the fix the rejected create removed the row and released its reservation; ✔ after the fix the row remains Broadcast and a rebuild cannot select its inputs. - stale_built_resume_does_not_downgrade_a_concurrently_finalized_row: ✖ before the fix the stale resume timed out after replacing ChainLocked with Broadcast; ✔ after the fix it re-dispatches from the attached ChainLock proof. - create_broadcast_does_not_downgrade_a_concurrently_finalized_row: ✖ before the fix the create completion replaced ChainLocked with Broadcast; ✔ after the fix it preserves the finalized status and proof. - Built-resume rejection assertions: ✖ before the fix the row stayed Built; ✔ after the fix it stays tracked at Broadcast for defensive resume. --- .../src/wallet/asset_lock/build.rs | 163 +++++++++++++++--- .../src/wallet/asset_lock/sync/recovery.rs | 145 +++++++++++----- .../src/wallet/asset_lock/sync/tracking.rs | 28 +-- .../src/wallet/asset_lock/tracked.rs | 2 + 4 files changed, 257 insertions(+), 81 deletions(-) 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..85bde5ec1c6 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 @@ -825,8 +825,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). /// @@ -1352,7 +1354,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 +1406,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 +1418,12 @@ 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. 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 // finality that arrives with no waiter active enriches the // record without advancing the tracked status // (`LockNotifyHandler` only wakes waiters, and @@ -1439,10 +1443,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 +1491,40 @@ impl AssetLockManager { local_proof = Some(proof); } Err(probe_err) => { + if input_conflict.is_none() { + 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}" + ), + ), + ); + } 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" + Broadcast-status lock was rejected before dispatch \ + with an input conflict sighted; entering the bounded \ + proof wait so live synchronization can settle the \ + lock before the conflict verdict is re-read" ); - 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}" - ), - )); } } } else { @@ -3088,9 +3105,56 @@ 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_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"); + 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" + ); + // 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 @@ -4533,9 +4597,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 +4609,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 +4641,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" ); } 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, From a160f11131b918b467f492441064243366392c8b Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 8 Sep 2026 02:15:01 +0700 Subject: [PATCH 2/4] docs(platform-wallet): stop the contested verdict claiming an earlier send A Broadcast row now means a broadcast was attempted, not that one reached the network: two pre-dispatch rejections can leave a row at Broadcast having sent nothing. The contested-verdict docs in the Rust error type and both mobile SDKs still asserted an earlier call had sent the transaction. Docs only; no behaviour change, so no test accompanies it. --- .../org/dashfoundation/dashsdk/errors/DashSdkError.kt | 3 ++- packages/rs-platform-wallet/src/error.rs | 3 ++- .../PlatformWallet/PlatformWalletResult.swift | 8 ++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index fb3ac974954..10286411c68 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 @@ -168,7 +168,8 @@ sealed class DashSdkError( * 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 + * `Broadcast`-status lock also had an earlier attempt that may have + * sent it), and this * is what the bounded wait expired with. * * The ONLY double-spend verdict the native side emits, and it is diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 7b4aca99c1f..0cafc8a5244 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -495,7 +495,8 @@ pub enum PlatformWalletError { /// 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. + /// `Broadcast`-status lock also had an earlier attempt that may have + /// 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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 78f7ebf7e22..f44dcb5b4ce 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -206,7 +206,7 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// 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 + /// also had an earlier attempt that may have sent it) — 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 @@ -581,9 +581,9 @@ public enum PlatformWalletError: LocalizedError { /// 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. + /// is what the wait expired with. A `Broadcast`-status lock also had an + /// earlier attempt that may have sent it, 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 From cb2f46a44dc938d6197d879578fff2825bb5e975 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 10 Sep 2026 19:33:32 +0700 Subject: [PATCH 3/4] fix(platform-wallet): skip offline contested proof wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Return the contested verdict immediately when transport readiness was missed and the defensive re-broadcast was rejected before dispatch. The readiness-deferred retry owns the next proof wait, while ready transports retain the bounded wait. This intermediate fast path returns the conflict snapshot taken before readiness. That bounds offline latency but introduces a stale-verdict hazard if finality lands during the post-rejection probe; the follow-up commit refreshes finality and the conflict before code 48. Test would have caught this in CI: ✖ on a160f11131: offline_broadcast_resume_with_a_conflict_skips_the_dead_proof_wait failed "the offline foreground resume must not add the default proof wait" with left: 195s, right: 15s ✔ here: the same test returns AssetLockInputContested after exactly the 15s readiness wait --- .../dashsdk/errors/DashSdkError.kt | 11 +- .../ERROR_CODE_REGISTRY.md | 2 +- packages/rs-platform-wallet-ffi/src/error.rs | 9 +- packages/rs-platform-wallet/src/error.rs | 11 +- .../src/wallet/asset_lock/sync/recovery.rs | 169 ++++++++++++++---- .../PlatformWallet/PlatformWalletResult.swift | 27 +-- 6 files changed, 172 insertions(+), 57 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index 10286411c68..fe9367ff98c 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,11 +166,12 @@ 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 also had an earlier attempt that may have - * sent it), 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. After a readiness miss and pre-dispatch rejection, + * the verdict returns immediately 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..15b431b5ce5 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; after a readiness miss and pre-dispatch rejection, 48 returns immediately 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..dfb32fccb5d 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -473,10 +473,11 @@ 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; after a readiness + /// miss and pre-dispatch rejection, it returns immediately 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 0cafc8a5244..0f4b75c31da 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -492,11 +492,12 @@ 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 also had an earlier attempt that may have - /// sent the transaction. + /// 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. If readiness was missed and the send was + /// rejected before dispatch, the verdict returns immediately 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/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index 85bde5ec1c6..74773a5799f 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 @@ -880,16 +880,16 @@ 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`]. When readiness was + /// missed and the send was rejected before dispatch, no live transport + /// can deliver a proof, so the contested verdict returns immediately and + /// the readiness-deferred retry owns the next proof wait. A proof already + /// present locally settles the lock before either path. 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 @@ -1420,10 +1420,13 @@ impl AssetLockManager { // // 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. 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 + // 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 + // 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 // finality that arrives with no waiter active enriches the // record without advancing the tracked status // (`LockNotifyHandler` only wakes waiters, and @@ -1491,7 +1494,38 @@ impl AssetLockManager { local_proof = Some(proof); } Err(probe_err) => { - if input_conflict.is_none() { + if let Some((input, spent_by, height)) = input_conflict { + if transport_missed.load(Ordering::Relaxed) { + tracing::warn!( + outpoint = %out_point, + %input, + %spent_by, + ?height, + error = %e, + probe = %probe_err, + "resume_asset_lock: defensive re-broadcast was \ + rejected after transport readiness was missed; \ + returning the standing conflict immediately and \ + leaving the next proof wait to the deferred retry" + ); + return Err(PlatformWalletError::AssetLockInputContested { + out_point: *out_point, + input, + spent_by, + height, + }); + } + 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, @@ -1515,16 +1549,6 @@ impl AssetLockManager { ), ); } - 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; entering the bounded \ - proof wait so live synchronization can settle the \ - lock before the conflict verdict is re-read" - ); } } } else { @@ -3049,11 +3073,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; @@ -3127,11 +3151,13 @@ mod tests { "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, @@ -3154,6 +3180,10 @@ mod tests { 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 @@ -5198,6 +5228,85 @@ mod tests { ); } + /// Regression: an offline `Broadcast` resume with a standing conflict + /// must return after the transport-readiness wait and rejected send. No + /// proof can arrive through the transport that just missed readiness, 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" + ); + } + /// 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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index f44dcb5b4ce..8ef47c269f7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -204,13 +204,15 @@ 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 - /// also had an earlier attempt that may have sent it) — 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. After a + /// readiness miss and pre-dispatch rejection, the verdict returns + /// immediately 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 +581,12 @@ 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 also had an - /// earlier attempt that may have sent it, 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. After a readiness miss and pre-dispatch rejection, it + /// returns immediately 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 From 751cd3b1033a99d5f503bef95e1f87ad08ca12de Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 10 Sep 2026 23:07:33 +0700 Subject: [PATCH 4/4] fix(platform-wallet): refresh offline contested verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refresh local finality and the input conflict immediately before returning code 48 from the offline Broadcast fast path. A recoverable proof completes the resume; finalized evidence without a proof suppresses the contested verdict, while only a genuine FinalityTimeout becomes code 20 and lookup errors such as WalletNotFound propagate unchanged. Document the refreshed snapshot as the verdict linearization point: code 48 remains provisional, and a proof arriving afterwards is reported by the next resume. Settlement now explicitly requires a recoverable proof. Tests would have caught this in CI: ✖ on commit A: offline_broadcast_resume_refreshes_finality_before_reporting_a_conflict failed "fresh local finality must outrank the stale conflict snapshot: AssetLockInputContested { ... }" ✔ here: the same test returns the ChainLock proof after exactly the 15s readiness wait ✖ on commit A: offline_broadcast_resume_preserves_wallet_removal_during_refresh failed "a removed wallet must report WalletNotFound, got AssetLockInputContested { ... }" ✔ here: the same test returns WalletNotFound after exactly the 15s readiness wait --- .../dashsdk/errors/DashSdkError.kt | 10 +- .../ERROR_CODE_REGISTRY.md | 2 +- packages/rs-platform-wallet-ffi/src/error.rs | 7 +- packages/rs-platform-wallet/src/error.rs | 5 +- .../src/wallet/asset_lock/sync/recovery.rs | 403 +++++++++++++++--- .../PlatformWallet/PlatformWalletResult.swift | 21 +- 6 files changed, 368 insertions(+), 80 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index fe9367ff98c..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 @@ -168,10 +168,12 @@ sealed class DashSdkError( * confirm while that spender stands and an unbounded proof wait would * hang. The resume still attempts recovery. With a ready transport, * the sighting bounds the proof wait and this is what that wait - * expired with. After a readiness miss and pre-dispatch rejection, - * the verdict returns immediately and the readiness-deferred retry - * owns the next proof wait. A `Broadcast`-status lock may also - * represent an earlier attempt that sent the transaction. + * 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 15b431b5ce5..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: with a ready transport the sighting bounds the proof wait and 48 reports its expiry; after a readiness miss and pre-dispatch rejection, 48 returns immediately 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 | +| 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 dfb32fccb5d..d13183a3914 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -474,9 +474,10 @@ pub enum PlatformWalletFFIResultCode { /// screen's ONLY verdict: a confirmed transaction of this wallet /// already spent one of the tracked lock's inputs. The resume still /// attempts recovery. With a ready transport the sighting bounds the - /// proof wait and this is what that wait expired with; after a readiness - /// miss and pre-dispatch rejection, it returns immediately and leaves the - /// next proof wait to the readiness-deferred retry. PROVISIONAL — 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 0f4b75c31da..944010155d0 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -494,8 +494,9 @@ pub enum PlatformWalletError { /// `reject` by default in 0.17, so the drop is silent and looks /// 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. If readiness was missed and the send was - /// rejected before dispatch, the verdict returns immediately and the + /// 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. /// 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 74773a5799f..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 @@ -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, @@ -884,12 +885,18 @@ impl AssetLockManager { /// 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`]. When readiness was - /// missed and the send was rejected before dispatch, no live transport - /// can deliver a proof, so the contested verdict returns immediately and - /// the readiness-deferred retry owns the next proof wait. A proof already - /// present locally settles the lock before either path. Blocking the send - /// attempt outright is what this evidence does NOT support: + /// 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 @@ -1424,9 +1431,11 @@ impl AssetLockManager { // 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 - // 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 + // 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 @@ -1494,37 +1503,84 @@ impl AssetLockManager { local_proof = Some(proof); } Err(probe_err) => { - if let Some((input, spent_by, height)) = input_conflict { + 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, - %input, - %spent_by, - ?height, error = %e, probe = %probe_err, - "resume_asset_lock: defensive re-broadcast was \ - rejected after transport readiness was missed; \ - returning the standing conflict immediately and \ - leaving the next proof wait to the deferred retry" + "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" ); - return Err(PlatformWalletError::AssetLockInputContested { - out_point: *out_point, - input, - spent_by, - height, - }); } - 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, @@ -1976,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. /// @@ -1996,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 { @@ -2004,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, @@ -2053,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 @@ -3236,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))); }, )); @@ -3330,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, @@ -3446,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() @@ -3525,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)); @@ -5229,9 +5295,9 @@ mod tests { } /// Regression: an offline `Broadcast` resume with a standing conflict - /// must return after the transport-readiness wait and rejected send. No - /// proof can arrive through the transport that just missed readiness, so - /// the deferred retry owns the next wait for connectivity. + /// 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. @@ -5307,6 +5373,221 @@ mod tests { ); } + /// 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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 8ef47c269f7..8166a4b4c5c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -205,11 +205,12 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// 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 attempts recovery. With a ready transport, the sighting bounds - /// the proof wait and this is what that wait expired with. After a - /// readiness miss and pre-dispatch rejection, the verdict returns - /// immediately 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, + /// 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 @@ -583,10 +584,12 @@ public enum PlatformWalletError: LocalizedError { /// already-confirmed transaction of this wallet spent first, so no peer /// 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. After a readiness miss and pre-dispatch rejection, it - /// returns immediately 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. + /// 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