From dbc4279fdb605afc7b1932080df21b0677acbeac Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:56:35 -0400 Subject: [PATCH 01/24] feat(kotlin-sdk): split build/broadcast with reservation release for BIP70 deferred submission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BIP70/BIP270 (CTX/DashSpend) sends must sign, POST the raw bytes to a merchant server, and broadcast only on ack — structurally impossible on the one-shot `sendToAddresses`. Expose the existing internal build/broadcast split with an explicit reservation lifecycle, keeping `CoreTransactionBuilder` internal so the manager stays the sole driver of the setFunding/buildSigned race. Rust core (rs-platform-wallet): - New `SignedPaymentRegistry`: a generic, in-memory registry that owns a built+signed tx and its held UTXO reservation between build and submission, keyed by an opaque `ReservationToken`. `broadcast` removes the entry before sending (no double-broadcast — a repeat/concurrent call gets `StaleToken`), binds each token to its originating wallet instance (`Arc::ptr_eq` on the shared `WalletManager`, so a re-created wallet is rejected), and reconciles the reservation on failure via the existing release-on-rejection path. `release` is idempotent. Reservations are memory-only, so a crash between build and broadcast drops both the entry and the reservation on restart — the same property dashj has. - `CoreWallet::release_transaction_reservation` — the explicit "abandoned / nacked" release arm. FFI (platform-wallet-ffi) — additive C ABI: - `core_wallet_transaction_get_bytes`, `core_wallet_signed_payment_register` (token + fee + txid), `core_wallet_signed_payment_broadcast`, `core_wallet_signed_payment_release`, backed by one process-global registry pinned to `SpvBroadcaster`. - New `ErrorStaleReservationToken` (22) result code. JNI (rs-unified-sdk-jni) — additive: `coreTransactionGetBytes`, `coreWalletRegisterSignedPayment` (BLOB), `coreWalletBroadcastSignedPayment`, `coreWalletReleaseSignedPayment`. Kotlin — additive: `ManagedPlatformWallet.SignedCoreTransaction`, `buildSignedPayment` (build under coreSendMutex), `broadcastSigned(token)`, `releaseReservation(token)`; `DashSdkError.PlatformWallet.StaleReservationToken`. No existing signatures change. Refs dashpay/platform#4089, dashpay/dash-wallet#1507 Phase 5c GAP-4. Co-Authored-By: Claude Fable 5 --- .../dashsdk/errors/DashSdkError.kt | 15 + .../dashsdk/ffi/WalletManagerNative.kt | 44 ++ .../dashsdk/wallet/ManagedCoreWallet.kt | 39 + .../dashsdk/wallet/ManagedPlatformWallet.kt | 137 ++++ .../dashsdk/errors/DashSdkErrorTest.kt | 10 + .../src/core_wallet/mod.rs | 2 + .../src/core_wallet/signed_payment.rs | 199 +++++ .../src/core_wallet/transaction_builder.rs | 5 + packages/rs-platform-wallet-ffi/src/error.rs | 9 + packages/rs-platform-wallet/src/lib.rs | 3 + .../src/wallet/core/broadcast.rs | 34 +- packages/rs-platform-wallet/src/wallet/mod.rs | 4 + .../src/wallet/signed_payment_registry.rs | 688 ++++++++++++++++++ .../rs-unified-sdk-jni/src/wallet_manager.rs | 175 +++++ 14 files changed, 1363 insertions(+), 1 deletion(-) create mode 100644 packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs create mode 100644 packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs 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 de41a05412a..bfa54c7fd66 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 @@ -176,6 +176,20 @@ sealed class DashSdkError( cause, ) + /** + * `ErrorStaleReservationToken` (native code 26). A deferred + * (BIP70/BIP270) [broadcastSigned][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.broadcastSigned] + * was given a reservation token that is unknown, already broadcast, + * already released, or was minted against a re-created wallet instance. + * The call did NOT touch the network — there is no double-broadcast — + * but the token can never succeed, so this is NOT retryable: rebuild the + * payment with + * [buildSignedPayment][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.buildSignedPayment]. + * (Release is idempotent and never raises this.) + */ + class StaleReservationToken(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + /** * Any other `PlatformWalletFFIResultCode` without a dedicated type. * Carries the platform-wallet [nativeCode] (already de-offset) and @@ -245,6 +259,7 @@ sealed class DashSdkError( 23 -> PlatformWallet.AssetLockNotTracked(message, cause) // ErrorAssetLockNotTracked 24 -> PlatformWallet.AssetLockAlreadyConsumed(message, cause) // ErrorAssetLockAlreadyConsumed 25 -> PlatformWallet.AssetLockFundingMismatch(message, cause) // ErrorAssetLockFundingMismatch + 26 -> PlatformWallet.StaleReservationToken(message, cause) // ErrorStaleReservationToken else -> PlatformWallet.Generic(code, message, cause) } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt index 0dfbbedc89d..daee4c76091 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt @@ -246,6 +246,50 @@ internal object WalletManagerNative { */ external fun coreTransactionFree(tx: Long) + /** + * `core_wallet_transaction_get_bytes` — the consensus-serialized bytes of a + * transaction from [coreTxBuilderBuildSigned], copied into a fresh + * `ByteArray`. The transaction handle must still be live (not yet freed by + * [coreTransactionFree]). + */ + external fun coreTransactionGetBytes(tx: Long): ByteArray + + /** + * `core_wallet_signed_payment_register` — register a built+signed + * transaction (from [coreTxBuilderBuildSigned]) for deferred + * (BIP70/BIP270) submission, holding its UTXO reservation. Does NOT consume + * the transaction — free it separately with [coreTransactionFree]. + * [accountType]/[accountIndex] identify the funding account (0 BIP44, + * 1 BIP32, 2 CoinJoin). + * + * Returns a big-endian BLOB decoded into a `SignedCoreTransaction`: + * `u64 token, u64 feeDuffs, u32 txidLen, txid utf8`. The raw tx bytes come + * from [coreTransactionGetBytes]. + */ + external fun coreWalletRegisterSignedPayment( + coreHandle: Long, + tx: Long, + accountType: Int, + accountIndex: Int, + ): ByteArray + + /** + * `core_wallet_signed_payment_broadcast` — broadcast the payment behind + * [token], reconciling its reservation on failure and consuming the token. + * A repeated/stale/wrong-wallet token throws + * `ErrorStaleReservationToken` (never a double-broadcast). [coreHandle] must + * resolve to the wallet the token was minted against. Returns the txid as a + * lowercase hex string. + */ + external fun coreWalletBroadcastSignedPayment(coreHandle: Long, token: Long): String + + /** + * `core_wallet_signed_payment_release` — release the funding reservation + * behind [token] and drop it. Idempotent: releasing an unknown / + * already-consumed token is a silent no-op. + */ + external fun coreWalletReleaseSignedPayment(token: Long) + /** * Enumerate the wallet's Platform-payment addresses with cached credit * balances, as a big-endian blob: `u32 rowCount` then per row 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 8a0e661d0ed..0b9c2bf588a 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 @@ -55,6 +55,45 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { ) } + /** + * Register a built+signed [tx] for deferred (BIP70/BIP270) submission, + * holding its UTXO reservation, and return the resulting + * [ManagedPlatformWallet.SignedCoreTransaction]. Does NOT consume [tx] — the + * caller still closes it. Reads the raw bytes off [tx] and decodes the + * register BLOB (`token, feeDuffs, txid`). + */ + internal fun registerSignedPayment( + tx: CoreTransaction, + ): ManagedPlatformWallet.SignedCoreTransaction { + val rawTxBytes = WalletManagerNative.coreTransactionGetBytes(tx.handle) + val blob = WalletManagerNative.coreWalletRegisterSignedPayment( + handle, + tx.handle, + tx.accountType.ffiValue, + tx.accountIndex, + ) + val buffer = java.nio.ByteBuffer.wrap(blob) // big-endian by default + val token = buffer.long + val feeDuffs = buffer.long + val txidLen = buffer.int + val txidBytes = ByteArray(txidLen) + buffer.get(txidBytes) + return ManagedPlatformWallet.SignedCoreTransaction( + txidHex = String(txidBytes, Charsets.UTF_8), + rawTxBytes = rawTxBytes, + feeDuffs = feeDuffs, + reservationToken = token, + ) + } + + /** + * Broadcast the deferred payment behind [token] and return its txid. A + * stale / already-broadcast / wrong-wallet token surfaces as + * [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.StaleReservationToken]. + */ + internal fun broadcastSignedPayment(token: Long): String = + WalletManagerNative.coreWalletBroadcastSignedPayment(handle, token) + override fun close() { cleanable.clean() } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt index ba05a9ff7ff..ec692911416 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt @@ -172,6 +172,143 @@ class ManagedPlatformWallet internal constructor( } } + /** + * A built, signed Core transaction whose funding UTXOs are reserved, + * awaiting a deferred [broadcastSigned] or [releaseReservation] — the + * split-out result of [buildSignedPayment] for BIP70/BIP270 (CTX/DashSpend) + * flows that must sign now, POST the raw bytes to a merchant server, and + * broadcast only on the server's ack. + * + * @property txidHex the transaction id (lowercase hex) the broadcast will + * return — computed from the signed bytes Rust-side so it matches exactly. + * @property rawTxBytes the consensus-serialized signed transaction, to hand + * to the merchant server. + * @property feeDuffs the fee the build charged, in duffs. + * @property reservationToken the opaque token for [broadcastSigned] / + * [releaseReservation]. Valid only for this wallet instance and only until + * consumed by one of those calls. + */ + class SignedCoreTransaction internal constructor( + val txidHex: String, + val rawTxBytes: ByteArray, + val feeDuffs: Long, + val reservationToken: Long, + ) { + override fun equals(other: Any?): Boolean = + other is SignedCoreTransaction && + txidHex == other.txidHex && + rawTxBytes.contentEquals(other.rawTxBytes) && + feeDuffs == other.feeDuffs && + reservationToken == other.reservationToken + + override fun hashCode(): Int { + var result = txidHex.hashCode() + result = 31 * result + rawTxBytes.contentHashCode() + result = 31 * result + feeDuffs.hashCode() + result = 31 * result + reservationToken.hashCode() + return result + } + + override fun toString(): String = + "SignedCoreTransaction(txidHex=$txidHex, feeDuffs=$feeDuffs, " + + "reservationToken=$reservationToken, rawTxBytes=${rawTxBytes.size} bytes)" + } + + /** + * Build and sign a Core payment to [recipients] WITHOUT broadcasting, + * reserving the funding UTXOs and returning a [SignedCoreTransaction] whose + * [SignedCoreTransaction.reservationToken] later drives [broadcastSigned] + * (server acked) or [releaseReservation] (abandoned / server nacked). + * + * The BIP70/BIP270 counterpart to [sendToAddresses]: those protocols sign, + * POST the raw bytes to a merchant server, and broadcast only on ack, which + * a single build-sign-broadcast call cannot express. The `new → addOutput* → + * setFunding → buildSigned` build runs under the same per-wallet + * [coreSendMutex] as [sendToAddresses] (closing the setFunding/buildSigned + * selection race); [buildSigned] reserves the selected UTXOs, so once this + * returns the reservation holds the inputs and [broadcastSigned] / + * [releaseReservation] operate on the token later WITHOUT the mutex. + * + * Process-death note: the reservation is in-memory. An app crash between + * this call and [broadcastSigned] drops the reservation on restart (the + * UTXOs become spendable again) — the same property dashj has. + * + * @param network the wallet network — see [sendToAddresses]. + * @param coreSignerHandle the manager's `MnemonicResolverHandle` — see + * [sendToAddresses]. No private key crosses the boundary. + */ + suspend fun buildSignedPayment( + recipients: List>, + network: org.dashfoundation.dashsdk.Network, + coreSignerHandle: Long, + accountType: AccountType = AccountType.BIP44, + accountIndex: Int = 0, + ): SignedCoreTransaction = withContext(Dispatchers.IO) { + require(accountIndex >= 0) { "accountIndex must be non-negative, got $accountIndex" } + require(recipients.isNotEmpty()) { "recipients must not be empty" } + require(recipients.all { it.second > 0 }) { + "every recipient amount must be positive" + } + val builderAccountType = when (accountType) { + AccountType.BIP44 -> CoreTransactionBuilder.AccountType.BIP44 + AccountType.BIP32 -> CoreTransactionBuilder.AccountType.BIP32 + } + coreSendMutex.withLock { + mapNativeErrors { + coreWallet().use { core -> + val builder = CoreTransactionBuilder(network) + // `buildSigned` consumes the builder; `use` still safely + // destroys it on the pre-build failure paths. + val signedTx = builder.use { + for ((address, amount) in recipients) { + it.addOutput(address, amount) + } + it.setFunding(this@ManagedPlatformWallet, builderAccountType, accountIndex) + it.buildSigned( + this@ManagedPlatformWallet, + builderAccountType, + accountIndex, + coreSignerHandle, + ) + } + // Register the signed tx (holding its reservation) before the + // native transaction is freed; `use` frees it afterward. + signedTx.use { tx -> core.registerSignedPayment(tx) } + } + } + } + } + + /** + * Broadcast the deferred payment behind [token] (from [buildSignedPayment]) + * and return its broadcast txid — the "merchant server acked" arm. Consumes + * the token: a second [broadcastSigned] with the same token, or one for a + * re-created wallet, throws + * [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.StaleReservationToken] + * rather than double-broadcasting. Operates on the token WITHOUT the + * [coreSendMutex] (the inputs are already reserved). + */ + suspend fun broadcastSigned(token: Long): String = withContext(Dispatchers.IO) { + mapNativeErrors { + coreWallet().use { core -> core.broadcastSignedPayment(token) } + } + } + + /** + * Release the funding reservation behind [token] (from [buildSignedPayment]) + * — the "payment abandoned / merchant server nacked" arm — returning the + * reserved UTXOs to spendable. Idempotent: releasing an unknown / + * already-broadcast / already-released token is a silent no-op, so it is + * always safe to call defensively. + */ + suspend fun releaseReservation(token: Long) { + withContext(Dispatchers.IO) { + mapNativeErrors { + WalletManagerNative.coreWalletReleaseSignedPayment(token) + } + } + } + /** * The wallet's Platform-payment addresses that currently hold credits, * each as a [FundingInput] whose `credits` is the full cached balance — diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index f8e397cade5..502b1d274f8 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -103,6 +103,16 @@ class DashSdkErrorTest { ) // The message must warn against retrying (distinct from the anchor case). assertTrue(broadcastUnconfirmed.message!!.contains("do NOT retry")) + + // Deferred build/broadcast: a stale/consumed/wrong-wallet reservation + // token → typed StaleReservationToken, not retryable. + val staleToken = DashSdkError.fromNative(DashSDKException(offset + 22, "stale token 7")) + assertTrue(staleToken is DashSdkError.PlatformWallet.StaleReservationToken) + assertFalse( + "StaleReservationToken must NOT be retryable (rebuild the payment)", + staleToken.isRetryable, + ) + assertEquals("stale token 7", staleToken.message) } @Test diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs index 8e12ebc1783..5a3dc9d3554 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs @@ -4,10 +4,12 @@ mod addresses; mod broadcast; +mod signed_payment; mod transaction_builder; mod wallet; pub use addresses::*; pub use broadcast::*; +pub use signed_payment::*; pub use transaction_builder::*; pub use wallet::*; diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs new file mode 100644 index 00000000000..e2ffee6c3c7 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs @@ -0,0 +1,199 @@ +//! FFI bindings for the deferred build → broadcast/release core-send lifecycle +//! (BIP70 / BIP270 "sign now, submit on merchant ack"). +//! +//! The one-shot [`core_wallet_broadcast_transaction`](super::broadcast) sends a +//! just-built transaction immediately. BIP70-style flows must split that: build +//! and sign now (reserving the funding UTXOs), hand the raw bytes to a merchant +//! server, then broadcast only on ack — or release the reservation on a nack / +//! abandonment. These entry points wrap a single process-global +//! [`SignedPaymentRegistry`] pinned to the production `SpvBroadcaster`; the +//! registry owns the built transaction and its held reservation between build +//! and submission and enforces the lifecycle invariants (no double-broadcast, +//! idempotent release, tokens bound to their originating wallet instance). +//! +//! These are ADDITIVE to the existing `core_wallet_tx_builder_*` / +//! `core_wallet_broadcast_transaction` surface — the immediate send path is +//! unchanged. + +use super::transaction_builder::{CoreAccountTypeFFI, FFICoreTransaction}; +use crate::error::*; +use crate::handle::{Handle, CORE_WALLET_STORAGE}; +use crate::runtime::runtime; +use crate::{check_ptr, unwrap_option_or_return}; +use once_cell::sync::Lazy; +use platform_wallet::broadcaster::SpvBroadcaster; +use platform_wallet::{ReservationToken, SignedPaymentError, SignedPaymentRegistry}; +use std::ffi::CString; +use std::os::raw::c_char; + +/// Process-global registry of signed-but-unsent payments, keyed by an opaque +/// [`ReservationToken`]. In-memory only: an app crash between build and +/// broadcast drops the registry entry and the underlying UTXO reservation +/// together, so nothing leaks across a restart. +static SIGNED_PAYMENT_REGISTRY: Lazy> = + Lazy::new(SignedPaymentRegistry::new); + +/// Borrow the consensus-serialized bytes of a transaction built by +/// `core_wallet_tx_builder_build_signed`, for the caller to copy into +/// `SignedCoreTransaction.rawTxBytes`. +/// +/// The written pointer borrows the `FFICoreTransaction`'s own buffer — it is +/// valid only until the transaction is freed with +/// `core_wallet_transaction_free`, so the caller must copy the bytes out +/// immediately and must not retain the pointer. +/// +/// # Safety +/// `tx` must be a valid, non-freed `FFICoreTransaction` pointer; +/// `out_ptr`/`out_len` must be writable. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_transaction_get_bytes( + tx: *const FFICoreTransaction, + out_ptr: *mut *const u8, + out_len: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(tx); + check_ptr!(out_ptr); + check_ptr!(out_len); + + let bytes = (*tx).bytes(); + *out_ptr = bytes.as_ptr(); + *out_len = bytes.len(); + PlatformWalletFFIResult::ok() +} + +/// Register a built, signed transaction for deferred submission and return a +/// reservation token. +/// +/// `core_wallet_tx_builder_build_signed` already reserved the funding UTXOs; the +/// registry takes its own copy of the transaction and holds the reservation +/// (via the captured wallet instance behind `core_handle`) until a later +/// [`core_wallet_signed_payment_broadcast`] or +/// [`core_wallet_signed_payment_release`]. The passed `tx` is NOT consumed — the +/// caller still frees it with `core_wallet_transaction_free`. +/// +/// `account_type`/`account_index` identify the funding account handed to +/// `set_funding`, so the reservation can be released on rejection/abandonment. +/// Writes `out_token`, `out_fee` (the build's fee in duffs), and `out_txid` (a +/// heap-allocated lowercase-hex C string the caller frees with +/// `core_wallet_free_address`). +/// +/// # Safety +/// `tx` must be a valid, non-freed `FFICoreTransaction`; `core_handle` a valid +/// core-wallet handle; the three out-pointers must be writable. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_signed_payment_register( + core_handle: Handle, + tx: *const FFICoreTransaction, + account_type: CoreAccountTypeFFI, + account_index: u32, + out_token: *mut u64, + out_fee: *mut u64, + out_txid: *mut *mut c_char, +) -> PlatformWalletFFIResult { + check_ptr!(tx); + check_ptr!(out_token); + check_ptr!(out_fee); + check_ptr!(out_txid); + + let core = unwrap_option_or_return!(CORE_WALLET_STORAGE.with_item(core_handle, |w| w.clone())); + + let transaction: dashcore::Transaction = match dashcore::consensus::deserialize((*tx).bytes()) { + Ok(t) => t, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorDeserialization, + format!("failed to deserialize signed transaction: {e}"), + ); + } + }; + let txid = transaction.txid(); + let fee = (*tx).fee(); + + let token = SIGNED_PAYMENT_REGISTRY.register( + core, + transaction, + account_type.as_standard_account_type(), + account_index, + ); + + // txid hex never contains a NUL, but handle the impossible case anyway. + let c_txid = match CString::new(txid.to_string()) { + Ok(s) => s, + Err(_) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorUtf8Conversion, + "txid string contained an interior NUL".to_string(), + ); + } + }; + + *out_token = token; + *out_fee = fee; + *out_txid = c_txid.into_raw(); + PlatformWalletFFIResult::ok() +} + +/// Broadcast the payment behind `token` (built earlier via +/// [`core_wallet_signed_payment_register`]), reconciling its UTXO reservation on +/// failure, and consume the token. +/// +/// The token is consumed atomically before the send, so a repeated or +/// concurrent broadcast of the same token gets `ErrorStaleReservationToken` +/// rather than a second send. `core_handle` must resolve to the same wallet +/// instance the token was minted against; a re-created wallet yields +/// `ErrorStaleReservationToken`. Writes `out_txid` (a heap C string freed with +/// `core_wallet_free_address`) on success. +/// +/// # Safety +/// `core_handle` must be a valid core-wallet handle; `out_txid` must be writable. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_signed_payment_broadcast( + core_handle: Handle, + token: u64, + out_txid: *mut *mut c_char, +) -> PlatformWalletFFIResult { + check_ptr!(out_txid); + + let core = unwrap_option_or_return!(CORE_WALLET_STORAGE.with_item(core_handle, |w| w.clone())); + + let result = runtime().block_on(SIGNED_PAYMENT_REGISTRY.broadcast(token as ReservationToken, &core)); + + match result { + Ok(txid) => { + let c_txid = match CString::new(txid.to_string()) { + Ok(s) => s, + Err(_) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorUtf8Conversion, + "txid string contained an interior NUL".to_string(), + ); + } + }; + *out_txid = c_txid.into_raw(); + PlatformWalletFFIResult::ok() + } + Err(e @ (SignedPaymentError::StaleToken(_) | SignedPaymentError::WalletMismatch(_))) => { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorStaleReservationToken, + e.to_string(), + ) + } + // Preserve the typed underlying wallet error (keeps the ambiguous + // "may already be on the network" retry semantics intact). + Err(SignedPaymentError::Broadcast(e)) => PlatformWalletFFIResult::from(e), + } +} + +/// Release the funding reservation behind `token` and drop it — the "payment +/// abandoned / merchant server nacked" arm. Idempotent: releasing an unknown / +/// already-consumed token is a silent success, so it never surfaces +/// `ErrorStaleReservationToken`. Needs no wallet handle: the release acts on the +/// wallet instance the token was minted against. +/// +/// # Safety +/// Always safe to call; `token` is a plain value. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_signed_payment_release(token: u64) -> PlatformWalletFFIResult { + runtime().block_on(SIGNED_PAYMENT_REGISTRY.release(token as ReservationToken)); + PlatformWalletFFIResult::ok() +} diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs index 8b79efcf2ab..3e30447cf59 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs @@ -59,6 +59,11 @@ impl FFICoreTransaction { unsafe { std::slice::from_raw_parts(self.tx_bytes, self.tx_len) } } } + + /// The fee (duffs) `build_signed` computed for this transaction. + pub(crate) fn fee(&self) -> u64 { + self.fee + } } #[derive(Clone, Copy)] diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 7d01177b5b7..b20f6122c6a 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -168,6 +168,15 @@ pub enum PlatformWalletFFIResultCode { /// Existing-lock recovery attempted to use a lock for the wrong funding /// family or bound identity index. ErrorAssetLockFundingMismatch = 25, + /// Maps `SignedPaymentError::StaleToken` / `SignedPaymentError::WalletMismatch` + /// from the deferred build → broadcast/release core-send lifecycle + /// (`core_wallet_signed_payment_*`). The reservation token is unknown, + /// already broadcast, already released, or was minted against a different + /// (re-created) wallet instance. The operation did NOT touch the network — + /// there is no double-broadcast — but the token can never succeed, so this + /// is NOT retryable: the host must rebuild the payment. Release is + /// idempotent and never surfaces this code. + ErrorStaleReservationToken = 26, NotFound = 98, // Used exclusively for all the Option that are retuned as errors ErrorUnknown = 99, diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index e91c5ccee0a..273ba9e82af 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -58,6 +58,9 @@ pub use wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; pub use wallet::asset_lock::AssetLockFunding; pub use wallet::core::WalletBalance; pub use wallet::core::{CoreWallet, SignedCoreTransaction}; +pub use wallet::signed_payment_registry::{ + ReservationToken, SignedPaymentError, SignedPaymentRegistry, +}; // DashPay types + crypto helpers re-exported through the identity // domain (they live under `identity::types::dashpay::*` and // `identity::crypto::*` internally). diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 0f3d7fd1f0d..55466a1dcf7 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -3,7 +3,9 @@ use key_wallet::account::account_type::StandardAccountType; use super::SignedCoreTransaction; use crate::broadcaster::TransactionBroadcaster; -use crate::wallet::reservations::broadcast_releasing_on_rejection; +use crate::wallet::reservations::{ + broadcast_releasing_on_rejection, release_reservation_after_rejected_broadcast, +}; use crate::{CoreWallet, PlatformWalletError}; impl CoreWallet { @@ -86,6 +88,36 @@ impl CoreWallet { .await .map_err(Into::into) } + + /// Release the funding account's UTXO reservation for `transaction` without + /// broadcasting — the "payment abandoned / merchant server nacked" arm of + /// the deferred build → broadcast/release lifecycle + /// ([`SignedPaymentRegistry`](crate::SignedPaymentRegistry)). + /// + /// `build_signed` reserves the selected inputs and leaves the reservation + /// held; when the caller decides never to broadcast, this returns those + /// inputs to spendable so a later build can reselect them. Idempotent at the + /// account layer (releasing an already-released reservation is a no-op), and + /// best-effort: a missing wallet/account is logged, not surfaced, since + /// there is nothing actionable to reconcile. + /// + /// `account_type`/`account_index` identify the funding account handed to + /// `set_funding` when the transaction was built. + pub async fn release_transaction_reservation( + &self, + account_type: StandardAccountType, + account_index: u32, + transaction: &Transaction, + ) { + release_reservation_after_rejected_broadcast( + &self.wallet_manager, + &self.wallet_id, + account_type, + account_index, + transaction, + ) + .await + } } #[cfg(test)] diff --git a/packages/rs-platform-wallet/src/wallet/mod.rs b/packages/rs-platform-wallet/src/wallet/mod.rs index 1963422be7c..43457733a33 100644 --- a/packages/rs-platform-wallet/src/wallet/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/mod.rs @@ -11,9 +11,13 @@ pub mod provider_key_at_index; pub(crate) mod reservations; #[cfg(feature = "shielded")] pub mod shielded; +pub mod signed_payment_registry; pub mod tokens; pub use self::core::CoreWallet; +pub use signed_payment_registry::{ + ReservationToken, SignedPaymentError, SignedPaymentRegistry, +}; pub use apply::ApplyError; pub use core_address_key::CoreAddressPrivateKey; pub use identity::IdentityWallet; diff --git a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs new file mode 100644 index 00000000000..ece79d13867 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -0,0 +1,688 @@ +//! In-memory registry backing the deferred build → broadcast/release core-send +//! lifecycle (BIP70 / BIP270 "sign now, submit on merchant ack"). +//! +//! The regular send path +//! ([`CoreWallet::broadcast_transaction_releasing_reservation`](crate::CoreWallet::broadcast_transaction_releasing_reservation)) +//! builds, signs, and broadcasts in one uninterrupted step. BIP70-style flows +//! must split that: sign now (reserving the funding UTXOs), hand the raw bytes +//! to a merchant server, and broadcast **only** once the server acks — or +//! release the reservation if it nacks / the user abandons. +//! +//! `TransactionBuilder::build_signed` already reserves the selected UTXOs in the +//! funding account's `ReservationSet` and leaves the reservation held on +//! success (see [`crate::wallet::reservations`]). This registry owns the built +//! transaction and its held reservation between build and submission, keyed by +//! an opaque [`ReservationToken`], and enforces the lifecycle invariants: +//! +//! * [`broadcast`](SignedPaymentRegistry::broadcast) removes the entry **before** +//! sending, so a repeated or concurrent broadcast of the same token can never +//! double-broadcast — the second caller finds nothing and gets +//! [`SignedPaymentError::StaleToken`]. +//! * [`release`](SignedPaymentRegistry::release) is idempotent: releasing an +//! unknown / already-consumed token is a silent no-op. +//! * A token is bound to the exact wallet instance it was minted against +//! (`Arc::ptr_eq` on the shared `WalletManager`). Broadcasting it through a +//! re-created wallet — whose in-memory `ReservationSet` no longer holds the +//! inputs — is a [`SignedPaymentError::WalletMismatch`] rather than a spend +//! against stale state. +//! +//! ## Process-death semantics +//! +//! The registry and the underlying `ReservationSet` are both in-memory. An app +//! crash between build and broadcast drops the registry entry **and** the +//! reservation together, so nothing leaks across a restart — the UTXOs are +//! spendable again on reload. This matches dashj's behaviour (its in-flight +//! reservations are likewise memory-only). No on-disk reservation persistence +//! exists to follow. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use dashcore::{Transaction, Txid}; +use key_wallet::account::account_type::StandardAccountType; + +use crate::broadcaster::TransactionBroadcaster; +use crate::wallet::core::CoreWallet; +use crate::PlatformWalletError; + +/// Opaque handle to a registered, signed-but-unsent payment. Minted by +/// [`SignedPaymentRegistry::register`]; consumed by +/// [`SignedPaymentRegistry::broadcast`] or +/// [`SignedPaymentRegistry::release`]. Values are unique for the process +/// lifetime and never reused, so a stale token can always be recognised. +pub type ReservationToken = u64; + +/// Failure of a deferred broadcast/release token operation. +#[derive(Debug, thiserror::Error)] +pub enum SignedPaymentError { + /// The token is unknown, already broadcast, or already released. The + /// registry never re-broadcasts, so this is the guard that turns a + /// double-broadcast into a typed error instead of a second send. + #[error("reservation token {0} is unknown, already broadcast, or already released")] + StaleToken(ReservationToken), + + /// The token was minted against a different (re-created) wallet instance + /// than the one it is being broadcast through. Its reservation lives in + /// that other instance's `ReservationSet`, so submitting it here would spend + /// against state this wallet never reserved. + #[error("reservation token {0} was minted against a different wallet instance")] + WalletMismatch(ReservationToken), + + /// The underlying broadcast failed. Carries the still-typed wallet error so + /// the FFI boundary can preserve the retry semantics (e.g. the ambiguous + /// [`PlatformWalletError::TransactionBroadcastUnconfirmed`] "may already be + /// on the network" signal). + #[error(transparent)] + Broadcast(#[from] PlatformWalletError), +} + +/// A built, signed transaction whose funding UTXOs are reserved, awaiting a +/// deferred broadcast or an explicit release. +struct RegisteredPayment { + /// The wallet instance the payment was built against — captured so the + /// broadcast/release act on the exact `ReservationSet` that holds the + /// inputs, and so a re-created wallet can be detected via `Arc::ptr_eq`. + core: CoreWallet, + /// The signed transaction to broadcast. + tx: Transaction, + /// The funding account whose reservation must be released on a rejected + /// broadcast or an explicit release. `None` for a CoinJoin funding, which + /// has no standard-account reservation to reconcile (it rides the + /// TTL backstop), mirroring `CoreAccountTypeFFI::as_standard_account_type`. + account_type: Option, + account_index: u32, +} + +/// Registry of signed-but-unsent payments keyed by [`ReservationToken`]. +/// +/// Generic over the broadcaster `B` so it can be unit-tested with mock +/// broadcasters; the FFI layer instantiates a single process-global registry +/// pinned to the production `SpvBroadcaster`. +pub struct SignedPaymentRegistry { + next_token: AtomicU64, + entries: Mutex>>, +} + +impl Default for SignedPaymentRegistry { + fn default() -> Self { + Self::new() + } +} + +impl SignedPaymentRegistry { + /// A fresh, empty registry. + pub fn new() -> Self { + Self { + // Start at 1 so 0 is never a valid token (matches the FFI's + // null-handle convention). + next_token: AtomicU64::new(1), + entries: Mutex::new(HashMap::new()), + } + } + + /// Take ownership of a built, signed `tx` (whose funding UTXOs `build_signed` + /// already reserved) and return an opaque token for a later + /// [`broadcast`](Self::broadcast) or [`release`](Self::release). + /// + /// `core` is the wallet the payment was built against; it is captured so the + /// later operation acts on the exact reservation state that holds the inputs. + pub fn register( + &self, + core: CoreWallet, + tx: Transaction, + account_type: Option, + account_index: u32, + ) -> ReservationToken { + let token = self.next_token.fetch_add(1, Ordering::SeqCst); + self.entries + .lock() + .expect("signed-payment registry mutex poisoned") + .insert( + token, + RegisteredPayment { + core, + tx, + account_type, + account_index, + }, + ); + token + } + + /// Broadcast the payment behind `token`, reconciling its UTXO reservation on + /// failure, then consume the token. + /// + /// The entry is removed **before** the send, so a repeated or concurrent + /// broadcast of the same token gets [`SignedPaymentError::StaleToken`] + /// instead of a second send. `current` must be the same wallet instance the + /// token was minted against (checked by `Arc::ptr_eq` on the shared + /// `WalletManager`); otherwise the call fails with + /// [`SignedPaymentError::WalletMismatch`] and the stale token is dropped. + /// + /// On a definitive rejection the reservation is released for an immediate + /// rebuild; on an ambiguous ("may already be on the network") failure it is + /// kept — the same policy as the non-deferred send path. + pub async fn broadcast( + &self, + token: ReservationToken, + current: &CoreWallet, + ) -> Result { + // Remove under the lock and drop the guard *before* awaiting — a + // std::Mutex guard must never be held across an await point, and the + // atomic take is what makes a double-broadcast impossible. + let entry = { + let mut entries = self + .entries + .lock() + .expect("signed-payment registry mutex poisoned"); + entries.remove(&token) + } + .ok_or(SignedPaymentError::StaleToken(token))?; + + if !Arc::ptr_eq(&entry.core.wallet_manager, ¤t.wallet_manager) { + // The token belongs to another wallet instance; it has been removed, + // so it can never be replayed here. + return Err(SignedPaymentError::WalletMismatch(token)); + } + + let txid = match entry.account_type { + Some(account_type) => { + entry + .core + .broadcast_transaction_releasing_reservation( + account_type, + entry.account_index, + &entry.tx, + ) + .await? + } + None => entry.core.broadcast_transaction(&entry.tx).await?, + }; + Ok(txid) + } + + /// Release the funding reservation behind `token` and drop it. Idempotent: + /// releasing an unknown / already-consumed token is a silent no-op, so a + /// double release (or a release after a broadcast) is harmless. + /// + /// The release acts on the wallet instance the token was minted against — + /// the one whose `ReservationSet` actually holds the inputs — so no wallet + /// handle need be threaded in. + pub async fn release(&self, token: ReservationToken) { + let entry = { + let mut entries = self + .entries + .lock() + .expect("signed-payment registry mutex poisoned"); + entries.remove(&token) + }; + let Some(entry) = entry else { + // Unknown / already consumed — idempotent no-op. + return; + }; + if let Some(account_type) = entry.account_type { + entry + .core + .release_transaction_reservation(account_type, entry.account_index, &entry.tx) + .await; + } + } + + /// Number of outstanding (registered but not yet broadcast/released) tokens. + #[cfg(test)] + pub(crate) fn outstanding(&self) -> usize { + self.entries + .lock() + .expect("signed-payment registry mutex poisoned") + .len() + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + use async_trait::async_trait; + use dashcore::{Address as DashAddress, Network, Transaction, Txid}; + use key_wallet::account::account_type::StandardAccountType; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + 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::wallet_info_interface::WalletInfoInterface; + + use super::{SignedPaymentError, SignedPaymentRegistry}; + use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; + use crate::test_support::{funded_wallet_manager, AlwaysMaybeSentBroadcaster, WalletSigner}; + use crate::wallet::core::CoreWallet; + use crate::PlatformWalletError; + + /// Broadcaster that records the exact bytes handed to it and succeeds, + /// so a test can assert the broadcast tx is byte-identical to the one the + /// caller registered. + struct RecordingBroadcaster { + sent: Mutex>>, + } + + impl RecordingBroadcaster { + fn new() -> Self { + Self { + sent: Mutex::new(Vec::new()), + } + } + + fn last_sent(&self) -> Option> { + self.sent.lock().unwrap().last().cloned() + } + } + + #[async_trait] + impl TransactionBroadcaster for RecordingBroadcaster { + async fn broadcast(&self, transaction: &Transaction) -> Result { + self.sent + .lock() + .unwrap() + .push(dashcore::consensus::serialize(transaction)); + Ok(transaction.txid()) + } + } + + /// Broadcaster that counts how many times it was asked to send. + struct CountingBroadcaster { + count: AtomicUsize, + } + + impl CountingBroadcaster { + fn new() -> Self { + Self { + count: AtomicUsize::new(0), + } + } + } + + #[async_trait] + impl TransactionBroadcaster for CountingBroadcaster { + async fn broadcast(&self, transaction: &Transaction) -> Result { + self.count.fetch_add(1, Ordering::SeqCst); + Ok(transaction.txid()) + } + } + + /// A testnet `CoreWallet` over the shared funded fixture plus a + /// 1_000_000-duff payment to a dummy recipient. + async fn funded_core_wallet( + account_type: StandardAccountType, + broadcaster: Arc, + ) -> (CoreWallet, WalletSigner, Vec<(DashAddress, u64)>) { + let (wallet_manager, wallet_id, balance, signer) = + funded_wallet_manager(account_type).await; + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let core = CoreWallet::new(sdk, wallet_manager, wallet_id, broadcaster, balance); + let recipient = DashAddress::dummy(Network::Testnet, 42); + (core, signer, vec![(recipient, 1_000_000u64)]) + } + + /// Build + sign a payment exactly as the deferred send path does: + /// `build_signed` reserves the inputs and leaves the reservation held for + /// the later broadcast/release. + async fn build_signed_tx( + core: &CoreWallet, + account_type: StandardAccountType, + account_index: u32, + outputs: &[(DashAddress, u64)], + signer: &S, + ) -> Result { + let mut wm = core.wallet_manager.write().await; + let (wallet, info) = wm + .get_wallet_and_info_mut(&core.wallet_id()) + .expect("wallet present in manager"); + let current_height = info.core_wallet.synced_height(); + let (managed_account, account) = match account_type { + StandardAccountType::BIP44Account => ( + info.core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&account_index) + .expect("bip44 managed account"), + wallet + .accounts + .standard_bip44_accounts + .get(&account_index) + .expect("bip44 account"), + ), + StandardAccountType::BIP32Account => ( + info.core_wallet + .accounts + .standard_bip32_accounts + .get_mut(&account_index) + .expect("bip32 managed account"), + wallet + .accounts + .standard_bip32_accounts + .get(&account_index) + .expect("bip32 account"), + ), + }; + let mut builder = TransactionBuilder::new() + .set_current_height(current_height) + .set_selection_strategy(SelectionStrategy::LargestFirst) + .set_funding(managed_account, account); + for (addr, amount) in outputs { + builder = builder.add_output(addr, *amount); + } + let (tx, _fee) = builder + .build_signed(signer, |addr| managed_account.address_derivation_path(&addr)) + .await + .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; + Ok(tx) + } + + /// Happy path: a registered token broadcasts the exact bytes it was built + /// with, and the token is consumed afterwards. + #[tokio::test] + async fn build_then_broadcast_sends_registered_bytes() { + let broadcaster = Arc::new(RecordingBroadcaster::new()); + let (core, signer, outputs) = + funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; + let registry = SignedPaymentRegistry::new(); + + let tx = build_signed_tx(&core, StandardAccountType::BIP44Account, 0, &outputs, &signer) + .await + .expect("build should succeed"); + let expected_bytes = dashcore::consensus::serialize(&tx); + let expected_txid = tx.txid(); + + let token = registry.register( + core.clone(), + tx, + Some(StandardAccountType::BIP44Account), + 0, + ); + assert_eq!(registry.outstanding(), 1); + + // Broadcast through a *clone* of the same wallet instance — the + // wallet-identity guard must accept it (same `Arc`). + let txid = registry + .broadcast(token, &core.clone()) + .await + .expect("broadcast should succeed"); + + assert_eq!(txid, expected_txid, "returned txid must match the built tx"); + assert_eq!( + broadcaster.last_sent().expect("a tx was sent"), + expected_bytes, + "broadcast bytes must be byte-identical to the registered tx" + ); + assert_eq!(registry.outstanding(), 0, "token consumed after broadcast"); + } + + /// build → release makes the reserved UTXO spendable again: a subsequent + /// build can reselect the released input. + #[tokio::test] + async fn build_then_release_frees_the_reservation() { + for account_type in [ + StandardAccountType::BIP44Account, + StandardAccountType::BIP32Account, + ] { + let broadcaster = Arc::new(RecordingBroadcaster::new()); + let (core, signer, outputs) = funded_core_wallet(account_type, broadcaster).await; + let registry = SignedPaymentRegistry::new(); + + let tx = build_signed_tx(&core, account_type, 0, &outputs, &signer) + .await + .expect("build should succeed"); + let token = registry.register(core.clone(), tx, Some(account_type), 0); + + // With the reservation held, an immediate rebuild finds no + // spendable UTXO and fails. + let blocked = build_signed_tx(&core, account_type, 0, &outputs, &signer).await; + assert!( + matches!(blocked, Err(PlatformWalletError::TransactionBuild(_))), + "rebuild must fail while the reservation is held for {account_type:?}, got {blocked:?}" + ); + + registry.release(token).await; + assert_eq!(registry.outstanding(), 0, "token consumed after release"); + + // The released input is spendable again — the rebuild succeeds. + let rebuilt = build_signed_tx(&core, account_type, 0, &outputs, &signer).await; + assert!( + rebuilt.is_ok(), + "rebuild after release should succeed for {account_type:?}, got {rebuilt:?}" + ); + } + } + + /// A second broadcast of the same token is a typed `StaleToken` error, never + /// a second send. + #[tokio::test] + async fn double_broadcast_is_a_stale_token_error() { + let broadcaster = Arc::new(CountingBroadcaster::new()); + let (core, signer, outputs) = + funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; + let registry = SignedPaymentRegistry::new(); + + let tx = build_signed_tx(&core, StandardAccountType::BIP44Account, 0, &outputs, &signer) + .await + .expect("build should succeed"); + let token = registry.register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0); + + registry + .broadcast(token, &core) + .await + .expect("first broadcast should succeed"); + let second = registry.broadcast(token, &core).await; + assert!( + matches!(second, Err(SignedPaymentError::StaleToken(t)) if t == token), + "second broadcast must be StaleToken, got {second:?}" + ); + assert_eq!( + broadcaster.count.load(Ordering::SeqCst), + 1, + "the network must have been hit exactly once" + ); + } + + /// Releasing twice — or releasing after a broadcast — is a harmless no-op. + #[tokio::test] + async fn double_release_is_idempotent() { + let broadcaster = Arc::new(RecordingBroadcaster::new()); + let (core, signer, outputs) = + funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; + let registry = SignedPaymentRegistry::new(); + + let tx = build_signed_tx(&core, StandardAccountType::BIP44Account, 0, &outputs, &signer) + .await + .expect("build should succeed"); + let token = registry.register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0); + + registry.release(token).await; + // Second release: no panic, no error, still consumed. + registry.release(token).await; + assert_eq!(registry.outstanding(), 0); + } + + /// Broadcasting after a release is a `StaleToken` error (the released token + /// can never reach the network). + #[tokio::test] + async fn broadcast_after_release_is_stale() { + let broadcaster = Arc::new(CountingBroadcaster::new()); + let (core, signer, outputs) = + funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; + let registry = SignedPaymentRegistry::new(); + + let tx = build_signed_tx(&core, StandardAccountType::BIP44Account, 0, &outputs, &signer) + .await + .expect("build should succeed"); + let token = registry.register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0); + + registry.release(token).await; + let sent = registry.broadcast(token, &core).await; + assert!( + matches!(sent, Err(SignedPaymentError::StaleToken(_))), + "broadcast of a released token must be StaleToken, got {sent:?}" + ); + assert_eq!(broadcaster.count.load(Ordering::SeqCst), 0, "nothing was sent"); + } + + /// An unknown token is a `StaleToken` error. + #[tokio::test] + async fn unknown_token_is_stale() { + let broadcaster = Arc::new(CountingBroadcaster::new()); + let (core, _signer, _outputs) = + funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; + let registry: SignedPaymentRegistry = SignedPaymentRegistry::new(); + + let sent = registry.broadcast(9999, &core).await; + assert!(matches!(sent, Err(SignedPaymentError::StaleToken(9999)))); + // Releasing an unknown token is a no-op, not a panic. + registry.release(9999).await; + } + + /// A token minted against one wallet instance cannot be broadcast through a + /// different (re-created) instance — its reservation lives elsewhere. + #[tokio::test] + async fn broadcast_rejects_a_different_wallet_instance() { + let broadcaster_a = Arc::new(CountingBroadcaster::new()); + let (core_a, signer_a, outputs_a) = + funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster_a)).await; + // A separate wallet-manager instance stands in for a re-created wallet. + let broadcaster_b = Arc::new(CountingBroadcaster::new()); + let (core_b, _signer_b, _outputs_b) = + funded_core_wallet(StandardAccountType::BIP44Account, broadcaster_b).await; + let registry = SignedPaymentRegistry::new(); + + let tx = + build_signed_tx(&core_a, StandardAccountType::BIP44Account, 0, &outputs_a, &signer_a) + .await + .expect("build should succeed"); + let token = registry.register( + core_a.clone(), + tx, + Some(StandardAccountType::BIP44Account), + 0, + ); + + let sent = registry.broadcast(token, &core_b).await; + assert!( + matches!(sent, Err(SignedPaymentError::WalletMismatch(t)) if t == token), + "broadcast through a different wallet instance must be WalletMismatch, got {sent:?}" + ); + assert_eq!( + broadcaster_a.count.load(Ordering::SeqCst), + 0, + "nothing was sent on the original wallet" + ); + assert_eq!(registry.outstanding(), 0, "the stale token is dropped"); + } + + /// An ambiguous ("may already be on the network") broadcast failure keeps + /// the reservation and surfaces the typed unconfirmed error; the token is + /// still consumed so it cannot be retried into a double-spend. + #[tokio::test] + async fn ambiguous_broadcast_keeps_reservation_and_consumes_token() { + let broadcaster = Arc::new(AlwaysMaybeSentBroadcaster); + let (core, signer, outputs) = + funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; + let registry = SignedPaymentRegistry::new(); + + let tx = build_signed_tx(&core, StandardAccountType::BIP44Account, 0, &outputs, &signer) + .await + .expect("build should succeed"); + let token = registry.register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0); + + let sent = registry.broadcast(token, &core).await; + assert!( + matches!( + sent, + Err(SignedPaymentError::Broadcast( + PlatformWalletError::TransactionBroadcastUnconfirmed(_) + )) + ), + "ambiguous failure must surface the typed unconfirmed error, got {sent:?}" + ); + assert_eq!(registry.outstanding(), 0, "token consumed even on failure"); + + // Reservation kept: an immediate rebuild fails at input selection. + let rebuilt = build_signed_tx(&core, StandardAccountType::BIP44Account, 0, &outputs, &signer) + .await; + assert!( + matches!(rebuilt, Err(PlatformWalletError::TransactionBuild(_))), + "rebuild must fail with the reservation kept, got {rebuilt:?}" + ); + } + + /// Concurrent broadcasts of the same token serialise on the registry mutex: + /// exactly one wins, every other gets `StaleToken`, and the network is hit + /// once. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_broadcasts_serialize_to_one_send() { + let broadcaster = Arc::new(CountingBroadcaster::new()); + let (core, signer, outputs) = + funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; + let registry = Arc::new(SignedPaymentRegistry::new()); + + let tx = build_signed_tx(&core, StandardAccountType::BIP44Account, 0, &outputs, &signer) + .await + .expect("build should succeed"); + let token = registry.register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0); + + let mut handles = Vec::new(); + for _ in 0..8 { + let registry = Arc::clone(®istry); + let core = core.clone(); + handles.push(tokio::spawn(async move { + registry.broadcast(token, &core).await + })); + } + let mut successes = 0; + let mut stale = 0; + for handle in handles { + match handle.await.expect("task panicked") { + Ok(_) => successes += 1, + Err(SignedPaymentError::StaleToken(_)) => stale += 1, + Err(other) => panic!("unexpected error: {other:?}"), + } + } + assert_eq!(successes, 1, "exactly one broadcast must win"); + assert_eq!(stale, 7, "every other broadcast must be StaleToken"); + assert_eq!( + broadcaster.count.load(Ordering::SeqCst), + 1, + "the network must have been hit exactly once" + ); + } + + /// Concurrent registrations hand out distinct tokens. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_registers_yield_distinct_tokens() { + let broadcaster = Arc::new(CountingBroadcaster::new()); + let (core, signer, outputs) = + funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; + // One built tx is enough; we register clones of it many times to probe + // the token allocator, not the reservation logic. + let tx = build_signed_tx(&core, StandardAccountType::BIP44Account, 0, &outputs, &signer) + .await + .expect("build should succeed"); + let registry = Arc::new(SignedPaymentRegistry::new()); + + let mut handles = Vec::new(); + for _ in 0..16 { + let registry = Arc::clone(®istry); + let core = core.clone(); + let tx = tx.clone(); + handles.push(tokio::spawn(async move { + registry.register(core, tx, Some(StandardAccountType::BIP44Account), 0) + })); + } + let mut tokens = Vec::new(); + for handle in handles { + tokens.push(handle.await.expect("task panicked")); + } + let unique: std::collections::HashSet<_> = tokens.iter().copied().collect(); + assert_eq!(unique.len(), tokens.len(), "all tokens must be distinct"); + assert_eq!(registry.outstanding(), 16); + } +} diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 8e0f3e676b5..d15d6a23b41 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1232,6 +1232,181 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c }) } +// ── Deferred build → broadcast/release core-send (BIP70/BIP270) ─────── +// +// ADDITIVE surface over the immediate `coreWalletBroadcastTransaction` path: +// a signed transaction built by [coreTxBuilderBuildSigned] can be registered +// (reserving its UTXOs), its raw bytes handed to a merchant server, and only +// then broadcast on ack — or its reservation released on nack/abandonment. +// Backed by the process-global registry in `platform_wallet_ffi` +// (`core_wallet_signed_payment_*`). See `SignedPaymentRegistry`. + +/// `core_wallet_transaction_get_bytes` — the consensus-serialized bytes of a +/// built transaction from [coreTxBuilderBuildSigned], copied into a fresh +/// Java `byte[]`. The underlying FFI hands back a borrowed pointer valid only +/// while `tx` lives, so we copy it here before returning. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreTransactionGetBytes( + mut env: JNIEnv, + _class: JClass, + tx: jlong, +) -> jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + if tx == 0 { + throw_sdk_exception(env, 1, "transaction handle is 0"); + return ptr::null_mut(); + } + let mut out_ptr: *const u8 = ptr::null(); + let mut out_len: usize = 0; + let result = unsafe { + platform_wallet_ffi::core_wallet_transaction_get_bytes( + tx as *const platform_wallet_ffi::FFICoreTransaction, + &mut out_ptr as *mut *const u8, + &mut out_len as *mut usize, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + // Copy immediately: the pointer borrows the transaction's own buffer. + let bytes: &[u8] = if out_ptr.is_null() || out_len == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(out_ptr, out_len) } + }; + env.byte_array_from_slice(bytes) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + +/// `core_wallet_signed_payment_register` — register a built+signed transaction +/// (from [coreTxBuilderBuildSigned]) for deferred submission, holding its UTXO +/// reservation. `accountType`/`accountIndex` are the funding account (0 BIP44, +/// 1 BIP32, 2 CoinJoin). The passed `tx` is NOT consumed — free it separately +/// with [coreTransactionFree]. +/// +/// Returns a big-endian BLOB the Kotlin side decodes into a +/// `SignedCoreTransaction`: `u64 token, u64 feeDuffs, u32 txidLen, txid utf8`. +/// The raw tx bytes are fetched separately via [coreTransactionGetBytes]. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreWalletRegisterSignedPayment( + mut env: JNIEnv, + _class: JClass, + core_handle: jlong, + tx: jlong, + account_type: jni::sys::jint, + account_index: jni::sys::jint, +) -> jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + if tx == 0 { + throw_sdk_exception(env, 1, "transaction handle is 0"); + return ptr::null_mut(); + } + let Some(account_type) = core_account_type(account_type) else { + throw_sdk_exception(env, 1, "accountType out of range (expected 0..=2)"); + return ptr::null_mut(); + }; + if account_index < 0 { + throw_sdk_exception(env, 1, "accountIndex must be non-negative"); + return ptr::null_mut(); + } + + let mut token: u64 = 0; + let mut fee: u64 = 0; + let mut out_txid: *mut c_char = ptr::null_mut(); + let result = unsafe { + platform_wallet_ffi::core_wallet_signed_payment_register( + core_handle as Handle, + tx as *const platform_wallet_ffi::FFICoreTransaction, + account_type, + account_index as u32, + &mut token as *mut u64, + &mut fee as *mut u64, + &mut out_txid as *mut *mut c_char, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + if out_txid.is_null() { + throw_sdk_exception(env, 1, "register returned a NULL txid"); + return ptr::null_mut(); + } + // Copy the txid out, then free the Rust-owned C string. + let txid = unsafe { CStr::from_ptr(out_txid) } + .to_string_lossy() + .into_owned(); + unsafe { platform_wallet_ffi::core_wallet_free_address(out_txid) }; + + // Assemble the big-endian BLOB (matches the Kotlin ByteBuffer decoder). + let txid_bytes = txid.into_bytes(); + let mut blob = Vec::with_capacity(8 + 8 + 4 + txid_bytes.len()); + blob.extend_from_slice(&token.to_be_bytes()); + blob.extend_from_slice(&fee.to_be_bytes()); + blob.extend_from_slice(&(txid_bytes.len() as u32).to_be_bytes()); + blob.extend_from_slice(&txid_bytes); + env.byte_array_from_slice(&blob) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + +/// `core_wallet_signed_payment_broadcast` — broadcast the payment behind +/// `token`, releasing/keeping its reservation per the broadcast outcome and +/// consuming the token. A repeated/stale token throws (native +/// `ErrorStaleReservationToken`, code 22) rather than double-broadcasting. +/// `coreHandle` must resolve to the wallet the token was minted against. +/// Returns the txid as a lowercase hex string. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreWalletBroadcastSignedPayment( + mut env: JNIEnv, + _class: JClass, + core_handle: jlong, + token: jlong, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let mut out_txid: *mut c_char = ptr::null_mut(); + let result = unsafe { + platform_wallet_ffi::core_wallet_signed_payment_broadcast( + core_handle as Handle, + token as u64, + &mut out_txid as *mut *mut c_char, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + if out_txid.is_null() { + throw_sdk_exception(env, 1, "broadcast returned a NULL txid"); + return ptr::null_mut(); + } + let txid = unsafe { CStr::from_ptr(out_txid) } + .to_string_lossy() + .into_owned(); + unsafe { platform_wallet_ffi::core_wallet_free_address(out_txid) }; + env.new_string(txid) + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + +/// `core_wallet_signed_payment_release` — release the funding reservation +/// behind `token` and drop it. Idempotent: releasing an unknown / already- +/// consumed token is a silent no-op (never throws the stale-token error). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreWalletReleaseSignedPayment( + mut env: JNIEnv, + _class: JClass, + token: jlong, +) { + guard(&mut env, (), |env| { + let result = + unsafe { platform_wallet_ffi::core_wallet_signed_payment_release(token as u64) }; + let _ = take_pwffi_error(env, result); + }) +} + /// Enumerate this wallet's Platform-payment addresses with their cached /// credit balances, returning a flat `byte[]` BLOB for the top-up /// funding-input builder (`TopUpIdentityScreen`). From 7f658eb1c9dd3b258fa5bf68f8f76842e657e993 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:48:47 -0400 Subject: [PATCH 02/24] fix(kotlin-sdk): bound deferred-payment token lifetime; harden register/release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review of the SignedPaymentRegistry deferred build→broadcast/release flow. BLOCKING: registry tokens never expired even though the key-wallet UTXO reservation they depend on is swept after RESERVATION_TTL_BLOCKS (24) and released by raw outpoint with no ownership check, so a long-outstanding token's broadcast/release could free or spend against an unrelated newer reservation. Bound the token lifetime: capture the wallet's synced height at register and refuse broadcast/release once the wallet has synced RESERVATION_MAX_AGE_BLOCKS (20, < TTL) past it, returning the typed StaleReservationToken WITHOUT releasing (which could free a newer build's reservation). The pinned key-wallet exposes no per-outpoint generation check, so this client-side bound is the primary guard. Also: - WalletMismatch now compares wallet_id in addition to Arc::ptr_eq on the shared WalletManager, so two wallets in one multi-wallet manager are told apart. - register() returns the raw tx bytes in the same native call and the JNI folds them into the register BLOB; the now-unused core_wallet_transaction_get_bytes / coreTransactionGetBytes is removed (one native round trip per kotlin-sdk rule). - register() does its fallible/pure marshalling before the reservation-holding insert, and the JNI releases the token if it can't hand the BLOB back to Kotlin — no orphaned reservation on a marshalling failure. - PlatformWallet teardown sweeps the registry of that wallet's tokens so a destroyed wallet's WalletManager is no longer pinned alive by a captured CoreWallet clone (hooked at platform_wallet_destroy, not the transient core-handle destroy the deferred flow cycles through). - Registry mutex recovers from poisoning instead of panicking, matching key-wallet's ReservationSet. Adds tests for token expiry (broadcast + release), same-manager different wallet_id mismatch, and the teardown sweep. Co-Authored-By: Claude Fable 5 --- .../dashsdk/ffi/WalletManagerNative.kt | 12 +- .../dashsdk/wallet/ManagedCoreWallet.kt | 8 +- .../src/core_wallet/mod.rs | 2 +- .../src/core_wallet/signed_payment.rs | 90 ++- packages/rs-platform-wallet-ffi/src/wallet.rs | 11 + .../src/wallet/core/wallet.rs | 16 + .../src/wallet/signed_payment_registry.rs | 581 +++++++++++++++--- .../rs-unified-sdk-jni/src/wallet_manager.rs | 73 +-- 8 files changed, 596 insertions(+), 197 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt index daee4c76091..54f2cd11688 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt @@ -246,14 +246,6 @@ internal object WalletManagerNative { */ external fun coreTransactionFree(tx: Long) - /** - * `core_wallet_transaction_get_bytes` — the consensus-serialized bytes of a - * transaction from [coreTxBuilderBuildSigned], copied into a fresh - * `ByteArray`. The transaction handle must still be live (not yet freed by - * [coreTransactionFree]). - */ - external fun coreTransactionGetBytes(tx: Long): ByteArray - /** * `core_wallet_signed_payment_register` — register a built+signed * transaction (from [coreTxBuilderBuildSigned]) for deferred @@ -263,8 +255,8 @@ internal object WalletManagerNative { * 1 BIP32, 2 CoinJoin). * * Returns a big-endian BLOB decoded into a `SignedCoreTransaction`: - * `u64 token, u64 feeDuffs, u32 txidLen, txid utf8`. The raw tx bytes come - * from [coreTransactionGetBytes]. + * `u64 token, u64 feeDuffs, u32 txidLen, txid utf8, u32 txBytesLen, txBytes`. + * The raw tx bytes come back in this same call — no second native round trip. */ external fun coreWalletRegisterSignedPayment( coreHandle: Long, 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 0b9c2bf588a..6961c3a093f 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 @@ -59,13 +59,12 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { * Register a built+signed [tx] for deferred (BIP70/BIP270) submission, * holding its UTXO reservation, and return the resulting * [ManagedPlatformWallet.SignedCoreTransaction]. Does NOT consume [tx] — the - * caller still closes it. Reads the raw bytes off [tx] and decodes the - * register BLOB (`token, feeDuffs, txid`). + * caller still closes it. Decodes the single register BLOB + * (`token, feeDuffs, txid, rawTxBytes`) — one native round trip. */ internal fun registerSignedPayment( tx: CoreTransaction, ): ManagedPlatformWallet.SignedCoreTransaction { - val rawTxBytes = WalletManagerNative.coreTransactionGetBytes(tx.handle) val blob = WalletManagerNative.coreWalletRegisterSignedPayment( handle, tx.handle, @@ -78,6 +77,9 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { val txidLen = buffer.int val txidBytes = ByteArray(txidLen) buffer.get(txidBytes) + val txBytesLen = buffer.int + val rawTxBytes = ByteArray(txBytesLen) + buffer.get(rawTxBytes) return ManagedPlatformWallet.SignedCoreTransaction( txidHex = String(txidBytes, Charsets.UTF_8), rawTxBytes = rawTxBytes, diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs index 5a3dc9d3554..01c0cf4167a 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs @@ -4,7 +4,7 @@ mod addresses; mod broadcast; -mod signed_payment; +pub(crate) mod signed_payment; mod transaction_builder; mod wallet; diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs index e2ffee6c3c7..9fce7b11c5f 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs @@ -30,37 +30,9 @@ use std::os::raw::c_char; /// [`ReservationToken`]. In-memory only: an app crash between build and /// broadcast drops the registry entry and the underlying UTXO reservation /// together, so nothing leaks across a restart. -static SIGNED_PAYMENT_REGISTRY: Lazy> = +pub(crate) static SIGNED_PAYMENT_REGISTRY: Lazy> = Lazy::new(SignedPaymentRegistry::new); -/// Borrow the consensus-serialized bytes of a transaction built by -/// `core_wallet_tx_builder_build_signed`, for the caller to copy into -/// `SignedCoreTransaction.rawTxBytes`. -/// -/// The written pointer borrows the `FFICoreTransaction`'s own buffer — it is -/// valid only until the transaction is freed with -/// `core_wallet_transaction_free`, so the caller must copy the bytes out -/// immediately and must not retain the pointer. -/// -/// # Safety -/// `tx` must be a valid, non-freed `FFICoreTransaction` pointer; -/// `out_ptr`/`out_len` must be writable. -#[no_mangle] -pub unsafe extern "C" fn core_wallet_transaction_get_bytes( - tx: *const FFICoreTransaction, - out_ptr: *mut *const u8, - out_len: *mut usize, -) -> PlatformWalletFFIResult { - check_ptr!(tx); - check_ptr!(out_ptr); - check_ptr!(out_len); - - let bytes = (*tx).bytes(); - *out_ptr = bytes.as_ptr(); - *out_len = bytes.len(); - PlatformWalletFFIResult::ok() -} - /// Register a built, signed transaction for deferred submission and return a /// reservation token. /// @@ -73,13 +45,20 @@ pub unsafe extern "C" fn core_wallet_transaction_get_bytes( /// /// `account_type`/`account_index` identify the funding account handed to /// `set_funding`, so the reservation can be released on rejection/abandonment. -/// Writes `out_token`, `out_fee` (the build's fee in duffs), and `out_txid` (a +/// Writes `out_token`, `out_fee` (the build's fee in duffs), `out_txid` (a /// heap-allocated lowercase-hex C string the caller frees with -/// `core_wallet_free_address`). +/// `core_wallet_free_address`), and `out_bytes_ptr`/`out_bytes_len` (the +/// consensus-serialized transaction bytes, returned in the same call so the +/// caller needs no second native round trip). +/// +/// The `out_bytes_ptr` buffer borrows the `FFICoreTransaction`'s own storage — +/// it is valid only until `tx` is freed with `core_wallet_transaction_free`, so +/// the caller must copy the bytes out immediately and must not retain the +/// pointer. /// /// # Safety /// `tx` must be a valid, non-freed `FFICoreTransaction`; `core_handle` a valid -/// core-wallet handle; the three out-pointers must be writable. +/// core-wallet handle; all out-pointers must be writable. #[no_mangle] pub unsafe extern "C" fn core_wallet_signed_payment_register( core_handle: Handle, @@ -89,15 +68,20 @@ pub unsafe extern "C" fn core_wallet_signed_payment_register( out_token: *mut u64, out_fee: *mut u64, out_txid: *mut *mut c_char, + out_bytes_ptr: *mut *const u8, + out_bytes_len: *mut usize, ) -> PlatformWalletFFIResult { check_ptr!(tx); check_ptr!(out_token); check_ptr!(out_fee); check_ptr!(out_txid); + check_ptr!(out_bytes_ptr); + check_ptr!(out_bytes_len); let core = unwrap_option_or_return!(CORE_WALLET_STORAGE.with_item(core_handle, |w| w.clone())); - let transaction: dashcore::Transaction = match dashcore::consensus::deserialize((*tx).bytes()) { + let bytes = (*tx).bytes(); + let transaction: dashcore::Transaction = match dashcore::consensus::deserialize(bytes) { Ok(t) => t, Err(e) => { return PlatformWalletFFIResult::err( @@ -109,14 +93,10 @@ pub unsafe extern "C" fn core_wallet_signed_payment_register( let txid = transaction.txid(); let fee = (*tx).fee(); - let token = SIGNED_PAYMENT_REGISTRY.register( - core, - transaction, - account_type.as_standard_account_type(), - account_index, - ); - - // txid hex never contains a NUL, but handle the impossible case anyway. + // Do all fallible/pure marshalling BEFORE the registry insert — that insert + // mints a token and holds the funding reservation, so a later failure would + // orphan the reservation with no token to release it. txid hex never + // contains a NUL, but handle the impossible case anyway. let c_txid = match CString::new(txid.to_string()) { Ok(s) => s, Err(_) => { @@ -127,9 +107,20 @@ pub unsafe extern "C" fn core_wallet_signed_payment_register( } }; + let token = runtime().block_on(SIGNED_PAYMENT_REGISTRY.register( + core, + transaction, + account_type.as_standard_account_type(), + account_index, + )); + *out_token = token; *out_fee = fee; *out_txid = c_txid.into_raw(); + // Borrowed view into the still-live `tx` buffer; the caller copies it out + // before freeing `tx` (mirrors the retired `core_wallet_transaction_get_bytes`). + *out_bytes_ptr = bytes.as_ptr(); + *out_bytes_len = bytes.len(); PlatformWalletFFIResult::ok() } @@ -156,7 +147,8 @@ pub unsafe extern "C" fn core_wallet_signed_payment_broadcast( let core = unwrap_option_or_return!(CORE_WALLET_STORAGE.with_item(core_handle, |w| w.clone())); - let result = runtime().block_on(SIGNED_PAYMENT_REGISTRY.broadcast(token as ReservationToken, &core)); + let result = + runtime().block_on(SIGNED_PAYMENT_REGISTRY.broadcast(token as ReservationToken, &core)); match result { Ok(txid) => { @@ -172,12 +164,14 @@ pub unsafe extern "C" fn core_wallet_signed_payment_broadcast( *out_txid = c_txid.into_raw(); PlatformWalletFFIResult::ok() } - Err(e @ (SignedPaymentError::StaleToken(_) | SignedPaymentError::WalletMismatch(_))) => { - PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorStaleReservationToken, - e.to_string(), - ) - } + Err( + e @ (SignedPaymentError::StaleToken(_) + | SignedPaymentError::WalletMismatch(_) + | SignedPaymentError::StaleReservationToken(_)), + ) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorStaleReservationToken, + e.to_string(), + ), // Preserve the typed underlying wallet error (keeps the ambiguous // "may already be on the network" retry semantics intact). Err(SignedPaymentError::Broadcast(e)) => PlatformWalletFFIResult::from(e), diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index 8ffd78a896f..7b10c6242e4 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -390,6 +390,17 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_withdraw( /// Destroy a PlatformWallet handle. #[no_mangle] pub unsafe extern "C" fn platform_wallet_destroy(handle: Handle) -> PlatformWalletFFIResult { + // Sweep any outstanding deferred-payment tokens bound to this wallet first, + // so the registry stops pinning its `WalletManager` (accounts, keys, sync + // state) alive for the rest of the process via the `CoreWallet` clone each + // token captured. Hooked here rather than into `core_wallet_destroy`: the + // deferred flow builds/registers on one short-lived core handle and + // broadcasts on another, so sweeping on core-handle destroy would drop + // tokens between register and broadcast. + PLATFORM_WALLET_STORAGE.with_item(handle, |wallet| { + crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY + .remove_entries_for_wallet(wallet.core()); + }); PLATFORM_WALLET_STORAGE.remove(handle); PlatformWalletFFIResult::ok() } diff --git a/packages/rs-platform-wallet/src/wallet/core/wallet.rs b/packages/rs-platform-wallet/src/wallet/core/wallet.rs index 8cc0488ade9..8ad384d873d 100644 --- a/packages/rs-platform-wallet/src/wallet/core/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/core/wallet.rs @@ -10,6 +10,7 @@ use tokio::sync::RwLock; use key_wallet::managed_account::address_pool::KeySource; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet_manager::WalletManager; use crate::broadcaster::TransactionBroadcaster; @@ -286,6 +287,21 @@ impl CoreWallet { pub fn network(&self) -> key_wallet::Network { self.sdk.network } + + /// Current synced block height for this wallet, or `None` if the wallet is no + /// longer present in the manager. + /// + /// Used by the deferred-payment + /// [`SignedPaymentRegistry`](crate::SignedPaymentRegistry) to bound a token's + /// lifetime against key-wallet's UTXO reservation TTL: a `build_signed` + /// reservation is stamped at this height, so the elapsed span since + /// registration tells the registry whether the reservation could have been + /// swept and re-selected out from under the token. + pub(crate) async fn synced_height(&self) -> Option { + let wm = self.wallet_manager.read().await; + wm.get_wallet_and_info(&self.wallet_id) + .map(|(_, info)| info.core_wallet.synced_height()) + } } impl std::fmt::Debug for CoreWallet { 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 ece79d13867..3c89be7e968 100644 --- a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -21,10 +21,24 @@ //! * [`release`](SignedPaymentRegistry::release) is idempotent: releasing an //! unknown / already-consumed token is a silent no-op. //! * A token is bound to the exact wallet instance it was minted against -//! (`Arc::ptr_eq` on the shared `WalletManager`). Broadcasting it through a -//! re-created wallet — whose in-memory `ReservationSet` no longer holds the -//! inputs — is a [`SignedPaymentError::WalletMismatch`] rather than a spend -//! against stale state. +//! (`Arc::ptr_eq` on the shared `WalletManager` **and** an equal `wallet_id`, +//! so two wallets sharing one multi-wallet `PlatformWalletManager` are still +//! told apart). Broadcasting it through a re-created wallet — whose in-memory +//! `ReservationSet` no longer holds the inputs — is a +//! [`SignedPaymentError::WalletMismatch`] rather than a spend against stale +//! state. +//! * A token has a bounded lifetime ([`RESERVATION_MAX_AGE_BLOCKS`]). Once the +//! wallet has synced far enough past the height at which `build_signed` +//! stamped the reservation that key-wallet's own `ReservationSet` TTL could +//! have swept and re-selected the funding UTXO for an unrelated build, +//! broadcasting or releasing the token would act on state that may no longer +//! be its own — so both are refused with +//! [`SignedPaymentError::StaleReservationToken`] and the caller must rebuild. +//! This guard is the primary defence: key-wallet exposes no per-outpoint +//! ownership/generation check to make [`release`](SignedPaymentRegistry::release) +//! itself generation-aware without modifying the pinned crate, so an +//! unconditional release-by-outpoint after a sweep is prevented by never +//! reaching it once the token is stale. //! //! ## Process-death semantics //! @@ -37,7 +51,7 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, MutexGuard}; use dashcore::{Transaction, Txid}; use key_wallet::account::account_type::StandardAccountType; @@ -53,6 +67,35 @@ use crate::PlatformWalletError; /// lifetime and never reused, so a stale token can always be recognised. pub type ReservationToken = u64; +/// Maximum age, in synced 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` reservation is stamped at the wallet's +/// synced height and swept by a later `reserve`/`reserved` call 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 the wallet's synced height to lag a few blocks behind the true tip. +const RESERVATION_MAX_AGE_BLOCKS: u32 = 20; + +/// Whether a token registered at `registered_height` is too old to act on at +/// `current_height` (see [`RESERVATION_MAX_AGE_BLOCKS`]). Unknown heights (the +/// wallet was gone at register or is gone now) disable the guard — the +/// wallet-mismatch / account-lookup paths already reject those cases. +fn reservation_expired(registered_height: Option, current_height: Option) -> bool { + match (registered_height, current_height) { + (Some(registered), Some(current)) => { + current.saturating_sub(registered) >= RESERVATION_MAX_AGE_BLOCKS + } + _ => false, + } +} + /// Failure of a deferred broadcast/release token operation. #[derive(Debug, thiserror::Error)] pub enum SignedPaymentError { @@ -69,6 +112,14 @@ pub enum SignedPaymentError { #[error("reservation token {0} was minted against a different wallet instance")] WalletMismatch(ReservationToken), + /// The token has outlived [`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. + #[error("reservation token {0} has outlived its reservation lifetime; rebuild the payment")] + StaleReservationToken(ReservationToken), + /// The underlying broadcast failed. Carries the still-typed wallet error so /// the FFI boundary can preserve the retry semantics (e.g. the ambiguous /// [`PlatformWalletError::TransactionBroadcastUnconfirmed`] "may already be @@ -92,6 +143,13 @@ struct RegisteredPayment { /// TTL backstop), mirroring `CoreAccountTypeFFI::as_standard_account_type`. account_type: Option, account_index: u32, + /// Wallet synced height captured at registration — a proxy for the height at + /// which `build_signed` stamped the funding reservation. Compared against the + /// wallet's current synced height to refuse a broadcast/release once the + /// reservation could plausibly have been swept (see + /// [`RESERVATION_MAX_AGE_BLOCKS`]). `None` when the wallet was not resolvable + /// at registration, which disables the age guard for this entry. + registered_height: Option, } /// Registry of signed-but-unsent payments keyed by [`ReservationToken`]. @@ -121,32 +179,46 @@ impl SignedPaymentRegistry { } } + /// Lock the entries map, recovering from a poisoned mutex rather than + /// panicking. The registry is a single process-global, so a panic elsewhere + /// while the lock was held would otherwise permanently disable deferred + /// payments for every wallet; the guarded `HashMap` has no invariant a + /// partial write could break, so recovery is safe (mirrors key-wallet's + /// sibling `ReservationSet::lock`). + fn lock(&self) -> MutexGuard<'_, HashMap>> { + self.entries + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + /// Take ownership of a built, signed `tx` (whose funding UTXOs `build_signed` /// already reserved) and return an opaque token for a later /// [`broadcast`](Self::broadcast) or [`release`](Self::release). /// /// `core` is the wallet the payment was built against; it is captured so the /// later operation acts on the exact reservation state that holds the inputs. - pub fn register( + /// The wallet's current synced height is captured too, to bound the token's + /// lifetime against key-wallet's reservation TTL (see + /// [`RESERVATION_MAX_AGE_BLOCKS`]). + pub async fn register( &self, core: CoreWallet, tx: Transaction, account_type: Option, account_index: u32, ) -> ReservationToken { + let registered_height = core.synced_height().await; let token = self.next_token.fetch_add(1, Ordering::SeqCst); - self.entries - .lock() - .expect("signed-payment registry mutex poisoned") - .insert( - token, - RegisteredPayment { - core, - tx, - account_type, - account_index, - }, - ); + self.lock().insert( + token, + RegisteredPayment { + core, + tx, + account_type, + account_index, + registered_height, + }, + ); token } @@ -171,21 +243,28 @@ impl SignedPaymentRegistry { // Remove under the lock and drop the guard *before* awaiting — a // std::Mutex guard must never be held across an await point, and the // atomic take is what makes a double-broadcast impossible. - let entry = { - let mut entries = self - .entries - .lock() - .expect("signed-payment registry mutex poisoned"); - entries.remove(&token) - } - .ok_or(SignedPaymentError::StaleToken(token))?; + let entry = { self.lock().remove(&token) }.ok_or(SignedPaymentError::StaleToken(token))?; - if !Arc::ptr_eq(&entry.core.wallet_manager, ¤t.wallet_manager) { - // The token belongs to another wallet instance; it has been removed, - // so it can never be replayed here. + // Bound the token to the exact wallet instance: the same shared + // `WalletManager` (`Arc::ptr_eq`) *and* the same `wallet_id`, so two + // wallets sharing one multi-wallet `PlatformWalletManager` are told + // apart (`ptr_eq` alone matches any pair within that manager). The + // entry is already removed, so a mismatched token can never be replayed. + if !Arc::ptr_eq(&entry.core.wallet_manager, ¤t.wallet_manager) + || entry.core.wallet_id() != current.wallet_id() + { return Err(SignedPaymentError::WalletMismatch(token)); } + // Refuse a token whose reservation could already have been swept and + // re-selected by an unrelated build. The entry is already removed, so we + // simply drop it — deliberately WITHOUT releasing, since a release by + // outpoint here could free a newer build's reservation. The stale + // reservation is reclaimed by key-wallet's own TTL sweep. + if reservation_expired(entry.registered_height, current.synced_height().await) { + return Err(SignedPaymentError::StaleReservationToken(token)); + } + let txid = match entry.account_type { Some(account_type) => { entry @@ -210,17 +289,19 @@ impl SignedPaymentRegistry { /// the one whose `ReservationSet` actually holds the inputs — so no wallet /// handle need be threaded in. pub async fn release(&self, token: ReservationToken) { - let entry = { - let mut entries = self - .entries - .lock() - .expect("signed-payment registry mutex poisoned"); - entries.remove(&token) - }; + let entry = { self.lock().remove(&token) }; let Some(entry) = entry else { // Unknown / already consumed — idempotent no-op. return; }; + // If the token has outlived its reservation lifetime, the funding + // outpoint may already have been swept and re-selected by an unrelated + // build; releasing it by outpoint could free that newer reservation. + // Drop the token without touching the `ReservationSet` — the original + // reservation is reclaimed by key-wallet's own TTL sweep. + if reservation_expired(entry.registered_height, entry.core.synced_height().await) { + return; + } if let Some(account_type) = entry.account_type { entry .core @@ -229,13 +310,36 @@ impl SignedPaymentRegistry { } } + /// Drop every outstanding token bound to `wallet` (same shared + /// `WalletManager` and `wallet_id`), returning how many were removed. + /// + /// Called from the FFI when a `PlatformWallet` is destroyed so the registry + /// stops pinning that wallet's `WalletManager` (accounts, keys, sync state) + /// alive for the rest of the process via its captured `CoreWallet` clone. + /// The reservations are intentionally not released: the wallet — and its + /// accounts' `ReservationSet`s — are being torn down with it, so there is + /// nothing to reconcile, and any surviving token would be a + /// [`WalletMismatch`](SignedPaymentError::WalletMismatch) against a + /// re-created instance regardless. + /// + /// This is hooked into `PlatformWallet` teardown rather than the transient + /// `CoreWallet` handle destroy: the deferred flow builds/registers on one + /// short-lived core handle and broadcasts on another, so sweeping on core + /// handle destroy would drop tokens between register and broadcast. + pub fn remove_entries_for_wallet(&self, wallet: &CoreWallet) -> usize { + let mut entries = self.lock(); + let before = entries.len(); + entries.retain(|_, entry| { + !(Arc::ptr_eq(&entry.core.wallet_manager, &wallet.wallet_manager) + && entry.core.wallet_id() == wallet.wallet_id()) + }); + before - entries.len() + } + /// Number of outstanding (registered but not yet broadcast/released) tokens. #[cfg(test)] pub(crate) fn outstanding(&self) -> usize { - self.entries - .lock() - .expect("signed-payment registry mutex poisoned") - .len() + self.lock().len() } } @@ -253,7 +357,7 @@ mod tests { use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; - use super::{SignedPaymentError, SignedPaymentRegistry}; + use super::{SignedPaymentError, SignedPaymentRegistry, RESERVATION_MAX_AGE_BLOCKS}; use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; use crate::test_support::{funded_wallet_manager, AlwaysMaybeSentBroadcaster, WalletSigner}; use crate::wallet::core::CoreWallet; @@ -373,7 +477,9 @@ mod tests { builder = builder.add_output(addr, *amount); } let (tx, _fee) = builder - .build_signed(signer, |addr| managed_account.address_derivation_path(&addr)) + .build_signed(signer, |addr| { + managed_account.address_derivation_path(&addr) + }) .await .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; Ok(tx) @@ -388,18 +494,21 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; let registry = SignedPaymentRegistry::new(); - let tx = build_signed_tx(&core, StandardAccountType::BIP44Account, 0, &outputs, &signer) - .await - .expect("build should succeed"); + let tx = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); let expected_bytes = dashcore::consensus::serialize(&tx); let expected_txid = tx.txid(); - let token = registry.register( - core.clone(), - tx, - Some(StandardAccountType::BIP44Account), - 0, - ); + let token = registry + .register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0) + .await; assert_eq!(registry.outstanding(), 1); // Broadcast through a *clone* of the same wallet instance — the @@ -433,7 +542,9 @@ mod tests { let tx = build_signed_tx(&core, account_type, 0, &outputs, &signer) .await .expect("build should succeed"); - let token = registry.register(core.clone(), tx, Some(account_type), 0); + let token = registry + .register(core.clone(), tx, Some(account_type), 0) + .await; // With the reservation held, an immediate rebuild finds no // spendable UTXO and fails. @@ -464,10 +575,18 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; let registry = SignedPaymentRegistry::new(); - let tx = build_signed_tx(&core, StandardAccountType::BIP44Account, 0, &outputs, &signer) - .await - .expect("build should succeed"); - let token = registry.register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0); + let tx = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0) + .await; registry .broadcast(token, &core) @@ -493,10 +612,18 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; let registry = SignedPaymentRegistry::new(); - let tx = build_signed_tx(&core, StandardAccountType::BIP44Account, 0, &outputs, &signer) - .await - .expect("build should succeed"); - let token = registry.register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0); + let tx = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0) + .await; registry.release(token).await; // Second release: no panic, no error, still consumed. @@ -513,10 +640,18 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; let registry = SignedPaymentRegistry::new(); - let tx = build_signed_tx(&core, StandardAccountType::BIP44Account, 0, &outputs, &signer) - .await - .expect("build should succeed"); - let token = registry.register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0); + let tx = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0) + .await; registry.release(token).await; let sent = registry.broadcast(token, &core).await; @@ -524,7 +659,11 @@ mod tests { matches!(sent, Err(SignedPaymentError::StaleToken(_))), "broadcast of a released token must be StaleToken, got {sent:?}" ); - assert_eq!(broadcaster.count.load(Ordering::SeqCst), 0, "nothing was sent"); + assert_eq!( + broadcaster.count.load(Ordering::SeqCst), + 0, + "nothing was sent" + ); } /// An unknown token is a `StaleToken` error. @@ -546,24 +685,34 @@ mod tests { #[tokio::test] async fn broadcast_rejects_a_different_wallet_instance() { let broadcaster_a = Arc::new(CountingBroadcaster::new()); - let (core_a, signer_a, outputs_a) = - funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster_a)).await; + let (core_a, signer_a, outputs_a) = funded_core_wallet( + StandardAccountType::BIP44Account, + Arc::clone(&broadcaster_a), + ) + .await; // A separate wallet-manager instance stands in for a re-created wallet. let broadcaster_b = Arc::new(CountingBroadcaster::new()); let (core_b, _signer_b, _outputs_b) = funded_core_wallet(StandardAccountType::BIP44Account, broadcaster_b).await; let registry = SignedPaymentRegistry::new(); - let tx = - build_signed_tx(&core_a, StandardAccountType::BIP44Account, 0, &outputs_a, &signer_a) - .await - .expect("build should succeed"); - let token = registry.register( - core_a.clone(), - tx, - Some(StandardAccountType::BIP44Account), + let tx = build_signed_tx( + &core_a, + StandardAccountType::BIP44Account, 0, - ); + &outputs_a, + &signer_a, + ) + .await + .expect("build should succeed"); + let token = registry + .register( + core_a.clone(), + tx, + Some(StandardAccountType::BIP44Account), + 0, + ) + .await; let sent = registry.broadcast(token, &core_b).await; assert!( @@ -588,10 +737,18 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; let registry = SignedPaymentRegistry::new(); - let tx = build_signed_tx(&core, StandardAccountType::BIP44Account, 0, &outputs, &signer) - .await - .expect("build should succeed"); - let token = registry.register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0); + let tx = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0) + .await; let sent = registry.broadcast(token, &core).await; assert!( @@ -606,8 +763,14 @@ mod tests { assert_eq!(registry.outstanding(), 0, "token consumed even on failure"); // Reservation kept: an immediate rebuild fails at input selection. - let rebuilt = build_signed_tx(&core, StandardAccountType::BIP44Account, 0, &outputs, &signer) - .await; + let rebuilt = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await; assert!( matches!(rebuilt, Err(PlatformWalletError::TransactionBuild(_))), "rebuild must fail with the reservation kept, got {rebuilt:?}" @@ -624,10 +787,18 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; let registry = Arc::new(SignedPaymentRegistry::new()); - let tx = build_signed_tx(&core, StandardAccountType::BIP44Account, 0, &outputs, &signer) - .await - .expect("build should succeed"); - let token = registry.register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0); + let tx = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0) + .await; let mut handles = Vec::new(); for _ in 0..8 { @@ -663,9 +834,15 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; // One built tx is enough; we register clones of it many times to probe // the token allocator, not the reservation logic. - let tx = build_signed_tx(&core, StandardAccountType::BIP44Account, 0, &outputs, &signer) - .await - .expect("build should succeed"); + let tx = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); let registry = Arc::new(SignedPaymentRegistry::new()); let mut handles = Vec::new(); @@ -674,7 +851,9 @@ mod tests { let core = core.clone(); let tx = tx.clone(); handles.push(tokio::spawn(async move { - registry.register(core, tx, Some(StandardAccountType::BIP44Account), 0) + registry + .register(core, tx, Some(StandardAccountType::BIP44Account), 0) + .await })); } let mut tokens = Vec::new(); @@ -685,4 +864,226 @@ mod tests { assert_eq!(unique.len(), tokens.len(), "all tokens must be distinct"); assert_eq!(registry.outstanding(), 16); } + + /// Force the wallet's synced height forward, simulating chain progress + /// between build/register and a later broadcast/release — the window in + /// which key-wallet's `ReservationSet` TTL can sweep the funding reservation. + async fn advance_synced_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_synced_height(height); + } + + /// Once the wallet has synced past `RESERVATION_MAX_AGE_BLOCKS` beyond the + /// registration height, the reservation could have been swept and + /// re-selected — so a broadcast must be refused with `StaleReservationToken` + /// (never a send) and must NOT release the reservation by outpoint (which + /// could free a newer, unrelated build's reservation). + #[tokio::test] + async fn expired_token_broadcast_is_stale_and_keeps_reservation() { + let broadcaster = Arc::new(CountingBroadcaster::new()); + let (core, signer, outputs) = + funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; + let registry = SignedPaymentRegistry::new(); + + let registered_height = core.synced_height().await.expect("synced height"); + let tx = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0) + .await; + + // Advance past the age bound but stay below key-wallet's 24-block TTL, so + // the reservation is provably still held (only our guard has tripped). + advance_synced_height(&core, registered_height + RESERVATION_MAX_AGE_BLOCKS + 2).await; + + let sent = registry.broadcast(token, &core).await; + assert!( + matches!(sent, Err(SignedPaymentError::StaleReservationToken(t)) if t == token), + "an expired token must broadcast as StaleReservationToken, got {sent:?}" + ); + assert_eq!( + broadcaster.count.load(Ordering::SeqCst), + 0, + "an expired token must never hit the network" + ); + assert_eq!(registry.outstanding(), 0, "the expired token is dropped"); + + // The reservation was NOT released: an immediate rebuild still can't + // reselect the input (it is reclaimed only by key-wallet's own TTL). + let rebuilt = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await; + assert!( + matches!(rebuilt, Err(PlatformWalletError::TransactionBuild(_))), + "expired broadcast must not release the reservation, got {rebuilt:?}" + ); + } + + /// Releasing an expired token must likewise NOT touch the `ReservationSet`: + /// its outpoint may already belong to a newer build. The token is dropped + /// and the original reservation is left to key-wallet's TTL sweep. + #[tokio::test] + async fn expired_token_release_keeps_reservation() { + let broadcaster = Arc::new(RecordingBroadcaster::new()); + let (core, signer, outputs) = + funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; + let registry = SignedPaymentRegistry::new(); + + let registered_height = core.synced_height().await.expect("synced height"); + let tx = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0) + .await; + + advance_synced_height(&core, registered_height + RESERVATION_MAX_AGE_BLOCKS + 2).await; + + registry.release(token).await; + assert_eq!(registry.outstanding(), 0, "the expired token is dropped"); + + // Reservation intentionally kept (not released by outpoint): rebuild + // still fails until the TTL backstop reclaims it. + let rebuilt = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await; + assert!( + matches!(rebuilt, Err(PlatformWalletError::TransactionBuild(_))), + "expired release must not free the reservation by outpoint, got {rebuilt:?}" + ); + } + + /// Two wallets sharing one multi-wallet `PlatformWalletManager` have the same + /// `wallet_manager` `Arc` (so `Arc::ptr_eq` alone can't tell them apart); the + /// `wallet_id` comparison must reject a token broadcast through the sibling. + #[tokio::test] + async fn broadcast_rejects_same_manager_different_wallet_id() { + let broadcaster = Arc::new(CountingBroadcaster::new()); + let (core, signer, outputs) = + funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; + let registry = SignedPaymentRegistry::new(); + + let tx = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0) + .await; + + // A sibling handle over the SAME manager Arc but a different wallet_id — + // `Arc::ptr_eq` on `wallet_manager` is true, so only the wallet_id check + // distinguishes it. + let mut sibling = core.clone(); + sibling.wallet_id[0] ^= 0xFF; + assert!(Arc::ptr_eq(&core.wallet_manager, &sibling.wallet_manager)); + + let sent = registry.broadcast(token, &sibling).await; + assert!( + matches!(sent, Err(SignedPaymentError::WalletMismatch(t)) if t == token), + "a sibling wallet in the same manager must be WalletMismatch, got {sent:?}" + ); + assert_eq!( + broadcaster.count.load(Ordering::SeqCst), + 0, + "nothing was sent for the mismatched wallet" + ); + assert_eq!(registry.outstanding(), 0, "the stale token is dropped"); + } + + /// Destroying a wallet sweeps only its own tokens from the registry, so its + /// captured `CoreWallet` clone stops pinning the `WalletManager` alive — + /// other wallets' tokens are untouched. + #[tokio::test] + async fn remove_entries_for_wallet_drops_only_that_wallets_tokens() { + let (core_a, signer_a, outputs_a) = funded_core_wallet( + StandardAccountType::BIP44Account, + Arc::new(CountingBroadcaster::new()), + ) + .await; + let (core_b, signer_b, outputs_b) = funded_core_wallet( + StandardAccountType::BIP44Account, + Arc::new(CountingBroadcaster::new()), + ) + .await; + let registry = SignedPaymentRegistry::new(); + + let tx_a = build_signed_tx( + &core_a, + StandardAccountType::BIP44Account, + 0, + &outputs_a, + &signer_a, + ) + .await + .expect("build A should succeed"); + let token_a = registry + .register( + core_a.clone(), + tx_a, + Some(StandardAccountType::BIP44Account), + 0, + ) + .await; + let tx_b = build_signed_tx( + &core_b, + StandardAccountType::BIP44Account, + 0, + &outputs_b, + &signer_b, + ) + .await + .expect("build B should succeed"); + let _token_b = registry + .register( + core_b.clone(), + tx_b, + Some(StandardAccountType::BIP44Account), + 0, + ) + .await; + assert_eq!(registry.outstanding(), 2); + + let removed = registry.remove_entries_for_wallet(&core_a); + assert_eq!(removed, 1, "exactly wallet A's one token is swept"); + assert_eq!(registry.outstanding(), 1, "wallet B's token survives"); + + // Wallet A's token is gone: broadcasting it is a plain StaleToken. + let sent = registry.broadcast(token_a, &core_a).await; + assert!( + matches!(sent, Err(SignedPaymentError::StaleToken(t)) if t == token_a), + "a swept token must be StaleToken, got {sent:?}" + ); + } } diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index d15d6a23b41..cdc3d6a13d0 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1241,45 +1241,6 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c // Backed by the process-global registry in `platform_wallet_ffi` // (`core_wallet_signed_payment_*`). See `SignedPaymentRegistry`. -/// `core_wallet_transaction_get_bytes` — the consensus-serialized bytes of a -/// built transaction from [coreTxBuilderBuildSigned], copied into a fresh -/// Java `byte[]`. The underlying FFI hands back a borrowed pointer valid only -/// while `tx` lives, so we copy it here before returning. -#[no_mangle] -pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreTransactionGetBytes( - mut env: JNIEnv, - _class: JClass, - tx: jlong, -) -> jbyteArray { - guard(&mut env, ptr::null_mut(), |env| { - if tx == 0 { - throw_sdk_exception(env, 1, "transaction handle is 0"); - return ptr::null_mut(); - } - let mut out_ptr: *const u8 = ptr::null(); - let mut out_len: usize = 0; - let result = unsafe { - platform_wallet_ffi::core_wallet_transaction_get_bytes( - tx as *const platform_wallet_ffi::FFICoreTransaction, - &mut out_ptr as *mut *const u8, - &mut out_len as *mut usize, - ) - }; - if take_pwffi_error(env, result) { - return ptr::null_mut(); - } - // Copy immediately: the pointer borrows the transaction's own buffer. - let bytes: &[u8] = if out_ptr.is_null() || out_len == 0 { - &[] - } else { - unsafe { std::slice::from_raw_parts(out_ptr, out_len) } - }; - env.byte_array_from_slice(bytes) - .map(|a| a.into_raw()) - .unwrap_or(ptr::null_mut()) - }) -} - /// `core_wallet_signed_payment_register` — register a built+signed transaction /// (from [coreTxBuilderBuildSigned]) for deferred submission, holding its UTXO /// reservation. `accountType`/`accountIndex` are the funding account (0 BIP44, @@ -1287,8 +1248,9 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c /// with [coreTransactionFree]. /// /// Returns a big-endian BLOB the Kotlin side decodes into a -/// `SignedCoreTransaction`: `u64 token, u64 feeDuffs, u32 txidLen, txid utf8`. -/// The raw tx bytes are fetched separately via [coreTransactionGetBytes]. +/// `SignedCoreTransaction`: +/// `u64 token, u64 feeDuffs, u32 txidLen, txid utf8, u32 txBytesLen, txBytes`. +/// The raw tx bytes come back in this same call (no second native round trip). #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreWalletRegisterSignedPayment( mut env: JNIEnv, @@ -1315,6 +1277,8 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c let mut token: u64 = 0; let mut fee: u64 = 0; let mut out_txid: *mut c_char = ptr::null_mut(); + let mut out_bytes_ptr: *const u8 = ptr::null(); + let mut out_bytes_len: usize = 0; let result = unsafe { platform_wallet_ffi::core_wallet_signed_payment_register( core_handle as Handle, @@ -1324,6 +1288,8 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c &mut token as *mut u64, &mut fee as *mut u64, &mut out_txid as *mut *mut c_char, + &mut out_bytes_ptr as *mut *const u8, + &mut out_bytes_len as *mut usize, ) }; if take_pwffi_error(env, result) { @@ -1339,16 +1305,33 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c .into_owned(); unsafe { platform_wallet_ffi::core_wallet_free_address(out_txid) }; + // Copy the raw tx bytes immediately: the pointer borrows the still-live + // transaction's own buffer. + let tx_bytes: &[u8] = if out_bytes_ptr.is_null() || out_bytes_len == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(out_bytes_ptr, out_bytes_len) } + }; + // Assemble the big-endian BLOB (matches the Kotlin ByteBuffer decoder). let txid_bytes = txid.into_bytes(); - let mut blob = Vec::with_capacity(8 + 8 + 4 + txid_bytes.len()); + let mut blob = Vec::with_capacity(8 + 8 + 4 + txid_bytes.len() + 4 + tx_bytes.len()); blob.extend_from_slice(&token.to_be_bytes()); blob.extend_from_slice(&fee.to_be_bytes()); blob.extend_from_slice(&(txid_bytes.len() as u32).to_be_bytes()); blob.extend_from_slice(&txid_bytes); - env.byte_array_from_slice(&blob) - .map(|a| a.into_raw()) - .unwrap_or(ptr::null_mut()) + blob.extend_from_slice(&(tx_bytes.len() as u32).to_be_bytes()); + blob.extend_from_slice(tx_bytes); + match env.byte_array_from_slice(&blob) { + Ok(array) => array.into_raw(), + Err(_) => { + // The registration already committed and is holding the funding + // reservation; release the token so it isn't orphaned to the + // 24-block TTL backstop when Kotlin never receives it. + let _ = unsafe { platform_wallet_ffi::core_wallet_signed_payment_release(token) }; + ptr::null_mut() + } + } }) } From 63f29260d7da83dde838bcc409984255fd3af23b Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:48:42 -0400 Subject: [PATCH 03/24] fix(kotlin-sdk): resolve rebase semantic conflicts onto feat/kotlin-sdk-and-example-app Rebasing the split build/broadcast work onto the current base surfaced three semantic collisions the textual merge could not catch: - error code 22 was reassigned on base (ErrorCoreInsufficientFunds and the asset-lock family 22-25); moved ErrorStaleReservationToken to the next free code 26 in platform-wallet-ffi and DashSdkError's native-code mapping. - base added its own CoreWallet::release_transaction_reservation (taking AccountTypePreference, superset incl. CoinJoin) for the finalized-transaction abandon path, colliding with this PR's identically-named StandardAccountType method. Renamed this PR's deferred-payment release to release_payment_reservation (sole caller: SignedPaymentRegistry::release). - base removed the per-wallet coreSendMutex and now serializes/gates core sends through the TeardownGate (gate.op), moving send concurrency safety into the Rust reservation layer. buildSignedPayment now opens with gate.op like its sibling sendToAddresses instead of the removed mutex, which also satisfies the GateCoverageLintTest handle-borrowing fence. Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/wallet/ManagedPlatformWallet.kt | 55 +++++++++---------- .../src/wallet/core/broadcast.rs | 7 ++- .../src/wallet/signed_payment_registry.rs | 2 +- 3 files changed, 34 insertions(+), 30 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt index ec692911416..023f16716fd 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt @@ -223,11 +223,12 @@ class ManagedPlatformWallet internal constructor( * The BIP70/BIP270 counterpart to [sendToAddresses]: those protocols sign, * POST the raw bytes to a merchant server, and broadcast only on ack, which * a single build-sign-broadcast call cannot express. The `new → addOutput* → - * setFunding → buildSigned` build runs under the same per-wallet - * [coreSendMutex] as [sendToAddresses] (closing the setFunding/buildSigned - * selection race); [buildSigned] reserves the selected UTXOs, so once this - * returns the reservation holds the inputs and [broadcastSigned] / - * [releaseReservation] operate on the token later WITHOUT the mutex. + * setFunding → buildSigned` build runs under the same per-wallet teardown + * gate ([gate]) as [sendToAddresses]; [buildSigned] atomically reserves the + * selected UTXOs in the Rust reservation layer (which closes the + * setFunding/buildSigned selection race), so once this returns the + * reservation holds the inputs and [broadcastSigned] / [releaseReservation] + * operate on the token later. * * Process-death note: the reservation is in-memory. An app crash between * this call and [broadcastSigned] drops the reservation on restart (the @@ -243,7 +244,7 @@ class ManagedPlatformWallet internal constructor( coreSignerHandle: Long, accountType: AccountType = AccountType.BIP44, accountIndex: Int = 0, - ): SignedCoreTransaction = withContext(Dispatchers.IO) { + ): SignedCoreTransaction = gate.op { require(accountIndex >= 0) { "accountIndex must be non-negative, got $accountIndex" } require(recipients.isNotEmpty()) { "recipients must not be empty" } require(recipients.all { it.second > 0 }) { @@ -253,28 +254,26 @@ class ManagedPlatformWallet internal constructor( AccountType.BIP44 -> CoreTransactionBuilder.AccountType.BIP44 AccountType.BIP32 -> CoreTransactionBuilder.AccountType.BIP32 } - coreSendMutex.withLock { - mapNativeErrors { - coreWallet().use { core -> - val builder = CoreTransactionBuilder(network) - // `buildSigned` consumes the builder; `use` still safely - // destroys it on the pre-build failure paths. - val signedTx = builder.use { - for ((address, amount) in recipients) { - it.addOutput(address, amount) - } - it.setFunding(this@ManagedPlatformWallet, builderAccountType, accountIndex) - it.buildSigned( - this@ManagedPlatformWallet, - builderAccountType, - accountIndex, - coreSignerHandle, - ) + mapNativeErrors { + coreWallet().use { core -> + val builder = CoreTransactionBuilder(network) + // `buildSigned` consumes the builder; `use` still safely + // destroys it on the pre-build failure paths. + val signedTx = builder.use { + for ((address, amount) in recipients) { + it.addOutput(address, amount) } - // Register the signed tx (holding its reservation) before the - // native transaction is freed; `use` frees it afterward. - signedTx.use { tx -> core.registerSignedPayment(tx) } + it.setFunding(this@ManagedPlatformWallet, builderAccountType, accountIndex) + it.buildSigned( + this@ManagedPlatformWallet, + builderAccountType, + accountIndex, + coreSignerHandle, + ) } + // Register the signed tx (holding its reservation) before the + // native transaction is freed; `use` frees it afterward. + signedTx.use { tx -> core.registerSignedPayment(tx) } } } } @@ -285,8 +284,8 @@ class ManagedPlatformWallet internal constructor( * the token: a second [broadcastSigned] with the same token, or one for a * re-created wallet, throws * [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.StaleReservationToken] - * rather than double-broadcasting. Operates on the token WITHOUT the - * [coreSendMutex] (the inputs are already reserved). + * rather than double-broadcasting. Operates on the token directly (the + * inputs are already reserved). */ suspend fun broadcastSigned(token: Long): String = withContext(Dispatchers.IO) { mapNativeErrors { diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 55466a1dcf7..8386f9a06ce 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -103,7 +103,12 @@ impl CoreWallet { /// /// `account_type`/`account_index` identify the funding account handed to /// `set_funding` when the transaction was built. - pub async fn release_transaction_reservation( + /// + /// Named distinctly from the `AccountTypePreference`-typed + /// [`release_transaction_reservation`](Self::release_transaction_reservation) + /// (the finalized-transaction abandon path); this `StandardAccountType` + /// form serves the deferred [`SignedPaymentRegistry`](crate::SignedPaymentRegistry). + pub async fn release_payment_reservation( &self, account_type: StandardAccountType, account_index: u32, 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 3c89be7e968..9f1028d840f 100644 --- a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -305,7 +305,7 @@ impl SignedPaymentRegistry { if let Some(account_type) = entry.account_type { entry .core - .release_transaction_reservation(account_type, entry.account_index, &entry.tx) + .release_payment_reservation(account_type, entry.account_index, &entry.tx) .await; } } From dcabaaf31363821b20dba3a6db375f7d23685d67 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Fri, 17 Jul 2026 08:55:54 -0400 Subject: [PATCH 04/24] fix(kotlin-sdk): assert native code 26 for the stale-reservation-token mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase onto feat/kotlin-sdk-and-example-app reassigned native code 22 to ErrorCoreInsufficientFunds and moved ErrorStaleReservationToken to code 26 (on both the Rust enum and DashSdkError's mapping), but DashSdkErrorTest still constructed code 22 and asserted StaleReservationToken. That deterministically resolved to CoreInsufficientFunds, so platformWalletCodesMapToPlatformWalletSubtree failed and :sdk:testDebugUnitTest — the "Kotlin SDK build + tests (x86_64 emulator)" CI job — went red without actually verifying the code-26 mapping. Point the assertion at code 26 so it exercises the real production mapping. Co-Authored-By: Claude Fable 5 --- .../org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index 502b1d274f8..b9f3b294fb1 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -106,7 +106,7 @@ class DashSdkErrorTest { // Deferred build/broadcast: a stale/consumed/wrong-wallet reservation // token → typed StaleReservationToken, not retryable. - val staleToken = DashSdkError.fromNative(DashSDKException(offset + 22, "stale token 7")) + val staleToken = DashSdkError.fromNative(DashSDKException(offset + 26, "stale token 7")) assertTrue(staleToken is DashSdkError.PlatformWallet.StaleReservationToken) assertFalse( "StaleReservationToken must NOT be retryable (rebuild the payment)", From b20b2953f7d7e7b457e0c8fb31076f6686d0fa0c Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Fri, 17 Jul 2026 08:56:08 -0400 Subject: [PATCH 05/24] fix(kotlin-sdk): bound the deferred-payment token on the reservation's own height clock The SignedPaymentRegistry age guard stamped registered_height with CoreWallet::synced_height() and compared it against a later synced_height(), while the funding reservation it is meant to stay under is stamped with last_processed_height() (the height finalize_transaction / build_signed pass to set_current_height, and the clock key-wallet's ReservationSet TTL sweeps against). synced_height can regress during a rescan while last_processed_height is monotonic, so measuring the reservation's age against synced_height could let a token outlive its reservation and act on an outpoint key-wallet had already swept and re-selected for an unrelated build. Read last_processed_height() for both the registration stamp and the current comparison so the guard measures the same clock the reservation is stamped with, trips strictly before the underlying TTL, and never regresses. Add CoreWallet::last_processed_height(); drop the now-unused synced_height(). The registry's expiry tests now stamp and advance last_processed_height to match production, and outstanding() is exposed under test-utils for downstream FFI tests. Co-Authored-By: Claude Fable 5 --- .../src/wallet/core/wallet.rs | 22 +++--- .../src/wallet/signed_payment_registry.rs | 77 +++++++++++-------- 2 files changed, 57 insertions(+), 42 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/wallet.rs b/packages/rs-platform-wallet/src/wallet/core/wallet.rs index 8ad384d873d..9dd2b0e4493 100644 --- a/packages/rs-platform-wallet/src/wallet/core/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/core/wallet.rs @@ -288,19 +288,23 @@ impl CoreWallet { self.sdk.network } - /// Current synced block height for this wallet, or `None` if the wallet is no - /// longer present in the manager. + /// Current last-processed block height for this wallet, or `None` if the + /// wallet is no longer present in the manager. /// - /// Used by the deferred-payment + /// This is the clock the funding reservation is actually stamped with: + /// `finalize_transaction` / `build_signed` reserve the selected inputs at + /// `set_current_height(last_processed_height())`, and key-wallet's + /// `ReservationSet` TTL sweeps entries relative to a later build's + /// `last_processed_height`. It is therefore the correct — and monotonic — + /// clock for the deferred-payment /// [`SignedPaymentRegistry`](crate::SignedPaymentRegistry) to bound a token's - /// lifetime against key-wallet's UTXO reservation TTL: a `build_signed` - /// reservation is stamped at this height, so the elapsed span since - /// registration tells the registry whether the reservation could have been - /// swept and re-selected out from under the token. - pub(crate) async fn synced_height(&self) -> Option { + /// lifetime against that TTL. `synced_height` is a different clock that can + /// regress during a rescan, so measuring the reservation's age against it + /// could let a token outlive its reservation. + pub(crate) async fn last_processed_height(&self) -> Option { let wm = self.wallet_manager.read().await; wm.get_wallet_and_info(&self.wallet_id) - .map(|(_, info)| info.core_wallet.synced_height()) + .map(|(_, info)| info.core_wallet.last_processed_height()) } } 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 9f1028d840f..56968725625 100644 --- a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -28,9 +28,10 @@ //! [`SignedPaymentError::WalletMismatch`] rather than a spend against stale //! state. //! * A token has a bounded lifetime ([`RESERVATION_MAX_AGE_BLOCKS`]). Once the -//! wallet has synced far enough past the height at which `build_signed` -//! stamped the reservation that key-wallet's own `ReservationSet` TTL could -//! have swept and re-selected the funding UTXO for an unrelated build, +//! 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 +//! funding UTXO for an unrelated build, //! broadcasting or releasing the token would act on state that may no longer //! be its own — so both are refused with //! [`SignedPaymentError::StaleReservationToken`] and the caller must rebuild. @@ -67,20 +68,22 @@ use crate::PlatformWalletError; /// lifetime and never reused, so a stale token can always be recognised. pub type ReservationToken = u64; -/// Maximum age, in synced blocks, of a registered token before its broadcast or -/// release is refused. +/// 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` reservation is stamped at the wallet's -/// synced height and swept by a later `reserve`/`reserved` call 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. +/// 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 the wallet's synced height to lag a few blocks behind the true tip. +/// for `last_processed_height` to lag a few blocks behind the true tip. const RESERVATION_MAX_AGE_BLOCKS: u32 = 20; /// Whether a token registered at `registered_height` is too old to act on at @@ -143,12 +146,13 @@ struct RegisteredPayment { /// TTL backstop), mirroring `CoreAccountTypeFFI::as_standard_account_type`. account_type: Option, account_index: u32, - /// Wallet synced height captured at registration — a proxy for the height at - /// which `build_signed` stamped the funding reservation. Compared against the - /// wallet's current synced height to refuse a broadcast/release once the - /// reservation could plausibly have been swept (see - /// [`RESERVATION_MAX_AGE_BLOCKS`]). `None` when the wallet was not resolvable - /// at registration, which disables the age guard for this entry. + /// Wallet `last_processed_height` captured at registration — the exact clock + /// `build_signed` / `finalize_transaction` stamps the funding reservation + /// with. 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`]). `None` when + /// the wallet was not resolvable at registration, which disables the age + /// guard for this entry. registered_height: Option, } @@ -197,8 +201,8 @@ impl SignedPaymentRegistry { /// /// `core` is the wallet the payment was built against; it is captured so the /// later operation acts on the exact reservation state that holds the inputs. - /// The wallet's current synced height is captured too, to bound the token's - /// lifetime against key-wallet's reservation TTL (see + /// The wallet's current `last_processed_height` is captured too, to bound the + /// token's lifetime against key-wallet's reservation TTL (see /// [`RESERVATION_MAX_AGE_BLOCKS`]). pub async fn register( &self, @@ -207,7 +211,7 @@ impl SignedPaymentRegistry { account_type: Option, account_index: u32, ) -> ReservationToken { - let registered_height = core.synced_height().await; + let registered_height = core.last_processed_height().await; let token = self.next_token.fetch_add(1, Ordering::SeqCst); self.lock().insert( token, @@ -261,7 +265,7 @@ impl SignedPaymentRegistry { // simply drop it — deliberately WITHOUT releasing, since a release by // outpoint here could free a newer build's reservation. The stale // reservation is reclaimed by key-wallet's own TTL sweep. - if reservation_expired(entry.registered_height, current.synced_height().await) { + if reservation_expired(entry.registered_height, current.last_processed_height().await) { return Err(SignedPaymentError::StaleReservationToken(token)); } @@ -299,7 +303,7 @@ impl SignedPaymentRegistry { // build; releasing it by outpoint could free that newer reservation. // Drop the token without touching the `ReservationSet` — the original // reservation is reclaimed by key-wallet's own TTL sweep. - if reservation_expired(entry.registered_height, entry.core.synced_height().await) { + if reservation_expired(entry.registered_height, entry.core.last_processed_height().await) { return; } if let Some(account_type) = entry.account_type { @@ -337,8 +341,10 @@ impl SignedPaymentRegistry { } /// Number of outstanding (registered but not yet broadcast/released) tokens. - #[cfg(test)] - pub(crate) fn outstanding(&self) -> usize { + /// Exposed under `test-utils` so downstream FFI-layer tests (e.g. the + /// `platform_wallet_destroy` final-alias sweep) can observe registry state. + #[cfg(any(test, feature = "test-utils"))] + pub fn outstanding(&self) -> usize { self.lock().len() } } @@ -442,7 +448,11 @@ mod tests { let (wallet, info) = wm .get_wallet_and_info_mut(&core.wallet_id()) .expect("wallet present in manager"); - let current_height = info.core_wallet.synced_height(); + // Stamp the reservation with `last_processed_height` exactly as the + // production `build_signed` / `finalize_transaction` paths do, so the + // registry's age guard (which now reads the same clock) is exercised + // against a faithfully-stamped reservation. + let current_height = info.core_wallet.last_processed_height(); let (managed_account, account) = match account_type { StandardAccountType::BIP44Account => ( info.core_wallet @@ -865,15 +875,16 @@ mod tests { assert_eq!(registry.outstanding(), 16); } - /// Force the wallet's synced height forward, simulating chain progress - /// between build/register and a later broadcast/release — the window in - /// which key-wallet's `ReservationSet` TTL can sweep the funding reservation. - async fn advance_synced_height(core: &CoreWallet, height: u32) { + /// Force the wallet's `last_processed_height` forward, simulating chain + /// progress between build/register and a later broadcast/release — the window + /// in which key-wallet's `ReservationSet` TTL can sweep the funding + /// reservation. This is the same clock the registry's 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_synced_height(height); + info.core_wallet.update_last_processed_height(height); } /// Once the wallet has synced past `RESERVATION_MAX_AGE_BLOCKS` beyond the @@ -888,7 +899,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; let registry = SignedPaymentRegistry::new(); - let registered_height = core.synced_height().await.expect("synced height"); + let registered_height = core.last_processed_height().await.expect("last processed height"); let tx = build_signed_tx( &core, StandardAccountType::BIP44Account, @@ -904,7 +915,7 @@ mod tests { // Advance past the age bound but stay below key-wallet's 24-block TTL, so // the reservation is provably still held (only our guard has tripped). - advance_synced_height(&core, registered_height + RESERVATION_MAX_AGE_BLOCKS + 2).await; + advance_processed_height(&core, registered_height + RESERVATION_MAX_AGE_BLOCKS + 2).await; let sent = registry.broadcast(token, &core).await; assert!( @@ -944,7 +955,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; let registry = SignedPaymentRegistry::new(); - let registered_height = core.synced_height().await.expect("synced height"); + let registered_height = core.last_processed_height().await.expect("last processed height"); let tx = build_signed_tx( &core, StandardAccountType::BIP44Account, @@ -958,7 +969,7 @@ mod tests { .register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0) .await; - advance_synced_height(&core, registered_height + RESERVATION_MAX_AGE_BLOCKS + 2).await; + advance_processed_height(&core, registered_height + RESERVATION_MAX_AGE_BLOCKS + 2).await; registry.release(token).await; assert_eq!(registry.outstanding(), 0, "the expired token is dropped"); From 26ee8b77016872f5b65d38a4c8355f6175c5855e Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Fri, 17 Jul 2026 08:56:22 -0400 Subject: [PATCH 06/24] fix(kotlin-sdk): sweep deferred-payment tokens only when the final wallet alias is destroyed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit platform_wallet_destroy unconditionally called remove_entries_for_wallet, which matches every registry entry sharing the destroyed handle's WalletManager pointer + wallet_id. But platform_wallet_manager_get_wallet hands out an independent handle per alias of the same logical wallet (the loadPersistedWallets path can publish a new wrapper while callers still hold an older one). Destroying one alias therefore consumed a sibling alias's still-live deferred-payment token: the sibling's later broadcast failed as stale while the sweep left the UTXO reserved until its TTL. Gate the sweep on final-alias liveness: after removing this handle, scan the remaining PlatformWallet handles for one that shares the same (WalletManager pointer + wallet_id) — exactly the key remove_entries_for_wallet matches. While a sibling is live the destructor only drops this handle; the sweep runs (releasing the registry's WalletManager pin) only once the last alias goes. Adds HandleStorage::any for the scan and a test_support helper that builds real PlatformWallet aliases; a new FFI test proves a sibling alias's token survives one alias's destruction and is swept when the final alias is destroyed. Co-Authored-By: Claude Fable 5 --- packages/rs-platform-wallet-ffi/src/handle.rs | 11 ++ packages/rs-platform-wallet-ffi/src/wallet.rs | 112 ++++++++++++++++-- .../rs-platform-wallet/src/test_support.rs | 67 +++++++++++ 3 files changed, 179 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/handle.rs b/packages/rs-platform-wallet-ffi/src/handle.rs index f343e4ccc98..68e5c77dd49 100644 --- a/packages/rs-platform-wallet-ffi/src/handle.rs +++ b/packages/rs-platform-wallet-ffi/src/handle.rs @@ -71,6 +71,17 @@ impl HandleStorage { guard.get(&handle).map(f) } + /// Whether any currently-stored item satisfies `predicate`. Used to detect + /// whether a logical resource still has a live handle after one of its + /// aliases is removed (e.g. the final-alias check in + /// `platform_wallet_destroy`). + pub fn any(&self, predicate: F) -> bool + where + F: Fn(&T) -> bool, + { + self.items.read().values().any(predicate) + } + pub fn with_item_mut(&self, handle: Handle, f: F) -> Option where F: FnOnce(&mut T) -> R, diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index 7b10c6242e4..85912e1e99c 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -390,17 +390,107 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_withdraw( /// Destroy a PlatformWallet handle. #[no_mangle] pub unsafe extern "C" fn platform_wallet_destroy(handle: Handle) -> PlatformWalletFFIResult { - // Sweep any outstanding deferred-payment tokens bound to this wallet first, - // so the registry stops pinning its `WalletManager` (accounts, keys, sync - // state) alive for the rest of the process via the `CoreWallet` clone each - // token captured. Hooked here rather than into `core_wallet_destroy`: the - // deferred flow builds/registers on one short-lived core handle and - // broadcasts on another, so sweeping on core-handle destroy would drop - // tokens between register and broadcast. - PLATFORM_WALLET_STORAGE.with_item(handle, |wallet| { - crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY - .remove_entries_for_wallet(wallet.core()); + // Remove this handle first so it is excluded from the final-alias scan + // below (and so a concurrent lookup can no longer resolve it). + let Some(wallet) = PLATFORM_WALLET_STORAGE.remove(handle) else { + return PlatformWalletFFIResult::ok(); + }; + + // `platform_wallet_manager_get_wallet` hands out an independent handle for + // each alias of the same logical wallet (they share the underlying + // `WalletManager` `Arc` and `wallet_id`). A deferred-payment token minted + // through one alias must NOT be invalidated when a *sibling* alias is + // destroyed — the token is still live and broadcastable through the survivor. + // + // So only sweep the registry when THIS is the final live alias: no other + // stored handle shares the same (`WalletManager` pointer + `wallet_id`) — + // exactly the key `remove_entries_for_wallet` matches on. When a sibling is + // still live, the destructor just drops this handle, leaving its tokens + // (and the shared `WalletManager` pin) in place. Once the last alias goes, + // the sweep runs, releasing the registry's pin on the wallet's + // `WalletManager` (accounts, keys, sync state) that each token's captured + // `CoreWallet` clone would otherwise keep alive for the process lifetime. + let core = wallet.core(); + let wallet_id = core.wallet_id(); + let manager = wallet.wallet_manager(); + let sibling_alias_alive = PLATFORM_WALLET_STORAGE.any(|other| { + other.wallet_id() == wallet_id + && std::sync::Arc::ptr_eq(other.wallet_manager(), manager) }); - PLATFORM_WALLET_STORAGE.remove(handle); + if !sibling_alias_alive { + crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY + .remove_entries_for_wallet(core); + } PlatformWalletFFIResult::ok() } + +#[cfg(test)] +mod destroy_tests { + use super::*; + use crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY; + use key_wallet::account::account_type::StandardAccountType; + use platform_wallet::test_support::test_platform_wallet_manager; + + fn dummy_tx() -> dashcore::Transaction { + dashcore::Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + } + } + + /// Destroying one alias handle of a logical wallet must NOT invalidate a + /// deferred-payment token registered against a sibling alias: the sweep runs + /// only when the FINAL alias is destroyed. Proves the + /// `platform_wallet_destroy` final-alias gating. + #[test] + fn destroying_one_alias_keeps_a_siblings_token() { + runtime().block_on(async { + let (manager, wallet_id) = test_platform_wallet_manager().await; + + // Two independent handles for the SAME logical wallet, exactly as two + // `platform_wallet_manager_get_wallet` calls would hand out. + let alias_a = manager.get_wallet(&wallet_id).await.expect("alias a"); + let alias_b = manager.get_wallet(&wallet_id).await.expect("alias b"); + let core = alias_a.core().clone(); + let handle_a = PLATFORM_WALLET_STORAGE.insert(alias_a); + let handle_b = PLATFORM_WALLET_STORAGE.insert(alias_b); + + // Register a deferred-payment token (the process-global registry is + // shared, so reason about deltas against a captured baseline). + let baseline = SIGNED_PAYMENT_REGISTRY.outstanding(); + let _token = SIGNED_PAYMENT_REGISTRY + .register( + core.clone(), + dummy_tx(), + Some(StandardAccountType::BIP44Account), + 0, + ) + .await; + assert_eq!(SIGNED_PAYMENT_REGISTRY.outstanding(), baseline + 1); + + // Destroy alias A while B is still live → token must survive. + let result = unsafe { platform_wallet_destroy(handle_a) }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!( + SIGNED_PAYMENT_REGISTRY.outstanding(), + baseline + 1, + "a sibling alias's token must survive destroying another alias" + ); + + // Destroy the final alias B → now the token is swept. + let result = unsafe { platform_wallet_destroy(handle_b) }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!( + SIGNED_PAYMENT_REGISTRY.outstanding(), + baseline, + "destroying the final alias must sweep the wallet's tokens" + ); + + // Keep the manager alive until the end (owns the wallet + adapter). + drop(manager); + }); + } +} diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 8fd146dc77c..3526d2d68e4 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -277,3 +277,70 @@ pub async fn funded_spv_core_wallet( signer, ) } + +/// No-op persister satisfying [`PlatformWalletManager`] construction for tests +/// that need a full [`PlatformWallet`] but no real persistence pipeline. +pub struct NoopTestPersister; + +impl crate::changeset::PlatformWalletPersistence for NoopTestPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: crate::changeset::PlatformWalletChangeSet, + ) -> Result<(), crate::changeset::PersistenceError> { + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), crate::changeset::PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + Ok(crate::changeset::ClientStartState::default()) + } +} + +struct NoopTestEventHandler; +impl crate::events::EventHandler for NoopTestEventHandler {} +impl crate::events::PlatformEventHandler for NoopTestEventHandler {} + +/// Build a full [`PlatformWallet`] over a mock SDK and a no-op persister, wired +/// through a real [`PlatformWalletManager`] so its `wallet_manager` `Arc` and +/// `wallet_id` are production-shaped. Returns the manager (which the caller must +/// keep alive — it owns the wallet-event adapter task and the registered +/// `Arc`) alongside the wallet id. +/// +/// Used by FFI-layer tests that need genuine `PlatformWallet` aliases, e.g. the +/// `platform_wallet_destroy` final-alias registry-sweep gating. +pub async fn test_platform_wallet_manager( +) -> (Arc>, WalletId) { + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + + // Canonical all-`abandon` BIP-39 test vector. + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let persister = Arc::new(NoopTestPersister); + let event_handler: Arc = + Arc::new(NoopTestEventHandler); + let manager = Arc::new(crate::PlatformWalletManager::new(sdk, persister, event_handler)); + + let mnemonic = + Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let seed_bytes = mnemonic.to_seed(""); + // `Some(0)` skips the SPV birth-height lookup so the create never hits the + // network. + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed_bytes, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("create test wallet"); + let wallet_id = wallet.wallet_id(); + (manager, wallet_id) +} From 32cd702fbe937f72e1be18d258c322403aa3eeae Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Fri, 17 Jul 2026 08:56:43 -0400 Subject: [PATCH 07/24] fix(kotlin-sdk): route deferred builds through the atomic finalize-and-register path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildSignedPayment funded, signed, and registered a deferred payment as three separate native round-trips (setFunding + buildSigned + registerSignedPayment). Once the base branch removed the per-wallet coreSendMutex in favour of the TeardownGate — which only counts active ops for safe teardown and does not serialize sends — that split lost its atomic select-and-reserve boundary: two concurrent deferred builds, or a deferred build racing an immediate send, could select the same UTXO before either reserved it and return two signed transactions spending the same input. Restore atomicity in the Rust reservation layer, the correct home now that the Kotlin mutex is gone: add core_wallet_signed_payment_finalize, which runs the same finalize_transaction the immediate V2 path uses — selection and ReservationSet insertion commit as one unit under the wallet-manager lock, signing only after the lock drops — and then registers the built, reserved tx in the same call. buildSignedPayment now issues that single native operation (CoreTransactionBuilder.finalizeSignedPayment + coreWalletFinalizeSignedPayment), so the select+reserve window can no longer interleave. The existing concurrent_same_account_finalizers_cannot_reserve_the_same_input test already covers the atomic boundary the deferred path now shares. The deprecated split wrappers remain but are no longer on the deferred path. Co-Authored-By: Claude Fable 5 --- .../dashsdk/ffi/WalletManagerNative.kt | 23 +++ .../dashsdk/wallet/CoreTransactionBuilder.kt | 32 +++++ .../dashsdk/wallet/ManagedCoreWallet.kt | 16 +-- .../dashsdk/wallet/ManagedPlatformWallet.kt | 61 +++++--- .../src/core_wallet/transaction_builder.rs | 129 +++++++++++++++++ .../rs-unified-sdk-jni/src/wallet_manager.rs | 133 +++++++++++++++++- 6 files changed, 360 insertions(+), 34 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt index 54f2cd11688..ff5a2eb647f 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt @@ -265,6 +265,29 @@ internal object WalletManagerNative { accountIndex: Int, ): ByteArray + /** + * `core_wallet_signed_payment_finalize` — atomically fund, reserve, sign, + * AND register a builder for deferred (BIP70/BIP270) submission in one + * native call. The concurrency-safe replacement for + * [coreTxBuilderSetFunding] + [coreTxBuilderBuildSigned] + + * [coreWalletRegisterSignedPayment]: selection and reservation commit as a + * single unit under the wallet-manager lock, closing the double-selection + * window. CONSUMES [builder]. [accountType]/[accountIndex] identify the + * funding account (0 BIP44, 1 BIP32, 2 CoinJoin); [coreSignerHandle] is a + * `MnemonicResolverHandle`. + * + * Returns the same big-endian BLOB [coreWalletRegisterSignedPayment] + * returns, decoded into a `SignedCoreTransaction`: + * `u64 token, u64 feeDuffs, u32 txidLen, txid utf8, u32 txBytesLen, txBytes`. + */ + external fun coreWalletFinalizeSignedPayment( + builder: Long, + walletHandle: Long, + accountType: Int, + accountIndex: Int, + coreSignerHandle: Long, + ): ByteArray + /** * `core_wallet_signed_payment_broadcast` — broadcast the payment behind * [token], reconciling its reservation on failure and consuming the token. diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/CoreTransactionBuilder.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/CoreTransactionBuilder.kt index 9a988601d30..df72543f231 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/CoreTransactionBuilder.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/CoreTransactionBuilder.kt @@ -159,6 +159,38 @@ class CoreTransactionBuilder internal constructor(network: Network) : AutoClosea return FinalizedCoreTransaction(transaction, fee) } + /** + * Consume this configured builder and, in ONE atomic native operation, + * select + reserve + sign the inputs and register the built transaction for + * deferred (BIP70/BIP270) submission. The concurrency-safe replacement for + * the deprecated [setFunding] + [buildSigned] + register split: selection + * and reservation commit as a single unit under the wallet-manager lock, so + * concurrent deferred builds cannot double-select an input. Returns the + * decoded [ManagedPlatformWallet.SignedCoreTransaction]. + */ + internal fun finalizeSignedPayment( + wallet: ManagedPlatformWallet, + accountType: AccountType, + accountIndex: Int, + coreSignerHandle: Long, + ): ManagedPlatformWallet.SignedCoreTransaction { + require(accountIndex >= 0) { "accountIndex must be non-negative" } + require(coreSignerHandle != 0L) { "coreSignerHandle must be non-zero" } + // Validate every borrowed dependency before transferring builder + // ownership. Once getAndSet(0) runs, JNI consumes the native builder. + val walletHandle = wallet.handle + val builderPtr = handleRef.getAndSet(0) + check(builderPtr != 0L) { "CoreTransactionBuilder has been consumed or closed" } + val blob = WalletManagerNative.coreWalletFinalizeSignedPayment( + builderPtr, + walletHandle, + accountType.ffiValue, + accountIndex, + coreSignerHandle, + ) + return ManagedPlatformWallet.SignedCoreTransaction.fromRegisterBlob(blob) + } + override fun close() { cleanable.clean() } 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 6961c3a093f..75f99b8d57e 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 @@ -71,21 +71,7 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { tx.accountType.ffiValue, tx.accountIndex, ) - val buffer = java.nio.ByteBuffer.wrap(blob) // big-endian by default - val token = buffer.long - val feeDuffs = buffer.long - val txidLen = buffer.int - val txidBytes = ByteArray(txidLen) - buffer.get(txidBytes) - val txBytesLen = buffer.int - val rawTxBytes = ByteArray(txBytesLen) - buffer.get(rawTxBytes) - return ManagedPlatformWallet.SignedCoreTransaction( - txidHex = String(txidBytes, Charsets.UTF_8), - rawTxBytes = rawTxBytes, - feeDuffs = feeDuffs, - reservationToken = token, - ) + return ManagedPlatformWallet.SignedCoreTransaction.fromRegisterBlob(blob) } /** diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt index 023f16716fd..971635e2ebe 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt @@ -212,6 +212,32 @@ class ManagedPlatformWallet internal constructor( override fun toString(): String = "SignedCoreTransaction(txidHex=$txidHex, feeDuffs=$feeDuffs, " + "reservationToken=$reservationToken, rawTxBytes=${rawTxBytes.size} bytes)" + + internal companion object { + /** + * Decode the big-endian native BLOB the deferred build/register FFI + * returns: `u64 token, u64 feeDuffs, u32 txidLen, txid utf8, + * u32 txBytesLen, txBytes`. Shared by the atomic + * finalize-and-register path and the deprecated register path. + */ + internal fun fromRegisterBlob(blob: ByteArray): SignedCoreTransaction { + val buffer = java.nio.ByteBuffer.wrap(blob) // big-endian by default + val token = buffer.long + val feeDuffs = buffer.long + val txidLen = buffer.int + val txidBytes = ByteArray(txidLen) + buffer.get(txidBytes) + val txBytesLen = buffer.int + val rawTxBytes = ByteArray(txBytesLen) + buffer.get(rawTxBytes) + return SignedCoreTransaction( + txidHex = String(txidBytes, Charsets.UTF_8), + rawTxBytes = rawTxBytes, + feeDuffs = feeDuffs, + reservationToken = token, + ) + } + } } /** @@ -255,25 +281,24 @@ class ManagedPlatformWallet internal constructor( AccountType.BIP32 -> CoreTransactionBuilder.AccountType.BIP32 } mapNativeErrors { - coreWallet().use { core -> - val builder = CoreTransactionBuilder(network) - // `buildSigned` consumes the builder; `use` still safely - // destroys it on the pre-build failure paths. - val signedTx = builder.use { - for ((address, amount) in recipients) { - it.addOutput(address, amount) - } - it.setFunding(this@ManagedPlatformWallet, builderAccountType, accountIndex) - it.buildSigned( - this@ManagedPlatformWallet, - builderAccountType, - accountIndex, - coreSignerHandle, - ) + // One atomic native operation: select + reserve + sign + register. + // `finalizeSignedPayment` consumes the builder on every path, so + // `use` only needs to destroy it on the pre-finalize failure paths + // (adding outputs). Selection and reservation commit as a single unit + // under the wallet-manager lock, so a concurrent deferred build — or a + // deferred build racing an immediate send — can no longer double- + // select the same input, restoring the atomicity the removed Kotlin + // per-wallet send mutex used to provide. + CoreTransactionBuilder(network).use { builder -> + for ((address, amount) in recipients) { + builder.addOutput(address, amount) } - // Register the signed tx (holding its reservation) before the - // native transaction is freed; `use` frees it afterward. - signedTx.use { tx -> core.registerSignedPayment(tx) } + builder.finalizeSignedPayment( + this@ManagedPlatformWallet, + builderAccountType, + accountIndex, + coreSignerHandle, + ) } } } diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs index 3e30447cf59..7bf823aeea7 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs @@ -17,6 +17,7 @@ use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBui use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle}; +use std::ffi::CString; use std::os::raw::{c_char, c_void}; use std::str::FromStr; @@ -139,6 +140,134 @@ pub unsafe extern "C" fn core_wallet_tx_builder_finalize( PlatformWalletFFIResult::ok() } +/// Atomically fund, reserve, and sign a configured builder for DEFERRED +/// (BIP70/BIP270) submission, then register the built transaction — holding its +/// UTXO reservation — in one native operation. +/// +/// This is the deferred counterpart to `core_wallet_tx_builder_finalize`: it +/// runs the same atomic `finalize_transaction`, where selection and insertion +/// into the account `ReservationSet` commit as a single unit under the +/// wallet-manager lock (signing happens after the lock is dropped). Routing the +/// deferred build through it closes the double-selection window that the +/// deprecated `set_funding` + `build_signed` + `register` sequence reopened once +/// the Kotlin per-wallet send mutex was removed: two concurrent deferred builds, +/// or a deferred build racing an immediate send, can no longer select the same +/// UTXO. Consumes `builder` on every path after its pointer is accepted. +/// +/// Writes `out_token` (the reservation token for a later +/// `core_wallet_signed_payment_broadcast` / `core_wallet_signed_payment_release`), +/// `out_fee` (the build's fee in duffs), `out_txid` (a heap C string freed with +/// `core_wallet_free_address`), and `out_tx` (an owned `FFICoreTransaction` +/// carrying the consensus-serialized bytes, freed with +/// `core_wallet_transaction_free`). `out_bytes_ptr`/`out_bytes_len` borrow +/// `out_tx`'s buffer — copy them out before freeing `out_tx`. +/// +/// # Safety +/// `builder` must be a valid, non-destroyed pointer; `wallet` a valid +/// platform-wallet handle; `core_signer_handle` a valid resolver handle; every +/// out-pointer must be writable. `out_tx` must point at writable storage for one +/// `FFICoreTransaction` (typically zeroed). +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn core_wallet_signed_payment_finalize( + builder: *mut FFITransactionBuilder, + wallet: Handle, + account_type: CoreAccountTypeFFI, + account_index: u32, + core_signer_handle: *mut MnemonicResolverHandle, + out_token: *mut u64, + out_fee: *mut u64, + out_txid: *mut *mut c_char, + out_tx: *mut FFICoreTransaction, + out_bytes_ptr: *mut *const u8, + out_bytes_len: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(builder); + check_ptr!(core_signer_handle); + check_ptr!(out_token); + check_ptr!(out_fee); + check_ptr!(out_txid); + check_ptr!(out_tx); + check_ptr!(out_bytes_ptr); + check_ptr!(out_bytes_len); + *out_token = 0; + + // `finalize_transaction` consumes the builder: reclaim both heap boxes up + // front so they are freed on every return path below. + let ffi = Box::from_raw(builder); + let inner = *Box::from_raw(ffi.inner as *mut TransactionBuilder); + + let wallet = unwrap_option_or_return!(PLATFORM_WALLET_STORAGE.with_item(wallet, |w| w.clone())); + + let builder_network: Network = ffi.network.into(); + if builder_network != wallet.network() { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "builder network does not match wallet network".to_string(), + ); + } + + let signer = + MnemonicResolverCoreSigner::new(core_signer_handle, wallet.wallet_id(), wallet.network()); + + // Atomic select + reserve + sign in one wallet-manager critical section. + let finalized = runtime().block_on(wallet.core().finalize_transaction( + inner, + account_type.into(), + account_index, + &signer, + )); + let finalized = unwrap_result_or_return!(finalized); + + let txid = finalized.transaction().txid(); + let fee = finalized.fee(); + + // Do the one fallible marshalling step BEFORE the registry insert: that + // insert mints a token and keeps the funding reservation held, so a later + // failure would orphan the reservation with no token to release it. txid hex + // never contains a NUL, but handle the impossible case anyway. + let c_txid = match CString::new(txid.to_string()) { + Ok(s) => s, + Err(_) => { + // Nothing registered yet — release the reservation finalize took. + runtime().block_on(wallet.core().abandon_transaction(&finalized)); + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorUtf8Conversion, + "txid string contained an interior NUL".to_string(), + ); + } + }; + + let serialized = dashcore::consensus::serialize(finalized.transaction()); + let len = serialized.len(); + + // Register the reserved+signed tx for deferred submission. `finalize` already + // committed the reservation; register just takes ownership of the built tx so + // a later broadcast/release can reconcile it, capturing the wallet instance + // whose `ReservationSet` holds the inputs. + let token = + runtime().block_on(crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY.register( + wallet.core().clone(), + finalized.transaction().clone(), + account_type.as_standard_account_type(), + account_index, + )); + + *out_tx = FFICoreTransaction { + tx_bytes: Box::into_raw(serialized.into_boxed_slice()) as *mut u8, + tx_len: len, + fee, + }; + *out_token = token; + *out_fee = fee; + *out_txid = c_txid.into_raw(); + // Borrowed view into the just-written `out_tx` buffer; the caller copies the + // bytes out before freeing `out_tx` with `core_wallet_transaction_free`. + *out_bytes_ptr = (*out_tx).tx_bytes as *const u8; + *out_bytes_len = len; + PlatformWalletFFIResult::ok() +} + impl CoreAccountTypeFFI { /// The `StandardAccountType` this maps to, or `None` for `CoinJoin`. /// diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index cdc3d6a13d0..82f33253f24 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1335,10 +1335,141 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c }) } +/// `core_wallet_signed_payment_finalize` — atomically fund, reserve, sign, and +/// register a builder for deferred (BIP70/BIP270) submission in ONE native +/// operation. This is the concurrency-safe replacement for the deprecated +/// `coreTxBuilderSetFunding` + `coreTxBuilderBuildSigned` + +/// `coreWalletRegisterSignedPayment` sequence: selection and reservation commit +/// as a single unit under the wallet-manager lock, so concurrent deferred builds +/// (or a deferred build racing an immediate send) can no longer double-select an +/// input. CONSUMES [builder]. `accountType`/`accountIndex` are the funding +/// account (0 BIP44, 1 BIP32, 2 CoinJoin); [coreSignerHandle] is a +/// `MnemonicResolverHandle`. +/// +/// Returns the same big-endian BLOB `coreWalletRegisterSignedPayment` returns, +/// decoded into a `SignedCoreTransaction`: +/// `u64 token, u64 feeDuffs, u32 txidLen, txid utf8, u32 txBytesLen, txBytes`. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreWalletFinalizeSignedPayment( + mut env: JNIEnv, + _class: JClass, + builder: jlong, + wallet_handle: jlong, + account_type: jni::sys::jint, + account_index: jni::sys::jint, + core_signer_handle: jlong, +) -> jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + if builder == 0 { + throw_sdk_exception(env, 1, "builder handle must be non-zero"); + return ptr::null_mut(); + } + // From here JNI owns the builder. Any pre-call boundary validation must + // destroy it, because Kotlin has already zeroed its owner token. + let destroy_builder = || unsafe { + platform_wallet_ffi::core_wallet_tx_builder_destroy( + builder as *mut platform_wallet_ffi::FFITransactionBuilder, + ) + }; + if wallet_handle == 0 || core_signer_handle == 0 { + destroy_builder(); + throw_sdk_exception(env, 1, "wallet and signer handles must be non-zero"); + return ptr::null_mut(); + } + let Some(account_type) = core_account_type(account_type) else { + destroy_builder(); + throw_sdk_exception(env, 1, "accountType out of range (expected 0..=2)"); + return ptr::null_mut(); + }; + if account_index < 0 { + destroy_builder(); + throw_sdk_exception(env, 1, "accountIndex must be non-negative"); + return ptr::null_mut(); + } + + // Own an out `FFICoreTransaction` on the heap; its fields are private to + // the FFI crate, so allocate it zeroed and let the FFI fill it in place. + let mut boxed: Box> = + Box::new(std::mem::MaybeUninit::zeroed()); + let out_tx = boxed.as_mut_ptr().cast::(); + + let mut token: u64 = 0; + let mut fee: u64 = 0; + let mut out_txid: *mut c_char = ptr::null_mut(); + let mut out_bytes_ptr: *const u8 = ptr::null(); + let mut out_bytes_len: usize = 0; + let result = unsafe { + platform_wallet_ffi::core_wallet_signed_payment_finalize( + builder as *mut platform_wallet_ffi::FFITransactionBuilder, + wallet_handle as Handle, + account_type, + account_index as u32, + core_signer_handle as *mut rs_sdk_ffi::MnemonicResolverHandle, + &mut token as *mut u64, + &mut fee as *mut u64, + &mut out_txid as *mut *mut c_char, + out_tx, + &mut out_bytes_ptr as *mut *const u8, + &mut out_bytes_len as *mut usize, + ) + }; + if take_pwffi_error(env, result) { + // The FFI freed the builder on the error path and left the out struct + // zeroed (null tx_bytes); dropping `boxed` frees only the box. + return ptr::null_mut(); + } + if out_txid.is_null() { + unsafe { platform_wallet_ffi::core_wallet_transaction_free(out_tx) }; + throw_sdk_exception(env, 1, "finalize returned a NULL txid"); + return ptr::null_mut(); + } + + // Copy the txid out, then free the Rust-owned C string. + let txid = unsafe { CStr::from_ptr(out_txid) } + .to_string_lossy() + .into_owned(); + unsafe { platform_wallet_ffi::core_wallet_free_address(out_txid) }; + + // Copy the raw tx bytes (they borrow the still-live `out_tx` buffer). + let tx_bytes: &[u8] = if out_bytes_ptr.is_null() || out_bytes_len == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(out_bytes_ptr, out_bytes_len) } + }; + + // Assemble the big-endian BLOB (matches the register decoder). + let txid_bytes = txid.into_bytes(); + let mut blob = Vec::with_capacity(8 + 8 + 4 + txid_bytes.len() + 4 + tx_bytes.len()); + blob.extend_from_slice(&token.to_be_bytes()); + blob.extend_from_slice(&fee.to_be_bytes()); + blob.extend_from_slice(&(txid_bytes.len() as u32).to_be_bytes()); + blob.extend_from_slice(&txid_bytes); + blob.extend_from_slice(&(tx_bytes.len() as u32).to_be_bytes()); + blob.extend_from_slice(tx_bytes); + let out = match env.byte_array_from_slice(&blob) { + Ok(array) => array.into_raw(), + Err(_) => { + // The registration already committed and is holding the funding + // reservation; release the token so it isn't orphaned to the TTL + // backstop when Kotlin never receives it. + let _ = + unsafe { platform_wallet_ffi::core_wallet_signed_payment_release(token) }; + ptr::null_mut() + } + }; + + // Free the tx bytes now that they are copied into the blob; `boxed` frees + // the outer box on scope exit. + unsafe { platform_wallet_ffi::core_wallet_transaction_free(out_tx) }; + out + }) +} + /// `core_wallet_signed_payment_broadcast` — broadcast the payment behind /// `token`, releasing/keeping its reservation per the broadcast outcome and /// consuming the token. A repeated/stale token throws (native -/// `ErrorStaleReservationToken`, code 22) rather than double-broadcasting. +/// `ErrorStaleReservationToken`, code 26) rather than double-broadcasting. /// `coreHandle` must resolve to the wallet the token was minted against. /// Returns the txid as a lowercase hex string. #[no_mangle] From 3cefea9e11a97f9b8c6e823d4058067353f83a42 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:12:16 -0400 Subject: [PATCH 08/24] =?UTF-8?q?fix(kotlin-sdk):=20delete=20the=20dead=20?= =?UTF-8?q?split=20register=E2=86=92broadcast=20chain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After finalize routing landed, the four-layer deferred-register chain core_wallet_signed_payment_register (FFI) → coreWalletRegisterSignedPayment (JNI) → WalletManagerNative.coreWalletRegisterSignedPayment (Kotlin) → ManagedCoreWallet.registerSignedPayment had zero callers. It is the unsafe variant whose age guard baselines registered_height at registration time (after external signing) rather than at the reservation's own height, so removing it also removes that mis-baselined path. The atomic core_wallet_signed_payment_finalize path is the only remaining register site. Delete all four layers; repoint the surviving broadcast/finalize doc comments at the finalize entry point; drop the now-unused FFICoreTransaction::fee accessor (keep the ABI field, silence the lint). Co-Authored-By: Claude Fable 5 --- .../dashsdk/ffi/WalletManagerNative.kt | 32 +---- .../dashsdk/wallet/ManagedCoreWallet.kt | 19 --- .../dashsdk/wallet/ManagedPlatformWallet.kt | 7 +- .../src/core_wallet/signed_payment.rs | 95 +------------- .../src/core_wallet/transaction_builder.rs | 8 +- .../rs-unified-sdk-jni/src/wallet_manager.rs | 118 ++---------------- 6 files changed, 23 insertions(+), 256 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt index ff5a2eb647f..afdd19873ae 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt @@ -246,38 +246,16 @@ internal object WalletManagerNative { */ external fun coreTransactionFree(tx: Long) - /** - * `core_wallet_signed_payment_register` — register a built+signed - * transaction (from [coreTxBuilderBuildSigned]) for deferred - * (BIP70/BIP270) submission, holding its UTXO reservation. Does NOT consume - * the transaction — free it separately with [coreTransactionFree]. - * [accountType]/[accountIndex] identify the funding account (0 BIP44, - * 1 BIP32, 2 CoinJoin). - * - * Returns a big-endian BLOB decoded into a `SignedCoreTransaction`: - * `u64 token, u64 feeDuffs, u32 txidLen, txid utf8, u32 txBytesLen, txBytes`. - * The raw tx bytes come back in this same call — no second native round trip. - */ - external fun coreWalletRegisterSignedPayment( - coreHandle: Long, - tx: Long, - accountType: Int, - accountIndex: Int, - ): ByteArray - /** * `core_wallet_signed_payment_finalize` — atomically fund, reserve, sign, * AND register a builder for deferred (BIP70/BIP270) submission in one - * native call. The concurrency-safe replacement for - * [coreTxBuilderSetFunding] + [coreTxBuilderBuildSigned] + - * [coreWalletRegisterSignedPayment]: selection and reservation commit as a - * single unit under the wallet-manager lock, closing the double-selection - * window. CONSUMES [builder]. [accountType]/[accountIndex] identify the - * funding account (0 BIP44, 1 BIP32, 2 CoinJoin); [coreSignerHandle] is a + * native call. Selection and reservation commit as a single unit under the + * wallet-manager lock, closing the double-selection window. CONSUMES + * [builder]. [accountType]/[accountIndex] identify the funding account + * (0 BIP44, 1 BIP32, 2 CoinJoin); [coreSignerHandle] is a * `MnemonicResolverHandle`. * - * Returns the same big-endian BLOB [coreWalletRegisterSignedPayment] - * returns, decoded into a `SignedCoreTransaction`: + * Returns a big-endian BLOB decoded into a `SignedCoreTransaction`: * `u64 token, u64 feeDuffs, u32 txidLen, txid utf8, u32 txBytesLen, txBytes`. */ external fun coreWalletFinalizeSignedPayment( 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 75f99b8d57e..a06e65520cf 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 @@ -55,25 +55,6 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { ) } - /** - * Register a built+signed [tx] for deferred (BIP70/BIP270) submission, - * holding its UTXO reservation, and return the resulting - * [ManagedPlatformWallet.SignedCoreTransaction]. Does NOT consume [tx] — the - * caller still closes it. Decodes the single register BLOB - * (`token, feeDuffs, txid, rawTxBytes`) — one native round trip. - */ - internal fun registerSignedPayment( - tx: CoreTransaction, - ): ManagedPlatformWallet.SignedCoreTransaction { - val blob = WalletManagerNative.coreWalletRegisterSignedPayment( - handle, - tx.handle, - tx.accountType.ffiValue, - tx.accountIndex, - ) - return ManagedPlatformWallet.SignedCoreTransaction.fromRegisterBlob(blob) - } - /** * Broadcast the deferred payment behind [token] and return its txid. A * stale / already-broadcast / wrong-wallet token surfaces as diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt index 971635e2ebe..9e4152bea46 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt @@ -215,10 +215,9 @@ class ManagedPlatformWallet internal constructor( internal companion object { /** - * Decode the big-endian native BLOB the deferred build/register FFI - * returns: `u64 token, u64 feeDuffs, u32 txidLen, txid utf8, - * u32 txBytesLen, txBytes`. Shared by the atomic - * finalize-and-register path and the deprecated register path. + * Decode the big-endian native BLOB the atomic + * finalize-and-register FFI returns: `u64 token, u64 feeDuffs, + * u32 txidLen, txid utf8, u32 txBytesLen, txBytes`. */ internal fun fromRegisterBlob(blob: ByteArray): SignedCoreTransaction { val buffer = java.nio.ByteBuffer.wrap(blob) // big-endian by default diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs index 9fce7b11c5f..6d335375658 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs @@ -15,7 +15,6 @@ //! `core_wallet_broadcast_transaction` surface — the immediate send path is //! unchanged. -use super::transaction_builder::{CoreAccountTypeFFI, FFICoreTransaction}; use crate::error::*; use crate::handle::{Handle, CORE_WALLET_STORAGE}; use crate::runtime::runtime; @@ -33,99 +32,9 @@ use std::os::raw::c_char; pub(crate) static SIGNED_PAYMENT_REGISTRY: Lazy> = Lazy::new(SignedPaymentRegistry::new); -/// Register a built, signed transaction for deferred submission and return a -/// reservation token. -/// -/// `core_wallet_tx_builder_build_signed` already reserved the funding UTXOs; the -/// registry takes its own copy of the transaction and holds the reservation -/// (via the captured wallet instance behind `core_handle`) until a later -/// [`core_wallet_signed_payment_broadcast`] or -/// [`core_wallet_signed_payment_release`]. The passed `tx` is NOT consumed — the -/// caller still frees it with `core_wallet_transaction_free`. -/// -/// `account_type`/`account_index` identify the funding account handed to -/// `set_funding`, so the reservation can be released on rejection/abandonment. -/// Writes `out_token`, `out_fee` (the build's fee in duffs), `out_txid` (a -/// heap-allocated lowercase-hex C string the caller frees with -/// `core_wallet_free_address`), and `out_bytes_ptr`/`out_bytes_len` (the -/// consensus-serialized transaction bytes, returned in the same call so the -/// caller needs no second native round trip). -/// -/// The `out_bytes_ptr` buffer borrows the `FFICoreTransaction`'s own storage — -/// it is valid only until `tx` is freed with `core_wallet_transaction_free`, so -/// the caller must copy the bytes out immediately and must not retain the -/// pointer. -/// -/// # Safety -/// `tx` must be a valid, non-freed `FFICoreTransaction`; `core_handle` a valid -/// core-wallet handle; all out-pointers must be writable. -#[no_mangle] -pub unsafe extern "C" fn core_wallet_signed_payment_register( - core_handle: Handle, - tx: *const FFICoreTransaction, - account_type: CoreAccountTypeFFI, - account_index: u32, - out_token: *mut u64, - out_fee: *mut u64, - out_txid: *mut *mut c_char, - out_bytes_ptr: *mut *const u8, - out_bytes_len: *mut usize, -) -> PlatformWalletFFIResult { - check_ptr!(tx); - check_ptr!(out_token); - check_ptr!(out_fee); - check_ptr!(out_txid); - check_ptr!(out_bytes_ptr); - check_ptr!(out_bytes_len); - - let core = unwrap_option_or_return!(CORE_WALLET_STORAGE.with_item(core_handle, |w| w.clone())); - - let bytes = (*tx).bytes(); - let transaction: dashcore::Transaction = match dashcore::consensus::deserialize(bytes) { - Ok(t) => t, - Err(e) => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorDeserialization, - format!("failed to deserialize signed transaction: {e}"), - ); - } - }; - let txid = transaction.txid(); - let fee = (*tx).fee(); - - // Do all fallible/pure marshalling BEFORE the registry insert — that insert - // mints a token and holds the funding reservation, so a later failure would - // orphan the reservation with no token to release it. txid hex never - // contains a NUL, but handle the impossible case anyway. - let c_txid = match CString::new(txid.to_string()) { - Ok(s) => s, - Err(_) => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorUtf8Conversion, - "txid string contained an interior NUL".to_string(), - ); - } - }; - - let token = runtime().block_on(SIGNED_PAYMENT_REGISTRY.register( - core, - transaction, - account_type.as_standard_account_type(), - account_index, - )); - - *out_token = token; - *out_fee = fee; - *out_txid = c_txid.into_raw(); - // Borrowed view into the still-live `tx` buffer; the caller copies it out - // before freeing `tx` (mirrors the retired `core_wallet_transaction_get_bytes`). - *out_bytes_ptr = bytes.as_ptr(); - *out_bytes_len = bytes.len(); - PlatformWalletFFIResult::ok() -} - /// Broadcast the payment behind `token` (built earlier via -/// [`core_wallet_signed_payment_register`]), reconciling its UTXO reservation on +/// [`core_wallet_signed_payment_finalize`](super::transaction_builder::core_wallet_signed_payment_finalize)), +/// reconciling its UTXO reservation on /// failure, and consume the token. /// /// The token is consumed atomically before the send, so a repeated or diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs index 7bf823aeea7..efa919da4a0 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs @@ -40,6 +40,9 @@ pub struct FFITransactionBuilder { pub struct FFICoreTransaction { tx_bytes: *mut u8, tx_len: usize, + // Part of the C ABI (the Swift host reads `FFICoreTransaction.fee`); the + // Rust side only writes it, so silence the never-read lint. + #[allow(dead_code)] fee: u64, } @@ -60,11 +63,6 @@ impl FFICoreTransaction { unsafe { std::slice::from_raw_parts(self.tx_bytes, self.tx_len) } } } - - /// The fee (duffs) `build_signed` computed for this transaction. - pub(crate) fn fee(&self) -> u64 { - self.fee - } } #[derive(Clone, Copy)] diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 82f33253f24..8baa7ee9742 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1235,119 +1235,21 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c // ── Deferred build → broadcast/release core-send (BIP70/BIP270) ─────── // // ADDITIVE surface over the immediate `coreWalletBroadcastTransaction` path: -// a signed transaction built by [coreTxBuilderBuildSigned] can be registered -// (reserving its UTXOs), its raw bytes handed to a merchant server, and only -// then broadcast on ack — or its reservation released on nack/abandonment. -// Backed by the process-global registry in `platform_wallet_ffi` +// [coreWalletFinalizeSignedPayment] atomically funds, reserves, signs, and +// registers a builder in one native call, returning the raw bytes to hand to a +// merchant server; the reservation is then broadcast on ack — or released on +// nack/abandonment. Backed by the process-global registry in `platform_wallet_ffi` // (`core_wallet_signed_payment_*`). See `SignedPaymentRegistry`. -/// `core_wallet_signed_payment_register` — register a built+signed transaction -/// (from [coreTxBuilderBuildSigned]) for deferred submission, holding its UTXO -/// reservation. `accountType`/`accountIndex` are the funding account (0 BIP44, -/// 1 BIP32, 2 CoinJoin). The passed `tx` is NOT consumed — free it separately -/// with [coreTransactionFree]. -/// -/// Returns a big-endian BLOB the Kotlin side decodes into a -/// `SignedCoreTransaction`: -/// `u64 token, u64 feeDuffs, u32 txidLen, txid utf8, u32 txBytesLen, txBytes`. -/// The raw tx bytes come back in this same call (no second native round trip). -#[no_mangle] -pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreWalletRegisterSignedPayment( - mut env: JNIEnv, - _class: JClass, - core_handle: jlong, - tx: jlong, - account_type: jni::sys::jint, - account_index: jni::sys::jint, -) -> jbyteArray { - guard(&mut env, ptr::null_mut(), |env| { - if tx == 0 { - throw_sdk_exception(env, 1, "transaction handle is 0"); - return ptr::null_mut(); - } - let Some(account_type) = core_account_type(account_type) else { - throw_sdk_exception(env, 1, "accountType out of range (expected 0..=2)"); - return ptr::null_mut(); - }; - if account_index < 0 { - throw_sdk_exception(env, 1, "accountIndex must be non-negative"); - return ptr::null_mut(); - } - - let mut token: u64 = 0; - let mut fee: u64 = 0; - let mut out_txid: *mut c_char = ptr::null_mut(); - let mut out_bytes_ptr: *const u8 = ptr::null(); - let mut out_bytes_len: usize = 0; - let result = unsafe { - platform_wallet_ffi::core_wallet_signed_payment_register( - core_handle as Handle, - tx as *const platform_wallet_ffi::FFICoreTransaction, - account_type, - account_index as u32, - &mut token as *mut u64, - &mut fee as *mut u64, - &mut out_txid as *mut *mut c_char, - &mut out_bytes_ptr as *mut *const u8, - &mut out_bytes_len as *mut usize, - ) - }; - if take_pwffi_error(env, result) { - return ptr::null_mut(); - } - if out_txid.is_null() { - throw_sdk_exception(env, 1, "register returned a NULL txid"); - return ptr::null_mut(); - } - // Copy the txid out, then free the Rust-owned C string. - let txid = unsafe { CStr::from_ptr(out_txid) } - .to_string_lossy() - .into_owned(); - unsafe { platform_wallet_ffi::core_wallet_free_address(out_txid) }; - - // Copy the raw tx bytes immediately: the pointer borrows the still-live - // transaction's own buffer. - let tx_bytes: &[u8] = if out_bytes_ptr.is_null() || out_bytes_len == 0 { - &[] - } else { - unsafe { std::slice::from_raw_parts(out_bytes_ptr, out_bytes_len) } - }; - - // Assemble the big-endian BLOB (matches the Kotlin ByteBuffer decoder). - let txid_bytes = txid.into_bytes(); - let mut blob = Vec::with_capacity(8 + 8 + 4 + txid_bytes.len() + 4 + tx_bytes.len()); - blob.extend_from_slice(&token.to_be_bytes()); - blob.extend_from_slice(&fee.to_be_bytes()); - blob.extend_from_slice(&(txid_bytes.len() as u32).to_be_bytes()); - blob.extend_from_slice(&txid_bytes); - blob.extend_from_slice(&(tx_bytes.len() as u32).to_be_bytes()); - blob.extend_from_slice(tx_bytes); - match env.byte_array_from_slice(&blob) { - Ok(array) => array.into_raw(), - Err(_) => { - // The registration already committed and is holding the funding - // reservation; release the token so it isn't orphaned to the - // 24-block TTL backstop when Kotlin never receives it. - let _ = unsafe { platform_wallet_ffi::core_wallet_signed_payment_release(token) }; - ptr::null_mut() - } - } - }) -} - /// `core_wallet_signed_payment_finalize` — atomically fund, reserve, sign, and /// register a builder for deferred (BIP70/BIP270) submission in ONE native -/// operation. This is the concurrency-safe replacement for the deprecated -/// `coreTxBuilderSetFunding` + `coreTxBuilderBuildSigned` + -/// `coreWalletRegisterSignedPayment` sequence: selection and reservation commit -/// as a single unit under the wallet-manager lock, so concurrent deferred builds -/// (or a deferred build racing an immediate send) can no longer double-select an -/// input. CONSUMES [builder]. `accountType`/`accountIndex` are the funding -/// account (0 BIP44, 1 BIP32, 2 CoinJoin); [coreSignerHandle] is a -/// `MnemonicResolverHandle`. +/// operation. Selection and reservation commit as a single unit under the +/// wallet-manager lock, so concurrent deferred builds (or a deferred build +/// racing an immediate send) can no longer double-select an input. CONSUMES +/// [builder]. `accountType`/`accountIndex` are the funding account (0 BIP44, +/// 1 BIP32, 2 CoinJoin); [coreSignerHandle] is a `MnemonicResolverHandle`. /// -/// Returns the same big-endian BLOB `coreWalletRegisterSignedPayment` returns, -/// decoded into a `SignedCoreTransaction`: +/// Returns a big-endian BLOB decoded into a `SignedCoreTransaction`: /// `u64 token, u64 feeDuffs, u32 txidLen, txid utf8, u32 txBytesLen, txBytes`. #[no_mangle] #[allow(clippy::too_many_arguments)] From b2dfb4cbccc6280cc5982e0c0b5854361cb25d5b Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:18:35 -0400 Subject: [PATCH 09/24] fix(kotlin-sdk): baseline the deferred token age on the pre-signing reservation height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit finalize_transaction captures last_processed_height inside the funding critical section and stamps the selected inputs' reservation with it, then signs after dropping the wallet-manager lock. The registry, however, sampled a FRESH last_processed_height in register() — run AFTER the (possibly slow, external) signer returned. A slow signer could let the wallet advance so the token's baseline was higher than the reservation's true stamp height, making the age guard measure from the wrong side of signing: the token looked young while its reservation had already aged toward key-wallet's TTL sweep, risking a release/broadcast against an outpoint key-wallet had swept and re-selected. Carry the stamp height on SignedCoreTransaction (reservation_height, captured in the funding section before signing) and have register() take the height as an explicit parameter instead of sampling. The atomic finalize FFI passes finalized.reservation_height(); the age guard now baselines on the same clock the reservation was stamped with. Adds a regression test that registers after the wallet advanced (modelling a slow signer) and proves the guard trips MAX_AGE past the reservation height, not past a post-signing sample. Co-Authored-By: Claude Fable 5 --- .../src/core_wallet/transaction_builder.rs | 11 +- packages/rs-platform-wallet-ffi/src/wallet.rs | 9 +- .../rs-platform-wallet/src/test_support.rs | 16 +- .../src/wallet/core/transaction.rs | 22 +- packages/rs-platform-wallet/src/wallet/mod.rs | 4 +- .../src/wallet/signed_payment_registry.rs | 200 ++++++++++++++++-- 6 files changed, 225 insertions(+), 37 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs index efa919da4a0..c638b3c681b 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs @@ -243,13 +243,18 @@ pub unsafe extern "C" fn core_wallet_signed_payment_finalize( // committed the reservation; register just takes ownership of the built tx so // a later broadcast/release can reconcile it, capturing the wallet instance // whose `ReservationSet` holds the inputs. - let token = - runtime().block_on(crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY.register( + let token = runtime().block_on( + crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY.register( wallet.core().clone(), finalized.transaction().clone(), account_type.as_standard_account_type(), account_index, - )); + // Baseline the age guard on the reservation's OWN stamp height, + // captured inside finalize's funding critical section before the + // external signer ran — never a fresh post-signing sample. + Some(finalized.reservation_height()), + ), + ); *out_tx = FFICoreTransaction { tx_bytes: Box::into_raw(serialized.into_boxed_slice()) as *mut u8, diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index 85912e1e99c..e080fffa796 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -414,12 +414,10 @@ pub unsafe extern "C" fn platform_wallet_destroy(handle: Handle) -> PlatformWall let wallet_id = core.wallet_id(); let manager = wallet.wallet_manager(); let sibling_alias_alive = PLATFORM_WALLET_STORAGE.any(|other| { - other.wallet_id() == wallet_id - && std::sync::Arc::ptr_eq(other.wallet_manager(), manager) + other.wallet_id() == wallet_id && std::sync::Arc::ptr_eq(other.wallet_manager(), manager) }); if !sibling_alias_alive { - crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY - .remove_entries_for_wallet(core); + crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY.remove_entries_for_wallet(core); } PlatformWalletFFIResult::ok() } @@ -467,6 +465,9 @@ mod destroy_tests { dummy_tx(), Some(StandardAccountType::BIP44Account), 0, + // This test exercises only the destroy-time sweep, not the + // age guard, so the reservation height is irrelevant here. + None, ) .await; assert_eq!(SIGNED_PAYMENT_REGISTRY.outstanding(), baseline + 1); diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 3526d2d68e4..7f323f58fc6 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -295,7 +295,9 @@ impl crate::changeset::PlatformWalletPersistence for NoopTestPersister { Ok(()) } - fn load(&self) -> Result { + fn load( + &self, + ) -> Result { Ok(crate::changeset::ClientStartState::default()) } } @@ -312,8 +314,10 @@ impl crate::events::PlatformEventHandler for NoopTestEventHandler {} /// /// Used by FFI-layer tests that need genuine `PlatformWallet` aliases, e.g. the /// `platform_wallet_destroy` final-alias registry-sweep gating. -pub async fn test_platform_wallet_manager( -) -> (Arc>, WalletId) { +pub async fn test_platform_wallet_manager() -> ( + Arc>, + WalletId, +) { use key_wallet::mnemonic::{Language, Mnemonic}; use key_wallet::wallet::initialization::WalletAccountCreationOptions; @@ -325,7 +329,11 @@ pub async fn test_platform_wallet_manager( let persister = Arc::new(NoopTestPersister); let event_handler: Arc = Arc::new(NoopTestEventHandler); - let manager = Arc::new(crate::PlatformWalletManager::new(sdk, persister, event_handler)); + let manager = Arc::new(crate::PlatformWalletManager::new( + sdk, + persister, + event_handler, + )); let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 81b2e8a8249..049cfa0e571 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -59,6 +59,15 @@ pub struct SignedCoreTransaction { fee: u64, funding_account_type: AccountTypePreference, funding_account_index: u32, + /// The wallet's `last_processed_height` captured **inside** the funding + /// critical section — the exact clock `set_current_height` stamped the + /// selected inputs' reservation with, sampled *before* the (potentially + /// slow, external) signer ran. The deferred-payment registry's age guard + /// must baseline off this, not off a fresh `last_processed_height` sampled + /// after signing: a slow external signer could otherwise let the wallet + /// advance far enough that the token looks fresh while the reservation it + /// covers has already aged toward key-wallet's TTL sweep. + reservation_height: u32, } impl SignedCoreTransaction { @@ -77,6 +86,14 @@ impl SignedCoreTransaction { pub fn funding_account_index(&self) -> u32 { self.funding_account_index } + + /// The `last_processed_height` the funding reservation was stamped with, + /// captured in the funding critical section before signing. The deferred + /// registry registers the token with this height so its age guard measures + /// the reservation's true age rather than a post-signing sample. + pub fn reservation_height(&self) -> u32 { + self.reservation_height + } } fn account( @@ -125,7 +142,7 @@ impl CoreWallet { account_index: u32, signer: &S, ) -> Result { - let (unsigned, fee, selected, paths) = { + let (unsigned, fee, selected, paths, height) = { let mut manager = self.wallet_manager.write().await; let (wallet, info) = manager .get_wallet_and_info_mut(&self.wallet_id) @@ -201,7 +218,7 @@ impl CoreWallet { } }; - (unsigned, fee, selected, paths) + (unsigned, fee, selected, paths, height) }; let signed = match signer @@ -223,6 +240,7 @@ impl CoreWallet { fee, funding_account_type: account_type, funding_account_index: account_index, + reservation_height: height, }) } diff --git a/packages/rs-platform-wallet/src/wallet/mod.rs b/packages/rs-platform-wallet/src/wallet/mod.rs index 43457733a33..e8ae111513f 100644 --- a/packages/rs-platform-wallet/src/wallet/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/mod.rs @@ -15,9 +15,6 @@ pub mod signed_payment_registry; pub mod tokens; pub use self::core::CoreWallet; -pub use signed_payment_registry::{ - ReservationToken, SignedPaymentError, SignedPaymentRegistry, -}; pub use apply::ApplyError; pub use core_address_key::CoreAddressPrivateKey; pub use identity::IdentityWallet; @@ -29,3 +26,4 @@ pub use platform_wallet::{ PlatformWallet, PlatformWalletInfo, WalletId, WalletStateReadGuard, WalletStateWriteGuard, }; pub use provider_key_at_index::{ProviderDerivedKey, ProviderKeyKind}; +pub use signed_payment_registry::{ReservationToken, SignedPaymentError, SignedPaymentRegistry}; 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 56968725625..dcaa11346e6 100644 --- a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -195,23 +195,32 @@ impl SignedPaymentRegistry { .unwrap_or_else(|poisoned| poisoned.into_inner()) } - /// Take ownership of a built, signed `tx` (whose funding UTXOs `build_signed` + /// Take ownership of a built, signed `tx` (whose funding UTXOs `finalize` /// already reserved) and return an opaque token for a later /// [`broadcast`](Self::broadcast) or [`release`](Self::release). /// /// `core` is the wallet the payment was built against; it is captured so the /// later operation acts on the exact reservation state that holds the inputs. - /// The wallet's current `last_processed_height` is captured too, to bound the - /// token's lifetime against key-wallet's reservation TTL (see - /// [`RESERVATION_MAX_AGE_BLOCKS`]). + /// + /// `registered_height` MUST be the `last_processed_height` the funding + /// reservation was stamped with — the height captured **inside** the funding + /// critical section, *before* signing (`SignedCoreTransaction::reservation_height`). + /// The caller passes it in rather than the registry sampling a fresh + /// `last_processed_height` here, which would be taken *after* the + /// (potentially slow, external) signer ran: a slow signer could let the + /// wallet advance so that a freshly-sampled height makes the token look + /// young while the reservation it covers has already aged toward + /// key-wallet's TTL. `None` disables the age guard for this entry (the + /// wallet-mismatch / account-lookup paths still reject a re-created wallet). + /// See [`RESERVATION_MAX_AGE_BLOCKS`]. pub async fn register( &self, core: CoreWallet, tx: Transaction, account_type: Option, account_index: u32, + registered_height: Option, ) -> ReservationToken { - let registered_height = core.last_processed_height().await; let token = self.next_token.fetch_add(1, Ordering::SeqCst); self.lock().insert( token, @@ -265,7 +274,10 @@ impl SignedPaymentRegistry { // simply drop it — deliberately WITHOUT releasing, since a release by // outpoint here could free a newer build's reservation. The stale // reservation is reclaimed by key-wallet's own TTL sweep. - if reservation_expired(entry.registered_height, current.last_processed_height().await) { + if reservation_expired( + entry.registered_height, + current.last_processed_height().await, + ) { return Err(SignedPaymentError::StaleReservationToken(token)); } @@ -303,7 +315,10 @@ impl SignedPaymentRegistry { // build; releasing it by outpoint could free that newer reservation. // Drop the token without touching the `ReservationSet` — the original // reservation is reclaimed by key-wallet's own TTL sweep. - if reservation_expired(entry.registered_height, entry.core.last_processed_height().await) { + if reservation_expired( + entry.registered_height, + entry.core.last_processed_height().await, + ) { return; } if let Some(account_type) = entry.account_type { @@ -517,7 +532,13 @@ mod tests { let expected_txid = tx.txid(); let token = registry - .register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0) + .register( + core.clone(), + tx, + Some(StandardAccountType::BIP44Account), + 0, + core.last_processed_height().await, + ) .await; assert_eq!(registry.outstanding(), 1); @@ -553,7 +574,13 @@ mod tests { .await .expect("build should succeed"); let token = registry - .register(core.clone(), tx, Some(account_type), 0) + .register( + core.clone(), + tx, + Some(account_type), + 0, + core.last_processed_height().await, + ) .await; // With the reservation held, an immediate rebuild finds no @@ -595,7 +622,13 @@ mod tests { .await .expect("build should succeed"); let token = registry - .register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0) + .register( + core.clone(), + tx, + Some(StandardAccountType::BIP44Account), + 0, + core.last_processed_height().await, + ) .await; registry @@ -632,7 +665,13 @@ mod tests { .await .expect("build should succeed"); let token = registry - .register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0) + .register( + core.clone(), + tx, + Some(StandardAccountType::BIP44Account), + 0, + core.last_processed_height().await, + ) .await; registry.release(token).await; @@ -660,7 +699,13 @@ mod tests { .await .expect("build should succeed"); let token = registry - .register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0) + .register( + core.clone(), + tx, + Some(StandardAccountType::BIP44Account), + 0, + core.last_processed_height().await, + ) .await; registry.release(token).await; @@ -721,6 +766,7 @@ mod tests { tx, Some(StandardAccountType::BIP44Account), 0, + core_a.last_processed_height().await, ) .await; @@ -757,7 +803,13 @@ mod tests { .await .expect("build should succeed"); let token = registry - .register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0) + .register( + core.clone(), + tx, + Some(StandardAccountType::BIP44Account), + 0, + core.last_processed_height().await, + ) .await; let sent = registry.broadcast(token, &core).await; @@ -807,7 +859,13 @@ mod tests { .await .expect("build should succeed"); let token = registry - .register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0) + .register( + core.clone(), + tx, + Some(StandardAccountType::BIP44Account), + 0, + core.last_processed_height().await, + ) .await; let mut handles = Vec::new(); @@ -861,8 +919,9 @@ mod tests { let core = core.clone(); let tx = tx.clone(); handles.push(tokio::spawn(async move { + let height = core.last_processed_height().await; registry - .register(core, tx, Some(StandardAccountType::BIP44Account), 0) + .register(core, tx, Some(StandardAccountType::BIP44Account), 0, height) .await })); } @@ -879,7 +938,10 @@ mod tests { /// progress between build/register and a later broadcast/release — the window /// in which key-wallet's `ReservationSet` TTL can sweep the funding /// reservation. This is the same clock the registry's age guard reads. - async fn advance_processed_height(core: &CoreWallet, height: u32) { + 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()) @@ -899,7 +961,10 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; let registry = SignedPaymentRegistry::new(); - let registered_height = core.last_processed_height().await.expect("last processed height"); + let registered_height = core + .last_processed_height() + .await + .expect("last processed height"); let tx = build_signed_tx( &core, StandardAccountType::BIP44Account, @@ -910,7 +975,13 @@ mod tests { .await .expect("build should succeed"); let token = registry - .register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0) + .register( + core.clone(), + tx, + Some(StandardAccountType::BIP44Account), + 0, + core.last_processed_height().await, + ) .await; // Advance past the age bound but stay below key-wallet's 24-block TTL, so @@ -955,7 +1026,10 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; let registry = SignedPaymentRegistry::new(); - let registered_height = core.last_processed_height().await.expect("last processed height"); + let registered_height = core + .last_processed_height() + .await + .expect("last processed height"); let tx = build_signed_tx( &core, StandardAccountType::BIP44Account, @@ -966,7 +1040,13 @@ mod tests { .await .expect("build should succeed"); let token = registry - .register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0) + .register( + core.clone(), + tx, + Some(StandardAccountType::BIP44Account), + 0, + core.last_processed_height().await, + ) .await; advance_processed_height(&core, registered_height + RESERVATION_MAX_AGE_BLOCKS + 2).await; @@ -1010,7 +1090,13 @@ mod tests { .await .expect("build should succeed"); let token = registry - .register(core.clone(), tx, Some(StandardAccountType::BIP44Account), 0) + .register( + core.clone(), + tx, + Some(StandardAccountType::BIP44Account), + 0, + core.last_processed_height().await, + ) .await; // A sibling handle over the SAME manager Arc but a different wallet_id — @@ -1065,6 +1151,7 @@ mod tests { tx_a, Some(StandardAccountType::BIP44Account), 0, + core_a.last_processed_height().await, ) .await; let tx_b = build_signed_tx( @@ -1082,6 +1169,7 @@ mod tests { tx_b, Some(StandardAccountType::BIP44Account), 0, + core_b.last_processed_height().await, ) .await; assert_eq!(registry.outstanding(), 2); @@ -1097,4 +1185,74 @@ mod tests { "a swept token must be StaleToken, got {sent:?}" ); } + + /// Regression for the "reservation height captured before signing, token + /// height sampled after" gap: `register` takes the reservation's OWN stamp + /// height, so a slow external signer that let `last_processed_height` + /// advance between stamping and registration cannot make the token look + /// younger than the reservation it covers. + /// + /// The wallet is advanced to `H + (MAX_AGE - 1)` *before* the token is + /// registered — modelling a signer slow enough that a fresh + /// post-signing sample would read that higher height. The token is + /// registered with the reservation's real stamp height `H`. One more block + /// (`H + MAX_AGE`) then trips the guard: exactly `MAX_AGE` past the + /// reservation. Under the old behaviour (sampling `last_processed_height` + /// at register time) the baseline would have been `H + MAX_AGE - 1`, so the + /// same final height would read an age of 1 and the token would broadcast — + /// this test would fail. Baselining on the passed-in reservation height is + /// what keeps the guard tripping before key-wallet's TTL sweep. + #[tokio::test] + async fn register_baselines_on_reservation_height_not_a_post_signing_sample() { + let broadcaster = Arc::new(CountingBroadcaster::new()); + let (core, signer, outputs) = + funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; + let registry = SignedPaymentRegistry::new(); + + let reservation_height = core + .last_processed_height() + .await + .expect("last processed height"); + let tx = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + + // Slow signer: the wallet advanced to just under the age bound while + // signing. A fresh sample here would read `reservation_height + + // MAX_AGE - 1`. + advance_processed_height(&core, reservation_height + RESERVATION_MAX_AGE_BLOCKS - 1).await; + + // Register with the reservation's OWN stamp height, not a fresh sample. + let token = registry + .register( + core.clone(), + tx, + Some(StandardAccountType::BIP44Account), + 0, + Some(reservation_height), + ) + .await; + + // One block past the reservation height (still below the 24-block TTL) + // trips the guard because the baseline is `reservation_height`. + advance_processed_height(&core, reservation_height + RESERVATION_MAX_AGE_BLOCKS).await; + + let sent = registry.broadcast(token, &core).await; + assert!( + matches!(sent, Err(SignedPaymentError::StaleReservationToken(t)) if t == token), + "a token past MAX_AGE from its reservation height must be StaleReservationToken, \ + got {sent:?}" + ); + assert_eq!( + broadcaster.count.load(Ordering::SeqCst), + 0, + "the network must not have been hit" + ); + } } From ebb5e7afc42c89f46da8b4e9d71d9e7c9210c11a Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:22:16 -0400 Subject: [PATCH 10/24] fix(kotlin-sdk): give the V2 handle and token paths one wallet-generation identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The V2 finalized-transaction handle validated only wallet_id, while the registry-token path validated the shared WalletManager Arc plus wallet_id. Neither can tell one wallet generation from another: after a wallet is removed and re-created under the same id, both the manager Arc and wallet_id are equal, so an old V2 handle could act through the old generation while the new generation selects the same inputs. Add CoreWallet::is_same_generation — the single generation identity both paths now share. Aliases of one generation share the per-generation Arc (created fresh in the wallet-lifecycle create/load paths); a re-created wallet gets a new one, so Arc::ptr_eq on it distinguishes generations that wallet_id + the manager Arc cannot. Holding either handle pins the balance Arc, so its address can't be reused for a different generation — the same soundness argument the registry already uses for the manager Arc. Apply it to both V2 broadcast and abandon (replacing the wallet_id-only check). The registry broadcast path adopts the same identity in the follow-up validate-under-lock change. Adds a unit test proving alias-vs-recreation. Co-Authored-By: Claude Fable 5 --- .../src/core_wallet/broadcast.rs | 12 ++- .../src/wallet/core/wallet.rs | 95 +++++++++++++++++++ 2 files changed, 103 insertions(+), 4 deletions(-) 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 9792c014ab4..676aeafde4d 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -32,11 +32,14 @@ pub unsafe extern "C" fn core_wallet_broadcast_signed_transaction_v2( "invalid core wallet handle".to_string(), ); }; - if wallet.wallet_id() != finalized.wallet.wallet_id() { + // Same generation identity the registry-token path uses: reject a caller + // handle that names a different wallet generation (e.g. a re-created wallet + // under the same id) before acting through the embedded originating wallet. + if !wallet.is_same_generation(&finalized.wallet) { runtime().block_on(finalized.wallet.abandon_transaction(&finalized.transaction)); return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, - "transaction was finalized by a different wallet".to_string(), + "transaction was finalized by a different wallet generation".to_string(), ); } let result = runtime().block_on( @@ -71,7 +74,8 @@ pub unsafe extern "C" fn core_wallet_abandon_signed_transaction_v2( "invalid core wallet handle".to_string(), ); }; - if wallet.wallet_id() != transaction.wallet.wallet_id() { + // Same generation identity as the broadcast path / registry-token path. + if !wallet.is_same_generation(&transaction.wallet) { runtime().block_on( transaction .wallet @@ -79,7 +83,7 @@ pub unsafe extern "C" fn core_wallet_abandon_signed_transaction_v2( ); return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, - "transaction was finalized by a different wallet".to_string(), + "transaction was finalized by a different wallet generation".to_string(), ); } runtime().block_on( diff --git a/packages/rs-platform-wallet/src/wallet/core/wallet.rs b/packages/rs-platform-wallet/src/wallet/core/wallet.rs index 9dd2b0e4493..101727c3e42 100644 --- a/packages/rs-platform-wallet/src/wallet/core/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/core/wallet.rs @@ -67,6 +67,36 @@ impl CoreWallet { self.wallet_id } + /// Whether `self` and `other` are handles to the same wallet *generation* — + /// the same logical wallet AND the same live in-memory instance. + /// + /// Two aliases of one generation (the `Arc` clones handed + /// out by `PlatformWalletManager::get_wallet`) share the per-generation + /// `Arc`; a wallet removed and re-created under the same + /// `wallet_id` gets a fresh one. `Arc::ptr_eq` on that balance therefore + /// distinguishes generations that `wallet_id` — and the shared multi-wallet + /// `WalletManager` `Arc` — alone cannot (both are equal across a + /// remove-then-recreate). While either handle is held the balance `Arc` + /// cannot be freed, so its address can never be reused for a different + /// generation, which makes the pointer comparison sound (the same soundness + /// argument the registry already relies on for `Arc::ptr_eq` on the + /// manager). + /// + /// This is the single generation identity shared by BOTH deferred-payment + /// paths — the registry-token path + /// ([`SignedPaymentRegistry`](crate::SignedPaymentRegistry)) and the V2 + /// finalized-transaction handle path — so neither acts on a re-created + /// wallet's `ReservationSet` while an old handle still names the old + /// generation. + pub fn is_same_generation( + &self, + other: &CoreWallet, + ) -> bool { + self.wallet_id == other.wallet_id + && Arc::ptr_eq(&self.wallet_manager, &other.wallet_manager) + && Arc::ptr_eq(&self.balance, &other.balance) + } + pub async fn set_gap_limit( &self, account_type: AccountTypePreference, @@ -330,3 +360,68 @@ impl Clone for CoreWallet { } } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use key_wallet::account::account_type::StandardAccountType; + + use super::WalletBalance; + use crate::test_support::{funded_wallet_manager, AlwaysOkBroadcaster}; + use crate::wallet::core::CoreWallet; + + /// The single generation identity both deferred-payment paths share: + /// aliases of one generation share the per-generation balance `Arc` (same + /// generation), while a wallet re-created under the same `wallet_id` and the + /// same multi-wallet `WalletManager` `Arc` but a fresh balance `Arc` is a + /// DIFFERENT generation. Neither `wallet_id` nor the manager `Arc` alone can + /// tell them apart — the balance `Arc` is what distinguishes them, closing + /// the gap where an old handle could act through the old generation while a + /// new generation selected the same inputs. + #[tokio::test] + async fn is_same_generation_distinguishes_recreation_from_aliases() { + let (manager, wallet_id, balance, _signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let broadcaster = Arc::new(AlwaysOkBroadcaster); + + let generation_a = CoreWallet::new( + Arc::clone(&sdk), + Arc::clone(&manager), + wallet_id, + Arc::clone(&broadcaster), + Arc::clone(&balance), + ); + + // A clone is an alias of the SAME generation (shares the balance Arc). + let alias = generation_a.clone(); + assert!( + generation_a.is_same_generation(&alias), + "aliases of one generation must compare equal" + ); + assert!(alias.is_same_generation(&generation_a)); + + // A re-created generation: SAME manager Arc + SAME wallet_id, fresh + // per-generation balance Arc. + let generation_b = CoreWallet::new( + sdk, + Arc::clone(&manager), + wallet_id, + broadcaster, + Arc::new(WalletBalance::new()), + ); + assert!( + !generation_a.is_same_generation(&generation_b), + "a re-created generation must NOT match, despite equal wallet_id + manager" + ); + // Sanity: it is ONLY the balance Arc that differs — wallet_id and the + // manager Arc are identical, so those checks alone could not tell the + // two generations apart. + assert_eq!(generation_a.wallet_id(), generation_b.wallet_id()); + assert!(Arc::ptr_eq( + &generation_a.wallet_manager, + &generation_b.wallet_manager + )); + } +} From 45d146e5c21aa8fd61136db21f6d7b3a10bec94a Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:24:38 -0400 Subject: [PATCH 11/24] fix(kotlin-sdk): validate the deferred token under the lock, consume only a match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SignedPaymentRegistry::broadcast removed the entry first and validated the wallet binding second, so a mismatched caller (wrong wallet, or a re-created generation) destroyed the ORIGINAL wallet's token and left its reservation stranded until the TTL backstop — a wrong-wallet broadcast could grief the rightful owner's in-flight payment. Peek under the registry lock, reject a non-matching caller with WalletMismatch WITHOUT removing the entry, and only remove (consume) an entry whose generation matches. The check-then-remove is one lock hold, so it stays atomic against a concurrent broadcast — the double-broadcast guard is unchanged (the second consumer finds nothing → StaleToken). The binding check now uses the shared CoreWallet::is_same_generation identity, so the registry-token and V2 handle paths agree on when a caller owns a token. Updates the two existing mismatch tests (which asserted the old drop-on-mismatch behaviour) and adds a regression proving a wrong-wallet broadcast preserves the owner's token and the owner can still broadcast it. Co-Authored-By: Claude Fable 5 --- .../src/wallet/signed_payment_registry.rs | 162 ++++++++++++++---- 1 file changed, 131 insertions(+), 31 deletions(-) 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 dcaa11346e6..4fb0b1315ca 100644 --- a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -14,17 +14,20 @@ //! transaction and its held reservation between build and submission, keyed by //! an opaque [`ReservationToken`], and enforces the lifecycle invariants: //! -//! * [`broadcast`](SignedPaymentRegistry::broadcast) removes the entry **before** -//! sending, so a repeated or concurrent broadcast of the same token can never +//! * [`broadcast`](SignedPaymentRegistry::broadcast) validates the wallet +//! binding **under the lock** and removes **only a matching** entry, so a +//! repeated or concurrent broadcast of the same token can never //! double-broadcast — the second caller finds nothing and gets -//! [`SignedPaymentError::StaleToken`]. +//! [`SignedPaymentError::StaleToken`] — and a wrong-wallet caller cannot +//! consume (and thereby strand) the rightful owner's token. //! * [`release`](SignedPaymentRegistry::release) is idempotent: releasing an //! unknown / already-consumed token is a silent no-op. -//! * A token is bound to the exact wallet instance it was minted against -//! (`Arc::ptr_eq` on the shared `WalletManager` **and** an equal `wallet_id`, -//! so two wallets sharing one multi-wallet `PlatformWalletManager` are still -//! told apart). Broadcasting it through a re-created wallet — whose in-memory -//! `ReservationSet` no longer holds the inputs — is a +//! * A token is bound to the exact wallet *generation* it was minted against +//! ([`CoreWallet::is_same_generation`](crate::CoreWallet::is_same_generation) — +//! the same identity the V2 finalized-transaction handle path uses). Two +//! wallets sharing one multi-wallet `PlatformWalletManager`, or a re-created +//! wallet under the same id whose in-memory `ReservationSet` no longer holds +//! the inputs, are both told apart: broadcasting through either is a //! [`SignedPaymentError::WalletMismatch`] rather than a spend against stale //! state. //! * A token has a bounded lifetime ([`RESERVATION_MAX_AGE_BLOCKS`]). Once the @@ -238,12 +241,19 @@ impl SignedPaymentRegistry { /// Broadcast the payment behind `token`, reconciling its UTXO reservation on /// failure, then consume the token. /// - /// The entry is removed **before** the send, so a repeated or concurrent - /// broadcast of the same token gets [`SignedPaymentError::StaleToken`] - /// instead of a second send. `current` must be the same wallet instance the - /// token was minted against (checked by `Arc::ptr_eq` on the shared - /// `WalletManager`); otherwise the call fails with - /// [`SignedPaymentError::WalletMismatch`] and the stale token is dropped. + /// The wallet binding is validated **under the registry lock**, and only a + /// *matching* entry is removed. So a wrong-wallet caller can never consume + /// (and thereby destroy) the rightful owner's token: a mismatched token is + /// left in the registry for its owner and this call returns + /// [`SignedPaymentError::WalletMismatch`]. `current` must be the same wallet + /// *generation* the token was minted against + /// (`CoreWallet::is_same_generation`); a re-created wallet under the same id + /// is a mismatch, not a spend against stale state. + /// + /// Because the check-and-consume happen atomically under one lock hold, a + /// repeated or concurrent broadcast of the same token by the rightful owner + /// gets [`SignedPaymentError::StaleToken`] instead of a second send — the + /// first consumer removed it. /// /// On a definitive rejection the reservation is released for an immediate /// rebuild; on an ambiguous ("may already be on the network") failure it is @@ -253,21 +263,30 @@ impl SignedPaymentRegistry { token: ReservationToken, current: &CoreWallet, ) -> Result { - // Remove under the lock and drop the guard *before* awaiting — a - // std::Mutex guard must never be held across an await point, and the - // atomic take is what makes a double-broadcast impossible. - let entry = { self.lock().remove(&token) }.ok_or(SignedPaymentError::StaleToken(token))?; - - // Bound the token to the exact wallet instance: the same shared - // `WalletManager` (`Arc::ptr_eq`) *and* the same `wallet_id`, so two - // wallets sharing one multi-wallet `PlatformWalletManager` are told - // apart (`ptr_eq` alone matches any pair within that manager). The - // entry is already removed, so a mismatched token can never be replayed. - if !Arc::ptr_eq(&entry.core.wallet_manager, ¤t.wallet_manager) - || entry.core.wallet_id() != current.wallet_id() - { - return Err(SignedPaymentError::WalletMismatch(token)); - } + // Validate the wallet binding UNDER the lock and consume ONLY a matching + // entry. Peeking first means a mismatched caller leaves the entry in + // place for its rightful owner rather than removing it (which would + // strand the owner's reservation until the TTL backstop). The + // check-then-remove is one lock hold, so it is atomic against a + // concurrent broadcast; the std::Mutex guard is dropped before any await. + let entry = { + let mut entries = self.lock(); + match entries.get(&token) { + None => return Err(SignedPaymentError::StaleToken(token)), + Some(entry) => { + // Same wallet generation the token was minted against — the + // single identity the V2 handle path also uses. A re-created + // wallet (same id + manager, new generation) is a mismatch. + if !entry.core.is_same_generation(current) { + // Leave the entry for its rightful owner. + return Err(SignedPaymentError::WalletMismatch(token)); + } + } + } + entries + .remove(&token) + .expect("entry present under the same lock hold") + }; // Refuse a token whose reservation could already have been swept and // re-selected by an unrelated build. The entry is already removed, so we @@ -780,7 +799,11 @@ mod tests { 0, "nothing was sent on the original wallet" ); - assert_eq!(registry.outstanding(), 0, "the stale token is dropped"); + assert_eq!( + registry.outstanding(), + 1, + "a mismatched broadcast must NOT consume the rightful owner's token" + ); } /// An ambiguous ("may already be on the network") broadcast failure keeps @@ -1116,7 +1139,11 @@ mod tests { 0, "nothing was sent for the mismatched wallet" ); - assert_eq!(registry.outstanding(), 0, "the stale token is dropped"); + assert_eq!( + registry.outstanding(), + 1, + "a mismatched broadcast must NOT consume the rightful owner's token" + ); } /// Destroying a wallet sweeps only its own tokens from the registry, so its @@ -1186,6 +1213,79 @@ mod tests { ); } + /// Regression for the wrong-wallet-broadcast token theft: a mismatched + /// caller must return `WalletMismatch` WITHOUT consuming the entry, so the + /// rightful owner's token — and its reservation — survive and it can still + /// be broadcast. Previously `broadcast` removed the entry and *then* + /// validated, so a wrong-wallet caller destroyed the owner's token and + /// stranded its reservation until the TTL backstop. + #[tokio::test] + async fn wrong_wallet_broadcast_preserves_the_owners_token() { + let broadcaster_a = Arc::new(CountingBroadcaster::new()); + let (core_a, signer_a, outputs_a) = funded_core_wallet( + StandardAccountType::BIP44Account, + Arc::clone(&broadcaster_a), + ) + .await; + // A separate wallet-manager instance is a different generation. + let broadcaster_b = Arc::new(CountingBroadcaster::new()); + let (core_b, _signer_b, _outputs_b) = + funded_core_wallet(StandardAccountType::BIP44Account, broadcaster_b).await; + let registry = SignedPaymentRegistry::new(); + + let tx = build_signed_tx( + &core_a, + StandardAccountType::BIP44Account, + 0, + &outputs_a, + &signer_a, + ) + .await + .expect("build should succeed"); + let token = registry + .register( + core_a.clone(), + tx, + Some(StandardAccountType::BIP44Account), + 0, + core_a.last_processed_height().await, + ) + .await; + + // Wrong wallet: mismatch, and the token MUST survive for its owner. + let mismatched = registry.broadcast(token, &core_b).await; + assert!( + matches!(mismatched, Err(SignedPaymentError::WalletMismatch(t)) if t == token), + "a wrong-wallet broadcast must be WalletMismatch, got {mismatched:?}" + ); + assert_eq!( + registry.outstanding(), + 1, + "the owner's token must survive a wrong-wallet broadcast" + ); + assert_eq!( + broadcaster_a.count.load(Ordering::SeqCst), + 0, + "nothing was sent for the mismatched caller" + ); + + // The rightful owner can still broadcast its own token. + registry + .broadcast(token, &core_a) + .await + .expect("the owner's broadcast should still succeed"); + assert_eq!( + broadcaster_a.count.load(Ordering::SeqCst), + 1, + "the owner's broadcast must reach the network exactly once" + ); + assert_eq!( + registry.outstanding(), + 0, + "the token is consumed by its owner" + ); + } + /// Regression for the "reservation height captured before signing, token /// height sampled after" gap: `register` takes the reservation's OWN stamp /// height, so a slow external signer that let `last_processed_height` From 44be4897f2f7eae4333e3c70dee7b41773330b3e Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:31:25 -0400 Subject: [PATCH 12/24] fix(kotlin-sdk): release deferred reservations at final-alias destroy; drop them at generation teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit platform_wallet_destroy called remove_entries_for_wallet, which only DROPPED the registry entries. But destroying the last wrapper alias does not remove the logical wallet from its manager — the accounts' ReservationSets stay live and the same wallet can be handed out again — so the dropped tokens' inputs stayed reserved until key-wallet's TTL. Tokens were consumed without releasing live reservations. Split the two teardown moments under one generation identity: - Final-alias destroy (wallet still live): release_entries_for_wallet RELEASES each of the generation's reservations against the still-live wallet (honouring the age guard), so a wallet handed out again can respend the inputs. The final-alias check and the match are both by CoreWallet::is_same_generation. - Actual generation teardown (platform_wallet_manager_remove_wallet): the wallet and its ReservationSets are gone, so remove_entries_for_wallet DROPS the generation's registry tokens (nothing to reconcile) and remove_matching drops its finalized-tx V2 handles. This makes any stale handle to the removed generation inert, which is what makes the destroy-time release provably race-free: a torn-down generation has already had its tokens swept here, so destroy/release can never release-by-outpoint against a re-created generation's inputs. platform_wallet_destroy now block_on's the release (as it already runs off the tokio runtime on the JNI / NativeCleaner threads). Adds HandleStorage::remove_matching, registry release_entries_for_wallet, a registry regression proving destroy-time release frees the reservation while teardown drop does not, and reworks the FFI destroy test to invoke destroy off-runtime. Co-Authored-By: Claude Fable 5 --- packages/rs-platform-wallet-ffi/src/handle.rs | 15 ++ .../rs-platform-wallet-ffi/src/manager.rs | 18 +- packages/rs-platform-wallet-ffi/src/wallet.rs | 93 +++++---- .../src/wallet/signed_payment_registry.rs | 187 ++++++++++++++---- 4 files changed, 237 insertions(+), 76 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/handle.rs b/packages/rs-platform-wallet-ffi/src/handle.rs index 68e5c77dd49..b4eba259f97 100644 --- a/packages/rs-platform-wallet-ffi/src/handle.rs +++ b/packages/rs-platform-wallet-ffi/src/handle.rs @@ -89,6 +89,21 @@ impl HandleStorage { let mut guard = self.items.write(); guard.get_mut(&handle).map(f) } + + /// Remove (and drop) every stored item satisfying `predicate`, returning how + /// many were removed. Used to sweep a wallet generation's handles at + /// teardown (e.g. abandon every finalized-transaction V2 handle whose + /// originating wallet was just removed from its manager — the reservation + /// ceases to exist with the generation, so dropping is the correct action). + pub fn remove_matching(&self, predicate: F) -> usize + where + F: Fn(&T) -> bool, + { + let mut guard = self.items.write(); + let before = guard.len(); + guard.retain(|_, item| !predicate(item)); + before - guard.len() + } } impl Default for HandleStorage { diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index ed90edaad74..aac69794eca 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -454,7 +454,23 @@ pub unsafe extern "C" fn platform_wallet_manager_remove_wallet( }); let result = unwrap_option_or_return!(option); match result { - Ok(_) => PlatformWalletFFIResult::ok(), + Ok(removed) => { + // Generation teardown: the wallet and its accounts' `ReservationSet`s + // are now gone from the manager, so the deferred-payment reservations + // cease to exist — there is nothing to reconcile. DROP (do not + // release) this generation's registry tokens and its finalized-tx V2 + // handles. This is the teardown half of the single generation policy + // both deferred paths share: it makes any stale handle to the removed + // generation inert, so a later destroy/release of a lingering handle + // can never release-by-outpoint against a re-created generation's + // inputs. + let core = removed.core(); + crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY + .remove_entries_for_wallet(core); + crate::handle::CORE_SIGNED_TRANSACTION_V2_STORAGE + .remove_matching(|tx| tx.wallet.is_same_generation(core)); + PlatformWalletFFIResult::ok() + } // Idempotency: a wallet that's already gone is the success // state callers want. Everything else is a real failure. Err(platform_wallet::PlatformWalletError::WalletNotFound(_)) => { diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index e080fffa796..31748edac55 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -397,27 +397,34 @@ pub unsafe extern "C" fn platform_wallet_destroy(handle: Handle) -> PlatformWall }; // `platform_wallet_manager_get_wallet` hands out an independent handle for - // each alias of the same logical wallet (they share the underlying - // `WalletManager` `Arc` and `wallet_id`). A deferred-payment token minted - // through one alias must NOT be invalidated when a *sibling* alias is - // destroyed — the token is still live and broadcastable through the survivor. + // each alias of the same wallet *generation* (they share the underlying + // `WalletManager` `Arc`, `wallet_id`, and the per-generation balance `Arc`). + // A deferred-payment token minted through one alias must NOT be invalidated + // when a *sibling* alias of the same generation is destroyed — the token is + // still live and broadcastable through the survivor. // - // So only sweep the registry when THIS is the final live alias: no other - // stored handle shares the same (`WalletManager` pointer + `wallet_id`) — - // exactly the key `remove_entries_for_wallet` matches on. When a sibling is - // still live, the destructor just drops this handle, leaving its tokens - // (and the shared `WalletManager` pin) in place. Once the last alias goes, - // the sweep runs, releasing the registry's pin on the wallet's - // `WalletManager` (accounts, keys, sync state) that each token's captured - // `CoreWallet` clone would otherwise keep alive for the process lifetime. + // So only reconcile when THIS is the final live alias of the generation: no + // other stored handle is the same generation + // (`CoreWallet::is_same_generation`). While a sibling is live, the + // destructor just drops this handle. + // + // Once the last alias goes, RELEASE (not merely drop) each of this + // generation's deferred-payment reservations: destroying the last wrapper + // handle does NOT remove the logical wallet from its manager, so the wallet + // — and its accounts' still-live `ReservationSet`s — remain, and the same + // wallet can be handed out again. Dropping the tokens without releasing + // would leave those inputs reserved until key-wallet's TTL. Releasing here + // also frees the registry's `CoreWallet` pin on the shared `WalletManager`. + // (Actual generation teardown — `remove_wallet` — instead drops the tokens, + // since the reservation ceases to exist with the generation.) let core = wallet.core(); - let wallet_id = core.wallet_id(); - let manager = wallet.wallet_manager(); - let sibling_alias_alive = PLATFORM_WALLET_STORAGE.any(|other| { - other.wallet_id() == wallet_id && std::sync::Arc::ptr_eq(other.wallet_manager(), manager) - }); + let sibling_alias_alive = + PLATFORM_WALLET_STORAGE.any(|other| other.core().is_same_generation(core)); if !sibling_alias_alive { - crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY.remove_entries_for_wallet(core); + runtime().block_on( + crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY + .release_entries_for_wallet(core), + ); } PlatformWalletFFIResult::ok() } @@ -445,7 +452,12 @@ mod destroy_tests { /// `platform_wallet_destroy` final-alias gating. #[test] fn destroying_one_alias_keeps_a_siblings_token() { - runtime().block_on(async { + // Async setup only. `platform_wallet_destroy` now itself does + // `runtime().block_on(...)` to release reservations, exactly as it does + // when called from the JNI / NativeCleaner threads (never from inside a + // tokio runtime). Calling it from within an outer `block_on` would nest + // runtimes and abort, so the destroys run on the plain test thread below. + let (manager, handle_a, handle_b, baseline) = runtime().block_on(async { let (manager, wallet_id) = test_platform_wallet_manager().await; // Two independent handles for the SAME logical wallet, exactly as two @@ -471,27 +483,28 @@ mod destroy_tests { ) .await; assert_eq!(SIGNED_PAYMENT_REGISTRY.outstanding(), baseline + 1); - - // Destroy alias A while B is still live → token must survive. - let result = unsafe { platform_wallet_destroy(handle_a) }; - assert_eq!(result.code, PlatformWalletFFIResultCode::Success); - assert_eq!( - SIGNED_PAYMENT_REGISTRY.outstanding(), - baseline + 1, - "a sibling alias's token must survive destroying another alias" - ); - - // Destroy the final alias B → now the token is swept. - let result = unsafe { platform_wallet_destroy(handle_b) }; - assert_eq!(result.code, PlatformWalletFFIResultCode::Success); - assert_eq!( - SIGNED_PAYMENT_REGISTRY.outstanding(), - baseline, - "destroying the final alias must sweep the wallet's tokens" - ); - - // Keep the manager alive until the end (owns the wallet + adapter). - drop(manager); + (manager, handle_a, handle_b, baseline) }); + + // Destroy alias A while B is still live → token must survive. + let result = unsafe { platform_wallet_destroy(handle_a) }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!( + SIGNED_PAYMENT_REGISTRY.outstanding(), + baseline + 1, + "a sibling alias's token must survive destroying another alias" + ); + + // Destroy the final alias B → now the token is swept. + let result = unsafe { platform_wallet_destroy(handle_b) }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!( + SIGNED_PAYMENT_REGISTRY.outstanding(), + baseline, + "destroying the final alias must sweep the wallet's tokens" + ); + + // Keep the manager alive until the end (owns the wallet + adapter). + drop(manager); } } 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 4fb0b1315ca..ff312eb0735 100644 --- a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -55,7 +55,7 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, MutexGuard}; +use std::sync::{Mutex, MutexGuard}; use dashcore::{Transaction, Txid}; use key_wallet::account::account_type::StandardAccountType; @@ -316,6 +316,27 @@ impl SignedPaymentRegistry { Ok(txid) } + /// Reconcile one already-removed entry's reservation, honouring the age + /// guard: if the token has outlived its reservation lifetime the funding + /// outpoint may already have been swept and re-selected by an unrelated + /// build, so releasing it by outpoint could free that newer reservation — + /// drop it without touching the `ReservationSet` (key-wallet's TTL reclaims + /// the original). Otherwise release the standard-account reservation. + async fn reconcile_removed_entry(entry: RegisteredPayment) { + if reservation_expired( + entry.registered_height, + entry.core.last_processed_height().await, + ) { + return; + } + if let Some(account_type) = entry.account_type { + entry + .core + .release_payment_reservation(account_type, entry.account_index, &entry.tx) + .await; + } + } + /// Release the funding reservation behind `token` and drop it. Idempotent: /// releasing an unknown / already-consumed token is a silent no-op, so a /// double release (or a release after a broadcast) is harmless. @@ -329,48 +350,62 @@ impl SignedPaymentRegistry { // Unknown / already consumed — idempotent no-op. return; }; - // If the token has outlived its reservation lifetime, the funding - // outpoint may already have been swept and re-selected by an unrelated - // build; releasing it by outpoint could free that newer reservation. - // Drop the token without touching the `ReservationSet` — the original - // reservation is reclaimed by key-wallet's own TTL sweep. - if reservation_expired( - entry.registered_height, - entry.core.last_processed_height().await, - ) { - return; - } - if let Some(account_type) = entry.account_type { - entry - .core - .release_payment_reservation(account_type, entry.account_index, &entry.tx) - .await; + Self::reconcile_removed_entry(entry).await; + } + + /// Release and drop every outstanding token bound to `wallet`'s *generation* + /// ([`CoreWallet::is_same_generation`](crate::CoreWallet::is_same_generation)), + /// returning how many were removed. Called from `platform_wallet_destroy` + /// when the **final** handle to a live wallet generation is destroyed. + /// + /// Unlike [`remove_entries_for_wallet`](Self::remove_entries_for_wallet) + /// (which drops without releasing at generation *teardown*), the generation + /// here is still live in its manager — destroying the last wrapper handle + /// does not remove the logical wallet, and the same wallet can be handed out + /// again. So each token's reservation is RELEASED against that still-live + /// generation (honouring the age guard), rather than left stranded in the + /// account `ReservationSet` until key-wallet's TTL. Race-free: matching is by + /// generation, and a generation that was actually torn down + /// (`remove_wallet`) has already had its tokens swept there, so this finds + /// none and cannot release against a re-created generation's inputs. + pub async fn release_entries_for_wallet(&self, wallet: &CoreWallet) -> usize { + // Take the matching entries out under the lock, then reconcile each with + // the guard dropped (the reconcile path awaits). + let taken: Vec> = { + let mut entries = self.lock(); + let tokens: Vec = entries + .iter() + .filter(|(_, entry)| entry.core.is_same_generation(wallet)) + .map(|(token, _)| *token) + .collect(); + tokens + .into_iter() + .filter_map(|token| entries.remove(&token)) + .collect() + }; + let count = taken.len(); + for entry in taken { + Self::reconcile_removed_entry(entry).await; } + count } /// Drop every outstanding token bound to `wallet` (same shared - /// `WalletManager` and `wallet_id`), returning how many were removed. - /// - /// Called from the FFI when a `PlatformWallet` is destroyed so the registry - /// stops pinning that wallet's `WalletManager` (accounts, keys, sync state) - /// alive for the rest of the process via its captured `CoreWallet` clone. - /// The reservations are intentionally not released: the wallet — and its - /// accounts' `ReservationSet`s — are being torn down with it, so there is - /// nothing to reconcile, and any surviving token would be a - /// [`WalletMismatch`](SignedPaymentError::WalletMismatch) against a - /// re-created instance regardless. + /// `WalletManager` and `wallet_id`), WITHOUT releasing, returning how many + /// were removed. /// - /// This is hooked into `PlatformWallet` teardown rather than the transient - /// `CoreWallet` handle destroy: the deferred flow builds/registers on one - /// short-lived core handle and broadcasts on another, so sweeping on core - /// handle destroy would drop tokens between register and broadcast. + /// Called from the FFI at actual wallet-generation *teardown* + /// (`platform_wallet_manager_remove_wallet`): the wallet — and its accounts' + /// `ReservationSet`s — are removed from the manager, so the reservations + /// cease to exist and there is nothing to reconcile. Dropping the tokens here + /// also makes any stale handle to that generation inert, so a later + /// destroy/release of a lingering handle can never release-by-outpoint + /// against a re-created generation's inputs — this is the teardown half of + /// the single generation policy the deferred paths share. pub fn remove_entries_for_wallet(&self, wallet: &CoreWallet) -> usize { let mut entries = self.lock(); let before = entries.len(); - entries.retain(|_, entry| { - !(Arc::ptr_eq(&entry.core.wallet_manager, &wallet.wallet_manager) - && entry.core.wallet_id() == wallet.wallet_id()) - }); + entries.retain(|_, entry| !entry.core.is_same_generation(wallet)); before - entries.len() } @@ -1211,6 +1246,88 @@ mod tests { matches!(sent, Err(SignedPaymentError::StaleToken(t)) if t == token_a), "a swept token must be StaleToken, got {sent:?}" ); + + // Generation teardown drops WITHOUT releasing: A's input stays reserved + // (the account's ReservationSet is conceptually gone with the wallet, so + // there is nothing to reconcile). An immediate rebuild on A still fails. + let blocked = build_signed_tx( + &core_a, + StandardAccountType::BIP44Account, + 0, + &outputs_a, + &signer_a, + ) + .await; + assert!( + matches!(blocked, Err(PlatformWalletError::TransactionBuild(_))), + "remove_entries_for_wallet must NOT release by outpoint, got {blocked:?}" + ); + } + + /// Regression for the final-alias-destroy leak: `release_entries_for_wallet` + /// must RELEASE each of the generation's reservations against the still-live + /// wallet, not merely drop them, so a wallet handed out again can respend the + /// inputs instead of leaving them reserved until key-wallet's TTL. This is + /// the destroy-time half of the teardown policy, and the counterpart to + /// `remove_entries_for_wallet` (drop-only, at actual generation teardown). + #[tokio::test] + async fn release_entries_for_wallet_frees_the_reservation() { + let broadcaster = Arc::new(RecordingBroadcaster::new()); + let (core, signer, outputs) = + funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; + let registry = SignedPaymentRegistry::new(); + + let tx = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let _token = registry + .register( + core.clone(), + tx, + Some(StandardAccountType::BIP44Account), + 0, + core.last_processed_height().await, + ) + .await; + + // Reservation held: an immediate rebuild fails at input selection. + let blocked = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await; + assert!( + matches!(blocked, Err(PlatformWalletError::TransactionBuild(_))), + "rebuild must fail while the reservation is held, got {blocked:?}" + ); + + // Final-alias destroy path: release (not drop) the generation's tokens. + let released = registry.release_entries_for_wallet(&core).await; + assert_eq!(released, 1, "the generation's one token is reconciled"); + assert_eq!(registry.outstanding(), 0); + + // The released input is spendable again — the rebuild now succeeds. + let rebuilt = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await; + assert!( + rebuilt.is_ok(), + "release_entries_for_wallet must free the reservation, got {rebuilt:?}" + ); } /// Regression for the wrong-wallet-broadcast token theft: a mismatched From 2b41aab9a74ae556b56a2283c4f41e6867f0a9f8 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:35:21 -0400 Subject: [PATCH 13/24] fix(kotlin-sdk): split the conflated deferred-token error code into three siblings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native code 26 (ErrorStaleReservationToken) mapped all three SignedPaymentError variants — StaleToken (unknown/already-broadcast/released), WalletMismatch (different wallet generation), and StaleReservationToken (aged out) — so a host could not tell "you already broadcast this", "wrong wallet", and "the reservation aged out; rebuild" apart, even though the remedy and messaging differ. Split at the FFI (additive sibling codes, no renumbering): - 26 ErrorStaleReservationToken -> StaleReservationToken (aged out) - 27 ErrorReservationTokenConsumed -> StaleToken (unknown/already broadcast/released) - 28 ErrorReservationWalletMismatch -> WalletMismatch (different generation) core_wallet_signed_payment_broadcast now maps each variant to its own code. All three remain non-retryable-in-place and none touch the network. Host impact (Kotlin SDK only — the Swift host does not map these codes): adds DashSdkError.PlatformWallet.ReservationTokenConsumed / ReservationWalletMismatch, maps 27/28, narrows the code-26 doc, updates the JNI/Kotlin broadcast KDocs, and extends DashSdkErrorTest to assert all three. Co-Authored-By: Claude Fable 5 --- .../dashsdk/errors/DashSdkError.kt | 43 ++++++++++++++++--- .../dashsdk/ffi/WalletManagerNative.kt | 10 +++-- .../dashsdk/wallet/ManagedCoreWallet.kt | 11 +++-- .../dashsdk/wallet/ManagedPlatformWallet.kt | 14 +++--- .../dashsdk/errors/DashSdkErrorTest.kt | 29 ++++++++++--- .../src/core_wallet/signed_payment.rs | 17 +++++--- packages/rs-platform-wallet-ffi/src/error.rs | 37 ++++++++++++---- .../rs-unified-sdk-jni/src/wallet_manager.rs | 10 +++-- 8 files changed, 130 insertions(+), 41 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 bfa54c7fd66..8364ab155f3 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 @@ -179,17 +179,46 @@ sealed class DashSdkError( /** * `ErrorStaleReservationToken` (native code 26). A deferred * (BIP70/BIP270) [broadcastSigned][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.broadcastSigned] - * was given a reservation token that is unknown, already broadcast, - * already released, or was minted against a re-created wallet instance. - * The call did NOT touch the network — there is no double-broadcast — - * but the token can never succeed, so this is NOT retryable: rebuild the - * payment with + * 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]. - * (Release is idempotent and never raises this.) + * + * Sibling of the other two deferred-token failures this code used to + * conflate: [ReservationTokenConsumed] (unknown / already broadcast / + * already released) and [ReservationWalletMismatch] (minted against a + * different wallet generation). */ class StaleReservationToken(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorReservationTokenConsumed` (native code 27). A deferred + * (BIP70/BIP270) [broadcastSigned][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.broadcastSigned] + * token is unknown, already broadcast, or already released — the guard + * that turns a double-broadcast (or a broadcast after release) into a + * typed error instead of a second send. The call did NOT touch the + * network. NOT retryable: rebuild the payment with + * [buildSignedPayment][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.buildSignedPayment]. + * (Release is idempotent and never raises this.) + */ + class ReservationTokenConsumed(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + + /** + * `ErrorReservationWalletMismatch` (native code 28). A deferred + * (BIP70/BIP270) [broadcastSigned][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.broadcastSigned] + * token was minted against a different wallet *generation* than the one + * broadcasting it (e.g. a wallet re-created under the same id); its + * reservation lives in that other generation's reservation set. The call + * did NOT touch the network and did NOT consume the rightful owner's + * token. NOT retryable through this handle: rebuild the payment with + * [buildSignedPayment][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.buildSignedPayment]. + */ + class ReservationWalletMismatch(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + /** * Any other `PlatformWalletFFIResultCode` without a dedicated type. * Carries the platform-wallet [nativeCode] (already de-offset) and @@ -260,6 +289,8 @@ sealed class DashSdkError( 24 -> PlatformWallet.AssetLockAlreadyConsumed(message, cause) // ErrorAssetLockAlreadyConsumed 25 -> PlatformWallet.AssetLockFundingMismatch(message, cause) // ErrorAssetLockFundingMismatch 26 -> PlatformWallet.StaleReservationToken(message, cause) // ErrorStaleReservationToken + 27 -> PlatformWallet.ReservationTokenConsumed(message, cause) // ErrorReservationTokenConsumed + 28 -> PlatformWallet.ReservationWalletMismatch(message, cause) // ErrorReservationWalletMismatch else -> PlatformWallet.Generic(code, message, cause) } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt index afdd19873ae..b99c3734c6d 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt @@ -269,10 +269,12 @@ internal object WalletManagerNative { /** * `core_wallet_signed_payment_broadcast` — broadcast the payment behind * [token], reconciling its reservation on failure and consuming the token. - * A repeated/stale/wrong-wallet token throws - * `ErrorStaleReservationToken` (never a double-broadcast). [coreHandle] must - * resolve to the wallet the token was minted against. Returns the txid as a - * lowercase hex string. + * Rather than double-broadcasting, an unusable token throws one of three + * sibling codes — `ErrorStaleReservationToken` (26, aged out), + * `ErrorReservationTokenConsumed` (27, already consumed/unknown), or + * `ErrorReservationWalletMismatch` (28, different wallet generation). + * [coreHandle] must resolve to the wallet the token was minted against. + * Returns the txid as a lowercase hex string. */ external fun coreWalletBroadcastSignedPayment(coreHandle: Long, token: Long): String 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 a06e65520cf..aa3a638e1c6 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 @@ -56,9 +56,14 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { } /** - * Broadcast the deferred payment behind [token] and return its txid. A - * stale / already-broadcast / wrong-wallet token surfaces as - * [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.StaleReservationToken]. + * Broadcast the deferred payment behind [token] and return its txid. An + * unusable token surfaces as one of the three sibling deferred-token + * errors — aged out + * ([org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.StaleReservationToken]), + * already consumed / unknown + * ([org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.ReservationTokenConsumed]), + * or a different wallet generation + * ([org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.ReservationWalletMismatch]). */ internal fun broadcastSignedPayment(token: Long): String = WalletManagerNative.coreWalletBroadcastSignedPayment(handle, token) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt index 9e4152bea46..cf0874ef3bb 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt @@ -305,11 +305,15 @@ class ManagedPlatformWallet internal constructor( /** * Broadcast the deferred payment behind [token] (from [buildSignedPayment]) * and return its broadcast txid — the "merchant server acked" arm. Consumes - * the token: a second [broadcastSigned] with the same token, or one for a - * re-created wallet, throws - * [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.StaleReservationToken] - * rather than double-broadcasting. Operates on the token directly (the - * inputs are already reserved). + * the token. Rather than double-broadcasting, an unusable token throws one + * of three sibling errors: already consumed / unknown + * ([org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.ReservationTokenConsumed], + * e.g. a second [broadcastSigned] with the same token), a different wallet + * generation + * ([org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.ReservationWalletMismatch], + * e.g. a re-created wallet), or aged out + * ([org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.StaleReservationToken]). + * Operates on the token directly (the inputs are already reserved). */ suspend fun broadcastSigned(token: Long): String = withContext(Dispatchers.IO) { mapNativeErrors { diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index b9f3b294fb1..d4690101357 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -104,15 +104,32 @@ class DashSdkErrorTest { // The message must warn against retrying (distinct from the anchor case). assertTrue(broadcastUnconfirmed.message!!.contains("do NOT retry")) - // Deferred build/broadcast: a stale/consumed/wrong-wallet reservation - // token → typed StaleReservationToken, not retryable. - val staleToken = DashSdkError.fromNative(DashSDKException(offset + 26, "stale token 7")) - assertTrue(staleToken is DashSdkError.PlatformWallet.StaleReservationToken) + // Deferred build/broadcast: the three sibling reservation-token failures + // map to three distinct typed errors, none retryable. + val agedOut = DashSdkError.fromNative(DashSDKException(offset + 26, "stale token 7")) + assertTrue(agedOut is DashSdkError.PlatformWallet.StaleReservationToken) assertFalse( "StaleReservationToken must NOT be retryable (rebuild the payment)", - staleToken.isRetryable, + agedOut.isRetryable, ) - assertEquals("stale token 7", staleToken.message) + assertEquals("stale token 7", agedOut.message) + + val consumed = DashSdkError.fromNative(DashSDKException(offset + 27, "already broadcast")) + assertTrue(consumed is DashSdkError.PlatformWallet.ReservationTokenConsumed) + assertFalse( + "ReservationTokenConsumed must NOT be retryable (rebuild the payment)", + consumed.isRetryable, + ) + assertEquals("already broadcast", consumed.message) + + val walletMismatch = + DashSdkError.fromNative(DashSDKException(offset + 28, "different generation")) + assertTrue(walletMismatch is DashSdkError.PlatformWallet.ReservationWalletMismatch) + assertFalse( + "ReservationWalletMismatch must NOT be retryable (rebuild the payment)", + walletMismatch.isRetryable, + ) + assertEquals("different generation", walletMismatch.message) } @Test diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs index 6d335375658..4e9a3e4df41 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs @@ -73,14 +73,21 @@ pub unsafe extern "C" fn core_wallet_signed_payment_broadcast( *out_txid = c_txid.into_raw(); PlatformWalletFFIResult::ok() } - Err( - e @ (SignedPaymentError::StaleToken(_) - | SignedPaymentError::WalletMismatch(_) - | SignedPaymentError::StaleReservationToken(_)), - ) => PlatformWalletFFIResult::err( + // Split the three deferred-token failures into distinct sibling codes so + // a host can message each precisely. All are non-retryable-in-place and + // none touched the network. + Err(e @ SignedPaymentError::StaleReservationToken(_)) => PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorStaleReservationToken, e.to_string(), ), + Err(e @ SignedPaymentError::StaleToken(_)) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorReservationTokenConsumed, + e.to_string(), + ), + Err(e @ SignedPaymentError::WalletMismatch(_)) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorReservationWalletMismatch, + e.to_string(), + ), // Preserve the typed underlying wallet error (keeps the ambiguous // "may already be on the network" retry semantics intact). Err(SignedPaymentError::Broadcast(e)) => PlatformWalletFFIResult::from(e), diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index b20f6122c6a..81273bc26ad 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -168,16 +168,37 @@ pub enum PlatformWalletFFIResultCode { /// Existing-lock recovery attempted to use a lock for the wrong funding /// family or bound identity index. ErrorAssetLockFundingMismatch = 25, - /// Maps `SignedPaymentError::StaleToken` / `SignedPaymentError::WalletMismatch` - /// from the deferred build → broadcast/release core-send lifecycle - /// (`core_wallet_signed_payment_*`). The reservation token is unknown, - /// already broadcast, already released, or was minted against a different - /// (re-created) wallet instance. The operation did NOT touch the network — - /// there is no double-broadcast — but the token can never succeed, so this - /// is NOT retryable: the host must rebuild the payment. Release is - /// idempotent and never surfaces this code. + /// Maps `SignedPaymentError::StaleReservationToken` from the deferred + /// build → broadcast/release core-send lifecycle (`core_wallet_signed_payment_*`): + /// the token has outlived the registry's `RESERVATION_MAX_AGE_BLOCKS` bound + /// and its funding reservation may already have been swept and re-selected by + /// key-wallet's TTL, so acting on it could touch a newer, unrelated + /// reservation. The operation did NOT touch the network. NOT retryable in + /// place — the host must rebuild the payment. + /// + /// Sibling codes split out the other two deferred-token failures that this + /// code used to conflate: [`Self::ErrorReservationTokenConsumed`] (27, + /// unknown / already broadcast / already released) and + /// [`Self::ErrorReservationWalletMismatch`] (28, 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. ErrorStaleReservationToken = 26, + /// Maps `SignedPaymentError::StaleToken`. The deferred reservation token is + /// unknown, already broadcast, or already released — the guard that turns a + /// double-broadcast (or a broadcast after release) into a typed error + /// instead of a second send. Did NOT touch the network; NOT retryable + /// (rebuild the payment). Release is idempotent and never surfaces this. + ErrorReservationTokenConsumed = 27, + + /// Maps `SignedPaymentError::WalletMismatch`. The deferred reservation token + /// was minted against a different wallet *generation* than the one it is + /// being broadcast through (e.g. a wallet re-created under the same id); its + /// reservation lives in that other generation's `ReservationSet`. Did NOT + /// touch the network and did NOT consume the rightful owner's token; NOT + /// retryable through this handle (rebuild the payment). + ErrorReservationWalletMismatch = 28, + NotFound = 98, // Used exclusively for all the Option that are retuned as errors ErrorUnknown = 99, } diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 8baa7ee9742..3bd5f9985d4 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1370,10 +1370,12 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c /// `core_wallet_signed_payment_broadcast` — broadcast the payment behind /// `token`, releasing/keeping its reservation per the broadcast outcome and -/// consuming the token. A repeated/stale token throws (native -/// `ErrorStaleReservationToken`, code 26) rather than double-broadcasting. -/// `coreHandle` must resolve to the wallet the token was minted against. -/// Returns the txid as a lowercase hex string. +/// consuming the token. Rather than double-broadcasting, an unusable token +/// throws one of three sibling codes: `ErrorStaleReservationToken` (26, aged +/// out), `ErrorReservationTokenConsumed` (27, unknown / already broadcast / +/// already released), or `ErrorReservationWalletMismatch` (28, different wallet +/// generation). `coreHandle` must resolve to the wallet the token was minted +/// against. Returns the txid as a lowercase hex string. #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreWalletBroadcastSignedPayment( mut env: JNIEnv, From 6b52b10d7002ae2f50ea688eb66e8cc9e7ce099b Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:36:07 -0400 Subject: [PATCH 14/24] docs(kotlin-sdk): correct buildSignedPayment KDoc to the finalize-and-register shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The KDoc still described the pre-finalize build (`new → addOutput* → setFunding → buildSigned`) and credited buildSigned with reserving the inputs. The deferred path now issues a single atomic finalizeSignedPayment (select + reserve + sign + register under the wallet-manager lock). Update the described step sequence and the atomicity claim to match. Co-Authored-By: Claude Fable 5 --- .../dashsdk/wallet/ManagedPlatformWallet.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt index cf0874ef3bb..e04bcc1ebf8 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt @@ -247,13 +247,14 @@ class ManagedPlatformWallet internal constructor( * * The BIP70/BIP270 counterpart to [sendToAddresses]: those protocols sign, * POST the raw bytes to a merchant server, and broadcast only on ack, which - * a single build-sign-broadcast call cannot express. The `new → addOutput* → - * setFunding → buildSigned` build runs under the same per-wallet teardown - * gate ([gate]) as [sendToAddresses]; [buildSigned] atomically reserves the - * selected UTXOs in the Rust reservation layer (which closes the - * setFunding/buildSigned selection race), so once this returns the - * reservation holds the inputs and [broadcastSigned] / [releaseReservation] - * operate on the token later. + * a single build-sign-broadcast call cannot express. The + * `new → addOutput* → finalizeSignedPayment` build runs under the same + * per-wallet teardown gate ([gate]) as [sendToAddresses]. The single atomic + * finalize does select + reserve + sign + register under the wallet-manager + * lock (closing the funding/signing selection race the old setFunding + + * buildSigned split had), so once this returns the reservation holds the + * inputs and [broadcastSigned] / [releaseReservation] operate on the token + * later. * * Process-death note: the reservation is in-memory. An app crash between * this call and [broadcastSigned] drops the reservation on restart (the From 54b88d13d88e64c08cbeb63b12e960d5381567c8 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:41:12 -0400 Subject: [PATCH 15/24] fix(kotlin-sdk): make the deferred payment token an owning AutoCloseable with a Cleaner backstop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildSignedPayment returned a plain SignedCoreTransaction through a cancellable coroutine. The blocking JNI registration mints the reservation token before the Kotlin object exists, so if cancellation was observed after that native call returned — or the caller simply dropped the value — the token (and its funding reservation) was orphaned until key-wallet's TTL, with no release path. Make SignedCoreTransaction an AutoCloseable that registers a NativeCleaner backstop at construction: close(), or GC if the caller never calls it, releases the token exactly once. Native release is idempotent and tokens are process-unique, so releasing a token already consumed by broadcastSigned / releaseReservation (or closing twice) is a harmless no-op. This closes the cancellation window — the object is Cleaner-backed the instant it exists (no suspension point between the native return and construction), so a discarded object always releases its token. Adds a pure-JVM test pinning the ownership contract (owning AutoCloseable) and the Cleaner run-once guarantee it relies on, and documents the ownership on buildSignedPayment. :sdk:testDebugUnitTest passes. Co-Authored-By: Claude Fable 5 --- .../dashsdk/wallet/ManagedPlatformWallet.kt | 47 ++++++++++++- .../wallet/SignedCoreTransactionTest.kt | 69 +++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/SignedCoreTransactionTest.kt diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt index e04bcc1ebf8..0376e1ee332 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt @@ -179,6 +179,19 @@ class ManagedPlatformWallet internal constructor( * flows that must sign now, POST the raw bytes to a merchant server, and * broadcast only on the server's ack. * + * **Owns the reservation token.** The blocking native registration mints the + * token before this object exists, so if the object were then discarded — + * the caller drops it, or a coroutine cancellation is observed after + * [buildSignedPayment]'s native call returned — the token (and its funding + * reservation) would be orphaned until key-wallet's TTL. This type is + * therefore [AutoCloseable] with a [NativeCleaner] GC backstop: [close], or + * GC if you never call it, releases the token exactly once. Release is + * idempotent native-side and tokens are process-unique (never reused), so + * releasing a token already consumed by [broadcastSigned] / + * [releaseReservation] — or releasing twice — is a harmless no-op. A caller + * that broadcasts or releases can still `use`/close this object; a caller + * that abandons it is covered by GC. + * * @property txidHex the transaction id (lowercase hex) the broadcast will * return — computed from the signed bytes Rust-side so it matches exactly. * @property rawTxBytes the consensus-serialized signed transaction, to hand @@ -186,14 +199,29 @@ class ManagedPlatformWallet internal constructor( * @property feeDuffs the fee the build charged, in duffs. * @property reservationToken the opaque token for [broadcastSigned] / * [releaseReservation]. Valid only for this wallet instance and only until - * consumed by one of those calls. + * consumed by one of those calls (or released by [close] / GC). */ class SignedCoreTransaction internal constructor( val txidHex: String, val rawTxBytes: ByteArray, val feeDuffs: Long, val reservationToken: Long, - ) { + ) : AutoCloseable { + + // GC backstop: releases the token if it was neither broadcast nor + // released. The action must not reference this object (it would never + // become phantom-reachable), so it captures the token by value. + private val cleanable = NativeCleaner.register(this, TokenRelease(reservationToken)) + + /** + * Release the funding reservation if this payment was neither broadcast + * nor released, and drop the token. Idempotent — safe to call after a + * [broadcastSigned] / [releaseReservation] (native no-op) and safe to + * call twice. The [NativeCleaner] backstop runs the same release on GC + * if you never call [close]. + */ + override fun close() = cleanable.clean() + override fun equals(other: Any?): Boolean = other is SignedCoreTransaction && txidHex == other.txidHex && @@ -213,6 +241,13 @@ class ManagedPlatformWallet internal constructor( "SignedCoreTransaction(txidHex=$txidHex, feeDuffs=$feeDuffs, " + "reservationToken=$reservationToken, rawTxBytes=${rawTxBytes.size} bytes)" + /** Releases the reservation token exactly once, on [close] or GC. */ + private class TokenRelease(private val token: Long) : Runnable { + override fun run() { + WalletManagerNative.coreWalletReleaseSignedPayment(token) + } + } + internal companion object { /** * Decode the big-endian native BLOB the atomic @@ -256,6 +291,14 @@ class ManagedPlatformWallet internal constructor( * inputs and [broadcastSigned] / [releaseReservation] operate on the token * later. * + * The returned [SignedCoreTransaction] OWNS the token: it is [AutoCloseable] + * with a GC/[NativeCleaner] backstop, so a token that is neither broadcast + * nor released is never orphaned — even if the caller drops the object or a + * cancellation discards it after this call's blocking native registration + * already minted the token. The backstop releases the reservation on GC (or + * on an explicit [SignedCoreTransaction.close]); consuming the token via + * [broadcastSigned] / [releaseReservation] makes that release a native no-op. + * * Process-death note: the reservation is in-memory. An app crash between * this call and [broadcastSigned] drops the reservation on restart (the * UTXOs become spendable again) — the same property dashj has. diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/SignedCoreTransactionTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/SignedCoreTransactionTest.kt new file mode 100644 index 00000000000..c32011891e2 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/SignedCoreTransactionTest.kt @@ -0,0 +1,69 @@ +package org.dashfoundation.dashsdk.wallet + +import org.dashfoundation.dashsdk.ffi.NativeCleaner +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test +import java.nio.ByteBuffer +import java.util.concurrent.atomic.AtomicInteger + +/** + * Ownership contract for the deferred-payment token (blocker: "Kotlin + * cancellation can orphan a token"). + * + * These are pure-JVM tests — they never call the native release itself (that + * needs the loaded cdylib on the emulator harness). They pin the two properties + * the fix rests on: [ManagedPlatformWallet.SignedCoreTransaction] is an owning + * [AutoCloseable], and the [NativeCleaner] backstop it registers runs its + * release action exactly once (on the first clean / GC and never again), so a + * token abandoned by a dropped object or an observed cancellation is released, + * and a token already consumed by broadcast/release is not double-released. + */ +class SignedCoreTransactionTest { + + private fun registerBlob(token: Long, fee: Long, txid: String, txBytes: ByteArray): ByteArray { + val txidBytes = txid.toByteArray(Charsets.UTF_8) + val buf = ByteBuffer.allocate(8 + 8 + 4 + txidBytes.size + 4 + txBytes.size) + buf.putLong(token) + buf.putLong(fee) + buf.putInt(txidBytes.size) + buf.put(txidBytes) + buf.putInt(txBytes.size) + buf.put(txBytes) + return buf.array() + } + + @Test + fun fromRegisterBlobDecodesFieldsAndIsAnOwningCloseable() { + val txBytes = byteArrayOf(1, 2, 3, 4, 5) + val blob = registerBlob(token = 42L, fee = 7L, txid = "abcd", txBytes = txBytes) + + val signed = ManagedPlatformWallet.SignedCoreTransaction.fromRegisterBlob(blob) + + assertEquals(42L, signed.reservationToken) + assertEquals(7L, signed.feeDuffs) + assertEquals("abcd", signed.txidHex) + assertArrayEquals(txBytes, signed.rawTxBytes) + + // Compile-time proof that the token is owned by a closeable: a dropped + // object can be reclaimed via close() / GC rather than leaking the token. + @Suppress("UNUSED_VARIABLE") + val asCloseable: AutoCloseable = signed + } + + @Test + fun cleanerBackstopRunsTheReleaseActionExactlyOnce() { + // The GC/close backstop SignedCoreTransaction relies on: the release + // action runs once on the first clean() and never again — so releasing a + // token that was already broadcast/consumed (or closing twice) cannot + // fire a second native release. + val runs = AtomicInteger(0) + val owner = Any() + val cleanable = NativeCleaner.register(owner) { runs.incrementAndGet() } + + cleanable.clean() + cleanable.clean() + + assertEquals(1, runs.get()) + } +} From 2ebe209045efaa944552a08595dfa8a97a948c4f Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:43:05 -0400 Subject: [PATCH 16/24] style(kotlin-sdk): rustfmt wallet_manager.rs after the dead-chain deletion Normalize two pre-existing long lines in coreWalletFinalizeSignedPayment that `cargo fmt --check` flags, so the JNI crate is formatting-clean after the register-chain removal touched this file. Co-Authored-By: Claude Fable 5 --- packages/rs-unified-sdk-jni/src/wallet_manager.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 3bd5f9985d4..f772b7e357b 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1294,7 +1294,9 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c // the FFI crate, so allocate it zeroed and let the FFI fill it in place. let mut boxed: Box> = Box::new(std::mem::MaybeUninit::zeroed()); - let out_tx = boxed.as_mut_ptr().cast::(); + let out_tx = boxed + .as_mut_ptr() + .cast::(); let mut token: u64 = 0; let mut fee: u64 = 0; @@ -1355,8 +1357,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c // The registration already committed and is holding the funding // reservation; release the token so it isn't orphaned to the TTL // backstop when Kotlin never receives it. - let _ = - unsafe { platform_wallet_ffi::core_wallet_signed_payment_release(token) }; + let _ = unsafe { platform_wallet_ffi::core_wallet_signed_payment_release(token) }; ptr::null_mut() } }; From f15fdcc699c7f8542d47f70b3d833cbce3a87ed6 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:59:39 -0400 Subject: [PATCH 17/24] fix(kotlin-sdk): object-owning broadcast/release overloads for SignedCoreTransaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2: the bare-Long token API couples the reservation's lifetime to the SignedCoreTransaction's GC-reachability — extracting the token and dropping the object lets the Cleaner backstop release the reservation out from under a pending broadcast. The object overloads keep the payment reachable across the native call (reachabilityFence) and disarm the backstop once the token is consumed; the bare-token docs now warn about the reachability requirement. Co-Authored-By: Claude Fable 5 --- .../dashsdk/wallet/ManagedPlatformWallet.kt | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt index 0376e1ee332..12befcc7cf9 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt @@ -358,6 +358,11 @@ class ManagedPlatformWallet internal constructor( * e.g. a re-created wallet), or aged out * ([org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.StaleReservationToken]). * Operates on the token directly (the inputs are already reserved). + * + * Callers holding a [SignedCoreTransaction] should prefer the object + * overload: with the bare token, the source object must stay strongly + * reachable until this call returns, or its GC backstop can release the + * reservation mid-broadcast. */ suspend fun broadcastSigned(token: Long): String = withContext(Dispatchers.IO) { mapNativeErrors { @@ -365,6 +370,31 @@ class ManagedPlatformWallet internal constructor( } } + /** + * Broadcast [payment] and return its txid — the object-owning form of + * [broadcastSigned]. Prefer this over passing the bare + * [SignedCoreTransaction.reservationToken]: the token's lifetime is coupled + * to the object's GC-reachability (the [NativeCleaner] backstop releases the + * reservation when the object is collected), so a caller that extracts the + * `Long` and drops the object races GC and can find the reservation gone. + * This overload keeps the object reachable for the whole native call and + * disarms the backstop once the token is consumed. + */ + suspend fun broadcastSigned(payment: SignedCoreTransaction): String { + try { + val txid = broadcastSigned(payment.reservationToken) + // Token consumed: close() disarms the GC backstop (the underlying + // native release is an idempotent no-op on a consumed token). + payment.close() + return txid + } finally { + // The object must stay reachable across the suspend/native call — + // without this, GC could run the backstop mid-broadcast and release + // the reservation out from under it. + java.lang.ref.Reference.reachabilityFence(payment) + } + } + /** * Release the funding reservation behind [token] (from [buildSignedPayment]) * — the "payment abandoned / merchant server nacked" arm — returning the @@ -380,6 +410,20 @@ class ManagedPlatformWallet internal constructor( } } + /** + * Release [payment]'s funding reservation — the object-owning form of + * [releaseReservation]; see [broadcastSigned] for why it is preferred over + * the bare-token form. + */ + suspend fun releaseReservation(payment: SignedCoreTransaction) { + try { + releaseReservation(payment.reservationToken) + payment.close() + } finally { + java.lang.ref.Reference.reachabilityFence(payment) + } + } + /** * The wallet's Platform-payment addresses that currently hold credits, * each as a [FundingInput] whose `credits` is the full cached balance — From a253ecd86c6a4650a224007f2d8906cb548d970b Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:33:19 -0400 Subject: [PATCH 18/24] fix(kotlin-sdk): retain a releasable account handle for CoinJoin-funded deferred payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deferred-payment registry stored only an `Option`, so a CoinJoin funding — which has no `StandardAccountType` — reconciled nothing on rejection/abandon/free and kept its inputs reserved until key-wallet's 24-block TTL, even though `finalize` reserves the selected inputs for every account variant. Carry the full `AccountTypePreference` (BIP44/BIP32/CoinJoin) as the entry's releasable account handle. The registry now broadcasts through the new `broadcast_payment_releasing_reservation` and releases through `release_transaction_reservation` (both `AccountTypePreference`-typed and CoinJoin-capable), so a rejected or abandoned CoinJoin deferred payment frees its reservation immediately. The FFI finalize passes `account_type.into()` instead of the `StandardAccountType` subset; the now-unused `release_payment_reservation` (registry-only) is removed. Test: `coinjoin_funded_release_frees_the_reservation_immediately` funds CoinJoin account 0, finalizes a sweep, registers the token, and proves release makes the input immediately spendable again. Adds a `#[cfg(test)]` `funded_coinjoin_wallet_manager` fixture. Co-Authored-By: Claude Opus 4.8 --- .../src/core_wallet/transaction_builder.rs | 7 +- packages/rs-platform-wallet-ffi/src/wallet.rs | 4 +- .../rs-platform-wallet/src/test_support.rs | 76 +++++++ .../src/wallet/core/broadcast.rs | 68 +++--- .../src/wallet/signed_payment_registry.rs | 201 ++++++++++++++---- 5 files changed, 278 insertions(+), 78 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs index c638b3c681b..429b66df4c0 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs @@ -247,7 +247,12 @@ pub unsafe extern "C" fn core_wallet_signed_payment_finalize( crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY.register( wallet.core().clone(), finalized.transaction().clone(), - account_type.as_standard_account_type(), + // Retain the FULL account handle (CoinJoin included), not just the + // `StandardAccountType` subset: `finalize` reserved the selected + // inputs regardless of variant, so a CoinJoin-funded deferred payment + // must be able to release them immediately on rejection/abandon + // rather than stranding them until the 24-block TTL. + account_type.into(), account_index, // Baseline the age guard on the reservation's OWN stamp height, // captured inside finalize's funding critical section before the diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index 31748edac55..29fe4be476b 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -433,7 +433,7 @@ pub unsafe extern "C" fn platform_wallet_destroy(handle: Handle) -> PlatformWall mod destroy_tests { use super::*; use crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY; - use key_wallet::account::account_type::StandardAccountType; + use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use platform_wallet::test_support::test_platform_wallet_manager; fn dummy_tx() -> dashcore::Transaction { @@ -475,7 +475,7 @@ mod destroy_tests { .register( core.clone(), dummy_tx(), - Some(StandardAccountType::BIP44Account), + AccountTypePreference::BIP44, 0, // This test exercises only the destroy-time sweep, not the // age guard, so the reservation height is irrelevant here. diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 7f323f58fc6..ec8b85bfd63 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -18,6 +18,11 @@ use dashcore::Txid; use dashcore::{Network, Transaction}; use key_wallet::account::account_type::StandardAccountType; use key_wallet::bip32::ExtendedPubKey; +// Only the `#[cfg(test)]` CoinJoin fixture needs the trait (for +// `next_address_with_info` on a non-standard account); gate it to match so a +// `test-utils`-only build does not flag it unused. +#[cfg(test)] +use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::signer::{ExtendedPubKeySigner, Signer, SignerMethod}; use key_wallet::test_utils::TestWalletContext; use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; @@ -257,6 +262,77 @@ pub(crate) async fn funded_wallet_manager_with_outputs( (Arc::new(RwLock::new(wm)), wallet_id, balance, signer) } +/// Like [`funded_wallet_manager`] but funds the wallet's CoinJoin account 0 +/// (created by `WalletAccountCreationOptions::Default`) with a single spendable +/// UTXO. Lets the deferred-payment tests exercise a CoinJoin-funded reservation, +/// which has no `StandardAccountType` yet must still be released immediately on +/// rejection/abandon rather than stranded until the TTL backstop. +/// +/// Only the crate's own `#[cfg(test)]` unit tests consume it, so it is gated on +/// `cfg(test)` directly — under the `test-utils` feature alone (the FFI crate's +/// build) it would compile with no user and trip `dead_code`. +#[cfg(test)] +pub(crate) async fn funded_coinjoin_wallet_manager() -> ( + Arc>>, + WalletId, + Arc, + WalletSigner, +) { + let mut ctx = TestWalletContext::new_random(); + + let coinjoin_xpub = ctx + .wallet + .accounts + .coinjoin_accounts + .get(&0) + .expect("default wallet has CoinJoin account 0") + .account_xpub; + // CoinJoin is a non-standard account type: its addresses come from the + // single external pool via `next_address_with_info`, not the standard + // receive/change split that `next_receive_address` serves. + let receive_address = ctx + .managed_wallet + .first_coinjoin_managed_account_mut() + .expect("coinjoin managed account") + .next_address_with_info(Some(&coinjoin_xpub), true) + .expect("coinjoin receive address") + .address; + + let funding_tx = Transaction::dummy(&receive_address, 0..1, &[10_000_000]); + let result = ctx + .check_transaction( + &funding_tx, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 1, + BlockHash::all_zeros(), + 1_700_000_000, + )), + ) + .await; + assert!( + result.is_relevant, + "funding tx should be relevant to the CoinJoin account" + ); + assert!(result.is_new_transaction); + + let signer = WalletSigner { + wallet: ctx.wallet.clone(), + }; + + let balance = Arc::new(WalletBalance::new()); + let info = PlatformWalletInfo { + core_wallet: ctx.managed_wallet, + balance: Arc::clone(&balance), + identity_manager: IdentityManager::new(), + tracked_asset_locks: BTreeMap::new(), + }; + + let mut wm = WalletManager::::new(Network::Testnet); + let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); + + (Arc::new(RwLock::new(wm)), wallet_id, balance, signer) +} + /// Funded SPV-backed Core wallet for downstream FFI lifecycle tests. The SPV /// runtime is intentionally not started; abandon/free only need wallet state. pub async fn funded_spv_core_wallet( diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 8386f9a06ce..299dc4df464 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -1,11 +1,10 @@ use dashcore::Transaction; use key_wallet::account::account_type::StandardAccountType; +use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use super::SignedCoreTransaction; -use crate::broadcaster::TransactionBroadcaster; -use crate::wallet::reservations::{ - broadcast_releasing_on_rejection, release_reservation_after_rejected_broadcast, -}; +use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; +use crate::wallet::reservations::broadcast_releasing_on_rejection; use crate::{CoreWallet, PlatformWalletError}; impl CoreWallet { @@ -89,39 +88,46 @@ impl CoreWallet { .map_err(Into::into) } - /// Release the funding account's UTXO reservation for `transaction` without - /// broadcasting — the "payment abandoned / merchant server nacked" arm of - /// the deferred build → broadcast/release lifecycle - /// ([`SignedPaymentRegistry`](crate::SignedPaymentRegistry)). + /// Broadcast a raw signed `transaction` for the deferred-payment + /// [`SignedPaymentRegistry`](crate::SignedPaymentRegistry), reconciling the + /// funding reservation on failure. /// - /// `build_signed` reserves the selected inputs and leaves the reservation - /// held; when the caller decides never to broadcast, this returns those - /// inputs to spendable so a later build can reselect them. Idempotent at the - /// account layer (releasing an already-released reservation is a no-op), and - /// best-effort: a missing wallet/account is logged, not surfaced, since - /// there is nothing actionable to reconcile. + /// Same policy as + /// [`broadcast_finalized_transaction`](Self::broadcast_finalized_transaction): + /// a definitive [`BroadcastError::Rejected`] releases the reservation for an + /// immediate rebuild; an ambiguous `MaybeSent` keeps it. Unlike the + /// `StandardAccountType`-typed + /// [`broadcast_transaction_releasing_reservation`](Self::broadcast_transaction_releasing_reservation) + /// used by the immediate send path, this takes an [`AccountTypePreference`] + /// so it ALSO reconciles a CoinJoin-funded deferred payment — one whose + /// `build_signed`/`finalize` reserved the selected inputs but which has no + /// `StandardAccountType`, and which previously kept its reservation held + /// until the TTL backstop. /// - /// `account_type`/`account_index` identify the funding account handed to - /// `set_funding` when the transaction was built. + /// The release delegates to + /// [`release_transaction_reservation`](Self::release_transaction_reservation), + /// so it acts only on the wallet *generation* this handle names (a wallet + /// re-created under the same id between build and broadcast cannot have its + /// reservation freed by this token). /// - /// Named distinctly from the `AccountTypePreference`-typed - /// [`release_transaction_reservation`](Self::release_transaction_reservation) - /// (the finalized-transaction abandon path); this `StandardAccountType` - /// form serves the deferred [`SignedPaymentRegistry`](crate::SignedPaymentRegistry). - pub async fn release_payment_reservation( + /// `account_type`/`account_index` identify the funding account handed to the + /// builder when the transaction was finalized. + pub(crate) async fn broadcast_payment_releasing_reservation( &self, - account_type: StandardAccountType, + account_type: AccountTypePreference, account_index: u32, transaction: &Transaction, - ) { - release_reservation_after_rejected_broadcast( - &self.wallet_manager, - &self.wallet_id, - account_type, - account_index, - transaction, - ) - .await + ) -> Result { + match self.broadcaster.broadcast(transaction).await { + Ok(txid) => Ok(txid), + Err(error) => { + if matches!(error, BroadcastError::Rejected { .. }) { + self.release_transaction_reservation(account_type, account_index, transaction) + .await; + } + Err(error.into()) + } + } } } 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 ff312eb0735..f13a67521a0 100644 --- a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -58,7 +58,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Mutex, MutexGuard}; use dashcore::{Transaction, Txid}; -use key_wallet::account::account_type::StandardAccountType; +use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use crate::broadcaster::TransactionBroadcaster; use crate::wallet::core::CoreWallet; @@ -143,11 +143,15 @@ struct RegisteredPayment { core: CoreWallet, /// The signed transaction to broadcast. tx: Transaction, - /// The funding account whose reservation must be released on a rejected - /// broadcast or an explicit release. `None` for a CoinJoin funding, which - /// has no standard-account reservation to reconcile (it rides the - /// TTL backstop), mirroring `CoreAccountTypeFFI::as_standard_account_type`. - account_type: Option, + /// The releasable funding-account handle — the account whose reservation + /// `finalize` took and which a rejected broadcast or an explicit release + /// must reconcile. An [`AccountTypePreference`] (not the narrower + /// `StandardAccountType`) so CoinJoin-funded deferred payments retain a + /// releasable handle too: `finalize` reserves the selected inputs for EVERY + /// account variant, so a CoinJoin token must be able to release them + /// immediately on rejection/abandon rather than stranding them until the + /// key-wallet TTL backstop. + account_type: AccountTypePreference, account_index: u32, /// Wallet `last_processed_height` captured at registration — the exact clock /// `build_signed` / `finalize_transaction` stamps the funding reservation @@ -220,7 +224,7 @@ impl SignedPaymentRegistry { &self, core: CoreWallet, tx: Transaction, - account_type: Option, + account_type: AccountTypePreference, account_index: u32, registered_height: Option, ) -> ReservationToken { @@ -300,19 +304,18 @@ impl SignedPaymentRegistry { return Err(SignedPaymentError::StaleReservationToken(token)); } - let txid = match entry.account_type { - Some(account_type) => { - entry - .core - .broadcast_transaction_releasing_reservation( - account_type, - entry.account_index, - &entry.tx, - ) - .await? - } - None => entry.core.broadcast_transaction(&entry.tx).await?, - }; + // 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 + .core + .broadcast_payment_releasing_reservation( + entry.account_type, + entry.account_index, + &entry.tx, + ) + .await?; Ok(txid) } @@ -321,7 +324,8 @@ impl SignedPaymentRegistry { /// outpoint may already have been swept and re-selected by an unrelated /// build, so releasing it by outpoint could free that newer reservation — /// drop it without touching the `ReservationSet` (key-wallet's TTL reclaims - /// the original). Otherwise release the standard-account reservation. + /// the original). Otherwise release the funding-account reservation (any + /// variant, CoinJoin included), bound to the token's own wallet generation. async fn reconcile_removed_entry(entry: RegisteredPayment) { if reservation_expired( entry.registered_height, @@ -329,12 +333,10 @@ impl SignedPaymentRegistry { ) { return; } - if let Some(account_type) = entry.account_type { - entry - .core - .release_payment_reservation(account_type, entry.account_index, &entry.tx) - .await; - } + entry + .core + .release_transaction_reservation(entry.account_type, entry.account_index, &entry.tx) + .await; } /// Release the funding reservation behind `token` and drop it. Idempotent: @@ -430,12 +432,24 @@ 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 super::{SignedPaymentError, SignedPaymentRegistry, RESERVATION_MAX_AGE_BLOCKS}; use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; use crate::test_support::{funded_wallet_manager, AlwaysMaybeSentBroadcaster, WalletSigner}; use crate::wallet::core::CoreWallet; + + /// The [`AccountTypePreference`] a `build_signed_tx` funding account maps to + /// — the registry now retains the full account handle (CoinJoin included), + /// so the tests register with the preference rather than the narrower + /// `StandardAccountType`. + fn preference(account_type: StandardAccountType) -> AccountTypePreference { + match account_type { + StandardAccountType::BIP44Account => AccountTypePreference::BIP44, + StandardAccountType::BIP32Account => AccountTypePreference::BIP32, + } + } use crate::PlatformWalletError; /// Broadcaster that records the exact bytes handed to it and succeeds, @@ -503,6 +517,19 @@ mod tests { (core, signer, vec![(recipient, 1_000_000u64)]) } + /// A testnet `CoreWallet` whose CoinJoin account 0 holds the funded UTXO — + /// the fixture for the CoinJoin-funded deferred-payment reservation tests. + async fn funded_coinjoin_core_wallet( + broadcaster: Arc, + ) -> (CoreWallet, WalletSigner, Vec<(DashAddress, u64)>) { + let (wallet_manager, wallet_id, balance, signer) = + crate::test_support::funded_coinjoin_wallet_manager().await; + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let core = CoreWallet::new(sdk, wallet_manager, wallet_id, broadcaster, balance); + let recipient = DashAddress::dummy(Network::Testnet, 42); + (core, signer, vec![(recipient, 1_000_000u64)]) + } + /// Build + sign a payment exactly as the deferred send path does: /// `build_signed` reserves the inputs and leaves the reservation held for /// the later broadcast/release. @@ -589,7 +616,7 @@ mod tests { .register( core.clone(), tx, - Some(StandardAccountType::BIP44Account), + AccountTypePreference::BIP44, 0, core.last_processed_height().await, ) @@ -631,7 +658,7 @@ mod tests { .register( core.clone(), tx, - Some(account_type), + preference(account_type), 0, core.last_processed_height().await, ) @@ -657,6 +684,92 @@ mod tests { } } + /// Regression for the deferred CoinJoin reservation leak: a CoinJoin-funded + /// deferred payment reserves its inputs (finalize reserves for EVERY account + /// variant), so releasing/abandoning it must free that reservation + /// immediately — not strand it until key-wallet's 24-block TTL. Before the + /// fix the registry entry carried only a `StandardAccountType`, so a CoinJoin + /// funding (which has none) reconciled nothing on release. + /// + /// Uses the production `finalize_transaction` path (the atomic + /// select+reserve+sign the FFI runs), which is the only builder that funds a + /// CoinJoin account, then registers/releases through the registry exactly as + /// `core_wallet_signed_payment_finalize` / `_release` do. The CoinJoin + /// funding path is a sweep (`SelectionStrategy::All`): the single output + /// drains the input minus fee, so no change address is derived — the only + /// shape a non-standard CoinJoin account can fund. + #[tokio::test] + async fn coinjoin_funded_release_frees_the_reservation_immediately() { + // A CoinJoin sweep of the funded account to a single recipient. + fn sweep_builder(recipient: &DashAddress) -> TransactionBuilder { + TransactionBuilder::new() + .set_selection_strategy(SelectionStrategy::All) + .add_output(recipient, 1_000_000) + } + + let broadcaster = Arc::new(RecordingBroadcaster::new()); + let (core, signer, outputs) = funded_coinjoin_core_wallet(broadcaster).await; + let recipient = outputs[0].0.clone(); + let registry = SignedPaymentRegistry::new(); + + // finalize: atomic select + reserve + sign against the CoinJoin account. + let finalized = core + .finalize_transaction( + sweep_builder(&recipient), + AccountTypePreference::CoinJoin, + 0, + &signer, + ) + .await + .expect("coinjoin finalize should succeed"); + + let token = registry + .register( + core.clone(), + finalized.transaction().clone(), + AccountTypePreference::CoinJoin, + 0, + Some(finalized.reservation_height()), + ) + .await; + + // Reservation held: a second CoinJoin finalize finds no unreserved input. + let blocked = core + .finalize_transaction( + sweep_builder(&recipient), + AccountTypePreference::CoinJoin, + 0, + &signer, + ) + .await; + assert!( + matches!( + blocked, + Err(PlatformWalletError::CoreInsufficientFunds { .. }) + ), + "rebuild must fail while the CoinJoin reservation is held, got {blocked:?}" + ); + + // Abandon/nack: the release MUST free the CoinJoin reservation now, not + // strand it until the TTL backstop. + registry.release(token).await; + assert_eq!(registry.outstanding(), 0, "token consumed after release"); + + let rebuilt = core + .finalize_transaction( + sweep_builder(&recipient), + AccountTypePreference::CoinJoin, + 0, + &signer, + ) + .await; + assert!( + rebuilt.is_ok(), + "releasing a CoinJoin-funded token must free its reservation immediately, \ + got {rebuilt:?}" + ); + } + /// A second broadcast of the same token is a typed `StaleToken` error, never /// a second send. #[tokio::test] @@ -679,7 +792,7 @@ mod tests { .register( core.clone(), tx, - Some(StandardAccountType::BIP44Account), + AccountTypePreference::BIP44, 0, core.last_processed_height().await, ) @@ -722,7 +835,7 @@ mod tests { .register( core.clone(), tx, - Some(StandardAccountType::BIP44Account), + AccountTypePreference::BIP44, 0, core.last_processed_height().await, ) @@ -756,7 +869,7 @@ mod tests { .register( core.clone(), tx, - Some(StandardAccountType::BIP44Account), + AccountTypePreference::BIP44, 0, core.last_processed_height().await, ) @@ -818,7 +931,7 @@ mod tests { .register( core_a.clone(), tx, - Some(StandardAccountType::BIP44Account), + AccountTypePreference::BIP44, 0, core_a.last_processed_height().await, ) @@ -864,7 +977,7 @@ mod tests { .register( core.clone(), tx, - Some(StandardAccountType::BIP44Account), + AccountTypePreference::BIP44, 0, core.last_processed_height().await, ) @@ -920,7 +1033,7 @@ mod tests { .register( core.clone(), tx, - Some(StandardAccountType::BIP44Account), + AccountTypePreference::BIP44, 0, core.last_processed_height().await, ) @@ -979,7 +1092,7 @@ mod tests { handles.push(tokio::spawn(async move { let height = core.last_processed_height().await; registry - .register(core, tx, Some(StandardAccountType::BIP44Account), 0, height) + .register(core, tx, AccountTypePreference::BIP44, 0, height) .await })); } @@ -1036,7 +1149,7 @@ mod tests { .register( core.clone(), tx, - Some(StandardAccountType::BIP44Account), + AccountTypePreference::BIP44, 0, core.last_processed_height().await, ) @@ -1101,7 +1214,7 @@ mod tests { .register( core.clone(), tx, - Some(StandardAccountType::BIP44Account), + AccountTypePreference::BIP44, 0, core.last_processed_height().await, ) @@ -1151,7 +1264,7 @@ mod tests { .register( core.clone(), tx, - Some(StandardAccountType::BIP44Account), + AccountTypePreference::BIP44, 0, core.last_processed_height().await, ) @@ -1211,7 +1324,7 @@ mod tests { .register( core_a.clone(), tx_a, - Some(StandardAccountType::BIP44Account), + AccountTypePreference::BIP44, 0, core_a.last_processed_height().await, ) @@ -1229,7 +1342,7 @@ mod tests { .register( core_b.clone(), tx_b, - Some(StandardAccountType::BIP44Account), + AccountTypePreference::BIP44, 0, core_b.last_processed_height().await, ) @@ -1290,7 +1403,7 @@ mod tests { .register( core.clone(), tx, - Some(StandardAccountType::BIP44Account), + AccountTypePreference::BIP44, 0, core.last_processed_height().await, ) @@ -1363,7 +1476,7 @@ mod tests { .register( core_a.clone(), tx, - Some(StandardAccountType::BIP44Account), + AccountTypePreference::BIP44, 0, core_a.last_processed_height().await, ) @@ -1450,7 +1563,7 @@ mod tests { .register( core.clone(), tx, - Some(StandardAccountType::BIP44Account), + AccountTypePreference::BIP44, 0, Some(reservation_height), ) From 74134974f5b53db34eaeee6962d09f87d1fc8c86 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:40:07 -0400 Subject: [PATCH 19/24] fix(kotlin-sdk): bind deferred-payment reservation cleanup to its own wallet generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deferred registry validated a token's generation at the registry lock, then released its reservation later, off that lock. `ReservationSet::release` removes an outpoint unconditionally and is reached via `wallet_id` — an identity a same-id remove-then-recreate preserves — so a wallet re-created in that window could have the NEW generation's reservation freed by the old token's cleanup. Bind the cleanup to the token's own generation: `release_transaction_reservation` now re-validates the generation and mutates the `ReservationSet` under a single manager read-lock hold, acting only when the wallet still registered under the id carries the same per-generation balance `Arc` the handle captured. A recreation needs the manager write lock, so it cannot interleave between the check and the release — validate-and-mutate is atomic. This protects both the registry (release/abandon and broadcast-on-rejection) and the V2 finalized-transaction handle path, which share this primitive. Adds `CoreWallet::generation()`. Test: `recreation_between_validation_and_cleanup_cannot_release_new_generation` recreates the wallet under the same id between registration and release and asserts the input stays reserved (the reservation the new generation owns is untouched). Co-Authored-By: Claude Opus 4.8 --- .../src/wallet/core/transaction.rs | 49 ++++++-- .../src/wallet/core/wallet.rs | 11 ++ .../src/wallet/signed_payment_registry.rs | 105 +++++++++++++++++- 3 files changed, 157 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 049cfa0e571..5547cc1d5fb 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -6,6 +6,7 @@ //! resolver without pinning wallet state. use std::collections::HashMap; +use std::sync::Arc; use dashcore::{Address, Transaction}; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; @@ -260,19 +261,53 @@ impl CoreWallet { account_index: u32, transaction: &Transaction, ) { + // Validate the generation AND mutate the `ReservationSet` under one + // manager-lock hold. `ReservationSet::release` removes an outpoint + // unconditionally, and it is reached via `wallet_id` — an identity that a + // remove-then-recreate under the same id preserves. Between a token's + // generation validation and this cleanup the wallet could therefore have + // been re-created, and an unguarded release-by-outpoint could then free + // the NEW generation's reservation on the same input. + // + // Binding the release to this handle's own generation closes that + // window: the wallet registered under `wallet_id` is the same generation + // as `self` iff their per-generation balance `Arc`s are pointer-equal + // (`wallet_id` + the shared manager `Arc` are both preserved across a + // recreation; only the balance `Arc` is fresh — the same identity + // `is_same_generation` uses). A read lock is enough and makes this atomic + // against recreation: a recreate needs the manager *write* lock, so it + // cannot interleave between the pointer check and the release below. let manager = self.wallet_manager.read().await; - let managed = manager.get_wallet_info(&self.wallet_id).and_then(|info| { - managed_account(&info.core_wallet.accounts, account_type, account_index) - }); - if let Some(managed) = managed { - managed.release_reservation(transaction); - } else { + let Some(info) = manager.get_wallet_info(&self.wallet_id) else { tracing::warn!( wallet_id = %hex::encode(self.wallet_id), ?account_type, account_index, - "could not release finalized Core transaction reservation" + "could not release finalized Core transaction reservation: wallet not found" ); + return; + }; + if !Arc::ptr_eq(&info.balance, self.generation()) { + // The wallet under this id is a different (re-created) generation: + // releasing by outpoint could free ITS reservation. Leave it — the + // original generation's reservation ceased to exist with it. + tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + ?account_type, + account_index, + "skipping reservation release: wallet was re-created under the same id \ + (different generation) since the token was minted" + ); + return; + } + match managed_account(&info.core_wallet.accounts, account_type, account_index) { + Some(managed) => managed.release_reservation(transaction), + None => tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + ?account_type, + account_index, + "could not release finalized Core transaction reservation: account not found" + ), } } } diff --git a/packages/rs-platform-wallet/src/wallet/core/wallet.rs b/packages/rs-platform-wallet/src/wallet/core/wallet.rs index 101727c3e42..832df6bc2f9 100644 --- a/packages/rs-platform-wallet/src/wallet/core/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/core/wallet.rs @@ -97,6 +97,17 @@ impl CoreWallet { && Arc::ptr_eq(&self.balance, &other.balance) } + /// This handle's per-generation balance `Arc` — the generation-identity + /// marker (see [`is_same_generation`](Self::is_same_generation)). The + /// manager stores the same `Arc` in `PlatformWalletInfo.balance`, so a + /// reservation-cleanup path can, **under the manager lock**, compare this + /// against the wallet currently registered under `wallet_id` and act only if + /// they are the same generation — binding a validate-then-mutate to one lock + /// hold and refusing to touch a generation re-created under the same id. + pub(crate) fn generation(&self) -> &Arc { + &self.balance + } + pub async fn set_gap_limit( &self, account_type: AccountTypePreference, 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 f13a67521a0..4983a505777 100644 --- a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -29,7 +29,17 @@ //! wallet under the same id whose in-memory `ReservationSet` no longer holds //! the inputs, are both told apart: broadcasting through either is a //! [`SignedPaymentError::WalletMismatch`] rather than a spend against stale -//! state. +//! state. That check happens at the registry lock, but the reservation +//! cleanup that follows it runs later, off the registry lock — so the +//! check-then-cleanup is *not* one atomic step against a same-id recreation. +//! The cleanup is made safe on its own: every reservation release +//! ([`CoreWallet::release_transaction_reservation`]) re-validates the +//! generation and mutates the `ReservationSet` under a single manager-lock +//! hold, acting only if the wallet still registered under the id is the same +//! generation the token captured (its per-generation balance `Arc`). A +//! 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 //! wallet's `last_processed_height` has advanced far enough past the height at //! which `build_signed` / `finalize_transaction` stamped the reservation that @@ -1585,4 +1595,97 @@ mod tests { "the network must not have been hit" ); } + + /// Replace the wallet's per-generation balance `Arc` under the manager write + /// lock, modelling a same-id remove-then-recreate: `wallet_id`, the manager + /// `Arc`, and the account `ReservationSet` (with the token's input still + /// reserved) are all preserved, only the generation marker is fresh. The + /// still-reserved input now conceptually belongs to the NEW generation. + async fn simulate_same_id_recreation(core: &CoreWallet) { + 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.balance = Arc::new(crate::wallet::core::WalletBalance::new()); + } + + /// Regression for the non-atomic generation-validation + cleanup: a token's + /// generation is validated at the registry lock, but its reservation cleanup + /// runs later off that lock. If the wallet is removed and re-created under + /// the SAME id in that window, an unguarded release-by-outpoint would free + /// the NEW generation's reservation on the same input. + /// + /// This test recreates the generation (same id, fresh balance `Arc`) between + /// registration and the release, then releases the now-stale token and + /// asserts the reservation SURVIVES — the release, bound to the token's own + /// generation under the manager lock, refuses to touch the re-created + /// generation. Under the pre-fix unconditional release the rebuild below + /// would succeed (the leak the reviewer flagged); with the guard it must + /// still fail. + #[tokio::test] + async fn recreation_between_validation_and_cleanup_cannot_release_new_generation() { + let broadcaster = Arc::new(RecordingBroadcaster::new()); + let (core, signer, outputs) = + funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; + let registry = SignedPaymentRegistry::new(); + + let tx = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register( + core.clone(), + tx, + AccountTypePreference::BIP44, + 0, + core.last_processed_height().await, + ) + .await; + + // Reservation held: a rebuild fails at input selection. + let blocked = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await; + assert!( + matches!(blocked, Err(PlatformWalletError::TransactionBuild(_))), + "rebuild must fail while the reservation is held, got {blocked:?}" + ); + + // Same-id wallet recreation between the token's validation and its + // cleanup: the wallet under this id is now a DIFFERENT generation. + simulate_same_id_recreation(&core).await; + + // Old cleanup runs. The token is dropped, but its release must NOT touch + // the re-created generation's reservation. + registry.release(token).await; + assert_eq!(registry.outstanding(), 0, "the stale token is dropped"); + + // The (new generation's) reservation on the input SURVIVES: a rebuild + // still cannot reselect it. Pre-fix, the unconditional release-by-outpoint + // would have freed it and this rebuild would succeed. + let rebuilt = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await; + assert!( + matches!(rebuilt, Err(PlatformWalletError::TransactionBuild(_))), + "a stale token's cleanup must NOT release a re-created generation's \ + reservation, got {rebuilt:?}" + ); + } } From 199645bd62e583c90d34290cb3334466e85b75ca Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:42:38 -0400 Subject: [PATCH 20/24] fix(swift-sdk): surface deferred-token codes 26/27/28 as typed errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PlatformWalletResultCode` jumped from 25 straight to 98, so the three deferred build->broadcast/release codes this PR owns (26 StaleReservationToken, 27 ReservationTokenConsumed, 28 ReservationWalletMismatch) fell through to `.errorUnknown` on iOS, erasing their distinct retry semantics. Add the three raw codes to `PlatformWalletResultCode`, matching cases to `PlatformWalletError`, and map them in both `init(ffi:)` and `init(result:)`. The `init(result:)` switch (no default) stays exhaustive — the same non-exhaustive-switch class shumkov flagged on #4184. Messages pass the Rust `Display` string straight through, matching the Kotlin SDK's mapping verbatim. Verified with `swiftc -parse` (the DashSDKFFI xcframework — cbindgen header + cdylib — is built separately by build_ios.sh and is not present in this checkout, so a full `swift build` type-check isn't possible here). Co-Authored-By: Claude Opus 4.8 --- .../PlatformWallet/PlatformWalletResult.swift | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index e741d96244f..45ef6996dd6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -66,6 +66,24 @@ public enum PlatformWalletResultCode: Int32, Sendable { case errorAssetLockNotTracked = 23 case errorAssetLockAlreadyConsumed = 24 case errorAssetLockFundingMismatch = 25 + /// A deferred (BIP70/BIP270) reservation 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. + case errorStaleReservationToken = 26 + /// A deferred reservation token is unknown, already broadcast, or already + /// released — the guard that turns a double-broadcast (or a broadcast after + /// release) into a typed error instead of a second send. The call did NOT + /// touch the network. NOT retryable: rebuild the payment. (Release is + /// idempotent and never surfaces this.) + case errorReservationTokenConsumed = 27 + /// A deferred reservation token was minted against a different wallet + /// *generation* than the one broadcasting it (e.g. a wallet re-created under + /// the same id); its reservation lives in that other generation's reservation + /// set. The call did NOT touch the network and did NOT consume the rightful + /// owner's token. NOT retryable through this handle: rebuild the payment. + case errorReservationWalletMismatch = 28 case notFound = 98 case errorUnknown = 99 @@ -123,6 +141,12 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorAssetLockAlreadyConsumed case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ASSET_LOCK_FUNDING_MISMATCH: self = .errorAssetLockFundingMismatch + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_STALE_RESERVATION_TOKEN: + self = .errorStaleReservationToken + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_RESERVATION_TOKEN_CONSUMED: + self = .errorReservationTokenConsumed + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_RESERVATION_WALLET_MISMATCH: + self = .errorReservationWalletMismatch case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -242,6 +266,21 @@ public enum PlatformWalletError: LocalizedError { /// to retry, and the retry re-fetches the address nonce so the mismatch /// self-heals. The submitted/expected nonce values are in the message. case addressNonceMismatch(String) + /// A deferred (BIP70/BIP270) reservation token has outlived its funding + /// reservation's lifetime — key-wallet's TTL may already have swept and + /// re-selected the inputs. Nothing was broadcast. NOT retryable in place; + /// rebuild the payment. Sibling of `reservationTokenConsumed` and + /// `reservationWalletMismatch`, which this code used to conflate. + case staleReservationToken(String) + /// A deferred reservation token is unknown, already broadcast, or already + /// released — the double-broadcast guard. Nothing was broadcast. NOT + /// retryable; rebuild the payment. + case reservationTokenConsumed(String) + /// A deferred reservation token was minted against a different wallet + /// generation than the one broadcasting it (e.g. a wallet re-created under + /// the same id). Nothing was broadcast and the rightful owner's token was + /// not consumed. NOT retryable through this handle; rebuild the payment. + case reservationWalletMismatch(String) case notFound(String) case unknown(String) @@ -263,6 +302,8 @@ public enum PlatformWalletError: LocalizedError { .shieldedNoRecordedAnchor(let m), .transactionBroadcastUnconfirmed(let m), .addressNonceMismatch(let m), + .staleReservationToken(let m), .reservationTokenConsumed(let m), + .reservationWalletMismatch(let m), .notFound(let m), .unknown(let m): return m } @@ -302,6 +343,12 @@ public enum PlatformWalletError: LocalizedError { self = .transactionBroadcastUnconfirmed(detail) case .errorAddressNonceMismatch: self = .addressNonceMismatch(detail) + case .errorStaleReservationToken: + self = .staleReservationToken(detail) + case .errorReservationTokenConsumed: + self = .reservationTokenConsumed(detail) + case .errorReservationWalletMismatch: + self = .reservationWalletMismatch(detail) case .notFound: self = .notFound(detail) case .errorUnknown: self = .unknown(detail) } From 911c8f726441d1ec913e82ff1acc212264912eb3 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:43:04 -0400 Subject: [PATCH 21/24] docs(kotlin-sdk): correct broadcast KDoc to the split deferred-token error codes `core_wallet_signed_payment_broadcast` still documented the pre-split semantics: a repeated broadcast and a re-created wallet both as `ErrorStaleReservationToken` (26). Since the three-way split, a repeated/concurrent broadcast yields `ErrorReservationTokenConsumed` (27) and a re-created wallet generation yields `ErrorReservationWalletMismatch` (28); 26 is reserved for the aged-out case. Co-Authored-By: Claude Opus 4.8 --- .../src/core_wallet/signed_payment.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs index 4e9a3e4df41..29c792bd646 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs @@ -38,10 +38,13 @@ pub(crate) static SIGNED_PAYMENT_REGISTRY: Lazy Date: Tue, 21 Jul 2026 16:23:42 -0400 Subject: [PATCH 22/24] fix(platform-wallet): age-guard the V2 finalized-transaction handle broadcast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pinned V2 finalized-transaction handle (core_wallet_tx_builder_finalize → broadcast_finalized_transaction) had no reservation age guard, so a long-held handle could broadcast against funding inputs that key-wallet's ReservationSet TTL sweep may already have released and re-selected for an unrelated build — the same stale-release hazard the deferred registry-token path already defends against. This becomes live the moment iOS starts issuing deferred sends (follow-up requested on PR #4185). Mirror the registry-token age policy on the V2 handle path: - Hoist RESERVATION_MAX_AGE_BLOCKS (20) and reservation_expired() from signed_payment_registry into wallet::reservations so both the registry and the V2 handle path bound a reservation's lifetime against key-wallet's TTL with one shared number. - broadcast_finalized_transaction now refuses, before touching the broadcaster, once current last_processed_height - the reservation's stamp height (already carried on SignedCoreTransaction::reservation_height) >= the shared bound, returning the new token-less PlatformWalletError::StaleReservation. The stale reservation is left for key-wallet's TTL to reclaim (never released by outpoint, which could free a newer build's reservation). The check runs after the FFI layer's generation-identity check, matching the registry ordering. - The FFI reuses the existing ErrorStaleReservationToken (26) code for this variant (documented as shared between the registry-token and V2-handle surfaces); no new codes allocated. - Abandon/free (abandon_transaction) remain allowed at any age — releasing an old reservation is always safe. Tests: fresh handle broadcasts; aged handle refuses with StaleReservation yet still abandons cleanly and frees its inputs; exact boundary at the threshold (BIP44/BIP32); FFI mapping of StaleReservation to the shared code. Co-Authored-By: Claude Fable 5 --- packages/rs-platform-wallet-ffi/src/error.rs | 46 ++++ packages/rs-platform-wallet/src/error.rs | 21 ++ .../src/wallet/core/broadcast.rs | 197 +++++++++++++++++- .../src/wallet/reservations.rs | 46 ++++ .../src/wallet/signed_payment_registry.rs | 51 ++--- 5 files changed, 321 insertions(+), 40 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 81273bc26ad..e951b362e90 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -182,6 +182,16 @@ pub enum PlatformWalletFFIResultCode { /// [`Self::ErrorReservationWalletMismatch`] (28, 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 — releasing an aged reservation is always allowed. ErrorStaleReservationToken = 26, /// Maps `SignedPaymentError::StaleToken`. The deferred reservation token is @@ -338,6 +348,14 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::TransactionBroadcastUnconfirmed(..) => { PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed } + // 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 @@ -792,6 +810,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` (26) 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 6e11514beaa..b73202c49ab 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -90,6 +90,27 @@ 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 (releasing an old reservation is always safe). + /// + /// 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/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 299dc4df464..1d355d60775 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -4,16 +4,44 @@ use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePr 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 { /// Broadcast an atomically finalized transaction. A definitive rejection /// releases its reservation; an ambiguous `MaybeSent` outcome retains it. + /// + /// # 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)) skip this guard — + /// releasing an old reservation is always safe. pub async fn broadcast_finalized_transaction( &self, transaction: &SignedCoreTransaction, ) -> Result { + if reservation_expired( + Some(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) => { @@ -141,14 +169,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. @@ -230,6 +261,166 @@ 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 { + 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 + .expect("finalize should succeed") + } + + /// 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`), yet must still abandon cleanly at any age: releasing an + /// old reservation returns the inputs so an immediate rebuild can reselect + /// them. + #[tokio::test] + async fn aged_finalized_handle_refuses_broadcast_but_abandons() { + 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:?}" + ); + + // Abandon is always allowed, even for an aged handle. It releases the + // reservation so an immediate rebuild can reselect the same inputs. + core.abandon_transaction(&finalized).await; + let rebuilt = finalize_tx(&core, account_type, &outputs, &signer).await; + core.abandon_transaction(&rebuilt).await; + } + } + + /// The guard boundary is exact: `current - stamped >= RESERVATION_MAX_AGE_BLOCKS` + /// refuses, one block below still broadcasts. + #[tokio::test] + async fn finalized_handle_age_guard_boundary_is_exact() { + // One below the bound: still fresh enough to broadcast. + let (below_core, below_signer, below_outputs) = funded_core_wallet( + StandardAccountType::BIP44Account, + Arc::new(AlwaysOkBroadcaster), + ) + .await; + let below_stamped = below_core + .last_processed_height() + .await + .expect("last processed height"); + let below = finalize_tx( + &below_core, + AccountTypePreference::BIP44, + &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" + ); + + // Exactly at the bound: refused. + let (at_core, at_signer, at_outputs) = funded_core_wallet( + StandardAccountType::BIP44Account, + Arc::new(AlwaysOkBroadcaster), + ) + .await; + let at_stamped = at_core + .last_processed_height() + .await + .expect("last processed height"); + let at = finalize_tx( + &at_core, + AccountTypePreference::BIP44, + &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" + ); + } + + /// 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/reservations.rs b/packages/rs-platform-wallet/src/wallet/reservations.rs index 096a7c2af12..153fa99462a 100644 --- a/packages/rs-platform-wallet/src/wallet/reservations.rs +++ b/packages/rs-platform-wallet/src/wallet/reservations.rs @@ -22,6 +22,52 @@ 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 *consume* +/// at `current_height` (see [`RESERVATION_MAX_AGE_BLOCKS`]). Unknown heights +/// (the wallet was gone at register or is gone now) disable the guard — the +/// wallet-mismatch / account-lookup / generation-identity paths already reject +/// those cases. +/// +/// Consuming (broadcasting) a stale reservation is refused; *releasing* one +/// (abandon/free) is always allowed and must never route through this check — +/// returning old inputs to the selectable pool is safe at any age. +pub(crate) fn reservation_expired( + registered_height: Option, + current_height: Option, +) -> bool { + match (registered_height, current_height) { + (Some(registered), Some(current)) => { + current.saturating_sub(registered) >= RESERVATION_MAX_AGE_BLOCKS + } + _ => 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 4983a505777..be41355d29a 100644 --- a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -40,7 +40,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 @@ -72,6 +73,10 @@ use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePr use crate::broadcaster::TransactionBroadcaster; use crate::wallet::core::CoreWallet; +// 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 @@ -81,37 +86,6 @@ use crate::PlatformWalletError; /// lifetime and never reused, so a stale token can always be recognised. pub type ReservationToken = u64; -/// 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 registered at `registered_height` is too old to act on at -/// `current_height` (see [`RESERVATION_MAX_AGE_BLOCKS`]). Unknown heights (the -/// wallet was gone at register or is gone now) disable the guard — the -/// wallet-mismatch / account-lookup paths already reject those cases. -fn reservation_expired(registered_height: Option, current_height: Option) -> bool { - match (registered_height, current_height) { - (Some(registered), Some(current)) => { - current.saturating_sub(registered) >= RESERVATION_MAX_AGE_BLOCKS - } - _ => false, - } -} - /// Failure of a deferred broadcast/release token operation. #[derive(Debug, thiserror::Error)] pub enum SignedPaymentError { @@ -128,7 +102,8 @@ pub enum SignedPaymentError { #[error("reservation token {0} was minted against a different wallet instance")] WalletMismatch(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 @@ -167,7 +142,8 @@ struct RegisteredPayment { /// `build_signed` / `finalize_transaction` stamps the funding reservation /// with. 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`]). `None` when + /// swept by key-wallet's TTL (see + /// [`RESERVATION_MAX_AGE_BLOCKS`](crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS)). `None` when /// the wallet was not resolvable at registration, which disables the age /// guard for this entry. registered_height: Option, @@ -229,7 +205,7 @@ impl SignedPaymentRegistry { /// young while the reservation it covers has already aged toward /// key-wallet's TTL. `None` disables the age guard for this entry (the /// wallet-mismatch / account-lookup paths still reject a re-created wallet). - /// See [`RESERVATION_MAX_AGE_BLOCKS`]. + /// See [`RESERVATION_MAX_AGE_BLOCKS`](crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS). pub async fn register( &self, core: CoreWallet, @@ -445,10 +421,12 @@ mod tests { use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; - use super::{SignedPaymentError, SignedPaymentRegistry, RESERVATION_MAX_AGE_BLOCKS}; + use super::{SignedPaymentError, SignedPaymentRegistry}; use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; use crate::test_support::{funded_wallet_manager, AlwaysMaybeSentBroadcaster, WalletSigner}; use crate::wallet::core::CoreWallet; + use crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS; + use crate::PlatformWalletError; /// The [`AccountTypePreference`] a `build_signed_tx` funding account maps to /// — the registry now retains the full account handle (CoinJoin included), @@ -460,7 +438,6 @@ mod tests { StandardAccountType::BIP32Account => AccountTypePreference::BIP32, } } - use crate::PlatformWalletError; /// Broadcaster that records the exact bytes handed to it and succeeds, /// so a test can assert the broadcast tx is byte-identical to the one the From c64af1a6ebfc5e679bde19b2c30f3d78b17d9afd Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:34:54 -0400 Subject: [PATCH 23/24] test+docs(v2-age-guard): boundary test covers both account types; Kotlin docs note shared code 26 Review round 2: the exact-boundary test now loops BIP44 and BIP32 (the commit previously claimed both but tested one), and the Kotlin docs for StaleReservationToken and ManagedCoreWallet.broadcastTransaction now say the V2 handle surface shares native code 26 with the deferred-token path, distinguishable by message. Co-Authored-By: Claude Fable 5 --- .../dashsdk/errors/DashSdkError.kt | 21 +++-- .../dashsdk/wallet/ManagedCoreWallet.kt | 9 +- .../src/wallet/core/broadcast.rs | 92 +++++++++---------- 3 files changed, 64 insertions(+), 58 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 8364ab155f3..e8d55cf3d3c 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 @@ -177,13 +177,20 @@ sealed class DashSdkError( ) /** - * `ErrorStaleReservationToken` (native code 26). 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 26). 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 aa3a638e1c6..df56a9110df 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 @@ -40,7 +40,14 @@ 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 26, shared with the deferred-token surface) instead of + * broadcasting against inputs key-wallet's TTL may have re-selected — + * rebuild the transaction; [abandonTransaction] works at any age. + */ fun broadcastTransaction(tx: FinalizedCoreTransaction): String = WalletManagerNative.coreWalletBroadcastSignedTransactionV2( handle, diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 1d355d60775..50093d8b878 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -353,60 +353,52 @@ mod tests { } /// The guard boundary is exact: `current - stamped >= RESERVATION_MAX_AGE_BLOCKS` - /// refuses, one block below still broadcasts. + /// 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() { - // One below the bound: still fresh enough to broadcast. - let (below_core, below_signer, below_outputs) = funded_core_wallet( - StandardAccountType::BIP44Account, - Arc::new(AlwaysOkBroadcaster), - ) - .await; - let below_stamped = below_core - .last_processed_height() - .await - .expect("last processed height"); - let below = finalize_tx( - &below_core, - AccountTypePreference::BIP44, - &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) + 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 - .is_ok(), - "one block below the bound must still broadcast" - ); + .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( - StandardAccountType::BIP44Account, - Arc::new(AlwaysOkBroadcaster), - ) - .await; - let at_stamped = at_core - .last_processed_height() - .await - .expect("last processed height"); - let at = finalize_tx( - &at_core, - AccountTypePreference::BIP44, - &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" - ); + // 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) From ea4f7834903a436e962e2953a2bf745bd9b3d9bb Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:19:09 -0400 Subject: [PATCH 24/24] fix(platform-wallet): age-guard the V2 abandon/free reservation release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shumkov (PR #4185 follow-up) found the age guard covered only broadcast: `abandon_transaction` — and therefore the `_v2_free` deinit/GC backstop and the FFI broadcast/abandon failure paths that route their cleanup through it — still released the funding reservation by outpoint unconditionally at any age. A FinalizedCoreTransaction GC'd after ~1h whose outpoint was TTL-swept (24 blocks) and re-reserved would free the newer build's reservation, letting its inputs be re-selected into a third build (conflicting spends). Honor `reservation_expired` in `abandon_transaction`, mirroring the registry's `reconcile_removed_entry`: once aged past the shared `RESERVATION_MAX_AGE_BLOCKS` bound, skip the by-outpoint release (leave the outpoint for key-wallet's TTL to reclaim) while still tearing down the handle; below the bound, release as before. This covers every consumer of `abandon_transaction`, including the `_v2_free` GC-backstop and the FFI failure paths, off the same predicate/clock the broadcast guard uses. Also correct the reservation-policy docs that claimed releasing was always safe (`reservations.rs`, `broadcast_finalized_transaction`), and the misleading ManagedCoreWallet KDoc: after a stale-refused broadcast the handle is already consumed, so `abandonTransaction` is an invalid-handle error, not a recovery — the reservation waits out the TTL. Tests: platform-wallet gains aged-skips-release / below-bound-releases pairs (BIP44+BIP32); platform-wallet-ffi gains aged `_v2_free` and aged failure-path skip-release tests via a new `age_core_past_reservation_guard` test helper. Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/wallet/ManagedCoreWallet.kt | 19 ++++- .../src/core_wallet/broadcast.rs | 71 ++++++++++++++++ .../rs-platform-wallet/src/test_support.rs | 31 +++++++ .../src/wallet/core/broadcast.rs | 84 ++++++++++++++++--- .../src/wallet/core/transaction.rs | 34 ++++++++ .../src/wallet/reservations.rs | 17 +++- 6 files changed, 238 insertions(+), 18 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 df56a9110df..770d6b29f20 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 @@ -45,8 +45,13 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { * reservation age bound throws the typed * [StaleReservationToken][org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.StaleReservationToken] * (native code 26, shared with the deferred-token surface) instead of - * broadcasting against inputs key-wallet's TTL may have re-selected — - * rebuild the transaction; [abandonTransaction] works at any age. + * 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( @@ -54,7 +59,15 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { 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 676aeafde4d..ab731720414 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -214,6 +214,27 @@ mod tests { runtime().block_on(core.abandon_transaction(&retry)); } + /// Prove the funding reservation is *still held*: a fresh finalize of the + /// same size cannot reselect the single fixture UTXO, so it fails at the + /// build stage. Used to show an aged abandon/free skipped the by-outpoint + /// release (leaving the input reserved for key-wallet's TTL). + fn assert_still_reserved(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, + )); + assert!( + rebuild.is_err(), + "aged abandon/free must skip the release, leaving the input reserved; \ + got {rebuild:?}" + ); + } + #[test] fn double_free_is_safe_and_releases_reservation() { let (core, signer) = @@ -253,6 +274,56 @@ 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_skips_reservation_release() { + 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 skipped the release: the input is still reserved. + assert_still_reserved(&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 age guard: when the handle has aged out, the failure-path + /// cleanup must skip the by-outpoint release rather than free a possibly + /// re-reserved outpoint. + #[test] + fn aged_failure_path_abandon_skips_reservation_release() { + 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 must be left alone. + let invalid = + unsafe { core_wallet_abandon_signed_transaction_v2(u64::MAX, transaction_handle) }; + assert_eq!( + invalid.code, + PlatformWalletFFIResultCode::ErrorInvalidHandle + ); + assert_still_reserved(&origin, &signer, 51); + } + #[test] fn abandon_then_free_or_broadcast_cannot_reconsume_handle() { let (core, signer) = diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index ec8b85bfd63..109466a3b2a 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 50093d8b878..6e9fbd5673a 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -30,8 +30,10 @@ impl CoreWallet { /// 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)) skip this guard — - /// releasing an old reservation is always safe. + /// ([`abandon_transaction`](Self::abandon_transaction)) honor the same bound: + /// once aged they too skip the by-outpoint release and leave the outpoint for + /// the TTL, because releasing a swept-and-re-reserved outpoint could free a + /// newer build's reservation — only below the bound do they release. pub async fn broadcast_finalized_transaction( &self, transaction: &SignedCoreTransaction, @@ -274,11 +276,28 @@ mod tests { for (addr, amount) in outputs { builder = builder.add_output(addr, *amount); } - core.finalize_transaction(builder, account_type, 0, signer) + 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 @@ -316,11 +335,13 @@ mod tests { /// 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`), yet must still abandon cleanly at any age: releasing an - /// old reservation returns the inputs so an immediate rebuild can reselect - /// them. + /// surface as `Ok`). An aged abandon/free must then **skip** the by-outpoint + /// release: key-wallet's TTL may already have swept and re-reserved the + /// outpoint, so releasing it could free a newer build's reservation. We prove + /// the skip by showing the input is still reserved after abandon — an + /// immediate rebuild cannot reselect it. #[tokio::test] - async fn aged_finalized_handle_refuses_broadcast_but_abandons() { + async fn aged_finalized_handle_refuses_broadcast_and_skips_release() { for account_type in [AccountTypePreference::BIP44, AccountTypePreference::BIP32] { let (core, signer, outputs) = funded_core_wallet( account_type_standard(account_type), @@ -344,11 +365,52 @@ mod tests { {account_type:?}, got {sent:?}" ); - // Abandon is always allowed, even for an aged handle. It releases the - // reservation so an immediate rebuild can reselect the same inputs. + // Aged abandon skips the by-outpoint release. The reservation is left + // for key-wallet's TTL, so the input stays reserved and a rebuild + // cannot reselect it (it would surface as a build/insufficient-funds + // error). + core.abandon_transaction(&finalized).await; + let rebuilt = try_finalize_tx(&core, account_type, &outputs, &signer).await; + assert!( + rebuilt.is_err(), + "aged abandon must skip the release, leaving the input reserved \ + for {account_type:?}, got {rebuilt:?}" + ); + } + } + + /// 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; - let rebuilt = finalize_tx(&core, account_type, &outputs, &signer).await; - core.abandon_transaction(&rebuilt).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; } } diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 5547cc1d5fb..af9ba2eb1d7 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, Utxo}; use super::CoreWallet; use crate::broadcaster::TransactionBroadcaster; +use crate::wallet::reservations::reservation_expired; use crate::PlatformWalletError; fn map_builder_error( @@ -246,7 +247,40 @@ 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. + /// + /// Once the reservation has aged past + /// [`RESERVATION_MAX_AGE_BLOCKS`](crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS), + /// key-wallet's `ReservationSet` TTL may already have swept its outpoint and + /// let an unrelated build re-reserve it; releasing by outpoint now would free + /// that newer build's reservation (`ReservationSet::release` is + /// unconditional). So the aged path **skips** the by-outpoint release and + /// leaves the outpoint for the TTL to reclaim, dropping only the handle — + /// exactly the policy the deferred registry's `reconcile_removed_entry` + /// applies. Below the bound the reservation is provably still ours and is + /// released so an immediate rebuild can reselect the inputs. pub async fn abandon_transaction(&self, transaction: &SignedCoreTransaction) { + if reservation_expired( + Some(transaction.reservation_height), + self.last_processed_height().await, + ) { + // Aged past the shared bound: the outpoint may have been swept and + // re-reserved by an unrelated build. Leave it for key-wallet's TTL; + // releasing it here could free that newer reservation. The handle + // itself is already dropped by the caller (FFI storage removal). + 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 153fa99462a..045d4c596e4 100644 --- a/packages/rs-platform-wallet/src/wallet/reservations.rs +++ b/packages/rs-platform-wallet/src/wallet/reservations.rs @@ -47,15 +47,24 @@ 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; -/// Whether a reservation stamped at `registered_height` is too old to *consume* +/// Whether a reservation stamped at `registered_height` is too old to act on /// at `current_height` (see [`RESERVATION_MAX_AGE_BLOCKS`]). Unknown heights /// (the wallet was gone at register or is gone now) disable the guard — the /// wallet-mismatch / account-lookup / generation-identity paths already reject /// those cases. /// -/// Consuming (broadcasting) a stale reservation is refused; *releasing* one -/// (abandon/free) is always allowed and must never route through this check — -/// returning old inputs to the selectable pool is safe at any age. +/// 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`. pub(crate) fn reservation_expired( registered_height: Option, current_height: Option,