From 61f871e38065e2296442c6208f04814f8fe8974d Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:31:36 +0700 Subject: [PATCH 01/15] fix(platform-wallet): age-guard the finalized-transaction handle broadcast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased down to the age-guard onto current v4.2-dev: the #4185/#4308 stack it was riding merged, and #4323/#4325 renamed the finalized- transaction surface (the v2 suffix is gone), so the guard now lands on core_wallet_broadcast_signed_transaction and the slice-based finalize_transaction signature. Mirrors the deferred registry-token age policy on the finalized-handle path: RESERVATION_MAX_AGE_BLOCKS (20; key-wallet TTL 24) and reservation_expired() live in wallet::reservations, shared by both surfaces. broadcast_finalized_transaction refuses with StaleReservation (FFI ErrorStaleReservationToken, 34) before touching the broadcaster once the reservation's stamp height has aged past the bound — and the refusal reconciles the reservation on the way out, exactly like the registry's stale-token branch: the FFI wrapper has already consumed the opaque handle, so no follow-up abandon is possible, and the owner- guarded release (safe at any age; a no-op once ownership transferred) frees the still-owned inputs for the instructed immediate rebuild. Abandon/free likewise release owner-guarded at any age, with the by-outpoint skip retained only for token-less builds. Boundary tests cover both account types on the platform and FFI layers, including the terminal FFI stale-broadcast path. Co-Authored-By: Claude Fable 5 --- .../dashsdk/errors/DashSdkError.kt | 24 +- .../dashsdk/wallet/ManagedCoreWallet.kt | 28 +- .../src/core_wallet/broadcast.rs | 112 ++++++++ packages/rs-platform-wallet-ffi/src/error.rs | 53 ++++ packages/rs-platform-wallet/src/error.rs | 27 ++ .../rs-platform-wallet/src/test_support.rs | 32 +++ .../src/wallet/core/broadcast.rs | 269 +++++++++++++++++- .../src/wallet/core/transaction.rs | 39 +++ .../src/wallet/reservations.rs | 72 +++++ .../src/wallet/signed_payment_registry.rs | 69 ++--- 10 files changed, 663 insertions(+), 62 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 b6c555a3add..59863deb13b 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 @@ -266,13 +266,23 @@ sealed class DashSdkError( PlatformWallet(message, cause) /** - * `ErrorStaleReservationToken` (native code 34). A deferred - * (BIP70/BIP270) [broadcastSigned][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.broadcastSigned] - * token has outlived its funding reservation's lifetime: key-wallet's - * TTL may already have swept and re-selected the inputs, so acting on it - * could touch a newer, unrelated reservation. The call did NOT touch the - * network. NOT retryable in place — rebuild the payment with - * [buildSignedPayment][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.buildSignedPayment]. + * `ErrorStaleReservationToken` (native code 34). A payment's funding + * reservation has outlived its lifetime: key-wallet's TTL may already + * have swept and re-selected the inputs, so sending it could spend + * against a newer, unrelated reservation. The call did NOT touch the + * network, and it released the still-owned reservation on the way out + * (owner-guarded — a no-op if ownership had already transferred). NOT + * retryable in place — rebuild the payment, which can reselect the + * freed inputs immediately. + * + * The code is shared by BOTH deferred-payment surfaces (the messages + * distinguish them): a deferred (BIP70/BIP270) + * [broadcastSigned][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.broadcastSigned] + * token, rebuilt with + * [buildSignedPayment][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.buildSignedPayment]; + * and a finalized handle whose + * [broadcastTransaction][org.dashfoundation.dashsdk.wallet.ManagedCoreWallet.broadcastTransaction] + * aged past the same reservation bound (abandon still works at any age). * * Sibling of the other two deferred-token failures this code used to * conflate: [ReservationTokenConsumed] (unknown / already broadcast / diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt index dbed938ea3e..cb7fe5eb35d 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt @@ -27,14 +27,38 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { check(it != 0L) { "ManagedCoreWallet has been closed" } } - /** Consume and broadcast a finalized transaction. */ + /** + * Consume and broadcast a finalized transaction. A handle held past the + * reservation age bound throws the typed + * [StaleReservationToken][org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.StaleReservationToken] + * (native code 34, shared with the deferred-token surface) instead of + * broadcasting against inputs key-wallet's TTL may have re-selected. + * + * On that refusal the handle has **already been consumed** by this call and + * its funding reservation released owner-guarded (freed only while this + * build still owned it; a no-op once a TTL sweep or re-reservation + * transferred ownership), so a follow-up [abandonTransaction] is an + * invalid-handle error, not a recovery path — there is nothing left to + * release. Recover by rebuilding the transaction, which can reselect the + * freed inputs immediately. + */ fun broadcastTransaction(tx: FinalizedCoreTransaction): String = WalletManagerNative.coreWalletBroadcastSignedTransaction( handle, tx.takeForBroadcast(), ) - /** Consume without sending and release the selected inputs immediately. */ + /** + * Consume a finalized transaction without sending. With the build's owner + * token present (the normal funded-finalize case) the release is + * owner-guarded and safe at any age: it frees the selected inputs while + * this build still owns them — so a rebuild can reselect them immediately — + * and no-ops once key-wallet's TTL sweep or a re-reservation transferred + * ownership. Only a token-less handle honours the reservation age bound and + * skips its unguarded by-outpoint release past it (releasing by outpoint + * could free a newer build's reservation), leaving the aged reservation for + * the TTL to reclaim. The handle is torn down either way. + */ fun abandonTransaction(tx: FinalizedCoreTransaction) { WalletManagerNative.coreWalletAbandonSignedTransaction( handle, diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs index 8eacdf4f355..55de0604cf5 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -288,6 +288,26 @@ mod tests { runtime().block_on(core.abandon_transaction(&retry)); } + /// Prove the funding reservation was released owner-guarded: a fresh + /// finalize of the same size reselects the single fixture UTXO. An aged + /// abandon/free with the build's owner token present releases via + /// `release_reservation_if_owner` (safe at any age — no-op once ownership + /// transferred), so the input must be immediately reselectable. + fn assert_released_for_rebuild(core: &TestCore, signer: &WalletSigner, tag: u8) { + let rebuild = runtime().block_on(core.finalize_transaction( + TransactionBuilder::new().add_output( + &Address::dummy(Network::Testnet, usize::from(tag)), + 1_000_000, + ), + &[AccountTypePreference::BIP44], + 0, + signer, + )); + let rebuilt = rebuild + .expect("aged abandon/free must release the still-owned reservation for a rebuild"); + runtime().block_on(core.abandon_transaction(&rebuilt)); + } + #[test] fn double_free_is_safe_and_releases_reservation() { let (core, signer) = @@ -327,6 +347,98 @@ mod tests { CORE_WALLET_STORAGE.remove(other_handle); } + /// The deinit/GC backstop (`core_wallet_signed_transaction_free`) is the + /// exact path shumkov flagged: a `FinalizedCoreTransaction` never broadcast + /// or abandoned, freed by the host GC long after finalize. The funded + /// finalize stamped an owner token, so the aged free still releases — + /// owner-guarded via `release_reservation_if_owner`, which is safe at any + /// age (it no-ops once key-wallet's TTL swept and an unrelated build + /// re-reserved the outpoint) — freeing the still-owned input for a rebuild. + /// The handle is torn down (the storage entry is removed) so a re-free is a + /// safe no-op. + #[test] + fn aged_free_releases_owner_guarded() { + let (core, signer) = + runtime().block_on(funded_spv_core_wallet(StandardAccountType::BIP44Account)); + let transaction_handle = insert(&core, finalize(&core, &signer, 48)); + + // Age the pinned handle past the guard bound (still below the TTL, so the + // reservation is provably still held — only the software guard trips). + runtime().block_on(platform_wallet::test_support::age_core_past_reservation_guard(&core)); + + core_wallet_signed_transaction_free(transaction_handle); + + // The aged free released owner-guarded: the input is reselectable. + assert_released_for_rebuild(&core, &signer, 49); + // Handle is gone regardless — a re-free is a harmless no-op. + core_wallet_signed_transaction_free(transaction_handle); + } + + /// The FFI broadcast/abandon *failure* paths (invalid or wrong-generation + /// wallet handle) route their cleanup through `abandon_transaction`, so they + /// inherit the same policy: an aged handle with the build's owner token + /// still releases owner-guarded (safe at any age), so the failure-path + /// cleanup frees the still-owned input instead of stranding it. + #[test] + fn aged_failure_path_abandon_releases_owner_guarded() { + let (origin, signer) = + runtime().block_on(funded_spv_core_wallet(StandardAccountType::BIP44Account)); + let transaction_handle = insert(&origin, finalize(&origin, &signer, 50)); + + runtime().block_on(platform_wallet::test_support::age_core_past_reservation_guard(&origin)); + + // Invalid wallet handle → routes through abandon_transaction, then returns + // ErrorInvalidHandle. The embedded aged reservation is released + // owner-guarded on the way out. + let invalid = + unsafe { core_wallet_abandon_signed_transaction(u64::MAX, transaction_handle) }; + assert_eq!( + invalid.code, + PlatformWalletFFIResultCode::ErrorInvalidHandle + ); + assert_released_for_rebuild(&origin, &signer, 51); + } + + /// The terminal FFI stale-broadcast behavior: by the time the age guard + /// runs, `core_wallet_broadcast_signed_transaction` has already consumed + /// the opaque handle (and the host bindings cleared theirs before entering + /// the ABI), so no follow-up abandon is possible. The refusal must + /// therefore reconcile the reservation itself — owner-guarded, freeing the + /// still-owned input so the instructed immediate rebuild can reselect it — + /// and surface the shared `ErrorStaleReservationToken` (34) code with no + /// txid. A retry of the consumed handle is `NotFound`, not a resend. + #[test] + fn aged_broadcast_refuses_and_releases_for_rebuild() { + let (core, signer) = + runtime().block_on(funded_spv_core_wallet(StandardAccountType::BIP44Account)); + let core_handle = CORE_WALLET_STORAGE.insert(core.clone()); + let transaction_handle = insert(&core, finalize(&core, &signer, 52)); + + runtime().block_on(platform_wallet::test_support::age_core_past_reservation_guard(&core)); + + let mut txid = ptr::null_mut(); + let stale = unsafe { + core_wallet_broadcast_signed_transaction(core_handle, transaction_handle, &mut txid) + }; + assert_eq!( + stale.code, + PlatformWalletFFIResultCode::ErrorStaleReservationToken + ); + assert!(txid.is_null()); + + // The refusal released owner-guarded: the input is reselectable with no + // further cleanup call. + assert_released_for_rebuild(&core, &signer, 53); + + // The handle was consumed by the refused broadcast — a retry cannot + // reconsume it. + let retry = unsafe { + core_wallet_broadcast_signed_transaction(core_handle, transaction_handle, &mut txid) + }; + assert_eq!(retry.code, PlatformWalletFFIResultCode::NotFound); + CORE_WALLET_STORAGE.remove(core_handle); + } + #[test] fn abandon_then_free_or_broadcast_cannot_reconsume_handle() { let (core, signer) = diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index cb560d46f31..a36356671aa 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -283,6 +283,23 @@ pub enum PlatformWalletFFIResultCode { /// [`Self::ErrorReservationWalletMismatch`] (36, minted against a different /// wallet generation). All three are non-retryable-in-place and none touched /// the network; they are distinct codes so a host can message each precisely. + /// + /// Also maps `PlatformWalletError::StaleReservation` from the atomic + /// finalized-transaction handle path + /// (`core_wallet_broadcast_signed_transaction`): a pinned handle whose + /// funding reservation aged past the SAME `RESERVATION_MAX_AGE_BLOCKS` bound + /// carries the identical "may already have been swept — rebuild" meaning, so + /// the two surfaces intentionally share this one code. The handle carries + /// no numeric reservation token, hence a distinct (token-less) wallet-error + /// variant behind the same FFI code. The refusal reconciles the reservation + /// on the way out: a funded finalize always stamps an owner token, so the + /// release is owner-guarded (safe at any age — a no-op once ownership + /// transferred) and the still-owned inputs are freed for the instructed + /// rebuild. Abandon/free of a handle never surfaces this — abandon returns + /// no result code and likewise releases owner-guarded at any age; only a + /// token-less build skips its unguarded by-outpoint release past the bound + /// (leaving the aged outpoint to key-wallet's TTL, since releasing it + /// unguarded could free an unrelated newer build's reservation). ErrorStaleReservationToken = 34, /// Maps `SignedPaymentError::StaleToken`. The deferred reservation token is @@ -579,6 +596,14 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::TransactionBroadcast(..) => { PlatformWalletFFIResultCode::ErrorTransactionBroadcastRejected } + // The finalized-transaction handle path's age guard. Shares the + // `ErrorStaleReservationToken` code with the deferred registry-token + // sibling (`SignedPaymentError::StaleReservationToken`): both mean + // "the funding reservation may already have been swept — rebuild", + // and neither touched the network. See the code's doc note. + PlatformWalletError::StaleReservation => { + PlatformWalletFFIResultCode::ErrorStaleReservationToken + } // A definitively-failed address-nonce race (reaches the blanket impl // via identity `top_up_from_addresses` → `?`/`.into()`). Exposing // provided/expected nonce as structured out-fields is INTENTIONALLY @@ -1195,6 +1220,34 @@ mod tests { assert_eq!(msg, rendered, "Display payload must survive verbatim"); } + /// The finalized-transaction handle age guard + /// (`core_wallet_broadcast_signed_transaction` → `broadcast_finalized_transaction`) + /// surfaces `PlatformWalletError::StaleReservation` through the blanket + /// `From` impl, which must reuse the deferred registry-token path's + /// `ErrorStaleReservationToken` (34) code rather than flattening to + /// `ErrorUnknown` — the two surfaces share the "reservation may have been + /// swept; rebuild" meaning and this one code. The typed Display rendering + /// survives across the boundary as the message. + #[test] + fn stale_reservation_maps_to_shared_stale_reservation_code() { + let err = PlatformWalletError::StaleReservation; + let rendered = err.to_string(); + let result: PlatformWalletFFIResult = err.into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorStaleReservationToken, + "StaleReservation must reuse the registry-token stale code (rendered: {rendered})" + ); + assert!(!result.message.is_null()); + let msg = unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned(); + assert_eq!( + msg, rendered, + "Display payload must survive the FFI boundary verbatim" + ); + } + /// `AddressNonceMismatch` maps to the dedicated `ErrorAddressNonceMismatch` /// FFI code through the blanket `From` impl (the path identity /// `top_up_from_addresses` takes via `?`/`.into()`) rather than flattening diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 8dab7054699..79b857cbb54 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -118,6 +118,33 @@ pub enum PlatformWalletError { )] TransactionBroadcastUnconfirmed(String), + /// A finalized transaction handle + /// (`core_wallet_tx_builder_finalize` → `broadcast_finalized_transaction`) + /// was held long enough that its funding reservation may already have been + /// swept and re-selected by key-wallet's TTL: the wallet's + /// `last_processed_height` advanced at least + /// [`RESERVATION_MAX_AGE_BLOCKS`](crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS) + /// blocks past the height the reservation was stamped at + /// ([`SignedCoreTransaction::reservation_height`](crate::SignedCoreTransaction::reservation_height)). + /// Broadcasting it could spend against a newer, unrelated reservation, so it + /// is refused **before** touching the network — NOT retryable in place, the + /// caller must rebuild the payment. The refusal reconciles the reservation + /// on the way out: a funded finalize always stamps an owner token, so the + /// release is owner-guarded (`release_reservation_if_owner`, safe at any + /// age — it no-ops once ownership transferred) and the still-owned inputs + /// are freed for the instructed rebuild. Abandoning/freeing the handle + /// likewise releases owner-guarded at any age; only a token-less build + /// skips its unguarded by-outpoint release past the bound and leaves the + /// aged outpoint for key-wallet's TTL to reclaim. + /// + /// This is the handle-path sibling of the deferred registry-token + /// [`SignedPaymentError::StaleReservationToken`](crate::SignedPaymentError::StaleReservationToken); + /// both share the same age bound and the FFI `ErrorStaleReservationToken` + /// code. Carries no token — the handle path is keyed by an opaque handle, + /// not a numeric reservation token. + #[error("finalized transaction reservation has outlived its lifetime; rebuild the payment")] + StaleReservation, + #[error("Transaction building failed: {0}")] TransactionBuild(String), diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 31c7abdf446..559f8d0c9ea 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -532,6 +532,38 @@ pub async fn funded_spv_core_wallet( ) } +/// Advance `core`'s `last_processed_height` to just past the reservation age +/// guard bound ([`RESERVATION_MAX_AGE_BLOCKS`](crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS)) +/// but below key-wallet's `ReservationSet` TTL, so a handle finalized at the +/// current height ages enough to trip the software guard while its underlying +/// reservation is provably still held (no key-wallet sweep yet). Returns the new +/// height. +/// +/// FFI lifecycle tests use this to exercise aged owner-guarded cleanup — the +/// deinit/GC backstop and the broadcast/abandon failure paths that route their +/// cleanup through `abandon_transaction`, which releases owner-guarded at any +/// age (only a token-less build skips its by-outpoint release). +pub async fn age_core_past_reservation_guard(core: &crate::CoreWallet) -> u32 +where + B: crate::broadcaster::TransactionBroadcaster + ?Sized, +{ + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let stamped = core + .last_processed_height() + .await + .expect("wallet present in manager"); + let target = stamped + crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS + 2; + { + let mut wm = core.wallet_manager.write().await; + let (_, info) = wm + .get_wallet_and_info_mut(&core.wallet_id()) + .expect("wallet present in manager"); + info.core_wallet.update_last_processed_height(target); + } + target +} + /// No-op persister satisfying [`PlatformWalletManager`] construction for tests /// that need a full [`PlatformWallet`] but no real persistence pipeline. pub struct NoopTestPersister; diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 6c53c5dba57..c0270630312 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -4,7 +4,7 @@ use key_wallet::ReservationToken; use super::SignedCoreTransaction; use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; -use crate::wallet::reservations::broadcast_releasing_on_rejection; +use crate::wallet::reservations::{broadcast_releasing_on_rejection, reservation_expired}; use crate::{CoreWallet, PlatformWalletError}; impl CoreWallet { @@ -18,10 +18,47 @@ impl CoreWallet { /// same inputs under a new token. Releasing by outpoint alone would then /// free that other build's inputs (the `dashpay/platform#4185` double-spend /// window); presenting the token frees only inputs this build still owns. + /// + /// # Reservation age guard + /// + /// A finalized-transaction handle can be pinned by the host for an + /// arbitrary time between `finalize` and this broadcast. If the wallet's + /// `last_processed_height` advances at least + /// [`RESERVATION_MAX_AGE_BLOCKS`](crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS) + /// blocks past the height the funding reservation was stamped at + /// ([`SignedCoreTransaction::reservation_height`]), key-wallet's own + /// `ReservationSet` TTL could already have swept those inputs and let an + /// unrelated build re-select them. Broadcasting then would spend against a + /// newer, unrelated reservation, so the send is refused with + /// [`PlatformWalletError::StaleReservation`] **before** the broadcaster is + /// touched — mirroring the deferred registry token's + /// [`broadcast`](crate::SignedPaymentRegistry::broadcast) guard, off the + /// same bound and the same `last_processed_height` clock, and running after + /// the FFI layer's generation-identity check just as the registry does. + /// + /// The refusal also reconciles the reservation, exactly like the registry's + /// stale-token branch: the FFI wrapper has already consumed the opaque + /// handle by the time this runs (and the host bindings clear their local + /// handles before entering the ABI), so a follow-up + /// [`abandon_transaction`](Self::abandon_transaction) is unreachable from + /// the caller's side. Abandoning here releases owner-guarded + /// (`release_reservation_if_owner`), which is safe at ANY age — between the + /// guard bound and key-wallet's TTL the reservation is typically STILL this + /// build's, so the release is what lets the instructed immediate rebuild + /// reselect the inputs instead of stranding them until the TTL backstop. + /// Only a token-less build (never reached on the funded finalize path) + /// skips, leaving the aged reservation for the TTL to reclaim. pub async fn broadcast_finalized_transaction( &self, transaction: &SignedCoreTransaction, ) -> Result { + if reservation_expired( + transaction.reservation_height(), + self.last_processed_height().await, + ) { + self.abandon_transaction(transaction).await; + return Err(PlatformWalletError::StaleReservation); + } match self.broadcaster.broadcast(transaction.transaction()).await { Ok(txid) => Ok(txid), Err(error) => { @@ -158,14 +195,17 @@ mod tests { use key_wallet::signer::Signer; use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy; use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder; + use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use crate::broadcaster::TransactionBroadcaster; use crate::test_support::{ - funded_wallet_manager, AlwaysMaybeSentBroadcaster, RejectFirstBroadcaster, WalletSigner, + funded_wallet_manager, AlwaysMaybeSentBroadcaster, AlwaysOkBroadcaster, + RejectFirstBroadcaster, WalletSigner, }; use crate::wallet::core::CoreWallet; - use crate::PlatformWalletError; + use crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS; + use crate::{PlatformWalletError, SignedCoreTransaction}; /// Builds a testnet `CoreWallet` over the shared funded fixture and a /// 1_000_000-duff payment to a dummy recipient. @@ -247,6 +287,229 @@ mod tests { Ok(tx) } + /// Atomically fund + reserve + sign a `SignedCoreTransaction` the way the + /// finalized-handle path (`core_wallet_tx_builder_finalize`) does, capturing + /// the reservation's stamp height on the returned handle. + async fn finalize_tx( + core: &CoreWallet, + account_type: AccountTypePreference, + outputs: &[(DashAddress, u64)], + signer: &WalletSigner, + ) -> SignedCoreTransaction { + try_finalize_tx(core, account_type, outputs, signer) + .await + .expect("finalize should succeed") + } + + /// Like [`finalize_tx`] but surfaces the build error instead of panicking — + /// used to prove a *rebuild* fails when a still-held reservation keeps its + /// inputs out of the selectable pool. + async fn try_finalize_tx( + core: &CoreWallet, + account_type: AccountTypePreference, + outputs: &[(DashAddress, u64)], + signer: &WalletSigner, + ) -> Result { + let mut builder = TransactionBuilder::new(); + for (addr, amount) in outputs { + builder = builder.add_output(addr, *amount); + } + core.finalize_transaction(builder, &[account_type], 0, signer) + .await + } + + /// Force the wallet's `last_processed_height` forward, simulating chain + /// progress between `finalize` and a later broadcast of the pinned + /// handle — the window in which key-wallet's `ReservationSet` TTL can sweep + /// the funding reservation. Same clock the age guard reads. + async fn advance_processed_height( + core: &CoreWallet, + height: u32, + ) { + let mut wm = core.wallet_manager.write().await; + let (_, info) = wm + .get_wallet_and_info_mut(&core.wallet_id()) + .expect("wallet present in manager"); + info.core_wallet.update_last_processed_height(height); + } + + /// A freshly finalized handle — no chain progress since `finalize` — + /// broadcasts normally: the age guard does not trip. + #[tokio::test] + async fn fresh_finalized_handle_broadcasts() { + for account_type in [AccountTypePreference::BIP44, AccountTypePreference::BIP32] { + let (core, signer, outputs) = funded_core_wallet( + account_type_standard(account_type), + Arc::new(AlwaysOkBroadcaster), + ) + .await; + let finalized = finalize_tx(&core, account_type, &outputs, &signer).await; + let sent = core.broadcast_finalized_transaction(&finalized).await; + assert!( + sent.is_ok(), + "a fresh handle must broadcast for {account_type:?}, got {sent:?}" + ); + } + } + + /// A handle pinned while the wallet syncs past `RESERVATION_MAX_AGE_BLOCKS` + /// beyond its reservation stamp must be refused with `StaleReservation` + /// (never a send — the broadcaster is `AlwaysOk`, so a leaked send would + /// surface as `Ok`). The refusal itself reconciles the reservation, + /// OWNER-GUARDED — this is terminal at the FFI boundary, where the opaque + /// handle was consumed before the guard ran, so no follow-up abandon is + /// possible. Below key-wallet's TTL the reservation is still this build's, + /// `release_reservation_if_owner` frees it, and the instructed immediate + /// rebuild reselects the inputs with NO further cleanup call. A late + /// abandon of the stale original is then an owner-guarded no-op — ownership + /// has transferred to the rebuild, whose reservation must survive it. + #[tokio::test] + async fn aged_finalized_handle_refusal_releases_for_rebuild() { + for account_type in [AccountTypePreference::BIP44, AccountTypePreference::BIP32] { + let (core, signer, outputs) = funded_core_wallet( + account_type_standard(account_type), + Arc::new(AlwaysOkBroadcaster), + ) + .await; + let stamped = core + .last_processed_height() + .await + .expect("last processed height"); + let finalized = finalize_tx(&core, account_type, &outputs, &signer).await; + + // Advance past the guard bound (stay below key-wallet's 24-block TTL, + // so the reservation is provably still held — only our guard tripped). + advance_processed_height(&core, stamped + RESERVATION_MAX_AGE_BLOCKS + 2).await; + + let sent = core.broadcast_finalized_transaction(&finalized).await; + assert!( + matches!(sent, Err(PlatformWalletError::StaleReservation)), + "an aged handle must refuse with StaleReservation for \ + {account_type:?}, got {sent:?}" + ); + + // The refusal released the still-owned reservation: an immediate + // rebuild reselects the single fixture UTXO without any abandon. + let rebuilt = try_finalize_tx(&core, account_type, &outputs, &signer).await; + let rebuilt = rebuilt.unwrap_or_else(|error| { + panic!( + "the stale refusal must release the still-owned reservation \ + so a rebuild succeeds for {account_type:?}, got {error:?}" + ) + }); + + // A late abandon of the stale original must be an owner-guarded + // no-op: ownership transferred to the rebuild, so the rebuild's + // reservation still holds the fixture's only UTXO and a competing + // finalize must fail. + core.abandon_transaction(&finalized).await; + let competing = try_finalize_tx(&core, account_type, &outputs, &signer).await; + assert!( + competing.is_err(), + "abandoning the consumed stale handle must not free the \ + rebuild's reservation for {account_type:?}, got a successful \ + competing finalize" + ); + core.abandon_transaction(&rebuilt).await; + } + } + + /// Below the guard bound the reservation is provably still ours (no sweep + /// possible yet), so abandon/free release it — owner-guarded, via the token + /// the funded finalize stamped — returning the inputs so an immediate + /// rebuild reselects them. + #[tokio::test] + async fn below_bound_finalized_handle_abandon_releases() { + for account_type in [AccountTypePreference::BIP44, AccountTypePreference::BIP32] { + let (core, signer, outputs) = funded_core_wallet( + account_type_standard(account_type), + Arc::new(AlwaysOkBroadcaster), + ) + .await; + let stamped = core + .last_processed_height() + .await + .expect("last processed height"); + let finalized = finalize_tx(&core, account_type, &outputs, &signer).await; + + // Aged, but one shy of the guard bound: still below both the guard and + // the TTL, so the reservation is unambiguously ours to release. + advance_processed_height(&core, stamped + RESERVATION_MAX_AGE_BLOCKS - 1).await; + + core.abandon_transaction(&finalized).await; + + // The release freed the input: an immediate rebuild reselects it. + let rebuilt = try_finalize_tx(&core, account_type, &outputs, &signer).await; + assert!( + rebuilt.is_ok(), + "below-bound abandon must release the input so a rebuild reselects \ + it for {account_type:?}, got {rebuilt:?}" + ); + core.abandon_transaction(&rebuilt.expect("rebuild")).await; + } + } + + /// The guard boundary is exact: `current - stamped >= RESERVATION_MAX_AGE_BLOCKS` + /// refuses, one block below still broadcasts — for both standard account + /// types, like the fresh/aged tests. + #[tokio::test] + async fn finalized_handle_age_guard_boundary_is_exact() { + for account_type in [AccountTypePreference::BIP44, AccountTypePreference::BIP32] { + // One below the bound: still fresh enough to broadcast. + let (below_core, below_signer, below_outputs) = funded_core_wallet( + account_type_standard(account_type), + Arc::new(AlwaysOkBroadcaster), + ) + .await; + let below_stamped = below_core + .last_processed_height() + .await + .expect("last processed height"); + let below = finalize_tx(&below_core, account_type, &below_outputs, &below_signer).await; + advance_processed_height(&below_core, below_stamped + RESERVATION_MAX_AGE_BLOCKS - 1) + .await; + assert!( + below_core + .broadcast_finalized_transaction(&below) + .await + .is_ok(), + "one block below the bound must still broadcast ({account_type:?})" + ); + + // Exactly at the bound: refused. + let (at_core, at_signer, at_outputs) = funded_core_wallet( + account_type_standard(account_type), + Arc::new(AlwaysOkBroadcaster), + ) + .await; + let at_stamped = at_core + .last_processed_height() + .await + .expect("last processed height"); + let at = finalize_tx(&at_core, account_type, &at_outputs, &at_signer).await; + advance_processed_height(&at_core, at_stamped + RESERVATION_MAX_AGE_BLOCKS).await; + assert!( + matches!( + at_core.broadcast_finalized_transaction(&at).await, + Err(PlatformWalletError::StaleReservation) + ), + "exactly at the bound must refuse with StaleReservation ({account_type:?})" + ); + } + } + + /// Map a builder `AccountTypePreference` (BIP44/BIP32 only in these tests) + /// to the `StandardAccountType` the funded fixture is keyed by. + fn account_type_standard(account_type: AccountTypePreference) -> StandardAccountType { + match account_type { + AccountTypePreference::BIP44 => StandardAccountType::BIP44Account, + AccountTypePreference::BIP32 => StandardAccountType::BIP32Account, + other => { + unreachable!("only standard-account funding is exercised by these tests: {other:?}") + } + } + } + /// A pre-send broadcast rejection must release the UTXO reservation taken /// while building the transaction, so an immediate retry can reselect those /// inputs instead of failing with spurious insufficient funds until the TTL diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 2987d01518b..17e3db6862b 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -21,6 +21,7 @@ use key_wallet::{DerivationPath, ReservationToken, Utxo}; use super::{CoreWallet, WalletGeneration}; use crate::broadcaster::TransactionBroadcaster; +use crate::wallet::reservations::reservation_expired; use crate::PlatformWalletError; /// What funded (or failed to fund) a build, for attributing a shortfall. @@ -523,7 +524,45 @@ impl CoreWallet { } /// Release a finalized transaction that the caller has chosen not to send. + /// + /// # Reservation age guard + /// + /// This is the abandon/free arm of the finalized-transaction handle — + /// including the FFI broadcast/abandon *failure* paths (invalid or + /// wrong-generation wallet handle) that route their cleanup here, and the + /// host-language deinit/GC backstop + /// (`core_wallet_signed_transaction_free`). A pinned handle can reach it + /// long after `finalize`, so it honors the **same** age bound as + /// [`broadcast_finalized_transaction`](Self::broadcast_finalized_transaction), + /// off the same shared [`reservation_expired`] predicate and the same + /// `last_processed_height` clock. + /// + /// With the build's owner token present the release is owner-guarded + /// (`release_reservation_if_owner`), which is safe at ANY age: it frees the + /// inputs only while this build still owns them and no-ops once key-wallet's + /// TTL sweep or a re-reservation transferred ownership. Between + /// [`RESERVATION_MAX_AGE_BLOCKS`](crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS) + /// and the TTL the reservation is typically STILL this build's, so an aged + /// abandon must still release — skipping would strand the inputs for + /// several more blocks while the host has already discarded the payment. + /// Only a token-less build (never reached on the funded finalize path) + /// honours the age bound and skips: its only release primitive is the + /// unguarded by-outpoint form, which after a sweep could free a newer + /// build's reservation. This mirrors the deferred registry's + /// `reconcile_removed_entry` policy exactly. pub async fn abandon_transaction(&self, transaction: &SignedCoreTransaction) { + if transaction.reservation_token.is_none() + && reservation_expired( + transaction.reservation_height, + self.last_processed_height().await, + ) + { + // Aged, and no owner token to guard the release: the outpoint may + // have been swept and re-reserved by an unrelated build. Leave it + // for key-wallet's TTL; releasing by outpoint could free that newer + // reservation. + return; + } self.release_transaction_reservation( &transaction.funding_accounts, &transaction.transaction, diff --git a/packages/rs-platform-wallet/src/wallet/reservations.rs b/packages/rs-platform-wallet/src/wallet/reservations.rs index 365d6514229..f213be8e362 100644 --- a/packages/rs-platform-wallet/src/wallet/reservations.rs +++ b/packages/rs-platform-wallet/src/wallet/reservations.rs @@ -22,6 +22,78 @@ use tokio::sync::RwLock; use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; +/// Maximum age, in `last_processed_height` blocks, of a held funding +/// reservation before an operation that would *consume* it (broadcast) is +/// refused. Shared by the two deferred/split core-send surfaces so they bound a +/// reservation's lifetime against the same TTL with one number: +/// +/// * the deferred build → broadcast/release registry +/// ([`SignedPaymentRegistry`](crate::SignedPaymentRegistry)), and +/// * the atomic finalized-transaction handle path +/// (`core_wallet_tx_builder_finalize` → +/// `broadcast_finalized_transaction`). +/// +/// Kept strictly below key-wallet's `RESERVATION_TTL_BLOCKS` (24, ~1h at the +/// mainnet block target): a `build_signed` / `finalize_transaction` reservation +/// is stamped at the wallet's `last_processed_height` (via `set_current_height`) +/// and swept by a later `reserve`/`reserved` call — itself stamped with the same +/// `last_processed_height` clock — once it is `RESERVATION_TTL_BLOCKS` old, +/// silently returning the outpoint to the selectable pool where an unrelated +/// build can re-select and re-reserve it. `ReservationSet::release` removes an +/// outpoint unconditionally, with no ownership/generation check, so acting on a +/// reservation that was already swept could free (or broadcast against) a newer, +/// unrelated one. Refusing at this lower bound guarantees the guard always trips +/// **before** the underlying reservation could have been swept, leaving a margin +/// for `last_processed_height` to lag a few blocks behind the true tip. +pub(crate) const RESERVATION_MAX_AGE_BLOCKS: u32 = 20; + +/// Whether a reservation stamped at `registered_height` is too old to act on at +/// `current_height` (see [`RESERVATION_MAX_AGE_BLOCKS`]). The registration +/// height is mandatory on both surfaces — it is derived from the finalized +/// [`SignedCoreTransaction::reservation_height`](crate::SignedCoreTransaction) +/// (captured inside the funding critical section, before the potentially-slow +/// external signer ran), never sampled independently. +/// +/// *Consuming* (broadcasting) a stale reservation is refused: once the outpoint +/// may already have been swept by key-wallet's TTL and re-reserved by an +/// unrelated build, broadcasting would spend against that newer reservation. +/// The guarded broadcasts +/// ([`broadcast_finalized_transaction`](crate::CoreWallet::broadcast_finalized_transaction) +/// and the registry's [`broadcast`](crate::SignedPaymentRegistry::broadcast)) +/// refuse with their stale-reservation errors, reconciling the reservation on +/// the way out. Cleanup (abandon/free, and that refusal-path reconciliation) +/// distinguishes two cases by the build's owner token: +/// +/// * **Owner token present** (every funded finalize): the release is +/// owner-guarded (`release_reservation_if_owner`) and therefore safe at ANY +/// age — it frees the inputs only while this build still owns them and no-ops +/// once a TTL sweep or re-reservation transferred ownership — so aged cleanup +/// still releases, letting an immediate rebuild reselect the inputs. +/// * **Token-less** (a build that reserved nothing): the only release primitive +/// is `ReservationSet::release`, which removes an outpoint unconditionally +/// with no ownership check, so past the bound the by-outpoint release is +/// skipped and the aged reservation is left for key-wallet's TTL to reclaim. +/// +/// An unknown *current* height means the wallet is gone from the manager, which +/// disables the guard (`None` → not expired). That is safe only because every +/// caller establishes liveness first and so never reaches here with a removed +/// wallet: the registry's +/// [`broadcast`](crate::SignedPaymentRegistry::broadcast) refuses with +/// `SignedPaymentError::WalletRemoved` before sampling the height, its +/// `reconcile_removed_entry` release is itself generation-bound and no-ops on a +/// missing wallet, and the finalized-transaction handle path runs after the +/// FFI layer's generation-identity check. The earlier claim that "the +/// wallet-mismatch / account-lookup paths already reject those cases" was wrong +/// for the registry broadcast path — `is_same_generation` compares handles (a +/// removed generation matches itself) and that path performs no account lookup +/// at all (`dashpay/platform#4185`). +pub(crate) fn reservation_expired(registered_height: u32, current_height: Option) -> bool { + match current_height { + Some(current) => current.saturating_sub(registered_height) >= RESERVATION_MAX_AGE_BLOCKS, + None => false, + } +} + /// Broadcast `tx` and reconcile the funding account's UTXO reservation on /// failure. /// diff --git a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs index a7a29620782..d482d74e695 100644 --- a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -41,7 +41,8 @@ //! recreation needs the manager write lock, so it cannot slip between that //! check and the release; a stale token can therefore never free a re-created //! generation's reservation. -//! * A token has a bounded lifetime ([`RESERVATION_MAX_AGE_BLOCKS`]). Once the +//! * A token has a bounded lifetime +//! ([`RESERVATION_MAX_AGE_BLOCKS`](crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS)). Once the //! wallet's `last_processed_height` has advanced far enough past the height at //! which `build_signed` / `finalize_transaction` stamped the reservation that //! key-wallet's own `ReservationSet` TTL could have swept and re-selected the @@ -79,6 +80,10 @@ use key_wallet::ReservationToken as FundingReservationToken; use crate::broadcaster::TransactionBroadcaster; use crate::wallet::core::{CoreWallet, SignedCoreTransaction}; +// The age bound and its predicate are shared with the atomic finalized- +// transaction handle path (`broadcast_finalized_transaction`), so both surfaces +// measure a reservation's lifetime against key-wallet's TTL with one number. +use crate::wallet::reservations::reservation_expired; use crate::PlatformWalletError; /// Opaque handle to a registered, signed-but-unsent payment. Minted by @@ -121,48 +126,6 @@ impl std::fmt::Display for ReservationToken { } } -/// Maximum age, in `last_processed_height` blocks, of a registered token before -/// its broadcast or release is refused. -/// -/// Kept strictly below key-wallet's `RESERVATION_TTL_BLOCKS` (24, ~1h at the -/// mainnet block target): a `build_signed` / `finalize_transaction` reservation -/// is stamped at the wallet's `last_processed_height` (via `set_current_height`) -/// and swept by a later `reserve`/`reserved` call — itself stamped with the same -/// `last_processed_height` clock — once it is `RESERVATION_TTL_BLOCKS` old, -/// silently returning the outpoint to the selectable pool where an unrelated -/// build can re-select and re-reserve it. -/// `ReservationSet::release` removes an outpoint unconditionally, with no -/// ownership/generation check, so acting on a token whose reservation was -/// already swept could free (or broadcast against) a newer, unrelated -/// reservation. Refusing at this lower bound guarantees the guard always trips -/// **before** the underlying reservation could have been swept, leaving a margin -/// for `last_processed_height` to lag a few blocks behind the true tip. -const RESERVATION_MAX_AGE_BLOCKS: u32 = 20; - -/// Whether a token stamped at `registered_height` is too old to act on at -/// `current_height` (see [`RESERVATION_MAX_AGE_BLOCKS`]). The registration -/// height is mandatory — it is derived from the finalized -/// [`SignedCoreTransaction::reservation_height`](crate::SignedCoreTransaction) -/// the registry consumed. -/// -/// An unknown *current* height means the wallet is gone from the manager, which -/// disables the guard (`None` → not expired). That is safe only because every -/// caller establishes liveness first and so never reaches here with a removed -/// wallet: [`broadcast`](SignedPaymentRegistry::broadcast) refuses with -/// [`SignedPaymentError::WalletRemoved`] before sampling the height, and -/// [`reconcile_removed_entry`](SignedPaymentRegistry::reconcile_removed_entry)'s -/// release is itself generation-bound and no-ops on a missing wallet. The -/// earlier claim that "the wallet-mismatch / account-lookup paths already reject -/// those cases" was wrong for the broadcast path — `is_same_generation` compares -/// handles (a removed generation matches itself) and the broadcast path performs -/// no account lookup at all (`dashpay/platform#4185`). -fn reservation_expired(registered_height: u32, current_height: Option) -> bool { - match current_height { - Some(current) => current.saturating_sub(registered_height) >= RESERVATION_MAX_AGE_BLOCKS, - None => false, - } -} - /// Failure of a deferred broadcast/release token operation. #[derive(Debug, thiserror::Error)] pub enum SignedPaymentError { @@ -196,11 +159,15 @@ pub enum SignedPaymentError { #[error("reservation token {0} belongs to a wallet that is no longer in the manager")] WalletRemoved(ReservationToken), - /// The token has outlived [`RESERVATION_MAX_AGE_BLOCKS`], so its underlying + /// The token has outlived + /// [`RESERVATION_MAX_AGE_BLOCKS`](crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS), so its underlying /// UTXO reservation may already have been swept by key-wallet's TTL and - /// re-selected by an unrelated build. Acting on it (broadcast or release) - /// could touch a newer reservation, so it is refused and the caller must - /// rebuild the payment. + /// re-selected by an unrelated build. The *broadcast* is refused and the + /// caller must rebuild the payment — but the reservation itself is + /// reconciled on the way out: with the build's owner token present the + /// release is owner-guarded and safe at any age (it no-ops once ownership + /// transferred), freeing still-owned inputs for the rebuild. Only a + /// token-less entry is dropped without releasing. #[error("reservation token {0} has outlived its reservation lifetime; rebuild the payment")] StaleReservationToken(ReservationToken), @@ -260,8 +227,10 @@ struct RegisteredPayment { /// reservation with (`SignedCoreTransaction::reservation_height`). Compared /// against the wallet's current `last_processed_height` to refuse a /// broadcast/release once the reservation could plausibly have been swept by - /// key-wallet's TTL (see [`RESERVATION_MAX_AGE_BLOCKS`]). Mandatory: it is - /// derived from the consumed ownership object, never sampled independently. + /// key-wallet's TTL (see + /// [`RESERVATION_MAX_AGE_BLOCKS`](crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS)). + /// Mandatory: it is derived from the consumed ownership object, never + /// sampled independently. registered_height: u32, /// The key-wallet [`FundingReservationToken`] stamped onto the funding /// inputs when `finalize_transaction` reserved them @@ -667,13 +636,13 @@ mod tests { use super::{ RegisterWrongGeneration, ReservationToken, SignedPaymentError, SignedPaymentRegistry, - RESERVATION_MAX_AGE_BLOCKS, }; use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; use crate::test_support::{ funded_wallet_manager, AlwaysMaybeSentBroadcaster, AlwaysRejectedBroadcaster, WalletSigner, }; use crate::wallet::core::{CoreWallet, SignedCoreTransaction}; + use crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS; use crate::PlatformWalletError; /// The [`AccountTypePreference`] a `build_signed_tx` funding account maps to From 0f2cb03b3d8d34b6cb989f0b6d8cd907961328ee Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:53:04 -0400 Subject: [PATCH 02/15] docs(platform-wallet): de-link the pub(crate) reservation bound from a public doc Co-Authored-By: Claude Fable 5 --- packages/rs-platform-wallet/src/error.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 79b857cbb54..8f3780a58b5 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -123,7 +123,7 @@ pub enum PlatformWalletError { /// was held long enough that its funding reservation may already have been /// swept and re-selected by key-wallet's TTL: the wallet's /// `last_processed_height` advanced at least - /// [`RESERVATION_MAX_AGE_BLOCKS`](crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS) + /// `RESERVATION_MAX_AGE_BLOCKS` /// blocks past the height the reservation was stamped at /// ([`SignedCoreTransaction::reservation_height`](crate::SignedCoreTransaction::reservation_height)). /// Broadcasting it could spend against a newer, unrelated reservation, so it From ffc05fc44609fe5af2c5945fc11f51cfe4d6fa77 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:41:59 -0400 Subject: [PATCH 03/15] fix(kotlin-sdk): map the native stale-broadcast error on the public broadcastTransaction The method documents DashSdkError.PlatformWallet.StaleReservationToken but called the JNI native directly, so direct callers received the internal DashSDKException instead of the documented public type. Co-Authored-By: Claude Fable 5 --- .../org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt index cb7fe5eb35d..3e67b47c847 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt @@ -42,11 +42,12 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { * release. Recover by rebuilding the transaction, which can reselect the * freed inputs immediately. */ - fun broadcastTransaction(tx: FinalizedCoreTransaction): String = + fun broadcastTransaction(tx: FinalizedCoreTransaction): String = mapNativeErrors { WalletManagerNative.coreWalletBroadcastSignedTransaction( handle, tx.takeForBroadcast(), ) + } /** * Consume a finalized transaction without sending. With the build's owner From e4e678448633c9b5b3b617086dca9b43b9c87f04 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:54:10 -0400 Subject: [PATCH 04/15] fix(platform-wallet): validate reservation age atomically with dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pre-checked age is not an ordering invariant: between the check and the broadcaster await, sync catch-up can advance last_processed_height past the bound and a concurrent finalization can trigger key-wallet's TTL sweep, re-reserving the same inputs under a new token — the old signed transaction then hits the wire against reassigned UTXOs. New shared primitive dispatch_unexpired performs the age check and reaches the broadcaster under ONE wallet-manager READ guard. Both writers this orders against — the ReservationSet TTL sweep (inside coin selection) and height advancement — mutate under the manager WRITE lock, so 'the reservation is unexpired' and 'dispatch has begun' become a single atomic observation. Ownership needs no separate probe: the key-wallet TTL exceeds RESERVATION_MAX_AGE_BLOCKS on the same clock, so an unexpired reservation cannot already have been swept. Both check-then-dispatch sites now route through it: the finalized- handle broadcast and the registry-token broadcast (whose composite gains the reservation height and returns the stale verdict for the registry's existing owner-guarded reconciliation). Reconciliation runs OUTSIDE the guard — those paths retake manager locks. Deliberate cost: writers queue behind the network await, bounded by the broadcaster's own timeout — the price of the invariant without a key-wallet-side in-broadcast pin. Co-Authored-By: Claude Fable 5 --- .../src/wallet/core/broadcast.rs | 145 ++++++++++++++++-- .../src/wallet/signed_payment_registry.rs | 51 +++--- 2 files changed, 160 insertions(+), 36 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index c0270630312..e9b5053020c 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -1,5 +1,6 @@ use dashcore::Transaction; use key_wallet::account::account_type::StandardAccountType; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::ReservationToken; use super::SignedCoreTransaction; @@ -7,7 +8,57 @@ use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; use crate::wallet::reservations::{broadcast_releasing_on_rejection, reservation_expired}; use crate::{CoreWallet, PlatformWalletError}; +/// Outcome of [`CoreWallet::dispatch_unexpired`] — the guarded +/// age-check-and-send. `Stale` means the broadcaster was never touched. +pub(crate) enum GuardedDispatch { + /// The reservation aged past the bound; nothing was sent. + Stale, + /// The broadcaster was reached; its verbatim outcome. + Sent(Result), +} + impl CoreWallet { + /// Age-check and dispatch as ONE observation under the wallet-manager + /// READ lock. + /// + /// The two writers this orders against are key-wallet's + /// `ReservationSet` TTL sweep — it runs inside coin selection, which + /// mutates wallet state under the manager WRITE lock — and + /// `last_processed_height` advancement (same lock). Held across the + /// check AND the broadcaster await, the read guard makes "the + /// reservation is unexpired" and "dispatch has begun" a single atomic + /// observation: neither a sweep nor a height advance can interleave + /// between them. Ownership needs no separate probe: key-wallet's TTL + /// exceeds `RESERVATION_MAX_AGE_BLOCKS` on the same clock, so a + /// reservation that passes the age check under this guard cannot + /// already have been swept, and self-releases only run on this + /// transaction's own rejection/abandon paths, which are sequenced + /// after this call returns. + /// + /// Deliberate cost: writers (height catch-up, finalization) queue + /// behind the network await, bounded by the broadcaster's own timeout. + /// That is the price of the ordering invariant without a + /// key-wallet-side in-broadcast pin. + /// + /// Callers do their stale/rejection reconciliation AFTER this returns: + /// those paths retake manager locks and must not run under this guard + /// (tokio's write-preferring `RwLock` would deadlock a re-entrant read + /// behind a queued writer). + pub(crate) async fn dispatch_unexpired( + &self, + reservation_height: u32, + transaction: &Transaction, + ) -> GuardedDispatch { + let wm = self.wallet_manager.read().await; + let height = wm + .get_wallet_and_info(&self.wallet_id) + .map(|(_, info)| info.core_wallet.last_processed_height()); + if reservation_expired(reservation_height, height) { + return GuardedDispatch::Stale; + } + GuardedDispatch::Sent(self.broadcaster.broadcast(transaction).await) + } + /// Broadcast an atomically finalized transaction. A definitive rejection /// releases its reservation; an ambiguous `MaybeSent` outcome retains it. /// @@ -52,16 +103,23 @@ impl CoreWallet { &self, transaction: &SignedCoreTransaction, ) -> Result { - if reservation_expired( - transaction.reservation_height(), - self.last_processed_height().await, - ) { - self.abandon_transaction(transaction).await; - return Err(PlatformWalletError::StaleReservation); - } - match self.broadcaster.broadcast(transaction.transaction()).await { - Ok(txid) => Ok(txid), - Err(error) => { + // Check and dispatch are one guarded observation — see + // [`Self::dispatch_unexpired`]. A plain check-then-send here let + // sync catch-up age the reservation and a concurrent finalization + // sweep + re-reserve the same inputs between the two steps, so the + // old signed transaction could hit the wire against reassigned + // UTXOs. Reconciliation stays OUTSIDE the guard (it retakes + // manager locks). + match self + .dispatch_unexpired(transaction.reservation_height(), transaction.transaction()) + .await + { + GuardedDispatch::Stale => { + self.abandon_transaction(transaction).await; + Err(PlatformWalletError::StaleReservation) + } + GuardedDispatch::Sent(Ok(txid)) => Ok(txid), + GuardedDispatch::Sent(Err(error)) => { if matches!(error, crate::broadcaster::BroadcastError::Rejected { .. }) { self.release_transaction_reservation( transaction.funding_accounts(), @@ -166,15 +224,28 @@ impl CoreWallet { /// build stamped across all of them /// (`SignedCoreTransaction::reservation_token`), `None` only when the build /// reserved nothing. + /// `reservation_height` is the height the funding reservation was + /// stamped at; the age bound is re-checked ATOMICALLY with dispatch + /// under the manager read guard ([`Self::dispatch_unexpired`]) — a + /// pre-checked age is not an invariant, because catch-up can advance + /// the clock and a concurrent finalization can sweep + re-reserve the + /// inputs between a caller's check and the send. On the stale outcome + /// nothing was sent and NOTHING is released here: the caller owns the + /// reconciliation policy (the registry reconciles owner-guarded). pub(crate) async fn broadcast_payment_releasing_reservation( &self, accounts: &[key_wallet::account::AccountType], transaction: &Transaction, token: Option, + reservation_height: u32, ) -> Result { - match self.broadcaster.broadcast(transaction).await { - Ok(txid) => Ok(txid), - Err(error) => { + match self + .dispatch_unexpired(reservation_height, transaction) + .await + { + GuardedDispatch::Stale => Err(PlatformWalletError::StaleReservation), + GuardedDispatch::Sent(Ok(txid)) => Ok(txid), + GuardedDispatch::Sent(Err(error)) => { if matches!(error, BroadcastError::Rejected { .. }) { self.release_transaction_reservation(accounts, transaction, token) .await; @@ -189,6 +260,7 @@ impl CoreWallet { mod tests { use std::sync::Arc; + use super::GuardedDispatch; use dashcore::{Address as DashAddress, Network, Transaction}; use key_wallet::account::account_type::StandardAccountType; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; @@ -414,6 +486,53 @@ mod tests { } } + /// The age bound is validated ATOMICALLY with dispatch, not merely + /// before it: [`CoreWallet::dispatch_unexpired`] samples the height and + /// reaches the broadcaster under ONE wallet-manager read guard, so a + /// height advance (a manager WRITE) can never interleave between a + /// passed check and the send. The single-threaded proof of that + /// ordering: the same handle's inputs dispatch while fresh, and the + /// identical call refuses — broadcaster untouched — once catch-up + /// advances the clock past the bound. The verdict is derived from the + /// state observed INSIDE the guard at each dispatch, never from a + /// caller-side pre-check that could go stale in the gap. + #[tokio::test] + async fn guarded_dispatch_rechecks_age_inside_the_guard() { + let (core, signer, outputs) = funded_core_wallet( + account_type_standard(AccountTypePreference::BIP44), + Arc::new(AlwaysOkBroadcaster), + ) + .await; + let stamped = core + .last_processed_height() + .await + .expect("last processed height"); + let finalized = finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + + // Fresh: the guarded dispatch reaches the broadcaster. + let fresh = core + .dispatch_unexpired(finalized.reservation_height(), finalized.transaction()) + .await; + assert!( + matches!(fresh, GuardedDispatch::Sent(Ok(_))), + "a fresh reservation must dispatch under the guard" + ); + + // Catch-up advances the clock past the bound; the identical call now + // refuses inside the guard with the broadcaster never touched + // (`AlwaysOk` would have surfaced a leaked send as `Sent(Ok)`). + advance_processed_height(&core, stamped + RESERVATION_MAX_AGE_BLOCKS + 2).await; + let stale = core + .dispatch_unexpired(finalized.reservation_height(), finalized.transaction()) + .await; + assert!( + matches!(stale, GuardedDispatch::Stale), + "an aged reservation must refuse inside the guard, not dispatch" + ); + + core.abandon_transaction(&finalized).await; + } + /// Below the guard bound the reservation is provably still ours (no sweep /// possible yet), so abandon/free release it — owner-guarded, via the token /// the funded finalize stamped — returning the inputs so an immediate diff --git a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs index d482d74e695..8b9bc4b8fe4 100644 --- a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -467,39 +467,44 @@ impl SignedPaymentRegistry { return Err(SignedPaymentError::WalletRemoved(token)); } - // Refuse to SEND a token whose reservation could already have been - // swept and re-selected by an unrelated build — but reconcile its - // reservation first. With the build's owner token present the release - // is safe at ANY age: `release_reservation_if_owner` frees the inputs - // only while this build still owns them and no-ops after a TTL sweep - // or re-reservation transferred ownership. Between the guard bound - // (RESERVATION_MAX_AGE_BLOCKS) and key-wallet's TTL the reservation is - // typically STILL HELD, so dropping without releasing would strand the - // inputs for several more blocks while telling the caller to rebuild — - // and the rebuild would fail selection. Only a token-less entry falls - // back to the drop-without-release policy (an unguarded by-outpoint - // release could free a newer build's reservation). - if reservation_expired( - entry.registered_height, - current.last_processed_height().await, - ) { - Self::reconcile_removed_entry(entry).await; - return Err(SignedPaymentError::StaleReservationToken(token)); - } - // One releasing-broadcast path for every funding variant, CoinJoin // included: a definitive rejection releases the reservation for an // immediate rebuild, an ambiguous outcome keeps it, and the release is // bound to the token's own wallet generation. - let txid = entry + // + // The age bound is NOT pre-checked here: it is re-validated + // atomically with dispatch, under the wallet-manager read guard + // inside `broadcast_payment_releasing_reservation` — a check made + // out here is stale by the time the send begins (catch-up can + // advance the clock, and a concurrent finalization can sweep + + // re-reserve the inputs in the gap). On the stale outcome the + // broadcaster was never touched and the entry is reconciled below + // exactly as the old pre-check did: with the build's owner token + // present the release is safe at ANY age + // (`release_reservation_if_owner` no-ops once ownership was + // transferred); between the guard bound and key-wallet's TTL the + // reservation is typically STILL HELD, so releasing is what lets + // the instructed immediate rebuild reselect the inputs. Only a + // token-less entry falls back to drop-without-release (an + // unguarded by-outpoint release could free a newer build's + // reservation). + match entry .core .broadcast_payment_releasing_reservation( &entry.funding_accounts, &entry.tx, entry.funding_reservation_token, + entry.registered_height, ) - .await?; - Ok(txid) + .await + { + Ok(txid) => Ok(txid), + Err(PlatformWalletError::StaleReservation) => { + Self::reconcile_removed_entry(entry).await; + Err(SignedPaymentError::StaleReservationToken(token)) + } + Err(error) => Err(error.into()), + } } /// Reconcile one already-removed entry's reservation, bound to the token's From 9b033cb07dabd78be9a244cc0e054fb75924cbc7 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:52:38 -0400 Subject: [PATCH 05/15] fix(platform-wallet): drop the manager guard before the broadcast await MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Held across the broadcaster await, the read guard starved the very pipeline the await depends on: the production SpvBroadcaster waits on dash-spv's mempool manager, whose local-transaction handler takes wallet.write() on this same manager lock before it can process the echo/IS-lock/confirmation events that complete the wait. Every dispatch therefore rode the full 30s acceptance timeout to an ambiguous MaybeSent — reservation kept while the transaction was actually on-chain, rebuild selection left with no spendable UTXOs — and tokio's write-preferring queue stalled the whole manager for the window. The mock broadcasters in the test suite never touch the wallet lock, which is why no test caught it. The age check stays at dispatch time under the read guard; the guard now drops before the await (the same lock-free shape as broadcast_releasing_on_rejection). The residual check-to-wire gap is covered by key-wallet's TTL margin — the same margin that already covers the propagation phase, which the guard never spanned — and releasing early is strictly stronger afterwards: the mempool pipeline marks the inputs spent in the wallet's own view within milliseconds instead of after the timeout. All atomicity claims in docs, comments, and the test narrative are rewritten to the actual contract. Co-Authored-By: Claude Fable 5 --- .../src/wallet/core/broadcast.rs | 107 ++++++++++-------- .../src/wallet/signed_payment_registry.rs | 13 ++- 2 files changed, 66 insertions(+), 54 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index e9b5053020c..c29df5cf267 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -18,43 +18,51 @@ pub(crate) enum GuardedDispatch { } impl CoreWallet { - /// Age-check and dispatch as ONE observation under the wallet-manager - /// READ lock. + /// Age-check under the wallet-manager READ lock, then dispatch + /// immediately after releasing it. /// - /// The two writers this orders against are key-wallet's - /// `ReservationSet` TTL sweep — it runs inside coin selection, which - /// mutates wallet state under the manager WRITE lock — and - /// `last_processed_height` advancement (same lock). Held across the - /// check AND the broadcaster await, the read guard makes "the - /// reservation is unexpired" and "dispatch has begun" a single atomic - /// observation: neither a sweep nor a height advance can interleave - /// between them. Ownership needs no separate probe: key-wallet's TTL - /// exceeds `RESERVATION_MAX_AGE_BLOCKS` on the same clock, so a - /// reservation that passes the age check under this guard cannot - /// already have been swept, and self-releases only run on this - /// transaction's own rejection/abandon paths, which are sequenced - /// after this call returns. + /// The age check orders against key-wallet's `ReservationSet` TTL + /// sweep — it runs inside coin selection, which mutates wallet state + /// under the manager WRITE lock — and `last_processed_height` + /// advancement (same lock): a reservation that passes the check under + /// this guard cannot already have been swept, because key-wallet's TTL + /// exceeds `RESERVATION_MAX_AGE_BLOCKS` on the same height clock, and + /// self-releases only run on this transaction's own rejection/abandon + /// paths, which are sequenced after this call returns. /// - /// Deliberate cost: writers (height catch-up, finalization) queue - /// behind the network await, bounded by the broadcaster's own timeout. - /// That is the price of the ordering invariant without a - /// key-wallet-side in-broadcast pin. + /// The guard is deliberately DROPPED before the broadcaster await. The + /// production `SpvBroadcaster` waits on dash-spv's mempool pipeline, + /// and that pipeline's local-transaction handler takes `wallet.write()` + /// on this same manager lock before it can process the very + /// echo/IS-lock/confirmation events the wait needs — held across the + /// await, the guard starves the pipeline and every dispatch rides the + /// full acceptance timeout to an ambiguous verdict while the whole + /// manager stalls behind tokio's write-preferring queue. Check-to-wire + /// is therefore a small non-atomic gap rather than an invariant; it is + /// covered by the same TTL margin as the propagation phase that follows + /// (the guard never spanned rebroadcasts either), and dropping early is + /// strictly stronger afterwards: the mempool pipeline marks the inputs + /// spent in the wallet's own view within milliseconds instead of after + /// the timeout. (Same lock-free shape as + /// `broadcast_releasing_on_rejection`.) /// /// Callers do their stale/rejection reconciliation AFTER this returns: - /// those paths retake manager locks and must not run under this guard - /// (tokio's write-preferring `RwLock` would deadlock a re-entrant read - /// behind a queued writer). + /// those paths retake manager locks. pub(crate) async fn dispatch_unexpired( &self, reservation_height: u32, transaction: &Transaction, ) -> GuardedDispatch { - let wm = self.wallet_manager.read().await; - let height = wm - .get_wallet_and_info(&self.wallet_id) - .map(|(_, info)| info.core_wallet.last_processed_height()); - if reservation_expired(reservation_height, height) { - return GuardedDispatch::Stale; + { + let wm = self.wallet_manager.read().await; + let height = wm + .get_wallet_and_info(&self.wallet_id) + .map(|(_, info)| info.core_wallet.last_processed_height()); + if reservation_expired(reservation_height, height) { + return GuardedDispatch::Stale; + } + // Guard dropped here — see above: holding it across the await + // starves the SPV pipeline that must complete the wait. } GuardedDispatch::Sent(self.broadcaster.broadcast(transaction).await) } @@ -103,13 +111,15 @@ impl CoreWallet { &self, transaction: &SignedCoreTransaction, ) -> Result { - // Check and dispatch are one guarded observation — see - // [`Self::dispatch_unexpired`]. A plain check-then-send here let - // sync catch-up age the reservation and a concurrent finalization - // sweep + re-reserve the same inputs between the two steps, so the - // old signed transaction could hit the wire against reassigned - // UTXOs. Reconciliation stays OUTSIDE the guard (it retakes - // manager locks). + // The age check happens at dispatch time, inside + // [`Self::dispatch_unexpired`] — not out here, where it would go + // stale before the send (sync catch-up can age the reservation and + // a concurrent finalization can sweep + re-reserve the same inputs + // in the gap, letting the old signed transaction hit the wire + // against reassigned UTXOs). The residual check-to-wire gap and + // why the manager guard must not span the broadcaster await are + // documented on `dispatch_unexpired`. Reconciliation retakes + // manager locks after it returns. match self .dispatch_unexpired(transaction.reservation_height(), transaction.transaction()) .await @@ -486,18 +496,19 @@ mod tests { } } - /// The age bound is validated ATOMICALLY with dispatch, not merely - /// before it: [`CoreWallet::dispatch_unexpired`] samples the height and - /// reaches the broadcaster under ONE wallet-manager read guard, so a - /// height advance (a manager WRITE) can never interleave between a - /// passed check and the send. The single-threaded proof of that - /// ordering: the same handle's inputs dispatch while fresh, and the - /// identical call refuses — broadcaster untouched — once catch-up - /// advances the clock past the bound. The verdict is derived from the - /// state observed INSIDE the guard at each dispatch, never from a - /// caller-side pre-check that could go stale in the gap. + /// The age bound is validated by [`CoreWallet::dispatch_unexpired`] + /// itself, immediately before the send — never by a caller-side + /// pre-check that could go stale in the gap. The height sample and the + /// expiry verdict happen under a wallet-manager read guard that is + /// dropped before the broadcaster await (holding it across the await + /// starves the SPV mempool pipeline — see `dispatch_unexpired`'s doc); + /// the residual check-to-wire gap is covered by key-wallet's TTL + /// margin, the same margin that covers the propagation phase. The + /// single-threaded proof here: the same handle's inputs dispatch while + /// fresh, and the identical call refuses — broadcaster untouched — + /// once catch-up advances the clock past the bound. #[tokio::test] - async fn guarded_dispatch_rechecks_age_inside_the_guard() { + async fn guarded_dispatch_rechecks_age_at_dispatch() { let (core, signer, outputs) = funded_core_wallet( account_type_standard(AccountTypePreference::BIP44), Arc::new(AlwaysOkBroadcaster), @@ -515,7 +526,7 @@ mod tests { .await; assert!( matches!(fresh, GuardedDispatch::Sent(Ok(_))), - "a fresh reservation must dispatch under the guard" + "a fresh reservation must dispatch" ); // Catch-up advances the clock past the bound; the identical call now @@ -527,7 +538,7 @@ mod tests { .await; assert!( matches!(stale, GuardedDispatch::Stale), - "an aged reservation must refuse inside the guard, not dispatch" + "an aged reservation must refuse at the check, not dispatch" ); core.abandon_transaction(&finalized).await; diff --git a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs index 8b9bc4b8fe4..be0ca53f8e5 100644 --- a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -472,12 +472,13 @@ impl SignedPaymentRegistry { // immediate rebuild, an ambiguous outcome keeps it, and the release is // bound to the token's own wallet generation. // - // The age bound is NOT pre-checked here: it is re-validated - // atomically with dispatch, under the wallet-manager read guard - // inside `broadcast_payment_releasing_reservation` — a check made - // out here is stale by the time the send begins (catch-up can - // advance the clock, and a concurrent finalization can sweep + - // re-reserve the inputs in the gap). On the stale outcome the + // The age bound is NOT pre-checked here: it is re-validated at + // dispatch time inside `broadcast_payment_releasing_reservation` + // (height sampled under the manager read guard, which drops before + // the broadcaster await) — a check made out here is stale by the + // time the send begins (catch-up can advance the clock, and a + // concurrent finalization can sweep + re-reserve the inputs in + // the gap). On the stale outcome the // broadcaster was never touched and the entry is reconciled below // exactly as the old pre-check did: with the build's owner token // present the release is safe at ANY age From 80c54b44205fe3be332e1e179d755c133ad99d0c Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Wed, 12 Aug 2026 08:52:54 -0400 Subject: [PATCH 06/15] fix(platform-wallet): pin in-broadcast inputs across the dispatch await MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guarded dispatch proves reservation freshness under the manager read guard but must drop that guard before the broadcaster await (holding it starves the SPV mempool pipeline). Both production broadcasters can suspend before submission, and in that unbounded gap sync catch-up can advance last_processed_height past key-wallet's reservation TTL, letting a concurrent build's selection sweep the dispatched build's reservation and re-reserve the same inputs — the already-signed transaction would then hit the wire against inputs reassigned to another payment. Close the window with a non-expiring in-broadcast pin on WalletGeneration, installed atomically with the freshness check while the read guard is still held (freshness below the TTL on the same clock IS the ownership proof — sweeps and height advances run under the write lock) and released by RAII only after the broadcaster returns, cancelled dispatches included. Pins are counted per outpoint so a duplicate dispatch of the same transaction keeps the fence until its last send returns. Every coin-selection choke point — finalize_transaction, the contact-payment build, the asset-lock build — now refuses a build whose selection picked a pinned input, releasing its fresh reservation exactly under the still-held write guard. The registry-token broadcast shares dispatch_unexpired and therefore the same primitive. Also document the Kotlin-side consume semantics: after broadcastTransaction consumes the handle, a follow-up abandonTransaction fails locally with IllegalStateException before any native code runs — not with a native invalid-handle error. Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/wallet/ManagedCoreWallet.kt | 11 +- .../src/wallet/asset_lock/build.rs | 35 +++ .../src/wallet/core/broadcast.rs | 183 +++++++++++-- .../src/wallet/core/generation.rs | 258 +++++++++++++++++- .../src/wallet/core/transaction.rs | 20 ++ .../src/wallet/identity/network/payments.rs | 28 ++ 6 files changed, 505 insertions(+), 30 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt index 3e67b47c847..82fd8e145fb 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt @@ -37,8 +37,11 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { * On that refusal the handle has **already been consumed** by this call and * its funding reservation released owner-guarded (freed only while this * build still owned it; a no-op once a TTL sweep or re-reservation - * transferred ownership), so a follow-up [abandonTransaction] is an - * invalid-handle error, not a recovery path — there is nothing left to + * transferred ownership). This call consumes the Kotlin-side handle up + * front (on EVERY outcome, success included), so a follow-up + * [abandonTransaction] fails locally with [IllegalStateException] because + * [FinalizedCoreTransaction] has already been consumed; it never re-enters + * native code and is not a recovery path — there is nothing left to * release. Recover by rebuilding the transaction, which can reselect the * freed inputs immediately. */ @@ -59,6 +62,10 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { * skips its unguarded by-outpoint release past it (releasing by outpoint * could free a newer build's reservation), leaving the aged reservation for * the TTL to reclaim. The handle is torn down either way. + * + * Consumes the Kotlin-side handle: calling this (or [broadcastTransaction]) + * on an already-consumed [FinalizedCoreTransaction] fails locally with + * [IllegalStateException] before any native code runs. */ fun abandonTransaction(tx: FinalizedCoreTransaction) { WalletManagerNative.coreWalletAbandonSignedTransaction( 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 1fe80310c2a..9d2e6a70f1c 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -195,6 +195,41 @@ impl AssetLockManager { )) })?; + // Refuse a selection that picked an input pinned by an IN-FLIGHT + // BROADCAST dispatch (`WalletGeneration::pin_in_broadcast`): this + // build's own selection swept that dispatch's aged reservation + // (catch-up advanced past key-wallet's TTL while it was suspended + // pre-submission) and re-reserved the input, so broadcasting this + // asset lock would race the pinned, already-signed transaction on + // the wire. Same backstop as `finalize_transaction` and the + // contact-payment build. The release runs under the write guard + // held since selection, so it is exact; the token form is + // owner-guarded like the drain-floor abandon below. The consumed + // funding key index is the same residue any discarded build leaves, + // reclaimed by the gap-limit scan. + if let Some(pinned) = info.generation.in_broadcast_conflict(&result.transaction) { + let funds_account = match funding_account { + AssetLockFundingAccount::Bip44 { account_index } => info + .core_wallet + .bip44_managed_account_at_index(account_index), + AssetLockFundingAccount::CoinJoin { account_index } => info + .core_wallet + .accounts + .coinjoin_accounts + .get(&account_index), + }; + if let Some(account) = funds_account { + match result.reservation_token { + Some(token) => account.release_reservation_if_owner(&result.transaction, token), + None => account.release_reservation(&result.transaction), + } + } + return Err(PlatformWalletError::AssetLockTransaction(format!( + "selected input {pinned} is mid-broadcast by an in-flight dispatch; \ + retry after it completes" + ))); + } + // 4. Pull the (pubkey, path) for our single credit output. // // `build_asset_lock_with_signer` always returns the `Public` diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index c29df5cf267..8404b4e9821 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -18,8 +18,9 @@ pub(crate) enum GuardedDispatch { } impl CoreWallet { - /// Age-check under the wallet-manager READ lock, then dispatch - /// immediately after releasing it. + /// Age-check AND pin under the wallet-manager READ lock, then dispatch + /// immediately after releasing it, keeping the pin until the broadcaster + /// returns. /// /// The age check orders against key-wallet's `ReservationSet` TTL /// sweep — it runs inside coin selection, which mutates wallet state @@ -28,7 +29,9 @@ impl CoreWallet { /// this guard cannot already have been swept, because key-wallet's TTL /// exceeds `RESERVATION_MAX_AGE_BLOCKS` on the same height clock, and /// self-releases only run on this transaction's own rejection/abandon - /// paths, which are sequenced after this call returns. + /// paths, which are sequenced after this call returns. That proof of + /// still-held ownership is what authorizes the pin taken in the same + /// guarded section (the pin's owner check). /// /// The guard is deliberately DROPPED before the broadcaster await. The /// production `SpvBroadcaster` waits on dash-spv's mempool pipeline, @@ -37,14 +40,30 @@ impl CoreWallet { /// echo/IS-lock/confirmation events the wait needs — held across the /// await, the guard starves the pipeline and every dispatch rides the /// full acceptance timeout to an ambiguous verdict while the whole - /// manager stalls behind tokio's write-preferring queue. Check-to-wire - /// is therefore a small non-atomic gap rather than an invariant; it is - /// covered by the same TTL margin as the propagation phase that follows - /// (the guard never spanned rebroadcasts either), and dropping early is - /// strictly stronger afterwards: the mempool pipeline marks the inputs - /// spent in the wallet's own view within milliseconds instead of after - /// the timeout. (Same lock-free shape as - /// `broadcast_releasing_on_rejection`.) + /// manager stalls behind tokio's write-preferring queue. (Same + /// lock-free shape as `broadcast_releasing_on_rejection`.) + /// + /// What spans the await instead is the **in-broadcast pin** + /// ([`WalletGeneration::pin_in_broadcast`](super::WalletGeneration::pin_in_broadcast)), + /// installed on the manager-registered generation while the guard was + /// still held. Both production broadcasters can suspend *before* + /// submission (the SPV path awaits configuration, event subscription and + /// the network lock ahead of its local dispatch), catch-up can advance + /// the clock by many blocks in that gap, and async scheduling puts no + /// bound on it — so a freshness check alone is not an ordering + /// invariant against the TTL sweep + re-reserve race. The pin is: it + /// has no TTL, every coin-selection choke point refuses a build whose + /// selection picked a pinned input (under the same write lock the sweep + /// runs under), and it is released — via RAII, so a cancelled dispatch + /// releases it too — only after the broadcaster returns, which is + /// strictly after initial network dispatch. The propagation phase that + /// follows is unchanged: once dispatch returns, the mempool pipeline + /// marks the inputs spent in the wallet's own view within milliseconds. + /// + /// A wallet no longer in the manager skips the pin (there is no + /// registered generation to fence builds on — they cannot fund from a + /// removed wallet); liveness is the FFI layer's generation check, + /// established before this runs. /// /// Callers do their stale/rejection reconciliation AFTER this returns: /// those paths retake manager locks. @@ -53,17 +72,22 @@ impl CoreWallet { reservation_height: u32, transaction: &Transaction, ) -> GuardedDispatch { - { + let _in_broadcast_pin = { let wm = self.wallet_manager.read().await; - let height = wm - .get_wallet_and_info(&self.wallet_id) - .map(|(_, info)| info.core_wallet.last_processed_height()); + let info = wm.get_wallet_info(&self.wallet_id); + let height = info.map(|info| info.core_wallet.last_processed_height()); if reservation_expired(reservation_height, height) { return GuardedDispatch::Stale; } - // Guard dropped here — see above: holding it across the await - // starves the SPV pipeline that must complete the wait. - } + // Pin BEFORE the guard drops: check-and-pin is one atomic step, + // and freshness under this guard proves the reservation is still + // ours to pin (see the method docs). The pin outlives the guard + // and is dropped only after the broadcaster returns below. + info.map(|info| info.generation.pin_in_broadcast(transaction)) + // Guard dropped here — holding it across the await starves the + // SPV pipeline that must complete the wait; the pin, not the + // guard, covers check-to-wire. + }; GuardedDispatch::Sent(self.broadcaster.broadcast(transaction).await) } @@ -116,10 +140,12 @@ impl CoreWallet { // stale before the send (sync catch-up can age the reservation and // a concurrent finalization can sweep + re-reserve the same inputs // in the gap, letting the old signed transaction hit the wire - // against reassigned UTXOs). The residual check-to-wire gap and - // why the manager guard must not span the broadcaster await are - // documented on `dispatch_unexpired`. Reconciliation retakes - // manager locks after it returns. + // against reassigned UTXOs). The check also installs the + // in-broadcast pin that fences the inputs against exactly that + // sweep + re-reserve until the broadcaster returns; why the manager + // guard itself must not span the broadcaster await is documented on + // `dispatch_unexpired`. Reconciliation retakes manager locks after + // it returns. match self .dispatch_unexpired(transaction.reservation_height(), transaction.transaction()) .await @@ -239,7 +265,10 @@ impl CoreWallet { /// under the manager read guard ([`Self::dispatch_unexpired`]) — a /// pre-checked age is not an invariant, because catch-up can advance /// the clock and a concurrent finalization can sweep + re-reserve the - /// inputs between a caller's check and the send. On the stale outcome + /// inputs between a caller's check and the send. The same guarded + /// section installs the in-broadcast pin that fences the inputs against + /// that sweep + re-reserve for the whole broadcaster await — the same + /// primitive as the finalized-handle path. On the stale outcome /// nothing was sent and NOTHING is released here: the caller owns the /// reconciliation policy (the registry reconciles owner-guarded). pub(crate) async fn broadcast_payment_releasing_reservation( @@ -502,8 +531,9 @@ mod tests { /// expiry verdict happen under a wallet-manager read guard that is /// dropped before the broadcaster await (holding it across the await /// starves the SPV mempool pipeline — see `dispatch_unexpired`'s doc); - /// the residual check-to-wire gap is covered by key-wallet's TTL - /// margin, the same margin that covers the propagation phase. The + /// the check-to-wire gap is covered by the in-broadcast pin installed + /// in the same guarded section (see + /// `in_broadcast_pin_blocks_reselection_until_dispatch_returns`). The /// single-threaded proof here: the same handle's inputs dispatch while /// fresh, and the identical call refuses — broadcaster untouched — /// once catch-up advances the clock past the bound. @@ -640,6 +670,109 @@ mod tests { } } + /// A broadcaster that models the pre-submission suspension window of the + /// production broadcasters: `broadcast` parks between two barriers, so the + /// test can interleave catch-up and a competing build while the dispatch + /// is provably mid-await (freshness already checked, guard already + /// dropped, pin held). + struct GatedBroadcaster { + entered: Arc, + release: Arc, + } + + #[async_trait::async_trait] + impl TransactionBroadcaster for GatedBroadcaster { + async fn broadcast( + &self, + transaction: &Transaction, + ) -> Result { + self.entered.wait().await; + self.release.wait().await; + Ok(transaction.txid()) + } + } + + /// THE CHECK-TO-WIRE RACE the in-broadcast pin closes: the freshness + /// check passes under the manager read guard, the guard drops, and the + /// dispatch suspends inside the broadcaster BEFORE submission. Catch-up + /// then advances the clock past key-wallet's reservation TTL, so a + /// competing finalize's own selection sweeps the dispatched build's + /// reservation and re-selects its input — pre-pin, that build completed + /// and raced the already-signed transaction on the wire. With the pin + /// held across the await, the competing finalize must be REFUSED, and + /// only after the dispatch returns (pin dropped, RAII) may a new build + /// take the input again. + #[tokio::test] + async fn in_broadcast_pin_blocks_reselection_until_dispatch_returns() { + let entered = Arc::new(tokio::sync::Barrier::new(2)); + let release = Arc::new(tokio::sync::Barrier::new(2)); + let (core, signer, outputs) = funded_core_wallet( + StandardAccountType::BIP44Account, + Arc::new(GatedBroadcaster { + entered: Arc::clone(&entered), + release: Arc::clone(&release), + }), + ) + .await; + let stamped = core + .last_processed_height() + .await + .expect("last processed height"); + let finalized = finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + + // Age the handle to ONE BELOW the guard bound: the freshness check + // must pass, which is exactly what makes the pre-submission window + // dangerous without the pin. + advance_processed_height(&core, stamped + RESERVATION_MAX_AGE_BLOCKS - 1).await; + + let dispatcher = tokio::spawn({ + let core = core.clone(); + async move { core.broadcast_finalized_transaction(&finalized).await } + }); + // The dispatcher is now suspended INSIDE the broadcaster: freshness + // checked, manager guard dropped, pin held. + entered.wait().await; + + // Catch-up races far past key-wallet's TTL measured from the original + // reservation stamp, so the NEXT selection's sweep reclaims the + // dispatched build's reservation and its input returns to the + // selectable pool. + advance_processed_height(&core, stamped + RESERVATION_MAX_AGE_BLOCKS + 48).await; + + // The competing finalize re-selects the fixture's only UTXO — the + // pinned input — and must be refused by the pin backstop, not + // completed into a conflicting signed transaction. + let competing = + try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + match competing { + Err(PlatformWalletError::TransactionBuild(message)) => assert!( + message.contains("mid-broadcast"), + "the refusal must name the in-flight broadcast, got: {message}" + ), + other => panic!("a build re-selecting a pinned input must be refused, got {other:?}"), + } + + // Let the dispatch complete: the send succeeds (the age check passed + // before the suspension) and the pin is dropped with it. + release.wait().await; + let sent = dispatcher.await.expect("dispatcher task"); + assert!( + sent.is_ok(), + "the pinned dispatch itself must complete, got {sent:?}" + ); + + // Pin lifted: a new build may take the input again. (The mock manager + // runs no mempool pipeline; in production the dispatched transaction's + // inputs would be marked spent by processing moments later — the + // assertion here is only that the pin's lifetime ended with the + // dispatch.) + let after = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + let after = after.unwrap_or_else(|error| { + panic!("the pin must lift once the dispatch returns, got {error:?}") + }); + core.abandon_transaction(&after).await; + } + /// A pre-send broadcast rejection must release the UTXO reservation taken /// while building the transaction, so an immediate retry can reselect those /// inputs instead of failing with spurious insufficient funds until the TTL diff --git a/packages/rs-platform-wallet/src/wallet/core/generation.rs b/packages/rs-platform-wallet/src/wallet/core/generation.rs index 5d70488443a..e9afe8d200b 100644 --- a/packages/rs-platform-wallet/src/wallet/core/generation.rs +++ b/packages/rs-platform-wallet/src/wallet/core/generation.rs @@ -1,9 +1,13 @@ //! Per-wallet-*generation* shared state: the identity marker every handle to -//! one generation shares, and that generation's lifecycle gate. +//! one generation shares, that generation's lifecycle gate, and the +//! in-broadcast outpoint pins that fence a mid-dispatch transaction's inputs +//! against concurrent re-selection. +use std::collections::HashMap; use std::ops::Deref; -use std::sync::Arc; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use dashcore::{OutPoint, Transaction}; use tokio::sync::{OwnedRwLockWriteGuard, RwLock, RwLockReadGuard}; use super::balance::WalletBalance; @@ -59,6 +63,40 @@ pub struct WalletGeneration { /// a retry loop, and the guard must outlive the loop iteration that produced /// the `Arc` it came from. lifecycle: Arc>, + /// Outpoints currently **pinned by an in-flight broadcast dispatch** + /// ([`pin_in_broadcast`](Self::pin_in_broadcast)), counted per outpoint. + /// + /// The guarded dispatch (`CoreWallet::dispatch_unexpired`) proves under the + /// wallet-manager read guard that a finalized transaction's funding + /// reservation is still its own, then must release that guard before the + /// broadcaster await (holding it starves the SPV mempool pipeline). The + /// broadcaster can suspend *before* submission, and in that gap sync + /// catch-up can advance `last_processed_height` far enough that key-wallet's + /// `ReservationSet` TTL sweeps the reservation and a concurrent build + /// re-reserves the very same inputs — the dispatch would then put an + /// already-signed transaction on the wire against inputs reassigned to + /// another payment. This map is the non-expiring pin that outlives the + /// dropped guard: every coin-selection choke point + /// (`CoreWallet::finalize_transaction`, the contact-payment build, the + /// asset-lock build) checks its freshly reserved selection against it — + /// still under the manager write lock, the same synchronization height + /// advancement and the TTL sweep run under — and refuses a build whose + /// selection picked a pinned input, closing the sweep + re-reserve window + /// for as long as the dispatch is in flight. + /// + /// A *count* per outpoint rather than a set: `broadcast_finalized_transaction` + /// takes `&SignedCoreTransaction`, so a direct Rust caller can dispatch the + /// same transaction twice concurrently (idempotent on the wire — same txid). + /// Counting keeps the pin held until the LAST dispatch returns instead of + /// letting the first completion unpin the other's in-flight send. + /// + /// A `std::sync::Mutex` like key-wallet's own `ReservationSet`: critical + /// sections are a few hash operations, never held across an await, and the + /// sync lock is what lets [`InBroadcastPin::drop`] unpin from a plain + /// (non-async) `Drop` — which is also what makes the pin + /// cancellation-safe when the dispatching future is dropped mid-await. + /// Never persisted: after a restart nothing is mid-dispatch. + in_broadcast: Mutex>, } impl Default for WalletGeneration { @@ -68,11 +106,12 @@ impl Default for WalletGeneration { } impl WalletGeneration { - /// A fresh generation: zeroed balance, uncontended gate. + /// A fresh generation: zeroed balance, uncontended gate, nothing pinned. pub fn new() -> Self { Self { balance: WalletBalance::new(), lifecycle: Arc::new(RwLock::new(())), + in_broadcast: Mutex::new(HashMap::new()), } } @@ -127,6 +166,112 @@ impl WalletGeneration { pub async fn teardown_guard(&self) -> OwnedRwLockWriteGuard<()> { Arc::clone(&self.lifecycle).write_owned().await } + + /// Recovers from a poisoned mutex rather than panicking: the guarded data + /// is a plain count map with no invariant a partial write could break, and + /// panicking here would strand every later build and dispatch on this + /// generation. (Same policy as key-wallet's `ReservationSet`.) + fn in_broadcast_lock(&self) -> MutexGuard<'_, HashMap> { + self.in_broadcast + .lock() + .unwrap_or_else(PoisonError::into_inner) + } + + /// Pin `transaction`'s inputs as **in-broadcast** until the returned + /// [`InBroadcastPin`] is dropped. + /// + /// Taken by the guarded dispatch (`CoreWallet::dispatch_unexpired`) while + /// it still holds the wallet-manager READ guard that proved the funding + /// reservation fresh — the freshness bound sits strictly below key-wallet's + /// reservation TTL on the same `last_processed_height` clock, and both the + /// TTL sweep and height advancement mutate under the manager WRITE lock, so + /// under that guard the reservation is provably still this build's: that + /// proof is the pin's owner check, and installing the pin before the guard + /// drops makes check-and-pin one atomic step. The pin then *outlives* the + /// guard, deliberately: it is what keeps the check meaningful across the + /// broadcaster await the guard must not span (see the + /// [`in_broadcast`](Self::in_broadcast) field docs for the full race). + /// + /// The pin has **no TTL** — a suspended dispatch keeps its inputs fenced no + /// matter how far catch-up advances the clock — and is released only by + /// dropping the returned guard object, which happens even when the + /// dispatching future is cancelled mid-await (`Drop` runs on unwind and on + /// future drop alike). + /// + /// Callers pin on the generation currently REGISTERED in the manager + /// (`PlatformWalletInfo::generation`), the same object the build-side + /// conflict checks read, so the fence works even for a dispatch through a + /// stale-generation handle. + pub(crate) fn pin_in_broadcast(self: &Arc, transaction: &Transaction) -> InBroadcastPin { + let outpoints: Vec = transaction + .input + .iter() + .map(|input| input.previous_output) + .collect(); + { + let mut pinned = self.in_broadcast_lock(); + for outpoint in &outpoints { + *pinned.entry(*outpoint).or_insert(0) += 1; + } + } + InBroadcastPin { + generation: Arc::clone(self), + outpoints, + } + } + + /// The first of `transaction`'s inputs that is currently pinned by an + /// in-flight broadcast dispatch, or `None` when the selection is clear. + /// + /// Called by every coin-selection choke point immediately after it built + /// and reserved a selection, while it still holds the wallet-manager WRITE + /// guard: a hit means this build's own selection swept an aged reservation + /// whose transaction is mid-dispatch and re-reserved its input — completing + /// the build would race that transaction on the wire, so the caller must + /// release its fresh reservation (exact under the still-held write guard) + /// and refuse the build. In the normal case a pinned input is still + /// *reserved* and never reaches selection at all; this check is the + /// backstop for exactly the post-sweep window. + pub(crate) fn in_broadcast_conflict(&self, transaction: &Transaction) -> Option { + let pinned = self.in_broadcast_lock(); + transaction + .input + .iter() + .map(|input| input.previous_output) + .find(|outpoint| pinned.contains_key(outpoint)) + } + + /// Drop one pin count for each of `outpoints` — the [`InBroadcastPin`] + /// release half of [`pin_in_broadcast`](Self::pin_in_broadcast). + fn unpin_in_broadcast(&self, outpoints: &[OutPoint]) { + let mut pinned = self.in_broadcast_lock(); + for outpoint in outpoints { + match pinned.get_mut(outpoint) { + Some(count) if *count > 1 => *count -= 1, + Some(_) => { + pinned.remove(outpoint); + } + // Unreachable by construction — every pin increments before its + // guard can decrement — but a miscount must not panic a Drop. + None => debug_assert!(false, "unpin of an outpoint that was never pinned"), + } + } + } +} + +/// RAII guard for one dispatch's in-broadcast input pins — see +/// [`WalletGeneration::pin_in_broadcast`]. Dropping it (normal return, +/// unwind, or the dispatching future being cancelled mid-await) releases +/// exactly the pins that call took, count-wise, never another dispatch's. +pub(crate) struct InBroadcastPin { + generation: Arc, + outpoints: Vec, +} + +impl Drop for InBroadcastPin { + fn drop(&mut self) { + self.generation.unpin_in_broadcast(&self.outpoints); + } } impl Deref for WalletGeneration { @@ -136,3 +281,110 @@ impl Deref for WalletGeneration { &self.balance } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use dashcore::{OutPoint, Transaction, TxIn, Txid}; + + use super::WalletGeneration; + + /// A minimal transaction spending exactly the given outpoints — the only + /// part of a transaction the pin machinery reads. + fn spending(outpoints: &[OutPoint]) -> Transaction { + Transaction { + version: 2, + lock_time: 0, + input: outpoints + .iter() + .map(|outpoint| TxIn { + previous_output: *outpoint, + ..Default::default() + }) + .collect(), + output: Vec::new(), + special_transaction_payload: None, + } + } + + fn outpoint(byte: u8, vout: u32) -> OutPoint { + OutPoint::new(Txid::from([byte; 32]), vout) + } + + /// A held pin flags every input of the pinned transaction — and only + /// those — and dropping the pin clears the conflict. This is the RAII + /// contract the dispatch relies on for cancellation-safety: a dispatching + /// future dropped mid-await unpins exactly the same way. + #[test] + fn pin_flags_inputs_until_dropped() { + let generation = Arc::new(WalletGeneration::new()); + let a = outpoint(0x01, 0); + let b = outpoint(0x02, 1); + let unrelated = outpoint(0x03, 0); + + let pin = generation.pin_in_broadcast(&spending(&[a, b])); + + // Both pinned inputs conflict; an unrelated selection does not. + assert_eq!(generation.in_broadcast_conflict(&spending(&[a])), Some(a)); + assert_eq!(generation.in_broadcast_conflict(&spending(&[b])), Some(b)); + assert_eq!( + generation.in_broadcast_conflict(&spending(&[unrelated, a])), + Some(a), + "a mixed selection must surface its pinned input" + ); + assert_eq!( + generation.in_broadcast_conflict(&spending(&[unrelated])), + None + ); + + drop(pin); + assert_eq!( + generation.in_broadcast_conflict(&spending(&[a, b])), + None, + "dropping the pin must clear the conflict" + ); + } + + /// Pins COUNT per outpoint: two concurrent dispatches of the same + /// transaction (legal through `&SignedCoreTransaction`, idempotent on the + /// wire) each take a pin, and the fence must hold until the LAST one + /// returns — the first completion must not unpin the other's in-flight + /// send. + #[test] + fn pins_are_counted_per_outpoint() { + let generation = Arc::new(WalletGeneration::new()); + let a = outpoint(0x10, 0); + let tx = spending(&[a]); + + let first = generation.pin_in_broadcast(&tx); + let second = generation.pin_in_broadcast(&tx); + + drop(first); + assert_eq!( + generation.in_broadcast_conflict(&tx), + Some(a), + "one dispatch still in flight must keep the outpoint fenced" + ); + + drop(second); + assert_eq!(generation.in_broadcast_conflict(&tx), None); + } + + /// Pins are per generation: a re-created wallet's fresh generation starts + /// with nothing pinned, and the old generation's pins die with its last + /// handle — nothing leaks across the recreation boundary. + #[test] + fn pins_do_not_cross_generations() { + let old_generation = Arc::new(WalletGeneration::new()); + let a = outpoint(0x20, 0); + let _pin = old_generation.pin_in_broadcast(&spending(&[a])); + + let new_generation = Arc::new(WalletGeneration::new()); + assert_eq!( + new_generation.in_broadcast_conflict(&spending(&[a])), + None, + "a fresh generation must not inherit the old generation's pins" + ); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 17e3db6862b..3ca67a7c4ce 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -402,6 +402,26 @@ impl CoreWallet { }; } + // Refuse a selection that picked an input pinned by an IN-FLIGHT + // BROADCAST. A pinned input is normally still reserved and never + // reaches selection; getting here means this build's own + // selection swept that dispatch's aged reservation (catch-up + // advanced the clock past key-wallet's TTL while the dispatch + // was suspended pre-submission) and re-reserved the input under + // our token. Completing this build would race the pinned, + // already-signed transaction on the wire — the double-spend the + // dispatch-side age guard exists to prevent + // (`WalletGeneration::pin_in_broadcast`). Still under the write + // guard, so the check is atomic with our reservation and the + // release is exact. + if let Some(pinned) = info.generation.in_broadcast_conflict(&unsigned) { + release_all!(offered_accounts, info.core_wallet.accounts, &unsigned); + return Err(PlatformWalletError::TransactionBuild(format!( + "selected input {pinned} is mid-broadcast by an in-flight dispatch; \ + retry after it completes" + ))); + } + // Map every selected input back to the account that owns it. That // mapping — not the offered list — is what the transaction carries: // selection routinely takes nothing from most offered sources, and diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index d447f282222..0ca9f3fedbf 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -1269,6 +1269,34 @@ impl DashPayView<'_, B> { } }; + // Refuse a selection that picked an input pinned by an IN-FLIGHT + // BROADCAST dispatch (`WalletGeneration::pin_in_broadcast`): our + // own selection swept that dispatch's aged reservation (catch-up + // advanced past key-wallet's TTL while it was suspended + // pre-submission) and re-reserved the input, so completing this + // payment would race the pinned, already-signed transaction on + // the wire. Same backstop as `finalize_transaction` and the + // asset-lock build. The `build_signed` reservation is token-less; + // the by-outpoint release is exact because the write guard has + // been held since selection. Roll back the consumed payment + // address exactly like the build-failure arm above — nothing was + // persisted or broadcast. + if let Some(pinned) = info.generation.in_broadcast_conflict(&tx) { + managed_account.release_reservation(&tx); + if let Some(external_account) = info + .core_wallet + .accounts + .dashpay_external_accounts + .get_mut(&key) + { + return_contact_payment_address_to_pool(external_account, &payment_address); + } + return Err(PlatformWalletError::TransactionBuild(format!( + "selected input {pinned} is mid-broadcast by an in-flight dispatch; \ + retry after it completes" + ))); + } + (payment_address, used_flip_changeset, tx, fee) }; From 2b911bc40e34ca1ff143cb896dd1bd591ba03b60 Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Thu, 13 Aug 2026 12:22:24 -0400 Subject: [PATCH 07/15] fix(platform-wallet): fence dispatched inputs past the broadcaster return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dispatch_unexpired` dropped its in-broadcast pin the moment `TransactionBroadcaster::broadcast` returned. That is safe only for `SpvBroadcaster`, which injects the transaction into dash-spv's local mempool pipeline so the inputs leave this wallet's selectable set within milliseconds. `DapiBroadcaster::broadcast` only awaits `sdk.execute` and injects nothing, so on that path both an accepted response and an ambiguous `MaybeSent` returned with the input still selectable while the transaction was in flight — and if catch-up had advanced `last_processed_height` past key-wallet's 24-block reservation TTL during the await, the reservation was already swept too. The input was then neither reserved nor fenced: exactly the sweep + re-select race the pin was added to close (dashpay/platform#4309). The pin becomes a two-phase fence on `WalletGeneration`: * dispatching — the existing counted, non-expiring pin, from check-and-pin until the broadcaster returns. * pending-spend — installed when the broadcaster returns anything but a definitive pre-send rejection, lasting `IN_BROADCAST_FENCE_BLOCKS` (24, key-wallet's own `RESERVATION_TTL_BLOCKS`) past the height the dispatch was authorized at. The second phase is key-wallet's reservation renewal implemented one layer up: `ReservationSet` exposes no renew primitive at the pinned revision, so instead of re-stamping the reservation we re-anchor an equivalent TTL at dispatch — which is the moment the transaction actually reached the network, and the point the TTL should always have been measured from. Only `BroadcastError::Rejected` frees the inputs at dispatch return; that outcome proves nothing is on the wire, and the caller releases the reservation in the same breath. The fence lapses rather than persisting: an outpoint the wallet has already observed as spent never reaches selection at all, so in the normal case the bound is never consulted. It exists only so a never-observed transaction cannot strand its inputs forever, and it leaves the residual exposure identical to the one key-wallet's reservation TTL already accepts. Nothing here touches the wallet-manager lock, so the SPV-starvation fix from 9b033cb07d is preserved verbatim: the manager read guard is still dropped before the broadcaster await. Lapsed entries are reaped by the conflict check itself — the only place the fence is read — so the map stays bounded with no background task. Tests: the barrier-gated race test's tail assertion is corrected (its 48-block catch-up already outruns the new bound, so it still proves the *dispatching* phase and says so); a new `dispatched_input_stays_fenced_after_the_broadcaster_returns` dispatches at the oldest height the age guard admits, then probes a height where the reservation is provably swept and only the fence stands. Reverting the `retain_pending_spend` call makes that test's competing build succeed, i.e. it reproduces the reported race. Five generation-level tests cover the non-expiring dispatching phase, the bound, on-read reaping, the longer-fence-wins merge, and the rejection path installing no fence. Co-Authored-By: Claude Opus 4.8 --- .../src/wallet/asset_lock/build.rs | 6 +- .../src/wallet/core/broadcast.rs | 194 ++++++++- .../src/wallet/core/generation.rs | 384 +++++++++++++++--- .../src/wallet/core/transaction.rs | 5 +- .../src/wallet/identity/network/payments.rs | 5 +- .../src/wallet/reservations.rs | 46 +++ 6 files changed, 551 insertions(+), 89 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 ddba5a3df45..664981aee3b 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -17,6 +17,7 @@ use key_wallet::wallet::managed_wallet_info::asset_lock_builder::{ }; use key_wallet::wallet::managed_wallet_info::managed_account_operations::ManagedAccountOperations; use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; @@ -233,7 +234,10 @@ impl AssetLockManager { // owner-guarded like the drain-floor abandon below. The consumed // funding key index is the same residue any discarded build leaves, // reclaimed by the gap-limit scan. - if let Some(pinned) = info.generation.in_broadcast_conflict(&result.transaction) { + if let Some(pinned) = info.generation.in_broadcast_conflict( + &result.transaction, + info.core_wallet.last_processed_height(), + ) { // The pooled build reserves in EVERY contributing account's own // set under the one owner token, so the release must sweep // `result.funding_accounts` — the same per-account idiom as diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 8404b4e9821..51288900b1a 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -52,13 +52,39 @@ impl CoreWallet { /// the clock by many blocks in that gap, and async scheduling puts no /// bound on it — so a freshness check alone is not an ordering /// invariant against the TTL sweep + re-reserve race. The pin is: it - /// has no TTL, every coin-selection choke point refuses a build whose - /// selection picked a pinned input (under the same write lock the sweep - /// runs under), and it is released — via RAII, so a cancelled dispatch - /// releases it too — only after the broadcaster returns, which is - /// strictly after initial network dispatch. The propagation phase that - /// follows is unchanged: once dispatch returns, the mempool pipeline - /// marks the inputs spent in the wallet's own view within milliseconds. + /// has no TTL while the dispatch is in flight, and every coin-selection + /// choke point refuses a build whose selection picked a pinned input + /// (under the same write lock the sweep runs under). + /// + /// # Where the fence is released, and why that point is safe + /// + /// The pin is *not* simply dropped when the broadcaster returns. That + /// return means "the transaction may now be on the network", not "this + /// wallet has observed the spend", and the two differ per broadcaster: + /// `SpvBroadcaster` injects into dash-spv's local mempool pipeline, so the + /// inputs leave this wallet's selectable set within milliseconds; + /// `DapiBroadcaster::broadcast` only awaits `sdk.execute` and injects + /// nothing, so on that path the inputs are still selectable while the + /// transaction is in flight (`dashpay/platform#4309`). So: + /// + /// * **Definitive pre-send rejection** (`BroadcastError::Rejected`) — the + /// transaction provably did not reach the network. The fence is dropped + /// immediately here, and the caller releases the reservation in the same + /// breath, so an instant rebuild can reselect the inputs. + /// * **Anything else** (accepted, or an ambiguous `MaybeSent`) — the pin is + /// converted to a pending-spend fence + /// (`InBroadcastPin::retain_pending_spend`) + /// lasting + /// [`IN_BROADCAST_FENCE_BLOCKS`](crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS) + /// past the height this dispatch was authorized at. Once the wallet does + /// observe the spend the outpoint stops reaching selection at all, so the + /// fence goes inert without waiting for that bound; the bound is only the + /// backstop for a transaction that is never observed, and matches the TTL + /// the reservation itself would have had, re-anchored at dispatch. + /// + /// Neither phase touches the wallet-manager lock, so nothing here can + /// starve the SPV mempool pipeline: the guard is still dropped before the + /// broadcaster await, exactly as it was. /// /// A wallet no longer in the manager skips the pin (there is no /// registered generation to fence builds on — they cannot fund from a @@ -72,7 +98,7 @@ impl CoreWallet { reservation_height: u32, transaction: &Transaction, ) -> GuardedDispatch { - let _in_broadcast_pin = { + let mut in_broadcast_pin = { let wm = self.wallet_manager.read().await; let info = wm.get_wallet_info(&self.wallet_id); let height = info.map(|info| info.core_wallet.last_processed_height()); @@ -81,14 +107,33 @@ impl CoreWallet { } // Pin BEFORE the guard drops: check-and-pin is one atomic step, // and freshness under this guard proves the reservation is still - // ours to pin (see the method docs). The pin outlives the guard - // and is dropped only after the broadcaster returns below. - info.map(|info| info.generation.pin_in_broadcast(transaction)) + // ours to pin (see the method docs). The pin outlives the guard, + // and — unless the send is definitively rejected — outlives the + // broadcaster return too, as a pending-spend fence anchored at the + // height sampled right here. + info.map(|info| { + info.generation + .pin_in_broadcast(transaction, info.core_wallet.last_processed_height()) + }) // Guard dropped here — holding it across the await starves the // SPV pipeline that must complete the wait; the pin, not the // guard, covers check-to-wire. }; - GuardedDispatch::Sent(self.broadcaster.broadcast(transaction).await) + let outcome = self.broadcaster.broadcast(transaction).await; + // Retain the fence for everything except a definitive pre-send + // rejection: only `Rejected` proves the transaction is not on the + // network, so only `Rejected` may free the inputs at dispatch return. + // An ambiguous `MaybeSent` is precisely the case that must stay fenced. + if !matches!( + outcome, + Err(crate::broadcaster::BroadcastError::Rejected { .. }) + ) { + if let Some(pin) = in_broadcast_pin.as_mut() { + pin.retain_pending_spend(); + } + } + drop(in_broadcast_pin); + GuardedDispatch::Sent(outcome) } /// Broadcast an atomically finalized transaction. A definitive rejection @@ -315,7 +360,7 @@ mod tests { RejectFirstBroadcaster, WalletSigner, }; use crate::wallet::core::CoreWallet; - use crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS; + use crate::wallet::reservations::{IN_BROADCAST_FENCE_BLOCKS, RESERVATION_MAX_AGE_BLOCKS}; use crate::{PlatformWalletError, SignedCoreTransaction}; /// Builds a testnet `CoreWallet` over the shared funded fixture and a @@ -753,7 +798,7 @@ mod tests { } // Let the dispatch complete: the send succeeds (the age check passed - // before the suspension) and the pin is dropped with it. + // before the suspension). release.wait().await; let sent = dispatcher.await.expect("dispatcher task"); assert!( @@ -761,18 +806,127 @@ mod tests { "the pinned dispatch itself must complete, got {sent:?}" ); - // Pin lifted: a new build may take the input again. (The mock manager - // runs no mempool pipeline; in production the dispatched transaction's - // inputs would be marked spent by processing moments later — the - // assertion here is only that the pin's lifetime ended with the - // dispatch.) + // A new build may take the input again — but note WHY, because it is + // no longer "the pin lifted with the dispatch". The dispatch converted + // its pin into a pending-spend fence bounded at + // `dispatch_height + IN_BROADCAST_FENCE_BLOCKS`, and the catch-up above + // raced 48 blocks past the reservation stamp — well beyond that bound — + // so the fence is already lapsed here. The retained fence itself, and + // the bound it lapses at, are covered by + // `dispatched_input_stays_fenced_after_the_broadcaster_returns`. + assert!( + stamped + RESERVATION_MAX_AGE_BLOCKS + 48 + >= (stamped + RESERVATION_MAX_AGE_BLOCKS - 1) + IN_BROADCAST_FENCE_BLOCKS, + "this test's catch-up must outrun the pending-spend bound for the \ + assertion below to be about the pin, not the fence" + ); let after = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; let after = after.unwrap_or_else(|error| { - panic!("the pin must lift once the dispatch returns, got {error:?}") + panic!("the dispatching pin must lift once the dispatch returns, got {error:?}") }); core.abandon_transaction(&after).await; } + /// `dashpay/platform#4309`: THE RACE THE DISPATCHING PIN ALONE LEFT OPEN. + /// The broadcaster returning is not the spend being observed. The mock + /// manager here runs no mempool pipeline, which is precisely the + /// `DapiBroadcaster` shape — `broadcast` awaits `sdk.execute` and injects + /// nothing into this wallet's state — so at dispatch return the input is + /// still in the selectable set while the transaction is in flight. With the + /// pin dropped at that point, a competing build re-selected it immediately + /// (the previous revision of the test above asserted exactly that). The + /// pending-spend fence keeps it out until + /// `IN_BROADCAST_FENCE_BLOCKS` past the dispatch height, and no longer: + /// a never-observed transaction must not strand its inputs forever. + /// + /// Heights are chosen so the reservation is provably swept while the fence + /// still stands — the state pre-fix was "unreserved AND unfenced". + #[tokio::test] + async fn dispatched_input_stays_fenced_after_the_broadcaster_returns() { + let (core, signer, outputs) = funded_core_wallet( + StandardAccountType::BIP44Account, + Arc::new(AlwaysOkBroadcaster), + ) + .await; + let stamped = core + .last_processed_height() + .await + .expect("last processed height"); + let finalized = finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + + // Dispatch at the OLDEST height the age guard still admits — one below + // `RESERVATION_MAX_AGE_BLOCKS`. That is what separates the two clocks: + // the reservation's TTL runs from `stamped`, the fence's bound from + // here, so there is a window in which the reservation is swept and only + // the fence protects the input. (A handle sitting between finalize and + // broadcast is exactly how that gap arises in production.) + let dispatch_height = stamped + RESERVATION_MAX_AGE_BLOCKS - 1; + advance_processed_height(&core, dispatch_height).await; + assert!(core + .broadcast_finalized_transaction(&finalized) + .await + .is_ok()); + + // Catch-up past key-wallet's 24-block reservation TTL (measured from + // the reservation stamp), so the funding reservation is swept and the + // input returns to the selectable pool — but still short of the fence's + // dispatch-anchored bound. The fence is now the ONLY thing holding it; + // pre-fix this window was unreserved AND unfenced. + let swept_but_fenced = stamped + IN_BROADCAST_FENCE_BLOCKS + 4; + assert!( + swept_but_fenced >= stamped + 24 + && swept_but_fenced < dispatch_height + IN_BROADCAST_FENCE_BLOCKS, + "the probe height must be past key-wallet's reservation TTL and below the fence bound" + ); + advance_processed_height(&core, swept_but_fenced).await; + let racing = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + match racing { + Err(PlatformWalletError::TransactionBuild(message)) => assert!( + message.contains("mid-broadcast"), + "the post-dispatch refusal must name the in-flight broadcast, got: {message}" + ), + other => panic!( + "an input handed to the network must stay fenced after the \ + broadcaster returns, got {other:?}" + ), + } + + // At the bound the fence lapses and the input is selectable again. + advance_processed_height(&core, dispatch_height + IN_BROADCAST_FENCE_BLOCKS).await; + let after = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + let after = after + .unwrap_or_else(|error| panic!("the fence must lapse at its bound, got {error:?}")); + core.abandon_transaction(&after).await; + } + + /// The rejection path is the one outcome that frees the inputs at dispatch + /// return: Core definitively did not accept the transaction, so there is + /// nothing on the wire to fence against and an immediate rebuild must + /// reselect. No pending-spend fence may be installed. + #[tokio::test] + async fn definitively_rejected_dispatch_installs_no_fence() { + let (core, signer, outputs) = funded_core_wallet( + StandardAccountType::BIP44Account, + Arc::new(RejectFirstBroadcaster::new()), + ) + .await; + let finalized = finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + + let sent = core.broadcast_finalized_transaction(&finalized).await; + assert!( + matches!(sent, Err(PlatformWalletError::TransactionBroadcast(_))), + "the fixture must reject the first send, got {sent:?}" + ); + + // Rejection released the reservation AND installed no fence, so the + // rebuild succeeds at the very next height with no waiting. + let rebuilt = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + let rebuilt = rebuilt.unwrap_or_else(|error| { + panic!("a definitively rejected send must leave its inputs free, got {error:?}") + }); + core.abandon_transaction(&rebuilt).await; + } + /// A pre-send broadcast rejection must release the UTXO reservation taken /// while building the transaction, so an immediate retry can reselect those /// inputs instead of failing with spurious insufficient funds until the TTL diff --git a/packages/rs-platform-wallet/src/wallet/core/generation.rs b/packages/rs-platform-wallet/src/wallet/core/generation.rs index e9afe8d200b..6f91d8fa487 100644 --- a/packages/rs-platform-wallet/src/wallet/core/generation.rs +++ b/packages/rs-platform-wallet/src/wallet/core/generation.rs @@ -63,8 +63,8 @@ pub struct WalletGeneration { /// a retry loop, and the guard must outlive the loop iteration that produced /// the `Arc` it came from. lifecycle: Arc>, - /// Outpoints currently **pinned by an in-flight broadcast dispatch** - /// ([`pin_in_broadcast`](Self::pin_in_broadcast)), counted per outpoint. + /// Outpoints currently fenced against re-selection because a broadcast + /// dispatch owns them ([`pin_in_broadcast`](Self::pin_in_broadcast)). /// /// The guarded dispatch (`CoreWallet::dispatch_unexpired`) proves under the /// wallet-manager read guard that a finalized transaction's funding @@ -75,28 +75,72 @@ pub struct WalletGeneration { /// `ReservationSet` TTL sweeps the reservation and a concurrent build /// re-reserves the very same inputs — the dispatch would then put an /// already-signed transaction on the wire against inputs reassigned to - /// another payment. This map is the non-expiring pin that outlives the - /// dropped guard: every coin-selection choke point - /// (`CoreWallet::finalize_transaction`, the contact-payment build, the - /// asset-lock build) checks its freshly reserved selection against it — - /// still under the manager write lock, the same synchronization height - /// advancement and the TTL sweep run under — and refuses a build whose - /// selection picked a pinned input, closing the sweep + re-reserve window - /// for as long as the dispatch is in flight. + /// another payment. This map is the fence that outlives the dropped guard: + /// every coin-selection choke point (`CoreWallet::finalize_transaction`, the + /// contact-payment build, the asset-lock build) checks its freshly reserved + /// selection against it — still under the manager write lock, the same + /// synchronization height advancement and the TTL sweep run under — and + /// refuses a build whose selection picked a fenced input. /// - /// A *count* per outpoint rather than a set: `broadcast_finalized_transaction` - /// takes `&SignedCoreTransaction`, so a direct Rust caller can dispatch the - /// same transaction twice concurrently (idempotent on the wire — same txid). - /// Counting keeps the pin held until the LAST dispatch returns instead of - /// letting the first completion unpin the other's in-flight send. + /// # Two phases, because dispatch return is not "the spend is safe" + /// + /// [`InBroadcastFence`] holds both phases per outpoint: + /// + /// * **dispatching** — a counted, non-expiring pin, live from check-and-pin + /// until the broadcaster returns. + /// * **pending-spend** — a height-bounded fence installed *when the + /// broadcaster returns anything other than a definitive pre-send + /// rejection*, i.e. when the transaction may be on the network. + /// + /// The second phase exists because dispatch returning does not mean the + /// wallet has observed the spend. `SpvBroadcaster` injects the transaction + /// into dash-spv's local mempool pipeline, so its inputs leave this wallet's + /// selectable set within milliseconds — but `DapiBroadcaster::broadcast` only + /// awaits `sdk.execute` and performs no local injection at all, so both an + /// accepted response and an ambiguous `MaybeSent` return with the input still + /// selectable here while the transaction is in flight. Dropping the fence at + /// dispatch return would therefore reopen, on the DAPI path, exactly the + /// sweep + re-select race the pin was added to close + /// (`dashpay/platform#4309`). + /// + /// A *count* for the dispatching phase rather than a set: + /// `broadcast_finalized_transaction` takes `&SignedCoreTransaction`, so a + /// direct Rust caller can dispatch the same transaction twice concurrently + /// (idempotent on the wire — same txid). Counting keeps the pin held until + /// the LAST dispatch returns instead of letting the first completion unpin + /// the other's in-flight send. /// /// A `std::sync::Mutex` like key-wallet's own `ReservationSet`: critical /// sections are a few hash operations, never held across an await, and the - /// sync lock is what lets [`InBroadcastPin::drop`] unpin from a plain - /// (non-async) `Drop` — which is also what makes the pin + /// sync lock is what lets [`InBroadcastPin::drop`] settle the fence from a + /// plain (non-async) `Drop` — which is also what makes the pin /// cancellation-safe when the dispatching future is dropped mid-await. - /// Never persisted: after a restart nothing is mid-dispatch. - in_broadcast: Mutex>, + /// Never persisted: after a restart nothing is mid-dispatch, and a + /// transaction that actually landed is reconciled by sync. + in_broadcast: Mutex>, +} + +/// One outpoint's broadcast fence — see `WalletGeneration::in_broadcast`. +#[derive(Debug, Default)] +struct InBroadcastFence { + /// Dispatches currently *inside* the broadcaster await for this outpoint. + /// Non-expiring while non-zero: a suspended dispatch keeps its inputs + /// fenced no matter how far catch-up advances the clock. + dispatching: u32, + /// `last_processed_height` at which the pending-spend phase lapses, set when + /// a dispatch returns without a definitive pre-send rejection. `None` means + /// no dispatch has handed this outpoint to the network. + pending_until: Option, +} + +impl InBroadcastFence { + /// Whether this fence still blocks re-selection at `current_height`. + fn blocks(&self, current_height: u32) -> bool { + self.dispatching > 0 + || self + .pending_until + .is_some_and(|until| current_height < until) + } } impl Default for WalletGeneration { @@ -171,7 +215,7 @@ impl WalletGeneration { /// is a plain count map with no invariant a partial write could break, and /// panicking here would strand every later build and dispatch on this /// generation. (Same policy as key-wallet's `ReservationSet`.) - fn in_broadcast_lock(&self) -> MutexGuard<'_, HashMap> { + fn in_broadcast_lock(&self) -> MutexGuard<'_, HashMap> { self.in_broadcast .lock() .unwrap_or_else(PoisonError::into_inner) @@ -192,17 +236,27 @@ impl WalletGeneration { /// broadcaster await the guard must not span (see the /// [`in_broadcast`](Self::in_broadcast) field docs for the full race). /// - /// The pin has **no TTL** — a suspended dispatch keeps its inputs fenced no - /// matter how far catch-up advances the clock — and is released only by - /// dropping the returned guard object, which happens even when the + /// The dispatching phase has **no TTL** — a suspended dispatch keeps its + /// inputs fenced no matter how far catch-up advances the clock — and ends + /// only when the returned guard is dropped, which happens even when the /// dispatching future is cancelled mid-await (`Drop` runs on unwind and on /// future drop alike). /// + /// `dispatch_height` is the `last_processed_height` sampled in the *same* + /// guarded section as the freshness check. It anchors the pending-spend + /// phase that [`InBroadcastPin::retain_pending_spend`] installs, so that + /// phase is measured from the moment the transaction was authorized to go + /// to the network rather than from the much older reservation stamp. + /// /// Callers pin on the generation currently REGISTERED in the manager /// (`PlatformWalletInfo::generation`), the same object the build-side /// conflict checks read, so the fence works even for a dispatch through a /// stale-generation handle. - pub(crate) fn pin_in_broadcast(self: &Arc, transaction: &Transaction) -> InBroadcastPin { + pub(crate) fn pin_in_broadcast( + self: &Arc, + transaction: &Transaction, + dispatch_height: u32, + ) -> InBroadcastPin { let outpoints: Vec = transaction .input .iter() @@ -211,29 +265,45 @@ impl WalletGeneration { { let mut pinned = self.in_broadcast_lock(); for outpoint in &outpoints { - *pinned.entry(*outpoint).or_insert(0) += 1; + pinned.entry(*outpoint).or_default().dispatching += 1; } } InBroadcastPin { generation: Arc::clone(self), outpoints, + dispatch_height, + retain_pending_spend: false, } } - /// The first of `transaction`'s inputs that is currently pinned by an - /// in-flight broadcast dispatch, or `None` when the selection is clear. + /// The first of `transaction`'s inputs that is currently fenced by a + /// broadcast dispatch, or `None` when the selection is clear. /// /// Called by every coin-selection choke point immediately after it built /// and reserved a selection, while it still holds the wallet-manager WRITE /// guard: a hit means this build's own selection swept an aged reservation - /// whose transaction is mid-dispatch and re-reserved its input — completing - /// the build would race that transaction on the wire, so the caller must - /// release its fresh reservation (exact under the still-held write guard) - /// and refuse the build. In the normal case a pinned input is still - /// *reserved* and never reaches selection at all; this check is the - /// backstop for exactly the post-sweep window. - pub(crate) fn in_broadcast_conflict(&self, transaction: &Transaction) -> Option { - let pinned = self.in_broadcast_lock(); + /// whose transaction is mid-dispatch (or already handed to the network) and + /// re-reserved its input — completing the build would race that transaction + /// on the wire, so the caller must release its fresh reservation (exact + /// under the still-held write guard) and refuse the build. In the normal + /// case a fenced input is still *reserved* and never reaches selection at + /// all; this check is the backstop for exactly the post-sweep window. + /// + /// `current_height` is the caller's `last_processed_height`, read under the + /// same write guard — the identical clock the pending-spend bound was + /// stamped against and the one key-wallet's TTL sweep runs on. + /// + /// Lapsed entries are reaped here rather than by a timer: this is the only + /// place the fence is consulted, so pruning on read keeps the map bounded by + /// the outpoints dispatched since the last build without any background + /// task. + pub(crate) fn in_broadcast_conflict( + &self, + transaction: &Transaction, + current_height: u32, + ) -> Option { + let mut pinned = self.in_broadcast_lock(); + pinned.retain(|_, fence| fence.blocks(current_height)); transaction .input .iter() @@ -241,36 +311,80 @@ impl WalletGeneration { .find(|outpoint| pinned.contains_key(outpoint)) } - /// Drop one pin count for each of `outpoints` — the [`InBroadcastPin`] - /// release half of [`pin_in_broadcast`](Self::pin_in_broadcast). - fn unpin_in_broadcast(&self, outpoints: &[OutPoint]) { + /// End one dispatch's hold on `outpoints` — the [`InBroadcastPin`] release + /// half of [`pin_in_broadcast`](Self::pin_in_broadcast). + /// + /// `pending_until` is `Some(height)` when that dispatch reached the network + /// (anything but a definitive pre-send rejection): the dispatching count + /// drops but the outpoint stays fenced until `height`. It is `None` for a + /// rejection, which frees the outpoint immediately — the transaction is + /// provably not on the wire, and the caller releases its reservation in the + /// same breath so an immediate rebuild can reselect. + fn unpin_in_broadcast(&self, outpoints: &[OutPoint], pending_until: Option) { let mut pinned = self.in_broadcast_lock(); for outpoint in outpoints { - match pinned.get_mut(outpoint) { - Some(count) if *count > 1 => *count -= 1, - Some(_) => { - pinned.remove(outpoint); - } - // Unreachable by construction — every pin increments before its - // guard can decrement — but a miscount must not panic a Drop. - None => debug_assert!(false, "unpin of an outpoint that was never pinned"), + let Some(fence) = pinned.get_mut(outpoint) else { + // Unreachable by construction — every pin inserts before its + // guard can remove — but a miscount must not panic a Drop. + debug_assert!(false, "unpin of an outpoint that was never pinned"); + continue; + }; + fence.dispatching = fence.dispatching.saturating_sub(1); + if let Some(until) = pending_until { + // Never shorten a fence another dispatch already extended: two + // concurrent dispatches of the same transaction must both be + // covered, so the later bound wins. + fence.pending_until = Some(fence.pending_until.map_or(until, |cur| cur.max(until))); + } + if fence.dispatching == 0 && fence.pending_until.is_none() { + pinned.remove(outpoint); } } } } -/// RAII guard for one dispatch's in-broadcast input pins — see +/// RAII guard for one dispatch's in-broadcast input fence — see /// [`WalletGeneration::pin_in_broadcast`]. Dropping it (normal return, -/// unwind, or the dispatching future being cancelled mid-await) releases -/// exactly the pins that call took, count-wise, never another dispatch's. +/// unwind, or the dispatching future being cancelled mid-await) ends exactly +/// the dispatching hold that call took, count-wise, never another dispatch's. +/// +/// By default the drop frees the outpoints outright: a guard dropped without +/// [`retain_pending_spend`](Self::retain_pending_spend) means the transaction +/// never reached the network (a definitive pre-send rejection, or a cancelled +/// dispatch), so there is nothing on the wire to fence against. pub(crate) struct InBroadcastPin { generation: Arc, outpoints: Vec, + /// `last_processed_height` sampled in the guarded section that took this + /// pin — the anchor for the pending-spend bound. + dispatch_height: u32, + /// Set by [`retain_pending_spend`](Self::retain_pending_spend). + retain_pending_spend: bool, +} + +impl InBroadcastPin { + /// Convert this pin, on drop, into a pending-spend fence lasting + /// [`IN_BROADCAST_FENCE_BLOCKS`](crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS) + /// blocks past the height the dispatch was authorized at. + /// + /// Called once the broadcaster has returned anything other than a + /// definitive pre-send rejection — i.e. once the transaction may be on the + /// network but this wallet has not necessarily observed the spend yet. See + /// the `WalletGeneration::in_broadcast` field docs for why dispatch return + /// is not, by itself, safe. + pub(crate) fn retain_pending_spend(&mut self) { + self.retain_pending_spend = true; + } } impl Drop for InBroadcastPin { fn drop(&mut self) { - self.generation.unpin_in_broadcast(&self.outpoints); + let pending_until = self.retain_pending_spend.then(|| { + self.dispatch_height + .saturating_add(crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS) + }); + self.generation + .unpin_in_broadcast(&self.outpoints, pending_until); } } @@ -289,6 +403,11 @@ mod tests { use dashcore::{OutPoint, Transaction, TxIn, Txid}; use super::WalletGeneration; + use crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS; + + /// The height every test pins at, so a fence installed by + /// `retain_pending_spend` lapses at `DISPATCH_HEIGHT + IN_BROADCAST_FENCE_BLOCKS`. + const DISPATCH_HEIGHT: u32 = 1_000; /// A minimal transaction spending exactly the given outpoints — the only /// part of a transaction the pin machinery reads. @@ -313,9 +432,11 @@ mod tests { } /// A held pin flags every input of the pinned transaction — and only - /// those — and dropping the pin clears the conflict. This is the RAII - /// contract the dispatch relies on for cancellation-safety: a dispatching - /// future dropped mid-await unpins exactly the same way. + /// those — and dropping a pin that was NOT retained clears the conflict. + /// This is the RAII contract the dispatch relies on for + /// cancellation-safety: a dispatching future dropped mid-await never + /// reaches `retain_pending_spend`, so it unpins outright, exactly as a + /// definitive pre-send rejection does. #[test] fn pin_flags_inputs_until_dropped() { let generation = Arc::new(WalletGeneration::new()); @@ -323,27 +444,131 @@ mod tests { let b = outpoint(0x02, 1); let unrelated = outpoint(0x03, 0); - let pin = generation.pin_in_broadcast(&spending(&[a, b])); + let pin = generation.pin_in_broadcast(&spending(&[a, b]), DISPATCH_HEIGHT); // Both pinned inputs conflict; an unrelated selection does not. - assert_eq!(generation.in_broadcast_conflict(&spending(&[a])), Some(a)); - assert_eq!(generation.in_broadcast_conflict(&spending(&[b])), Some(b)); assert_eq!( - generation.in_broadcast_conflict(&spending(&[unrelated, a])), + generation.in_broadcast_conflict(&spending(&[a]), DISPATCH_HEIGHT), + Some(a) + ); + assert_eq!( + generation.in_broadcast_conflict(&spending(&[b]), DISPATCH_HEIGHT), + Some(b) + ); + assert_eq!( + generation.in_broadcast_conflict(&spending(&[unrelated, a]), DISPATCH_HEIGHT), Some(a), "a mixed selection must surface its pinned input" ); assert_eq!( - generation.in_broadcast_conflict(&spending(&[unrelated])), + generation.in_broadcast_conflict(&spending(&[unrelated]), DISPATCH_HEIGHT), None ); drop(pin); assert_eq!( - generation.in_broadcast_conflict(&spending(&[a, b])), + generation.in_broadcast_conflict(&spending(&[a, b]), DISPATCH_HEIGHT), + None, + "dropping an unretained pin must clear the conflict" + ); + } + + /// The dispatching pin has no TTL: however far catch-up advances the + /// clock while the broadcaster is suspended, the inputs stay fenced. + #[test] + fn dispatching_pin_never_expires() { + let generation = Arc::new(WalletGeneration::new()); + let a = outpoint(0x05, 0); + let tx = spending(&[a]); + + let _pin = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT); + + assert_eq!( + generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT + 10_000), + Some(a), + "a suspended dispatch must keep its inputs fenced at any height" + ); + } + + /// `dashpay/platform#4309`: a dispatch that reached the network keeps its + /// inputs fenced AFTER the broadcaster returns — the DAPI path performs no + /// local mempool injection, so the wallet has not observed the spend yet + /// and the outpoint would otherwise be immediately re-selectable. The + /// fence lapses only once the clock has advanced a full + /// `IN_BROADCAST_FENCE_BLOCKS` past the dispatch height. + #[test] + fn retained_pin_fences_past_dispatch_until_the_bound() { + let generation = Arc::new(WalletGeneration::new()); + let a = outpoint(0x30, 0); + let tx = spending(&[a]); + + let mut pin = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT); + pin.retain_pending_spend(); + drop(pin); + + assert_eq!( + generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT), + Some(a), + "the fence must survive the dispatch return" + ); + assert_eq!( + generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT + IN_BROADCAST_FENCE_BLOCKS - 1), + Some(a), + "one block below the bound must still fence" + ); + assert_eq!( + generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT + IN_BROADCAST_FENCE_BLOCKS), + None, + "exactly at the bound the fence lapses" + ); + } + + /// A lapsed fence is reaped, not merely ignored: the read that observes + /// the lapse is what prunes the entry, so the map cannot grow without + /// bound across dispatches. + #[test] + fn lapsed_fences_are_reaped_on_read() { + let generation = Arc::new(WalletGeneration::new()); + let a = outpoint(0x31, 0); + let unrelated = outpoint(0x32, 0); + + let mut pin = generation.pin_in_broadcast(&spending(&[a]), DISPATCH_HEIGHT); + pin.retain_pending_spend(); + drop(pin); + assert_eq!(generation.in_broadcast_lock().len(), 1); + + // A read past the bound — about an unrelated selection — still reaps. + assert_eq!( + generation.in_broadcast_conflict( + &spending(&[unrelated]), + DISPATCH_HEIGHT + IN_BROADCAST_FENCE_BLOCKS + ), + None + ); + assert!( + generation.in_broadcast_lock().is_empty(), + "the lapsed entry must be pruned by the read that observed the lapse" + ); + } + + /// The rejection path is the ONLY one that frees inputs at dispatch + /// return, and it frees them completely — no residual pending-spend fence + /// keeps an immediate rebuild out. + #[test] + fn rejected_dispatch_frees_the_input_immediately() { + let generation = Arc::new(WalletGeneration::new()); + let a = outpoint(0x33, 0); + let tx = spending(&[a]); + + // No `retain_pending_spend` — this models `BroadcastError::Rejected`. + drop(generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT)); + + assert_eq!( + generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT), None, - "dropping the pin must clear the conflict" + "a definitively rejected send must not fence its inputs" ); + assert!(generation.in_broadcast_lock().is_empty()); } /// Pins COUNT per outpoint: two concurrent dispatches of the same @@ -357,18 +582,45 @@ mod tests { let a = outpoint(0x10, 0); let tx = spending(&[a]); - let first = generation.pin_in_broadcast(&tx); - let second = generation.pin_in_broadcast(&tx); + let first = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT); + let second = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT); drop(first); assert_eq!( - generation.in_broadcast_conflict(&tx), + generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT), Some(a), "one dispatch still in flight must keep the outpoint fenced" ); drop(second); - assert_eq!(generation.in_broadcast_conflict(&tx), None); + assert_eq!(generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT), None); + } + + /// Two concurrent dispatches of the same transaction that BOTH reach the + /// network must leave the longer fence standing — a first completion at a + /// lower dispatch height must not shorten the second's protection. + #[test] + fn the_longer_pending_fence_wins() { + let generation = Arc::new(WalletGeneration::new()); + let a = outpoint(0x11, 0); + let tx = spending(&[a]); + + let mut early = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT); + let mut late = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT + 5); + late.retain_pending_spend(); + drop(late); + early.retain_pending_spend(); + drop(early); + + assert_eq!( + generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT + IN_BROADCAST_FENCE_BLOCKS), + Some(a), + "the later dispatch's bound must win over the earlier one's" + ); + assert_eq!( + generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT + 5 + IN_BROADCAST_FENCE_BLOCKS), + None + ); } /// Pins are per generation: a re-created wallet's fresh generation starts @@ -378,11 +630,11 @@ mod tests { fn pins_do_not_cross_generations() { let old_generation = Arc::new(WalletGeneration::new()); let a = outpoint(0x20, 0); - let _pin = old_generation.pin_in_broadcast(&spending(&[a])); + let _pin = old_generation.pin_in_broadcast(&spending(&[a]), DISPATCH_HEIGHT); let new_generation = Arc::new(WalletGeneration::new()); assert_eq!( - new_generation.in_broadcast_conflict(&spending(&[a])), + new_generation.in_broadcast_conflict(&spending(&[a]), DISPATCH_HEIGHT), None, "a fresh generation must not inherit the old generation's pins" ); diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 9aeaba9e99b..76f21957c93 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -431,7 +431,10 @@ impl CoreWallet { // (`WalletGeneration::pin_in_broadcast`). Still under the write // guard, so the check is atomic with our reservation and the // release is exact. - if let Some(pinned) = info.generation.in_broadcast_conflict(&unsigned) { + if let Some(pinned) = info + .generation + .in_broadcast_conflict(&unsigned, info.core_wallet.last_processed_height()) + { release_all!(offered_accounts, info.core_wallet.accounts, &unsigned); return Err(PlatformWalletError::TransactionBuild(format!( "selected input {pinned} is mid-broadcast by an in-flight dispatch; \ diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index b86761004a2..5651d77f65e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -1322,7 +1322,10 @@ impl DashPayView<'_, B> { // nothing no-op. Roll back the consumed payment address exactly // like the build-failure arm above — nothing was persisted or // broadcast. - if let Some(pinned) = info.generation.in_broadcast_conflict(&tx) { + if let Some(pinned) = info + .generation + .in_broadcast_conflict(&tx, info.core_wallet.last_processed_height()) + { for at in &offered_accounts { if let Some(managed) = info.core_wallet.accounts.funds_account_mut(at) { managed.release_reservation(&tx); diff --git a/packages/rs-platform-wallet/src/wallet/reservations.rs b/packages/rs-platform-wallet/src/wallet/reservations.rs index 3b924a76aea..c133aed7faf 100644 --- a/packages/rs-platform-wallet/src/wallet/reservations.rs +++ b/packages/rs-platform-wallet/src/wallet/reservations.rs @@ -50,6 +50,52 @@ use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; /// for `last_processed_height` to lag a few blocks behind the true tip. pub(crate) const RESERVATION_MAX_AGE_BLOCKS: u32 = 20; +/// How long, in `last_processed_height` blocks past **dispatch**, a transaction +/// that reached the network keeps its inputs fenced against re-selection by +/// [`WalletGeneration::pin_in_broadcast`](crate::wallet::core::WalletGeneration::pin_in_broadcast)'s +/// pending-spend phase. +/// +/// # Why a fence past dispatch is needed at all +/// +/// `SpvBroadcaster` injects the dispatched transaction into dash-spv's local +/// mempool pipeline, so on that path the wallet marks the inputs spent within +/// milliseconds of dispatch returning and they leave the selectable set on +/// their own. `DapiBroadcaster::broadcast` does no such injection — it awaits +/// `sdk.execute` and returns — so on the DAPI path an accepted response *and* +/// an ambiguous `MaybeSent` both return with the inputs still selectable here +/// while the transaction is in flight. Ending the fence at dispatch return +/// therefore reopens the sweep + re-select race on that path +/// (`dashpay/platform#4309`): key-wallet's `ReservationSet` TTL is stamped at +/// *build* time, so a handle that sat between `finalize` and broadcast can be +/// swept the instant the next selection runs. +/// +/// # Why exactly key-wallet's TTL, re-anchored at dispatch +/// +/// The correct fix would be to renew the underlying reservation at dispatch so +/// its TTL runs from the moment the transaction actually went to the network; +/// key-wallet exposes no such primitive at the pinned revision (`ReservationSet` +/// and its `RESERVATION_TTL_BLOCKS` are private). This constant is that renewal +/// implemented one layer up: **24, key-wallet's own `RESERVATION_TTL_BLOCKS`** +/// (~1 h at the mainnet block target), measured from dispatch instead of from +/// the build. The inputs are then continuously protected — by the reservation +/// until its build-anchored TTL, then by this fence — for a full TTL past the +/// moment they were actually committed to the network, which is the point the +/// TTL was always meant to be measured from. Coupled by convention, exactly as +/// [`RESERVATION_MAX_AGE_BLOCKS`] above is: if key-wallet's TTL changes, change +/// this in lockstep. +/// +/// # Why it must lapse +/// +/// A fenced outpoint that the wallet has already observed as spent never +/// reaches a selection in the first place, so in the common case this bound is +/// never consulted — the fence goes inert on its own. The bound exists for the +/// transaction that is *never* observed (dropped from mempool for fee or +/// conflict): its reservation is already gone at TTL, and a non-expiring fence +/// would strand those funds permanently with nothing able to clear it. Lapsing +/// at the same TTL leaves the residual exposure identical to the one +/// key-wallet's reservation TTL already accepts, and no larger. +pub(crate) const IN_BROADCAST_FENCE_BLOCKS: u32 = 24; + /// Whether a reservation stamped at `registered_height` is too old to act on at /// `current_height` (see [`RESERVATION_MAX_AGE_BLOCKS`]). The registration /// height is mandatory on both surfaces — it is derived from the finalized From 6cb9cf793382a166861e32acc3ed8e541bf784d0 Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Thu, 13 Aug 2026 12:53:50 -0400 Subject: [PATCH 08/15] refactor(platform-wallet): anchor the broadcast fence on the checked height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dispatch_unexpired` sampled `last_processed_height` twice under the same manager read guard — once for the freshness check, once for the pin's anchor. Both reads are identical in practice, but the pin is now taken via `info.zip(height)` so the fence is anchored on the very value the check consumed and the two cannot drift apart. Co-Authored-By: Claude Opus 4.8 --- .../src/wallet/core/broadcast.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 51288900b1a..1d239f96c93 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -109,12 +109,14 @@ impl CoreWallet { // and freshness under this guard proves the reservation is still // ours to pin (see the method docs). The pin outlives the guard, // and — unless the send is definitively rejected — outlives the - // broadcaster return too, as a pending-spend fence anchored at the - // height sampled right here. - info.map(|info| { - info.generation - .pin_in_broadcast(transaction, info.core_wallet.last_processed_height()) - }) + // broadcaster return too, as a pending-spend fence. + // + // That fence is anchored on the SAME `height` the freshness check + // just consumed, not a fresh sample: the two must not be able to + // disagree, or the fence could be stamped against a clock the + // check never saw. + info.zip(height) + .map(|(info, height)| info.generation.pin_in_broadcast(transaction, height)) // Guard dropped here — holding it across the await starves the // SPV pipeline that must complete the wait; the pin, not the // guard, covers check-to-wire. From 89586f724851855ab6a17de7e143cfa1a1f265fe Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Fri, 14 Aug 2026 13:42:40 -0400 Subject: [PATCH 09/15] fix(platform-wallet): fence broadcast inputs by default, release only on rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dashpay/platform#4309 review: three blocking findings — "pin the reservation until initial network dispatch", "retain the input fence when dispatch outlives the reservation TTL", and "preserve the fence when a dispatched broadcast future is cancelled" — are one defect. `InBroadcastPin::drop` decided whether to fence from a flag set only AFTER `broadcaster.broadcast(...).await` returned, so the guard's default was "nothing reached the network". But every path the reviewer names stops INSIDE that await: the dispatching future cancelled, an unwind, or the broadcaster suspending before submission. None of them carries any information about whether the transaction was sent, and the old default freed the inputs there — letting an immediate reselection double-spend a transaction already on the wire. Invert it. The pin now fences by default and `release_pending_spend()` (replacing `retain_pending_spend()`) is called ONLY on `BroadcastError::Rejected`, the one outcome that proves nothing was sent. Absence of evidence that a send happened is not evidence that it did not, so the fence survives every exit except the proving one. The bound is unchanged: IN_BROADCAST_FENCE_BLOCKS past dispatch height. This reverses a documented intent — the old test doc asserted a cancelled dispatch "unpins outright, exactly as a definitive pre-send rejection does". That equivalence is the bug; doc rewritten to match. Tests: `dropping_an_unreleased_pin_keeps_the_fence` reproduces the cancellation case and pins the bound with a negative control (the fence lapses at the bound, it is not permanent). Existing rejection/counting tests now release explicitly. 689 platform-wallet lib tests pass. --- .../src/wallet/core/broadcast.rs | 17 ++- .../src/wallet/core/generation.rs | 130 +++++++++++++----- 2 files changed, 104 insertions(+), 43 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 1d239f96c93..84c45d6c5cb 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -122,16 +122,21 @@ impl CoreWallet { // guard, covers check-to-wire. }; let outcome = self.broadcaster.broadcast(transaction).await; - // Retain the fence for everything except a definitive pre-send - // rejection: only `Rejected` proves the transaction is not on the - // network, so only `Rejected` may free the inputs at dispatch return. - // An ambiguous `MaybeSent` is precisely the case that must stay fenced. - if !matches!( + // The pin already fences by default, so the inputs stay held on EVERY + // exit from the await above — including one this code never observes: + // the dispatching future being cancelled, or an unwind, mid-`broadcast`. + // Neither says anything about whether the transaction reached the + // network, and freeing the inputs there lets an immediate reselection + // double-spend a transaction already on the wire (`dashpay/platform#4309`). + // + // Only a definitive pre-send rejection proves nothing was sent, so it is + // the one outcome that releases. An ambiguous `MaybeSent` stays fenced. + if matches!( outcome, Err(crate::broadcaster::BroadcastError::Rejected { .. }) ) { if let Some(pin) = in_broadcast_pin.as_mut() { - pin.retain_pending_spend(); + pin.release_pending_spend(); } } drop(in_broadcast_pin); diff --git a/packages/rs-platform-wallet/src/wallet/core/generation.rs b/packages/rs-platform-wallet/src/wallet/core/generation.rs index 6f91d8fa487..69c20c1e5d3 100644 --- a/packages/rs-platform-wallet/src/wallet/core/generation.rs +++ b/packages/rs-platform-wallet/src/wallet/core/generation.rs @@ -272,7 +272,9 @@ impl WalletGeneration { generation: Arc::clone(self), outpoints, dispatch_height, - retain_pending_spend: false, + // Fenced by default; only a definitive pre-send rejection releases + // it. See the `InBroadcastPin` type docs (`dashpay/platform#4309`). + retain_pending_spend: true, } } @@ -348,32 +350,42 @@ impl WalletGeneration { /// unwind, or the dispatching future being cancelled mid-await) ends exactly /// the dispatching hold that call took, count-wise, never another dispatch's. /// -/// By default the drop frees the outpoints outright: a guard dropped without -/// [`retain_pending_spend`](Self::retain_pending_spend) means the transaction -/// never reached the network (a definitive pre-send rejection, or a cancelled -/// dispatch), so there is nothing on the wire to fence against. +/// The drop fences the outpoints by DEFAULT, as a pending spend. Only +/// [`release_pending_spend`](Self::release_pending_spend) — called on a +/// definitive pre-send rejection, the one outcome that PROVES the transaction +/// is not on the wire — frees them outright. +/// +/// The default is deliberately the conservative one (`dashpay/platform#4309`). +/// This guard's drop runs on paths that carry no information about whether the +/// transaction was sent: the dispatching future cancelled mid-await, an unwind, +/// or a suspension inside the broadcaster before submission. Treating those +/// like a rejection — the previous behaviour — frees inputs that may already be +/// spent on the network, so an immediate reselection double-spends them. Absence +/// of evidence that a send happened is not evidence that it did not, so the +/// fence must survive every exit except the one that proves otherwise. pub(crate) struct InBroadcastPin { generation: Arc, outpoints: Vec, /// `last_processed_height` sampled in the guarded section that took this /// pin — the anchor for the pending-spend bound. dispatch_height: u32, - /// Set by [`retain_pending_spend`](Self::retain_pending_spend). + /// Starts `true`; cleared only by + /// [`release_pending_spend`](Self::release_pending_spend). retain_pending_spend: bool, } impl InBroadcastPin { - /// Convert this pin, on drop, into a pending-spend fence lasting - /// [`IN_BROADCAST_FENCE_BLOCKS`](crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS) - /// blocks past the height the dispatch was authorized at. + /// Free the outpoints outright on drop, instead of leaving the pending-spend + /// fence this pin installs by default. /// - /// Called once the broadcaster has returned anything other than a - /// definitive pre-send rejection — i.e. once the transaction may be on the - /// network but this wallet has not necessarily observed the spend yet. See - /// the `WalletGeneration::in_broadcast` field docs for why dispatch return - /// is not, by itself, safe. - pub(crate) fn retain_pending_spend(&mut self) { - self.retain_pending_spend = true; + /// Call ONLY on a definitive pre-send rejection — the single outcome that + /// proves the transaction never reached the network, so there is nothing on + /// the wire to fence against and an immediate retry may reselect the inputs. + /// An ambiguous outcome, a cancellation, or an unwind must NOT call this: + /// see the type docs and the `WalletGeneration::in_broadcast` field docs for + /// why dispatch return is not, by itself, safe. + pub(crate) fn release_pending_spend(&mut self) { + self.retain_pending_spend = false; } } @@ -432,11 +444,10 @@ mod tests { } /// A held pin flags every input of the pinned transaction — and only - /// those — and dropping a pin that was NOT retained clears the conflict. - /// This is the RAII contract the dispatch relies on for - /// cancellation-safety: a dispatching future dropped mid-await never - /// reaches `retain_pending_spend`, so it unpins outright, exactly as a - /// definitive pre-send rejection does. + /// those — and dropping a pin whose fence was explicitly RELEASED clears + /// the conflict. Release models the one outcome that proves nothing was + /// sent: a definitive pre-send rejection. Every other exit keeps the fence + /// (see [`dropping_an_unreleased_pin_keeps_the_fence`]). #[test] fn pin_flags_inputs_until_dropped() { let generation = Arc::new(WalletGeneration::new()); @@ -444,7 +455,7 @@ mod tests { let b = outpoint(0x02, 1); let unrelated = outpoint(0x03, 0); - let pin = generation.pin_in_broadcast(&spending(&[a, b]), DISPATCH_HEIGHT); + let mut pin = generation.pin_in_broadcast(&spending(&[a, b]), DISPATCH_HEIGHT); // Both pinned inputs conflict; an unrelated selection does not. assert_eq!( @@ -465,11 +476,54 @@ mod tests { None ); + // A definitive pre-send rejection: nothing is on the wire, so the + // inputs are free again the moment the guard drops. + pin.release_pending_spend(); drop(pin); assert_eq!( generation.in_broadcast_conflict(&spending(&[a, b]), DISPATCH_HEIGHT), None, - "dropping an unretained pin must clear the conflict" + "dropping a released pin must clear the conflict" + ); + } + + /// `dashpay/platform#4309`: the paths this guard's drop actually runs on — + /// the dispatching future cancelled mid-await, or an unwind — carry NO + /// information about whether the transaction reached the network. The + /// previous default freed the inputs there, so an immediate reselection + /// could double-spend a transaction already on the wire. Dropping without + /// an explicit release must therefore leave the pending-spend fence + /// standing, exactly as an ambiguous `MaybeSent` does. + #[test] + fn dropping_an_unreleased_pin_keeps_the_fence() { + let generation = Arc::new(WalletGeneration::new()); + let a = outpoint(0x07, 0); + let tx = spending(&[a]); + + // No `release_pending_spend()` — models a cancelled dispatch. + drop(generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT)); + + assert_eq!( + generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT), + Some(a), + "a cancelled dispatch must NOT free inputs that may be on the wire" + ); + assert_eq!( + generation.in_broadcast_conflict( + &tx, + DISPATCH_HEIGHT + crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS - 1 + ), + Some(a), + "the fence must hold for the full pending-spend bound" + ); + // Negative control: the fence is bounded, not permanent. + assert_eq!( + generation.in_broadcast_conflict( + &tx, + DISPATCH_HEIGHT + crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS + ), + None, + "the fence must lapse once the bound is reached" ); } @@ -502,9 +556,8 @@ mod tests { let a = outpoint(0x30, 0); let tx = spending(&[a]); - let mut pin = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT); - pin.retain_pending_spend(); - drop(pin); + // Fenced by default now — no explicit retain needed. + drop(generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT)); assert_eq!( generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT), @@ -532,9 +585,7 @@ mod tests { let a = outpoint(0x31, 0); let unrelated = outpoint(0x32, 0); - let mut pin = generation.pin_in_broadcast(&spending(&[a]), DISPATCH_HEIGHT); - pin.retain_pending_spend(); - drop(pin); + drop(generation.pin_in_broadcast(&spending(&[a]), DISPATCH_HEIGHT)); assert_eq!(generation.in_broadcast_lock().len(), 1); // A read past the bound — about an unrelated selection — still reaps. @@ -560,8 +611,11 @@ mod tests { let a = outpoint(0x33, 0); let tx = spending(&[a]); - // No `retain_pending_spend` — this models `BroadcastError::Rejected`. - drop(generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT)); + // An explicit release — this models `BroadcastError::Rejected`, the one + // outcome that proves the transaction never reached the network. + let mut pin = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT); + pin.release_pending_spend(); + drop(pin); assert_eq!( generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT), @@ -582,8 +636,12 @@ mod tests { let a = outpoint(0x10, 0); let tx = spending(&[a]); - let first = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT); - let second = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT); + // Both model a definitive rejection, so the count — not a leftover + // pending-spend fence — is what keeps the outpoint held. + let mut first = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT); + let mut second = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT); + first.release_pending_spend(); + second.release_pending_spend(); drop(first); assert_eq!( @@ -605,11 +663,9 @@ mod tests { let a = outpoint(0x11, 0); let tx = spending(&[a]); - let mut early = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT); - let mut late = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT + 5); - late.retain_pending_spend(); + let early = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT); + let late = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT + 5); drop(late); - early.retain_pending_spend(); drop(early); assert_eq!( From 58efacf4b4e49f86e7544257dfce049be1db072b Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Tue, 18 Aug 2026 21:59:37 -0400 Subject: [PATCH 10/15] fix(platform-wallet): anchor the broadcast fence after the await, not before MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pending-spend fence was bounded at `dispatch_height + IN_BROADCAST_FENCE_BLOCKS`, where `dispatch_height` was the `last_processed_height` sampled in the guarded section BEFORE `broadcaster.broadcast(...).await`. A broadcast await can suspend for minutes in the middle of chain catch-up — the ordinary mobile case. If the wallet advances a full fence interval inside that await, the fence installed when the send returns is ALREADY LAPSED: the next coin selection reaps it and can reselect an input of a transaction that reached the network. An expired fence is indistinguishable from no fence, so inverting the fence-by-default polarity did not close this — it made the guard retain a bound that had already run out. The same defect hits the cancellation path, where `Drop` is synchronous and cannot await the manager lock to read a fresh clock at all. Fix, in two halves: * Non-rejection outcomes (accepted and ambiguous `MaybeSent`) sample `last_processed_height` AFTER the broadcaster returns, while the dispatching phase of the pin is still held, and anchor a full interval on that reading. The dispatching hold covers the sampling, so the outpoints are never selectable between the return and the stamp. Check-vs-fence clock consistency is preserved by making this the single anchor: both readings come from the same `last_processed_height` under the manager guard, the clock `in_broadcast_conflict` and key-wallet's TTL sweep also run on. * A dispatch that never reaches that sample — future cancelled or unwound inside `broadcast`, or the wallet gone from the manager — settles UNANCHORED and blocks unconditionally until the first coin selection stamps it from its own height, read under the manager write guard. That is the drop-time clock, deferred to the first moment it is both readable and relevant: nothing can reach a fenced outpoint in between. Anchoring runs over every entry, so an unanchored fence is still bounded and reaped even when its own outpoint is never re-selected. `pin_in_broadcast` no longer accepts a height at all, which makes the stale anchor unrepresentable rather than merely corrected. Tests: `fence_anchors_after_the_await_so_catch_up_cannot_pre_expire_it` and `cancelled_dispatch_fence_survives_catch_up_during_the_await` drive the real `dispatch_unexpired` through a barrier-gated broadcaster with catch-up running inside the await; both fail against the pre-await anchor by returning a fully signed competing transaction spending the same outpoint. The tail of `in_broadcast_pin_blocks_reselection_until_dispatch_returns` asserted the buggy behaviour ("the pin lifted, so the input is selectable") and is rewritten. Generation-level: `cancelled_dispatch_fence_anchors_at_the_first_selection_not_at_dispatch`, `an_unanchored_fence_outlives_an_anchored_one`, `unanchored_fences_are_bounded_and_reaped_by_unrelated_reads`. 694 platform-wallet tests pass; fmt and clippy clean. Refs: dashpay/platform#4309 --- .../src/wallet/core/broadcast.rs | 339 ++++++++++++-- .../src/wallet/core/generation.rs | 418 +++++++++++++++--- .../src/wallet/reservations.rs | 22 +- 3 files changed, 689 insertions(+), 90 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 84c45d6c5cb..b09860f4244 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -73,18 +73,47 @@ impl CoreWallet { /// breath, so an instant rebuild can reselect the inputs. /// * **Anything else** (accepted, or an ambiguous `MaybeSent`) — the pin is /// converted to a pending-spend fence - /// (`InBroadcastPin::retain_pending_spend`) + /// ([`InBroadcastPin::anchor_pending_spend`](super::generation::InBroadcastPin::anchor_pending_spend)) /// lasting /// [`IN_BROADCAST_FENCE_BLOCKS`](crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS) - /// past the height this dispatch was authorized at. Once the wallet does - /// observe the spend the outpoint stops reaching selection at all, so the - /// fence goes inert without waiting for that bound; the bound is only the - /// backstop for a transaction that is never observed, and matches the TTL - /// the reservation itself would have had, re-anchored at dispatch. + /// past a `last_processed_height` sampled **after** the broadcaster + /// returned. Once the wallet does observe the spend the outpoint stops + /// reaching selection at all, so the fence goes inert without waiting for + /// that bound; the bound is only the backstop for a transaction that is + /// never observed, and matches the TTL the reservation itself would have + /// had, re-anchored at the moment the transaction actually went out. /// - /// Neither phase touches the wallet-manager lock, so nothing here can - /// starve the SPV mempool pipeline: the guard is still dropped before the - /// broadcaster await, exactly as it was. + /// # Why the fence anchor is sampled after the await, not before + /// + /// The height that authorizes the send and the height that bounds the fence + /// are the same *clock* but must not be the same *reading*. A broadcast + /// await can suspend for minutes in the middle of chain catch-up — the + /// ordinary mobile case — and if the wallet advances a full + /// `IN_BROADCAST_FENCE_BLOCKS` in that gap, a fence anchored on the + /// pre-await reading is already expired at the instant it is installed. The + /// next coin selection reaps it and may reselect an input of a transaction + /// that reached the network: an expired fence is indistinguishable from no + /// fence (`dashpay/platform#4309`). + /// + /// So the post-await sample is the SINGLE anchor for the installed fence, + /// which keeps the check-vs-fence clock consistency intact — both readings + /// come from `last_processed_height` under the manager guard, the same clock + /// `in_broadcast_conflict` and key-wallet's TTL sweep run on. The + /// **dispatching** phase stays held across that sampling, so there is no + /// window in which the outpoints are neither pinned nor fenced. + /// + /// A dispatch that never reaches the sample — the caller's future cancelled + /// or unwound inside `broadcast`, or the wallet removed from the manager — + /// settles its fence UNANCHORED, blocking unconditionally until the first + /// coin selection stamps it from its own height. That is the drop-time + /// clock, deferred to the first moment a synchronous `Drop` could act on it, + /// and the fence is unreachable in between. + /// + /// Neither phase touches the wallet-manager lock while the broadcaster is + /// running, so nothing here can starve the SPV mempool pipeline: the guard + /// is still dropped before the broadcaster await, exactly as it was, and the + /// post-await sample is a short read taken only once the send has returned — + /// the same point the callers below already retake manager locks at. /// /// A wallet no longer in the manager skips the pin (there is no /// registered generation to fence builds on — they cannot fund from a @@ -111,12 +140,10 @@ impl CoreWallet { // and — unless the send is definitively rejected — outlives the // broadcaster return too, as a pending-spend fence. // - // That fence is anchored on the SAME `height` the freshness check - // just consumed, not a fresh sample: the two must not be able to - // disagree, or the fence could be stamped against a clock the - // check never saw. - info.zip(height) - .map(|(info, height)| info.generation.pin_in_broadcast(transaction, height)) + // `height` is NOT handed to the pin. It anchors the freshness + // check and nothing else; the fence's own anchor is sampled after + // the await below, for the reason documented there. + info.map(|info| info.generation.pin_in_broadcast(transaction)) // Guard dropped here — holding it across the await starves the // SPV pipeline that must complete the wait; the pin, not the // guard, covers check-to-wire. @@ -138,6 +165,41 @@ impl CoreWallet { if let Some(pin) = in_broadcast_pin.as_mut() { pin.release_pending_spend(); } + } else if let Some(pin) = in_broadcast_pin.as_mut() { + // EVERY non-rejection outcome — accepted or ambiguous `MaybeSent` — + // anchors its fence on a height sampled HERE, after the broadcaster + // returned, and gets a full `IN_BROADCAST_FENCE_BLOCKS` from it. + // + // The pre-await sample cannot serve: `broadcast` can suspend for + // minutes mid-catch-up (mobile), and if the wallet advances a whole + // fence interval inside the await, a fence anchored back there is + // ALREADY LAPSED when it is installed — the next coin selection + // reaps it and can reselect an input of a transaction that may have + // reached the network. An expired fence is no fence + // (`dashpay/platform#4309`). + // + // Clock consistency — the property the pre-await anchor was chosen + // for — is kept by making THIS the single anchor: it is read from + // the same `last_processed_height` under the same manager guard the + // freshness check used, and `in_broadcast_conflict` compares against + // that same clock under the write lock. Nothing is stamped against a + // height no guarded section ever saw. + // + // The DISPATCHING phase is still held while this runs (the pin is + // alive; it settles at `drop` below), so the outpoints never become + // selectable between the broadcaster's return and the stamp — the + // manager read lock is awaited under that cover. A wallet that left + // the manager in the meantime yields no height and leaves the pin + // unanchored, which fences unconditionally until the next selection + // stamps it: strictly the safer side. + let height = { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .map(|info| info.core_wallet.last_processed_height()) + }; + if let Some(height) = height { + pin.anchor_pending_spend(height); + } } drop(in_broadcast_pin); GuardedDispatch::Sent(outcome) @@ -751,9 +813,13 @@ mod tests { /// competing finalize's own selection sweeps the dispatched build's /// reservation and re-selects its input — pre-pin, that build completed /// and raced the already-signed transaction on the wire. With the pin - /// held across the await, the competing finalize must be REFUSED, and - /// only after the dispatch returns (pin dropped, RAII) may a new build - /// take the input again. + /// held across the await, the competing finalize must be REFUSED. + /// + /// The tail then covers the HANDOFF from the dispatching pin to the + /// pending-spend fence, which is where the catch-up in this test matters a + /// second time: the fence is anchored on the height sampled AFTER the + /// broadcaster returned, so a 48-block advance *inside* the await extends + /// the protection instead of expiring it (`dashpay/platform#4309`). #[tokio::test] async fn in_broadcast_pin_blocks_reselection_until_dispatch_returns() { let entered = Arc::new(tokio::sync::Barrier::new(2)); @@ -813,23 +879,42 @@ mod tests { "the pinned dispatch itself must complete, got {sent:?}" ); - // A new build may take the input again — but note WHY, because it is - // no longer "the pin lifted with the dispatch". The dispatch converted - // its pin into a pending-spend fence bounded at - // `dispatch_height + IN_BROADCAST_FENCE_BLOCKS`, and the catch-up above - // raced 48 blocks past the reservation stamp — well beyond that bound — - // so the fence is already lapsed here. The retained fence itself, and - // the bound it lapses at, are covered by - // `dispatched_input_stays_fenced_after_the_broadcaster_returns`. + // The dispatching pin has now lifted — but the input is NOT selectable + // again, and the difference is the point of `dashpay/platform#4309`. + // + // This assertion used to read the other way: the fence was anchored on + // the height sampled BEFORE the await, the catch-up above raced 48 + // blocks past it, and the fence was therefore installed already lapsed, + // so a new build took the input immediately. That "the pin lifted" pass + // was the bug in test form — the transaction had reached the network. + // + // The fence is now anchored on the POST-await sample, which is the + // height this catch-up moved to, so the input stays held. + let post_await = stamped + RESERVATION_MAX_AGE_BLOCKS + 48; assert!( - stamped + RESERVATION_MAX_AGE_BLOCKS + 48 - >= (stamped + RESERVATION_MAX_AGE_BLOCKS - 1) + IN_BROADCAST_FENCE_BLOCKS, - "this test's catch-up must outrun the pending-spend bound for the \ - assertion below to be about the pin, not the fence" + post_await >= (stamped + RESERVATION_MAX_AGE_BLOCKS - 1) + IN_BROADCAST_FENCE_BLOCKS, + "this test's catch-up must outrun a PRE-await anchor, so the \ + assertion below distinguishes the two anchors" ); + let still_fenced = + try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + match still_fenced { + Err(PlatformWalletError::TransactionBuild(message)) => assert!( + message.contains("mid-broadcast"), + "the post-dispatch refusal must name the in-flight broadcast, got: {message}" + ), + other => panic!( + "catch-up during the await must not expire the fence before it \ + is installed, got {other:?}" + ), + } + + // And it lapses a full interval past that post-await anchor — bounded, + // not permanent. + advance_processed_height(&core, post_await + IN_BROADCAST_FENCE_BLOCKS).await; let after = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; let after = after.unwrap_or_else(|error| { - panic!("the dispatching pin must lift once the dispatch returns, got {error:?}") + panic!("the fence must lapse a bound past the post-await anchor, got {error:?}") }); core.abandon_transaction(&after).await; } @@ -906,6 +991,198 @@ mod tests { core.abandon_transaction(&after).await; } + /// `dashpay/platform#4309`: THE FENCE MUST NOT BE INSTALLED ALREADY EXPIRED. + /// + /// The scenario the reviewer identified, which fencing-by-default alone did + /// not close. The pending-spend bound used to be anchored on the + /// `last_processed_height` sampled BEFORE `broadcaster.broadcast().await`. + /// A broadcast await can suspend for minutes in the middle of chain + /// catch-up — routine on mobile — and if the wallet advances a full + /// `IN_BROADCAST_FENCE_BLOCKS` inside that await, the fence installed when + /// the send returns is ALREADY LAPSED. The very next coin selection reaps it + /// and reselects the input of a transaction that may be on the network: an + /// expired fence is indistinguishable from no fence at all. + /// + /// Here the broadcaster parks, catch-up runs a full fence interval plus 5 + /// blocks past the pre-await sample, and the send then returns `Ok` — the + /// accepted case, where the transaction is certainly on the wire. The input + /// must still be fenced afterwards, and the bound must run from the + /// POST-await height. + /// + /// The reservation is provably swept by then (the catch-up outruns + /// key-wallet's 24-block TTL measured from the build stamp), so the fence is + /// the only thing holding the input — pre-fix this window was unreserved AND + /// unfenced, and the competing finalize below returned `Ok`. + #[tokio::test] + async fn fence_anchors_after_the_await_so_catch_up_cannot_pre_expire_it() { + let entered = Arc::new(tokio::sync::Barrier::new(2)); + let release = Arc::new(tokio::sync::Barrier::new(2)); + let (core, signer, outputs) = funded_core_wallet( + StandardAccountType::BIP44Account, + Arc::new(GatedBroadcaster { + entered: Arc::clone(&entered), + release: Arc::clone(&release), + }), + ) + .await; + let stamped = core + .last_processed_height() + .await + .expect("last processed height"); + let finalized = finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + + // Dispatch while fresh: the pre-await sample is `stamped`, so that is + // the anchor the old code would have used. + let dispatcher = tokio::spawn({ + let core = core.clone(); + async move { core.broadcast_finalized_transaction(&finalized).await } + }); + entered.wait().await; + + // Catch-up INSIDE the await runs a whole fence interval past the + // pre-await sample. A fence anchored back there expires at + // `stamped + IN_BROADCAST_FENCE_BLOCKS`, which this height has passed. + let post_await = stamped + IN_BROADCAST_FENCE_BLOCKS + 5; + assert!( + post_await >= stamped + IN_BROADCAST_FENCE_BLOCKS, + "the catch-up must outrun a pre-await anchor for this test to \ + distinguish the two" + ); + assert!( + post_await >= stamped + 24, + "the catch-up must also outrun key-wallet's reservation TTL, so the \ + fence is the only thing holding the input" + ); + advance_processed_height(&core, post_await).await; + + // The send returns accepted — the transaction IS on the network. + release.wait().await; + let sent = dispatcher.await.expect("dispatcher task"); + assert!( + sent.is_ok(), + "the dispatch itself must succeed, got {sent:?}" + ); + + // Pre-fix: the fence was installed already lapsed and this build + // succeeded, constructing a double-spend of a transaction on the wire. + let racing = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + match racing { + Err(PlatformWalletError::TransactionBuild(message)) => assert!( + message.contains("mid-broadcast"), + "the refusal must name the in-flight broadcast, got: {message}" + ), + other => panic!( + "a fence anchored before the await expires during catch-up and \ + leaves the input reselectable, got {other:?}" + ), + } + + // The bound runs from the POST-await height: still fenced one below it, + // lapsed at it. Bounded protection, not a permanent hold. + advance_processed_height(&core, post_await + IN_BROADCAST_FENCE_BLOCKS - 1).await; + assert!( + matches!( + try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await, + Err(PlatformWalletError::TransactionBuild(_)) + ), + "one block below the post-await bound must still fence" + ); + advance_processed_height(&core, post_await + IN_BROADCAST_FENCE_BLOCKS).await; + let after = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + let after = after.unwrap_or_else(|error| { + panic!("the fence must lapse at the post-await bound, got {error:?}") + }); + core.abandon_transaction(&after).await; + } + + /// `dashpay/platform#4309`, the CANCELLATION twin of the test above. + /// + /// A caller wrapping the send in `timeout`/`select!` drops the dispatching + /// future mid-`broadcast`. That path reaches neither the release nor the + /// post-await height sample, and cancellation proves nothing: DAPI may have + /// delivered the request while awaiting its response, SPV may have + /// dispatched to peers while awaiting an echo or IS-lock. Retaining the + /// fence there is necessary but NOT sufficient — if the retained fence is + /// stamped from a height sampled before the await, the same catch-up that + /// made the caller time out has already expired it. + /// + /// So a cancelled dispatch settles its fence with NO bound, and the first + /// coin selection to consult it stamps a full interval from its own height, + /// read under the manager write guard. Here catch-up runs well past a fence + /// interval before the abort, and the input must still be refused. + #[tokio::test] + async fn cancelled_dispatch_fence_survives_catch_up_during_the_await() { + let entered = Arc::new(tokio::sync::Barrier::new(2)); + let release = Arc::new(tokio::sync::Barrier::new(2)); + let (core, signer, outputs) = funded_core_wallet( + StandardAccountType::BIP44Account, + Arc::new(GatedBroadcaster { + entered: Arc::clone(&entered), + release: Arc::clone(&release), + }), + ) + .await; + let stamped = core + .last_processed_height() + .await + .expect("last processed height"); + let finalized = finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + + let dispatcher = tokio::spawn({ + let core = core.clone(); + async move { core.broadcast_finalized_transaction(&finalized).await } + }); + // Parked inside `broadcast`: pin held, guard dropped, nothing decided. + entered.wait().await; + + // Catch-up past both key-wallet's reservation TTL and a whole fence + // interval measured from the pre-await sample. + let cancelled_at = stamped + IN_BROADCAST_FENCE_BLOCKS + 5; + advance_processed_height(&core, cancelled_at).await; + + // Cancel mid-await, exactly as `timeout`/`select!` would. Awaiting the + // handle guarantees the future — and with it `InBroadcastPin::drop` — + // has actually run before the assertions below. + dispatcher.abort(); + let cancelled = dispatcher.await; + assert!( + cancelled.is_err_and(|error| error.is_cancelled()), + "the dispatching future must have been cancelled mid-broadcast" + ); + + // Pre-fix: the retained fence carried the pre-await anchor and was + // already lapsed here, so this build reselected an input whose + // transaction may have been delivered. + let racing = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + match racing { + Err(PlatformWalletError::TransactionBuild(message)) => assert!( + message.contains("mid-broadcast"), + "the refusal must name the in-flight broadcast, got: {message}" + ), + other => panic!( + "a cancelled dispatch must not leave an already-expired fence, \ + got {other:?}" + ), + } + + // The build above is what anchored the fence, on the height it read. + // The interval runs from there, and then lapses. + advance_processed_height(&core, cancelled_at + IN_BROADCAST_FENCE_BLOCKS - 1).await; + assert!( + matches!( + try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await, + Err(PlatformWalletError::TransactionBuild(_)) + ), + "one block below the anchored bound must still fence" + ); + advance_processed_height(&core, cancelled_at + IN_BROADCAST_FENCE_BLOCKS).await; + let after = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + let after = after.unwrap_or_else(|error| { + panic!("a cancelled dispatch's fence must still lapse, got {error:?}") + }); + core.abandon_transaction(&after).await; + } + /// The rejection path is the one outcome that frees the inputs at dispatch /// return: Core definitively did not accept the transaction, so there is /// nothing on the wire to fence against and an immediate rebuild must diff --git a/packages/rs-platform-wallet/src/wallet/core/generation.rs b/packages/rs-platform-wallet/src/wallet/core/generation.rs index 69c20c1e5d3..7df93cac91c 100644 --- a/packages/rs-platform-wallet/src/wallet/core/generation.rs +++ b/packages/rs-platform-wallet/src/wallet/core/generation.rs @@ -87,7 +87,8 @@ pub struct WalletGeneration { /// [`InBroadcastFence`] holds both phases per outpoint: /// /// * **dispatching** — a counted, non-expiring pin, live from check-and-pin - /// until the broadcaster returns. + /// until the broadcaster returns *and* the post-return height sample that + /// anchors the next phase has been taken. /// * **pending-spend** — a height-bounded fence installed *when the /// broadcaster returns anything other than a definitive pre-send /// rejection*, i.e. when the transaction may be on the network. @@ -103,6 +104,36 @@ pub struct WalletGeneration { /// sweep + re-select race the pin was added to close /// (`dashpay/platform#4309`). /// + /// # The pending-spend phase is anchored AFTER the await, never before + /// + /// A full [`IN_BROADCAST_FENCE_BLOCKS`](crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS) + /// interval is measured from a `last_processed_height` sampled once the + /// broadcaster has returned — not from the height the freshness check + /// consumed on the way in. Anchoring on the pre-await sample looks + /// clock-consistent and is in fact the same defect one layer along: a + /// broadcast await can suspend for minutes mid-catch-up on mobile, and if + /// the wallet advances a full fence interval in that gap the fence is + /// ALREADY LAPSED at the instant it is installed. The next selection reaps + /// it and may reselect an input of a transaction that reached the network + /// — an already-expired fence is indistinguishable from no fence + /// (`dashpay/platform#4309`). + /// + /// Clock consistency is preserved by making the post-await sample the + /// SINGLE anchor: it is read under the manager guard, from the same + /// `last_processed_height` the freshness check and key-wallet's TTL sweep + /// use, and the dispatching phase stays held across the sampling so no + /// selection can slip between the broadcaster's return and the fence being + /// stamped. + /// + /// When a dispatch stops without ever taking that sample — cancelled or + /// unwound inside `broadcast`, the case a caller's `timeout`/`select!` + /// produces — [`InBroadcastFence::pending_unanchored`] fences + /// unconditionally and the first selection to consult the fence anchors it + /// on ITS height. That is the drop-time clock, deferred to the earliest + /// moment it is both readable (a synchronous `Drop` cannot await the + /// manager lock) and relevant (nothing can reach a fenced outpoint in + /// between). + /// /// A *count* for the dispatching phase rather than a set: /// `broadcast_finalized_transaction` takes `&SignedCoreTransaction`, so a /// direct Rust caller can dispatch the same transaction twice concurrently @@ -127,9 +158,18 @@ struct InBroadcastFence { /// Non-expiring while non-zero: a suspended dispatch keeps its inputs /// fenced no matter how far catch-up advances the clock. dispatching: u32, - /// `last_processed_height` at which the pending-spend phase lapses, set when - /// a dispatch returns without a definitive pre-send rejection. `None` means - /// no dispatch has handed this outpoint to the network. + /// A dispatch settled this outpoint's pending-spend phase without a + /// post-await height sample — the dispatching future was cancelled or + /// unwound inside `broadcast`, or the wallet left the manager before the + /// sample could be taken. Blocks UNCONDITIONALLY until + /// [`WalletGeneration::in_broadcast_conflict`] anchors it, because a + /// synchronous `Drop` has no lock-free way to read `last_processed_height` + /// and the pre-await sample is exactly the stale anchor that made the fence + /// arrive already lapsed (`dashpay/platform#4309`). + pending_unanchored: bool, + /// `last_processed_height` at which the ANCHORED pending-spend phase lapses. + /// `None` means no dispatch has handed this outpoint to the network with a + /// height to measure from. pending_until: Option, } @@ -137,10 +177,67 @@ impl InBroadcastFence { /// Whether this fence still blocks re-selection at `current_height`. fn blocks(&self, current_height: u32) -> bool { self.dispatching > 0 + || self.pending_unanchored || self .pending_until .is_some_and(|until| current_height < until) } + + /// Give the pending-spend phase a bound measured from `current_height`, the + /// caller's `last_processed_height` read under the manager WRITE guard. + /// + /// Called by [`WalletGeneration::in_broadcast_conflict`] before it decides + /// anything, so an unanchored fence is bounded at the first moment it could + /// possibly matter — a fenced outpoint is unreachable by any other path, so + /// there is no window between the settle and this stamp. Never SHORTENS an + /// existing bound: a concurrent dispatch of the same transaction may + /// already have installed a longer one. + fn anchor(&mut self, current_height: u32) { + if !self.pending_unanchored { + return; + } + self.pending_unanchored = false; + self.extend_pending_until( + current_height.saturating_add(crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS), + ); + } + + /// Push the anchored bound out to `until`, never in. Two concurrent + /// dispatches of the same transaction must BOTH be covered, so the later + /// bound wins. + fn extend_pending_until(&mut self, until: u32) { + self.pending_until = Some(self.pending_until.map_or(until, |cur| cur.max(until))); + } + + /// Whether nothing holds this outpoint any more, so the entry can be + /// dropped from the map. + fn is_clear(&self) -> bool { + self.dispatching == 0 && !self.pending_unanchored && self.pending_until.is_none() + } +} + +/// How one dispatch's pending-spend phase settles when its [`InBroadcastPin`] +/// is dropped — see [`WalletGeneration::pin_in_broadcast`]. +/// +/// The variants are ordered by how much the dispatch managed to prove, and the +/// INITIAL value is the least-informed one: a pin that learns nothing before it +/// drops must fence (`dashpay/platform#4309`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PendingSpendSettle { + /// Nothing was learned: the dispatching future was cancelled or unwound + /// mid-`broadcast`, or its post-await height sample never completed. The + /// transaction may be on the wire and there is no trustworthy height to + /// measure from, so the fence is installed unanchored and + /// [`InBroadcastFence::anchor`] stamps it from the next selection's clock. + Unanchored, + /// The broadcaster returned something other than a definitive pre-send + /// rejection, and `last_processed_height` was sampled AFTER that return: + /// the fence lapses [`IN_BROADCAST_FENCE_BLOCKS`](crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS) + /// past this height. + AnchoredAt(u32), + /// A definitive pre-send rejection — the one outcome that proves the + /// transaction never reached the network. No pending-spend phase at all. + Released, } impl Default for WalletGeneration { @@ -242,21 +339,25 @@ impl WalletGeneration { /// dispatching future is cancelled mid-await (`Drop` runs on unwind and on /// future drop alike). /// - /// `dispatch_height` is the `last_processed_height` sampled in the *same* - /// guarded section as the freshness check. It anchors the pending-spend - /// phase that [`InBroadcastPin::retain_pending_spend`] installs, so that - /// phase is measured from the moment the transaction was authorized to go - /// to the network rather than from the much older reservation stamp. + /// # No height is taken here, deliberately + /// + /// This call takes NO `last_processed_height`, even though one is in hand + /// under the guard. The pending-spend phase must be measured from a height + /// sampled once the broadcaster has RETURNED + /// ([`InBroadcastPin::anchor_pending_spend`]): a broadcast await can + /// suspend for minutes mid-catch-up, and a fence anchored on the pre-await + /// sample arrives already lapsed whenever the wallet advanced a full fence + /// interval in the gap — which is no fence at all + /// (`dashpay/platform#4309`). Not accepting the height makes that + /// mis-anchoring unrepresentable rather than merely fixed. A pin that is + /// never anchored still fences, unconditionally, until the first selection + /// stamps it (see [`InBroadcastPin`]). /// /// Callers pin on the generation currently REGISTERED in the manager /// (`PlatformWalletInfo::generation`), the same object the build-side /// conflict checks read, so the fence works even for a dispatch through a /// stale-generation handle. - pub(crate) fn pin_in_broadcast( - self: &Arc, - transaction: &Transaction, - dispatch_height: u32, - ) -> InBroadcastPin { + pub(crate) fn pin_in_broadcast(self: &Arc, transaction: &Transaction) -> InBroadcastPin { let outpoints: Vec = transaction .input .iter() @@ -271,10 +372,12 @@ impl WalletGeneration { InBroadcastPin { generation: Arc::clone(self), outpoints, - dispatch_height, - // Fenced by default; only a definitive pre-send rejection releases - // it. See the `InBroadcastPin` type docs (`dashpay/platform#4309`). - retain_pending_spend: true, + // Fenced by default, and with no anchor yet: a pin that learns + // nothing before it drops must still hold the inputs. Only a + // definitive pre-send rejection releases, and only a post-await + // height sample bounds. See the `InBroadcastPin` type docs + // (`dashpay/platform#4309`). + settle: PendingSpendSettle::Unanchored, } } @@ -295,6 +398,24 @@ impl WalletGeneration { /// same write guard — the identical clock the pending-spend bound was /// stamped against and the one key-wallet's TTL sweep runs on. /// + /// # Anchoring, before anything is decided + /// + /// A dispatch that stopped without a post-await height sample (cancelled or + /// unwound inside `broadcast`) leaves its fence UNANCHORED, because a + /// synchronous `Drop` cannot await the manager lock to read the clock. This + /// call supplies it: every unanchored fence is stamped + /// [`IN_BROADCAST_FENCE_BLOCKS`](crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS) + /// past `current_height` BEFORE the reap and the lookup below, so the + /// interval always runs from a clock at least as recent as the moment the + /// dispatch stopped — never from the stale pre-await sample that made the + /// fence arrive already lapsed (`dashpay/platform#4309`). + /// + /// Deferring the anchor to here loses nothing: this is the ONLY place the + /// fence is read, so between a pin's drop and this call there is no path by + /// which a fenced outpoint could be selected. It also cannot over-hold + /// across repeated builds — the stamp happens once, and the entry is a + /// plain bounded fence from then on. + /// /// Lapsed entries are reaped here rather than by a timer: this is the only /// place the fence is consulted, so pruning on read keeps the map bounded by /// the outpoints dispatched since the last build without any background @@ -305,6 +426,9 @@ impl WalletGeneration { current_height: u32, ) -> Option { let mut pinned = self.in_broadcast_lock(); + for fence in pinned.values_mut() { + fence.anchor(current_height); + } pinned.retain(|_, fence| fence.blocks(current_height)); transaction .input @@ -316,13 +440,21 @@ impl WalletGeneration { /// End one dispatch's hold on `outpoints` — the [`InBroadcastPin`] release /// half of [`pin_in_broadcast`](Self::pin_in_broadcast). /// - /// `pending_until` is `Some(height)` when that dispatch reached the network - /// (anything but a definitive pre-send rejection): the dispatching count - /// drops but the outpoint stays fenced until `height`. It is `None` for a - /// rejection, which frees the outpoint immediately — the transaction is - /// provably not on the wire, and the caller releases its reservation in the - /// same breath so an immediate rebuild can reselect. - fn unpin_in_broadcast(&self, outpoints: &[OutPoint], pending_until: Option) { + /// `settle` says what that dispatch proved: + /// + /// * [`PendingSpendSettle::AnchoredAt`] — it returned something other than a + /// definitive pre-send rejection AND a post-return `last_processed_height` + /// was sampled: the dispatching count drops but the outpoint stays fenced + /// a full interval past that height. + /// * [`PendingSpendSettle::Unanchored`] — it stopped without that sample + /// (cancelled or unwound mid-`broadcast`). The outpoint stays fenced with + /// no bound; [`InBroadcastFence::anchor`] supplies one from the next + /// selection's clock. + /// * [`PendingSpendSettle::Released`] — a definitive pre-send rejection, + /// which frees the outpoint immediately: the transaction is provably not + /// on the wire, and the caller releases its reservation in the same breath + /// so an immediate rebuild can reselect. + fn unpin_in_broadcast(&self, outpoints: &[OutPoint], settle: PendingSpendSettle) { let mut pinned = self.in_broadcast_lock(); for outpoint in outpoints { let Some(fence) = pinned.get_mut(outpoint) else { @@ -332,13 +464,20 @@ impl WalletGeneration { continue; }; fence.dispatching = fence.dispatching.saturating_sub(1); - if let Some(until) = pending_until { + match settle { // Never shorten a fence another dispatch already extended: two // concurrent dispatches of the same transaction must both be // covered, so the later bound wins. - fence.pending_until = Some(fence.pending_until.map_or(until, |cur| cur.max(until))); + PendingSpendSettle::AnchoredAt(height) => fence.extend_pending_until( + height.saturating_add(crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS), + ), + // Additive alongside any existing bound rather than replacing + // it: this phase outlasts every anchored one until it is + // stamped, and stamping keeps the longer of the two. + PendingSpendSettle::Unanchored => fence.pending_unanchored = true, + PendingSpendSettle::Released => {} } - if fence.dispatching == 0 && fence.pending_until.is_none() { + if fence.is_clear() { pinned.remove(outpoint); } } @@ -363,18 +502,55 @@ impl WalletGeneration { /// spent on the network, so an immediate reselection double-spends them. Absence /// of evidence that a send happened is not evidence that it did not, so the /// fence must survive every exit except the one that proves otherwise. +/// +/// # The bound is set by the dispatch, not by the pin's birth +/// +/// Fencing by default is only half of it: a fence installed with an +/// ALREADY-LAPSED bound is indistinguishable from no fence. The bound therefore +/// comes from [`anchor_pending_spend`](Self::anchor_pending_spend), called with +/// a `last_processed_height` sampled AFTER the broadcaster returned and while +/// this pin's dispatching phase is still held. A pin that never reaches that +/// call — the cancellation and unwind paths — settles UNANCHORED and blocks +/// unconditionally until the next coin selection stamps it from its own clock. +/// Neither path can consult the pre-await height, because this pin does not +/// carry one (`dashpay/platform#4309`). pub(crate) struct InBroadcastPin { generation: Arc, outpoints: Vec, - /// `last_processed_height` sampled in the guarded section that took this - /// pin — the anchor for the pending-spend bound. - dispatch_height: u32, - /// Starts `true`; cleared only by - /// [`release_pending_spend`](Self::release_pending_spend). - retain_pending_spend: bool, + /// How the pending-spend phase settles on drop. Starts + /// [`PendingSpendSettle::Unanchored`] — the least-informed, most + /// conservative state — and is narrowed only by an explicit call. + settle: PendingSpendSettle, } impl InBroadcastPin { + /// Bound the pending-spend fence at `current_height` + + /// [`IN_BROADCAST_FENCE_BLOCKS`](crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS). + /// + /// `current_height` MUST be a `last_processed_height` sampled after the + /// broadcaster returned, under the wallet-manager guard, while this pin is + /// still alive. Those three conditions are what make the bound meaningful: + /// sampling before the await lets a long suspension (minutes, mid-catch-up + /// on mobile) age the anchor past the whole interval, so the fence lapses + /// the moment it is installed and the next selection reselects an input of + /// a transaction that may have reached the network; holding the pin across + /// the sampling leaves no window between the broadcaster's return and the + /// stamp; and reading under the manager guard keeps this anchor on the same + /// clock the freshness check, key-wallet's TTL sweep and + /// [`WalletGeneration::in_broadcast_conflict`] all use + /// (`dashpay/platform#4309`). + /// + /// Not calling this is always SAFE — the fence simply stays unanchored and + /// is stamped by the first selection instead — so a cancelled dispatch, an + /// unwind, or a wallet that left the manager needs no special handling. + pub(crate) fn anchor_pending_spend(&mut self, current_height: u32) { + // A rejection already proved nothing was sent; a late anchor must not + // resurrect a fence the caller deliberately released. + if self.settle != PendingSpendSettle::Released { + self.settle = PendingSpendSettle::AnchoredAt(current_height); + } + } + /// Free the outpoints outright on drop, instead of leaving the pending-spend /// fence this pin installs by default. /// @@ -385,18 +561,14 @@ impl InBroadcastPin { /// see the type docs and the `WalletGeneration::in_broadcast` field docs for /// why dispatch return is not, by itself, safe. pub(crate) fn release_pending_spend(&mut self) { - self.retain_pending_spend = false; + self.settle = PendingSpendSettle::Released; } } impl Drop for InBroadcastPin { fn drop(&mut self) { - let pending_until = self.retain_pending_spend.then(|| { - self.dispatch_height - .saturating_add(crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS) - }); self.generation - .unpin_in_broadcast(&self.outpoints, pending_until); + .unpin_in_broadcast(&self.outpoints, self.settle); } } @@ -443,6 +615,17 @@ mod tests { OutPoint::new(Txid::from([byte; 32]), vout) } + /// Settle a pin the way a dispatch that reached the network does: the + /// broadcaster returned something other than a definitive rejection, and + /// `last_processed_height` was sampled at `height` AFTER that return. The + /// anchor is the POST-await reading — `pin_in_broadcast` is deliberately + /// given no height at all (`dashpay/platform#4309`). + fn settle_dispatched(generation: &Arc, tx: &Transaction, height: u32) { + let mut pin = generation.pin_in_broadcast(tx); + pin.anchor_pending_spend(height); + drop(pin); + } + /// A held pin flags every input of the pinned transaction — and only /// those — and dropping a pin whose fence was explicitly RELEASED clears /// the conflict. Release models the one outcome that proves nothing was @@ -455,7 +638,7 @@ mod tests { let b = outpoint(0x02, 1); let unrelated = outpoint(0x03, 0); - let mut pin = generation.pin_in_broadcast(&spending(&[a, b]), DISPATCH_HEIGHT); + let mut pin = generation.pin_in_broadcast(&spending(&[a, b])); // Both pinned inputs conflict; an unrelated selection does not. assert_eq!( @@ -500,8 +683,10 @@ mod tests { let a = outpoint(0x07, 0); let tx = spending(&[a]); - // No `release_pending_spend()` — models a cancelled dispatch. - drop(generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT)); + // Neither `release_pending_spend()` nor `anchor_pending_spend()` — + // models a dispatch cancelled inside `broadcast`, which reaches + // neither call. + drop(generation.pin_in_broadcast(&tx)); assert_eq!( generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT), @@ -516,7 +701,9 @@ mod tests { Some(a), "the fence must hold for the full pending-spend bound" ); - // Negative control: the fence is bounded, not permanent. + // Negative control: the fence is bounded, not permanent. The bound is + // measured from the height of the FIRST consult above, which here is + // `DISPATCH_HEIGHT`. assert_eq!( generation.in_broadcast_conflict( &tx, @@ -527,6 +714,84 @@ mod tests { ); } + /// `dashpay/platform#4309`, the CANCELLATION half of the stale-anchor + /// defect. Keeping the fence on cancellation is not enough on its own: the + /// cancelled dispatch had been suspended inside `broadcast` while catch-up + /// ran, so a bound measured from anything sampled before that await is + /// already in the past when the pin drops, and the fence is installed + /// DEAD — reaped by the very next selection, exactly as if it had never + /// been installed. + /// + /// A cancelled pin therefore settles with no bound at all and is anchored + /// by the first selection to consult it, on the height THAT selection reads + /// under the manager write guard. Here the clock has run 10_000 blocks past + /// where the dispatch started — far beyond `IN_BROADCAST_FENCE_BLOCKS` — + /// and the fence must still be live, then run a full interval from the + /// height that observed it. + #[test] + fn cancelled_dispatch_fence_anchors_at_the_first_selection_not_at_dispatch() { + let generation = Arc::new(WalletGeneration::new()); + let a = outpoint(0x08, 0); + let tx = spending(&[a]); + + // Cancelled mid-`broadcast`: no anchor, no release. + drop(generation.pin_in_broadcast(&tx)); + + // Catch-up ran far past a whole fence interval during the await. A + // pre-await anchor would have lapsed thousands of blocks ago. + let observed = DISPATCH_HEIGHT + 10_000; + assert!(observed > DISPATCH_HEIGHT + IN_BROADCAST_FENCE_BLOCKS); + assert_eq!( + generation.in_broadcast_conflict(&tx, observed), + Some(a), + "a fence settled without a post-dispatch height sample must not \ + arrive already lapsed, however far catch-up ran" + ); + + // ...and it is anchored on THAT height, not re-anchored by later reads. + assert_eq!( + generation.in_broadcast_conflict(&tx, observed + IN_BROADCAST_FENCE_BLOCKS - 1), + Some(a), + "the interval must run a full bound from the observing height" + ); + assert_eq!( + generation.in_broadcast_conflict(&tx, observed + IN_BROADCAST_FENCE_BLOCKS), + None, + "the anchor is stamped once: repeated reads must not extend the fence" + ); + } + + /// An unanchored fence outlasts an anchored one for the same outpoint. Two + /// concurrent dispatches of the same transaction can settle differently — + /// one returns and anchors, the other is cancelled — and the outpoint must + /// be covered by the longer of the two. Anchoring at the observing height + /// is what guarantees that: heights advance, so a bound stamped now is + /// never shorter than one stamped from an earlier sample. + #[test] + fn an_unanchored_fence_outlives_an_anchored_one() { + let generation = Arc::new(WalletGeneration::new()); + let a = outpoint(0x09, 0); + let tx = spending(&[a]); + + let cancelled = generation.pin_in_broadcast(&tx); + settle_dispatched(&generation, &tx, DISPATCH_HEIGHT); + drop(cancelled); + + // Past the anchored dispatch's bound, the cancelled one still holds. + let observed = DISPATCH_HEIGHT + IN_BROADCAST_FENCE_BLOCKS; + assert_eq!( + generation.in_broadcast_conflict(&tx, observed), + Some(a), + "the cancelled dispatch's unanchored fence must outlast the \ + anchored one" + ); + assert_eq!( + generation.in_broadcast_conflict(&tx, observed + IN_BROADCAST_FENCE_BLOCKS), + None, + "and it must still lapse a bound past the height that anchored it" + ); + } + /// The dispatching pin has no TTL: however far catch-up advances the /// clock while the broadcaster is suspended, the inputs stay fenced. #[test] @@ -535,7 +800,7 @@ mod tests { let a = outpoint(0x05, 0); let tx = spending(&[a]); - let _pin = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT); + let _pin = generation.pin_in_broadcast(&tx); assert_eq!( generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT + 10_000), @@ -556,8 +821,8 @@ mod tests { let a = outpoint(0x30, 0); let tx = spending(&[a]); - // Fenced by default now — no explicit retain needed. - drop(generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT)); + // Fenced by default; the bound comes from the POST-await sample. + settle_dispatched(&generation, &tx, DISPATCH_HEIGHT); assert_eq!( generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT), @@ -585,7 +850,7 @@ mod tests { let a = outpoint(0x31, 0); let unrelated = outpoint(0x32, 0); - drop(generation.pin_in_broadcast(&spending(&[a]), DISPATCH_HEIGHT)); + settle_dispatched(&generation, &spending(&[a]), DISPATCH_HEIGHT); assert_eq!(generation.in_broadcast_lock().len(), 1); // A read past the bound — about an unrelated selection — still reaps. @@ -613,7 +878,7 @@ mod tests { // An explicit release — this models `BroadcastError::Rejected`, the one // outcome that proves the transaction never reached the network. - let mut pin = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT); + let mut pin = generation.pin_in_broadcast(&tx); pin.release_pending_spend(); drop(pin); @@ -625,6 +890,47 @@ mod tests { assert!(generation.in_broadcast_lock().is_empty()); } + /// An UNANCHORED fence must still be bounded and reaped even when its own + /// outpoint is never re-selected. Anchoring runs over every entry, not just + /// the ones the querying transaction spends, so a cancelled dispatch of a + /// coin nobody touches again cannot sit in the map unbounded forever — + /// which is the same "it must lapse" property `IN_BROADCAST_FENCE_BLOCKS` + /// exists to guarantee for funds that are otherwise stranded. + #[test] + fn unanchored_fences_are_bounded_and_reaped_by_unrelated_reads() { + let generation = Arc::new(WalletGeneration::new()); + let a = outpoint(0x34, 0); + let unrelated = outpoint(0x35, 0); + + // Cancelled dispatch: unanchored, no bound yet. + drop(generation.pin_in_broadcast(&spending(&[a]))); + assert_eq!(generation.in_broadcast_lock().len(), 1); + + // A read about a DIFFERENT selection anchors it at this height... + assert_eq!( + generation.in_broadcast_conflict(&spending(&[unrelated]), DISPATCH_HEIGHT), + None + ); + assert_eq!( + generation.in_broadcast_lock().len(), + 1, + "the fence must still stand — anchoring is not releasing" + ); + + // ...so a later unrelated read past that bound reaps it. + assert_eq!( + generation.in_broadcast_conflict( + &spending(&[unrelated]), + DISPATCH_HEIGHT + IN_BROADCAST_FENCE_BLOCKS + ), + None + ); + assert!( + generation.in_broadcast_lock().is_empty(), + "an unanchored fence must not outlive its bound in the map" + ); + } + /// Pins COUNT per outpoint: two concurrent dispatches of the same /// transaction (legal through `&SignedCoreTransaction`, idempotent on the /// wire) each take a pin, and the fence must hold until the LAST one @@ -638,8 +944,8 @@ mod tests { // Both model a definitive rejection, so the count — not a leftover // pending-spend fence — is what keeps the outpoint held. - let mut first = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT); - let mut second = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT); + let mut first = generation.pin_in_broadcast(&tx); + let mut second = generation.pin_in_broadcast(&tx); first.release_pending_spend(); second.release_pending_spend(); @@ -663,8 +969,12 @@ mod tests { let a = outpoint(0x11, 0); let tx = spending(&[a]); - let early = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT); - let late = generation.pin_in_broadcast(&tx, DISPATCH_HEIGHT + 5); + let mut early = generation.pin_in_broadcast(&tx); + let mut late = generation.pin_in_broadcast(&tx); + // Each anchors on its OWN post-await sample; the later return sees the + // higher clock. + early.anchor_pending_spend(DISPATCH_HEIGHT); + late.anchor_pending_spend(DISPATCH_HEIGHT + 5); drop(late); drop(early); @@ -686,7 +996,7 @@ mod tests { fn pins_do_not_cross_generations() { let old_generation = Arc::new(WalletGeneration::new()); let a = outpoint(0x20, 0); - let _pin = old_generation.pin_in_broadcast(&spending(&[a]), DISPATCH_HEIGHT); + let _pin = old_generation.pin_in_broadcast(&spending(&[a])); let new_generation = Arc::new(WalletGeneration::new()); assert_eq!( diff --git a/packages/rs-platform-wallet/src/wallet/reservations.rs b/packages/rs-platform-wallet/src/wallet/reservations.rs index c133aed7faf..6a880fcc43c 100644 --- a/packages/rs-platform-wallet/src/wallet/reservations.rs +++ b/packages/rs-platform-wallet/src/wallet/reservations.rs @@ -55,6 +55,15 @@ pub(crate) const RESERVATION_MAX_AGE_BLOCKS: u32 = 20; /// [`WalletGeneration::pin_in_broadcast`](crate::wallet::core::WalletGeneration::pin_in_broadcast)'s /// pending-spend phase. /// +/// "Past dispatch" means past a `last_processed_height` sampled once the +/// broadcaster has RETURNED, not the one the pre-send freshness check consumed. +/// A broadcast await can suspend for minutes mid-catch-up, and anchoring this +/// interval before it means the fence can arrive already lapsed — which is the +/// same as never installing it (`dashpay/platform#4309`). A dispatch that stops +/// without that sample fences unbounded until the next coin selection stamps it +/// from its own height. See `CoreWallet::dispatch_unexpired` and +/// `WalletGeneration::in_broadcast_conflict`. +/// /// # Why a fence past dispatch is needed at all /// /// `SpvBroadcaster` injects the dispatched transaction into dash-spv's local @@ -76,11 +85,14 @@ pub(crate) const RESERVATION_MAX_AGE_BLOCKS: u32 = 20; /// key-wallet exposes no such primitive at the pinned revision (`ReservationSet` /// and its `RESERVATION_TTL_BLOCKS` are private). This constant is that renewal /// implemented one layer up: **24, key-wallet's own `RESERVATION_TTL_BLOCKS`** -/// (~1 h at the mainnet block target), measured from dispatch instead of from -/// the build. The inputs are then continuously protected — by the reservation -/// until its build-anchored TTL, then by this fence — for a full TTL past the -/// moment they were actually committed to the network, which is the point the -/// TTL was always meant to be measured from. Coupled by convention, exactly as +/// (~1 h at the mainnet block target), measured from the broadcaster's return +/// instead of from the build. The inputs are then continuously protected — by +/// the reservation until its build-anchored TTL, then by this fence — for a full +/// TTL past the moment they were actually committed to the network, which is the +/// point the TTL was always meant to be measured from. Sampling the anchor +/// *after* the send is what makes that literally true rather than approximately: +/// an anchor taken before a long await measures from a moment the transaction +/// had not yet gone anywhere. Coupled by convention, exactly as /// [`RESERVATION_MAX_AGE_BLOCKS`] above is: if key-wallet's TTL changes, change /// this in lockstep. /// From f1250e0bf11b1b13241a75609e75e82f4b8de114 Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Tue, 18 Aug 2026 21:59:51 -0400 Subject: [PATCH 11/15] docs(platform-wallet-ffi,swift-sdk): document the terminal stale-token broadcast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ErrorStaleReservationToken` (34) from `core_wallet_broadcast_signed_transaction` is terminal, and neither the Rust ABI doc nor the Swift wrapper said so. A caller reading either could reasonably expect the refusal to be retryable, or to need an abandon to clean up — both are wrong and the second is unreachable. State it on both surfaces: the handle is already consumed when the age guard runs (a retry returns `NotFound` (98) rather than resending, and abandon has nothing left to free), the refusal path reconciles the reservation itself owner-guarded, and the only recovery is to REBUILD the transaction — the released inputs are immediately reselectable with no cleanup call in between. Behaviour is unchanged; this documents what `aged_broadcast_refuses_and_releases_for_rebuild` already asserts. The deprecated Swift `broadcastTransaction(_:)` delegates to the documented `broadcastTransactionWithOutcome(_:)`, so it is covered by the same note. Refs: dashpay/platform#4309 --- .../src/core_wallet/broadcast.rs | 22 +++++++++++++++++++ .../CoreWallet/ManagedCoreWallet.swift | 17 ++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs index 55de0604cf5..d1f1edc79cf 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -31,6 +31,28 @@ fn classify_broadcast_result( /// (removed, or re-created under the same id) is refused with `NotFound` (98) /// **before** the network is touched; the handle is consumed and its reservation /// reconciled. This mirrors the deferred-token path's `WalletRemoved` → 98. +/// +/// # `ErrorStaleReservationToken` (34) is TERMINAL +/// +/// A handle held past `RESERVATION_MAX_AGE_BLOCKS` — the wallet's +/// `last_processed_height` advanced that far beyond the funding reservation's +/// stamp — is refused with `ErrorStaleReservationToken` (34) and no txid, again +/// **before** the network is touched. Nothing was sent. +/// +/// There is no retry and no abandon from that outcome: the handle was already +/// consumed at the top of this call, so a second +/// `core_wallet_broadcast_signed_transaction` with it returns `NotFound` (98) +/// rather than resending, and `core_wallet_abandon_signed_transaction` likewise +/// finds nothing to free. The refusal path performs the reconciliation itself — +/// it releases the funding reservation owner-guarded, so the inputs are free +/// while this build still owned them and untouched once a TTL sweep or +/// re-reservation transferred ownership. +/// +/// **The caller must REBUILD the transaction.** That is the whole recovery: the +/// released inputs are immediately reselectable by a fresh +/// `core_wallet_tx_builder_*` → `finalize` sequence, and no cleanup call is +/// needed (or possible) in between. See +/// `aged_broadcast_refuses_and_releases_for_rebuild`. #[no_mangle] pub unsafe extern "C" fn core_wallet_broadcast_signed_transaction( handle: Handle, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift index 69af4d0e297..8ce56359353 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift @@ -242,6 +242,23 @@ public class ManagedCoreWallet { /// Consume and broadcast an atomically finalized transaction, returning /// the authoritative accepted/rejected/unknown network outcome. + /// + /// - Important: `.errorStaleReservationToken` (native code 34) is a + /// **terminal** outcome, not a retryable one. It means the handle was held + /// until the wallet's `last_processed_height` advanced past the funding + /// reservation's age bound, so the transaction was refused *before* the + /// network was touched — nothing was sent. + /// + /// Neither a retry nor an abandon is possible: `takeForBroadcast()` above + /// has already consumed this handle, so calling + /// `broadcastTransactionWithOutcome(_:)` again throws locally, and + /// `abandonTransaction(_:)` has nothing left to release. The refusal path + /// in Rust reconciles the reservation itself, releasing the inputs + /// owner-guarded. + /// + /// **Recover by rebuilding the transaction.** The released inputs are + /// immediately reselectable by a fresh builder → `finalize` sequence, with + /// no cleanup call in between. public func broadcastTransactionWithOutcome( _ tx: FinalizedCoreTransaction ) throws -> CoreTransactionBroadcastOutcome { From 7dde1c12d25e63f2615b147f6ed2a28362da993d Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:56:44 -0400 Subject: [PATCH 12/15] fix(platform-wallet): settle the in-broadcast pin under the manager guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dispatch_unexpired` read the post-await `last_processed_height` under the wallet-manager read guard, but released that guard before anchoring the pin and dropping it. The sample and the install therefore sat in two different critical sections, and a manager writer queued behind the released guard — SPV catch-up applying a batch of blocks is exactly that writer — could advance the height in between. The fence then landed bounded on a height the wallet had already left, in the same instant the dispatching hold was lifted: the outpoint went from fully held to fully free with no live pending-spend phase between the two. Move the sampling and the settle into one guarded section (`CoreWallet::settle_dispatch_fence`), so the dispatching→pending handoff is atomic with respect to height writers and the installed bound is measured from the height that is current at the instant the fence becomes the only protection. The settle is a consuming `InBroadcastPin::settle_pending_spend` (and `settle_released` for the definitive-rejection path) rather than a `&mut` narrow plus an end-of-scope `drop`: taking `self` makes the transition a statement the dispatch *places* inside the guard scope instead of one that floats to wherever the binding happens to end. `Drop` stays exactly as it was — the unanchored fallback the cancellation/unwind design relies on — so nothing new runs at drop time and the settle never acquires a lock the pin did not already take. Lock order is unchanged: the settle touches only `WalletGeneration::in_broadcast`, a `std::sync::Mutex`, for a few hash operations, never awaits, and never takes the manager lock, which is the same manager→`in_broadcast` order every `in_broadcast_conflict` call site already uses under the manager WRITE guard. Regression test `settle_does_not_interleave_with_a_parked_height_writer` parks a manager writer across the post-await section, has it advance a full `IN_BROADCAST_FENCE_BLOCKS`, and records the fence state at the instant it is granted the lock. Two interleavings are legal — granted after the handoff (must see the pin settled, with a bound still ahead of the height it holds) or granted before the dispatch sampled (the dispatch then reads the advanced height, so the bound must run from that advance) — and the torn third case, seeing the pin still dispatching while the installed bound sits below the advance, fails. It is repeated 16 times because the interleaving is scheduler-dependent; against a shape with anything suspending between the sample and the settle it fails 6/6 runs, and it is green 5/5 with the guard held. Refs: dashpay/platform#4309 --- .../src/wallet/core/broadcast.rs | 302 +++++++++++++++--- .../src/wallet/core/generation.rs | 170 ++++++---- 2 files changed, 372 insertions(+), 100 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index b09860f4244..9326a871f85 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -3,6 +3,7 @@ use key_wallet::account::account_type::StandardAccountType; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::ReservationToken; +use super::generation::InBroadcastPin; use super::SignedCoreTransaction; use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; use crate::wallet::reservations::{broadcast_releasing_on_rejection, reservation_expired}; @@ -73,7 +74,7 @@ impl CoreWallet { /// breath, so an instant rebuild can reselect the inputs. /// * **Anything else** (accepted, or an ambiguous `MaybeSent`) — the pin is /// converted to a pending-spend fence - /// ([`InBroadcastPin::anchor_pending_spend`](super::generation::InBroadcastPin::anchor_pending_spend)) + /// ([`settle_dispatch_fence`](Self::settle_dispatch_fence)) /// lasting /// [`IN_BROADCAST_FENCE_BLOCKS`](crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS) /// past a `last_processed_height` sampled **after** the broadcaster @@ -102,6 +103,14 @@ impl CoreWallet { /// **dispatching** phase stays held across that sampling, so there is no /// window in which the outpoints are neither pinned nor fenced. /// + /// Sampling *and* installing happen under ONE guard + /// ([`settle_dispatch_fence`](Self::settle_dispatch_fence)), so the + /// dispatching→pending handoff is atomic with respect to height writers: no + /// manager writer can advance the clock between the reading and the install, + /// which is what would otherwise let the fence arrive already lapsed while + /// the dispatching hold was being lifted (`dashpay/platform#4309`, review + /// round 2). + /// /// A dispatch that never reaches the sample — the caller's future cancelled /// or unwound inside `broadcast`, or the wallet removed from the manager — /// settles its fence UNANCHORED, blocking unconditionally until the first @@ -127,7 +136,7 @@ impl CoreWallet { reservation_height: u32, transaction: &Transaction, ) -> GuardedDispatch { - let mut in_broadcast_pin = { + let in_broadcast_pin = { let wm = self.wallet_manager.read().await; let info = wm.get_wallet_info(&self.wallet_id); let height = info.map(|info| info.core_wallet.last_processed_height()); @@ -158,53 +167,82 @@ impl CoreWallet { // // Only a definitive pre-send rejection proves nothing was sent, so it is // the one outcome that releases. An ambiguous `MaybeSent` stays fenced. - if matches!( - outcome, - Err(crate::broadcaster::BroadcastError::Rejected { .. }) - ) { - if let Some(pin) = in_broadcast_pin.as_mut() { - pin.release_pending_spend(); - } - } else if let Some(pin) = in_broadcast_pin.as_mut() { - // EVERY non-rejection outcome — accepted or ambiguous `MaybeSent` — - // anchors its fence on a height sampled HERE, after the broadcaster - // returned, and gets a full `IN_BROADCAST_FENCE_BLOCKS` from it. - // - // The pre-await sample cannot serve: `broadcast` can suspend for - // minutes mid-catch-up (mobile), and if the wallet advances a whole - // fence interval inside the await, a fence anchored back there is - // ALREADY LAPSED when it is installed — the next coin selection - // reaps it and can reselect an input of a transaction that may have - // reached the network. An expired fence is no fence - // (`dashpay/platform#4309`). - // - // Clock consistency — the property the pre-await anchor was chosen - // for — is kept by making THIS the single anchor: it is read from - // the same `last_processed_height` under the same manager guard the - // freshness check used, and `in_broadcast_conflict` compares against - // that same clock under the write lock. Nothing is stamped against a - // height no guarded section ever saw. - // - // The DISPATCHING phase is still held while this runs (the pin is - // alive; it settles at `drop` below), so the outpoints never become - // selectable between the broadcaster's return and the stamp — the - // manager read lock is awaited under that cover. A wallet that left - // the manager in the meantime yields no height and leaves the pin - // unanchored, which fences unconditionally until the next selection - // stamps it: strictly the safer side. - let height = { - let wm = self.wallet_manager.read().await; - wm.get_wallet_info(&self.wallet_id) - .map(|info| info.core_wallet.last_processed_height()) - }; - if let Some(height) = height { - pin.anchor_pending_spend(height); + if let Some(pin) = in_broadcast_pin { + if matches!( + outcome, + Err(crate::broadcaster::BroadcastError::Rejected { .. }) + ) { + // Provably nothing on the wire: free the outpoints outright. + // No height and no guard — this installs no bound, so there is + // nothing a concurrent height advance could mistime. + pin.settle_released(); + } else { + // EVERY non-rejection outcome — accepted or ambiguous + // `MaybeSent` — hands the pin to the guarded settle below. + self.settle_dispatch_fence(pin).await; } } - drop(in_broadcast_pin); GuardedDispatch::Sent(outcome) } + /// Close out one dispatch's **dispatching** phase and install its + /// **pending-spend** fence, both inside a SINGLE wallet-manager read guard. + /// + /// The fence is bounded a full + /// [`IN_BROADCAST_FENCE_BLOCKS`](crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS) + /// past a `last_processed_height` sampled here, after the broadcaster + /// returned. The pre-await sample cannot serve: `broadcast` can suspend for + /// minutes mid-catch-up (mobile), and if the wallet advances a whole fence + /// interval inside the await, a fence anchored back there is ALREADY LAPSED + /// when it is installed — the next coin selection reaps it and can reselect + /// an input of a transaction that may have reached the network. An expired + /// fence is no fence (`dashpay/platform#4309`). + /// + /// # Why the guard spans the settle, not just the sample + /// + /// Sampling the height under the guard is not enough on its own. The + /// dispatching→pending transition is the pin's settle, and while the sample + /// and the settle sat in two different critical sections the guard was + /// released between them: a manager writer queued behind it — SPV catch-up + /// applying a batch of blocks is exactly that writer — could advance + /// `last_processed_height` in the gap, so the fence landed anchored on a + /// height the wallet had already left, at the same instant the dispatching + /// hold was lifted. The outpoint went from fully held to fully free with no + /// live pending-spend phase in between (`dashpay/platform#4309`, review + /// round 2). + /// + /// Holding the guard across both makes the transition atomic with respect to + /// height writers, so the installed bound is measured from the height that + /// is current at the instant the fence becomes the only protection — the + /// fence is never born dead. `settle_pending_spend` CONSUMES the pin so this + /// is a statement placed inside the guard's scope rather than an + /// end-of-scope drop that a later edit could float back outside it. + /// + /// # This cannot deadlock, and cannot starve the SPV pipeline + /// + /// The settle takes only `WalletGeneration::in_broadcast`, a + /// `std::sync::Mutex`, for a few hash operations; it never awaits and never + /// touches the wallet-manager lock. That is the crate's existing lock order + /// — manager lock, then `in_broadcast`, exactly as every + /// `in_broadcast_conflict` call site takes them under the manager WRITE + /// guard — so no inversion is possible. And the guarded section is still the + /// short, await-free read the broadcaster await was deliberately kept out + /// of; nothing here is held across a network wait. + /// + /// A wallet that left the manager yields no height and settles the pin + /// UNANCHORED, which fences unconditionally until the first coin selection + /// stamps it from its own clock: strictly the safer side. + async fn settle_dispatch_fence(&self, pin: InBroadcastPin) { + let manager = self.wallet_manager.read().await; + let height = manager + .get_wallet_info(&self.wallet_id) + .map(|info| info.core_wallet.last_processed_height()); + // Consumes the pin: the dispatching hold lifts and the bound lands in + // one critical section, with `manager` still held around both. + pin.settle_pending_spend(height); + drop(manager); + } + /// Broadcast an atomically finalized transaction. A definitive rejection /// releases its reservation; an ambiguous `MaybeSent` outcome retains it. /// @@ -1095,6 +1133,182 @@ mod tests { core.abandon_transaction(&after).await; } + /// `dashpay/platform#4309`, REVIEW ROUND 2: THE DISPATCHING→PENDING HANDOFF + /// MUST BE ATOMIC AGAINST MANAGER WRITERS. + /// + /// Anchoring on the post-await sample is not enough if the guard that sample + /// was read under is released before the fence is installed. The sample sat + /// in one critical section and `drop(pin)` — the statement that lifts the + /// dispatching hold AND installs the bound — sat outside it. A manager + /// writer queued behind that guard (SPV catch-up applying a batch of blocks + /// is exactly that writer) is woken the instant it is released, and on + /// another worker thread it can advance `last_processed_height` before the + /// resuming dispatch reaches the install. The fence then lands anchored on a + /// height the wallet has already left, in the same instant the dispatching + /// hold goes away: born dead, reaped by the next selection, with the input + /// of a possibly-sent transaction reselectable. + /// + /// The writer here parks on the manager WRITE lock while the dispatch is + /// inside its post-await section, then advances a full fence interval, and + /// records what the transition looked like at the moment it was granted the + /// lock. Exactly two interleavings are legal, and the fence must be live + /// under both: + /// + /// * granted AFTER the handoff — it must see the pin settled (`dispatching` + /// lifted) with a bound that is still ahead of the height it holds, i.e. + /// the fence was born live; or + /// * granted BEFORE the dispatch even sampled — the dispatch then reads the + /// advanced height, so the installed bound must run from the writer's own + /// advance. + /// + /// The torn third case — granted mid-handoff, so it sees the pin still + /// dispatching AND the fence ends up anchored below its advance — is what + /// the released guard allowed, and is what this fails on. + /// + /// Repeated, because the interleaving is scheduler-dependent: the writer has + /// to be granted the lock inside the window to observe a split, and with the + /// sample and the settle adjacent that window is a handful of instructions. + /// It widens the moment anything suspends between them — an added `.await`, + /// a second guarded read — which is the regression this guards against, and + /// which a single attempt catches only intermittently. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn settle_does_not_interleave_with_a_parked_height_writer() { + for attempt in 0..16 { + parked_writer_handoff_attempt(attempt).await; + } + } + + /// One scenario run of + /// [`settle_does_not_interleave_with_a_parked_height_writer`] — see its docs + /// for what the two legal interleavings are and why the third fails. + async fn parked_writer_handoff_attempt(attempt: u32) { + let entered = Arc::new(tokio::sync::Barrier::new(2)); + let release = Arc::new(tokio::sync::Barrier::new(2)); + let (core, signer, outputs) = funded_core_wallet( + StandardAccountType::BIP44Account, + Arc::new(GatedBroadcaster { + entered: Arc::clone(&entered), + release: Arc::clone(&release), + }), + ) + .await; + let stamped = core + .last_processed_height() + .await + .expect("last processed height"); + let finalized = finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + let fenced = finalized.transaction().input[0].previous_output; + + // A full fence interval plus a margin: a bound anchored on `stamped` is + // dead at this height, so an anchor below it is observably stale. + let writer_height = stamped + IN_BROADCAST_FENCE_BLOCKS + 5; + + let dispatcher = tokio::spawn({ + let core = core.clone(); + async move { core.broadcast_finalized_transaction(&finalized).await } + }); + // Parked inside `broadcast`: freshness checked, pre-await guard already + // dropped, pin held, and nothing holds the manager lock. + entered.wait().await; + + let writer = tokio::spawn({ + let manager = Arc::clone(&core.wallet_manager); + let generation = Arc::clone(core.generation()); + let wallet_id = core.wallet_id(); + async move { + // Wait for the dispatch to be INSIDE its post-await manager read + // section. While nothing holds the lock a `try_write` succeeds, + // so the first failure is that read guard being held. Bounded: + // if the section is missed the writer simply parks late, which + // is the "granted after the handoff" case below. + let mut spins = 0u32; + while manager.try_write().is_ok() { + spins += 1; + if spins >= 200_000 { + break; + } + if spins.is_multiple_of(512) { + tokio::task::yield_now().await; + } + } + + // Park behind that read guard, exactly as an SPV catch-up batch + // waiting to apply blocks does. + let mut guard = manager.write().await; + // What did the handoff look like the moment we were granted it? + let observed = generation.in_broadcast_fence_state(&fenced); + let (_, info) = guard + .get_wallet_and_info_mut(&wallet_id) + .expect("wallet present in manager"); + let height_at_grant = info.core_wallet.last_processed_height(); + info.core_wallet.update_last_processed_height(writer_height); + drop(guard); + (observed, height_at_grant) + } + }); + + release.wait().await; + let sent = dispatcher.await.expect("dispatcher task"); + assert!( + sent.is_ok(), + "the dispatch itself must succeed, got {sent:?}" + ); + let (observed, height_at_grant) = writer.await.expect("writer task"); + + let installed = core + .generation() + .in_broadcast_fence_state(&fenced) + .expect("the dispatched input must still carry a fence"); + + match observed { + // Granted after the handoff completed: the bound must already have + // been installed, and installed LIVE at the height then current. + Some((0, false, Some(until))) => assert!( + until > height_at_grant, + "attempt {attempt}: the fence must be born live — bound {until} \ + was already lapsed at the height {height_at_grant} current when \ + the dispatching hold was lifted" + ), + // Granted before the dispatch sampled: the dispatch then reads the + // advanced height, so the bound must run from the writer's advance. + Some((1, false, None)) => assert!( + installed + .2 + .is_some_and(|until| until >= writer_height + IN_BROADCAST_FENCE_BLOCKS), + "attempt {attempt}: a writer that advanced the clock to \ + {writer_height} before the dispatch sampled must have its \ + advance reflected in the installed fence, got {installed:?} — a \ + lower bound means the dispatching→pending handoff interleaved \ + with the advance and the fence was installed on a stale height" + ), + other => panic!( + "attempt {attempt}: unexpected fence state at the writer's \ + grant: {other:?} (installed: {installed:?})" + ), + } + + // Whichever way it went, the fence is still BOUNDED — this hardening + // must not turn the pending-spend phase into a permanent hold. (The + // writer's own advance may already have consumed the bound: that is the + // designed lapse, not a defect, so only probe below it when there is a + // below.) + let lapses_at = installed.2.expect("an anchored bound"); + if lapses_at > writer_height { + assert!( + matches!( + try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await, + Err(PlatformWalletError::TransactionBuild(_)) + ), + "the input must stay fenced below the installed bound" + ); + } + advance_processed_height(&core, lapses_at.max(writer_height)).await; + let after = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + let after = after + .unwrap_or_else(|error| panic!("the fence must lapse at its bound, got {error:?}")); + core.abandon_transaction(&after).await; + } + /// `dashpay/platform#4309`, the CANCELLATION twin of the test above. /// /// A caller wrapping the send in `timeout`/`select!` drops the dispatching diff --git a/packages/rs-platform-wallet/src/wallet/core/generation.rs b/packages/rs-platform-wallet/src/wallet/core/generation.rs index 7df93cac91c..7c034c7ef33 100644 --- a/packages/rs-platform-wallet/src/wallet/core/generation.rs +++ b/packages/rs-platform-wallet/src/wallet/core/generation.rs @@ -344,7 +344,7 @@ impl WalletGeneration { /// This call takes NO `last_processed_height`, even though one is in hand /// under the guard. The pending-spend phase must be measured from a height /// sampled once the broadcaster has RETURNED - /// ([`InBroadcastPin::anchor_pending_spend`]): a broadcast await can + /// ([`InBroadcastPin::settle_pending_spend`]): a broadcast await can /// suspend for minutes mid-catch-up, and a fence anchored on the pre-await /// sample arrives already lapsed whenever the wallet advanced a full fence /// interval in the gap — which is no fence at all @@ -482,6 +482,29 @@ impl WalletGeneration { } } } + + /// `outpoint`'s raw fence state — `(dispatching, pending_unanchored, + /// pending_until)` — or `None` when nothing holds it. + /// + /// A test-only WINDOW ON THE TRANSITION, deliberately not + /// [`in_broadcast_conflict`](Self::in_broadcast_conflict): that call anchors + /// and reaps as a side effect, so it cannot report whether a dispatch's + /// dispatching→pending handoff had completed at the moment it was observed. + /// `settle_does_not_interleave_with_a_parked_height_writer` needs exactly + /// that distinction (`dashpay/platform#4309`). + #[cfg(test)] + pub(crate) fn in_broadcast_fence_state( + &self, + outpoint: &OutPoint, + ) -> Option<(u32, bool, Option)> { + self.in_broadcast_lock().get(outpoint).map(|fence| { + ( + fence.dispatching, + fence.pending_unanchored, + fence.pending_until, + ) + }) + } } /// RAII guard for one dispatch's in-broadcast input fence — see @@ -490,9 +513,9 @@ impl WalletGeneration { /// the dispatching hold that call took, count-wise, never another dispatch's. /// /// The drop fences the outpoints by DEFAULT, as a pending spend. Only -/// [`release_pending_spend`](Self::release_pending_spend) — called on a -/// definitive pre-send rejection, the one outcome that PROVES the transaction -/// is not on the wire — frees them outright. +/// [`settle_released`](Self::settle_released) — called on a definitive pre-send +/// rejection, the one outcome that PROVES the transaction is not on the wire — +/// frees them outright. /// /// The default is deliberately the conservative one (`dashpay/platform#4309`). /// This guard's drop runs on paths that carry no information about whether the @@ -507,13 +530,20 @@ impl WalletGeneration { /// /// Fencing by default is only half of it: a fence installed with an /// ALREADY-LAPSED bound is indistinguishable from no fence. The bound therefore -/// comes from [`anchor_pending_spend`](Self::anchor_pending_spend), called with -/// a `last_processed_height` sampled AFTER the broadcaster returned and while -/// this pin's dispatching phase is still held. A pin that never reaches that -/// call — the cancellation and unwind paths — settles UNANCHORED and blocks +/// comes from [`settle_pending_spend`](Self::settle_pending_spend), called with +/// a `last_processed_height` sampled AFTER the broadcaster returned and from a +/// manager guard still held across the call. A pin that never reaches that call +/// — the cancellation and unwind paths — settles UNANCHORED and blocks /// unconditionally until the next coin selection stamps it from its own clock. /// Neither path can consult the pre-await height, because this pin does not /// carry one (`dashpay/platform#4309`). +/// +/// That settle CONSUMES the pin, so the dispatching→pending transition is a +/// statement the dispatch places inside its guard scope rather than an +/// end-of-scope drop that can float outside it. When it floated, a manager +/// writer queued behind the released guard could advance the height between the +/// sample and the install, and the fence landed dead in the same instant the +/// dispatching hold was lifted (`dashpay/platform#4309`, review round 2). pub(crate) struct InBroadcastPin { generation: Arc, outpoints: Vec, @@ -524,35 +554,64 @@ pub(crate) struct InBroadcastPin { } impl InBroadcastPin { - /// Bound the pending-spend fence at `current_height` + + /// End the dispatching phase and install the pending-spend fence, bounded at + /// `current_height` + /// [`IN_BROADCAST_FENCE_BLOCKS`](crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS). /// /// `current_height` MUST be a `last_processed_height` sampled after the - /// broadcaster returned, under the wallet-manager guard, while this pin is - /// still alive. Those three conditions are what make the bound meaningful: - /// sampling before the await lets a long suspension (minutes, mid-catch-up - /// on mobile) age the anchor past the whole interval, so the fence lapses - /// the moment it is installed and the next selection reselects an input of - /// a transaction that may have reached the network; holding the pin across - /// the sampling leaves no window between the broadcaster's return and the - /// stamp; and reading under the manager guard keeps this anchor on the same - /// clock the freshness check, key-wallet's TTL sweep and - /// [`WalletGeneration::in_broadcast_conflict`] all use - /// (`dashpay/platform#4309`). + /// broadcaster returned, from a wallet-manager guard **the caller is still + /// holding across this call**; `None` says the wallet has left the manager, + /// so there is no height to read and the fence settles unanchored (the safer + /// side — it then blocks unconditionally until the first selection stamps + /// it). /// - /// Not calling this is always SAFE — the fence simply stays unanchored and - /// is stamped by the first selection instead — so a cancelled dispatch, an - /// unwind, or a wallet that left the manager needs no special handling. - pub(crate) fn anchor_pending_spend(&mut self, current_height: u32) { - // A rejection already proved nothing was sent; a late anchor must not - // resurrect a fence the caller deliberately released. - if self.settle != PendingSpendSettle::Released { - self.settle = PendingSpendSettle::AnchoredAt(current_height); + /// # Why this CONSUMES the pin + /// + /// The dispatching→pending transition is this call. Ending it at an implicit + /// end-of-scope drop instead put the sample and the install in two different + /// critical sections: the guard the height was read under was released + /// first, and a manager writer queued behind it could advance + /// `last_processed_height` before the drop landed. The fence then arrived + /// bounded on a height the wallet had already left — installed dead — at the + /// same instant the dispatching hold was removed, so the outpoint went from + /// fully held to fully free with no pending-spend phase in between + /// (`dashpay/platform#4309`, review round 2). Taking `self` moves the + /// transition to a statement the caller *places*, inside the guard scope, + /// rather than to wherever the binding happens to end. + /// + /// Three conditions make the bound meaningful, and this signature is what + /// keeps all three checkable at the call site: the sample is POST-await (a + /// suspension of minutes mid-catch-up would otherwise age a pre-await anchor + /// past the whole interval, so the fence lapses the moment it is installed); + /// the pin is still alive across the sampling (no window between the + /// broadcaster's return and the stamp); and the reading comes from the + /// manager guard, the same clock the freshness check, key-wallet's TTL sweep + /// and [`WalletGeneration::in_broadcast_conflict`] all use. + /// + /// # Deadlock safety + /// + /// Safe to call with the manager guard held: the settle takes only + /// [`WalletGeneration::in_broadcast`], a `std::sync::Mutex`, for a few hash + /// operations, never awaits, and never touches the wallet-manager lock. That + /// is the crate's existing lock order — manager lock, then `in_broadcast`, + /// exactly as every `in_broadcast_conflict` call site takes them under the + /// manager WRITE guard — so nothing here can invert it. + /// + /// Not calling this at all is always SAFE: [`Drop`] settles the fence + /// unanchored, which is precisely the cancellation/unwind fallback (see the + /// type docs). + pub(crate) fn settle_pending_spend(mut self, current_height: Option) { + if let Some(height) = current_height { + self.settle = PendingSpendSettle::AnchoredAt(height); } + // The transition happens HERE — while the caller's manager guard is + // still held — not at whatever later point this binding would have gone + // out of scope. + drop(self); } - /// Free the outpoints outright on drop, instead of leaving the pending-spend - /// fence this pin installs by default. + /// Free the outpoints outright, consuming the pin, instead of leaving the + /// pending-spend fence it installs by default. /// /// Call ONLY on a definitive pre-send rejection — the single outcome that /// proves the transaction never reached the network, so there is nothing on @@ -560,8 +619,14 @@ impl InBroadcastPin { /// An ambiguous outcome, a cancellation, or an unwind must NOT call this: /// see the type docs and the `WalletGeneration::in_broadcast` field docs for /// why dispatch return is not, by itself, safe. - pub(crate) fn release_pending_spend(&mut self) { + /// + /// Unlike [`settle_pending_spend`](Self::settle_pending_spend) this needs no + /// manager guard and no height: it installs no bound, and freeing an + /// outpoint that provably never reached the wire cannot be mistimed by a + /// height advance. + pub(crate) fn settle_released(mut self) { self.settle = PendingSpendSettle::Released; + drop(self); } } @@ -621,9 +686,9 @@ mod tests { /// anchor is the POST-await reading — `pin_in_broadcast` is deliberately /// given no height at all (`dashpay/platform#4309`). fn settle_dispatched(generation: &Arc, tx: &Transaction, height: u32) { - let mut pin = generation.pin_in_broadcast(tx); - pin.anchor_pending_spend(height); - drop(pin); + generation + .pin_in_broadcast(tx) + .settle_pending_spend(Some(height)); } /// A held pin flags every input of the pinned transaction — and only @@ -638,7 +703,7 @@ mod tests { let b = outpoint(0x02, 1); let unrelated = outpoint(0x03, 0); - let mut pin = generation.pin_in_broadcast(&spending(&[a, b])); + let pin = generation.pin_in_broadcast(&spending(&[a, b])); // Both pinned inputs conflict; an unrelated selection does not. assert_eq!( @@ -660,9 +725,8 @@ mod tests { ); // A definitive pre-send rejection: nothing is on the wire, so the - // inputs are free again the moment the guard drops. - pin.release_pending_spend(); - drop(pin); + // inputs are free again the moment the pin settles. + pin.settle_released(); assert_eq!( generation.in_broadcast_conflict(&spending(&[a, b]), DISPATCH_HEIGHT), None, @@ -683,9 +747,9 @@ mod tests { let a = outpoint(0x07, 0); let tx = spending(&[a]); - // Neither `release_pending_spend()` nor `anchor_pending_spend()` — - // models a dispatch cancelled inside `broadcast`, which reaches - // neither call. + // Neither `settle_released()` nor `settle_pending_spend()` — models a + // dispatch cancelled inside `broadcast`, which reaches neither call and + // falls back to `Drop`. drop(generation.pin_in_broadcast(&tx)); assert_eq!( @@ -878,9 +942,7 @@ mod tests { // An explicit release — this models `BroadcastError::Rejected`, the one // outcome that proves the transaction never reached the network. - let mut pin = generation.pin_in_broadcast(&tx); - pin.release_pending_spend(); - drop(pin); + generation.pin_in_broadcast(&tx).settle_released(); assert_eq!( generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT), @@ -944,19 +1006,17 @@ mod tests { // Both model a definitive rejection, so the count — not a leftover // pending-spend fence — is what keeps the outpoint held. - let mut first = generation.pin_in_broadcast(&tx); - let mut second = generation.pin_in_broadcast(&tx); - first.release_pending_spend(); - second.release_pending_spend(); + let first = generation.pin_in_broadcast(&tx); + let second = generation.pin_in_broadcast(&tx); - drop(first); + first.settle_released(); assert_eq!( generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT), Some(a), "one dispatch still in flight must keep the outpoint fenced" ); - drop(second); + second.settle_released(); assert_eq!(generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT), None); } @@ -969,14 +1029,12 @@ mod tests { let a = outpoint(0x11, 0); let tx = spending(&[a]); - let mut early = generation.pin_in_broadcast(&tx); - let mut late = generation.pin_in_broadcast(&tx); + let early = generation.pin_in_broadcast(&tx); + let late = generation.pin_in_broadcast(&tx); // Each anchors on its OWN post-await sample; the later return sees the // higher clock. - early.anchor_pending_spend(DISPATCH_HEIGHT); - late.anchor_pending_spend(DISPATCH_HEIGHT + 5); - drop(late); - drop(early); + late.settle_pending_spend(Some(DISPATCH_HEIGHT + 5)); + early.settle_pending_spend(Some(DISPATCH_HEIGHT)); assert_eq!( generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT + IN_BROADCAST_FENCE_BLOCKS), From adb65b46e1d7bb54c39cd8a5f345919c27bae701 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:36:55 -0400 Subject: [PATCH 13/15] fix(platform-wallet): fence broadcast inputs until the spend is observed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pending-spend fence was bounded at `last_processed_height + N`. Three revisions of this fix moved where that height was sampled — before the broadcaster await, after it, after it under a still-held manager guard — and all three are unsound for a reason none of them addressed: elapsed chain height is not evidence about the dispatched transaction. During catch-up the wallet advances `last_processed_height` by thousands of blocks in seconds, and those blocks were mined BEFORE the transaction was submitted. A routine historical sync completing between the install and the next build therefore consumes the whole interval, the next `in_broadcast_conflict` reaps the fence, and the input is reselectable while the transaction may be on the wire. On the `DapiBroadcaster` path — which returns from `sdk.execute` without injecting anything into local wallet state — nothing else is holding it. The fence now ends on evidence: `WalletGeneration::observe_spent` releases an outpoint when the wallet OBSERVES it spent, by the dispatch's own transaction or by a competing one. Either way the outpoint has left the selectable set, so there is no re-selection left to race. The observation is driven by `SpendObservationHandler` off the wallet-event fan-out, projecting the same per-record input walk that produces `CoreChangeSet::spent_utxos`, so the fence and the persisted spent set cannot disagree about what "spent" means. `IN_BROADCAST_FENCE_BLOCKS` (24 blocks) is replaced by `IN_BROADCAST_FENCE_ORPHAN_TIMEOUT` (1 h), a pure anti-strand backstop for a transaction that is never observed at all. It is measured on `Instant` — the only clock here with no chain input, so catch-up, a re-org, historical headers and system clock changes cannot fast-forward it. Reading it needs no lock and no await, which collapses machinery the height version required: the anchored/unanchored split, the `in_broadcast_conflict` height parameter, and the manager-guarded `settle_dispatch_fence` all go away, and the deadline is now computed inside the same `in_broadcast` critical section that installs it. Also: * Typed `PlatformWalletError::InputMidBroadcast { outpoint }` replaces the three `message.contains("mid-broadcast")` string refusals at the finalized-transaction, contact-payment and asset-lock choke points. Its FFI code is deliberately unchanged (those variants already fell to `ErrorUnknown`); the mapping is now one explicit, documented arm. * The 16-repetition probabilistic handoff regression is replaced by a deterministic one. `on_next_settle_boundary` runs an observer AT the dispatching→pending midpoint and blocks the settle until it publishes, so there is no race to lose; the observer probes with `try_lock` to distinguish "held across the transition" from "granted after it". Evidence: the new `fence_survives_a_full_historical_catch_up_advance` fails on all four prior revisions (2b911bc40e, 89586f7248, 58efacf4b4, 7dde1c12d2), each producing a fully signed competing transaction spending the same outpoint with the fence map empty. The new handoff regression fails 10/10 against a split-transition implementation and passes 10/10 against this one. 869 lib tests + 9 green; fmt and clippy clean. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-ffi/src/error.rs | 19 + .../src/changeset/core_bridge.rs | 39 +- packages/rs-platform-wallet/src/error.rs | 32 + .../rs-platform-wallet/src/manager/mod.rs | 11 +- .../src/wallet/asset_lock/build.rs | 15 +- .../src/wallet/core/broadcast.rs | 840 +++++------- .../src/wallet/core/generation.rs | 1201 ++++++++++------- .../rs-platform-wallet/src/wallet/core/mod.rs | 2 + .../src/wallet/core/spend_observer.rs | 343 +++++ .../src/wallet/core/transaction.rs | 16 +- .../src/wallet/identity/network/payments.rs | 12 +- .../src/wallet/reservations.rs | 92 +- 12 files changed, 1540 insertions(+), 1082 deletions(-) create mode 100644 packages/rs-platform-wallet/src/wallet/core/spend_observer.rs diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 0207c9846f1..5b67bb9bb76 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -620,6 +620,25 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::StaleReservation => { PlatformWalletFFIResultCode::ErrorStaleReservationToken } + // A coin selection that picked an input still held by an in-flight + // broadcast dispatch. Typed on the Rust side (it carries the + // conflicting `OutPoint`, and is the one build refusal that is + // safely retryable unchanged), but DELIBERATELY mapped to the same + // numeric code it produced before that variant existed: all three + // choke points previously returned it as + // `TransactionBuild` / `AssetLockTransaction`, neither of which is + // matched here, so both fell to `ErrorUnknown`. + // + // Minting a dedicated code is a separate, coordinated change — the + // numeric space is a cross-PR registry (see the claim table on + // `ErrorStaleReservationToken` above) and every new value has to be + // mirrored into the Swift and Kotlin result enums. This arm exists + // so the mapping is an explicit, reviewable decision in one place + // rather than an accident of the catch-all, and so it is a one-line + // change when a code is claimed (`dashpay/platform#4309`). + PlatformWalletError::InputMidBroadcast { .. } => { + PlatformWalletFFIResultCode::ErrorUnknown + } // A definitively-failed address-nonce race (reaches the blanket impl // via identity `top_up_from_addresses` → `?`/`.into()`). Exposing // provided/expected nonce as structured out-fields is INTENTIONALLY diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index df1b4701cf9..3a2950043d0 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -1089,9 +1089,9 @@ fn derive_spent_utxos(record: &TransactionRecord) -> Vec { .input_details .iter() .filter_map(|detail| { - let input = record.transaction.input.get(detail.index as usize)?; + let outpoint = spent_outpoint(record, detail)?; Some(Utxo { - outpoint: input.previous_output, + outpoint, txout: TxOut { value: detail.value, script_pubkey: ScriptBuf::default(), @@ -1108,6 +1108,41 @@ fn derive_spent_utxos(record: &TransactionRecord) -> Vec { .collect() } +/// The outpoint one [`InputDetail`] says this record spent, or `None` when the +/// detail's index does not address a real input. +/// +/// The single definition of "this record spent one of ours", shared by +/// [`derive_spent_utxos`] above — which turns it into the persister's +/// [`CoreChangeSet::spent_utxos`] removals — and by +/// [`spent_outpoints`], which drives the in-broadcast fence's release. The two +/// consumers must not be able to disagree about which inputs count: the fence +/// releases an outpoint precisely when the wallet treats it as spent, so a +/// divergence would either strand a fence forever or drop one early +/// (`dashpay/platform#4309`). +/// +/// [`InputDetail`]: key_wallet::managed_account::transaction_record::InputDetail +fn spent_outpoint( + record: &TransactionRecord, + detail: &key_wallet::managed_account::transaction_record::InputDetail, +) -> Option { + record + .transaction + .input + .get(detail.index as usize) + .map(|input| input.previous_output) +} + +/// Every outpoint of ours that `record` spends. +/// +/// The fence-side view of [`derive_spent_utxos`], built on the same +/// [`spent_outpoint`] walk — see that function for why they share it. +pub(crate) fn spent_outpoints(record: &TransactionRecord) -> impl Iterator + '_ { + record + .input_details + .iter() + .filter_map(|detail| spent_outpoint(record, detail)) +} + impl CoreChangeSet { /// Cheap "should we bother round-tripping the persister" check used /// by the adapter to drop empty events without locking. Skips the diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 8bbc9baef9c..b6fa770c8b7 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -148,6 +148,38 @@ pub enum PlatformWalletError { #[error("Transaction building failed: {0}")] TransactionBuild(String), + /// Coin selection picked an outpoint that a broadcast dispatch is still + /// holding — the transaction spending it is in flight, or has reached the + /// network and has not yet been observed spent by this wallet + /// ([`WalletGeneration::in_broadcast_conflict`](crate::wallet::core::WalletGeneration::in_broadcast_conflict)). + /// Completing the build would race that transaction on the wire, so it is + /// refused and its own fresh reservation released. NOTHING was built, + /// signed or broadcast. + /// + /// A TRANSIENT, EXPECTED condition, and the reason it is a variant of its + /// own rather than a [`Self::TransactionBuild`] / + /// [`Self::AssetLockTransaction`] string: it is the one build failure a + /// caller may safely retry UNCHANGED once the in-flight dispatch settles, + /// and telling it apart from a genuine build failure previously meant + /// substring-matching prose (`message.contains("mid-broadcast")`, which the + /// tests did too). All three selection choke points — the + /// finalized-transaction build, the contact-payment build and the + /// asset-lock build — now return this one variant. + /// + /// `outpoint` is the first conflicting input, carried structurally so + /// callers and diagnostics need not parse it back out of a message. + /// + /// Reaching a caller at all is the uncommon path: a fenced input is + /// normally still reserved and never offered to selection. This fires only + /// in the window after key-wallet's reservation TTL swept that dispatch's + /// reservation, which is exactly what the fence exists to cover + /// (`dashpay/platform#4309`). + #[error( + "selected input {outpoint} is mid-broadcast by an in-flight dispatch; \ + retry after it completes" + )] + InputMidBroadcast { outpoint: dashcore::OutPoint }, + /// The address handed to [`CoreWallet::sign_message`] cannot be a signing /// target at all: unparseable, encoded for a different network than the /// wallet's, or not P2PKH. A caller-input error — the classic Dash diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 1e64401db2b..c399569f40b 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -31,7 +31,7 @@ use crate::manager::platform_address_sync::PlatformAddressSyncManager; use crate::manager::shielded_sync::ShieldedSyncManager; use crate::spv::SpvRuntime; use crate::wallet::asset_lock::LockNotifyHandler; -use crate::wallet::core::BalanceUpdateHandler; +use crate::wallet::core::{BalanceUpdateHandler, SpendObservationHandler}; use crate::wallet::identity::network::DashPayPaymentHandler; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; use crate::wallet::PlatformWallet; @@ -467,6 +467,14 @@ impl PlatformWalletManager

{ // with SPV's write lock. let lock_handler = Arc::new(LockNotifyHandler::new(Arc::clone(&lock_notify))); let balance_handler = Arc::new(BalanceUpdateHandler::new(Arc::clone(&wallets))); + // SpendObservationHandler releases in-broadcast input fences when the + // wallet observes the fenced outpoints spent — the evidence that ends + // the fence a dispatch installs (`dashpay/platform#4309`). It takes the + // same `wallets` map, and for the same lock reason as the balance + // handler: the event fires inside SPV's block-processing write section, + // so the generation cannot be resolved through the wallet-manager lock. + let spend_observation_handler = + Arc::new(SpendObservationHandler::new(Arc::clone(&wallets))); // DashPayPaymentHandler records incoming DashPay payments and // confirms sent ones off the wallet-event fan-out, keeping that // domain logic out of the generic core-changeset bridge. It holds @@ -480,6 +488,7 @@ impl PlatformWalletManager

{ app_handler, lock_handler, balance_handler, + spend_observation_handler, Arc::clone(&dashpay_payment_handler) as Arc, ])); 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 664981aee3b..bd1251e3c7f 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -17,7 +17,6 @@ use key_wallet::wallet::managed_wallet_info::asset_lock_builder::{ }; use key_wallet::wallet::managed_wallet_info::managed_account_operations::ManagedAccountOperations; use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; -use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; @@ -234,10 +233,7 @@ impl AssetLockManager { // owner-guarded like the drain-floor abandon below. The consumed // funding key index is the same residue any discarded build leaves, // reclaimed by the gap-limit scan. - if let Some(pinned) = info.generation.in_broadcast_conflict( - &result.transaction, - info.core_wallet.last_processed_height(), - ) { + if let Some(outpoint) = info.generation.in_broadcast_conflict(&result.transaction) { // The pooled build reserves in EVERY contributing account's own // set under the one owner token, so the release must sweep // `result.funding_accounts` — the same per-account idiom as @@ -253,10 +249,11 @@ impl AssetLockManager { } } } - return Err(PlatformWalletError::AssetLockTransaction(format!( - "selected input {pinned} is mid-broadcast by an in-flight dispatch; \ - retry after it completes" - ))); + // Typed and shared with the other two choke points rather than an + // `AssetLockTransaction` string — the condition and the correct + // caller response are identical on all three + // (`PlatformWalletError::InputMidBroadcast`). + return Err(PlatformWalletError::InputMidBroadcast { outpoint }); } // 4. Pull the (pubkey, path) for our single credit output. diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 9326a871f85..f8770ecd819 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -3,7 +3,6 @@ use key_wallet::account::account_type::StandardAccountType; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::ReservationToken; -use super::generation::InBroadcastPin; use super::SignedCoreTransaction; use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; use crate::wallet::reservations::{broadcast_releasing_on_rejection, reservation_expired}; @@ -73,56 +72,49 @@ impl CoreWallet { /// immediately here, and the caller releases the reservation in the same /// breath, so an instant rebuild can reselect the inputs. /// * **Anything else** (accepted, or an ambiguous `MaybeSent`) — the pin is - /// converted to a pending-spend fence - /// ([`settle_dispatch_fence`](Self::settle_dispatch_fence)) - /// lasting - /// [`IN_BROADCAST_FENCE_BLOCKS`](crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS) - /// past a `last_processed_height` sampled **after** the broadcaster - /// returned. Once the wallet does observe the spend the outpoint stops - /// reaching selection at all, so the fence goes inert without waiting for - /// that bound; the bound is only the backstop for a transaction that is - /// never observed, and matches the TTL the reservation itself would have - /// had, re-anchored at the moment the transaction actually went out. + /// converted to a **pending-spend fence** that lasts until this wallet + /// OBSERVES the outpoints spent + /// ([`WalletGeneration::observe_spent`](super::WalletGeneration::observe_spent)), + /// by the dispatch's own transaction or by a competing one. /// - /// # Why the fence anchor is sampled after the await, not before + /// # Why the fence waits for an observation instead of a height bound /// - /// The height that authorizes the send and the height that bounds the fence - /// are the same *clock* but must not be the same *reading*. A broadcast - /// await can suspend for minutes in the middle of chain catch-up — the - /// ordinary mobile case — and if the wallet advances a full - /// `IN_BROADCAST_FENCE_BLOCKS` in that gap, a fence anchored on the - /// pre-await reading is already expired at the instant it is installed. The - /// next coin selection reaps it and may reselect an input of a transaction - /// that reached the network: an expired fence is indistinguishable from no - /// fence (`dashpay/platform#4309`). + /// Three earlier revisions bounded the pending-spend phase at + /// `last_processed_height + N` and disagreed only about where to sample the + /// height — before the await, after it, after it under a still-held guard. + /// Every one of them can be consumed by a routine historical catch-up: the + /// wallet advances that height by thousands of blocks in seconds, and those + /// blocks were mined BEFORE this transaction was submitted, so they are not + /// evidence that it has been seen or dropped. On the `DapiBroadcaster` path + /// — which returns from `sdk.execute` without injecting anything into local + /// wallet state — the input then becomes reselectable while the transaction + /// is in flight (`dashpay/platform#4309`, review round 5). /// - /// So the post-await sample is the SINGLE anchor for the installed fence, - /// which keeps the check-vs-fence clock consistency intact — both readings - /// come from `last_processed_height` under the manager guard, the same clock - /// `in_broadcast_conflict` and key-wallet's TTL sweep run on. The - /// **dispatching** phase stays held across that sampling, so there is no - /// window in which the outpoints are neither pinned nor fenced. + /// The release condition is therefore evidence, not elapsed chain: the + /// outpoint is freed when the wallet sees it spent. That is a fact about + /// this transaction rather than about the chain's past, and it arrives on + /// both broadcaster paths — SPV within milliseconds via its local mempool + /// pipeline, DAPI when the transaction is relayed back or lands in a block. /// - /// Sampling *and* installing happen under ONE guard - /// ([`settle_dispatch_fence`](Self::settle_dispatch_fence)), so the - /// dispatching→pending handoff is atomic with respect to height writers: no - /// manager writer can advance the clock between the reading and the install, - /// which is what would otherwise let the fence arrive already lapsed while - /// the dispatching hold was being lifted (`dashpay/platform#4309`, review - /// round 2). + /// An orphan backstop + /// ([`IN_BROADCAST_FENCE_ORPHAN_TIMEOUT`](crate::wallet::reservations::IN_BROADCAST_FENCE_ORPHAN_TIMEOUT)) + /// stops a transaction that is never observed at all — evicted for fee, + /// conflicted away — from holding its inputs for the life of the process. + /// It is measured on a monotonic [`Instant`](std::time::Instant), which no + /// amount of chain catch-up can fast-forward, and it is a liveness valve + /// rather than a safety argument. /// - /// A dispatch that never reaches the sample — the caller's future cancelled - /// or unwound inside `broadcast`, or the wallet removed from the manager — - /// settles its fence UNANCHORED, blocking unconditionally until the first - /// coin selection stamps it from its own height. That is the drop-time - /// clock, deferred to the first moment a synchronous `Drop` could act on it, - /// and the fence is unreachable in between. + /// # Why there is no post-await manager guard any more /// - /// Neither phase touches the wallet-manager lock while the broadcaster is - /// running, so nothing here can starve the SPV mempool pipeline: the guard - /// is still dropped before the broadcaster await, exactly as it was, and the - /// post-await sample is a short read taken only once the send has returned — - /// the same point the callers below already retake manager locks at. + /// Round 4 of this review added one: the fence's height had to be sampled + /// and installed inside a single manager read guard, or a writer queued + /// behind it could advance the clock in between and the fence would land + /// already lapsed. With no height to sample there is nothing for a height + /// writer to interleave with — the backstop deadline is read from + /// `Instant::now()` inside the same `in_broadcast` critical section that + /// installs it. So the settle needs no manager lock at all, and this method + /// now touches the wallet-manager lock exactly once, before the send, which + /// also removes a lock acquisition from every dispatch. /// /// A wallet no longer in the manager skips the pin (there is no /// registered generation to fence builds on — they cannot fund from a @@ -149,9 +141,11 @@ impl CoreWallet { // and — unless the send is definitively rejected — outlives the // broadcaster return too, as a pending-spend fence. // - // `height` is NOT handed to the pin. It anchors the freshness - // check and nothing else; the fence's own anchor is sampled after - // the await below, for the reason documented there. + // `height` is NOT handed to the pin, and no height is sampled for + // it later either. It authorizes the freshness check and nothing + // else; the fence answers to observed spends and a monotonic clock, + // because elapsed chain height is not evidence about this + // transaction (see the method docs). info.map(|info| info.generation.pin_in_broadcast(transaction)) // Guard dropped here — holding it across the await starves the // SPV pipeline that must complete the wait; the pin, not the @@ -172,77 +166,21 @@ impl CoreWallet { outcome, Err(crate::broadcaster::BroadcastError::Rejected { .. }) ) { - // Provably nothing on the wire: free the outpoints outright. - // No height and no guard — this installs no bound, so there is - // nothing a concurrent height advance could mistime. + // Provably nothing on the wire: free the outpoints outright, so + // an immediate rebuild can reselect them. pin.settle_released(); } else { // EVERY non-rejection outcome — accepted or ambiguous - // `MaybeSent` — hands the pin to the guarded settle below. - self.settle_dispatch_fence(pin).await; + // `MaybeSent` — opens the pending-spend phase, which holds the + // outpoints until the wallet observes them spent. No manager + // guard is taken: there is no height to sample, so there is + // nothing for a concurrent height writer to interleave with. + pin.settle_pending_spend(); } } GuardedDispatch::Sent(outcome) } - /// Close out one dispatch's **dispatching** phase and install its - /// **pending-spend** fence, both inside a SINGLE wallet-manager read guard. - /// - /// The fence is bounded a full - /// [`IN_BROADCAST_FENCE_BLOCKS`](crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS) - /// past a `last_processed_height` sampled here, after the broadcaster - /// returned. The pre-await sample cannot serve: `broadcast` can suspend for - /// minutes mid-catch-up (mobile), and if the wallet advances a whole fence - /// interval inside the await, a fence anchored back there is ALREADY LAPSED - /// when it is installed — the next coin selection reaps it and can reselect - /// an input of a transaction that may have reached the network. An expired - /// fence is no fence (`dashpay/platform#4309`). - /// - /// # Why the guard spans the settle, not just the sample - /// - /// Sampling the height under the guard is not enough on its own. The - /// dispatching→pending transition is the pin's settle, and while the sample - /// and the settle sat in two different critical sections the guard was - /// released between them: a manager writer queued behind it — SPV catch-up - /// applying a batch of blocks is exactly that writer — could advance - /// `last_processed_height` in the gap, so the fence landed anchored on a - /// height the wallet had already left, at the same instant the dispatching - /// hold was lifted. The outpoint went from fully held to fully free with no - /// live pending-spend phase in between (`dashpay/platform#4309`, review - /// round 2). - /// - /// Holding the guard across both makes the transition atomic with respect to - /// height writers, so the installed bound is measured from the height that - /// is current at the instant the fence becomes the only protection — the - /// fence is never born dead. `settle_pending_spend` CONSUMES the pin so this - /// is a statement placed inside the guard's scope rather than an - /// end-of-scope drop that a later edit could float back outside it. - /// - /// # This cannot deadlock, and cannot starve the SPV pipeline - /// - /// The settle takes only `WalletGeneration::in_broadcast`, a - /// `std::sync::Mutex`, for a few hash operations; it never awaits and never - /// touches the wallet-manager lock. That is the crate's existing lock order - /// — manager lock, then `in_broadcast`, exactly as every - /// `in_broadcast_conflict` call site takes them under the manager WRITE - /// guard — so no inversion is possible. And the guarded section is still the - /// short, await-free read the broadcaster await was deliberately kept out - /// of; nothing here is held across a network wait. - /// - /// A wallet that left the manager yields no height and settles the pin - /// UNANCHORED, which fences unconditionally until the first coin selection - /// stamps it from its own clock: strictly the safer side. - async fn settle_dispatch_fence(&self, pin: InBroadcastPin) { - let manager = self.wallet_manager.read().await; - let height = manager - .get_wallet_info(&self.wallet_id) - .map(|info| info.core_wallet.last_processed_height()); - // Consumes the pin: the dispatching hold lifts and the bound lands in - // one critical section, with `manager` still held around both. - pin.settle_pending_spend(height); - drop(manager); - } - /// Broadcast an atomically finalized transaction. A definitive rejection /// releases its reservation; an ambiguous `MaybeSent` outcome retains it. /// @@ -467,7 +405,7 @@ mod tests { RejectFirstBroadcaster, WalletSigner, }; use crate::wallet::core::CoreWallet; - use crate::wallet::reservations::{IN_BROADCAST_FENCE_BLOCKS, RESERVATION_MAX_AGE_BLOCKS}; + use crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS; use crate::{PlatformWalletError, SignedCoreTransaction}; /// Builds a testnet `CoreWallet` over the shared funded fixture and a @@ -844,6 +782,92 @@ mod tests { } } + /// The `WalletEvent` the wallet emits when it observes `tx` — the real + /// shape the spend-observation seam consumes. + /// + /// `input_details` is what upstream populates for inputs that spent OUR + /// outpoints, and it is the only part of the record either the fence or + /// `CoreChangeSet::spent_utxos` reads. + fn spend_event( + core: &CoreWallet, + tx: &Transaction, + ) -> key_wallet_manager::WalletEvent { + use key_wallet::managed_account::transaction_record::{ + InputDetail, TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::transaction_router::TransactionType; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + + let record = TransactionRecord::new( + tx.clone(), + key_wallet::account::AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::InBlock(BlockInfo::new( + 1_000, + dashcore::BlockHash::from([7u8; 32]), + 1_234_567_890, + )), + TransactionType::Standard, + TransactionDirection::Outgoing, + tx.input + .iter() + .enumerate() + .map(|(index, _)| InputDetail { + index: index as u32, + value: 0, + address: DashAddress::dummy(Network::Testnet, 1), + }) + .collect(), + Vec::new(), + 0, + ); + key_wallet_manager::WalletEvent::TransactionDetected { + wallet_id: core.wallet_id(), + record: Box::new(record), + balance: key_wallet::WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + addresses_derived: Vec::new(), + } + } + + /// Retire fences from `event` through the REAL projection the + /// `SpendObservationHandler` uses, so these tests exercise the production + /// event→outpoints mapping rather than a stand-in. + /// + /// Only the generation lookup is short-circuited: resolving it goes through + /// the manager's `wallets` map, which a `CoreWallet` fixture does not build. + fn observe_via_event_handler( + core: &CoreWallet, + event: key_wallet_manager::WalletEvent, + ) { + let spent = crate::wallet::core::spend_observer::observed_spends(&event); + assert!( + !spent.is_empty(), + "the fixture event must report at least one spend, or the test \ + would pass without observing anything" + ); + core.generation().observe_spent(spent); + } + + /// Assert that `result` is the typed in-broadcast conflict, and return the + /// outpoint it names. + /// + /// The tests used to spell this `message.contains("mid-broadcast")`, which + /// is exactly the substring-matching the typed + /// `PlatformWalletError::InputMidBroadcast` variant removes + /// (`dashpay/platform#4309`, review round 5 suggestion). + fn expect_mid_broadcast( + result: Result, + context: &str, + ) -> dashcore::OutPoint { + match result { + Err(PlatformWalletError::InputMidBroadcast { outpoint }) => outpoint, + other => panic!("{context}, got {other:?}"), + } + } + /// THE CHECK-TO-WIRE RACE the in-broadcast pin closes: the freshness /// check passes under the manager read guard, the guard drops, and the /// dispatch suspends inside the broadcaster BEFORE submission. Catch-up @@ -853,11 +877,10 @@ mod tests { /// and raced the already-signed transaction on the wire. With the pin /// held across the await, the competing finalize must be REFUSED. /// - /// The tail then covers the HANDOFF from the dispatching pin to the - /// pending-spend fence, which is where the catch-up in this test matters a - /// second time: the fence is anchored on the height sampled AFTER the - /// broadcaster returned, so a 48-block advance *inside* the await extends - /// the protection instead of expiring it (`dashpay/platform#4309`). + /// The tail covers the HANDOFF from the dispatching pin to the + /// pending-spend fence: the dispatch returns, the pin lifts, and the + /// outpoint stays fenced anyway — now with no dependence on how far the + /// 48-block catch-up moved the chain clock. #[tokio::test] async fn in_broadcast_pin_blocks_reselection_until_dispatch_returns() { let entered = Arc::new(tokio::sync::Barrier::new(2)); @@ -875,41 +898,30 @@ mod tests { .await .expect("last processed height"); let finalized = finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + let fenced = finalized.transaction().input[0].previous_output; - // Age the handle to ONE BELOW the guard bound: the freshness check - // must pass, which is exactly what makes the pre-submission window - // dangerous without the pin. + // Dispatch at the oldest height the age guard admits, so the pin is + // taken and the broadcaster then parks pre-submission. advance_processed_height(&core, stamped + RESERVATION_MAX_AGE_BLOCKS - 1).await; - let dispatcher = tokio::spawn({ let core = core.clone(); async move { core.broadcast_finalized_transaction(&finalized).await } }); - // The dispatcher is now suspended INSIDE the broadcaster: freshness - // checked, manager guard dropped, pin held. entered.wait().await; - // Catch-up races far past key-wallet's TTL measured from the original - // reservation stamp, so the NEXT selection's sweep reclaims the - // dispatched build's reservation and its input returns to the - // selectable pool. + // Catch-up well past key-wallet's reservation TTL while the dispatch is + // suspended: the reservation is swept, so only the pin holds the input. advance_processed_height(&core, stamped + RESERVATION_MAX_AGE_BLOCKS + 48).await; - - // The competing finalize re-selects the fixture's only UTXO — the - // pinned input — and must be refused by the pin backstop, not - // completed into a conflicting signed transaction. - let competing = - try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; - match competing { - Err(PlatformWalletError::TransactionBuild(message)) => assert!( - message.contains("mid-broadcast"), - "the refusal must name the in-flight broadcast, got: {message}" + let racing = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + assert_eq!( + expect_mid_broadcast( + racing, + "a build that swept a mid-dispatch reservation must be refused" ), - other => panic!("a build re-selecting a pinned input must be refused, got {other:?}"), - } + fenced, + "the refusal must name the conflicting outpoint" + ); - // Let the dispatch complete: the send succeeds (the age check passed - // before the suspension). release.wait().await; let sent = dispatcher.await.expect("dispatcher task"); assert!( @@ -917,62 +929,109 @@ mod tests { "the pinned dispatch itself must complete, got {sent:?}" ); - // The dispatching pin has now lifted — but the input is NOT selectable - // again, and the difference is the point of `dashpay/platform#4309`. - // - // This assertion used to read the other way: the fence was anchored on - // the height sampled BEFORE the await, the catch-up above raced 48 - // blocks past it, and the fence was therefore installed already lapsed, - // so a new build took the input immediately. That "the pin lifted" pass - // was the bug in test form — the transaction had reached the network. - // - // The fence is now anchored on the POST-await sample, which is the - // height this catch-up moved to, so the input stays held. - let post_await = stamped + RESERVATION_MAX_AGE_BLOCKS + 48; - assert!( - post_await >= (stamped + RESERVATION_MAX_AGE_BLOCKS - 1) + IN_BROADCAST_FENCE_BLOCKS, - "this test's catch-up must outrun a PRE-await anchor, so the \ - assertion below distinguishes the two anchors" - ); + // The dispatching pin has now lifted — and the input is STILL not + // selectable. This assertion has been through three revisions of + // `dashpay/platform#4309`: it originally asserted the input was free + // again (the bug), then that it was fenced until a height-anchored + // bound. It now holds regardless of the chain clock, because the fence + // is waiting for an observed spend that this mock manager — which runs + // no mempool pipeline, exactly like the `DapiBroadcaster` path — never + // produces. let still_fenced = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; - match still_fenced { - Err(PlatformWalletError::TransactionBuild(message)) => assert!( - message.contains("mid-broadcast"), - "the post-dispatch refusal must name the in-flight broadcast, got: {message}" - ), - other => panic!( - "catch-up during the await must not expire the fence before it \ - is installed, got {other:?}" + expect_mid_broadcast( + still_fenced, + "the broadcaster returning is not the spend being observed, so the \ + input must stay fenced", + ); + } + + /// `dashpay/platform#4309`, THE ROUND-5 BLOCKER, VERBATIM. + /// + /// > after [the guard is released], a synchronization writer queued during + /// > the short critical section — or ordinary catch-up completing before + /// > the next build — can immediately advance `last_processed_height` by + /// > the whole interval. […] Those elapsed heights may be historical blocks + /// > mined BEFORE the transaction was submitted, so they provide no + /// > evidence that the submitted transaction has been observed or dropped. + /// + /// This is the test that FAILS on every prior revision of this PR. Each of + /// them installed `pending_until = + IN_BROADCAST_FENCE_BLOCKS` + /// and reaped the fence once `last_processed_height` reached it; the + /// catch-up below clears that bound by a wide margin no matter which height + /// was sampled — pre-await, post-await, or post-await under a held guard — + /// so all three leave the input reselectable here while the transaction is + /// on the network. + /// + /// The broadcaster is `AlwaysOk`: the transaction is ACCEPTED, so it is + /// certainly on the wire. The manager runs no mempool pipeline, which is + /// the `DapiBroadcaster` shape — `sdk.execute` returns without injecting + /// anything locally — so nothing has observed the spend. + #[tokio::test] + async fn fence_survives_a_full_historical_catch_up_advance() { + let (core, signer, outputs) = funded_core_wallet( + StandardAccountType::BIP44Account, + Arc::new(AlwaysOkBroadcaster), + ) + .await; + let stamped = core + .last_processed_height() + .await + .expect("last processed height"); + let finalized = finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + let fenced = finalized.transaction().input[0].previous_output; + + assert!(core + .broadcast_finalized_transaction(&finalized) + .await + .is_ok()); + + // Historical catch-up. Not a few blocks past some bound — a whole + // month of blocks, all of them mined long before this transaction was + // submitted, applied in the instant between the dispatch returning and + // the next build. This is the ordinary mobile resync, and it is what + // consumed every height-anchored bound this PR previously shipped. + let caught_up = stamped + 17_000; + advance_processed_height(&core, caught_up).await; + + let racing = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + assert_eq!( + expect_mid_broadcast( + racing, + "historical catch-up must not retire a fence: those blocks predate \ + the transaction and are not evidence it was seen or dropped" ), - } + fenced, + ); + + // And it is not merely slow to expire — no amount of further chain + // progress retires it either. + advance_processed_height(&core, caught_up + 500_000).await; + expect_mid_broadcast( + try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await, + "no quantity of elapsed height may retire the fence", + ); - // And it lapses a full interval past that post-await anchor — bounded, - // not permanent. - advance_processed_height(&core, post_await + IN_BROADCAST_FENCE_BLOCKS).await; + // The ONLY things that can: an observed spend, or the orphan backstop. + core.generation().observe_spent([fenced]); let after = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; let after = after.unwrap_or_else(|error| { - panic!("the fence must lapse a bound past the post-await anchor, got {error:?}") + panic!("an observed spend must release the fence, got {error:?}") }); core.abandon_transaction(&after).await; } - /// `dashpay/platform#4309`: THE RACE THE DISPATCHING PIN ALONE LEFT OPEN. - /// The broadcaster returning is not the spend being observed. The mock - /// manager here runs no mempool pipeline, which is precisely the - /// `DapiBroadcaster` shape — `broadcast` awaits `sdk.execute` and injects - /// nothing into this wallet's state — so at dispatch return the input is - /// still in the selectable set while the transaction is in flight. With the - /// pin dropped at that point, a competing build re-selected it immediately - /// (the previous revision of the test above asserted exactly that). The - /// pending-spend fence keeps it out until - /// `IN_BROADCAST_FENCE_BLOCKS` past the dispatch height, and no longer: - /// a never-observed transaction must not strand its inputs forever. + /// The fence's designed release: the wallet OBSERVES the dispatched + /// transaction's own spend, off the wallet-event fan-out. /// - /// Heights are chosen so the reservation is provably swept while the fence - /// still stands — the state pre-fix was "unreserved AND unfenced". + /// Drives the real seam — [`SpendObservationHandler`] fed a + /// `TransactionDetected` event carrying the dispatched transaction — rather + /// than calling `observe_spent` directly, so this covers the projection + /// from a `WalletEvent` to the outpoints it retires. That projection is + /// shared with `CoreChangeSet::spent_utxos`, so the fence and the + /// persister's spent set cannot disagree. #[tokio::test] - async fn dispatched_input_stays_fenced_after_the_broadcaster_returns() { + async fn observing_the_dispatched_transaction_releases_the_fence() { let (core, signer, outputs) = funded_core_wallet( StandardAccountType::BIP44Account, Arc::new(AlwaysOkBroadcaster), @@ -983,84 +1042,49 @@ mod tests { .await .expect("last processed height"); let finalized = finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + let sent_tx = finalized.transaction().clone(); + let fenced = sent_tx.input[0].previous_output; - // Dispatch at the OLDEST height the age guard still admits — one below - // `RESERVATION_MAX_AGE_BLOCKS`. That is what separates the two clocks: - // the reservation's TTL runs from `stamped`, the fence's bound from - // here, so there is a window in which the reservation is swept and only - // the fence protects the input. (A handle sitting between finalize and - // broadcast is exactly how that gap arises in production.) - let dispatch_height = stamped + RESERVATION_MAX_AGE_BLOCKS - 1; - advance_processed_height(&core, dispatch_height).await; assert!(core .broadcast_finalized_transaction(&finalized) .await .is_ok()); - // Catch-up past key-wallet's 24-block reservation TTL (measured from - // the reservation stamp), so the funding reservation is swept and the - // input returns to the selectable pool — but still short of the fence's - // dispatch-anchored bound. The fence is now the ONLY thing holding it; - // pre-fix this window was unreserved AND unfenced. - let swept_but_fenced = stamped + IN_BROADCAST_FENCE_BLOCKS + 4; - assert!( - swept_but_fenced >= stamped + 24 - && swept_but_fenced < dispatch_height + IN_BROADCAST_FENCE_BLOCKS, - "the probe height must be past key-wallet's reservation TTL and below the fence bound" - ); - advance_processed_height(&core, swept_but_fenced).await; - let racing = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; - match racing { - Err(PlatformWalletError::TransactionBuild(message)) => assert!( - message.contains("mid-broadcast"), - "the post-dispatch refusal must name the in-flight broadcast, got: {message}" - ), - other => panic!( - "an input handed to the network must stay fenced after the \ - broadcaster returns, got {other:?}" + // Catch-up past key-wallet's 24-block reservation TTL, so the funding + // reservation is swept and the input is back in the selectable pool. + // That is the window the fence exists for — without it the reservation, + // not the fence, is what refuses the competing build, and this test + // would pass without exercising the fence at all. + advance_processed_height(&core, stamped + 17_000).await; + assert_eq!( + expect_mid_broadcast( + try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await, + "the dispatched input must be fenced before the spend is observed", ), - } + fenced, + ); + + // The wallet sees its own transaction — mempool relay on the DAPI path, + // or the local pipeline on the SPV one. Either way this event is what + // arrives. + observe_via_event_handler(&core, spend_event(&core, &sent_tx)); - // At the bound the fence lapses and the input is selectable again. - advance_processed_height(&core, dispatch_height + IN_BROADCAST_FENCE_BLOCKS).await; let after = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; - let after = after - .unwrap_or_else(|error| panic!("the fence must lapse at its bound, got {error:?}")); + let after = after.unwrap_or_else(|error| { + panic!("observing the dispatch's own spend must release the fence, got {error:?}") + }); core.abandon_transaction(&after).await; } - /// `dashpay/platform#4309`: THE FENCE MUST NOT BE INSTALLED ALREADY EXPIRED. - /// - /// The scenario the reviewer identified, which fencing-by-default alone did - /// not close. The pending-spend bound used to be anchored on the - /// `last_processed_height` sampled BEFORE `broadcaster.broadcast().await`. - /// A broadcast await can suspend for minutes in the middle of chain - /// catch-up — routine on mobile — and if the wallet advances a full - /// `IN_BROADCAST_FENCE_BLOCKS` inside that await, the fence installed when - /// the send returns is ALREADY LAPSED. The very next coin selection reaps it - /// and reselects the input of a transaction that may be on the network: an - /// expired fence is indistinguishable from no fence at all. - /// - /// Here the broadcaster parks, catch-up runs a full fence interval plus 5 - /// blocks past the pre-await sample, and the send then returns `Ok` — the - /// accepted case, where the transaction is certainly on the wire. The input - /// must still be fenced afterwards, and the bound must run from the - /// POST-await height. - /// - /// The reservation is provably swept by then (the catch-up outruns - /// key-wallet's 24-block TTL measured from the build stamp), so the fence is - /// the only thing holding the input — pre-fix this window was unreserved AND - /// unfenced, and the competing finalize below returned `Ok`. + /// A COMPETING spend releases the fence too. The outpoint has left this + /// wallet's selectable set whoever spent it, so there is no re-selection + /// left that could race anything on the wire — continuing to fence would + /// only delay the backstop. #[tokio::test] - async fn fence_anchors_after_the_await_so_catch_up_cannot_pre_expire_it() { - let entered = Arc::new(tokio::sync::Barrier::new(2)); - let release = Arc::new(tokio::sync::Barrier::new(2)); + async fn observing_a_competing_spend_releases_the_fence() { let (core, signer, outputs) = funded_core_wallet( StandardAccountType::BIP44Account, - Arc::new(GatedBroadcaster { - entered: Arc::clone(&entered), - release: Arc::clone(&release), - }), + Arc::new(AlwaysOkBroadcaster), ) .await; let stamped = core @@ -1068,128 +1092,57 @@ mod tests { .await .expect("last processed height"); let finalized = finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + let fenced = finalized.transaction().input[0].previous_output; - // Dispatch while fresh: the pre-await sample is `stamped`, so that is - // the anchor the old code would have used. - let dispatcher = tokio::spawn({ - let core = core.clone(); - async move { core.broadcast_finalized_transaction(&finalized).await } - }); - entered.wait().await; + assert!(core + .broadcast_finalized_transaction(&finalized) + .await + .is_ok()); - // Catch-up INSIDE the await runs a whole fence interval past the - // pre-await sample. A fence anchored back there expires at - // `stamped + IN_BROADCAST_FENCE_BLOCKS`, which this height has passed. - let post_await = stamped + IN_BROADCAST_FENCE_BLOCKS + 5; - assert!( - post_await >= stamped + IN_BROADCAST_FENCE_BLOCKS, - "the catch-up must outrun a pre-await anchor for this test to \ - distinguish the two" + // Sweep the funding reservation (see the sibling test above), so the + // fence is the only thing holding the input. + advance_processed_height(&core, stamped + 17_000).await; + expect_mid_broadcast( + try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await, + "the dispatched input must be fenced before any spend is observed", ); - assert!( - post_await >= stamped + 24, - "the catch-up must also outrun key-wallet's reservation TTL, so the \ - fence is the only thing holding the input" - ); - advance_processed_height(&core, post_await).await; - // The send returns accepted — the transaction IS on the network. - release.wait().await; - let sent = dispatcher.await.expect("dispatcher task"); - assert!( - sent.is_ok(), - "the dispatch itself must succeed, got {sent:?}" + // A DIFFERENT transaction spending the same outpoint — a competing + // spend the wallet observes. Its txid differs from the dispatched one. + let mut competing = finalized.transaction().clone(); + competing.lock_time = finalized.transaction().lock_time + 1; + assert_ne!( + competing.txid(), + finalized.transaction().txid(), + "the fixture must model a genuinely different transaction" ); - // Pre-fix: the fence was installed already lapsed and this build - // succeeded, constructing a double-spend of a transaction on the wire. - let racing = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; - match racing { - Err(PlatformWalletError::TransactionBuild(message)) => assert!( - message.contains("mid-broadcast"), - "the refusal must name the in-flight broadcast, got: {message}" - ), - other => panic!( - "a fence anchored before the await expires during catch-up and \ - leaves the input reselectable, got {other:?}" - ), - } + observe_via_event_handler(&core, spend_event(&core, &competing)); - // The bound runs from the POST-await height: still fenced one below it, - // lapsed at it. Bounded protection, not a permanent hold. - advance_processed_height(&core, post_await + IN_BROADCAST_FENCE_BLOCKS - 1).await; - assert!( - matches!( - try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await, - Err(PlatformWalletError::TransactionBuild(_)) - ), - "one block below the post-await bound must still fence" - ); - advance_processed_height(&core, post_await + IN_BROADCAST_FENCE_BLOCKS).await; let after = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; let after = after.unwrap_or_else(|error| { - panic!("the fence must lapse at the post-await bound, got {error:?}") + panic!("a competing spend must also release the fence, got {error:?}") }); + assert_eq!( + after.transaction().input[0].previous_output, + fenced, + "the fixture has one UTXO, so the rebuild reselects the same outpoint" + ); core.abandon_transaction(&after).await; } - /// `dashpay/platform#4309`, REVIEW ROUND 2: THE DISPATCHING→PENDING HANDOFF - /// MUST BE ATOMIC AGAINST MANAGER WRITERS. - /// - /// Anchoring on the post-await sample is not enough if the guard that sample - /// was read under is released before the fence is installed. The sample sat - /// in one critical section and `drop(pin)` — the statement that lifts the - /// dispatching hold AND installs the bound — sat outside it. A manager - /// writer queued behind that guard (SPV catch-up applying a batch of blocks - /// is exactly that writer) is woken the instant it is released, and on - /// another worker thread it can advance `last_processed_height` before the - /// resuming dispatch reaches the install. The fence then lands anchored on a - /// height the wallet has already left, in the same instant the dispatching - /// hold goes away: born dead, reaped by the next selection, with the input - /// of a possibly-sent transaction reselectable. + /// The ORPHAN BACKSTOP, and its catch-up immunity in one test. /// - /// The writer here parks on the manager WRITE lock while the dispatch is - /// inside its post-await section, then advances a full fence interval, and - /// records what the transition looked like at the moment it was granted the - /// lock. Exactly two interleavings are legal, and the fence must be live - /// under both: - /// - /// * granted AFTER the handoff — it must see the pin settled (`dispatching` - /// lifted) with a bound that is still ahead of the height it holds, i.e. - /// the fence was born live; or - /// * granted BEFORE the dispatch even sampled — the dispatch then reads the - /// advanced height, so the installed bound must run from the writer's own - /// advance. - /// - /// The torn third case — granted mid-handoff, so it sees the pin still - /// dispatching AND the fence ends up anchored below its advance — is what - /// the released guard allowed, and is what this fails on. - /// - /// Repeated, because the interleaving is scheduler-dependent: the writer has - /// to be granted the lock inside the window to observe a split, and with the - /// sample and the settle adjacent that window is a handful of instructions. - /// It widens the moment anything suspends between them — an added `.await`, - /// a second guarded read — which is the regression this guards against, and - /// which a single attempt catches only intermittently. - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn settle_does_not_interleave_with_a_parked_height_writer() { - for attempt in 0..16 { - parked_writer_handoff_attempt(attempt).await; - } - } - - /// One scenario run of - /// [`settle_does_not_interleave_with_a_parked_height_writer`] — see its docs - /// for what the two legal interleavings are and why the third fails. - async fn parked_writer_handoff_attempt(attempt: u32) { - let entered = Arc::new(tokio::sync::Barrier::new(2)); - let release = Arc::new(tokio::sync::Barrier::new(2)); + /// A transaction that is never observed — evicted for fee, conflicted away + /// — must not hold its inputs for the life of the process. The backstop is + /// the release valve, and it runs on a monotonic clock: the enormous chain + /// advance below does not move it one bit, and only elapsing the real + /// deadline frees the input. + #[tokio::test] + async fn the_orphan_backstop_is_the_only_timeout_and_catch_up_cannot_move_it() { let (core, signer, outputs) = funded_core_wallet( StandardAccountType::BIP44Account, - Arc::new(GatedBroadcaster { - entered: Arc::clone(&entered), - release: Arc::clone(&release), - }), + Arc::new(AlwaysOkBroadcaster), ) .await; let stamped = core @@ -1199,133 +1152,45 @@ mod tests { let finalized = finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; let fenced = finalized.transaction().input[0].previous_output; - // A full fence interval plus a margin: a bound anchored on `stamped` is - // dead at this height, so an anchor below it is observably stale. - let writer_height = stamped + IN_BROADCAST_FENCE_BLOCKS + 5; - - let dispatcher = tokio::spawn({ - let core = core.clone(); - async move { core.broadcast_finalized_transaction(&finalized).await } - }); - // Parked inside `broadcast`: freshness checked, pre-await guard already - // dropped, pin held, and nothing holds the manager lock. - entered.wait().await; - - let writer = tokio::spawn({ - let manager = Arc::clone(&core.wallet_manager); - let generation = Arc::clone(core.generation()); - let wallet_id = core.wallet_id(); - async move { - // Wait for the dispatch to be INSIDE its post-await manager read - // section. While nothing holds the lock a `try_write` succeeds, - // so the first failure is that read guard being held. Bounded: - // if the section is missed the writer simply parks late, which - // is the "granted after the handoff" case below. - let mut spins = 0u32; - while manager.try_write().is_ok() { - spins += 1; - if spins >= 200_000 { - break; - } - if spins.is_multiple_of(512) { - tokio::task::yield_now().await; - } - } + assert!(core + .broadcast_finalized_transaction(&finalized) + .await + .is_ok()); - // Park behind that read guard, exactly as an SPV catch-up batch - // waiting to apply blocks does. - let mut guard = manager.write().await; - // What did the handoff look like the moment we were granted it? - let observed = generation.in_broadcast_fence_state(&fenced); - let (_, info) = guard - .get_wallet_and_info_mut(&wallet_id) - .expect("wallet present in manager"); - let height_at_grant = info.core_wallet.last_processed_height(); - info.core_wallet.update_last_processed_height(writer_height); - drop(guard); - (observed, height_at_grant) - } - }); + // Catch-up cannot fast-forward the backstop. + advance_processed_height(&core, stamped + 17_000).await; + expect_mid_broadcast( + try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await, + "the backstop is not measured in blocks, so catch-up must not consume it", + ); - release.wait().await; - let sent = dispatcher.await.expect("dispatcher task"); + // Elapsing the real (monotonic) deadline does. assert!( - sent.is_ok(), - "the dispatch itself must succeed, got {sent:?}" + core.generation().test_elapse_orphan_backstop(&fenced), + "an unobserved dispatch must carry a backstop deadline" ); - let (observed, height_at_grant) = writer.await.expect("writer task"); - - let installed = core - .generation() - .in_broadcast_fence_state(&fenced) - .expect("the dispatched input must still carry a fence"); - - match observed { - // Granted after the handoff completed: the bound must already have - // been installed, and installed LIVE at the height then current. - Some((0, false, Some(until))) => assert!( - until > height_at_grant, - "attempt {attempt}: the fence must be born live — bound {until} \ - was already lapsed at the height {height_at_grant} current when \ - the dispatching hold was lifted" - ), - // Granted before the dispatch sampled: the dispatch then reads the - // advanced height, so the bound must run from the writer's advance. - Some((1, false, None)) => assert!( - installed - .2 - .is_some_and(|until| until >= writer_height + IN_BROADCAST_FENCE_BLOCKS), - "attempt {attempt}: a writer that advanced the clock to \ - {writer_height} before the dispatch sampled must have its \ - advance reflected in the installed fence, got {installed:?} — a \ - lower bound means the dispatching→pending handoff interleaved \ - with the advance and the fence was installed on a stale height" - ), - other => panic!( - "attempt {attempt}: unexpected fence state at the writer's \ - grant: {other:?} (installed: {installed:?})" - ), - } - - // Whichever way it went, the fence is still BOUNDED — this hardening - // must not turn the pending-spend phase into a permanent hold. (The - // writer's own advance may already have consumed the bound: that is the - // designed lapse, not a defect, so only probe below it when there is a - // below.) - let lapses_at = installed.2.expect("an anchored bound"); - if lapses_at > writer_height { - assert!( - matches!( - try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await, - Err(PlatformWalletError::TransactionBuild(_)) - ), - "the input must stay fenced below the installed bound" - ); - } - advance_processed_height(&core, lapses_at.max(writer_height)).await; let after = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; - let after = after - .unwrap_or_else(|error| panic!("the fence must lapse at its bound, got {error:?}")); + let after = after.unwrap_or_else(|error| { + panic!("the orphan backstop must eventually free the input, got {error:?}") + }); core.abandon_transaction(&after).await; } - /// `dashpay/platform#4309`, the CANCELLATION twin of the test above. + /// `dashpay/platform#4309`, the CANCELLATION path. /// /// A caller wrapping the send in `timeout`/`select!` drops the dispatching - /// future mid-`broadcast`. That path reaches neither the release nor the - /// post-await height sample, and cancellation proves nothing: DAPI may have - /// delivered the request while awaiting its response, SPV may have - /// dispatched to peers while awaiting an echo or IS-lock. Retaining the - /// fence there is necessary but NOT sufficient — if the retained fence is - /// stamped from a height sampled before the await, the same catch-up that - /// made the caller time out has already expired it. + /// future mid-`broadcast`. That path reaches neither the release nor any + /// return value, and cancellation proves nothing: DAPI may have delivered + /// the request while awaiting its response, SPV may have dispatched to + /// peers while awaiting an echo or IS-lock. So the fence must survive it — + /// and, unlike in earlier revisions, it needs no special case to do so: + /// `Drop` reads the same monotonic clock the normal path does, so a + /// cancelled dispatch settles exactly like a returning one. /// - /// So a cancelled dispatch settles its fence with NO bound, and the first - /// coin selection to consult it stamps a full interval from its own height, - /// read under the manager write guard. Here catch-up runs well past a fence - /// interval before the abort, and the input must still be refused. + /// Catch-up runs far past any bound a previous revision would have + /// installed before the abort. #[tokio::test] - async fn cancelled_dispatch_fence_survives_catch_up_during_the_await() { + async fn cancelled_dispatch_keeps_its_fence_across_catch_up() { let entered = Arc::new(tokio::sync::Barrier::new(2)); let release = Arc::new(tokio::sync::Barrier::new(2)); let (core, signer, outputs) = funded_core_wallet( @@ -1341,6 +1206,7 @@ mod tests { .await .expect("last processed height"); let finalized = finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; + let fenced = finalized.transaction().input[0].previous_output; let dispatcher = tokio::spawn({ let core = core.clone(); @@ -1349,10 +1215,7 @@ mod tests { // Parked inside `broadcast`: pin held, guard dropped, nothing decided. entered.wait().await; - // Catch-up past both key-wallet's reservation TTL and a whole fence - // interval measured from the pre-await sample. - let cancelled_at = stamped + IN_BROADCAST_FENCE_BLOCKS + 5; - advance_processed_height(&core, cancelled_at).await; + advance_processed_height(&core, stamped + 17_000).await; // Cancel mid-await, exactly as `timeout`/`select!` would. Awaiting the // handle guarantees the future — and with it `InBroadcastPin::drop` — @@ -1364,32 +1227,17 @@ mod tests { "the dispatching future must have been cancelled mid-broadcast" ); - // Pre-fix: the retained fence carried the pre-await anchor and was - // already lapsed here, so this build reselected an input whose - // transaction may have been delivered. - let racing = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; - match racing { - Err(PlatformWalletError::TransactionBuild(message)) => assert!( - message.contains("mid-broadcast"), - "the refusal must name the in-flight broadcast, got: {message}" - ), - other => panic!( - "a cancelled dispatch must not leave an already-expired fence, \ - got {other:?}" - ), - } - - // The build above is what anchored the fence, on the height it read. - // The interval runs from there, and then lapses. - advance_processed_height(&core, cancelled_at + IN_BROADCAST_FENCE_BLOCKS - 1).await; - assert!( - matches!( + assert_eq!( + expect_mid_broadcast( try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await, - Err(PlatformWalletError::TransactionBuild(_)) + "a cancelled dispatch may have reached the network, so its fence \ + must survive — including across catch-up" ), - "one block below the anchored bound must still fence" + fenced, ); - advance_processed_height(&core, cancelled_at + IN_BROADCAST_FENCE_BLOCKS).await; + + // Still a fence and not a permanent hold: the backstop applies here too. + assert!(core.generation().test_elapse_orphan_backstop(&fenced)); let after = try_finalize_tx(&core, AccountTypePreference::BIP44, &outputs, &signer).await; let after = after.unwrap_or_else(|error| { panic!("a cancelled dispatch's fence must still lapse, got {error:?}") diff --git a/packages/rs-platform-wallet/src/wallet/core/generation.rs b/packages/rs-platform-wallet/src/wallet/core/generation.rs index 7c034c7ef33..b2d35611fb3 100644 --- a/packages/rs-platform-wallet/src/wallet/core/generation.rs +++ b/packages/rs-platform-wallet/src/wallet/core/generation.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use std::ops::Deref; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::time::Instant; use dashcore::{OutPoint, Transaction}; use tokio::sync::{OwnedRwLockWriteGuard, RwLock, RwLockReadGuard}; @@ -86,12 +87,14 @@ pub struct WalletGeneration { /// /// [`InBroadcastFence`] holds both phases per outpoint: /// - /// * **dispatching** — a counted, non-expiring pin, live from check-and-pin - /// until the broadcaster returns *and* the post-return height sample that - /// anchors the next phase has been taken. - /// * **pending-spend** — a height-bounded fence installed *when the - /// broadcaster returns anything other than a definitive pre-send - /// rejection*, i.e. when the transaction may be on the network. + /// * **dispatching** — a counted, never-expiring pin, live from + /// check-and-pin until the broadcaster returns. + /// * **pending-spend** — installed when the broadcaster returns anything + /// other than a definitive pre-send rejection, i.e. when the transaction + /// may be on the network. It is released when the wallet OBSERVES the + /// outpoint spent ([`observe_spent`](Self::observe_spent)), and expires + /// only against an orphan backstop + /// ([`IN_BROADCAST_FENCE_ORPHAN_TIMEOUT`](crate::wallet::reservations::IN_BROADCAST_FENCE_ORPHAN_TIMEOUT)). /// /// The second phase exists because dispatch returning does not mean the /// wallet has observed the spend. `SpvBroadcaster` injects the transaction @@ -104,35 +107,56 @@ pub struct WalletGeneration { /// sweep + re-select race the pin was added to close /// (`dashpay/platform#4309`). /// - /// # The pending-spend phase is anchored AFTER the await, never before - /// - /// A full [`IN_BROADCAST_FENCE_BLOCKS`](crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS) - /// interval is measured from a `last_processed_height` sampled once the - /// broadcaster has returned — not from the height the freshness check - /// consumed on the way in. Anchoring on the pre-await sample looks - /// clock-consistent and is in fact the same defect one layer along: a - /// broadcast await can suspend for minutes mid-catch-up on mobile, and if - /// the wallet advances a full fence interval in that gap the fence is - /// ALREADY LAPSED at the instant it is installed. The next selection reaps - /// it and may reselect an input of a transaction that reached the network - /// — an already-expired fence is indistinguishable from no fence - /// (`dashpay/platform#4309`). + /// # The pending-spend phase ends on EVIDENCE, not on elapsed height + /// + /// Three earlier revisions bounded this phase at `height + N` blocks and + /// argued only about *which* height to anchor on — the pre-send check's, a + /// post-await sample, a post-await sample taken and installed under one + /// manager guard. All three are unsound for the same reason, which is not + /// about the anchor at all: `last_processed_height` is not a clock during + /// catch-up. The wallet can advance it by thousands of blocks in seconds, + /// and every one of those blocks was mined BEFORE the transaction was + /// submitted. Elapsed height says something about the chain's past and + /// nothing about whether a transaction submitted a moment ago has been seen + /// or dropped, so an ordinary historical sync completing between the + /// install and the next build consumes the whole interval and the input + /// becomes reselectable while the transaction may be on the wire + /// (`dashpay/platform#4309`, review round 5). + /// + /// So the bound is gone. The pending-spend phase is released by exactly one + /// thing — the wallet observing the outpoint spent, which is positive + /// evidence that the race the fence exists to prevent can no longer happen: + /// + /// * the dispatch's own transaction is seen in the mempool or in a block — + /// the spend the fence was protecting has landed; or + /// * a competing transaction spends the outpoint — the outpoint is gone + /// from this wallet's selectable set regardless, so there is nothing left + /// to fence. + /// + /// [`observe_spent`](Self::observe_spent) is driven from the same + /// spend-processing path that feeds + /// [`CoreChangeSet::spent_utxos`](crate::changeset::CoreChangeSet), so the + /// fence and the persisted spent set agree on what "spent" means by + /// construction. + /// + /// # The backstop is a monotonic wall clock, not a chain quantity + /// + /// A fence whose transaction is NEVER observed — evicted for fee, or + /// conflicted away — would otherwise hold its inputs for the life of the + /// process with nothing able to clear it. The orphan backstop + /// ([`IN_BROADCAST_FENCE_ORPHAN_TIMEOUT`](crate::wallet::reservations::IN_BROADCAST_FENCE_ORPHAN_TIMEOUT)) + /// is that release valve, and it is deliberately measured on + /// [`Instant`](std::time::Instant): a monotonic clock with no chain input, + /// which catch-up, a re-org, a peer feeding historical headers, or a system + /// clock adjustment cannot fast-forward. It is a liveness device and is not + /// claimed to be evidence of anything. /// - /// Clock consistency is preserved by making the post-await sample the - /// SINGLE anchor: it is read under the manager guard, from the same - /// `last_processed_height` the freshness check and key-wallet's TTL sweep - /// use, and the dispatching phase stays held across the sampling so no - /// selection can slip between the broadcaster's return and the fence being - /// stamped. - /// - /// When a dispatch stops without ever taking that sample — cancelled or - /// unwound inside `broadcast`, the case a caller's `timeout`/`select!` - /// produces — [`InBroadcastFence::pending_unanchored`] fences - /// unconditionally and the first selection to consult the fence anchors it - /// on ITS height. That is the drop-time clock, deferred to the earliest - /// moment it is both readable (a synchronous `Drop` cannot await the - /// manager lock) and relevant (nothing can reach a fenced outpoint in - /// between). + /// Reading it needs no lock and no await, so — unlike a height — it is + /// available from a synchronous `Drop`. That is what collapses the whole + /// anchored/unanchored split earlier revisions needed: the deadline is + /// computed inside the same `in_broadcast` critical section that installs + /// it, on every exit path including cancellation and unwind, so there is no + /// sample-to-install window left to make atomic. /// /// A *count* for the dispatching phase rather than a set: /// `broadcast_finalized_transaction` takes `&SignedCoreTransaction`, so a @@ -149,92 +173,119 @@ pub struct WalletGeneration { /// Never persisted: after a restart nothing is mid-dispatch, and a /// transaction that actually landed is reconciled by sync. in_broadcast: Mutex>, + /// Test-only one-shot hook fired at the dispatching→pending midpoint — + /// see [`WalletGeneration::on_next_settle_boundary`]. + #[cfg(test)] + settle_boundary_hook: SettleBoundaryHook, +} + +/// Holder for the test-only settle-boundary hook. +/// +/// A newtype purely so [`WalletGeneration`] can keep its derived [`Debug`]: +/// `Box` has none. +#[cfg(test)] +#[derive(Default)] +struct SettleBoundaryHook(Mutex>>); + +#[cfg(test)] +impl std::fmt::Debug for SettleBoundaryHook { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SettleBoundaryHook(..)") + } } /// One outpoint's broadcast fence — see `WalletGeneration::in_broadcast`. #[derive(Debug, Default)] struct InBroadcastFence { /// Dispatches currently *inside* the broadcaster await for this outpoint. - /// Non-expiring while non-zero: a suspended dispatch keeps its inputs - /// fenced no matter how far catch-up advances the clock. + /// Never expires while non-zero: a suspended dispatch keeps its inputs + /// fenced no matter what else happens. dispatching: u32, - /// A dispatch settled this outpoint's pending-spend phase without a - /// post-await height sample — the dispatching future was cancelled or - /// unwound inside `broadcast`, or the wallet left the manager before the - /// sample could be taken. Blocks UNCONDITIONALLY until - /// [`WalletGeneration::in_broadcast_conflict`] anchors it, because a - /// synchronous `Drop` has no lock-free way to read `last_processed_height` - /// and the pre-await sample is exactly the stale anchor that made the fence - /// arrive already lapsed (`dashpay/platform#4309`). - pending_unanchored: bool, - /// `last_processed_height` at which the ANCHORED pending-spend phase lapses. - /// `None` means no dispatch has handed this outpoint to the network with a - /// height to measure from. - pending_until: Option, + /// The [`Instant`] at which the pending-spend phase gives up waiting for an + /// observation and releases the outpoint anyway — the ORPHAN BACKSTOP, for + /// a transaction the wallet never sees spent at all. + /// + /// `None` means no dispatch has handed this outpoint to the network (or an + /// observation already retired the phase). A monotonic instant, never a + /// height: see the `WalletGeneration::in_broadcast` field docs for why + /// every chain-derived bound here was unsound. + pending_until: Option, + /// The wallet has OBSERVED this outpoint spent + /// ([`WalletGeneration::observe_spent`]). Retires the pending-spend phase + /// and suppresses re-installation by a dispatch of the same transaction + /// that is still inside its broadcaster await — the SPV path routinely + /// observes the spend before `broadcast` returns, and re-fencing an + /// already-spent outpoint for a full backstop interval would leave dead + /// entries in the map for no benefit. + observed_spent: bool, } impl InBroadcastFence { - /// Whether this fence still blocks re-selection at `current_height`. - fn blocks(&self, current_height: u32) -> bool { - self.dispatching > 0 - || self.pending_unanchored - || self - .pending_until - .is_some_and(|until| current_height < until) + /// Whether this fence still blocks re-selection at `now`. + /// + /// Takes no height, deliberately. The fence is released by evidence + /// ([`WalletGeneration::observe_spent`]) and, failing that, by a monotonic + /// clock — never by chain progress, which during catch-up runs over blocks + /// that predate the dispatch entirely (`dashpay/platform#4309`). + fn blocks(&self, now: Instant) -> bool { + self.dispatching > 0 || self.pending_until.is_some_and(|until| now < until) } - /// Give the pending-spend phase a bound measured from `current_height`, the - /// caller's `last_processed_height` read under the manager WRITE guard. - /// - /// Called by [`WalletGeneration::in_broadcast_conflict`] before it decides - /// anything, so an unanchored fence is bounded at the first moment it could - /// possibly matter — a fenced outpoint is unreachable by any other path, so - /// there is no window between the settle and this stamp. Never SHORTENS an - /// existing bound: a concurrent dispatch of the same transaction may - /// already have installed a longer one. - fn anchor(&mut self, current_height: u32) { - if !self.pending_unanchored { + /// Open (or extend) the pending-spend phase, expiring one orphan-backstop + /// interval past `now`. + /// + /// A no-op once the spend has been observed: the evidence that retires the + /// phase must not be undone by a slower concurrent dispatch of the same + /// transaction settling afterwards. + /// + /// Never SHORTENS an existing deadline. Two concurrent dispatches of the + /// same transaction must both be covered, so the later one wins. + fn open_pending(&mut self, now: Instant) { + if self.observed_spent { return; } - self.pending_unanchored = false; - self.extend_pending_until( - current_height.saturating_add(crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS), - ); + let until = now + crate::wallet::reservations::IN_BROADCAST_FENCE_ORPHAN_TIMEOUT; + self.pending_until = Some(self.pending_until.map_or(until, |cur| cur.max(until))); } - /// Push the anchored bound out to `until`, never in. Two concurrent - /// dispatches of the same transaction must BOTH be covered, so the later - /// bound wins. - fn extend_pending_until(&mut self, until: u32) { - self.pending_until = Some(self.pending_until.map_or(until, |cur| cur.max(until))); + /// Record that the wallet observed this outpoint spent and retire the + /// pending-spend phase. + /// + /// The dispatching count is untouched: it tracks live `InBroadcastPin`s, + /// not chain state, and a pin must end at its own drop or the count leaks. + fn observe_spent(&mut self) { + self.observed_spent = true; + self.pending_until = None; } /// Whether nothing holds this outpoint any more, so the entry can be /// dropped from the map. fn is_clear(&self) -> bool { - self.dispatching == 0 && !self.pending_unanchored && self.pending_until.is_none() + self.dispatching == 0 && self.pending_until.is_none() } } /// How one dispatch's pending-spend phase settles when its [`InBroadcastPin`] /// is dropped — see [`WalletGeneration::pin_in_broadcast`]. /// -/// The variants are ordered by how much the dispatch managed to prove, and the -/// INITIAL value is the least-informed one: a pin that learns nothing before it -/// drops must fence (`dashpay/platform#4309`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// The INITIAL value is the least-informed one: a pin that learns nothing +/// before it drops must fence (`dashpay/platform#4309`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] enum PendingSpendSettle { - /// Nothing was learned: the dispatching future was cancelled or unwound - /// mid-`broadcast`, or its post-await height sample never completed. The - /// transaction may be on the wire and there is no trustworthy height to - /// measure from, so the fence is installed unanchored and - /// [`InBroadcastFence::anchor`] stamps it from the next selection's clock. - Unanchored, - /// The broadcaster returned something other than a definitive pre-send - /// rejection, and `last_processed_height` was sampled AFTER that return: - /// the fence lapses [`IN_BROADCAST_FENCE_BLOCKS`](crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS) - /// past this height. - AnchoredAt(u32), + /// The transaction may be on the network — the broadcaster returned + /// something other than a definitive pre-send rejection, or the dispatch + /// stopped without returning at all (cancelled or unwound mid-`broadcast`). + /// Both open the pending-spend phase, which then waits for an observed + /// spend and falls back on the orphan backstop. + /// + /// The two cases need no distinction any more. When the phase carried a + /// height-derived bound they did: a cancelled dispatch had no post-await + /// sample to anchor on, so it had to fence unanchored and borrow a later + /// selection's clock. A monotonic [`Instant`] is readable from `Drop` + /// itself, so both cases stamp their own deadline at the moment they + /// settle. + #[default] + Pending, /// A definitive pre-send rejection — the one outcome that proves the /// transaction never reached the network. No pending-spend phase at all. Released, @@ -253,6 +304,8 @@ impl WalletGeneration { balance: WalletBalance::new(), lifecycle: Arc::new(RwLock::new(())), in_broadcast: Mutex::new(HashMap::new()), + #[cfg(test)] + settle_boundary_hook: SettleBoundaryHook::default(), } } @@ -334,24 +387,23 @@ impl WalletGeneration { /// [`in_broadcast`](Self::in_broadcast) field docs for the full race). /// /// The dispatching phase has **no TTL** — a suspended dispatch keeps its - /// inputs fenced no matter how far catch-up advances the clock — and ends - /// only when the returned guard is dropped, which happens even when the - /// dispatching future is cancelled mid-await (`Drop` runs on unwind and on - /// future drop alike). - /// - /// # No height is taken here, deliberately - /// - /// This call takes NO `last_processed_height`, even though one is in hand - /// under the guard. The pending-spend phase must be measured from a height - /// sampled once the broadcaster has RETURNED - /// ([`InBroadcastPin::settle_pending_spend`]): a broadcast await can - /// suspend for minutes mid-catch-up, and a fence anchored on the pre-await - /// sample arrives already lapsed whenever the wallet advanced a full fence - /// interval in the gap — which is no fence at all - /// (`dashpay/platform#4309`). Not accepting the height makes that - /// mis-anchoring unrepresentable rather than merely fixed. A pin that is - /// never anchored still fences, unconditionally, until the first selection - /// stamps it (see [`InBroadcastPin`]). + /// inputs fenced no matter how long it takes — and ends only when the + /// returned guard is dropped, which happens even when the dispatching + /// future is cancelled mid-await (`Drop` runs on unwind and on future drop + /// alike). + /// + /// # No height is taken here, and none is taken later either + /// + /// This call takes NO `last_processed_height`, and neither does the settle + /// that follows it. Chain height cannot bound this fence at all: catch-up + /// advances it over blocks mined before the transaction was ever submitted, + /// so any `height + N` bound can be consumed by an ordinary historical sync + /// without a single piece of evidence about the dispatch + /// (`dashpay/platform#4309`). The pending-spend phase ends when the wallet + /// OBSERVES the outpoint spent ([`observe_spent`](Self::observe_spent)), + /// backstopped by a monotonic [`Instant`] the chain cannot move. Accepting + /// no height at either end makes the mis-anchoring unrepresentable rather + /// than merely corrected. /// /// Callers pin on the generation currently REGISTERED in the manager /// (`PlatformWalletInfo::generation`), the same object the build-side @@ -372,12 +424,11 @@ impl WalletGeneration { InBroadcastPin { generation: Arc::clone(self), outpoints, - // Fenced by default, and with no anchor yet: a pin that learns - // nothing before it drops must still hold the inputs. Only a - // definitive pre-send rejection releases, and only a post-await - // height sample bounds. See the `InBroadcastPin` type docs + // Fenced by default: a pin that learns nothing before it drops must + // still hold the inputs. Only a definitive pre-send rejection + // narrows this. See the `InBroadcastPin` type docs // (`dashpay/platform#4309`). - settle: PendingSpendSettle::Unanchored, + settle: PendingSpendSettle::Pending, } } @@ -394,42 +445,25 @@ impl WalletGeneration { /// case a fenced input is still *reserved* and never reaches selection at /// all; this check is the backstop for exactly the post-sweep window. /// - /// `current_height` is the caller's `last_processed_height`, read under the - /// same write guard — the identical clock the pending-spend bound was - /// stamped against and the one key-wallet's TTL sweep runs on. - /// - /// # Anchoring, before anything is decided - /// - /// A dispatch that stopped without a post-await height sample (cancelled or - /// unwound inside `broadcast`) leaves its fence UNANCHORED, because a - /// synchronous `Drop` cannot await the manager lock to read the clock. This - /// call supplies it: every unanchored fence is stamped - /// [`IN_BROADCAST_FENCE_BLOCKS`](crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS) - /// past `current_height` BEFORE the reap and the lookup below, so the - /// interval always runs from a clock at least as recent as the moment the - /// dispatch stopped — never from the stale pre-await sample that made the - /// fence arrive already lapsed (`dashpay/platform#4309`). - /// - /// Deferring the anchor to here loses nothing: this is the ONLY place the - /// fence is read, so between a pin's drop and this call there is no path by - /// which a fenced outpoint could be selected. It also cannot over-hold - /// across repeated builds — the stamp happens once, and the entry is a - /// plain bounded fence from then on. - /// - /// Lapsed entries are reaped here rather than by a timer: this is the only + /// # No height parameter, deliberately + /// + /// This used to take the caller's `last_processed_height` and reap every + /// fence the chain had advanced past. That is the defect: during catch-up + /// the wallet advances that height over blocks mined BEFORE the dispatch, + /// so an ordinary historical sync completing between a dispatch and this + /// call could retire a fence protecting a transaction that had just gone to + /// the network (`dashpay/platform#4309`, review round 5). The fence now + /// answers to observed spends and a monotonic clock only, so the chain + /// clock has no way in. + /// + /// Expired entries are reaped here rather than by a timer: this is the only /// place the fence is consulted, so pruning on read keeps the map bounded by /// the outpoints dispatched since the last build without any background /// task. - pub(crate) fn in_broadcast_conflict( - &self, - transaction: &Transaction, - current_height: u32, - ) -> Option { + pub(crate) fn in_broadcast_conflict(&self, transaction: &Transaction) -> Option { + let now = Instant::now(); let mut pinned = self.in_broadcast_lock(); - for fence in pinned.values_mut() { - fence.anchor(current_height); - } - pinned.retain(|_, fence| fence.blocks(current_height)); + pinned.retain(|_, fence| fence.blocks(now)); transaction .input .iter() @@ -437,25 +471,84 @@ impl WalletGeneration { .find(|outpoint| pinned.contains_key(outpoint)) } + /// Release the pending-spend fence on every outpoint in `outpoints` that + /// this wallet has just OBSERVED spent. + /// + /// This is the fence's real release path — the one that carries evidence. + /// It is driven off the wallet-event fan-out by + /// [`SpendObservationHandler`](super::SpendObservationHandler), from the + /// same per-record input walk that feeds + /// [`CoreChangeSet::spent_utxos`](crate::changeset::CoreChangeSet), so + /// "the fence considers this spent" and "the persister removes this UTXO" + /// are the same fact by construction. + /// + /// Both spend shapes are a release, and for the same reason — after either + /// one there is no longer a selectable outpoint whose re-selection could + /// race a transaction on the wire: + /// + /// * **the dispatch's own transaction**, seen in the mempool or in a block. + /// This is the overwhelmingly common case and the one the fence was + /// waiting for. + /// * **a competing transaction** spending the same outpoint. The outpoint + /// leaves this wallet's UTXO set either way; continuing to fence it would + /// protect nothing and only delay the orphan backstop. + /// + /// Idempotent, and safe for outpoints this generation never fenced — + /// block processing hands over every spend it sees, the vast majority of + /// which have nothing to do with any dispatch. + /// + /// Takes only the `in_broadcast` `std::sync::Mutex` for a few hash + /// operations and never awaits, so it is safe to call from a synchronous + /// event handler running inside SPV's block-processing write section. + pub(crate) fn observe_spent(&self, outpoints: impl IntoIterator) { + let mut pinned = self.in_broadcast_lock(); + for outpoint in outpoints { + let Some(fence) = pinned.get_mut(&outpoint) else { + continue; + }; + fence.observe_spent(); + if fence.is_clear() { + pinned.remove(&outpoint); + } + } + } + /// End one dispatch's hold on `outpoints` — the [`InBroadcastPin`] release /// half of [`pin_in_broadcast`](Self::pin_in_broadcast). /// /// `settle` says what that dispatch proved: /// - /// * [`PendingSpendSettle::AnchoredAt`] — it returned something other than a - /// definitive pre-send rejection AND a post-return `last_processed_height` - /// was sampled: the dispatching count drops but the outpoint stays fenced - /// a full interval past that height. - /// * [`PendingSpendSettle::Unanchored`] — it stopped without that sample - /// (cancelled or unwound mid-`broadcast`). The outpoint stays fenced with - /// no bound; [`InBroadcastFence::anchor`] supplies one from the next - /// selection's clock. + /// * [`PendingSpendSettle::Pending`] — the transaction may be on the + /// network (any non-rejection outcome, or a cancelled/unwound dispatch + /// that returned nothing at all). The dispatching count drops and the + /// pending-spend phase opens, to be released by an observed spend or, if + /// none ever arrives, by the orphan backstop. /// * [`PendingSpendSettle::Released`] — a definitive pre-send rejection, /// which frees the outpoint immediately: the transaction is provably not /// on the wire, and the caller releases its reservation in the same breath /// so an immediate rebuild can reselect. + /// + /// # One critical section, so the handoff is never observable half-done + /// + /// Lifting the dispatching hold and opening the pending-spend phase happen + /// under a single `in_broadcast` lock acquisition, and the backstop + /// deadline is computed from [`Instant::now`] INSIDE it. There is no + /// second clock to read and no guard to release in between, so no observer + /// can catch this outpoint in the torn state — `dispatching` already + /// lifted, pending-spend not yet open — that would make it briefly + /// selectable. Earlier revisions sampled a `last_processed_height` from the + /// wallet-manager lock and had to hold that guard across the install to get + /// the same property (`dashpay/platform#4309`, review round 4); reading a + /// monotonic clock needs no guard at all. + /// + /// [`Self::settle_boundary_hook`] fires at exactly that midpoint under + /// `cfg(test)`, which is what makes the property testable without racing + /// the scheduler for it. fn unpin_in_broadcast(&self, outpoints: &[OutPoint], settle: PendingSpendSettle) { + let now = Instant::now(); let mut pinned = self.in_broadcast_lock(); + #[cfg(test)] + self.fire_settle_boundary_hook(); for outpoint in outpoints { let Some(fence) = pinned.get_mut(outpoint) else { // Unreachable by construction — every pin inserts before its @@ -465,16 +558,7 @@ impl WalletGeneration { }; fence.dispatching = fence.dispatching.saturating_sub(1); match settle { - // Never shorten a fence another dispatch already extended: two - // concurrent dispatches of the same transaction must both be - // covered, so the later bound wins. - PendingSpendSettle::AnchoredAt(height) => fence.extend_pending_until( - height.saturating_add(crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS), - ), - // Additive alongside any existing bound rather than replacing - // it: this phase outlasts every anchored one until it is - // stamped, and stamping keeps the longer of the two. - PendingSpendSettle::Unanchored => fence.pending_unanchored = true, + PendingSpendSettle::Pending => fence.open_pending(now), PendingSpendSettle::Released => {} } if fence.is_clear() { @@ -483,28 +567,125 @@ impl WalletGeneration { } } - /// `outpoint`'s raw fence state — `(dispatching, pending_unanchored, - /// pending_until)` — or `None` when nothing holds it. + /// `outpoint`'s raw fence state — `(dispatching, pending_until, + /// observed_spent)` — or `None` when nothing holds it. /// /// A test-only WINDOW ON THE TRANSITION, deliberately not - /// [`in_broadcast_conflict`](Self::in_broadcast_conflict): that call anchors - /// and reaps as a side effect, so it cannot report whether a dispatch's + /// [`in_broadcast_conflict`](Self::in_broadcast_conflict): that call reaps + /// as a side effect, so it cannot report whether a dispatch's /// dispatching→pending handoff had completed at the moment it was observed. - /// `settle_does_not_interleave_with_a_parked_height_writer` needs exactly - /// that distinction (`dashpay/platform#4309`). #[cfg(test)] pub(crate) fn in_broadcast_fence_state( &self, outpoint: &OutPoint, - ) -> Option<(u32, bool, Option)> { - self.in_broadcast_lock().get(outpoint).map(|fence| { - ( - fence.dispatching, - fence.pending_unanchored, - fence.pending_until, - ) - }) + ) -> Option<(u32, Option, bool)> { + self.in_broadcast_lock() + .get(outpoint) + .map(|fence| (fence.dispatching, fence.pending_until, fence.observed_spent)) } + + /// Force `outpoint`'s orphan backstop to have already elapsed. + /// + /// The deterministic stand-in for waiting out + /// [`IN_BROADCAST_FENCE_ORPHAN_TIMEOUT`](crate::wallet::reservations::IN_BROADCAST_FENCE_ORPHAN_TIMEOUT). + /// Sets the deadline to the current instant, so the next + /// [`in_broadcast_conflict`](Self::in_broadcast_conflict) — which compares + /// `now < until` against a later reading of a monotonic clock — sees it + /// lapsed. Returns whether there was a pending phase to expire. + #[cfg(test)] + pub(crate) fn test_elapse_orphan_backstop(&self, outpoint: &OutPoint) -> bool { + let mut pinned = self.in_broadcast_lock(); + match pinned.get_mut(outpoint) { + Some(fence) if fence.pending_until.is_some() => { + fence.pending_until = Some(Instant::now()); + true + } + _ => false, + } + } + + /// Run `hook` at the dispatching→pending midpoint of the very next + /// [`unpin_in_broadcast`](Self::unpin_in_broadcast) on this generation: + /// after the `in_broadcast` lock is taken, before any fence is mutated. + /// + /// The test-only synchronization hook that makes the handoff regression + /// DETERMINISTIC (`dashpay/platform#4309`, review round 5 suggestion). The + /// previous regression parked a writer and hoped the scheduler granted it + /// the lock inside a window a handful of instructions wide, so it stayed + /// green against the pre-fix code. With this hook the observer is run at + /// the midpoint by construction, and what it can see there is the whole + /// assertion. + /// + /// One-shot: consumed by the settle that fires it, so an unrelated later + /// settle cannot re-enter the test's handshake. + #[cfg(test)] + pub(crate) fn on_next_settle_boundary(&self, hook: Box) { + *self + .settle_boundary_hook + .0 + .lock() + .unwrap_or_else(PoisonError::into_inner) = Some(hook); + } + + /// A NON-BLOCKING look at `outpoint`'s fence, for an observer that must + /// distinguish "the transition is in progress" from "the outpoint is free". + /// + /// A blocking read cannot make that distinction: correct code holds the + /// `in_broadcast` lock across the whole dispatching→pending handoff, so an + /// observer that simply waits for the lock always sees the finished state + /// and can never tell whether it was granted mid-transition or after it. + /// Probing with `try_lock` turns "held" into an observable outcome, which is + /// exactly the invariant the deterministic handoff regression asserts + /// (`dashpay/platform#4309`, review round 5). + #[cfg(test)] + pub(crate) fn try_probe_in_broadcast(&self, outpoint: &OutPoint) -> InBroadcastProbe { + match self.in_broadcast.try_lock() { + Err(std::sync::TryLockError::WouldBlock) => InBroadcastProbe::TransitionInProgress, + Err(std::sync::TryLockError::Poisoned(poisoned)) => { + Self::probe_entry(poisoned.into_inner().get(outpoint)) + } + Ok(pinned) => Self::probe_entry(pinned.get(outpoint)), + } + } + + #[cfg(test)] + fn probe_entry(fence: Option<&InBroadcastFence>) -> InBroadcastProbe { + match fence { + Some(fence) if fence.blocks(Instant::now()) => InBroadcastProbe::Fenced, + _ => InBroadcastProbe::Free, + } + } + + /// Take and run a hook armed by [`Self::on_next_settle_boundary`], if any. + /// + /// Called with the `in_broadcast` lock HELD, which is the point: an + /// observer that tries to read the fence from another thread while this + /// runs must find the lock held rather than a half-applied transition. + #[cfg(test)] + fn fire_settle_boundary_hook(&self) { + let hook = self + .settle_boundary_hook + .0 + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take(); + if let Some(hook) = hook { + hook(); + } + } +} + +/// What [`WalletGeneration::try_probe_in_broadcast`] saw. +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InBroadcastProbe { + /// The `in_broadcast` lock was held — a settle (or a conflict check) is + /// mid-flight, so the outpoint cannot be selected by anyone right now. + TransitionInProgress, + /// The outpoint carries a live fence. + Fenced, + /// Nothing holds the outpoint: a build could select it. + Free, } /// RAII guard for one dispatch's in-broadcast input fence — see @@ -526,87 +707,53 @@ impl WalletGeneration { /// of evidence that a send happened is not evidence that it did not, so the /// fence must survive every exit except the one that proves otherwise. /// -/// # The bound is set by the dispatch, not by the pin's birth +/// # The pending-spend phase waits for evidence, not for a bound to run out /// -/// Fencing by default is only half of it: a fence installed with an -/// ALREADY-LAPSED bound is indistinguishable from no fence. The bound therefore -/// comes from [`settle_pending_spend`](Self::settle_pending_spend), called with -/// a `last_processed_height` sampled AFTER the broadcaster returned and from a -/// manager guard still held across the call. A pin that never reaches that call -/// — the cancellation and unwind paths — settles UNANCHORED and blocks -/// unconditionally until the next coin selection stamps it from its own clock. -/// Neither path can consult the pre-await height, because this pin does not -/// carry one (`dashpay/platform#4309`). +/// Fencing by default is only half of it. Three earlier revisions paired that +/// default with a `last_processed_height + N` bound and argued about where to +/// sample the height; all three could be consumed by an ordinary historical +/// catch-up, because those elapsed blocks were mined before the transaction was +/// submitted and say nothing about it (`dashpay/platform#4309`, review round 5). /// -/// That settle CONSUMES the pin, so the dispatching→pending transition is a -/// statement the dispatch places inside its guard scope rather than an -/// end-of-scope drop that can float outside it. When it floated, a manager -/// writer queued behind the released guard could advance the height between the -/// sample and the install, and the fence landed dead in the same instant the -/// dispatching hold was lifted (`dashpay/platform#4309`, review round 2). +/// The pending-spend phase now ends when the wallet OBSERVES the outpoint spent +/// ([`WalletGeneration::observe_spent`]), with a monotonic-clock orphan backstop +/// so a transaction that is never observed cannot strand its inputs. Both are +/// readable without a lock, a guard or an await, so EVERY exit — normal return, +/// cancellation, unwind — settles the same way and stamps its own deadline. The +/// cancellation path needs no special case at all any more. pub(crate) struct InBroadcastPin { generation: Arc, outpoints: Vec, /// How the pending-spend phase settles on drop. Starts - /// [`PendingSpendSettle::Unanchored`] — the least-informed, most - /// conservative state — and is narrowed only by an explicit call. + /// [`PendingSpendSettle::Pending`] — the conservative state — and is + /// narrowed only by an explicit [`settle_released`](Self::settle_released). settle: PendingSpendSettle, } impl InBroadcastPin { - /// End the dispatching phase and install the pending-spend fence, bounded at - /// `current_height` + - /// [`IN_BROADCAST_FENCE_BLOCKS`](crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS). - /// - /// `current_height` MUST be a `last_processed_height` sampled after the - /// broadcaster returned, from a wallet-manager guard **the caller is still - /// holding across this call**; `None` says the wallet has left the manager, - /// so there is no height to read and the fence settles unanchored (the safer - /// side — it then blocks unconditionally until the first selection stamps - /// it). - /// - /// # Why this CONSUMES the pin - /// - /// The dispatching→pending transition is this call. Ending it at an implicit - /// end-of-scope drop instead put the sample and the install in two different - /// critical sections: the guard the height was read under was released - /// first, and a manager writer queued behind it could advance - /// `last_processed_height` before the drop landed. The fence then arrived - /// bounded on a height the wallet had already left — installed dead — at the - /// same instant the dispatching hold was removed, so the outpoint went from - /// fully held to fully free with no pending-spend phase in between - /// (`dashpay/platform#4309`, review round 2). Taking `self` moves the - /// transition to a statement the caller *places*, inside the guard scope, - /// rather than to wherever the binding happens to end. - /// - /// Three conditions make the bound meaningful, and this signature is what - /// keeps all three checkable at the call site: the sample is POST-await (a - /// suspension of minutes mid-catch-up would otherwise age a pre-await anchor - /// past the whole interval, so the fence lapses the moment it is installed); - /// the pin is still alive across the sampling (no window between the - /// broadcaster's return and the stamp); and the reading comes from the - /// manager guard, the same clock the freshness check, key-wallet's TTL sweep - /// and [`WalletGeneration::in_broadcast_conflict`] all use. - /// - /// # Deadlock safety - /// - /// Safe to call with the manager guard held: the settle takes only - /// [`WalletGeneration::in_broadcast`], a `std::sync::Mutex`, for a few hash - /// operations, never awaits, and never touches the wallet-manager lock. That - /// is the crate's existing lock order — manager lock, then `in_broadcast`, - /// exactly as every `in_broadcast_conflict` call site takes them under the - /// manager WRITE guard — so nothing here can invert it. - /// - /// Not calling this at all is always SAFE: [`Drop`] settles the fence - /// unanchored, which is precisely the cancellation/unwind fallback (see the - /// type docs). - pub(crate) fn settle_pending_spend(mut self, current_height: Option) { - if let Some(height) = current_height { - self.settle = PendingSpendSettle::AnchoredAt(height); - } - // The transition happens HERE — while the caller's manager guard is - // still held — not at whatever later point this binding would have gone - // out of scope. + /// End the dispatching phase and open the pending-spend fence, which then + /// waits for [`WalletGeneration::observe_spent`] and expires only against + /// the orphan backstop. + /// + /// # Why this takes no height, and no guard + /// + /// It used to take a `last_processed_height` sampled after the broadcaster + /// returned, from a wallet-manager guard the caller had to keep held across + /// the call so no writer could advance the clock between the sample and the + /// install. Both requirements are gone with the height itself: the orphan + /// deadline is [`Instant::now`] read INSIDE the `in_broadcast` critical + /// section that installs it, so there is no second clock, no guard to + /// release, and no window to protect (`dashpay/platform#4309`). + /// + /// # This is equivalent to just dropping the pin + /// + /// Kept as an explicit consuming call because it states the dispatch's + /// verdict at the call site, symmetrically with + /// [`settle_released`](Self::settle_released) — which is the one that + /// actually differs from the default. Not calling either is always SAFE: + /// [`Drop`] settles exactly this way, which is what makes the cancellation + /// and unwind paths correct without a special case. + pub(crate) fn settle_pending_spend(self) { drop(self); } @@ -620,10 +767,9 @@ impl InBroadcastPin { /// see the type docs and the `WalletGeneration::in_broadcast` field docs for /// why dispatch return is not, by itself, safe. /// - /// Unlike [`settle_pending_spend`](Self::settle_pending_spend) this needs no - /// manager guard and no height: it installs no bound, and freeing an - /// outpoint that provably never reached the wire cannot be mistimed by a - /// height advance. + /// This is the ONLY narrowing of the pin's default. Everything else — an + /// accepted send, an ambiguous `MaybeSent`, a cancellation, an unwind — + /// leaves the pending-spend fence in place to await an observed spend. pub(crate) fn settle_released(mut self) { self.settle = PendingSpendSettle::Released; drop(self); @@ -647,16 +793,12 @@ impl Deref for WalletGeneration { #[cfg(test)] mod tests { - use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{mpsc, Arc}; use dashcore::{OutPoint, Transaction, TxIn, Txid}; - use super::WalletGeneration; - use crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS; - - /// The height every test pins at, so a fence installed by - /// `retain_pending_spend` lapses at `DISPATCH_HEIGHT + IN_BROADCAST_FENCE_BLOCKS`. - const DISPATCH_HEIGHT: u32 = 1_000; + use super::{InBroadcastProbe, WalletGeneration}; /// A minimal transaction spending exactly the given outpoints — the only /// part of a transaction the pin machinery reads. @@ -680,387 +822,412 @@ mod tests { OutPoint::new(Txid::from([byte; 32]), vout) } - /// Settle a pin the way a dispatch that reached the network does: the - /// broadcaster returned something other than a definitive rejection, and - /// `last_processed_height` was sampled at `height` AFTER that return. The - /// anchor is the POST-await reading — `pin_in_broadcast` is deliberately - /// given no height at all (`dashpay/platform#4309`). - fn settle_dispatched(generation: &Arc, tx: &Transaction, height: u32) { - generation - .pin_in_broadcast(tx) - .settle_pending_spend(Some(height)); + /// Settle a pin the way a dispatch that may have reached the network does: + /// the broadcaster returned something other than a definitive rejection. + fn settle_dispatched(generation: &Arc, tx: &Transaction) { + generation.pin_in_broadcast(tx).settle_pending_spend(); } /// A held pin flags every input of the pinned transaction — and only /// those — and dropping a pin whose fence was explicitly RELEASED clears /// the conflict. Release models the one outcome that proves nothing was - /// sent: a definitive pre-send rejection. Every other exit keeps the fence - /// (see [`dropping_an_unreleased_pin_keeps_the_fence`]). + /// sent: a definitive pre-send rejection. #[test] fn pin_flags_inputs_until_dropped() { let generation = Arc::new(WalletGeneration::new()); - let a = outpoint(0x01, 0); - let b = outpoint(0x02, 1); - let unrelated = outpoint(0x03, 0); + let (a, b, unrelated) = (outpoint(1, 0), outpoint(1, 1), outpoint(2, 0)); + let pinned_tx = spending(&[a, b]); - let pin = generation.pin_in_broadcast(&spending(&[a, b])); + let pin = generation.pin_in_broadcast(&pinned_tx); - // Both pinned inputs conflict; an unrelated selection does not. - assert_eq!( - generation.in_broadcast_conflict(&spending(&[a]), DISPATCH_HEIGHT), - Some(a) - ); - assert_eq!( - generation.in_broadcast_conflict(&spending(&[b]), DISPATCH_HEIGHT), - Some(b) - ); + assert_eq!(generation.in_broadcast_conflict(&spending(&[a])), Some(a)); + assert_eq!(generation.in_broadcast_conflict(&spending(&[b])), Some(b)); assert_eq!( - generation.in_broadcast_conflict(&spending(&[unrelated, a]), DISPATCH_HEIGHT), + generation.in_broadcast_conflict(&spending(&[unrelated, a])), Some(a), - "a mixed selection must surface its pinned input" + "the conflict is reported for whichever input is fenced" ); assert_eq!( - generation.in_broadcast_conflict(&spending(&[unrelated]), DISPATCH_HEIGHT), - None + generation.in_broadcast_conflict(&spending(&[unrelated])), + None, + "an unrelated input is untouched by the pin" ); - // A definitive pre-send rejection: nothing is on the wire, so the - // inputs are free again the moment the pin settles. pin.settle_released(); assert_eq!( - generation.in_broadcast_conflict(&spending(&[a, b]), DISPATCH_HEIGHT), + generation.in_broadcast_conflict(&spending(&[a, b])), None, - "dropping a released pin must clear the conflict" + "a released pin frees its outpoints outright" ); } - /// `dashpay/platform#4309`: the paths this guard's drop actually runs on — - /// the dispatching future cancelled mid-await, or an unwind — carry NO - /// information about whether the transaction reached the network. The - /// previous default freed the inputs there, so an immediate reselection - /// could double-spend a transaction already on the wire. Dropping without - /// an explicit release must therefore leave the pending-spend fence - /// standing, exactly as an ambiguous `MaybeSent` does. + /// `dashpay/platform#4309`: dropping a pin WITHOUT a definitive rejection + /// keeps the fence. This is the cancellation / unwind / suspension path, + /// none of which proves the transaction failed to reach the network. #[test] fn dropping_an_unreleased_pin_keeps_the_fence() { let generation = Arc::new(WalletGeneration::new()); - let a = outpoint(0x07, 0); + let a = outpoint(3, 0); let tx = spending(&[a]); - // Neither `settle_released()` nor `settle_pending_spend()` — models a - // dispatch cancelled inside `broadcast`, which reaches neither call and - // falls back to `Drop`. drop(generation.pin_in_broadcast(&tx)); assert_eq!( - generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT), - Some(a), - "a cancelled dispatch must NOT free inputs that may be on the wire" - ); - assert_eq!( - generation.in_broadcast_conflict( - &tx, - DISPATCH_HEIGHT + crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS - 1 - ), + generation.in_broadcast_conflict(&tx), Some(a), - "the fence must hold for the full pending-spend bound" - ); - // Negative control: the fence is bounded, not permanent. The bound is - // measured from the height of the FIRST consult above, which here is - // `DISPATCH_HEIGHT`. - assert_eq!( - generation.in_broadcast_conflict( - &tx, - DISPATCH_HEIGHT + crate::wallet::reservations::IN_BROADCAST_FENCE_BLOCKS - ), - None, - "the fence must lapse once the bound is reached" + "an un-released pin must leave the outpoint fenced on drop" ); } - /// `dashpay/platform#4309`, the CANCELLATION half of the stale-anchor - /// defect. Keeping the fence on cancellation is not enough on its own: the - /// cancelled dispatch had been suspended inside `broadcast` while catch-up - /// ran, so a bound measured from anything sampled before that await is - /// already in the past when the pin drops, and the fence is installed - /// DEAD — reaped by the very next selection, exactly as if it had never - /// been installed. - /// - /// A cancelled pin therefore settles with no bound at all and is anchored - /// by the first selection to consult it, on the height THAT selection reads - /// under the manager write guard. Here the clock has run 10_000 blocks past - /// where the dispatch started — far beyond `IN_BROADCAST_FENCE_BLOCKS` — - /// and the fence must still be live, then run a full interval from the - /// height that observed it. + /// THE HEADLINE PROPERTY (`dashpay/platform#4309`, review round 5). + /// + /// A pending-spend fence is not consulted against chain height at all, so + /// no amount of catch-up can retire it. Previous revisions bounded the + /// fence at `height + IN_BROADCAST_FENCE_BLOCKS` and every one of them lost + /// the fence to a historical sync that advanced the clock past the bound + /// over blocks mined BEFORE the dispatch. + /// + /// `in_broadcast_conflict` no longer takes a height, so this test states + /// the property the only way it can still be stated: the fence survives + /// unboundedly many consultations and any amount of elapsed chain, and only + /// an observation (or the orphan backstop) clears it. #[test] - fn cancelled_dispatch_fence_anchors_at_the_first_selection_not_at_dispatch() { + fn a_pending_fence_is_immune_to_chain_progress() { let generation = Arc::new(WalletGeneration::new()); - let a = outpoint(0x08, 0); + let a = outpoint(4, 0); let tx = spending(&[a]); - // Cancelled mid-`broadcast`: no anchor, no release. - drop(generation.pin_in_broadcast(&tx)); + settle_dispatched(&generation, &tx); + + // Stand in for an arbitrarily long catch-up: every build during it + // consults the fence, and each consultation also reaps. None of them + // may retire this entry. + for _ in 0..10_000 { + assert_eq!( + generation.in_broadcast_conflict(&tx), + Some(a), + "no number of selections — i.e. no amount of chain progress — \ + may retire a fence that has seen no observed spend" + ); + } + } - // Catch-up ran far past a whole fence interval during the await. A - // pre-await anchor would have lapsed thousands of blocks ago. - let observed = DISPATCH_HEIGHT + 10_000; - assert!(observed > DISPATCH_HEIGHT + IN_BROADCAST_FENCE_BLOCKS); - assert_eq!( - generation.in_broadcast_conflict(&tx, observed), - Some(a), - "a fence settled without a post-dispatch height sample must not \ - arrive already lapsed, however far catch-up ran" - ); + /// The fence's real release: the wallet observes the outpoint spent by the + /// dispatch's OWN transaction. + #[test] + fn an_observed_spend_clears_the_fence() { + let generation = Arc::new(WalletGeneration::new()); + let a = outpoint(5, 0); + let tx = spending(&[a]); + + settle_dispatched(&generation, &tx); + assert_eq!(generation.in_broadcast_conflict(&tx), Some(a)); + + generation.observe_spent([a]); - // ...and it is anchored on THAT height, not re-anchored by later reads. - assert_eq!( - generation.in_broadcast_conflict(&tx, observed + IN_BROADCAST_FENCE_BLOCKS - 1), - Some(a), - "the interval must run a full bound from the observing height" - ); assert_eq!( - generation.in_broadcast_conflict(&tx, observed + IN_BROADCAST_FENCE_BLOCKS), + generation.in_broadcast_conflict(&tx), None, - "the anchor is stamped once: repeated reads must not extend the fence" + "observing the spend is what ends the fence" ); } - /// An unanchored fence outlasts an anchored one for the same outpoint. Two - /// concurrent dispatches of the same transaction can settle differently — - /// one returns and anchors, the other is cancelled — and the outpoint must - /// be covered by the longer of the two. Anchoring at the observing height - /// is what guarantees that: heights advance, so a bound stamped now is - /// never shorter than one stamped from an earlier sample. + /// A COMPETING spend clears the fence too, and for the same reason: after + /// it the outpoint is out of this wallet's selectable set, so there is no + /// re-selection left that could race anything on the wire. #[test] - fn an_unanchored_fence_outlives_an_anchored_one() { + fn a_competing_spend_also_clears_the_fence() { let generation = Arc::new(WalletGeneration::new()); - let a = outpoint(0x09, 0); - let tx = spending(&[a]); + let a = outpoint(6, 0); + let ours = spending(&[a]); - let cancelled = generation.pin_in_broadcast(&tx); - settle_dispatched(&generation, &tx, DISPATCH_HEIGHT); - drop(cancelled); + settle_dispatched(&generation, &ours); - // Past the anchored dispatch's bound, the cancelled one still holds. - let observed = DISPATCH_HEIGHT + IN_BROADCAST_FENCE_BLOCKS; - assert_eq!( - generation.in_broadcast_conflict(&tx, observed), - Some(a), - "the cancelled dispatch's unanchored fence must outlast the \ - anchored one" - ); - assert_eq!( - generation.in_broadcast_conflict(&tx, observed + IN_BROADCAST_FENCE_BLOCKS), - None, - "and it must still lapse a bound past the height that anchored it" - ); + // A different transaction spending the same outpoint — the wallet sees + // it and hands the outpoint over as spent. + let competing = spending(&[a, outpoint(7, 0)]); + generation.observe_spent(competing.input.iter().map(|i| i.previous_output)); + + assert_eq!(generation.in_broadcast_conflict(&ours), None); } - /// The dispatching pin has no TTL: however far catch-up advances the - /// clock while the broadcaster is suspended, the inputs stay fenced. + /// Observing spends the fence never knew about is a harmless no-op — block + /// processing hands over every spend it sees, and almost none of them + /// belong to a dispatch. #[test] - fn dispatching_pin_never_expires() { + fn observing_unfenced_outpoints_is_a_no_op() { let generation = Arc::new(WalletGeneration::new()); - let a = outpoint(0x05, 0); - let tx = spending(&[a]); + let fenced = outpoint(8, 0); + let tx = spending(&[fenced]); + settle_dispatched(&generation, &tx); - let _pin = generation.pin_in_broadcast(&tx); + generation.observe_spent([outpoint(9, 0), outpoint(9, 1)]); + generation.observe_spent([]); assert_eq!( - generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT + 10_000), - Some(a), - "a suspended dispatch must keep its inputs fenced at any height" + generation.in_broadcast_conflict(&tx), + Some(fenced), + "unrelated observations must not disturb a live fence" ); } - /// `dashpay/platform#4309`: a dispatch that reached the network keeps its - /// inputs fenced AFTER the broadcaster returns — the DAPI path performs no - /// local mempool injection, so the wallet has not observed the spend yet - /// and the outpoint would otherwise be immediately re-selectable. The - /// fence lapses only once the clock has advanced a full - /// `IN_BROADCAST_FENCE_BLOCKS` past the dispatch height. + /// The DISPATCHING phase never expires and is never released by an + /// observation: it tracks a live `InBroadcastPin`, so it ends at that pin's + /// drop and nowhere else. An observation arriving mid-dispatch (the SPV + /// path routinely beats the broadcaster's return) still suppresses the + /// pending phase the settle would otherwise open. #[test] - fn retained_pin_fences_past_dispatch_until_the_bound() { + fn a_mid_dispatch_observation_suppresses_the_pending_phase() { let generation = Arc::new(WalletGeneration::new()); - let a = outpoint(0x30, 0); + let a = outpoint(10, 0); let tx = spending(&[a]); - // Fenced by default; the bound comes from the POST-await sample. - settle_dispatched(&generation, &tx, DISPATCH_HEIGHT); + let pin = generation.pin_in_broadcast(&tx); + generation.observe_spent([a]); assert_eq!( - generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT), + generation.in_broadcast_conflict(&tx), Some(a), - "the fence must survive the dispatch return" + "the dispatching hold outlives an observation — the pin is still live" ); + + pin.settle_pending_spend(); + assert_eq!( - generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT + IN_BROADCAST_FENCE_BLOCKS - 1), - Some(a), - "one block below the bound must still fence" + generation.in_broadcast_conflict(&tx), + None, + "an already-observed spend must not be re-fenced by the settle, or \ + the map would carry a dead entry for a whole backstop interval" ); assert_eq!( - generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT + IN_BROADCAST_FENCE_BLOCKS), + generation.in_broadcast_fence_state(&a), None, - "exactly at the bound the fence lapses" + "and the entry is reaped rather than left behind" ); } - /// A lapsed fence is reaped, not merely ignored: the read that observes - /// the lapse is what prunes the entry, so the map cannot grow without - /// bound across dispatches. + /// The orphan backstop exists so a transaction that is NEVER observed + /// cannot strand its inputs for the life of the process. #[test] - fn lapsed_fences_are_reaped_on_read() { + fn the_orphan_backstop_eventually_frees_an_unobserved_fence() { let generation = Arc::new(WalletGeneration::new()); - let a = outpoint(0x31, 0); - let unrelated = outpoint(0x32, 0); + let a = outpoint(11, 0); + let tx = spending(&[a]); - settle_dispatched(&generation, &spending(&[a]), DISPATCH_HEIGHT); - assert_eq!(generation.in_broadcast_lock().len(), 1); + settle_dispatched(&generation, &tx); + assert_eq!(generation.in_broadcast_conflict(&tx), Some(a)); - // A read past the bound — about an unrelated selection — still reaps. - assert_eq!( - generation.in_broadcast_conflict( - &spending(&[unrelated]), - DISPATCH_HEIGHT + IN_BROADCAST_FENCE_BLOCKS - ), - None - ); assert!( - generation.in_broadcast_lock().is_empty(), - "the lapsed entry must be pruned by the read that observed the lapse" + generation.test_elapse_orphan_backstop(&a), + "the settled fence must carry a backstop deadline to elapse" ); - } - - /// The rejection path is the ONLY one that frees inputs at dispatch - /// return, and it frees them completely — no residual pending-spend fence - /// keeps an immediate rebuild out. - #[test] - fn rejected_dispatch_frees_the_input_immediately() { - let generation = Arc::new(WalletGeneration::new()); - let a = outpoint(0x33, 0); - let tx = spending(&[a]); - - // An explicit release — this models `BroadcastError::Rejected`, the one - // outcome that proves the transaction never reached the network. - generation.pin_in_broadcast(&tx).settle_released(); assert_eq!( - generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT), + generation.in_broadcast_conflict(&tx), None, - "a definitively rejected send must not fence its inputs" + "an unobserved fence must eventually lapse rather than strand the input" ); - assert!(generation.in_broadcast_lock().is_empty()); } - /// An UNANCHORED fence must still be bounded and reaped even when its own - /// outpoint is never re-selected. Anchoring runs over every entry, not just - /// the ones the querying transaction spends, so a cancelled dispatch of a - /// coin nobody touches again cannot sit in the map unbounded forever — - /// which is the same "it must lapse" property `IN_BROADCAST_FENCE_BLOCKS` - /// exists to guarantee for funds that are otherwise stranded. + /// The backstop is on a MONOTONIC clock, so it is not reachable from chain + /// state — which is the whole point of moving off `last_processed_height`. + /// A dispatching pin is not subject to it either. #[test] - fn unanchored_fences_are_bounded_and_reaped_by_unrelated_reads() { + fn the_backstop_does_not_apply_while_dispatching() { let generation = Arc::new(WalletGeneration::new()); - let a = outpoint(0x34, 0); - let unrelated = outpoint(0x35, 0); - - // Cancelled dispatch: unanchored, no bound yet. - drop(generation.pin_in_broadcast(&spending(&[a]))); - assert_eq!(generation.in_broadcast_lock().len(), 1); - - // A read about a DIFFERENT selection anchors it at this height... - assert_eq!( - generation.in_broadcast_conflict(&spending(&[unrelated]), DISPATCH_HEIGHT), - None - ); - assert_eq!( - generation.in_broadcast_lock().len(), - 1, - "the fence must still stand — anchoring is not releasing" - ); + let a = outpoint(12, 0); + let tx = spending(&[a]); - // ...so a later unrelated read past that bound reaps it. - assert_eq!( - generation.in_broadcast_conflict( - &spending(&[unrelated]), - DISPATCH_HEIGHT + IN_BROADCAST_FENCE_BLOCKS - ), - None - ); + let pin = generation.pin_in_broadcast(&tx); assert!( - generation.in_broadcast_lock().is_empty(), - "an unanchored fence must not outlive its bound in the map" + !generation.test_elapse_orphan_backstop(&a), + "a dispatching pin has no backstop deadline — it cannot expire at all" ); + assert_eq!(generation.in_broadcast_conflict(&tx), Some(a)); + + drop(pin); } - /// Pins COUNT per outpoint: two concurrent dispatches of the same - /// transaction (legal through `&SignedCoreTransaction`, idempotent on the - /// wire) each take a pin, and the fence must hold until the LAST one - /// returns — the first completion must not unpin the other's in-flight - /// send. + /// Two concurrent dispatches of the same transaction: the pin is COUNTED, + /// so the first completion must not unpin the second's in-flight send. #[test] fn pins_are_counted_per_outpoint() { let generation = Arc::new(WalletGeneration::new()); - let a = outpoint(0x10, 0); + let a = outpoint(13, 0); let tx = spending(&[a]); - // Both model a definitive rejection, so the count — not a leftover - // pending-spend fence — is what keeps the outpoint held. let first = generation.pin_in_broadcast(&tx); let second = generation.pin_in_broadcast(&tx); first.settle_released(); assert_eq!( - generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT), + generation.in_broadcast_conflict(&tx), Some(a), - "one dispatch still in flight must keep the outpoint fenced" + "one dispatch's rejection must not free another's in-flight inputs" ); second.settle_released(); - assert_eq!(generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT), None); + assert_eq!(generation.in_broadcast_conflict(&tx), None); } - /// Two concurrent dispatches of the same transaction that BOTH reach the - /// network must leave the longer fence standing — a first completion at a - /// lower dispatch height must not shorten the second's protection. + /// A settle must never SHORTEN a deadline another dispatch already + /// installed: both dispatches have to stay covered. #[test] fn the_longer_pending_fence_wins() { let generation = Arc::new(WalletGeneration::new()); - let a = outpoint(0x11, 0); + let a = outpoint(14, 0); + let tx = spending(&[a]); + + settle_dispatched(&generation, &tx); + let first_deadline = generation + .in_broadcast_fence_state(&a) + .and_then(|(_, until, _)| until) + .expect("a settled fence carries a deadline"); + + // A second, later dispatch of the same transaction. + settle_dispatched(&generation, &tx); + let second_deadline = generation + .in_broadcast_fence_state(&a) + .and_then(|(_, until, _)| until) + .expect("still fenced"); + + assert!( + second_deadline >= first_deadline, + "the later dispatch's deadline must win, never be rolled back" + ); + } + + /// Fences are per GENERATION: a wallet re-created under the same id gets a + /// fresh map and cannot be blocked by the previous instance's dispatches. + #[test] + fn pins_do_not_cross_generations() { + let first = Arc::new(WalletGeneration::new()); + let second = Arc::new(WalletGeneration::new()); + let a = outpoint(15, 0); let tx = spending(&[a]); - let early = generation.pin_in_broadcast(&tx); - let late = generation.pin_in_broadcast(&tx); - // Each anchors on its OWN post-await sample; the later return sees the - // higher clock. - late.settle_pending_spend(Some(DISPATCH_HEIGHT + 5)); - early.settle_pending_spend(Some(DISPATCH_HEIGHT)); + let _pin = first.pin_in_broadcast(&tx); + assert_eq!(first.in_broadcast_conflict(&tx), Some(a)); assert_eq!( - generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT + IN_BROADCAST_FENCE_BLOCKS), - Some(a), - "the later dispatch's bound must win over the earlier one's" + second.in_broadcast_conflict(&tx), + None, + "a different generation's fence must not block this one's builds" ); + } + + /// Lapsed entries are reaped on read, so the map stays bounded by the + /// outpoints dispatched since the last build without any background task. + #[test] + fn lapsed_fences_are_reaped_on_read() { + let generation = Arc::new(WalletGeneration::new()); + let a = outpoint(16, 0); + let tx = spending(&[a]); + + settle_dispatched(&generation, &tx); + assert!(generation.test_elapse_orphan_backstop(&a)); + + // Reading about an UNRELATED transaction still reaps. assert_eq!( - generation.in_broadcast_conflict(&tx, DISPATCH_HEIGHT + 5 + IN_BROADCAST_FENCE_BLOCKS), + generation.in_broadcast_conflict(&spending(&[outpoint(17, 0)])), None ); + assert_eq!( + generation.in_broadcast_fence_state(&a), + None, + "the lapsed entry must be gone from the map, not merely inert" + ); } - /// Pins are per generation: a re-created wallet's fresh generation starts - /// with nothing pinned, and the old generation's pins die with its last - /// handle — nothing leaks across the recreation boundary. + /// `dashpay/platform#4309`, REVIEW ROUND 5 SUGGESTION: THE + /// DISPATCHING→PENDING HANDOFF REGRESSION, MADE DETERMINISTIC. + /// + /// The transition lifts the dispatching hold and opens the pending-spend + /// phase. If those two ever land in separate critical sections, an observer + /// in between sees the outpoint held by NOTHING and a build can select an + /// input whose transaction may be on the wire. + /// + /// The previous regression parked a manager writer and hoped the scheduler + /// granted it the lock inside a window a handful of instructions wide. It + /// did not reliably do so — the reviewer showed it stays green against the + /// pre-fix code — so it proved nothing. + /// + /// This one is deterministic. [`WalletGeneration::on_next_settle_boundary`] + /// runs the observer AT the midpoint by construction, and the settling + /// thread BLOCKS until the observer has published what it saw, so there is + /// no race to lose. The observer probes with `try_lock` + /// ([`WalletGeneration::try_probe_in_broadcast`]) rather than blocking, + /// because a blocking read cannot distinguish "held across the whole + /// transition" — the property under test — from "granted after it". + /// + /// Legal: `TransitionInProgress`. Illegal: `Free`, which is exactly what a + /// split transition would expose. #[test] - fn pins_do_not_cross_generations() { - let old_generation = Arc::new(WalletGeneration::new()); - let a = outpoint(0x20, 0); - let _pin = old_generation.pin_in_broadcast(&spending(&[a])); + fn the_settle_handoff_is_never_observable_half_done() { + let generation = Arc::new(WalletGeneration::new()); + let a = outpoint(18, 0); + let tx = spending(&[a]); - let new_generation = Arc::new(WalletGeneration::new()); + let pin = generation.pin_in_broadcast(&tx); + + // Hand the observer its own generation handle; it runs on another + // thread, woken exactly at the midpoint. + let (at_midpoint_tx, at_midpoint_rx) = mpsc::channel::<()>(); + let (observed_tx, observed_rx) = mpsc::channel::(); + let observer = std::thread::spawn({ + let generation = Arc::clone(&generation); + move || { + at_midpoint_rx.recv().expect("midpoint signal"); + let probe = generation.try_probe_in_broadcast(&a); + observed_tx.send(probe).expect("publish observation"); + } + }); + + // At the midpoint: wake the observer and do not proceed until it has + // published. That is what removes the scheduling race — the settle is + // provably still in progress while the observation is taken. + generation.on_next_settle_boundary(Box::new(move || { + at_midpoint_tx.send(()).expect("wake observer"); + let probe = observed_rx.recv().expect("observation"); + assert_ne!( + probe, + InBroadcastProbe::Free, + "the dispatching→pending handoff was observable half-done: the \ + outpoint was held by nothing mid-transition, so a build could \ + have selected an input whose transaction may be on the wire" + ); + })); + + pin.settle_pending_spend(); + observer.join().expect("observer thread"); + + // And the end state is a live fence, not merely an unobservable + // transition into nothing. assert_eq!( - new_generation.in_broadcast_conflict(&spending(&[a]), DISPATCH_HEIGHT), - None, - "a fresh generation must not inherit the old generation's pins" + generation.in_broadcast_conflict(&tx), + Some(a), + "the settled fence must be live once the transition completes" ); } + + /// The settle-boundary hook is ONE-SHOT, so a test's handshake cannot be + /// re-entered by an unrelated later settle on the same generation. + #[test] + fn the_settle_boundary_hook_fires_once() { + let generation = Arc::new(WalletGeneration::new()); + let fired = Arc::new(AtomicUsize::new(0)); + let tx = spending(&[outpoint(19, 0)]); + + generation.on_next_settle_boundary(Box::new({ + let fired = Arc::clone(&fired); + move || { + fired.fetch_add(1, Ordering::SeqCst); + } + })); + + settle_dispatched(&generation, &tx); + settle_dispatched(&generation, &tx); + + assert_eq!(fired.load(Ordering::SeqCst), 1); + } } diff --git a/packages/rs-platform-wallet/src/wallet/core/mod.rs b/packages/rs-platform-wallet/src/wallet/core/mod.rs index 36fe92318d6..d3f10bf0ede 100644 --- a/packages/rs-platform-wallet/src/wallet/core/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/core/mod.rs @@ -4,12 +4,14 @@ mod broadcast; pub mod generation; // Inherent `CoreWallet::sign_message` only — no types to re-export. mod sign_message; +pub mod spend_observer; mod transaction; pub mod wallet; pub use balance::WalletBalance; pub use balance_handler::BalanceUpdateHandler; pub use generation::WalletGeneration; +pub use spend_observer::SpendObservationHandler; pub(crate) use transaction::resolve_source_accounts; pub use transaction::{SignedCoreTransaction, ASSET_LOCK_FUNDING_SOURCES, SEND_FUNDING_SOURCES}; pub use wallet::CoreWallet; diff --git a/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs b/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs new file mode 100644 index 00000000000..fb60e198744 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs @@ -0,0 +1,343 @@ +//! Event handler that releases in-broadcast input fences when the wallet +//! OBSERVES the fenced outpoints spent. +//! +//! This is the evidence half of the broadcast fence +//! ([`WalletGeneration::pin_in_broadcast`](super::WalletGeneration::pin_in_broadcast)). +//! The dispatch side installs a fence when a transaction may have reached the +//! network; this side takes it down when the wallet can actually see that the +//! outpoints are spent. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use dash_spv::EventHandler; +use tokio::sync::RwLock; + +use crate::changeset::core_bridge::spent_outpoints; +use crate::events::{PlatformEventHandler, WalletEvent}; +use crate::wallet::platform_wallet::WalletId; +use crate::wallet::PlatformWallet; + +/// Releases a wallet generation's in-broadcast fences as the wallet observes +/// the fenced outpoints spent. +/// +/// # Why the fence needs this at all +/// +/// A dispatch that returns anything but a definitive pre-send rejection leaves +/// its inputs fenced, because the broadcaster's return says "this may be on the +/// network", not "this wallet has seen the spend" — and on the +/// `DapiBroadcaster` path the two are far apart, since `sdk.execute` injects +/// nothing into local wallet state. Something has to end that fence, and three +/// earlier revisions tried to end it on elapsed `last_processed_height`. That +/// cannot work: catch-up advances the chain clock over blocks mined *before* +/// the transaction was submitted, so an ordinary historical sync retires a +/// fence without a shred of evidence about the transaction it was protecting +/// (`dashpay/platform#4309`). +/// +/// So the fence ends on the observation instead, and this handler is where the +/// observation arrives. +/// +/// # Which events, and why these are the right ones +/// +/// The two variants that carry spend-bearing transaction records, which are +/// exactly the two [`build_core_changeset`](crate::changeset::core_bridge) +/// derives [`CoreChangeSet::spent_utxos`](crate::changeset::CoreChangeSet) +/// from: +/// +/// * [`WalletEvent::TransactionDetected`] — first sighting, typically the +/// mempool relay of the transaction this very wallet just dispatched. On the +/// DAPI path this is the moment the wallet learns its own send exists. +/// * [`WalletEvent::BlockProcessed`] — the `inserted` records, i.e. spends +/// arriving in a block (including the dispatch's own transaction confirming +/// without ever having been seen in the mempool). +/// +/// `TransactionInstantLocked` and `ChainLockProcessed` are deliberately not +/// handled: they promote the finality of a record the wallet already has, and +/// the spend was already observed when that record first arrived. Handling them +/// would re-derive the same outpoints for no change. +/// +/// Both spend shapes release, and the fence does not care which it saw — the +/// dispatch's own transaction, or a competing transaction spending the same +/// outpoint. After either one the outpoint is out of this wallet's selectable +/// set, so there is no re-selection left to race. See +/// [`WalletGeneration::observe_spent`](super::WalletGeneration::observe_spent). +/// +/// # Lock discipline +/// +/// Mirrors [`BalanceUpdateHandler`](super::BalanceUpdateHandler), for the same +/// reason: `on_wallet_event` is synchronous and runs inside SPV's block +/// processing, which holds the wallet-manager WRITE lock for the whole batch. +/// Resolving the generation through *that* lock would deadlock or silently drop +/// every event during initial sync, so this handler holds an `Arc` clone of the +/// manager's `wallets` map instead — a separate lock, written only by manager +/// lifecycle methods, so `try_read` essentially never contends. Releasing the +/// fence then takes only the generation's `in_broadcast` `std::sync::Mutex` for +/// a few hash operations and never awaits. +/// +/// A dropped observation (contended map, or a wallet not in the map) is +/// FAIL-SAFE in the direction that matters: the fence simply stays up until +/// another spend event for the same outpoint arrives or the orphan backstop +/// expires. It can delay a release; it can never cause one. +pub struct SpendObservationHandler { + wallets: Arc>>>, +} + +impl SpendObservationHandler { + pub fn new(wallets: Arc>>>) -> Self { + Self { wallets } + } + + /// Hand `outpoints` to `wallet_id`'s generation as observed spends. + fn release_fences(&self, wallet_id: &WalletId, outpoints: Vec) { + if outpoints.is_empty() { + return; + } + // try_read on the wallets map, NOT the SPV-contended wallet_manager + // lock — see the type docs. + let Ok(wallets) = self.wallets.try_read() else { + tracing::debug!( + wallet = %hex::encode(wallet_id), + spent = outpoints.len(), + "in-broadcast fence release deferred: wallets-map lock contended" + ); + return; + }; + if let Some(wallet) = wallets.get(wallet_id) { + wallet.generation().observe_spent(outpoints); + } + } +} + +impl EventHandler for SpendObservationHandler { + fn on_wallet_event(&self, event: &WalletEvent) { + if let Some(wallet_id) = observing_wallet(event) { + self.release_fences(wallet_id, observed_spends(event)); + } + } +} + +impl PlatformEventHandler for SpendObservationHandler {} + +/// The wallet whose fences `event` can retire, or `None` for a variant that +/// carries no spend. +fn observing_wallet(event: &WalletEvent) -> Option<&WalletId> { + match event { + WalletEvent::TransactionDetected { wallet_id, .. } + | WalletEvent::BlockProcessed { wallet_id, .. } => Some(wallet_id), + WalletEvent::TransactionInstantLocked { .. } + | WalletEvent::ChainLockProcessed { .. } + | WalletEvent::SyncHeightAdvanced { .. } => None, + } +} + +/// Project a [`WalletEvent`] into the outpoints of ours it reports spent. +/// +/// Split out of the handler so the projection — which decides *what counts as +/// observing a spend*, the fence's entire release condition — is unit-testable +/// without standing up a `PlatformWallet` and a manager, and so the dispatch +/// tests can drive the real projection rather than a hand-rolled stand-in. +/// +/// Built on [`spent_outpoints`], the same per-record input walk that produces +/// [`CoreChangeSet::spent_utxos`](crate::changeset::CoreChangeSet), so the +/// fence and the persisted spent set cannot diverge. +pub(crate) fn observed_spends(event: &WalletEvent) -> Vec { + match event { + // First sighting — typically the mempool relay of the transaction this + // wallet just dispatched. On the DAPI path this is the moment the + // wallet learns its own send exists. + WalletEvent::TransactionDetected { record, .. } => spent_outpoints(record).collect(), + // Spends arriving in a block, including a dispatch's own transaction + // confirming without ever having been seen in the mempool. + // + // `inserted` only: `updated` and `matured` re-emit records the wallet + // already holds, whose spends were observed when they first arrived. + WalletEvent::BlockProcessed { inserted, .. } => { + inserted.iter().flat_map(spent_outpoints).collect() + } + // Finality promotions of records the wallet already holds, and a bare + // watermark advance. No new spend in any of them — and note that the + // watermark is precisely the "chain moved" signal that must NOT touch + // a fence (`dashpay/platform#4309`). + WalletEvent::TransactionInstantLocked { .. } + | WalletEvent::ChainLockProcessed { .. } + | WalletEvent::SyncHeightAdvanced { .. } => Vec::new(), + } +} + +#[cfg(test)] +mod tests { + //! Cover the projection — which events count as observing a spend, and + //! which outpoints they yield. That decision IS the fence's release + //! condition (`dashpay/platform#4309`), so it is pinned here rather than + //! only exercised end to end. + + use dashcore::hashes::Hash; + use dashcore::{ + Address as DashAddress, BlockHash, Network, OutPoint, ScriptBuf, Transaction, TxIn, Txid, + Witness, + }; + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::transaction_record::{ + InputDetail, TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::transaction_router::TransactionType; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + use key_wallet::WalletCoreBalance; + + use super::*; + + const WALLET_ID: WalletId = [3u8; 32]; + + fn outpoint(byte: u8) -> OutPoint { + OutPoint { + txid: Txid::from_slice(&[byte; 32]).expect("valid txid"), + vout: 0, + } + } + + fn spending(outpoints: &[OutPoint]) -> Transaction { + Transaction { + version: 2, + lock_time: 0, + input: outpoints + .iter() + .map(|previous_output| TxIn { + previous_output: *previous_output, + script_sig: ScriptBuf::new(), + sequence: 0xffff_ffff, + witness: Witness::new(), + }) + .collect(), + output: Vec::new(), + special_transaction_payload: None, + } + } + + /// A record whose `input_details` claim the given input indexes as ours — + /// the shape upstream builds for inputs that spent our outpoints. + fn record_claiming(tx: &Transaction, ours: &[u32]) -> TransactionRecord { + TransactionRecord::new( + tx.clone(), + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::InBlock(BlockInfo::new( + 1_000, + BlockHash::from_slice(&[4u8; 32]).expect("valid block hash"), + 1_234_567_890, + )), + TransactionType::Standard, + TransactionDirection::Outgoing, + ours.iter() + .map(|index| InputDetail { + index: *index, + value: 1_000, + address: DashAddress::dummy(Network::Testnet, 1), + }) + .collect(), + Vec::new(), + 0, + ) + } + + fn detected(record: TransactionRecord) -> WalletEvent { + WalletEvent::TransactionDetected { + wallet_id: WALLET_ID, + record: Box::new(record), + balance: WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + addresses_derived: Vec::new(), + } + } + + fn block_processed(inserted: Vec) -> WalletEvent { + WalletEvent::BlockProcessed { + wallet_id: WALLET_ID, + height: 1_000, + chain_lock: None, + inserted, + updated: Vec::new(), + matured: Vec::new(), + balance: WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + addresses_derived: Vec::new(), + } + } + + /// A first sighting — the mempool relay of our own DAPI-broadcast send — + /// reports its spends. This is the event that ends the fence in the case + /// the whole redesign exists for. + #[test] + fn a_detected_transaction_reports_its_spends() { + let (a, b) = (outpoint(1), outpoint(2)); + let tx = spending(&[a, b]); + + assert_eq!( + observed_spends(&detected(record_claiming(&tx, &[0, 1]))), + [a, b] + ); + } + + /// Only inputs the record claims as OURS count. A transaction that also + /// spends someone else's coins must not retire a fence on an outpoint this + /// wallet does not own — same rule `CoreChangeSet::spent_utxos` follows, + /// because both walk `input_details`. + #[test] + fn only_our_inputs_are_reported() { + let (ours, theirs) = (outpoint(3), outpoint(4)); + let tx = spending(&[ours, theirs]); + + assert_eq!( + observed_spends(&detected(record_claiming(&tx, &[0]))), + [ours], + "an input the record does not claim is not a spend of ours" + ); + } + + /// An `input_details` index that does not address a real input is skipped + /// rather than panicking. + #[test] + fn an_out_of_range_input_index_is_skipped() { + let tx = spending(&[outpoint(5)]); + + assert!(observed_spends(&detected(record_claiming(&tx, &[7]))).is_empty()); + } + + /// A block's `inserted` records report their spends — the dispatch's own + /// transaction confirming without ever being seen in the mempool. + #[test] + fn block_processed_reports_inserted_record_spends() { + let (a, b) = (outpoint(6), outpoint(7)); + let first = spending(&[a]); + let second = spending(&[b]); + + let spends = observed_spends(&block_processed(vec![ + record_claiming(&first, &[0]), + record_claiming(&second, &[0]), + ])); + + assert_eq!(spends, [a, b]); + } + + /// THE VARIANT THAT MUST NEVER TOUCH A FENCE. + /// + /// `SyncHeightAdvanced` is the bare "the chain moved" watermark, and it is + /// precisely the signal three earlier revisions of this fix let retire a + /// fence — via a `last_processed_height + N` bound rather than directly, + /// but with the same effect. It reports no spend and must stay that way + /// (`dashpay/platform#4309`). + #[test] + fn chain_progress_alone_reports_no_spend() { + let event = WalletEvent::SyncHeightAdvanced { + wallet_id: WALLET_ID, + height: 900_000, + }; + + assert!(observed_spends(&event).is_empty()); + assert!( + observing_wallet(&event).is_none(), + "a bare watermark advance must not even resolve a wallet to act on" + ); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 76f21957c93..1bf6dd15a56 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -431,15 +431,15 @@ impl CoreWallet { // (`WalletGeneration::pin_in_broadcast`). Still under the write // guard, so the check is atomic with our reservation and the // release is exact. - if let Some(pinned) = info - .generation - .in_broadcast_conflict(&unsigned, info.core_wallet.last_processed_height()) - { + // + // The refusal is TYPED (`InputMidBroadcast`, carrying the + // conflicting outpoint) rather than a build-failure string: this is + // the one build error that is safely retryable unchanged once the + // dispatch settles, and callers should not have to substring-match + // prose to tell it apart (`dashpay/platform#4309`). + if let Some(outpoint) = info.generation.in_broadcast_conflict(&unsigned) { release_all!(offered_accounts, info.core_wallet.accounts, &unsigned); - return Err(PlatformWalletError::TransactionBuild(format!( - "selected input {pinned} is mid-broadcast by an in-flight dispatch; \ - retry after it completes" - ))); + return Err(PlatformWalletError::InputMidBroadcast { outpoint }); } // Map every selected input back to the account that owns it. That diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index 5651d77f65e..5a7493c7e55 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -1322,10 +1322,7 @@ impl DashPayView<'_, B> { // nothing no-op. Roll back the consumed payment address exactly // like the build-failure arm above — nothing was persisted or // broadcast. - if let Some(pinned) = info - .generation - .in_broadcast_conflict(&tx, info.core_wallet.last_processed_height()) - { + if let Some(outpoint) = info.generation.in_broadcast_conflict(&tx) { for at in &offered_accounts { if let Some(managed) = info.core_wallet.accounts.funds_account_mut(at) { managed.release_reservation(&tx); @@ -1339,10 +1336,9 @@ impl DashPayView<'_, B> { { return_contact_payment_address_to_pool(external_account, &payment_address); } - return Err(PlatformWalletError::TransactionBuild(format!( - "selected input {pinned} is mid-broadcast by an in-flight dispatch; \ - retry after it completes" - ))); + // Typed, and the SAME variant the other two choke points + // return — see `PlatformWalletError::InputMidBroadcast`. + return Err(PlatformWalletError::InputMidBroadcast { outpoint }); } ( diff --git a/packages/rs-platform-wallet/src/wallet/reservations.rs b/packages/rs-platform-wallet/src/wallet/reservations.rs index 6a880fcc43c..82b3237c540 100644 --- a/packages/rs-platform-wallet/src/wallet/reservations.rs +++ b/packages/rs-platform-wallet/src/wallet/reservations.rs @@ -16,6 +16,8 @@ //! `Built` row first); those call the broadcaster directly and then //! [`release_reservation_after_rejected_broadcast`]. +use std::time::Duration; + use dashcore::{Transaction, Txid}; use key_wallet::account::account_type::StandardAccountType; use key_wallet::account::AccountType; @@ -50,19 +52,39 @@ use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; /// for `last_processed_height` to lag a few blocks behind the true tip. pub(crate) const RESERVATION_MAX_AGE_BLOCKS: u32 = 20; -/// How long, in `last_processed_height` blocks past **dispatch**, a transaction -/// that reached the network keeps its inputs fenced against re-selection by -/// [`WalletGeneration::pin_in_broadcast`](crate::wallet::core::WalletGeneration::pin_in_broadcast)'s -/// pending-spend phase. +/// The ORPHAN BACKSTOP for a broadcast input fence +/// ([`WalletGeneration::pin_in_broadcast`](crate::wallet::core::WalletGeneration::pin_in_broadcast)'s +/// pending-spend phase): how long an outpoint the wallet has *never observed +/// spent* stays fenced after its dispatch returned. +/// +/// This is **not** the mechanism that makes the fence safe, and it carries no +/// evidence about the dispatched transaction. The fence is released by +/// [`WalletGeneration::observe_spent`](crate::wallet::core::WalletGeneration::observe_spent) +/// when the wallet actually observes the outpoint spent — by the dispatch's own +/// transaction or by a competing one. This constant only stops a fence whose +/// transaction is never observed at all from stranding those inputs for the +/// life of the process. +/// +/// # Why a wall clock, and why `Instant` /// -/// "Past dispatch" means past a `last_processed_height` sampled once the -/// broadcaster has RETURNED, not the one the pre-send freshness check consumed. -/// A broadcast await can suspend for minutes mid-catch-up, and anchoring this -/// interval before it means the fence can arrive already lapsed — which is the -/// same as never installing it (`dashpay/platform#4309`). A dispatch that stops -/// without that sample fences unbounded until the next coin selection stamps it -/// from its own height. See `CoreWallet::dispatch_unexpired` and -/// `WalletGeneration::in_broadcast_conflict`. +/// Every height-anchored form of this bound is unsound, and the reason is not +/// where the anchor is sampled (`dashpay/platform#4309`, rounds 2-4 all moved +/// the sample and all failed). It is that `last_processed_height` is not a +/// clock at all during catch-up: the wallet can advance it by thousands of +/// blocks in seconds, and those blocks were **mined before the transaction was +/// submitted**. Elapsed height is therefore evidence about the chain's past, +/// never about whether a transaction submitted *now* has been seen or dropped — +/// so no `installed_height + N` bound, however carefully sampled or however +/// atomically installed, can survive an ordinary historical sync. +/// +/// [`Instant`] is the only clock in this crate with no chain input whatsoever. +/// It is monotonic, cannot be moved by catch-up, by a re-org, by a peer feeding +/// historical headers, or by a system clock adjustment. That is exactly the +/// "expiry clock that historical catch-up cannot fast-forward" the fix requires, +/// and it is *readable from a synchronous `Drop`* — which is what lets the +/// bound be stamped at the instant the pending-spend phase begins, on every exit +/// path including cancellation and unwind, with no manager lock, no height +/// sample and therefore no sample-to-install window to make atomic. /// /// # Why a fence past dispatch is needed at all /// @@ -76,37 +98,25 @@ pub(crate) const RESERVATION_MAX_AGE_BLOCKS: u32 = 20; /// therefore reopens the sweep + re-select race on that path /// (`dashpay/platform#4309`): key-wallet's `ReservationSet` TTL is stamped at /// *build* time, so a handle that sat between `finalize` and broadcast can be -/// swept the instant the next selection runs. -/// -/// # Why exactly key-wallet's TTL, re-anchored at dispatch -/// -/// The correct fix would be to renew the underlying reservation at dispatch so -/// its TTL runs from the moment the transaction actually went to the network; -/// key-wallet exposes no such primitive at the pinned revision (`ReservationSet` -/// and its `RESERVATION_TTL_BLOCKS` are private). This constant is that renewal -/// implemented one layer up: **24, key-wallet's own `RESERVATION_TTL_BLOCKS`** -/// (~1 h at the mainnet block target), measured from the broadcaster's return -/// instead of from the build. The inputs are then continuously protected — by -/// the reservation until its build-anchored TTL, then by this fence — for a full -/// TTL past the moment they were actually committed to the network, which is the -/// point the TTL was always meant to be measured from. Sampling the anchor -/// *after* the send is what makes that literally true rather than approximately: -/// an anchor taken before a long await measures from a moment the transaction -/// had not yet gone anywhere. Coupled by convention, exactly as -/// [`RESERVATION_MAX_AGE_BLOCKS`] above is: if key-wallet's TTL changes, change -/// this in lockstep. +/// swept the instant the next selection runs. On BOTH paths the release is now +/// the same observation, so the DAPI path is no longer the odd one out — it +/// simply reaches the observation later, when SPV relays the transaction back +/// or a block carries it. /// -/// # Why it must lapse +/// # Why one hour /// -/// A fenced outpoint that the wallet has already observed as spent never -/// reaches a selection in the first place, so in the common case this bound is -/// never consulted — the fence goes inert on its own. The bound exists for the -/// transaction that is *never* observed (dropped from mempool for fee or -/// conflict): its reservation is already gone at TTL, and a non-expiring fence -/// would strand those funds permanently with nothing able to clear it. Lapsing -/// at the same TTL leaves the residual exposure identical to the one -/// key-wallet's reservation TTL already accepts, and no larger. -pub(crate) const IN_BROADCAST_FENCE_BLOCKS: u32 = 24; +/// The real-time analogue of the bound this replaces: key-wallet's +/// `RESERVATION_TTL_BLOCKS` is 24 blocks, ~1 h at the 2.5-minute mainnet block +/// target. Keeping the same magnitude means the residual exposure of an +/// *unobserved* transaction is the one key-wallet's reservation TTL already +/// accepts, and no larger — only the clock changed, not the budget. It is also +/// long enough that the observation path wins in every healthy flow (an +/// accepted transaction is relayed back in seconds), and short enough that a +/// genuinely dropped transaction's inputs come back inside one session rather +/// than only at process exit — the fence is in-memory and never persisted, so a +/// bound much longer than a session would make process restart the real +/// recovery path. +pub(crate) const IN_BROADCAST_FENCE_ORPHAN_TIMEOUT: Duration = Duration::from_secs(60 * 60); /// Whether a reservation stamped at `registered_height` is too old to act on at /// `current_height` (see [`RESERVATION_MAX_AGE_BLOCKS`]). The registration From 520e5d949136a76f38b642f3a088c203bfc4732e Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:52:34 -0400 Subject: [PATCH 14/15] test(platform-wallet): fire the settle-boundary hook inside the torn state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handoff regression's hook fired on lock ACQUISITION, before any fence was mutated, so the observer's TransitionInProgress proved only that a lock was taken before mutating — a property the first half of a split-critical-section implementation satisfies too. Such a split could retain the hook in its decrement section, let the observation complete before the unsafe free interval opened, and still pass (review round 6). Fire the hook between the dispatching decrement and the pending install instead — the torn state itself. Verified by mutation: splitting unpin_in_broadcast into two critical sections with the hook honored at the semantic boundary now fails the test with an observed Free. Co-Authored-By: Claude Fable 5 --- .../src/wallet/core/generation.rs | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/generation.rs b/packages/rs-platform-wallet/src/wallet/core/generation.rs index b2d35611fb3..1e4e0831cc4 100644 --- a/packages/rs-platform-wallet/src/wallet/core/generation.rs +++ b/packages/rs-platform-wallet/src/wallet/core/generation.rs @@ -542,13 +542,16 @@ impl WalletGeneration { /// monotonic clock needs no guard at all. /// /// [`Self::settle_boundary_hook`] fires at exactly that midpoint under - /// `cfg(test)`, which is what makes the property testable without racing - /// the scheduler for it. + /// `cfg(test)` — after the first outpoint's dispatching hold is lifted and + /// before its pending phase opens, i.e. inside the torn state itself, not + /// merely after the lock is acquired. A hook that fired on lock + /// acquisition would be satisfied by the first half of a split + /// implementation too; fired here, only a critical section that spans + /// both halves keeps the boundary unobservable + /// (`dashpay/platform#4309`, review round 6). fn unpin_in_broadcast(&self, outpoints: &[OutPoint], settle: PendingSpendSettle) { let now = Instant::now(); let mut pinned = self.in_broadcast_lock(); - #[cfg(test)] - self.fire_settle_boundary_hook(); for outpoint in outpoints { let Some(fence) = pinned.get_mut(outpoint) else { // Unreachable by construction — every pin inserts before its @@ -557,6 +560,11 @@ impl WalletGeneration { continue; }; fence.dispatching = fence.dispatching.saturating_sub(1); + // The dispatching→pending midpoint: this outpoint's dispatching + // hold is lifted, its pending phase is not yet open. One-shot, so + // in effect it fires at the first outpoint's midpoint. + #[cfg(test)] + self.fire_settle_boundary_hook(); match settle { PendingSpendSettle::Pending => fence.open_pending(now), PendingSpendSettle::Released => {} @@ -606,7 +614,8 @@ impl WalletGeneration { /// Run `hook` at the dispatching→pending midpoint of the very next /// [`unpin_in_broadcast`](Self::unpin_in_broadcast) on this generation: - /// after the `in_broadcast` lock is taken, before any fence is mutated. + /// after an outpoint's dispatching hold is lifted, before its pending + /// phase is opened — the torn state itself. /// /// The test-only synchronization hook that makes the handoff regression /// DETERMINISTIC (`dashpay/platform#4309`, review round 5 suggestion). The @@ -616,6 +625,13 @@ impl WalletGeneration { /// the midpoint by construction, and what it can see there is the whole /// assertion. /// + /// The firing point matters (round 6): fired on lock ACQUISITION, the + /// observation would complete before any fence was touched, so an + /// implementation that split the decrement and the pending install into + /// separate critical sections — the regression under test — would satisfy + /// it with its first section alone. Fired between the two operations, the + /// observation is protected only if one critical section spans both. + /// /// One-shot: consumed by the settle that fires it, so an unrelated later /// settle cannot re-enter the test's handshake. #[cfg(test)] @@ -1153,9 +1169,12 @@ mod tests { /// pre-fix code — so it proved nothing. /// /// This one is deterministic. [`WalletGeneration::on_next_settle_boundary`] - /// runs the observer AT the midpoint by construction, and the settling - /// thread BLOCKS until the observer has published what it saw, so there is - /// no race to lose. The observer probes with `try_lock` + /// runs the observer AT the midpoint by construction — after the + /// dispatching hold is lifted, before the pending phase opens, so the + /// probe lands inside the torn state itself rather than before any fence + /// was touched (round 6) — and the settling thread BLOCKS until the + /// observer has published what it saw, so there is no race to lose. The + /// observer probes with `try_lock` /// ([`WalletGeneration::try_probe_in_broadcast`]) rather than blocking, /// because a blocking read cannot distinguish "held across the whole /// transition" — the property under test — from "granted after it". From efe7c73888dce557d3740c1cba014a5a53e56e92 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:53:52 -0400 Subject: [PATCH 15/15] test(wallet): drive the production SpendObservationHandler in the fence release tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headline release tests claimed to exercise the spend-observation seam, but observe_via_event_handler called observed_spends and then WalletGeneration::observe_spent directly — bypassing on_wallet_event, the observing_wallet variant gate, the wallets-map try_read, the wallet-id lookup, and selection of the registered generation. A handler that routed an event to the wrong generation, or failed its map lookup, would not have failed them. observe_via_event_handler now stands up the real handler over a real wallets map whose Arc entry shares the fixture's manager, wallet id, and generation, and dispatches through on_wallet_event — the full production path. New coverage on top: * spend_observation_releases_only_the_matching_registered_generation: two fenced wallets in ONE map; an event naming an unregistered wallet releases neither fence, and wallet A's spend event releases A while B's fence stands. * manager::tests::constructor_wires_spend_observation_into_the_event_fanout: a spend event through the manager's OWN PlatformEventManager releases a registered wallet's fence, pinning the handler's registration in the constructor list itself (the accidental-omission regression). The spend-event fixture moved to test_support::observed_spend_event so the broadcast tests and the manager wiring test cannot drift onto different event shapes, and the manager's retained event_manager field is now gated on any(test, feature = "shielded") so the wiring test can reach the fan-out. Co-Authored-By: Claude Fable 5 --- .../rs-platform-wallet/src/manager/mod.rs | 81 +++++++- .../rs-platform-wallet/src/test_support.rs | 58 ++++++ .../src/wallet/core/broadcast.rs | 194 +++++++++++++----- 3 files changed, 278 insertions(+), 55 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index c399569f40b..a08550b47a4 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -386,9 +386,11 @@ pub struct PlatformWalletManager { /// onto the freshly-created `NetworkShieldedCoordinator` that /// forwards into `on_shielded_sync_progress`. Sub-managers /// (`SpvRuntime`, `PlatformAddressSyncManager`, etc.) hold their - /// own clones already, so `configure_shielded` is the only reader of - /// this retained handle — hence it is `shielded`-gated. - #[cfg(feature = "shielded")] + /// own clones already, so `configure_shielded` is the only + /// production reader of this retained handle — hence it is gated to + /// `shielded`, plus `test` so the handler-wiring test can dispatch + /// an event through the manager's own fan-out. + #[cfg(any(test, feature = "shielded"))] pub(super) event_manager: Arc, pub(super) persister: Arc

, /// Cancellation token + join handle for the wallet-event adapter @@ -544,7 +546,7 @@ impl PlatformWalletManager

{ shielded_sync_manager: shielded_sync, #[cfg(feature = "shielded")] shielded_coordinator, - #[cfg(feature = "shielded")] + #[cfg(any(test, feature = "shielded"))] event_manager, persister, event_adapter_cancel, @@ -1023,6 +1025,77 @@ mod tests { )) } + /// The constructor must register [`SpendObservationHandler`] on the event + /// fan-out, over the LIVE wallets map (`dashpay/platform#4309`, review + /// round 6): a spend-bearing wallet event dispatched through the manager's + /// own `event_manager` must release a registered wallet's in-broadcast + /// fence. Dropping the handler from the constructor's handler list — the + /// accidental-omission regression this pins — fails the final assertion, + /// because nothing else on the fan-out calls `observe_spent`. + #[tokio::test] + async fn constructor_wires_spend_observation_into_the_event_fanout() { + use dashcore::hashes::Hash as _; + + let mgr = make_manager(); + + // A funded wallet registered in the manager's live wallets map — the + // same map the constructor handed to its handlers. + let (wallet_manager, wallet_id, generation, _signer) = + crate::test_support::funded_wallet_manager( + key_wallet::account::account_type::StandardAccountType::BIP44Account, + ) + .await; + let spv = Arc::new(SpvRuntime::new( + Arc::clone(&wallet_manager), + Arc::new(PlatformEventManager::new(Vec::new())), + )); + let wallet = Arc::new(PlatformWallet::new( + Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")), + wallet_id, + wallet_manager, + Arc::clone(&generation), + Arc::new(Notify::new()), + Arc::new(NoopPersister) as Arc, + Arc::new(crate::broadcaster::SpvBroadcaster::new(spv)), + )); + mgr.wallets.write().await.insert(wallet_id, wallet); + + // Fence an outpoint the way a dispatch does: pin, then settle into the + // pending-spend phase that only an observed spend may end. + let tx = dashcore::Transaction { + version: 2, + lock_time: 0, + input: vec![dashcore::TxIn { + previous_output: dashcore::OutPoint { + txid: dashcore::Txid::from_slice(&[9u8; 32]).expect("txid"), + vout: 0, + }, + script_sig: dashcore::ScriptBuf::new(), + sequence: 0xffff_ffff, + witness: dashcore::Witness::new(), + }], + output: Vec::new(), + special_transaction_payload: None, + }; + generation.pin_in_broadcast(&tx).settle_pending_spend(); + assert!( + generation.in_broadcast_conflict(&tx).is_some(), + "the settled pin must leave the pending-spend fence up" + ); + + // The spend event, dispatched through the manager's OWN fan-out — not + // a hand-built handler — so the assertion covers registration itself. + mgr.event_manager + .on_wallet_event(&crate::test_support::observed_spend_event(wallet_id, &tx)); + + assert!( + generation.in_broadcast_conflict(&tx).is_none(), + "a spend event through the manager's event fan-out must release \ + the registered wallet's fence — is SpendObservationHandler still \ + in the constructor's handler list?" + ); + } + /// `shutdown()` joins every started coordinator through the shared /// [`ThreadRegistry`], reports each as cleanly joined, and is /// idempotent — a second call finds nothing running and still reports diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 559f8d0c9ea..cf63544023d 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -263,6 +263,64 @@ pub(crate) async fn funded_wallet_manager_with_outputs( (Arc::new(RwLock::new(wm)), wallet_id, generation, signer) } +/// The `WalletEvent` the wallet emits when it first observes `tx` spending +/// its outpoints — the real shape the spend-observation seam +/// ([`SpendObservationHandler`](crate::wallet::core::SpendObservationHandler)) +/// consumes off the event fan-out. +/// +/// `input_details` claims EVERY input as ours, which is what upstream +/// populates for inputs that spent this wallet's outpoints — and the only +/// part of the record either the in-broadcast fence or +/// `CoreChangeSet::spent_utxos` reads. +/// +/// Shared between the broadcast-fence release tests +/// (`wallet::core::broadcast`) and the manager-level fan-out wiring test +/// (`manager::tests`), so the two cannot drift onto different event shapes. +#[cfg(test)] +pub(crate) fn observed_spend_event( + wallet_id: WalletId, + tx: &Transaction, +) -> key_wallet_manager::WalletEvent { + use dashcore::Address as DashAddress; + use key_wallet::managed_account::transaction_record::{ + InputDetail, TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::transaction_router::TransactionType; + + let record = TransactionRecord::new( + tx.clone(), + key_wallet::account::AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::InBlock(BlockInfo::new( + 1_000, + dashcore::BlockHash::from([7u8; 32]), + 1_234_567_890, + )), + TransactionType::Standard, + TransactionDirection::Outgoing, + tx.input + .iter() + .enumerate() + .map(|(index, _)| InputDetail { + index: index as u32, + value: 0, + address: DashAddress::dummy(Network::Testnet, 1), + }) + .collect(), + Vec::new(), + 0, + ); + key_wallet_manager::WalletEvent::TransactionDetected { + wallet_id, + record: Box::new(record), + balance: key_wallet::WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + addresses_derived: Vec::new(), + } +} + /// Funds BOTH standard families — BIP44 account 0 and BIP32 account 0 — each /// with its own chain-locked UTXO set, for the pooled-send tests: a spend /// larger than either family's balance must draw from both. diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index f8770ecd819..34f70220656 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -404,7 +404,8 @@ mod tests { funded_wallet_manager, AlwaysMaybeSentBroadcaster, AlwaysOkBroadcaster, RejectFirstBroadcaster, WalletSigner, }; - use crate::wallet::core::CoreWallet; + use crate::wallet::core::{CoreWallet, SpendObservationHandler}; + use crate::wallet::platform_wallet::WalletId; use crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS; use crate::{PlatformWalletError, SignedCoreTransaction}; @@ -783,72 +784,76 @@ mod tests { } /// The `WalletEvent` the wallet emits when it observes `tx` — the real - /// shape the spend-observation seam consumes. - /// - /// `input_details` is what upstream populates for inputs that spent OUR - /// outpoints, and it is the only part of the record either the fence or - /// `CoreChangeSet::spent_utxos` reads. + /// shape the spend-observation seam consumes. Shared fixture, so this + /// module and the manager-level wiring test cannot drift onto different + /// event shapes. fn spend_event( core: &CoreWallet, tx: &Transaction, ) -> key_wallet_manager::WalletEvent { - use key_wallet::managed_account::transaction_record::{ - InputDetail, TransactionDirection, TransactionRecord, - }; - use key_wallet::transaction_checking::transaction_router::TransactionType; - use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; - - let record = TransactionRecord::new( - tx.clone(), - key_wallet::account::AccountType::Standard { - index: 0, - standard_account_type: StandardAccountType::BIP44Account, - }, - TransactionContext::InBlock(BlockInfo::new( - 1_000, - dashcore::BlockHash::from([7u8; 32]), - 1_234_567_890, - )), - TransactionType::Standard, - TransactionDirection::Outgoing, - tx.input + crate::test_support::observed_spend_event(core.wallet_id(), tx) + } + + /// An `Arc` sharing `core`'s manager, wallet id, and + /// generation — the entry the production wallets map holds for this + /// wallet, so the spend-observation tests can resolve the REAL registered + /// generation through a real map. Its own `SpvBroadcaster` is inert: the + /// spend-observation seam only ever reads `generation()` through it. + fn platform_wallet_sharing( + core: &CoreWallet, + ) -> Arc { + let spv = Arc::new(crate::spv::SpvRuntime::new( + Arc::clone(&core.wallet_manager), + Arc::new(crate::events::PlatformEventManager::new(Vec::new())), + )); + Arc::new(crate::wallet::PlatformWallet::new( + Arc::clone(&core.sdk), + core.wallet_id(), + Arc::clone(&core.wallet_manager), + Arc::clone(core.generation()), + Arc::new(tokio::sync::Notify::new()), + Arc::new(crate::test_support::NoopTestPersister) + as Arc, + Arc::new(crate::broadcaster::SpvBroadcaster::new(spv)), + )) + } + + /// A wallets map — the production `BTreeMap>` + /// behind its own `RwLock` — holding one entry per fixture wallet. + fn wallets_map( + cores: &[&CoreWallet], + ) -> Arc< + tokio::sync::RwLock< + std::collections::BTreeMap>, + >, + > { + Arc::new(tokio::sync::RwLock::new( + cores .iter() - .enumerate() - .map(|(index, _)| InputDetail { - index: index as u32, - value: 0, - address: DashAddress::dummy(Network::Testnet, 1), - }) + .map(|core| (core.wallet_id(), platform_wallet_sharing(core))) .collect(), - Vec::new(), - 0, - ); - key_wallet_manager::WalletEvent::TransactionDetected { - wallet_id: core.wallet_id(), - record: Box::new(record), - balance: key_wallet::WalletCoreBalance::default(), - account_balances: std::collections::BTreeMap::new(), - addresses_derived: Vec::new(), - } + )) } - /// Retire fences from `event` through the REAL projection the - /// `SpendObservationHandler` uses, so these tests exercise the production - /// event→outpoints mapping rather than a stand-in. - /// - /// Only the generation lookup is short-circuited: resolving it goes through - /// the manager's `wallets` map, which a `CoreWallet` fixture does not build. + /// Retire fences from `event` by driving the PRODUCTION spend-observation + /// seam end to end: a real [`SpendObservationHandler`] over a real wallets + /// map whose entry shares `core`'s registered generation. `on_wallet_event` + /// therefore exercises the whole handler path — the variant gate + /// (`observing_wallet`), the projection (`observed_spends`), the + /// wallets-map `try_read`, the wallet-id lookup, and the selected + /// generation's release — not a shortcut to `observe_spent` + /// (`dashpay/platform#4309`, review round 6). fn observe_via_event_handler( core: &CoreWallet, event: key_wallet_manager::WalletEvent, ) { - let spent = crate::wallet::core::spend_observer::observed_spends(&event); assert!( - !spent.is_empty(), + !crate::wallet::core::spend_observer::observed_spends(&event).is_empty(), "the fixture event must report at least one spend, or the test \ would pass without observing anything" ); - core.generation().observe_spent(spent); + let handler = SpendObservationHandler::new(wallets_map(&[core])); + dash_spv::EventHandler::on_wallet_event(&handler, &event); } /// Assert that `result` is the typed in-broadcast conflict, and return the @@ -1131,6 +1136,93 @@ mod tests { core.abandon_transaction(&after).await; } + /// The handler releases ONLY the generation registered under the event's + /// wallet id (`dashpay/platform#4309`, review round 6). Two fenced wallets + /// share ONE wallets map — the production shape — and: an event naming a + /// wallet id registered NOWHERE releases neither fence, and wallet A's own + /// spend event releases A's fence while B's stands. A handler that routed + /// by anything but the event's wallet id, or that failed its map lookup + /// open, fails one of the two halves. + #[tokio::test] + async fn spend_observation_releases_only_the_matching_registered_generation() { + let (core_a, signer_a, outputs_a) = funded_core_wallet( + StandardAccountType::BIP44Account, + Arc::new(AlwaysOkBroadcaster), + ) + .await; + let (core_b, signer_b, outputs_b) = funded_core_wallet( + StandardAccountType::BIP44Account, + Arc::new(AlwaysOkBroadcaster), + ) + .await; + assert_ne!( + core_a.wallet_id(), + core_b.wallet_id(), + "the fixture must model two distinct wallets" + ); + + // Dispatch both wallets' single UTXO and sweep both funding + // reservations, so each fence is the only thing holding its input + // (see the sibling release tests). + let mut sent = Vec::new(); + for (core, signer, outputs) in [ + (&core_a, &signer_a, &outputs_a), + (&core_b, &signer_b, &outputs_b), + ] { + let stamped = core + .last_processed_height() + .await + .expect("last processed height"); + let finalized = finalize_tx(core, AccountTypePreference::BIP44, outputs, signer).await; + sent.push(finalized.transaction().clone()); + assert!(core + .broadcast_finalized_transaction(&finalized) + .await + .is_ok()); + advance_processed_height(core, stamped + 17_000).await; + expect_mid_broadcast( + try_finalize_tx(core, AccountTypePreference::BIP44, outputs, signer).await, + "the dispatched input must be fenced before any spend is observed", + ); + } + + let handler = SpendObservationHandler::new(wallets_map(&[&core_a, &core_b])); + + // An event naming a wallet id registered NOWHERE: the lookup misses, + // nothing panics, and neither fence moves — the fail-safe direction. + let mut foreign = spend_event(&core_a, &sent[0]); + match &mut foreign { + key_wallet_manager::WalletEvent::TransactionDetected { wallet_id, .. } => { + *wallet_id = [0xEE; 32]; + } + other => unreachable!("the fixture builds TransactionDetected, got {other:?}"), + } + dash_spv::EventHandler::on_wallet_event(&handler, &foreign); + for (core, signer, outputs) in [ + (&core_a, &signer_a, &outputs_a), + (&core_b, &signer_b, &outputs_b), + ] { + expect_mid_broadcast( + try_finalize_tx(core, AccountTypePreference::BIP44, outputs, signer).await, + "an event for an unregistered wallet must release no fence", + ); + } + + // Wallet A's own spend event: A's registered generation releases, + // B's — same map, same handler, different wallet id — stands. + dash_spv::EventHandler::on_wallet_event(&handler, &spend_event(&core_a, &sent[0])); + let rebuilt = try_finalize_tx(&core_a, AccountTypePreference::BIP44, &outputs_a, &signer_a) + .await + .unwrap_or_else(|error| { + panic!("the matching wallet's fence must release, got {error:?}") + }); + core_a.abandon_transaction(&rebuilt).await; + expect_mid_broadcast( + try_finalize_tx(&core_b, AccountTypePreference::BIP44, &outputs_b, &signer_b).await, + "the other registered wallet's fence must stand", + ); + } + /// The ORPHAN BACKSTOP, and its catch-up immunity in one test. /// /// A transaction that is never observed — evicted for fee, conflicted away