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 c61be2ebff9..80e92fbf939 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 @@ -265,13 +265,20 @@ 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 acting on it could touch a + * newer, unrelated reservation. The call did NOT touch the network. + * NOT retryable in place — rebuild the payment. + * + * 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 token-less V2 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 54699f09eca..22227d5c85d 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,14 +42,34 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { tx.accountIndex, ) - /** Consume and broadcast a V2 finalized transaction. */ + /** + * Consume and broadcast a V2 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, so + * a follow-up [abandonTransaction] is an invalid-handle error, not a recovery + * path — there is nothing left to release, and the aged reservation is left + * for key-wallet's TTL to reclaim (releasing it by outpoint could free a + * newer build's reservation). Recover by rebuilding the transaction. + */ fun broadcastTransaction(tx: FinalizedCoreTransaction): String = WalletManagerNative.coreWalletBroadcastSignedTransactionV2( handle, tx.takeForBroadcast(), ) - /** Consume without sending and release the selected inputs immediately. */ + /** + * Consume a finalized transaction without sending. Below the reservation age + * bound this releases the selected inputs immediately so a rebuild can + * reselect them. If the handle has aged past the bound the by-outpoint + * release is skipped — key-wallet's TTL may already have swept and + * re-reserved the outpoint, so releasing it could free a newer build's + * reservation — and the aged reservation is left for the TTL to reclaim; the + * handle is torn down either way. + */ fun abandonTransaction(tx: FinalizedCoreTransaction) { WalletManagerNative.coreWalletAbandonSignedTransactionV2( 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 ee1f064b184..a6b9f4247d7 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -327,6 +327,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) = @@ -366,6 +386,57 @@ mod tests { CORE_WALLET_STORAGE.remove(other_handle); } + /// The deinit/GC backstop (`core_wallet_signed_transaction_v2_free`) is the + /// exact path shumkov flagged: a `FinalizedCoreTransaction` never broadcast + /// or abandoned, freed by the host GC long after finalize. If the reservation + /// has aged past the guard bound the free must **skip** the by-outpoint + /// release — key-wallet's TTL may already have swept and re-reserved the + /// outpoint, and releasing it would free that newer build's reservation. The + /// handle is still torn down (the storage entry is removed) so a re-free is a + /// safe no-op. + #[test] + fn aged_v2_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_v2_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_v2_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_v2(u64::MAX, transaction_handle) }; + assert_eq!( + invalid.code, + PlatformWalletFFIResultCode::ErrorInvalidHandle + ); + assert_released_for_rebuild(&origin, &signer, 51); + } + #[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 6e76b13e107..916bbc8e5a4 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -265,6 +265,20 @@ 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 V2 + /// finalized-transaction handle path + /// (`core_wallet_broadcast_signed_transaction_v2`): 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 V2 handle carries + /// no numeric reservation token, hence a distinct (token-less) wallet-error + /// variant behind the same FFI code. Abandon/free of a V2 handle never + /// surfaces this — abandon returns no result code, and past the age bound it + /// deliberately skips the by-outpoint release (dropping only the handle and + /// leaving the aged outpoint to key-wallet's TTL) precisely because + /// releasing an aged reservation could free an unrelated newer build's + /// reservation. ErrorStaleReservationToken = 34, /// Maps `SignedPaymentError::StaleToken`. The deferred reservation token is @@ -442,6 +456,14 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::TransactionBroadcast(..) => { PlatformWalletFFIResultCode::ErrorTransactionBroadcastRejected } + // The V2 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 @@ -1040,6 +1062,34 @@ mod tests { assert_eq!(msg, rendered, "Display payload must survive verbatim"); } + /// The V2 finalized-transaction handle age guard + /// (`core_wallet_broadcast_signed_transaction_v2` → `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 3e3632b7272..0589c86d230 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -90,6 +90,31 @@ pub enum PlatformWalletError { )] TransactionBroadcastUnconfirmed(String), + /// A finalized V2 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. Abandoning/freeing the handle stays + /// allowed at any age, but past the bound `abandon_transaction` drops only + /// the handle and deliberately **skips** the by-outpoint reservation + /// release: the outpoint may already have been swept and re-reserved by an + /// unrelated build, so releasing it could free that newer reservation. The + /// aged outpoint is left for key-wallet's TTL to reclaim. + /// + /// This is the V2 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 85349a31616..50c2afa1a2d 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -354,6 +354,37 @@ 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 the aged abandon/free skip-release +/// path — the deinit/GC backstop and the broadcast/abandon failure paths that +/// route their cleanup through `abandon_transaction`. +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 0176d661d3a..8c79d0b6f1f 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -5,7 +5,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 { @@ -19,10 +19,40 @@ 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 V2 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 stale reservation is deliberately left for key-wallet's TTL to + /// reclaim rather than released by outpoint here (which could free a newer + /// build's reservation); the caller must rebuild the payment. Abandon/free + /// ([`abandon_transaction`](Self::abandon_transaction)) release at any age + /// when the build stamped an owner token (`release_reservation_if_owner` + /// no-ops once ownership transferred); only a token-less build honours the + /// bound and skips its unguarded by-outpoint release. pub async fn broadcast_finalized_transaction( &self, transaction: &SignedCoreTransaction, ) -> Result { + if reservation_expired( + transaction.reservation_height(), + self.last_processed_height().await, + ) { + return Err(PlatformWalletError::StaleReservation); + } match self.broadcaster.broadcast(transaction.transaction()).await { Ok(txid) => Ok(txid), Err(error) => { @@ -164,14 +194,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. @@ -253,6 +286,216 @@ mod tests { Ok(tx) } + /// Atomically fund + reserve + sign a `SignedCoreTransaction` the way the V2 + /// 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 V2 + /// 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 V2 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 V2 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`). An aged abandon/free still releases, OWNER-GUARDED: + /// the funded finalize always stamps a token, and + /// `release_reservation_if_owner` frees the inputs only while this build + /// still owns them (no-op after a sweep/re-reservation). We prove the + /// release by showing an immediate rebuild reselects the inputs. + #[tokio::test] + async fn aged_finalized_handle_refuses_broadcast_but_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; + + // 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:?}" + ); + + // Aged abandon releases OWNER-GUARDED: below key-wallet's TTL the + // reservation is still this build's, `release_reservation_if_owner` + // frees it, and an immediate rebuild reselects the inputs. (Had a + // sweep already transferred ownership, the same call would no-op — + // safe either way; only a token-less build skips.) + core.abandon_transaction(&finalized).await; + let rebuilt = try_finalize_tx(&core, account_type, &outputs, &signer).await; + assert!( + rebuilt.is_ok(), + "aged abandon must release the still-owned reservation so a \ + rebuild succeeds for {account_type:?}, got {rebuilt:?}" + ); + core.abandon_transaction(&rebuilt.expect("rebuild")).await; + } + } + + /// Below the guard bound the reservation is provably still ours (no sweep + /// possible yet), so abandon/free **do** release by outpoint — returning the + /// inputs so an immediate rebuild reselects them. This is the mirror of the + /// aged skip case. + #[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, + AccountTypePreference::CoinJoin => { + unreachable!("coinjoin funding not exercised by these tests") + } + } + } + /// 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 32603873dae..3fe0e671ecb 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::{Account, DerivationPath, ReservationToken, Utxo}; use super::{CoreWallet, WalletGeneration}; use crate::broadcaster::TransactionBroadcaster; +use crate::wallet::reservations::reservation_expired; use crate::PlatformWalletError; fn map_builder_error( @@ -376,7 +377,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 V2 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_v2_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_account_type, transaction.funding_account_index, diff --git a/packages/rs-platform-wallet/src/wallet/reservations.rs b/packages/rs-platform-wallet/src/wallet/reservations.rs index 10b1cbebdfd..7c63cae2b59 100644 --- a/packages/rs-platform-wallet/src/wallet/reservations.rs +++ b/packages/rs-platform-wallet/src/wallet/reservations.rs @@ -22,6 +22,71 @@ 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 V2 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. +/// +/// Both *consuming* (broadcasting) and *releasing by outpoint* (abandon/free) a +/// stale reservation are 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 and releasing would free it — +/// `ReservationSet::release` removes an outpoint unconditionally, with no +/// ownership/generation check. An aged reservation is therefore left for +/// key-wallet's TTL to reclaim: the guarded broadcast +/// ([`broadcast_finalized_transaction`](crate::CoreWallet::broadcast_finalized_transaction)) +/// returns `StaleReservation`, and the guarded abandon/free paths (the registry's +/// `reconcile_removed_entry` and +/// [`abandon_transaction`](crate::CoreWallet::abandon_transaction)) tear the +/// handle/registry entry down without touching the `ReservationSet`. +/// +/// 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 V2 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 db385d55c61..0673a4d7c2a 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 @@ -80,6 +81,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 V2 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 @@ -122,48 +127,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 { @@ -197,7 +160,8 @@ 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 @@ -264,8 +228,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 @@ -674,13 +640,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