From 8f14318a7f5ff6da0a6cf817fa9ac9753fb107ff 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/47] 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 9c57d763b10..a20a6590a44 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 @@ -239,6 +239,20 @@ sealed class DashSdkError( class NotFound(message: String, cause: Throwable? = null) : PlatformWallet(message, 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 @@ -345,6 +359,7 @@ sealed class DashSdkError( 23 -> PlatformWallet.AssetLockNotTracked(message, cause) // ErrorAssetLockNotTracked 24 -> PlatformWallet.AssetLockAlreadyConsumed(message, cause) // ErrorAssetLockAlreadyConsumed 25 -> PlatformWallet.AssetLockFundingMismatch(message, cause) // ErrorAssetLockFundingMismatch + 34 -> PlatformWallet.StaleReservationToken(message, cause) // ErrorStaleReservationToken // ErrorSigningKeyUnavailable — the STRUCTURED signer // discriminator (dashpay/platform#4060 finding 7): the typed // completion code rides the whole Rust round-trip, no message 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 205fe225ba0..1b347214374 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 @@ -249,6 +249,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 27c846cd3b9..fcc90dc9234 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 @@ -173,6 +173,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 8879712206c..ca571d0de5f 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 @@ -115,6 +115,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 6676678cb6e..a065d2e07bc 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -235,6 +235,15 @@ pub enum PlatformWalletFFIResultCode { /// wallet-operation failure. Not retryable as-is — the key must be /// (re-)derived first. ErrorSigningKeyUnavailable = 31, + /// 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 = 34, 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 78c25061fa6..d9c0cd52b28 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1225,6 +1225,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 dd04607b37e27ee44db33f442577ab38aca5d028 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/47] 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 1b347214374..55f1bcb204e 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 @@ -249,14 +249,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 @@ -266,8 +258,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 d9c0cd52b28..4d95cdf7d09 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1234,45 +1234,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, @@ -1280,8 +1241,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, @@ -1308,6 +1270,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, @@ -1317,6 +1281,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) { @@ -1332,16 +1298,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 f09a7e1ee7dde8c41eae8ffb37eb2e70048e4dd7 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/47] 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 fcc90dc9234..523e55c58a8 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 @@ -224,11 +224,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 @@ -244,7 +245,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 }) { @@ -254,28 +255,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) } } } } @@ -286,8 +285,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 43a2414e2d3d0271ca37a8d3d44585c84d42981e 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/47] 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 ca571d0de5f..330f241a84b 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 @@ -118,7 +118,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 f6fbda8f278795db562367dc8188d83622b8c182 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/47] 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 5c7a461b67d9ae56621d4bcd1b8d08d29769bf45 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/47] 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 8dcd345ae95c6d0d50a53d4fd40739af78addea2 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/47] 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 55f1bcb204e..eede9ca39c5 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 @@ -268,6 +268,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 523e55c58a8..3e4163c018a 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 @@ -213,6 +213,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, + ) + } + } } /** @@ -256,25 +282,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 4d95cdf7d09..780fb8e8568 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1328,10 +1328,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 35a2335a3e6fe52a823167877a5356b37e6e9b99 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/47] =?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 eede9ca39c5..b5e8ddd8910 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 @@ -249,38 +249,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 3e4163c018a..466d9f40fdd 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 @@ -216,10 +216,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 780fb8e8568..1d4d26701ea 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1228,119 +1228,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 cd97c85234319c45740e65422f3e4715d28b71ab 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/47] 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 bfd58fc5e6b07f4ce5872a82ee70f5842602ce3c 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/47] 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 26fa825fa50..a6978c60bdc 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -47,11 +47,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 local_txid = finalized.transaction.transaction().txid(); @@ -90,7 +93,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 @@ -98,7 +102,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 b2784e5dfd58b5b7f02d3e51d2bece81fdc24293 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/47] 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 489e4563f08ba520a230eed93f181b616db38aaa 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/47] 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 c3aba1491b1..b6a051211c8 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -520,7 +520,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 9758f3ca4a92146f01c67276539e188b81d7a64a 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/47] 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 a20a6590a44..4ba10cc8f72 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 @@ -242,17 +242,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 @@ -360,6 +389,8 @@ sealed class DashSdkError( 24 -> PlatformWallet.AssetLockAlreadyConsumed(message, cause) // ErrorAssetLockAlreadyConsumed 25 -> PlatformWallet.AssetLockFundingMismatch(message, cause) // ErrorAssetLockFundingMismatch 34 -> PlatformWallet.StaleReservationToken(message, cause) // ErrorStaleReservationToken + 35 -> PlatformWallet.ReservationTokenConsumed(message, cause) // ErrorReservationTokenConsumed + 36 -> PlatformWallet.ReservationWalletMismatch(message, cause) // ErrorReservationWalletMismatch // ErrorSigningKeyUnavailable — the STRUCTURED signer // discriminator (dashpay/platform#4060 finding 7): the typed // completion code rides the whole Rust round-trip, no message 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 b5e8ddd8910..9966c5b5620 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 @@ -272,10 +272,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 466d9f40fdd..3b5ab52f4c5 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 @@ -306,11 +306,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 330f241a84b..85e4574d484 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 @@ -116,15 +116,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 a065d2e07bc..5dda2494c6d 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -235,16 +235,37 @@ pub enum PlatformWalletFFIResultCode { /// wallet-operation failure. Not retryable as-is — the key must be /// (re-)derived first. ErrorSigningKeyUnavailable = 31, - /// 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`] (35, + /// unknown / already broadcast / already released) and + /// [`Self::ErrorReservationWalletMismatch`] (36, minted against a different + /// wallet generation). All three are non-retryable-in-place and none touched + /// the network; they are distinct codes so a host can message each precisely. ErrorStaleReservationToken = 34, + /// 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 = 35, + + /// 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 = 36, + 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 1d4d26701ea..88b4706a909 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1363,10 +1363,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 1803197dba96201ea322733735be077958c04fe7 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/47] 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 3b5ab52f4c5..58c89955a53 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 @@ -248,13 +248,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 ade5cffbc16148470a5c158343296f98efaf3b99 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/47] 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 58c89955a53..930bcf5369c 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 @@ -180,6 +180,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 @@ -187,14 +200,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 && @@ -214,6 +242,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 @@ -257,6 +292,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 2f596c6aae57cd120506a11c516dff108e02d9e5 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/47] 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 88b4706a909..4c0c918045a 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1287,7 +1287,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; @@ -1348,8 +1350,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 5eba39ac1bf898398e9670d7d1d2470965aa3c5b 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/47] 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 930bcf5369c..bc2ae6f34d4 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 @@ -359,6 +359,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 { @@ -366,6 +371,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 @@ -381,6 +411,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 8173cee516a17869be84257c2700ddaf29698815 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/47] 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 019ec3021c706441e28c3b2e50e83578f6a702ec 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/47] 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 48d255f5c9f7c11d00c876319a09e44630395d77 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/47] 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 a194918ad55..487bdbfcb62 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -85,6 +85,24 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// structured signer completion code (dashpay/platform#4060 finding 7). /// Route to key repair; not retryable as-is. case errorSigningKeyUnavailable = 31 + /// 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 = 34 + /// 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 = 35 + /// 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 = 36 case notFound = 98 case errorUnknown = 99 @@ -148,6 +166,12 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorShutdownIncomplete case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SIGNING_KEY_UNAVAILABLE: self = .errorSigningKeyUnavailable + 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: @@ -280,6 +304,21 @@ public enum PlatformWalletError: LocalizedError { /// (dashpay/platform#4060 finding 7); route to key repair. Kotlin /// parity: `DashSdkError.PlatformWallet.SigningKeyUnavailable`. case signingKeyUnavailable(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) @@ -304,6 +343,8 @@ public enum PlatformWalletError: LocalizedError { .addressNonceMismatch(let m), .shutdownIncomplete(let m), .signingKeyUnavailable(let m), + .staleReservationToken(let m), .reservationTokenConsumed(let m), + .reservationWalletMismatch(let m), .notFound(let m), .unknown(let m): return m } @@ -349,6 +390,12 @@ public enum PlatformWalletError: LocalizedError { self = .shutdownIncomplete(detail) case .errorSigningKeyUnavailable: self = .signingKeyUnavailable(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 4b4b7b7469291e860185fe94054833d7d137e518 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/47] chore(rust-dashcore): pin to canonical dashpay dev rev with owner-tagged reservation API Point all rust-dashcore workspace crates at dashpay/rust-dashcore 8f78baa6b7979b9bea56501ad75b5a7b7150a711, the dev merge commit of PR dashpay/rust-dashcore#916, which lands the additive owner-tagged reservation API this PR consumes: key_wallet::ReservationToken, ReservationSet::reserve/release_if_owner, TransactionBuilder::build_{unsigned,signed}_reserved, ManagedCoreFundsAccount::release_reservation_if_owner, and AssetLockResult.reservation_token. Previously pinned to bfoss765/rust-dashcore because the API existed only on the fork branch before #916 merged. Now repointed to the canonical upstream repo (no personal-fork dependency). 8f78baa6 is a strict descendant of v4.2-dev's prior pin 70d4bf8, so this is a forward-only bump. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 50 +++++++++---------- Cargo.toml | 16 +++--- .../src/core_wallet/signed_payment.rs | 11 ++-- 3 files changed, 40 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9ea5c271632..0bdbd033393 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1229,7 +1229,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" dependencies = [ "bincode", "bincode_derive", @@ -1673,7 +1673,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" dependencies = [ "dash-network", ] @@ -1750,7 +1750,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" dependencies = [ "async-trait", "chrono", @@ -1779,7 +1779,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" dependencies = [ "anyhow", "base64-compat", @@ -1805,12 +1805,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" dependencies = [ "dashcore-rpc-json", "hex", @@ -1823,7 +1823,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" dependencies = [ "bincode", "dashcore", @@ -1838,7 +1838,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" dependencies = [ "bincode", "dashcore-private", @@ -2474,7 +2474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2535,7 +2535,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2904,7 +2904,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" [[package]] name = "glob" @@ -3588,7 +3588,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -3839,7 +3839,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4095,7 +4095,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" dependencies = [ "aes", "async-trait", @@ -4124,7 +4124,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4140,7 +4140,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" dependencies = [ "async-trait", "bincode", @@ -5709,7 +5709,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls", - "socket2 0.6.4", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -5747,9 +5747,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.5.10", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6556,7 +6556,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6569,7 +6569,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6628,7 +6628,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7488,7 +7488,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8937,7 +8937,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 86e5432b7ef..3c7a1ad0760 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,14 +52,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "8f78baa6b7979b9bea56501ad75b5a7b7150a711" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "8f78baa6b7979b9bea56501ad75b5a7b7150a711" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "8f78baa6b7979b9bea56501ad75b5a7b7150a711" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "8f78baa6b7979b9bea56501ad75b5a7b7150a711" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "8f78baa6b7979b9bea56501ad75b5a7b7150a711" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "8f78baa6b7979b9bea56501ad75b5a7b7150a711" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "8f78baa6b7979b9bea56501ad75b5a7b7150a711" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "8f78baa6b7979b9bea56501ad75b5a7b7150a711" } tokio-metrics = "0.5" 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: Thu, 23 Jul 2026 12:15:01 -0400 Subject: [PATCH 22/47] fix: renumber deferred-reservation error codes to 27/28/29 v4.1-dev added ErrorTransactionBroadcastRejected = 26, colliding with this PR's three deferred-reservation siblings that also claimed 26/27/28. Keep v4.1-dev's 26 and shift this PR's codes up by one: 27 = ErrorStaleReservationToken (was 26) 28 = ErrorReservationTokenConsumed (was 27) 29 = ErrorReservationWalletMismatch (was 28) 29 is free on v4.1-dev (#4184's AssetLockInsufficientFunds is not yet merged there). The Rust FFI enum and Swift bindings were renumbered in the rebase conflict resolution; this finishes the propagation through the Kotlin runtime mapping and KDoc (DashSdkError.kt, WalletManagerNative.kt), the Kotlin error-code test, and the signed_payment FFI doc comments (also recast from fix-round narration to an as-built description). Co-Authored-By: Claude Fable 5 --- .../org/dashfoundation/dashsdk/errors/DashSdkError.kt | 4 ++-- .../dashfoundation/dashsdk/ffi/WalletManagerNative.kt | 6 +++--- .../dashfoundation/dashsdk/errors/DashSdkErrorTest.kt | 6 +++--- .../src/core_wallet/signed_payment.rs | 10 +++++----- 4 files changed, 13 insertions(+), 13 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 4ba10cc8f72..c4abf7f1e23 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 @@ -257,7 +257,7 @@ sealed class DashSdkError( PlatformWallet(message, cause) /** - * `ErrorReservationTokenConsumed` (native code 27). A deferred + * `ErrorReservationTokenConsumed` (native code 28). 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 @@ -270,7 +270,7 @@ sealed class DashSdkError( PlatformWallet(message, cause) /** - * `ErrorReservationWalletMismatch` (native code 28). A deferred + * `ErrorReservationWalletMismatch` (native code 29). 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 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 9966c5b5620..d9b0b2e7300 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 @@ -273,9 +273,9 @@ internal object WalletManagerNative { * `core_wallet_signed_payment_broadcast` — broadcast the payment behind * [token], reconciling its reservation on failure and consuming the token. * 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). + * sibling codes — `ErrorStaleReservationToken` (27, aged out), + * `ErrorReservationTokenConsumed` (28, already consumed/unknown), or + * `ErrorReservationWalletMismatch` (29, different wallet generation). * [coreHandle] must resolve to the wallet the token was minted against. * Returns the txid as a lowercase hex string. */ 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 85e4574d484..ad9c740f3d4 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 @@ -118,7 +118,7 @@ class DashSdkErrorTest { // 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")) + val agedOut = DashSdkError.fromNative(DashSDKException(offset + 27, "stale token 7")) assertTrue(agedOut is DashSdkError.PlatformWallet.StaleReservationToken) assertFalse( "StaleReservationToken must NOT be retryable (rebuild the payment)", @@ -126,7 +126,7 @@ class DashSdkErrorTest { ) assertEquals("stale token 7", agedOut.message) - val consumed = DashSdkError.fromNative(DashSDKException(offset + 27, "already broadcast")) + val consumed = DashSdkError.fromNative(DashSDKException(offset + 28, "already broadcast")) assertTrue(consumed is DashSdkError.PlatformWallet.ReservationTokenConsumed) assertFalse( "ReservationTokenConsumed must NOT be retryable (rebuild the payment)", @@ -135,7 +135,7 @@ class DashSdkErrorTest { assertEquals("already broadcast", consumed.message) val walletMismatch = - DashSdkError.fromNative(DashSDKException(offset + 28, "different generation")) + DashSdkError.fromNative(DashSDKException(offset + 29, "different generation")) assertTrue(walletMismatch is DashSdkError.PlatformWallet.ReservationWalletMismatch) assertFalse( "ReservationWalletMismatch must NOT be retryable (rebuild the payment)", 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 29c792bd646..412fc92a78b 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 @@ -39,13 +39,13 @@ pub(crate) static SIGNED_PAYMENT_REGISTRY: Lazy Date: Thu, 23 Jul 2026 12:15:18 -0400 Subject: [PATCH 23/47] fix(platform-wallet): owner-guard the broadcast-reject reservation release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deferred send reserves its funding inputs at build, awaits the broadcast, and on a definitive rejection releases the reservation for an immediate rebuild. That release was unconditional (release-by-outpoint): during the broadcast await, key-wallet's TTL sweep can reclaim the reservation and a concurrent build can re-reserve the same outpoint under a new token, so the by-outpoint release would free that other build's inputs — the dashpay/platform#4185 release/re-reserve double-spend window. Capture the key_wallet::ReservationToken build_unsigned_reserved stamps onto the selected inputs, carry it on SignedCoreTransaction alongside reservation_height, thread it through the deferred registry (RegisteredPayment / register / broadcast / reconcile) and broadcast_payment_releasing_reservation, and release via ManagedCoreFundsAccount::release_reservation_if_owner so a rejected or abandoned send frees only inputs its own build still owns. The finalize sign-failure path (a platform-side await between reserve and release) is owner-guarded the same way. None (no reservation taken) keeps the old by-outpoint fallback, never reached on the funded finalize path. Adds a regression test: a rejected deferred broadcast whose outpoint was swept and re-reserved under a new token leaves that new reservation intact. Docs name the shared generation identity's sibling V2 handle path (dashpay/platform#4196). Co-Authored-By: Claude Fable 5 --- .../src/core_wallet/transaction_builder.rs | 4 + packages/rs-platform-wallet-ffi/src/wallet.rs | 3 + .../src/wallet/core/broadcast.rs | 31 ++- .../src/wallet/core/transaction.rs | 79 +++++- .../src/wallet/core/wallet.rs | 8 +- .../src/wallet/signed_payment_registry.rs | 230 +++++++++++++++--- 6 files changed, 305 insertions(+), 50 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 429b66df4c0..1b586a4e23c 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 @@ -258,6 +258,10 @@ pub unsafe extern "C" fn core_wallet_signed_payment_finalize( // captured inside finalize's funding critical section before the // external signer ran — never a fresh post-signing sample. Some(finalized.reservation_height()), + // The key-wallet reservation token finalize stamped onto the funding + // inputs, so a later broadcast-reject or release frees only inputs + // this build still owns (owner-guarded; `dashpay/platform#4185`). + finalized.reservation_token(), ), ); diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index 29fe4be476b..54450e6e626 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -480,6 +480,9 @@ mod destroy_tests { // This test exercises only the destroy-time sweep, not the // age guard, so the reservation height is irrelevant here. None, + // The dummy tx reserved nothing, so there is no funding token + // to owner-guard against — the destroy sweep drops the entry. + None, ) .await; assert_eq!(SIGNED_PAYMENT_REGISTRY.outstanding(), baseline + 1); diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 299dc4df464..0176d661d3a 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -1,6 +1,7 @@ use dashcore::Transaction; use key_wallet::account::account_type::StandardAccountType; use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; +use key_wallet::ReservationToken; use super::SignedCoreTransaction; use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; @@ -10,6 +11,14 @@ use crate::{CoreWallet, PlatformWalletError}; impl CoreWallet { /// Broadcast an atomically finalized transaction. A definitive rejection /// releases its reservation; an ambiguous `MaybeSent` outcome retains it. + /// + /// The release is owner-guarded by the finalized transaction's + /// [`reservation_token`](SignedCoreTransaction::reservation_token): the + /// broadcast is `.await`ed, and during that await key-wallet's TTL sweep can + /// reclaim this build's reservation and a concurrent build re-reserve the + /// same inputs under a new token. Releasing by outpoint alone would then + /// free that other build's inputs (the `dashpay/platform#4185` double-spend + /// window); presenting the token frees only inputs this build still owns. pub async fn broadcast_finalized_transaction( &self, transaction: &SignedCoreTransaction, @@ -22,6 +31,7 @@ impl CoreWallet { transaction.funding_account_type(), transaction.funding_account_index(), transaction.transaction(), + transaction.reservation_token(), ) .await; } @@ -108,22 +118,35 @@ impl CoreWallet { /// [`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). + /// reservation freed by this token) AND — via `token` — only on inputs this + /// build still owns. The deferred registry can hold the reservation across a + /// long build→broadcast gap, so a TTL sweep re-reserving the same inputs + /// under a new token is a real risk; the owner guard closes the + /// `dashpay/platform#4185` release/re-reserve race. /// /// `account_type`/`account_index` identify the funding account handed to the - /// builder when the transaction was finalized. + /// builder when the transaction was finalized; `token` is the + /// [`ReservationToken`] that build stamped + /// (`SignedCoreTransaction::reservation_token`), `None` only when the build + /// reserved nothing. pub(crate) async fn broadcast_payment_releasing_reservation( &self, account_type: AccountTypePreference, account_index: u32, transaction: &Transaction, + token: Option, ) -> 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; + self.release_transaction_reservation( + account_type, + account_index, + transaction, + token, + ) + .await; } Err(error.into()) } diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 5547cc1d5fb..ddc26b75266 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -17,7 +17,7 @@ use key_wallet::wallet::managed_wallet_info::transaction_builder::{ }; use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; -use key_wallet::{Account, DerivationPath, Utxo}; +use key_wallet::{Account, DerivationPath, ReservationToken, Utxo}; use super::CoreWallet; use crate::broadcaster::TransactionBroadcaster; @@ -69,6 +69,17 @@ pub struct SignedCoreTransaction { /// 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, + /// The key-wallet [`ReservationToken`] stamped onto the selected inputs when + /// `build_unsigned_reserved` reserved them, or `None` when the build took no + /// reservation (no reservation set attached — not reached on the funded + /// finalize path). Held so an abandoned or definitively-rejected send + /// releases the reservation *owner-guarded*: after this build's inputs may + /// have been swept by key-wallet's TTL and re-reserved by a concurrent build + /// under a new token, releasing by outpoint alone would free that other + /// build's inputs (the `dashpay/platform#4185` double-spend window). + /// [`ManagedCoreFundsAccount::release_reservation_if_owner`] releases only + /// inputs still owned by this token, closing that window. + reservation_token: Option, } impl SignedCoreTransaction { @@ -95,6 +106,15 @@ impl SignedCoreTransaction { pub fn reservation_height(&self) -> u32 { self.reservation_height } + + /// The key-wallet [`ReservationToken`] the funding inputs were reserved + /// under (`None` if the build reserved nothing). The broadcast/abandon + /// release paths present it to + /// [`ManagedCoreFundsAccount::release_reservation_if_owner`] so a rejected + /// or abandoned send frees only reservations this build still owns. + pub fn reservation_token(&self) -> Option { + self.reservation_token + } } fn account( @@ -143,7 +163,7 @@ impl CoreWallet { account_index: u32, signer: &S, ) -> Result { - let (unsigned, fee, selected, paths, height) = { + let (unsigned, fee, selected, paths, height, reservation_token) = { let mut manager = self.wallet_manager.write().await; let (wallet, info) = manager .get_wallet_and_info_mut(&self.wallet_id) @@ -165,13 +185,18 @@ impl CoreWallet { )) })?; - // `set_funding` observes ReservationSet and `build_unsigned` - // records its selection. There is no await between them and the - // manager write guard prevents another finalizer interleaving. - let (unsigned, fee) = builder + // `set_funding` observes ReservationSet and `build_unsigned_reserved` + // records its selection AND returns the token stamped onto the + // reserved inputs. There is no await between them and the manager + // write guard prevents another finalizer interleaving. The token + // rides in `SignedCoreTransaction` so a later abandon or rejected + // broadcast releases *only* the inputs this build still owns, even + // if a TTL sweep re-reserved them under a new token meanwhile + // (`dashpay/platform#4185`). + let (unsigned, fee, reservation_token) = builder .set_current_height(height) .set_funding(managed, &account) - .build_unsigned() + .build_unsigned_reserved() .map_err(|error| map_builder_error(error, account_type, account_index))?; let selected: Vec = match unsigned @@ -219,7 +244,7 @@ impl CoreWallet { } }; - (unsigned, fee, selected, paths, height) + (unsigned, fee, selected, paths, height, reservation_token) }; let signed = match signer @@ -230,8 +255,18 @@ impl CoreWallet { { Ok(signed) => signed, Err(error) => { - self.release_transaction_reservation(account_type, account_index, &unsigned) - .await; + // Signing awaited an (external) signer with the manager lock + // dropped, so key-wallet's TTL sweep could have reclaimed this + // build's reservation and a concurrent build re-taken the same + // inputs under a new token. Release owner-guarded so we free + // only what this build still owns. + self.release_transaction_reservation( + account_type, + account_index, + &unsigned, + reservation_token, + ) + .await; return Err(PlatformWalletError::TransactionBuild(error.to_string())); } }; @@ -242,6 +277,7 @@ impl CoreWallet { funding_account_type: account_type, funding_account_index: account_index, reservation_height: height, + reservation_token, }) } @@ -251,15 +287,27 @@ impl CoreWallet { transaction.funding_account_type, transaction.funding_account_index, &transaction.transaction, + transaction.reservation_token, ) .await; } + /// Release the funding reservation `transaction` holds, bound to this + /// handle's own wallet *generation*. + /// + /// `token` is the [`ReservationToken`] the build stamped onto the inputs + /// (`SignedCoreTransaction::reservation_token`). When present the release is + /// *owner-guarded* — it frees only inputs still owned by that token, so a + /// reservation key-wallet's TTL swept and a concurrent build re-took is left + /// untouched (`dashpay/platform#4185`). When `None` (the build reserved + /// nothing) it falls back to the unconditional by-outpoint release; that + /// path is never reached for a funded finalize, which always reserves. pub(crate) async fn release_transaction_reservation( &self, account_type: AccountTypePreference, account_index: u32, transaction: &Transaction, + token: Option, ) { // Validate the generation AND mutate the `ReservationSet` under one // manager-lock hold. `ReservationSet::release` removes an outpoint @@ -301,7 +349,16 @@ impl CoreWallet { return; } match managed_account(&info.core_wallet.accounts, account_type, account_index) { - Some(managed) => managed.release_reservation(transaction), + // Owner-guarded when the build stamped a token: even within this + // generation, a TTL sweep between build and release could have + // re-reserved the same outpoints under a new token, and an + // unconditional release would free that newer reservation. With the + // token key-wallet frees only inputs this build still owns. `None` + // (no reservation taken) falls back to the unconditional release. + Some(managed) => match token { + Some(token) => managed.release_reservation_if_owner(transaction, token), + None => managed.release_reservation(transaction), + }, None => tracing::warn!( wallet_id = %hex::encode(self.wallet_id), ?account_type, diff --git a/packages/rs-platform-wallet/src/wallet/core/wallet.rs b/packages/rs-platform-wallet/src/wallet/core/wallet.rs index 832df6bc2f9..1a9f7ccadeb 100644 --- a/packages/rs-platform-wallet/src/wallet/core/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/core/wallet.rs @@ -84,10 +84,10 @@ impl CoreWallet { /// /// 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. + /// ([`SignedPaymentRegistry`](crate::SignedPaymentRegistry), `dashpay/platform#4185`) + /// and the V2 finalized-transaction handle path (`dashpay/platform#4196`) — + /// 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, 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..a48e41b84d0 100644 --- a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -24,7 +24,8 @@ //! unknown / already-consumed token is a silent no-op. //! * 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 +//! the same identity the V2 finalized-transaction handle path +//! (`dashpay/platform#4196`) 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 @@ -69,6 +70,11 @@ use std::sync::{Mutex, MutexGuard}; use dashcore::{Transaction, Txid}; use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; +// key-wallet's UTXO-reservation token, distinct from this registry's own +// `ReservationToken` (the u64 payment handle below). Aliased so the two never +// blur: the funding token identifies the reserved *inputs* for an owner-guarded +// release, the payment handle identifies the *registered payment*. +use key_wallet::ReservationToken as FundingReservationToken; use crate::broadcaster::TransactionBroadcaster; use crate::wallet::core::CoreWallet; @@ -171,6 +177,16 @@ struct RegisteredPayment { /// the wallet was not resolvable at registration, which disables the age /// guard for this entry. registered_height: Option, + /// The key-wallet [`FundingReservationToken`] stamped onto the funding + /// inputs when `finalize_transaction` reserved them + /// (`SignedCoreTransaction::reservation_token`), or `None` if the build + /// reserved nothing. A deferred payment can sit here across many blocks, so + /// key-wallet's TTL may sweep its reservation and a concurrent build + /// re-reserve the same inputs under a new token before this entry is + /// broadcast or released. Presenting this token to the owner-guarded release + /// frees only inputs still owned by this build, never the other build's + /// (`dashpay/platform#4185`). + funding_reservation_token: Option, } /// Registry of signed-but-unsent payments keyed by [`ReservationToken`]. @@ -230,6 +246,12 @@ impl SignedPaymentRegistry { /// 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`]. + /// + /// `funding_reservation_token` MUST be the key-wallet token the build + /// stamped onto the reserved inputs (`SignedCoreTransaction::reservation_token`) + /// so a later broadcast-reject or release frees only inputs this build still + /// owns; `None` disables the owner guard (never the case for a funded + /// finalize, which always reserves). pub async fn register( &self, core: CoreWallet, @@ -237,6 +259,7 @@ impl SignedPaymentRegistry { account_type: AccountTypePreference, account_index: u32, registered_height: Option, + funding_reservation_token: Option, ) -> ReservationToken { let token = self.next_token.fetch_add(1, Ordering::SeqCst); self.lock().insert( @@ -247,6 +270,7 @@ impl SignedPaymentRegistry { account_type, account_index, registered_height, + funding_reservation_token, }, ); token @@ -324,6 +348,7 @@ impl SignedPaymentRegistry { entry.account_type, entry.account_index, &entry.tx, + entry.funding_reservation_token, ) .await?; Ok(txid) @@ -345,7 +370,12 @@ impl SignedPaymentRegistry { } entry .core - .release_transaction_reservation(entry.account_type, entry.account_index, &entry.tx) + .release_transaction_reservation( + entry.account_type, + entry.account_index, + &entry.tx, + entry.funding_reservation_token, + ) .await; } @@ -447,7 +477,9 @@ mod tests { use super::{SignedPaymentError, SignedPaymentRegistry, RESERVATION_MAX_AGE_BLOCKS}; use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; - use crate::test_support::{funded_wallet_manager, AlwaysMaybeSentBroadcaster, WalletSigner}; + use crate::test_support::{ + funded_wallet_manager, AlwaysMaybeSentBroadcaster, AlwaysRejectedBroadcaster, WalletSigner, + }; use crate::wallet::core::CoreWallet; /// The [`AccountTypePreference`] a `build_signed_tx` funding account maps to @@ -541,15 +573,17 @@ mod tests { } /// 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. + /// `build_signed_reserved` reserves the inputs, leaves the reservation held + /// for the later broadcast/release, and returns the key-wallet + /// [`ReservationToken`](key_wallet::ReservationToken) stamped onto them so + /// the test can register it for an owner-guarded release. async fn build_signed_tx( core: &CoreWallet, account_type: StandardAccountType, account_index: u32, outputs: &[(DashAddress, u64)], signer: &S, - ) -> Result { + ) -> Result<(Transaction, Option), PlatformWalletError> { let mut wm = core.wallet_manager.write().await; let (wallet, info) = wm .get_wallet_and_info_mut(&core.wallet_id()) @@ -592,13 +626,13 @@ mod tests { for (addr, amount) in outputs { builder = builder.add_output(addr, *amount); } - let (tx, _fee) = builder - .build_signed(signer, |addr| { + let (tx, _fee, reservation_token) = builder + .build_signed_reserved(signer, |addr| { managed_account.address_derivation_path(&addr) }) .await .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; - Ok(tx) + Ok((tx, reservation_token)) } /// Happy path: a registered token broadcasts the exact bytes it was built @@ -610,7 +644,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; let registry = SignedPaymentRegistry::new(); - let tx = build_signed_tx( + let (tx, reservation_token) = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -629,6 +663,7 @@ mod tests { AccountTypePreference::BIP44, 0, core.last_processed_height().await, + reservation_token, ) .await; assert_eq!(registry.outstanding(), 1); @@ -661,7 +696,7 @@ mod tests { 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) + let (tx, reservation_token) = build_signed_tx(&core, account_type, 0, &outputs, &signer) .await .expect("build should succeed"); let token = registry @@ -671,6 +706,7 @@ mod tests { preference(account_type), 0, core.last_processed_height().await, + reservation_token, ) .await; @@ -740,6 +776,7 @@ mod tests { AccountTypePreference::CoinJoin, 0, Some(finalized.reservation_height()), + finalized.reservation_token(), ) .await; @@ -789,7 +826,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; let registry = SignedPaymentRegistry::new(); - let tx = build_signed_tx( + let (tx, reservation_token) = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -805,6 +842,7 @@ mod tests { AccountTypePreference::BIP44, 0, core.last_processed_height().await, + reservation_token, ) .await; @@ -832,7 +870,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; let registry = SignedPaymentRegistry::new(); - let tx = build_signed_tx( + let (tx, reservation_token) = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -848,6 +886,7 @@ mod tests { AccountTypePreference::BIP44, 0, core.last_processed_height().await, + reservation_token, ) .await; @@ -866,7 +905,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; let registry = SignedPaymentRegistry::new(); - let tx = build_signed_tx( + let (tx, reservation_token) = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -882,6 +921,7 @@ mod tests { AccountTypePreference::BIP44, 0, core.last_processed_height().await, + reservation_token, ) .await; @@ -928,7 +968,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, broadcaster_b).await; let registry = SignedPaymentRegistry::new(); - let tx = build_signed_tx( + let (tx, reservation_token) = build_signed_tx( &core_a, StandardAccountType::BIP44Account, 0, @@ -944,6 +984,7 @@ mod tests { AccountTypePreference::BIP44, 0, core_a.last_processed_height().await, + reservation_token, ) .await; @@ -974,7 +1015,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; let registry = SignedPaymentRegistry::new(); - let tx = build_signed_tx( + let (tx, reservation_token) = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -990,6 +1031,7 @@ mod tests { AccountTypePreference::BIP44, 0, core.last_processed_height().await, + reservation_token, ) .await; @@ -1030,7 +1072,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; let registry = Arc::new(SignedPaymentRegistry::new()); - let tx = build_signed_tx( + let (tx, reservation_token) = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -1046,6 +1088,7 @@ mod tests { AccountTypePreference::BIP44, 0, core.last_processed_height().await, + reservation_token, ) .await; @@ -1083,7 +1126,7 @@ 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( + let (tx, reservation_token) = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -1102,7 +1145,7 @@ mod tests { handles.push(tokio::spawn(async move { let height = core.last_processed_height().await; registry - .register(core, tx, AccountTypePreference::BIP44, 0, height) + .register(core, tx, AccountTypePreference::BIP44, 0, height, reservation_token) .await })); } @@ -1146,7 +1189,7 @@ mod tests { .last_processed_height() .await .expect("last processed height"); - let tx = build_signed_tx( + let (tx, reservation_token) = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -1162,6 +1205,7 @@ mod tests { AccountTypePreference::BIP44, 0, core.last_processed_height().await, + reservation_token, ) .await; @@ -1211,7 +1255,7 @@ mod tests { .last_processed_height() .await .expect("last processed height"); - let tx = build_signed_tx( + let (tx, reservation_token) = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -1227,6 +1271,7 @@ mod tests { AccountTypePreference::BIP44, 0, core.last_processed_height().await, + reservation_token, ) .await; @@ -1261,7 +1306,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; let registry = SignedPaymentRegistry::new(); - let tx = build_signed_tx( + let (tx, reservation_token) = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -1277,6 +1322,7 @@ mod tests { AccountTypePreference::BIP44, 0, core.last_processed_height().await, + reservation_token, ) .await; @@ -1321,7 +1367,7 @@ mod tests { .await; let registry = SignedPaymentRegistry::new(); - let tx_a = build_signed_tx( + let (tx_a, reservation_token_a) = build_signed_tx( &core_a, StandardAccountType::BIP44Account, 0, @@ -1337,9 +1383,10 @@ mod tests { AccountTypePreference::BIP44, 0, core_a.last_processed_height().await, + reservation_token_a, ) .await; - let tx_b = build_signed_tx( + let (tx_b, reservation_token_b) = build_signed_tx( &core_b, StandardAccountType::BIP44Account, 0, @@ -1355,6 +1402,7 @@ mod tests { AccountTypePreference::BIP44, 0, core_b.last_processed_height().await, + reservation_token_b, ) .await; assert_eq!(registry.outstanding(), 2); @@ -1400,7 +1448,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; let registry = SignedPaymentRegistry::new(); - let tx = build_signed_tx( + let (tx, reservation_token) = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -1416,6 +1464,7 @@ mod tests { AccountTypePreference::BIP44, 0, core.last_processed_height().await, + reservation_token, ) .await; @@ -1473,7 +1522,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, broadcaster_b).await; let registry = SignedPaymentRegistry::new(); - let tx = build_signed_tx( + let (tx, reservation_token) = build_signed_tx( &core_a, StandardAccountType::BIP44Account, 0, @@ -1489,6 +1538,7 @@ mod tests { AccountTypePreference::BIP44, 0, core_a.last_processed_height().await, + reservation_token, ) .await; @@ -1553,7 +1603,7 @@ mod tests { .last_processed_height() .await .expect("last processed height"); - let tx = build_signed_tx( + let (tx, reservation_token) = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -1576,6 +1626,7 @@ mod tests { AccountTypePreference::BIP44, 0, Some(reservation_height), + reservation_token, ) .await; @@ -1619,9 +1670,9 @@ mod tests { /// 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. + /// generation. An unconditional release-by-outpoint would instead free the + /// new generation's reservation, and the rebuild below would succeed; the + /// generation guard makes it still fail. #[tokio::test] async fn recreation_between_validation_and_cleanup_cannot_release_new_generation() { let broadcaster = Arc::new(RecordingBroadcaster::new()); @@ -1629,7 +1680,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; let registry = SignedPaymentRegistry::new(); - let tx = build_signed_tx( + let (tx, reservation_token) = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -1645,6 +1696,7 @@ mod tests { AccountTypePreference::BIP44, 0, core.last_processed_height().await, + reservation_token, ) .await; @@ -1688,4 +1740,120 @@ mod tests { reservation, got {rebuilt:?}" ); } + + /// A funding builder over the fixture's outputs, selecting largest-first + /// like the production send path. + fn payment_builder(outputs: &[(DashAddress, u64)]) -> TransactionBuilder { + let mut builder = + TransactionBuilder::new().set_selection_strategy(SelectionStrategy::LargestFirst); + for (addr, amount) in outputs { + builder = builder.add_output(addr, *amount); + } + builder + } + + /// Unconditionally release `tx`'s input reservation on the BIP44 account, + /// modelling key-wallet's TTL sweep returning the outpoint to the selectable + /// pool — WITHOUT touching the registry entry, which still holds the token. + async fn force_release_reservation( + core: &CoreWallet, + tx: &Transaction, + ) { + let wm = core.wallet_manager.read().await; + let (_, info) = wm + .get_wallet_and_info(&core.wallet_id()) + .expect("wallet present in manager"); + info.core_wallet + .accounts + .standard_bip44_accounts + .get(&0) + .expect("bip44 managed account") + .release_reservation(tx); + } + + /// Owner-guarded release regression (`dashpay/platform#4185`): a rejected + /// deferred broadcast must free ONLY the inputs its own build still owns. If + /// key-wallet's TTL swept this build's reservation and a concurrent build + /// re-reserved the same outpoint under a new token, the rejection's release + /// must leave that other build's reservation intact — freeing it would let + /// coin selection hand the outpoint to a third build and double-spend it. + /// The registry threads the build's key-wallet `ReservationToken` to the + /// reject path, so the release is owner-guarded rather than by-outpoint. + #[tokio::test] + async fn rejected_broadcast_releases_only_its_own_reservation_not_one_retaken_after_a_sweep() { + let broadcaster = Arc::new(AlwaysRejectedBroadcaster); + let (core, signer, outputs) = + funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; + let registry = SignedPaymentRegistry::new(); + + // Build 1 reserves the sole funding UTXO under token T1 and registers it + // for deferred submission. + let finalized = core + .finalize_transaction( + payment_builder(&outputs), + AccountTypePreference::BIP44, + 0, + &signer, + ) + .await + .expect("first finalize should succeed"); + let token = registry + .register( + core.clone(), + finalized.transaction().clone(), + AccountTypePreference::BIP44, + 0, + Some(finalized.reservation_height()), + finalized.reservation_token(), + ) + .await; + + // Model key-wallet's TTL sweep: the outpoint returns to the selectable + // pool, but the registry still holds T1. + force_release_reservation(&core, finalized.transaction()).await; + + // A concurrent build re-selects and re-reserves that same outpoint under + // a NEW token T2. Held alive so its reservation persists to the end. + let retaken = core + .finalize_transaction( + payment_builder(&outputs), + AccountTypePreference::BIP44, + 0, + &signer, + ) + .await + .expect("re-reserving finalize should succeed after the sweep"); + + // Build 1's deferred broadcast is definitively rejected. Its release is + // owner-guarded by T1, so it must NOT free T2's reservation. + let sent = registry.broadcast(token, &core).await; + assert!( + matches!( + sent, + Err(SignedPaymentError::Broadcast( + PlatformWalletError::TransactionBroadcast(_) + )) + ), + "a rejected deferred broadcast must surface the rejection, got {sent:?}" + ); + + // T2 still owns the outpoint: a third build finds no free UTXO. Under the + // pre-fix unconditional release, build 1's rejection would have freed it + // and this build would succeed — double-spending T2's outpoint. + let third = core + .finalize_transaction( + payment_builder(&outputs), + AccountTypePreference::BIP44, + 0, + &signer, + ) + .await; + assert!( + matches!(third, Err(PlatformWalletError::CoreInsufficientFunds { .. })), + "the re-taken reservation must survive build 1's rejected broadcast, got {third:?}" + ); + + // Keep T2's build (and thus its reservation) alive until the assertions run. + drop(retaken); + } } From bc33a61195ab0bb204a6d24401df5ffcb55c00c4 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:21:43 -0400 Subject: [PATCH 24/47] fix(platform-wallet): enforce unique reservation ownership, stop wrapper-destroy from consuming payments, type the token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two of the three carried-forward lifecycle blockers on #4185 plus the two smaller review items. The wallet-removal/finalize linearization blocker is intentionally NOT included here (see PR discussion) — it needs a shared lifecycle gate that is a design change on a money path. Blocker 1 — unique reservation ownership: `SignedPaymentRegistry::register` now CONSUMES the non-`Clone` `SignedCoreTransaction` and derives the transaction, funding account, mandatory reservation height, and owner-guard token from it (new `SignedCoreTransaction::into_registered_parts`). Because the ownership object is moved exactly once, a single finalize can no longer mint two live tokens naming the same held reservation. The FFI finalizer passes the finalized object straight in; the former duplicate-registration test (16 clones of one reserved tx) is removed as it modelled the now-impossible pattern. Blocker 2 — final wallet-alias destroy no longer consumes independently-owned payments: `platform_wallet_destroy` no longer releases the generation's tokens when the last wrapper alias is dropped. A wrapper handle does not own the logical wallet or the registered payment (the manager still owns the wallet; each registry entry pins its own `CoreWallet`). Token cleanup now follows the payment owner (broadcast/release) or actual generation teardown (`remove_wallet` → `remove_entries_for_wallet`), never a transient alias count. The unused `release_entries_for_wallet` method and its test are removed; the destroy test now asserts tokens survive destroying every alias. Nit — typed token: `ReservationToken` is now a `#[repr(transparent)]` newtype instead of a bare `u64` alias, converted to/from `u64` only at the FFI boundary, so a payment handle can't be silently confused with another numeric id. Docs — JNI Rustdoc: the `coreWalletBroadcastSignedPayment` block referenced the pre-renumber codes (26/27/28); updated to the current enum values (27 StaleReservationToken / 28 ReservationTokenConsumed / 29 ReservationWalletMismatch). Co-Authored-By: Claude Fable 5 --- .../src/core_wallet/signed_payment.rs | 4 +- .../src/core_wallet/transaction_builder.rs | 32 +- packages/rs-platform-wallet-ffi/src/wallet.rs | 118 ++-- .../src/wallet/core/transaction.rs | 56 ++ .../src/wallet/signed_payment_registry.rs | 573 +++++------------- .../rs-unified-sdk-jni/src/wallet_manager.rs | 6 +- 6 files changed, 295 insertions(+), 494 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 412fc92a78b..75509509106 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 @@ -60,7 +60,7 @@ 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)); + runtime().block_on(SIGNED_PAYMENT_REGISTRY.broadcast(ReservationToken::from(token), &core)); match result { Ok(txid) => { @@ -107,6 +107,6 @@ pub unsafe extern "C" fn core_wallet_signed_payment_broadcast( /// 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)); + runtime().block_on(SIGNED_PAYMENT_REGISTRY.release(ReservationToken::from(token))); 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 1b586a4e23c..bf4b45a2856 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 @@ -240,29 +240,15 @@ pub unsafe extern "C" fn core_wallet_signed_payment_finalize( 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. + // committed the reservation; `register` CONSUMES the `SignedCoreTransaction` + // ownership object (deriving its transaction, funding account, reservation + // height, and owner-guard token internally) and captures the wallet instance + // whose `ReservationSet` holds the inputs. Because the object is consumed + // exactly once, this finalize can yield at most one token — no second token + // can ever name the same reservation (`dashpay/platform#4185`, blocker 1). let token = runtime().block_on( - crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY.register( - wallet.core().clone(), - finalized.transaction().clone(), - // 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 - // external signer ran — never a fresh post-signing sample. - Some(finalized.reservation_height()), - // The key-wallet reservation token finalize stamped onto the funding - // inputs, so a later broadcast-reject or release frees only inputs - // this build still owns (owner-guarded; `dashpay/platform#4185`). - finalized.reservation_token(), - ), + crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY + .register(wallet.core().clone(), finalized), ); *out_tx = FFICoreTransaction { @@ -270,7 +256,7 @@ pub unsafe extern "C" fn core_wallet_signed_payment_finalize( tx_len: len, fee, }; - *out_token = token; + *out_token = token.as_u64(); *out_fee = fee; *out_txid = c_txid.into_raw(); // Borrowed view into the just-written `out_tx` buffer; the caller copies the diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index 54450e6e626..d66b960dd93 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -390,42 +390,26 @@ 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 { - // 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 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. + // Destroying a wrapper alias must NOT touch the deferred-payment registry. // - // 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. + // `platform_wallet_manager_get_wallet` hands out an independent handle for + // each alias of a wallet *generation*, but none of those wrappers OWN the + // logical wallet — the manager still owns it and can hand out another alias, + // `platform_wallet_get_core` yields independently-owned core handles, and + // each registry entry pins its own `CoreWallet` (keeping the reservation + // live). A registered deferred-payment token is owned by the payment flow + // that minted it, NOT by any wrapper handle, so closing or garbage-collecting + // the last wrapper must leave the token intact: a later merchant ack has to + // remain broadcastable through a retained core handle or a re-acquired alias. // - // 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 sibling_alias_alive = - PLATFORM_WALLET_STORAGE.any(|other| other.core().is_same_generation(core)); - if !sibling_alias_alive { - runtime().block_on( - crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY - .release_entries_for_wallet(core), - ); - } + // Token cleanup therefore follows the payment owner (an explicit + // broadcast/release) or actual wallet-generation removal + // (`platform_wallet_manager_remove_wallet`, which drops the entries because + // the reservation ceases to exist with the generation) — never a transient + // wrapper-alias count. Dropping this handle just releases its `Arc`s; the + // registry entry's own `CoreWallet` clone keeps the generation alive as long + // as a token references it. (`dashpay/platform#4185`, blocker 2.) + let _ = PLATFORM_WALLET_STORAGE.remove(handle); PlatformWalletFFIResult::ok() } @@ -435,6 +419,7 @@ mod destroy_tests { use crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY; use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use platform_wallet::test_support::test_platform_wallet_manager; + use platform_wallet::SignedCoreTransaction; fn dummy_tx() -> dashcore::Transaction { dashcore::Transaction { @@ -446,18 +431,22 @@ mod destroy_tests { } } - /// 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. + /// Destroying wrapper alias handles must NEVER invalidate a deferred-payment + /// token — not even when the FINAL alias is destroyed. A wrapper handle does + /// not own the logical wallet or the registered payment; the token is owned + /// by the payment flow that minted it and stays live and actionable until its + /// owner broadcasts/releases it or the generation is actually removed + /// (`platform_wallet_manager_remove_wallet`). Regression for + /// `dashpay/platform#4185` blocker 2: the old final-alias sweep consumed + /// independently-owned payments. #[test] - fn destroying_one_alias_keeps_a_siblings_token() { - // 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 { + fn destroying_wrapper_aliases_never_sweeps_tokens() { + // Async setup only. `platform_wallet_destroy` and the final `release` + // each do their own `runtime().block_on(...)`, exactly as the JNI / + // NativeCleaner threads do (never from inside a tokio runtime). Calling + // them from within an outer `block_on` would nest runtimes and abort, so + // they run on the plain test thread below. + let (manager, handle_a, handle_b, token, 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 @@ -469,24 +458,25 @@ mod destroy_tests { 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). + // shared, so reason about deltas against a captured baseline). The + // dummy tx reserved nothing (reservation height 0, no funding token) — + // this test exercises destroy/ownership, not the age or owner guard. let baseline = SIGNED_PAYMENT_REGISTRY.outstanding(); - let _token = SIGNED_PAYMENT_REGISTRY + let token = SIGNED_PAYMENT_REGISTRY .register( core.clone(), - dummy_tx(), - AccountTypePreference::BIP44, - 0, - // This test exercises only the destroy-time sweep, not the - // age guard, so the reservation height is irrelevant here. - None, - // The dummy tx reserved nothing, so there is no funding token - // to owner-guard against — the destroy sweep drops the entry. - None, + SignedCoreTransaction::new_for_test( + dummy_tx(), + 0, + AccountTypePreference::BIP44, + 0, + 0, + None, + ), ) .await; assert_eq!(SIGNED_PAYMENT_REGISTRY.outstanding(), baseline + 1); - (manager, handle_a, handle_b, baseline) + (manager, handle_a, handle_b, token, baseline) }); // Destroy alias A while B is still live → token must survive. @@ -498,13 +488,23 @@ mod destroy_tests { "a sibling alias's token must survive destroying another alias" ); - // Destroy the final alias B → now the token is swept. + // Destroy the FINAL alias B → the token STILL survives: a wrapper alias + // does not own the payment, so its destruction must not consume the token. let result = unsafe { platform_wallet_destroy(handle_b) }; assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!( + SIGNED_PAYMENT_REGISTRY.outstanding(), + baseline + 1, + "destroying the final wrapper alias must NOT sweep an independently-owned token" + ); + + // The token is still fully live: its owner can release it even after both + // wrappers are gone (the registry entry pinned its own `CoreWallet`). + runtime().block_on(SIGNED_PAYMENT_REGISTRY.release(token)); assert_eq!( SIGNED_PAYMENT_REGISTRY.outstanding(), baseline, - "destroying the final alias must sweep the wallet's tokens" + "the payment owner can still release the surviving token" ); // Keep the manager alive until the end (owns the wallet + adapter). diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index ddc26b75266..a47ee150257 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -115,6 +115,62 @@ impl SignedCoreTransaction { pub fn reservation_token(&self) -> Option { self.reservation_token } + + /// Consume this finalized transaction into the owned parts the deferred + /// [`SignedPaymentRegistry`](crate::SignedPaymentRegistry) stores. + /// + /// Consuming (rather than cloning) is what enforces unique reservation + /// ownership: `SignedCoreTransaction` is deliberately not `Clone`, so a + /// finalize yields exactly one ownership object and the registry can be + /// handed it exactly once — a caller cannot mint two live tokens that name + /// the same held reservation (`dashpay/platform#4185`). The transaction, + /// funding account, and reservation height are derived here, not supplied + /// independently by the caller. + pub(crate) fn into_registered_parts(self) -> RegisteredPaymentParts { + RegisteredPaymentParts { + transaction: self.transaction, + funding_account_type: self.funding_account_type, + funding_account_index: self.funding_account_index, + reservation_height: self.reservation_height, + reservation_token: self.reservation_token, + } + } +} + +/// The owned facts the deferred-payment registry takes over when it registers a +/// finalized transaction. Produced only by +/// [`SignedCoreTransaction::into_registered_parts`], which consumes the +/// non-`Clone` ownership object exactly once. +pub(crate) struct RegisteredPaymentParts { + pub(crate) transaction: Transaction, + pub(crate) funding_account_type: AccountTypePreference, + pub(crate) funding_account_index: u32, + pub(crate) reservation_height: u32, + pub(crate) reservation_token: Option, +} + +#[cfg(any(test, feature = "test-utils"))] +impl SignedCoreTransaction { + /// Build a `SignedCoreTransaction` directly, for tests that need a finalized + /// ownership object without running the full funding + signing pipeline + /// (e.g. the registry and FFI destroy/lifecycle tests). + pub fn new_for_test( + transaction: Transaction, + fee: u64, + funding_account_type: AccountTypePreference, + funding_account_index: u32, + reservation_height: u32, + reservation_token: Option, + ) -> Self { + Self { + transaction, + fee, + funding_account_type, + funding_account_index, + reservation_height, + reservation_token, + } + } } fn account( 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 a48e41b84d0..2fb9fcda26e 100644 --- a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -77,7 +77,7 @@ use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePr use key_wallet::ReservationToken as FundingReservationToken; use crate::broadcaster::TransactionBroadcaster; -use crate::wallet::core::CoreWallet; +use crate::wallet::core::{CoreWallet, SignedCoreTransaction}; use crate::PlatformWalletError; /// Opaque handle to a registered, signed-but-unsent payment. Minted by @@ -85,7 +85,40 @@ use crate::PlatformWalletError; /// [`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; +/// +/// A distinct newtype rather than a bare `u64` alias so a payment handle can +/// never be silently confused with any other numeric identifier (the funding +/// [`FundingReservationToken`], an account index, a raw height). It crosses the +/// C ABI as a `u64` — [`from`](ReservationToken::from) / [`as_u64`](ReservationToken::as_u64) +/// are the only conversions, applied at the FFI boundary. +#[repr(transparent)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ReservationToken(u64); + +impl ReservationToken { + /// The raw wire value handed back across the FFI boundary to the host. + pub const fn as_u64(self) -> u64 { + self.0 + } +} + +impl From for ReservationToken { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl From for u64 { + fn from(token: ReservationToken) -> Self { + token.0 + } +} + +impl std::fmt::Display for ReservationToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} /// Maximum age, in `last_processed_height` blocks, of a registered token before /// its broadcast or release is refused. @@ -105,16 +138,17 @@ pub type ReservationToken = u64; /// 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, +/// Whether a token stamped at `registered_height` is too old to act on at +/// `current_height` (see [`RESERVATION_MAX_AGE_BLOCKS`]). The registration +/// height is mandatory — it is derived from the finalized +/// [`SignedCoreTransaction::reservation_height`](crate::SignedCoreTransaction) +/// the registry consumed. An unknown *current* height (the wallet is gone from +/// the manager now) disables the guard: the wallet-mismatch / account-lookup +/// paths already reject those cases. +fn reservation_expired(registered_height: u32, current_height: Option) -> bool { + match current_height { + Some(current) => current.saturating_sub(registered_height) >= RESERVATION_MAX_AGE_BLOCKS, + None => false, } } @@ -169,14 +203,14 @@ struct RegisteredPayment { /// 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 - /// 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, + /// Wallet `last_processed_height` captured inside the funding critical + /// section — the exact clock `finalize_transaction` stamps the funding + /// reservation with (`SignedCoreTransaction::reservation_height`). Compared + /// against the wallet's current `last_processed_height` to refuse a + /// broadcast/release once the reservation could plausibly have been swept by + /// key-wallet's TTL (see [`RESERVATION_MAX_AGE_BLOCKS`]). Mandatory: it is + /// derived from the consumed ownership object, never sampled independently. + registered_height: u32, /// The key-wallet [`FundingReservationToken`] stamped onto the funding /// inputs when `finalize_transaction` reserved them /// (`SignedCoreTransaction::reservation_token`), or `None` if the build @@ -228,49 +262,40 @@ impl SignedPaymentRegistry { .unwrap_or_else(|poisoned| poisoned.into_inner()) } - /// Take ownership of a built, signed `tx` (whose funding UTXOs `finalize` - /// already reserved) and return an opaque token for a later + /// Take ownership of a finalized [`SignedCoreTransaction`] (whose funding + /// UTXOs `finalize` already reserved) and return an opaque token for a later /// [`broadcast`](Self::broadcast) or [`release`](Self::release). /// + /// `signed` is **consumed**, which is what enforces unique reservation + /// ownership: `SignedCoreTransaction` is not `Clone`, so a single finalize + /// can be registered at most once — there is no way to mint two live tokens + /// that name the same held reservation (`dashpay/platform#4185`). The built + /// transaction, the funding account, the mandatory reservation height + /// (`SignedCoreTransaction::reservation_height` — captured inside the + /// funding critical section before the potentially-slow external signer ran, + /// so the age guard measures the reservation's true age rather than a + /// post-signing sample), and the owner-guard token + /// (`SignedCoreTransaction::reservation_token`) are all derived from that + /// object here rather than supplied independently by the caller. + /// /// `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. - /// - /// `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`]. - /// - /// `funding_reservation_token` MUST be the key-wallet token the build - /// stamped onto the reserved inputs (`SignedCoreTransaction::reservation_token`) - /// so a later broadcast-reject or release frees only inputs this build still - /// owns; `None` disables the owner guard (never the case for a funded - /// finalize, which always reserves). pub async fn register( &self, core: CoreWallet, - tx: Transaction, - account_type: AccountTypePreference, - account_index: u32, - registered_height: Option, - funding_reservation_token: Option, + signed: SignedCoreTransaction, ) -> ReservationToken { - let token = self.next_token.fetch_add(1, Ordering::SeqCst); + let parts = signed.into_registered_parts(); + let token = ReservationToken(self.next_token.fetch_add(1, Ordering::SeqCst)); self.lock().insert( token, RegisteredPayment { core, - tx, - account_type, - account_index, - registered_height, - funding_reservation_token, + tx: parts.transaction, + account_type: parts.funding_account_type, + account_index: parts.funding_account_index, + registered_height: parts.reservation_height, + funding_reservation_token: parts.reservation_token, }, ); token @@ -395,43 +420,6 @@ impl SignedPaymentRegistry { 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`), WITHOUT releasing, returning how many /// were removed. @@ -453,7 +441,7 @@ impl SignedPaymentRegistry { /// Number of outstanding (registered but not yet broadcast/released) tokens. /// Exposed under `test-utils` so downstream FFI-layer tests (e.g. the - /// `platform_wallet_destroy` final-alias sweep) can observe registry state. + /// `platform_wallet_destroy` lifecycle tests) can observe registry state. #[cfg(any(test, feature = "test-utils"))] pub fn outstanding(&self) -> usize { self.lock().len() @@ -475,12 +463,14 @@ 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::{ + ReservationToken, SignedPaymentError, SignedPaymentRegistry, RESERVATION_MAX_AGE_BLOCKS, + }; use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; use crate::test_support::{ funded_wallet_manager, AlwaysMaybeSentBroadcaster, AlwaysRejectedBroadcaster, WalletSigner, }; - use crate::wallet::core::CoreWallet; + use crate::wallet::core::{CoreWallet, SignedCoreTransaction}; /// The [`AccountTypePreference`] a `build_signed_tx` funding account maps to /// — the registry now retains the full account handle (CoinJoin included), @@ -573,17 +563,19 @@ mod tests { } /// Build + sign a payment exactly as the deferred send path does: - /// `build_signed_reserved` reserves the inputs, leaves the reservation held - /// for the later broadcast/release, and returns the key-wallet - /// [`ReservationToken`](key_wallet::ReservationToken) stamped onto them so - /// the test can register it for an owner-guarded release. + /// `build_signed_reserved` reserves the inputs and leaves the reservation + /// held for the later broadcast/release. Returns a finalized + /// [`SignedCoreTransaction`] — the same non-`Clone` ownership object the + /// production `finalize_transaction` path yields — so the test hands it to + /// [`SignedPaymentRegistry::register`] exactly once (it captures the funding + /// account, the reservation height, and the key-wallet owner-guard token). async fn build_signed_tx( core: &CoreWallet, account_type: StandardAccountType, account_index: u32, outputs: &[(DashAddress, u64)], signer: &S, - ) -> Result<(Transaction, Option), PlatformWalletError> { + ) -> Result { let mut wm = core.wallet_manager.write().await; let (wallet, info) = wm .get_wallet_and_info_mut(&core.wallet_id()) @@ -626,13 +618,20 @@ mod tests { for (addr, amount) in outputs { builder = builder.add_output(addr, *amount); } - let (tx, _fee, reservation_token) = builder + let (tx, fee, reservation_token) = builder .build_signed_reserved(signer, |addr| { managed_account.address_derivation_path(&addr) }) .await .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; - Ok((tx, reservation_token)) + Ok(SignedCoreTransaction::new_for_test( + tx, + fee, + preference(account_type), + account_index, + current_height, + reservation_token, + )) } /// Happy path: a registered token broadcasts the exact bytes it was built @@ -644,7 +643,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; let registry = SignedPaymentRegistry::new(); - let (tx, reservation_token) = build_signed_tx( + let signed = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -653,19 +652,10 @@ mod tests { ) .await .expect("build should succeed"); - let expected_bytes = dashcore::consensus::serialize(&tx); - let expected_txid = tx.txid(); + let expected_bytes = dashcore::consensus::serialize(signed.transaction()); + let expected_txid = signed.transaction().txid(); - let token = registry - .register( - core.clone(), - tx, - AccountTypePreference::BIP44, - 0, - core.last_processed_height().await, - reservation_token, - ) - .await; + let token = registry.register(core.clone(), signed).await; assert_eq!(registry.outstanding(), 1); // Broadcast through a *clone* of the same wallet instance — the @@ -696,19 +686,10 @@ mod tests { let (core, signer, outputs) = funded_core_wallet(account_type, broadcaster).await; let registry = SignedPaymentRegistry::new(); - let (tx, reservation_token) = build_signed_tx(&core, account_type, 0, &outputs, &signer) + let signed = build_signed_tx(&core, account_type, 0, &outputs, &signer) .await .expect("build should succeed"); - let token = registry - .register( - core.clone(), - tx, - preference(account_type), - 0, - core.last_processed_height().await, - reservation_token, - ) - .await; + let token = registry.register(core.clone(), signed).await; // With the reservation held, an immediate rebuild finds no // spendable UTXO and fails. @@ -769,16 +750,7 @@ mod tests { .await .expect("coinjoin finalize should succeed"); - let token = registry - .register( - core.clone(), - finalized.transaction().clone(), - AccountTypePreference::CoinJoin, - 0, - Some(finalized.reservation_height()), - finalized.reservation_token(), - ) - .await; + let token = registry.register(core.clone(), finalized).await; // Reservation held: a second CoinJoin finalize finds no unreserved input. let blocked = core @@ -826,7 +798,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; let registry = SignedPaymentRegistry::new(); - let (tx, reservation_token) = build_signed_tx( + let signed = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -835,16 +807,7 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry - .register( - core.clone(), - tx, - AccountTypePreference::BIP44, - 0, - core.last_processed_height().await, - reservation_token, - ) - .await; + let token = registry.register(core.clone(), signed).await; registry .broadcast(token, &core) @@ -870,7 +833,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; let registry = SignedPaymentRegistry::new(); - let (tx, reservation_token) = build_signed_tx( + let signed = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -879,16 +842,7 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry - .register( - core.clone(), - tx, - AccountTypePreference::BIP44, - 0, - core.last_processed_height().await, - reservation_token, - ) - .await; + let token = registry.register(core.clone(), signed).await; registry.release(token).await; // Second release: no panic, no error, still consumed. @@ -905,7 +859,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; let registry = SignedPaymentRegistry::new(); - let (tx, reservation_token) = build_signed_tx( + let signed = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -914,16 +868,7 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry - .register( - core.clone(), - tx, - AccountTypePreference::BIP44, - 0, - core.last_processed_height().await, - reservation_token, - ) - .await; + let token = registry.register(core.clone(), signed).await; registry.release(token).await; let sent = registry.broadcast(token, &core).await; @@ -946,10 +891,11 @@ mod tests { 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)))); + let unknown = ReservationToken::from(9999); + let sent = registry.broadcast(unknown, &core).await; + assert!(matches!(sent, Err(SignedPaymentError::StaleToken(t)) if t == unknown)); // Releasing an unknown token is a no-op, not a panic. - registry.release(9999).await; + registry.release(unknown).await; } /// A token minted against one wallet instance cannot be broadcast through a @@ -968,7 +914,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, broadcaster_b).await; let registry = SignedPaymentRegistry::new(); - let (tx, reservation_token) = build_signed_tx( + let signed = build_signed_tx( &core_a, StandardAccountType::BIP44Account, 0, @@ -977,16 +923,7 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry - .register( - core_a.clone(), - tx, - AccountTypePreference::BIP44, - 0, - core_a.last_processed_height().await, - reservation_token, - ) - .await; + let token = registry.register(core_a.clone(), signed).await; let sent = registry.broadcast(token, &core_b).await; assert!( @@ -1015,7 +952,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; let registry = SignedPaymentRegistry::new(); - let (tx, reservation_token) = build_signed_tx( + let signed = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -1024,16 +961,7 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry - .register( - core.clone(), - tx, - AccountTypePreference::BIP44, - 0, - core.last_processed_height().await, - reservation_token, - ) - .await; + let token = registry.register(core.clone(), signed).await; let sent = registry.broadcast(token, &core).await; assert!( @@ -1072,7 +1000,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; let registry = Arc::new(SignedPaymentRegistry::new()); - let (tx, reservation_token) = build_signed_tx( + let signed = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -1081,16 +1009,7 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry - .register( - core.clone(), - tx, - AccountTypePreference::BIP44, - 0, - core.last_processed_height().await, - reservation_token, - ) - .await; + let token = registry.register(core.clone(), signed).await; let mut handles = Vec::new(); for _ in 0..8 { @@ -1118,45 +1037,14 @@ mod tests { ); } - /// 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, reservation_token) = 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 { - let height = core.last_processed_height().await; - registry - .register(core, tx, AccountTypePreference::BIP44, 0, height, reservation_token) - .await - })); - } - 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); - } + // NOTE: the former `concurrent_registers_yield_distinct_tokens` test + // registered sixteen clones of ONE reserved transaction to probe the token + // allocator. That is exactly the duplicate-capability pattern unique + // ownership now forbids: `register` consumes a non-`Clone` + // `SignedCoreTransaction`, so a single reservation can be registered at most + // once (`dashpay/platform#4185`). Token distinctness is guaranteed by + // construction (the `AtomicU64` allocator), and concurrent consumption is + // covered by `concurrent_broadcasts_serialize_to_one_send`. /// Force the wallet's `last_processed_height` forward, simulating chain /// progress between build/register and a later broadcast/release — the window @@ -1189,7 +1077,7 @@ mod tests { .last_processed_height() .await .expect("last processed height"); - let (tx, reservation_token) = build_signed_tx( + let signed = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -1198,16 +1086,7 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry - .register( - core.clone(), - tx, - AccountTypePreference::BIP44, - 0, - core.last_processed_height().await, - reservation_token, - ) - .await; + let token = registry.register(core.clone(), signed).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). @@ -1255,7 +1134,7 @@ mod tests { .last_processed_height() .await .expect("last processed height"); - let (tx, reservation_token) = build_signed_tx( + let signed = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -1264,16 +1143,7 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry - .register( - core.clone(), - tx, - AccountTypePreference::BIP44, - 0, - core.last_processed_height().await, - reservation_token, - ) - .await; + let token = registry.register(core.clone(), signed).await; advance_processed_height(&core, registered_height + RESERVATION_MAX_AGE_BLOCKS + 2).await; @@ -1306,7 +1176,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, Arc::clone(&broadcaster)).await; let registry = SignedPaymentRegistry::new(); - let (tx, reservation_token) = build_signed_tx( + let signed = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -1315,16 +1185,7 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry - .register( - core.clone(), - tx, - AccountTypePreference::BIP44, - 0, - core.last_processed_height().await, - reservation_token, - ) - .await; + let token = registry.register(core.clone(), signed).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 @@ -1367,7 +1228,7 @@ mod tests { .await; let registry = SignedPaymentRegistry::new(); - let (tx_a, reservation_token_a) = build_signed_tx( + let signed_a = build_signed_tx( &core_a, StandardAccountType::BIP44Account, 0, @@ -1376,17 +1237,8 @@ mod tests { ) .await .expect("build A should succeed"); - let token_a = registry - .register( - core_a.clone(), - tx_a, - AccountTypePreference::BIP44, - 0, - core_a.last_processed_height().await, - reservation_token_a, - ) - .await; - let (tx_b, reservation_token_b) = build_signed_tx( + let token_a = registry.register(core_a.clone(), signed_a).await; + let signed_b = build_signed_tx( &core_b, StandardAccountType::BIP44Account, 0, @@ -1395,16 +1247,7 @@ mod tests { ) .await .expect("build B should succeed"); - let _token_b = registry - .register( - core_b.clone(), - tx_b, - AccountTypePreference::BIP44, - 0, - core_b.last_processed_height().await, - reservation_token_b, - ) - .await; + let _token_b = registry.register(core_b.clone(), signed_b).await; assert_eq!(registry.outstanding(), 2); let removed = registry.remove_entries_for_wallet(&core_a); @@ -1435,72 +1278,14 @@ mod tests { ); } - /// 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, reservation_token) = 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, - reservation_token, - ) - .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:?}" - ); - } + // NOTE: the former `release_entries_for_wallet_frees_the_reservation` test + // is removed with the `release_entries_for_wallet` method it exercised. + // Destroying wrapper aliases no longer releases deferred-payment tokens: a + // wrapper handle does not own the payment, so its destruction must leave the + // token live and broadcastable (`dashpay/platform#4185`, blocker 2). Token + // reservations are reconciled by the payment owner (explicit + // broadcast/release) or dropped at actual generation teardown + // (`remove_entries_for_wallet`). /// Regression for the wrong-wallet-broadcast token theft: a mismatched /// caller must return `WalletMismatch` WITHOUT consuming the entry, so the @@ -1522,7 +1307,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, broadcaster_b).await; let registry = SignedPaymentRegistry::new(); - let (tx, reservation_token) = build_signed_tx( + let signed = build_signed_tx( &core_a, StandardAccountType::BIP44Account, 0, @@ -1531,16 +1316,7 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry - .register( - core_a.clone(), - tx, - AccountTypePreference::BIP44, - 0, - core_a.last_processed_height().await, - reservation_token, - ) - .await; + let token = registry.register(core_a.clone(), signed).await; // Wrong wallet: mismatch, and the token MUST survive for its owner. let mismatched = registry.broadcast(token, &core_b).await; @@ -1603,7 +1379,7 @@ mod tests { .last_processed_height() .await .expect("last processed height"); - let (tx, reservation_token) = build_signed_tx( + let signed = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -1612,23 +1388,18 @@ mod tests { ) .await .expect("build should succeed"); + // The finalized object carries the reservation's OWN stamp height, + // captured at build time — not a value the caller samples at register. + assert_eq!(signed.reservation_height(), reservation_height); // 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, - AccountTypePreference::BIP44, - 0, - Some(reservation_height), - reservation_token, - ) - .await; + // Register: the age baseline is the reservation height the consumed + // object carries, not the advanced `last_processed_height` sampled now. + let token = registry.register(core.clone(), signed).await; // One block past the reservation height (still below the 24-block TTL) // trips the guard because the baseline is `reservation_height`. @@ -1680,7 +1451,7 @@ mod tests { funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; let registry = SignedPaymentRegistry::new(); - let (tx, reservation_token) = build_signed_tx( + let signed = build_signed_tx( &core, StandardAccountType::BIP44Account, 0, @@ -1689,16 +1460,7 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry - .register( - core.clone(), - tx, - AccountTypePreference::BIP44, - 0, - core.last_processed_height().await, - reservation_token, - ) - .await; + let token = registry.register(core.clone(), signed).await; // Reservation held: a rebuild fails at input selection. let blocked = build_signed_tx( @@ -1797,20 +1559,14 @@ mod tests { ) .await .expect("first finalize should succeed"); - let token = registry - .register( - core.clone(), - finalized.transaction().clone(), - AccountTypePreference::BIP44, - 0, - Some(finalized.reservation_height()), - finalized.reservation_token(), - ) - .await; + // Capture the built tx before `register` consumes the ownership object; + // the sweep below needs it to release the outpoint by hand. + let finalized_tx = finalized.transaction().clone(); + let token = registry.register(core.clone(), finalized).await; // Model key-wallet's TTL sweep: the outpoint returns to the selectable // pool, but the registry still holds T1. - force_release_reservation(&core, finalized.transaction()).await; + force_release_reservation(&core, &finalized_tx).await; // A concurrent build re-selects and re-reserves that same outpoint under // a NEW token T2. Held alive so its reservation persists to the end. @@ -1849,7 +1605,10 @@ mod tests { ) .await; assert!( - matches!(third, Err(PlatformWalletError::CoreInsufficientFunds { .. })), + matches!( + third, + Err(PlatformWalletError::CoreInsufficientFunds { .. }) + ), "the re-taken reservation must survive build 1's rejected broadcast, got {third:?}" ); diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 4c0c918045a..2b5e36fa7bb 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1365,9 +1365,9 @@ 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. 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 +/// throws one of three sibling codes: `ErrorStaleReservationToken` (27, aged +/// out), `ErrorReservationTokenConsumed` (28, unknown / already broadcast / +/// already released), or `ErrorReservationWalletMismatch` (29, different wallet /// generation). `coreHandle` must resolve to the wallet the token was minted /// against. Returns the txid as a lowercase hex string. #[no_mangle] From 8612a3fcf0aa2eb5329903e28db30185cfb7b524 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:00:08 -0400 Subject: [PATCH 25/47] fix(platform-wallet): bind registration to correct wallet generation + retain reservation owner through insertion (#4185 review) Addresses the two new thepastaclaw blockers plus the Kotlin owner-construction suggestion on PR #4185: 1. Registration could bind a reservation to the wrong wallet generation. SignedCoreTransaction now carries the unforgeable per-generation balance Arc (origin_generation) captured from the finalizing CoreWallet. SignedPaymentRegistry::register validates the supplied core against it and refuses a mismatch with the new typed RegisterWrongGeneration error, handing the rejected SignedCoreTransaction back so its reservation is not stranded. 2. Async registration could drop the reservation owner before insertion. register is now synchronous (its body has no await), so the consumed SignedCoreTransaction cannot be lost to a future dropped before its first poll. The FFI finalizer and all callers invoke it directly. 3. Kotlin: CoreTransactionBuilder.finalizeSignedPayment parses the native token first and releases it (owner-guarded) if SignedCoreTransaction construction throws, so an ABI/allocation/Cleaner failure never leaves the native token without a JVM owner. Adds a register_rejects_a_different_wallet_generation regression test. Co-Authored-By: Claude Fable 5 --- .../dashsdk/wallet/CoreTransactionBuilder.kt | 21 +- .../src/core_wallet/transaction_builder.rs | 26 ++- packages/rs-platform-wallet-ffi/src/wallet.rs | 6 +- packages/rs-platform-wallet/src/lib.rs | 2 +- .../src/wallet/core/transaction.rs | 40 +++- .../src/wallet/core/wallet.rs | 12 + packages/rs-platform-wallet/src/wallet/mod.rs | 4 +- .../src/wallet/signed_payment_registry.rs | 221 ++++++++++++++++-- 8 files changed, 297 insertions(+), 35 deletions(-) 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 df72543f231..a5c8c5fd7b6 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 @@ -188,7 +188,26 @@ class CoreTransactionBuilder internal constructor(network: Network) : AutoClosea accountIndex, coreSignerHandle, ) - return ManagedPlatformWallet.SignedCoreTransaction.fromRegisterBlob(blob) + // Native finalization has ALREADY inserted the payment and committed its + // reservation by the time this blob returns; the token only gains its + // owning NativeCleaner once fromRegisterBlob finishes constructing the + // SignedCoreTransaction. So if construction throws (allocation failure, a + // malformed blob from an ABI mismatch, or Cleaner-registration failure) + // the native token would be registered with no JVM owner able to release + // it, leaking the reservation until key-wallet's TTL. Parse the token + // first (its 8 big-endian bytes lead the blob) and release it defensively + // if ownership construction fails, mirroring the owner-guarded release on + // the rest of the deferred path (dashpay/platform#4185). + var token: Long? = null + return try { + token = java.nio.ByteBuffer.wrap(blob).long + ManagedPlatformWallet.SignedCoreTransaction.fromRegisterBlob(blob) + } catch (error: Throwable) { + token?.let { value -> + runCatching { WalletManagerNative.coreWalletReleaseSignedPayment(value) } + } + throw error + } } override fun close() { 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 bf4b45a2856..75df90b294c 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 @@ -242,14 +242,30 @@ pub unsafe extern "C" fn core_wallet_signed_payment_finalize( // Register the reserved+signed tx for deferred submission. `finalize` already // committed the reservation; `register` CONSUMES the `SignedCoreTransaction` // ownership object (deriving its transaction, funding account, reservation - // height, and owner-guard token internally) and captures the wallet instance + // height, and owner-guard token internally) and binds the token to the wallet // whose `ReservationSet` holds the inputs. Because the object is consumed // exactly once, this finalize can yield at most one token — no second token // can ever name the same reservation (`dashpay/platform#4185`, blocker 1). - let token = runtime().block_on( - crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY - .register(wallet.core().clone(), finalized), - ); + // + // `register` is SYNCHRONOUS: its reservation-owning insert runs inline with + // no future that could be dropped before its first poll and silently strand + // the consumed reservation (`dashpay/platform#4185`). It also validates that + // this wallet is the exact generation `finalize` bound the payment to; that + // always holds here (we register through the very wallet that finalized), but + // on the impossible mismatch it hands the finalized payment back so we + // release its reservation (owner-guarded) rather than leaking it. + let token = match crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY + .register(wallet.core().clone(), finalized) + { + Ok(token) => token, + Err(err) => { + runtime().block_on(wallet.core().abandon_transaction(&err.signed)); + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorReservationWalletMismatch, + "deferred payment was finalized against a different wallet generation".to_string(), + ); + } + }; *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 d66b960dd93..72bf64d985d 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -472,9 +472,13 @@ mod destroy_tests { 0, 0, None, + // Bind the finalized payment to this exact wallet + // generation so `register` accepts it (it now validates + // the wallet against the finalizing generation). + core.test_generation_marker(), ), ) - .await; + .expect("register with the same generation"); assert_eq!(SIGNED_PAYMENT_REGISTRY.outstanding(), baseline + 1); (manager, handle_a, handle_b, token, baseline) }); diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 273ba9e82af..efde6e58b83 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -59,7 +59,7 @@ 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, + RegisterWrongGeneration, ReservationToken, SignedPaymentError, SignedPaymentRegistry, }; // DashPay types + crypto helpers re-exported through the identity // domain (they live under `identity::types::dashpay::*` and diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index a47ee150257..ccc9d7227c3 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -19,7 +19,7 @@ use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePr use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::{Account, DerivationPath, ReservationToken, Utxo}; -use super::CoreWallet; +use super::{CoreWallet, WalletBalance}; use crate::broadcaster::TransactionBroadcaster; use crate::PlatformWalletError; @@ -80,6 +80,22 @@ pub struct SignedCoreTransaction { /// [`ManagedCoreFundsAccount::release_reservation_if_owner`] releases only /// inputs still owned by this token, closing that window. reservation_token: Option, + /// The per-generation balance `Arc` of the wallet this payment was + /// **finalized against** — captured from the originating `CoreWallet` inside + /// `finalize_transaction`. It is the same unforgeable generation-identity + /// marker [`CoreWallet::is_same_generation`] compares (a fresh `Arc` per + /// wallet generation; two aliases of one generation share it, a + /// remove-then-recreate under the same id gets a new one). + /// + /// The deferred-payment registry validates the wallet it is asked to bind + /// this payment to against **this** marker before it mints a token + /// ([`SignedPaymentRegistry::register`](crate::SignedPaymentRegistry::register)), + /// so a caller cannot finalize through wallet A and then register/broadcast + /// through an unrelated wallet B — the registry would otherwise treat B as + /// the owner, submit A's transaction through B's broadcaster, and run B's + /// cleanup while A's real reservation leaked until its TTL + /// (`dashpay/platform#4185`). + origin_generation: Arc, } impl SignedCoreTransaction { @@ -116,6 +132,16 @@ impl SignedCoreTransaction { self.reservation_token } + /// The per-generation balance `Arc` of the wallet this payment was finalized + /// against — the unforgeable generation-identity marker the deferred-payment + /// registry pointer-compares before binding the payment to a wallet (see + /// [`origin_generation`](Self::origin_generation) field docs). Borrowed, not + /// consumed, so the check can run before + /// [`into_registered_parts`](Self::into_registered_parts) takes ownership. + pub(crate) fn origin_generation(&self) -> &Arc { + &self.origin_generation + } + /// Consume this finalized transaction into the owned parts the deferred /// [`SignedPaymentRegistry`](crate::SignedPaymentRegistry) stores. /// @@ -154,6 +180,12 @@ impl SignedCoreTransaction { /// Build a `SignedCoreTransaction` directly, for tests that need a finalized /// ownership object without running the full funding + signing pipeline /// (e.g. the registry and FFI destroy/lifecycle tests). + /// + /// `origin_generation` is the per-generation balance `Arc` the payment is to + /// be treated as finalized against — a test that registers it must hand the + /// registry the SAME generation + /// ([`CoreWallet::test_generation_marker`](crate::CoreWallet::test_generation_marker)), + /// exactly as the production path binds a token to the finalizing wallet. pub fn new_for_test( transaction: Transaction, fee: u64, @@ -161,6 +193,7 @@ impl SignedCoreTransaction { funding_account_index: u32, reservation_height: u32, reservation_token: Option, + origin_generation: Arc, ) -> Self { Self { transaction, @@ -169,6 +202,7 @@ impl SignedCoreTransaction { funding_account_index, reservation_height, reservation_token, + origin_generation, } } } @@ -334,6 +368,10 @@ impl CoreWallet { funding_account_index: account_index, reservation_height: height, reservation_token, + // Capture the finalizing wallet's generation identity so the + // deferred registry can refuse to bind this payment to any other + // wallet (`dashpay/platform#4185`). + origin_generation: Arc::clone(self.generation()), }) } diff --git a/packages/rs-platform-wallet/src/wallet/core/wallet.rs b/packages/rs-platform-wallet/src/wallet/core/wallet.rs index 1a9f7ccadeb..da83596cf35 100644 --- a/packages/rs-platform-wallet/src/wallet/core/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/core/wallet.rs @@ -108,6 +108,18 @@ impl CoreWallet { &self.balance } + /// This handle's per-generation identity marker, cloned — for tests (and + /// downstream FFI-crate tests via `test-utils`) that build a finalized + /// [`SignedCoreTransaction`](crate::SignedCoreTransaction) with + /// [`new_for_test`](crate::SignedCoreTransaction::new_for_test) and must + /// stamp it with the SAME generation they then register it against, exactly + /// as the production `finalize_transaction` path binds a token to the + /// finalizing wallet. + #[cfg(any(test, feature = "test-utils"))] + pub fn test_generation_marker(&self) -> Arc { + Arc::clone(&self.balance) + } + pub async fn set_gap_limit( &self, account_type: AccountTypePreference, diff --git a/packages/rs-platform-wallet/src/wallet/mod.rs b/packages/rs-platform-wallet/src/wallet/mod.rs index e8ae111513f..96e11a5ae67 100644 --- a/packages/rs-platform-wallet/src/wallet/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/mod.rs @@ -26,4 +26,6 @@ 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}; +pub use signed_payment_registry::{ + RegisterWrongGeneration, 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 2fb9fcda26e..781c0a3c564 100644 --- a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -66,7 +66,7 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Mutex, MutexGuard}; +use std::sync::{Arc, Mutex, MutexGuard}; use dashcore::{Transaction, Txid}; use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; @@ -184,6 +184,33 @@ pub enum SignedPaymentError { Broadcast(#[from] PlatformWalletError), } +/// The wallet handed to [`SignedPaymentRegistry::register`] is **not** the +/// generation the payment was finalized against, so registering it would bind +/// the reservation to the wrong wallet. Registration is refused up front rather +/// than minting a token that later broadcasts through — and runs cleanup +/// against — a wallet whose `ReservationSet` never held the inputs +/// (`dashpay/platform#4185`). +/// +/// The rejected [`SignedCoreTransaction`] is returned so its held funding +/// reservation is **never stranded**: the caller still owns it and can release +/// it through the correct wallet ([`CoreWallet::abandon_transaction`]) or drop +/// it. This mirrors the owner-guarded discipline of the rest of the deferred +/// path — an ownership object is never dropped on a failure path without the +/// caller getting a chance to reconcile its reservation. +#[derive(Debug)] +pub struct RegisterWrongGeneration { + /// The finalized payment `register` refused to bind, handed back intact. + pub signed: SignedCoreTransaction, +} + +impl std::fmt::Display for RegisterWrongGeneration { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("registration wallet is not the generation the payment was finalized against") + } +} + +impl std::error::Error for RegisterWrongGeneration {} + /// A built, signed transaction whose funding UTXOs are reserved, awaiting a /// deferred broadcast or an explicit release. struct RegisteredPayment { @@ -278,13 +305,43 @@ impl SignedPaymentRegistry { /// (`SignedCoreTransaction::reservation_token`) are all derived from that /// object here rather than supplied independently by the caller. /// - /// `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 async fn register( + /// `core` is the wallet the token is bound to for its later broadcast / + /// release. It **must** be the same wallet *generation* the payment was + /// finalized against — validated here against the unforgeable + /// `origin_generation` marker `SignedCoreTransaction` captured at finalize. + /// Binding to any other wallet is refused with [`RegisterWrongGeneration`] + /// (the rejected `signed` handed back so its reservation is not stranded): + /// otherwise safe public code could finalize through wallet A and + /// `register(core_b, signed_from_a)`, after which broadcasting through B + /// would pass the generation check and submit A's transaction through B's + /// broadcaster while cleanup ran against B and A's real reservation leaked + /// until its TTL. Deriving/validating the core from the consumed object + /// (rather than trusting a separate argument) upholds the documented + /// guarantee that a token is bound to the generation whose `ReservationSet` + /// owns the inputs. + /// + /// Synchronous **by design**: the body performs the reservation-owning + /// insertion with no `.await`, so there is no future that could be dropped + /// before its first poll and silently drop the consumed `signed` — and its + /// held reservation — without inserting it. An `async fn` here would only + /// move `signed` into a future whose body runs on the first poll; dropping + /// that future before polling would leak the reservation to key-wallet's TTL + /// (`dashpay/platform#4185`). Callers invoke it directly. + pub fn register( &self, core: CoreWallet, signed: SignedCoreTransaction, - ) -> ReservationToken { + ) -> Result { + // Bind the payment to the EXACT generation it was finalized against. + // `core.generation()` and `signed.origin_generation()` are the same kind + // of per-generation balance `Arc` `is_same_generation` pointer-compares; + // a mismatch means `core` is a different (switched / stale / unrelated) + // wallet than the one whose `ReservationSet` holds the inputs. Refuse + // BEFORE consuming `signed`, and hand it back so the caller can reconcile + // its reservation. + if !Arc::ptr_eq(core.generation(), signed.origin_generation()) { + return Err(RegisterWrongGeneration { signed }); + } let parts = signed.into_registered_parts(); let token = ReservationToken(self.next_token.fetch_add(1, Ordering::SeqCst)); self.lock().insert( @@ -298,7 +355,7 @@ impl SignedPaymentRegistry { funding_reservation_token: parts.reservation_token, }, ); - token + Ok(token) } /// Broadcast the payment behind `token`, reconciling its UTXO reservation on @@ -464,7 +521,8 @@ mod tests { use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use super::{ - ReservationToken, SignedPaymentError, SignedPaymentRegistry, RESERVATION_MAX_AGE_BLOCKS, + RegisterWrongGeneration, ReservationToken, SignedPaymentError, SignedPaymentRegistry, + RESERVATION_MAX_AGE_BLOCKS, }; use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; use crate::test_support::{ @@ -631,6 +689,10 @@ mod tests { account_index, current_height, reservation_token, + // Stamp the finalizing generation so registering through this same + // `core` passes the registry's generation binding, exactly as the + // production finalize path does. + core.generation().clone(), )) } @@ -655,7 +717,9 @@ mod tests { let expected_bytes = dashcore::consensus::serialize(signed.transaction()); let expected_txid = signed.transaction().txid(); - let token = registry.register(core.clone(), signed).await; + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); assert_eq!(registry.outstanding(), 1); // Broadcast through a *clone* of the same wallet instance — the @@ -689,7 +753,9 @@ mod tests { let signed = build_signed_tx(&core, account_type, 0, &outputs, &signer) .await .expect("build should succeed"); - let token = registry.register(core.clone(), signed).await; + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); // With the reservation held, an immediate rebuild finds no // spendable UTXO and fails. @@ -750,7 +816,9 @@ mod tests { .await .expect("coinjoin finalize should succeed"); - let token = registry.register(core.clone(), finalized).await; + let token = registry + .register(core.clone(), finalized) + .expect("test registers with the finalizing generation"); // Reservation held: a second CoinJoin finalize finds no unreserved input. let blocked = core @@ -807,7 +875,9 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry.register(core.clone(), signed).await; + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); registry .broadcast(token, &core) @@ -842,7 +912,9 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry.register(core.clone(), signed).await; + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); registry.release(token).await; // Second release: no panic, no error, still consumed. @@ -868,7 +940,9 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry.register(core.clone(), signed).await; + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); registry.release(token).await; let sent = registry.broadcast(token, &core).await; @@ -923,7 +997,9 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry.register(core_a.clone(), signed).await; + let token = registry + .register(core_a.clone(), signed) + .expect("test registers with the finalizing generation"); let sent = registry.broadcast(token, &core_b).await; assert!( @@ -942,6 +1018,79 @@ mod tests { ); } + /// Regression for `dashpay/platform#4185` blocker: registration must bind the + /// token to the SAME wallet generation the payment was finalized against, not + /// to a separately-supplied wallet. Registering a payment finalized through + /// wallet A through an unrelated wallet B is refused up front with + /// [`RegisterWrongGeneration`], no token is minted (so B can never broadcast + /// A's transaction through B's broadcaster or run cleanup against B), and the + /// rejected `SignedCoreTransaction` is handed back so A's reservation is not + /// stranded — releasing it through A frees the input for an immediate rebuild. + #[tokio::test] + async fn register_rejects_a_different_wallet_generation() { + let (core_a, signer_a, outputs_a) = funded_core_wallet( + StandardAccountType::BIP44Account, + Arc::new(CountingBroadcaster::new()), + ) + .await; + // A separate wallet-manager instance stands in for an unrelated / re-created + // generation: same account shape, different generation-identity `Arc`. + let (core_b, _signer_b, _outputs_b) = funded_core_wallet( + StandardAccountType::BIP44Account, + Arc::new(CountingBroadcaster::new()), + ) + .await; + let registry = SignedPaymentRegistry::new(); + + let signed = build_signed_tx( + &core_a, + StandardAccountType::BIP44Account, + 0, + &outputs_a, + &signer_a, + ) + .await + .expect("build should succeed"); + + // Registering A's finalized payment through wallet B is refused, and no + // token is minted. + let baseline = registry.outstanding(); + let rejected = registry.register(core_b.clone(), signed); + let RegisterWrongGeneration { signed } = match rejected { + Err(err) => err, + Ok(_) => panic!("registering through a different generation must be refused"), + }; + assert_eq!( + registry.outstanding(), + baseline, + "a rejected registration must not mint a token" + ); + + // Registering through the correct generation (an alias of A) is accepted: + // the guard binds to generation identity, not wallet-manager pointer. + let token = registry + .register(core_a.clone(), signed) + .expect("registering through the finalizing generation must be accepted"); + assert_eq!(registry.outstanding(), baseline + 1); + + // The reservation is A's and is reachable: releasing the token frees the + // input, so an immediate rebuild on A succeeds — nothing was stranded. + registry.release(token).await; + assert_eq!(registry.outstanding(), baseline); + let rebuilt = build_signed_tx( + &core_a, + StandardAccountType::BIP44Account, + 0, + &outputs_a, + &signer_a, + ) + .await; + assert!( + rebuilt.is_ok(), + "the reservation must be reachable after a rejected mis-binding, got {rebuilt:?}" + ); + } + /// 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. @@ -961,7 +1110,9 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry.register(core.clone(), signed).await; + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); let sent = registry.broadcast(token, &core).await; assert!( @@ -1009,7 +1160,9 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry.register(core.clone(), signed).await; + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); let mut handles = Vec::new(); for _ in 0..8 { @@ -1086,7 +1239,9 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry.register(core.clone(), signed).await; + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); // 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). @@ -1143,7 +1298,9 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry.register(core.clone(), signed).await; + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); advance_processed_height(&core, registered_height + RESERVATION_MAX_AGE_BLOCKS + 2).await; @@ -1185,7 +1342,9 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry.register(core.clone(), signed).await; + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); // 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 @@ -1237,7 +1396,9 @@ mod tests { ) .await .expect("build A should succeed"); - let token_a = registry.register(core_a.clone(), signed_a).await; + let token_a = registry + .register(core_a.clone(), signed_a) + .expect("test registers with the finalizing generation"); let signed_b = build_signed_tx( &core_b, StandardAccountType::BIP44Account, @@ -1247,7 +1408,9 @@ mod tests { ) .await .expect("build B should succeed"); - let _token_b = registry.register(core_b.clone(), signed_b).await; + let _token_b = registry + .register(core_b.clone(), signed_b) + .expect("test registers with the finalizing generation"); assert_eq!(registry.outstanding(), 2); let removed = registry.remove_entries_for_wallet(&core_a); @@ -1316,7 +1479,9 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry.register(core_a.clone(), signed).await; + let token = registry + .register(core_a.clone(), signed) + .expect("test registers with the finalizing generation"); // Wrong wallet: mismatch, and the token MUST survive for its owner. let mismatched = registry.broadcast(token, &core_b).await; @@ -1399,7 +1564,9 @@ mod tests { // Register: the age baseline is the reservation height the consumed // object carries, not the advanced `last_processed_height` sampled now. - let token = registry.register(core.clone(), signed).await; + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); // One block past the reservation height (still below the 24-block TTL) // trips the guard because the baseline is `reservation_height`. @@ -1460,7 +1627,9 @@ mod tests { ) .await .expect("build should succeed"); - let token = registry.register(core.clone(), signed).await; + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); // Reservation held: a rebuild fails at input selection. let blocked = build_signed_tx( @@ -1562,7 +1731,9 @@ mod tests { // Capture the built tx before `register` consumes the ownership object; // the sweep below needs it to release the outpoint by hand. let finalized_tx = finalized.transaction().clone(); - let token = registry.register(core.clone(), finalized).await; + let token = registry + .register(core.clone(), finalized) + .expect("test registers with the finalizing generation"); // Model key-wallet's TTL sweep: the outpoint returns to the selectable // pool, but the registry still holds T1. From 13ddeec9636c8e832c41f27b15a1ef605d29ac35 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:39:14 -0400 Subject: [PATCH 26/47] fix(platform-wallet): linearize wallet removal with deferred broadcast + finalize (#4185 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wallet removal was not linearized with the deferred-payment registry, so a retained handle could push a removed wallet's payment onto the network. Two independent windows: 1. remove-then-sweep. `platform_wallet_manager_remove_wallet` called `manager.remove_wallet` and only afterwards swept the registry, with no shared lock spanning the two — and the removal's own awaits (shielded coordinator + identity-sync unregistration) sat in the gap. A concurrent `core_wallet_signed_payment_broadcast` in that window passed every guard: `is_same_generation` compares two handles, so a removed generation matches itself; `last_processed_height` is `None` once the wallet is gone and `reservation_expired` maps `None` to "not expired"; and `broadcast_payment_releasing_reservation` has no wallet-existence gate. 2. in-flight finalizer. `finalize_transaction` drops the manager write lock before awaiting the signer, and `register` only validates the payment against its finalizing generation — never that the generation still exists. A removal during the signer await swept the registry, then the finalizer inserted a fresh token no later sweep would catch, contradicting the documented teardown invariant that dropping tokens makes stale handles inert. Remedies: * `SignedPaymentRegistry` gains a lifecycle gate (`tokio::RwLock`). Teardown takes the exclusive side across BOTH the manager removal and the sweep, making them one linearization point; broadcast and release take the shared side for their whole duration. The existing `entries` mutex cannot do this — it is dropped before every await by design. Lock order is always gate then manager. * Broadcast rejects an absent current generation via the new `CoreWallet::is_current_generation`, returning `SignedPaymentError:: WalletRemoved` instead of silently proceeding to the broadcaster. * `core_wallet_signed_payment_finalize` holds the shared gate across its liveness check and the synchronous `register`, abandoning the payment (reconciling its reservation) if the wallet went away during signing. The gate is taken after the signer await, not around it, so an open signing prompt cannot stall teardown. No new FFI error code: the wallet-removed case is reported as the existing `NotFound` (98), which both hosts already map. Deliberately avoids the 29/30 renumbering contested in #4261. Swift/Kotlin/Rust docs updated to record that 98 now also carries this case, and how it differs from `ErrorReservationWalletMismatch` (29). Adds three FFI regression tests. All three fail against the pre-fix code — the race test reports a payment reaching the broadcaster after teardown completed. Also serializes the registry-count-asserting tests, which the new tests would otherwise race in the shared process-global registry. Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/errors/DashSdkError.kt | 9 + .../src/core_wallet/signed_payment.rs | 33 ++ .../src/core_wallet/transaction_builder.rs | 37 ++ packages/rs-platform-wallet-ffi/src/error.rs | 19 +- .../rs-platform-wallet-ffi/src/manager.rs | 359 +++++++++++++++++- packages/rs-platform-wallet-ffi/src/wallet.rs | 5 + .../src/wallet/core/wallet.rs | 28 ++ .../src/wallet/signed_payment_registry.rs | 159 +++++++- .../PlatformWallet/PlatformWalletResult.swift | 16 + 9 files changed, 643 insertions(+), 22 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 c4abf7f1e23..3e0694ce0ad 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 @@ -368,6 +368,15 @@ sealed class DashSdkError( } 7, // ErrorIdentityNotFound 8, // ErrorContactNotFound + // NotFound. Handle/Option lookup failures, plus the deferred + // (BIP70/BIP270) wallet-was-REMOVED case: a signed-payment broadcast + // whose wallet is no longer registered in the manager, or a + // signed-payment finalize whose wallet was removed while it was + // being signed (its reservation is reconciled before this returns). + // Nothing was broadcast, and unlike ReservationWalletMismatch (36) + // no other live generation holds the payment either — so it is not + // retryable. See dashpay/platform#4185. + 98, -> NotFound(message, cause) // 98 (PlatformWalletFFIResultCode::NotFound, the blanket Option → // result miss) stays inside the wallet-error family as the typed 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 75509509106..5be35613cd5 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 @@ -32,6 +32,29 @@ use std::os::raw::c_char; pub(crate) static SIGNED_PAYMENT_REGISTRY: Lazy> = Lazy::new(SignedPaymentRegistry::new); +/// Serializes tests that reason about the process-global registry's *contents*. +/// +/// [`SIGNED_PAYMENT_REGISTRY`] is one static shared by every test in the binary, +/// and the harness runs tests in parallel threads by default. Any test that +/// captures an `outstanding()` baseline and then asserts a delta against it is +/// therefore racing every other test that mints or consumes a token — the +/// baseline can be captured while a sibling's token is outstanding and compared +/// after that sibling consumed it. +/// +/// Tests take this around their whole body. Poisoning is recovered rather than +/// propagated (mirroring `SignedPaymentRegistry`'s own lock): a panic in one +/// test should fail that test, not cascade into every sibling. +#[cfg(test)] +pub(crate) static REGISTRY_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Take [`REGISTRY_TEST_LOCK`], recovering from poisoning. +#[cfg(test)] +pub(crate) fn registry_test_guard() -> std::sync::MutexGuard<'static, ()> { + REGISTRY_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + /// Broadcast the payment behind `token` (built earlier via /// [`core_wallet_signed_payment_finalize`](super::transaction_builder::core_wallet_signed_payment_finalize)), /// reconciling its UTXO reservation on @@ -91,6 +114,16 @@ pub unsafe extern "C" fn core_wallet_signed_payment_broadcast( PlatformWalletFFIResultCode::ErrorReservationWalletMismatch, e.to_string(), ), + // The wallet was REMOVED from the manager, so there is no live + // generation to broadcast through. Reported as the existing `NotFound` + // (98) rather than a new code: it is exactly the "the thing you named + // does not exist" case 98 already means, and both hosts already map it. + // Distinct from `ErrorReservationWalletMismatch` (29), where a DIFFERENT + // live generation answers to the same id. Did NOT touch the network and + // is NOT retryable — the wallet is gone. + Err(e @ SignedPaymentError::WalletRemoved(_)) => { + PlatformWalletFFIResult::err(PlatformWalletFFIResultCode::NotFound, 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/core_wallet/transaction_builder.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs index 75df90b294c..f3980a4d6d7 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 @@ -217,6 +217,43 @@ pub unsafe extern "C" fn core_wallet_signed_payment_finalize( )); let finalized = unwrap_result_or_return!(finalized); + // `finalize_transaction` drops the wallet-manager write lock before awaiting + // the (external, possibly slow) signer, so the host can have removed this + // wallet while we were signing — and that removal's registry sweep has then + // ALREADY run. Registering now would insert a live token for a removed + // generation, which no later sweep would catch, defeating the teardown + // invariant that dropping tokens makes stale handles inert + // (`dashpay/platform#4185`). + // + // Take the lifecycle gate (shared — concurrent payments are unaffected) and + // hold it across BOTH the liveness check and the synchronous `register`, so + // a teardown cannot interleave between them. Deliberately acquired AFTER the + // signer await rather than around it: holding it across an open signing + // prompt would stall every wallet's teardown for as long as the user takes, + // and the check below makes that unnecessary. + let (_lifecycle, wallet_is_live) = runtime().block_on(async { + let gate = crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY + .lifecycle_read() + .await; + let live = wallet.core().is_current_generation().await; + (gate, live) + }); + if !wallet_is_live { + // Nothing was registered, so no token would ever release this build's + // reservation. Reconcile it here: the release is generation-bound, so on + // a genuine removal it is a logged no-op (the `ReservationSet` died with + // the generation), and on a re-create it correctly declines to touch the + // new generation's inputs. + runtime().block_on(wallet.core().abandon_transaction(&finalized)); + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + "wallet is no longer registered in the manager (removed or re-created while the \ + payment was being signed); the payment was not registered and its reservation was \ + reconciled" + .to_string(), + ); + } + let txid = finalized.transaction().txid(); let fee = finalized.fee(); diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 5dda2494c6d..1ef8d5a5f4d 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -266,7 +266,24 @@ pub enum PlatformWalletFFIResultCode { /// retryable through this handle (rebuild the payment). ErrorReservationWalletMismatch = 36, - NotFound = 98, // Used exclusively for all the Option that are retuned as errors + /// The named thing does not exist. + /// + /// Originally (and still mostly) the code for every `Option` returned as an + /// error — a handle that resolves to nothing, a lookup that came back empty. + /// + /// The deferred build → broadcast/release lifecycle also reports its + /// wallet-was-REMOVED case here rather than minting a fourth + /// deferred-token code, because it *is* that same "does not exist" case: + /// `core_wallet_signed_payment_broadcast` maps + /// `SignedPaymentError::WalletRemoved` (the token's wallet is no longer + /// registered in the manager), and `core_wallet_signed_payment_finalize` + /// refuses to register a payment whose wallet was removed while it was being + /// signed — reconciling that build's reservation before returning. Neither + /// touched the network. Contrast [`Self::ErrorReservationWalletMismatch`] + /// (29), where a DIFFERENT live generation answers to the same wallet id; + /// here there is no live generation at all, so there is nothing to retry + /// against (`dashpay/platform#4185`). + NotFound = 98, ErrorUnknown = 99, } diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index b6a051211c8..a249dc3fe0b 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -506,6 +506,61 @@ pub unsafe extern "C" fn platform_wallet_manager_destroy( PlatformWalletFFIResult::ok() } +/// Remove one wallet from the manager, tearing down its generation's deferred +/// state in the same linearization step. +/// +/// Generic over the persister so tests can drive the exact production sequence +/// with the in-crate test fixture (the FFI handle storage is pinned to +/// [`FFIPersister`](crate::persistence::FFIPersister)). The ordering here is the +/// invariant under test — see the `remove_wallet_lifecycle_tests` module. +pub(crate) async fn remove_wallet_and_tear_down_generation< + P: platform_wallet::changeset::PlatformWalletPersistence + 'static, +>( + manager: &platform_wallet::PlatformWalletManager

, + wallet_id: &[u8; 32], +) -> Result<(), platform_wallet::PlatformWalletError> { + // Take the deferred-payment lifecycle gate for the WHOLE teardown, before + // touching the manager. Two things follow, and both are load-bearing + // (`dashpay/platform#4185`): + // + // * The manager removal and the registry sweep below become ONE step. They + // used to be two, with the removal's own `.await`s (shielded-coordinator + // and identity-sync unregistration) sitting in the gap — a concurrent + // `core_wallet_signed_payment_broadcast` on a retained handle would find + // its entry still registered, pass `is_same_generation` (a removed + // generation matches itself), skip the age guard (`last_processed_height` + // is `None` once the wallet is gone, which the guard maps to "not + // expired"), and reach the broadcaster — pushing a removed wallet's + // payment onto the network. + // + // * Acquiring it WAITS for in-flight payment operations. A finalize that is + // mid-signature holds the shared side (`finalize_transaction` drops the + // manager write lock before awaiting the signer, so nothing else stops + // it), so it runs to its liveness check and either registers before we + // start — and is swept below — or observes the removal and abandons. + // Either way it can no longer insert a token AFTER the sweep has run. + let _teardown = crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY + .lifecycle_write() + .await; + + let removed = manager.remove_wallet(wallet_id).await?; + + // 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)); + Ok(()) +} + /// Remove one wallet from the manager. Idempotent on missing wallets. #[no_mangle] pub unsafe extern "C" fn platform_wallet_manager_remove_wallet( @@ -516,27 +571,14 @@ pub unsafe extern "C" fn platform_wallet_manager_remove_wallet( let wallet_id_value = *wallet_id; let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { - runtime().block_on(manager.remove_wallet(&wallet_id_value)) + runtime().block_on(remove_wallet_and_tear_down_generation( + manager, + &wallet_id_value, + )) }); let result = unwrap_option_or_return!(option); match result { - 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() - } + Ok(()) => 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(_)) => { @@ -822,3 +864,284 @@ mod tests { assert_eq!(result.code, PlatformWalletFFIResultCode::Success); } } + +/// Wallet-generation teardown vs. the deferred-payment registry +/// (`dashpay/platform#4185`). +/// +/// The invariant every test here defends is one sentence: **no deferred-payment +/// token for a wallet that is not currently registered in the manager is ever +/// actionable.** Removal and the registry sweep used to be two independent steps +/// with the removal's own `.await`s in the gap, and `register` could land after +/// the sweep, so the invariant held only by timing. +/// +/// These drive [`remove_wallet_and_tear_down_generation`] — the exact sequence +/// `platform_wallet_manager_remove_wallet` runs — rather than the `extern "C"` +/// wrapper, because the FFI handle storage is pinned to `FFIPersister` while the +/// wallet fixture uses the in-crate test persister. The wrapper adds only handle +/// resolution and error-code mapping on top. +#[cfg(test)] +mod remove_wallet_lifecycle_tests { + use super::*; + use crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY; + use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; + use platform_wallet::test_support::test_platform_wallet_manager; + use platform_wallet::{ + CoreWallet, ReservationToken, SignedCoreTransaction, SignedPaymentError, + }; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::Duration; + + fn dummy_tx() -> dashcore::Transaction { + dashcore::Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + } + } + + /// Mint a token against `core`. The dummy tx reserved nothing (height 0, no + /// funding token), so these tests exercise the lifecycle guards rather than + /// the age or owner guard. + fn register_token( + core: &CoreWallet, + ) -> ReservationToken { + SIGNED_PAYMENT_REGISTRY + .register( + core.clone(), + SignedCoreTransaction::new_for_test( + dummy_tx(), + 0, + AccountTypePreference::BIP44, + 0, + 0, + None, + core.test_generation_marker(), + ), + ) + .expect("register binds to the finalizing generation") + } + + /// A token is dead iff broadcasting it reports it as unknown/consumed. Token- + /// scoped on purpose: the registry is a process-global shared with every + /// other test in the binary, so `outstanding()` deltas are not reliable under + /// the default parallel test harness. + async fn assert_token_is_gone( + token: ReservationToken, + core: &CoreWallet, + ) { + match SIGNED_PAYMENT_REGISTRY.broadcast(token, core).await { + Err(SignedPaymentError::StaleToken(t)) if t == token => {} + other => panic!("token {token} should have been swept, got {other:?}"), + } + } + + /// Requirement: a broadcast must FAIL CLEANLY when the wallet is no longer in + /// the manager, rather than silently proceeding to the network. + /// + /// The setup reproduces the in-flight-finalizer resurrection directly: + /// register AFTER teardown has already swept, which is exactly what + /// `core_wallet_signed_payment_finalize` used to do when the host removed the + /// wallet during the signer await. Before the fix this token was fully + /// actionable — `is_same_generation` passes (a removed generation matches + /// itself), `last_processed_height` is `None` so the age guard is skipped, + /// and `broadcast_payment_releasing_reservation` has no wallet-existence gate + /// — so the payment went to the broadcaster. + #[test] + fn broadcasting_a_token_for_a_removed_wallet_is_refused_before_the_network() { + // Shares the process-global registry with `wallet::destroy_tests`, + // which asserts on `outstanding()` counts — serialize against it. + let _registry = crate::core_wallet::signed_payment::registry_test_guard(); + + runtime().block_on(async { + let (manager, wallet_id) = test_platform_wallet_manager().await; + let wallet = manager + .get_wallet(&wallet_id) + .await + .expect("wallet present"); + let core = wallet.core().clone(); + + remove_wallet_and_tear_down_generation(&manager, &wallet_id) + .await + .expect("remove succeeds"); + assert!( + !core.is_current_generation().await, + "the retained handle must observe its generation as gone" + ); + + let token = register_token(&core); + + match SIGNED_PAYMENT_REGISTRY.broadcast(token, &core).await { + Err(SignedPaymentError::WalletRemoved(t)) if t == token => {} + other => panic!( + "a token whose wallet was removed must be refused without a send, got {other:?}" + ), + } + + // Refusing still CONSUMES the token: the generation is gone, so there + // is nothing to reconcile and nothing to retry. + assert_token_is_gone(token, &core).await; + }); + } + + /// Requirement: removal and the registry sweep are linearized with respect to + /// broadcast. Run repeatedly to shake the interleaving. + /// + /// Both orderings are legal, so the assertion cannot simply be "the broadcast + /// is refused": + /// + /// * teardown first → the entry is swept and the broadcast is refused + /// (`StaleToken`), or the wallet is gone and it is refused + /// (`WalletRemoved`); + /// * broadcast first → it holds the shared gate, the wallet is genuinely + /// still live, the payment legitimately goes to the broadcaster, and the + /// teardown waits. + /// + /// What must be impossible is the combination the pre-fix gap allowed: + /// reaching the broadcaster even though teardown had ALREADY completed. That + /// is what the completion-order tickets pin down. Because the gate serializes + /// the two, a broadcast that reached the broadcaster must have been holding + /// the gate, so the teardown cannot have finished before it — i.e. the + /// sender's ticket must precede the remover's. Without the gate the remover + /// could finish first and the send still go out, which is exactly the + /// `dashpay/platform#4185` finding. + #[test] + fn a_broadcast_never_reaches_the_broadcaster_after_teardown_completed() { + // Shares the process-global registry with `wallet::destroy_tests`, + // which asserts on `outstanding()` counts — serialize against it. + let _registry = crate::core_wallet::signed_payment::registry_test_guard(); + + for iteration in 0..25 { + runtime().block_on(async { + let (manager, wallet_id) = test_platform_wallet_manager().await; + let wallet = manager + .get_wallet(&wallet_id) + .await + .expect("wallet present"); + let core = wallet.core().clone(); + let token = register_token(&core); + + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + // Monotonic tickets stamped the instant each operation returns, + // giving a total order over the two completions. + let ticket = Arc::new(AtomicUsize::new(0)); + + let remover = { + let barrier = Arc::clone(&barrier); + let manager = Arc::clone(&manager); + let ticket = Arc::clone(&ticket); + tokio::spawn(async move { + barrier.wait().await; + let outcome = + remove_wallet_and_tear_down_generation(&manager, &wallet_id).await; + (outcome, ticket.fetch_add(1, Ordering::SeqCst)) + }) + }; + let sender = { + let barrier = Arc::clone(&barrier); + let core = core.clone(); + let ticket = Arc::clone(&ticket); + tokio::spawn(async move { + barrier.wait().await; + let outcome = SIGNED_PAYMENT_REGISTRY.broadcast(token, &core).await; + (outcome, ticket.fetch_add(1, Ordering::SeqCst)) + }) + }; + + let (removed, remover_ticket) = remover.await.expect("remover task"); + removed.expect("remove succeeds"); + let (sent, sender_ticket) = sender.await.expect("sender task"); + + // `Ok` is unreachable in-test (the fixture's SPV client is not + // started, so the broadcaster errors), but it is the same class + // of outcome: the payment was handed to the network layer. + let reached_broadcaster = + matches!(sent, Ok(_) | Err(SignedPaymentError::Broadcast(_))); + if reached_broadcaster { + assert!( + sender_ticket < remover_ticket, + "iteration {iteration}: a payment reached the broadcaster even though \ + wallet teardown had already completed — removal is not linearized with \ + broadcast (got {sent:?})" + ); + } else { + // The only other legal outcomes are the two clean refusals. + assert!( + matches!( + sent, + Err(SignedPaymentError::StaleToken(_)) + | Err(SignedPaymentError::WalletRemoved(_)) + ), + "iteration {iteration}: unexpected outcome {sent:?}" + ); + } + + // Whichever way it went, nothing survives teardown. + assert!(!core.is_current_generation().await); + assert_token_is_gone(token, &core).await; + }); + } + } + + /// Requirement: teardown WAITS for an in-flight finalizer, so a late + /// `register` cannot resurrect a token for a removed generation. + /// + /// Deterministic. The held shared guard stands in for + /// `core_wallet_signed_payment_finalize` sitting between its liveness check + /// and its synchronous `register`. Before the fix nothing connected those two + /// operations: the teardown ran to completion — sweep included — while the + /// finalizer was signing, and the token it then inserted was permanently + /// outside any sweep. + #[test] + fn teardown_waits_for_an_in_flight_finalizer_and_then_sweeps_its_token() { + // Shares the process-global registry with `wallet::destroy_tests`, + // which asserts on `outstanding()` counts — serialize against it. + let _registry = crate::core_wallet::signed_payment::registry_test_guard(); + + runtime().block_on(async { + let (manager, wallet_id) = test_platform_wallet_manager().await; + let wallet = manager + .get_wallet(&wallet_id) + .await + .expect("wallet present"); + let core = wallet.core().clone(); + + // The finalizer enters the gate (as the FFI does after signing). + let in_flight = SIGNED_PAYMENT_REGISTRY.lifecycle_read().await; + + let teardown = { + let manager = Arc::clone(&manager); + tokio::spawn(async move { + remove_wallet_and_tear_down_generation(&manager, &wallet_id).await + }) + }; + + // Teardown must block on the exclusive side of the gate. + tokio::time::sleep(Duration::from_millis(200)).await; + assert!( + !teardown.is_finished(), + "teardown must wait for the in-flight finalizer to leave the gate" + ); + + // Because teardown is still waiting, the finalizer's liveness check + // sees a live wallet and its register is legitimate. + assert!( + core.is_current_generation().await, + "the wallet must still be live while a finalizer holds the gate" + ); + let token = register_token(&core); + + drop(in_flight); + teardown + .await + .expect("teardown task") + .expect("remove succeeds"); + + // The teardown that was waiting sweeps the token the finalizer + // inserted — the invariant the gate exists to restore. + assert_token_is_gone(token, &core).await; + }); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index 72bf64d985d..df0f6fc9a6f 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -441,6 +441,11 @@ mod destroy_tests { /// independently-owned payments. #[test] fn destroying_wrapper_aliases_never_sweeps_tokens() { + // Asserts `outstanding()` DELTAS against a captured baseline, so it must + // not run while a sibling test mints or consumes tokens in the same + // process-global registry. + let _registry = crate::core_wallet::signed_payment::registry_test_guard(); + // Async setup only. `platform_wallet_destroy` and the final `release` // each do their own `runtime().block_on(...)`, exactly as the JNI / // NativeCleaner threads do (never from inside a tokio runtime). Calling diff --git a/packages/rs-platform-wallet/src/wallet/core/wallet.rs b/packages/rs-platform-wallet/src/wallet/core/wallet.rs index da83596cf35..629a534f441 100644 --- a/packages/rs-platform-wallet/src/wallet/core/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/core/wallet.rs @@ -359,6 +359,34 @@ impl CoreWallet { wm.get_wallet_and_info(&self.wallet_id) .map(|(_, info)| info.core_wallet.last_processed_height()) } + + /// Whether the generation this handle names is STILL the one registered + /// under its `wallet_id` in the manager. + /// + /// [`is_same_generation`](Self::is_same_generation) compares two *handles* + /// and therefore cannot see either way a generation stops being current: + /// + /// * **Removed** (`platform_wallet_manager_remove_wallet`). A retained + /// handle keeps `wallet_id`, the shared manager `Arc`, and its own balance + /// `Arc` alive, so two handles to the removed generation still compare + /// equal to each other. Only a lookup against the manager can tell that + /// nothing is registered under the id any more. + /// * **Re-created** under the same id. `wallet_id` and the manager `Arc` are + /// preserved; only the balance `Arc` is fresh. + /// + /// Both cases mean the same thing to a deferred payment: the accounts — + /// and therefore the `ReservationSet` holding its funding inputs — that this + /// handle names are no longer the wallet's live state, so acting on them + /// would spend against state the manager no longer owns. Callers that must + /// be atomic against a concurrent teardown take + /// [`SignedPaymentRegistry::lifecycle_read`](crate::SignedPaymentRegistry::lifecycle_read) + /// around the check and the action it gates; on its own this is a point-in- + /// time observation (`dashpay/platform#4185`). + pub async fn is_current_generation(&self) -> bool { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .is_some_and(|info| Arc::ptr_eq(&info.balance, self.generation())) + } } 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 781c0a3c564..5e00696ccc6 100644 --- a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -67,6 +67,7 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; +use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; use dashcore::{Transaction, Txid}; use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; @@ -142,9 +143,19 @@ const RESERVATION_MAX_AGE_BLOCKS: u32 = 20; /// `current_height` (see [`RESERVATION_MAX_AGE_BLOCKS`]). The registration /// height is mandatory — it is derived from the finalized /// [`SignedCoreTransaction::reservation_height`](crate::SignedCoreTransaction) -/// the registry consumed. An unknown *current* height (the wallet is gone from -/// the manager now) disables the guard: the wallet-mismatch / account-lookup -/// paths already reject those cases. +/// the registry consumed. +/// +/// An unknown *current* height means the wallet is gone from the manager, which +/// disables the guard (`None` → not expired). That is safe only because every +/// caller establishes liveness first and so never reaches here with a removed +/// wallet: [`broadcast`](SignedPaymentRegistry::broadcast) refuses with +/// [`SignedPaymentError::WalletRemoved`] before sampling the height, and +/// [`reconcile_removed_entry`](SignedPaymentRegistry::reconcile_removed_entry)'s +/// release is itself generation-bound and no-ops on a missing wallet. The +/// earlier claim that "the wallet-mismatch / account-lookup paths already reject +/// those cases" was wrong for the broadcast path — `is_same_generation` compares +/// handles (a removed generation matches itself) and the broadcast path performs +/// no account lookup at all (`dashpay/platform#4185`). fn reservation_expired(registered_height: u32, current_height: Option) -> bool { match current_height { Some(current) => current.saturating_sub(registered_height) >= RESERVATION_MAX_AGE_BLOCKS, @@ -168,6 +179,23 @@ pub enum SignedPaymentError { #[error("reservation token {0} was minted against a different wallet instance")] WalletMismatch(ReservationToken), + /// The wallet the token was minted against is no longer registered in the + /// manager — it was removed (`platform_wallet_manager_remove_wallet`), so + /// its accounts and their `ReservationSet`s ceased to exist along with it. + /// + /// Distinct from [`WalletMismatch`](Self::WalletMismatch), which means a + /// *different* live generation answers to the same id. Here there is no live + /// generation at all, so there is nothing to broadcast against and nothing + /// to reconcile: the token is dropped WITHOUT releasing (a release by + /// outpoint would have no `ReservationSet` to act on, and the reservation + /// died with the generation). + /// + /// Refusing here is what stops a retained handle from pushing a removed + /// wallet's payment onto the network after the host believed the wallet was + /// gone (`dashpay/platform#4185`). The network was NOT touched. + #[error("reservation token {0} belongs to a wallet that is no longer in the manager")] + WalletRemoved(ReservationToken), + /// The token has outlived [`RESERVATION_MAX_AGE_BLOCKS`], so its underlying /// 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) @@ -258,6 +286,19 @@ struct RegisteredPayment { pub struct SignedPaymentRegistry { next_token: AtomicU64, entries: Mutex>>, + /// Wallet-generation lifecycle gate, held across whole *operations* rather + /// than around individual map mutations — see + /// [`lifecycle_read`](Self::lifecycle_read) / + /// [`lifecycle_write`](Self::lifecycle_write). + /// + /// `entries` alone cannot provide this. It is a `std::sync::Mutex` that is + /// deliberately dropped before every `.await`, so it can only make a single + /// map mutation atomic — it cannot span a teardown (which awaits the manager + /// write lock plus shielded/identity unregistration) or a broadcast (which + /// awaits the network). Without a second, `await`-capable lock the + /// remove-then-sweep sequence and a concurrent broadcast interleave freely + /// (`dashpay/platform#4185`). + lifecycle: RwLock<()>, } impl Default for SignedPaymentRegistry { @@ -274,9 +315,58 @@ impl SignedPaymentRegistry { // null-handle convention). next_token: AtomicU64::new(1), entries: Mutex::new(HashMap::new()), + lifecycle: RwLock::new(()), } } + /// Enter the lifecycle gate as a *payment* operation — a broadcast, a + /// release, or a finalize→register sequence. + /// + /// Shared: any number of payment operations run concurrently, exactly as + /// before. What the guard excludes is a wallet-generation teardown + /// ([`lifecycle_write`](Self::lifecycle_write)), which is what makes a + /// generation-liveness observation + /// ([`CoreWallet::is_current_generation`]) safe to act on: a removal cannot + /// interleave between the check and the action the guard spans. + /// + /// Exposed (rather than only taken internally) because the finalize→register + /// sequence spans two crates: the FFI holds this guard across its liveness + /// check and the synchronous [`register`](Self::register), which is the only + /// way to stop an in-flight finalizer from inserting a token *after* + /// teardown already swept the registry. [`broadcast`](Self::broadcast) and + /// [`release`](Self::release) take it themselves, so a caller must NOT hold + /// it across those (the `RwLock` is not reentrant and tokio's is + /// write-preferring, so a pending teardown would deadlock the re-entry). + pub async fn lifecycle_read(&self) -> RwLockReadGuard<'_, ()> { + self.lifecycle.read().await + } + + /// Enter the lifecycle gate as a wallet-generation *teardown*. + /// + /// Exclusive against every payment operation. The FFI's + /// `platform_wallet_manager_remove_wallet` holds this across BOTH the + /// manager removal and the subsequent + /// [`remove_entries_for_wallet`](Self::remove_entries_for_wallet) sweep, so + /// the two are one linearization point rather than two independent steps + /// with a window between them (`dashpay/platform#4185`). + /// + /// Acquiring it also *waits for* in-flight payment operations to finish, so + /// a finalizer that is mid-signature when the host removes the wallet + /// completes and reconciles its own reservation before the sweep runs — + /// rather than registering a token into an already-swept registry. + /// + /// ## Lock ordering + /// + /// This gate is always taken BEFORE the wallet-manager `RwLock`, never + /// after: teardown takes it and then awaits `PlatformWalletManager:: + /// remove_wallet` (which takes the manager write lock); payment operations + /// take it and then await the manager read lock. Nothing in the wallet crate + /// acquires the gate while already holding a manager lock, so the two-lock + /// order is total and cannot deadlock. + pub async fn lifecycle_write(&self) -> RwLockWriteGuard<'_, ()> { + self.lifecycle.write().await + } + /// 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 @@ -327,6 +417,24 @@ impl SignedPaymentRegistry { /// move `signed` into a future whose body runs on the first poll; dropping /// that future before polling would leak the reservation to key-wallet's TTL /// (`dashpay/platform#4185`). Callers invoke it directly. + /// + /// # Liveness is the caller's obligation + /// + /// The generation check here is `signed`-relative: it proves `core` is the + /// wallet that *finalized* the payment. It says nothing about whether that + /// wallet is still registered in the manager, and being synchronous it + /// cannot ask (the manager lock is `async`). `finalize_transaction` drops + /// the manager write lock before awaiting the signer, so a teardown can run + /// to completion — sweep included — while a finalize is mid-signature; the + /// `register` that follows would then insert a live token for a removed + /// generation, defeating the documented teardown invariant that dropping + /// tokens makes stale handles inert. + /// + /// Callers must therefore hold [`lifecycle_read`](Self::lifecycle_read) + /// across `CoreWallet::is_current_generation` and this call, and abandon the + /// payment (releasing its reservation) when the wallet is gone. The FFI's + /// `core_wallet_signed_payment_finalize` is the production caller and does + /// exactly that. pub fn register( &self, core: CoreWallet, @@ -389,6 +497,13 @@ impl SignedPaymentRegistry { // 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. + // Hold the lifecycle gate for the whole operation. A wallet-generation + // teardown needs the exclusive side, so it cannot interleave between the + // liveness check below and the send: either the wallet is gone before we + // enter (our entry was already swept → `StaleToken`), or it stays live + // until we leave. Shared, so concurrent payments are unaffected. + let _lifecycle = self.lifecycle_read().await; + let entry = { let mut entries = self.lock(); match entries.get(&token) { @@ -408,6 +523,28 @@ impl SignedPaymentRegistry { .expect("entry present under the same lock hold") }; + // Refuse a token whose wallet is no longer registered in the manager. + // + // `is_same_generation` above compares two HANDLES, so it passes for a + // removed generation: both sides are the same removed wallet. Nothing + // further down re-checks — `broadcast_payment_releasing_reservation` + // goes straight to the broadcaster with no manager lookup, and the age + // guard below is *disabled* for a removed wallet + // (`last_processed_height` is `None`). So without this check a retained + // handle broadcasts a removed wallet's payment onto the network, and the + // teardown sweep cannot stop it: the sweep and the removal are one + // linearization point, but a broadcast that entered the gate first is + // outside it (`dashpay/platform#4185`). + // + // The entry is already removed, so we drop it WITHOUT releasing — the + // reservation ceased to exist with the generation, and a release by + // outpoint has no live `ReservationSet` to act on. Held under the + // lifecycle gate, so this is not a check-then-act: the wallet cannot be + // removed between here and the send below. + if !current.is_current_generation().await { + return Err(SignedPaymentError::WalletRemoved(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 @@ -469,6 +606,11 @@ 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) { + // Same lifecycle gate as `broadcast`: the reconciliation below reads the + // manager to bind its release to a live generation, so a teardown must + // not interleave between taking the entry and acting on it. + let _lifecycle = self.lifecycle_read().await; + let entry = { self.lock().remove(&token) }; let Some(entry) = entry else { // Unknown / already consumed — idempotent no-op. @@ -489,6 +631,17 @@ impl SignedPaymentRegistry { /// 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. + /// + /// # Must be called under [`lifecycle_write`](Self::lifecycle_write) + /// + /// Dropping the tokens is only half of teardown; the other half is the + /// manager removal itself, and the two are one atomic step only if the + /// caller holds the exclusive lifecycle gate across BOTH. Sweeping without + /// it leaves two windows a payment operation slips through — a broadcast + /// between the removal and this sweep still finds its entry, and an + /// in-flight finalizer registers a fresh token *after* this sweep has run + /// (`dashpay/platform#4185`). This function cannot take the gate itself: it + /// is synchronous, and the removal it must be atomic with is `async`. pub fn remove_entries_for_wallet(&self, wallet: &CoreWallet) -> usize { let mut entries = self.lock(); let before = entries.len(); diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 487bdbfcb62..b3f38aa67ad 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -103,6 +103,15 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// 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 = 36 + /// The named thing does not exist. Besides the handle/lookup failures this + /// has always covered, the deferred (BIP70/BIP270) payment calls report the + /// wallet-was-REMOVED case here: a signed-payment broadcast refuses a token + /// whose wallet is no longer registered in the manager, and a signed-payment + /// finalize refuses to register a payment whose wallet was removed while it + /// was being signed (reconciling its reservation first). Distinct from + /// `errorReservationWalletMismatch` (36), where a *different* live generation + /// answers to the same id. The call did NOT touch the network and is NOT + /// retryable — the wallet is gone. case notFound = 98 case errorUnknown = 99 @@ -319,6 +328,13 @@ public enum PlatformWalletError: LocalizedError { /// 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) + /// The named thing does not exist. For the deferred payment calls this is + /// the wallet-was-REMOVED case: the token's wallet (or the wallet a payment + /// was just signed against) is no longer registered in the manager, so there + /// is no live generation to act through. Nothing was broadcast; the + /// finalize path reconciles the build's reservation before returning. NOT + /// retryable — unlike `reservationWalletMismatch`, no other generation holds + /// this payment either. case notFound(String) case unknown(String) From 7fc0f24ad918407743ac57cd812043a59a00768b Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:18:47 -0400 Subject: [PATCH 27/47] fix(platform-wallet-ffi)!: move ErrorReservationWalletMismatch 29 -> 30 (#4185 review) Code 29 collided with `ErrorAssetLockInsufficientFunds` on #4184. Per the resolution of record in #4261's ERROR_CODE_REGISTRY.md, #4184 keeps 29 and this PR moves to 30. Verified 30 was genuinely free by reading `rs-platform-wallet-ffi/src/error.rs` at the head of all 62 open PRs: no PR defines a code 30. The `ErrorAssetLockCrossDomainConsentRequired` that in-tree comments name as 30's holder does not exist anywhere after #4184's re-scope. The discriminant is public ABI, so every mirror moves together: - Rust enum + its three rustdoc cross-references (error.rs) - two doc references in core_wallet/signed_payment.rs - JNI rustdoc (rs-unified-sdk-jni/src/wallet_manager.rs) - Swift PlatformWalletResultCode raw value + doc - Kotlin fromPlatformWalletNative branch, class KDoc, code-98 comment, WalletManagerNative KDoc, and the DashSdkErrorTest offset assertion Both Swift switches are symbolic (cbindgen `PLATFORM_WALLET_FFI_RESULT_CODE_*` constants), so only the enum raw value carried the number. Also disarms the NativeCleaner backstop in SignedCoreTransactionTest by closing the SignedCoreTransaction, so the armed native release cannot fire from the cleaner thread in a pure-JVM test. Note: #4256 is stacked downstream and still carries the pre-renumber 29; it must adopt 30 on rebase. --- .../org/dashfoundation/dashsdk/errors/DashSdkError.kt | 2 +- .../org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt | 2 +- .../org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt | 2 +- .../dashsdk/wallet/SignedCoreTransactionTest.kt | 7 +++++++ .../src/core_wallet/signed_payment.rs | 4 ++-- packages/rs-platform-wallet-ffi/src/error.rs | 2 +- packages/rs-unified-sdk-jni/src/wallet_manager.rs | 2 +- 7 files changed, 14 insertions(+), 7 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 3e0694ce0ad..93ca767abc5 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 @@ -270,7 +270,7 @@ sealed class DashSdkError( PlatformWallet(message, cause) /** - * `ErrorReservationWalletMismatch` (native code 29). A deferred + * `ErrorReservationWalletMismatch` (native code 30). 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 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 d9b0b2e7300..cc801169c5e 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 @@ -275,7 +275,7 @@ internal object WalletManagerNative { * Rather than double-broadcasting, an unusable token throws one of three * sibling codes — `ErrorStaleReservationToken` (27, aged out), * `ErrorReservationTokenConsumed` (28, already consumed/unknown), or - * `ErrorReservationWalletMismatch` (29, different wallet generation). + * `ErrorReservationWalletMismatch` (30, different wallet generation). * [coreHandle] must resolve to the wallet the token was minted against. * Returns the txid as a lowercase hex string. */ 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 ad9c740f3d4..6162e1eea06 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 @@ -135,7 +135,7 @@ class DashSdkErrorTest { assertEquals("already broadcast", consumed.message) val walletMismatch = - DashSdkError.fromNative(DashSDKException(offset + 29, "different generation")) + DashSdkError.fromNative(DashSDKException(offset + 30, "different generation")) assertTrue(walletMismatch is DashSdkError.PlatformWallet.ReservationWalletMismatch) assertFalse( "ReservationWalletMismatch must NOT be retryable (rebuild the payment)", 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 index c32011891e2..337e3431cf4 100644 --- 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 @@ -49,6 +49,13 @@ class SignedCoreTransactionTest { // object can be reclaimed via close() / GC rather than leaking the token. @Suppress("UNUSED_VARIABLE") val asCloseable: AutoCloseable = signed + + // Disarm the cleaner backstop before `signed` becomes unreachable: this + // is a pure-JVM test with no cdylib loaded, so the registered native + // release must never fire from the cleaner thread. (NativeCleaner already + // contains the resulting UnsatisfiedLinkError in `runCatching`, so this is + // determinism/hygiene rather than a crash fix.) + signed.close() } @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 5be35613cd5..bab65bbfcd0 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 @@ -64,7 +64,7 @@ pub(crate) fn registry_test_guard() -> std::sync::MutexGuard<'static, ()> { /// concurrent broadcast of the same token gets `ErrorReservationTokenConsumed` /// (28) rather than a second send. `core_handle` must resolve to the same wallet /// *generation* the token was minted against; a wallet re-created under the same -/// id yields `ErrorReservationWalletMismatch` (29). A token whose reservation +/// id yields `ErrorReservationWalletMismatch` (30). A token whose reservation /// may already have aged out of key-wallet's TTL yields /// `ErrorStaleReservationToken` (27). These three deferred-token failures are /// distinct codes so a host can message each precisely. Writes `out_txid` (a @@ -118,7 +118,7 @@ pub unsafe extern "C" fn core_wallet_signed_payment_broadcast( // generation to broadcast through. Reported as the existing `NotFound` // (98) rather than a new code: it is exactly the "the thing you named // does not exist" case 98 already means, and both hosts already map it. - // Distinct from `ErrorReservationWalletMismatch` (29), where a DIFFERENT + // Distinct from `ErrorReservationWalletMismatch` (30), where a DIFFERENT // live generation answers to the same id. Did NOT touch the network and // is NOT retryable — the wallet is gone. Err(e @ SignedPaymentError::WalletRemoved(_)) => { diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 1ef8d5a5f4d..33dfea98186 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -280,7 +280,7 @@ pub enum PlatformWalletFFIResultCode { /// refuses to register a payment whose wallet was removed while it was being /// signed — reconciling that build's reservation before returning. Neither /// touched the network. Contrast [`Self::ErrorReservationWalletMismatch`] - /// (29), where a DIFFERENT live generation answers to the same wallet id; + /// (30), where a DIFFERENT live generation answers to the same wallet id; /// here there is no live generation at all, so there is nothing to retry /// against (`dashpay/platform#4185`). NotFound = 98, diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 2b5e36fa7bb..02bef8d6079 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1367,7 +1367,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c /// consuming the token. Rather than double-broadcasting, an unusable token /// throws one of three sibling codes: `ErrorStaleReservationToken` (27, aged /// out), `ErrorReservationTokenConsumed` (28, unknown / already broadcast / -/// already released), or `ErrorReservationWalletMismatch` (29, different wallet +/// already released), or `ErrorReservationWalletMismatch` (30, different wallet /// generation). `coreHandle` must resolve to the wallet the token was minted /// against. Returns the txid as a lowercase hex string. #[no_mangle] From 5b6288ddc87ca5b877a959176d76dec02881fa8f Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:25:27 -0400 Subject: [PATCH 28/47] refactor(platform-wallet-ffi): drop stale HandleStorage::any left by the final-alias policy removal (#4185 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker 2 removed the final-alias sweep from `platform_wallet_destroy` (wallet.rs:392-414 is now just a storage `remove`), but the helper that policy was introduced for survived it. `HandleStorage::any` was added by ade399913c for that sweep and has had zero call sites since the policy was dropped. Because `handle` is a `pub mod` and the method is `pub`, no dead-code lint fires and it stayed in the crate's public Rust surface, with Rustdoc still pointing at "the final-alias check in `platform_wallet_destroy`" — a policy that no longer exists. It is not on the base branch, so removing it restores the base surface rather than breaking an existing consumer. `HandleStorage::remove_matching` is retained: it still backs the generation sweep at manager.rs:494. Also corrects a doc cross-reference to the same removed policy in `test_support::test_platform_wallet_manager`, which described the helper as backing "final-alias registry-sweep gating" when its only FFI consumer now asserts the opposite (destroying wrapper aliases must NOT sweep). No behavior change. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-ffi/src/handle.rs | 11 ----------- packages/rs-platform-wallet/src/test_support.rs | 3 ++- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/handle.rs b/packages/rs-platform-wallet-ffi/src/handle.rs index b4eba259f97..2a5692b2a52 100644 --- a/packages/rs-platform-wallet-ffi/src/handle.rs +++ b/packages/rs-platform-wallet-ffi/src/handle.rs @@ -71,17 +71,6 @@ 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/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index ec8b85bfd63..d3aa9d6f594 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -389,7 +389,8 @@ impl crate::events::PlatformEventHandler for NoopTestEventHandler {} /// `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. +/// `platform_wallet_destroy` regression asserting that destroying wrapper +/// aliases never sweeps an independently-owned deferred-payment token. pub async fn test_platform_wallet_manager() -> ( Arc>, WalletId, From e5c9982905dca51ae1a9f572a77eab5daef64df7 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:18:17 -0400 Subject: [PATCH 29/47] fix(platform-wallet): scope the lifecycle gate to the wallet generation (#4185 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate added in 0b0d5c76 lived on the FFI's process-global `SIGNED_PAYMENT_REGISTRY`, so it excluded only registry-token operations and did so across every wallet at once. Two consequences, both real: 1. Under-coverage. The V2 finalized-transaction-handle path bypassed it entirely. `core_wallet_tx_builder_finalize` awaited the external signer and then inserted into `CORE_SIGNED_TRANSACTION_V2_STORAGE` with no gate and no liveness re-check, so a teardown could sweep while signing was pending and the late finalizer published a handle no sweep would ever catch. `core_wallet_broadcast_signed_transaction_v2` then consumed such a handle and reached the broadcaster with no gate either — its `is_same_generation` check compares two HANDLES, and a removed generation matches itself. The same hole existed on the public Rust surface: `PlatformWalletManager::remove_wallet` never took the write side at all, so a direct embedder (the manager is public and `SignedPaymentRegistry` is re-exported) removed wallets with no exclusion. 2. Cross-wallet contention. A deferred broadcast holds the shared side across an SPV send; on one process-global write-preferring lock that send blocked teardown — and every payment operation queued behind the waiting writer — for every unrelated wallet in the process. Remedy: move the gate into shared per-generation state. * New `WalletGeneration` owns the lock-free `WalletBalance` AND that generation's `RwLock` lifecycle gate, and replaces `Arc` as the generation-identity marker. Folding them into one `Arc` is deliberate: the identity and the gate cannot diverge, so two handles can never compare as the same generation while excluding each other through different locks. `Deref` keeps every existing balance read unchanged. * `PlatformWalletManager::remove_wallet_with_teardown` takes that generation's exclusive gate across BOTH the removal and a caller-supplied teardown hook, and `remove_wallet` routes through it. The gate is no longer optional for any caller, FFI or not. The FFI passes its registry + V2-handle sweep as the hook. Lock order stays gate-then-manager: the lookup that resolves the gate drops `wallets` before awaiting it, then re-validates under the gate. * Every publication/network path now takes the generation's shared gate across its liveness check and the action: the registry `broadcast`/`release`, the token `core_wallet_signed_payment_finalize`, and — newly — both `core_wallet_tx_builder_finalize` and `core_wallet_broadcast_signed_transaction_v2`, which report a dead generation as the existing `NotFound` (98) after reconciling the build's reservation. V2 abandon/free stay ungated: their release is already generation-bound. The gate is still NOT held across an external signer await — finalizers acquire it only after the signature returns, so an open signing prompt cannot stall teardown, and a late finalizer instead fails its liveness check and abandons. The lifecycle comments that claimed the opposite were wrong about the code and are corrected. Adds four deterministic FFI regression tests alongside the existing three: a V2 broadcast-after-removal refusal, a teardown that waits for an in-flight V2 operation and then sweeps its handle, a public `PlatformWalletManager::remove_wallet` that waits for an in-flight payment, and a cross-wallet isolation test pinning the per-generation scoping. With the three guards removed the first three fail; with the gate re-pointed at a single process-global lock the fourth fails. No error-code changes: `ErrorReservationWalletMismatch` stays 30. Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/errors/DashSdkError.kt | 15 +- .../src/core_wallet/broadcast.rs | 34 ++ .../src/core_wallet/transaction_builder.rs | 58 ++- .../rs-platform-wallet-ffi/src/manager.rs | 365 ++++++++++++++++-- .../rs-platform-wallet/src/manager/load.rs | 10 +- .../src/manager/wallet_lifecycle.rs | 95 ++++- .../rs-platform-wallet/src/test_support.rs | 24 +- .../rs-platform-wallet/src/wallet/apply.rs | 6 +- .../src/wallet/asset_lock/sync/recovery.rs | 4 +- .../src/wallet/core/generation.rs | 138 +++++++ .../rs-platform-wallet/src/wallet/core/mod.rs | 2 + .../src/wallet/core/transaction.rs | 10 +- .../src/wallet/core/wallet.rs | 105 ++--- .../identity/network/contact_requests.rs | 4 +- .../src/wallet/platform_wallet.rs | 34 +- .../src/wallet/platform_wallet_traits.rs | 4 +- .../src/wallet/signed_payment_registry.rs | 149 +++---- .../PlatformWallet/PlatformWalletResult.swift | 25 +- 18 files changed, 845 insertions(+), 237 deletions(-) create mode 100644 packages/rs-platform-wallet/src/wallet/core/generation.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 93ca767abc5..8f8d546438e 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 @@ -368,11 +368,16 @@ sealed class DashSdkError( } 7, // ErrorIdentityNotFound 8, // ErrorContactNotFound - // NotFound. Handle/Option lookup failures, plus the deferred - // (BIP70/BIP270) wallet-was-REMOVED case: a signed-payment broadcast - // whose wallet is no longer registered in the manager, or a - // signed-payment finalize whose wallet was removed while it was - // being signed (its reservation is reconciled before this returns). + // NotFound. Handle/Option lookup failures, plus the + // wallet-was-REMOVED case on BOTH deferred-send paths: + // * deferred (BIP70/BIP270) TOKEN path — a signed-payment broadcast + // whose wallet is no longer registered in the manager, or a + // signed-payment finalize whose wallet was removed while it was + // being signed; + // * finalized-transaction HANDLE (V2) path — a tx-builder finalize + // whose wallet was removed or re-created during signing (no handle + // is published), or a V2 broadcast whose generation is gone. + // Every one reconciles the build's UTXO reservation before returning. // Nothing was broadcast, and unlike ReservationWalletMismatch (36) // no other live generation holds the payment either — so it is not // retryable. See dashpay/platform#4185. 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 a6978c60bdc..ee1f064b184 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -27,6 +27,11 @@ fn classify_broadcast_result( /// Success and `MaybeSent` both permanently consume the handle. A definitive /// rejection also consumes it after releasing the reservation. This prevents /// accidental rebroadcast through the same ownership token. +/// +/// A handle whose wallet generation is no longer registered in the manager +/// (removed, or re-created under the same id) is refused with `NotFound` (98) +/// **before** the network is touched; the handle is consumed and its reservation +/// reconciled. This mirrors the deferred-token path's `WalletRemoved` → 98. #[no_mangle] pub unsafe extern "C" fn core_wallet_broadcast_signed_transaction_v2( handle: Handle, @@ -58,6 +63,35 @@ pub unsafe extern "C" fn core_wallet_broadcast_signed_transaction_v2( ); } let local_txid = finalized.transaction.transaction().txid(); + + // Hold this generation's lifecycle gate across BOTH the liveness check and + // the send. The `is_same_generation` check above compares two HANDLES, so it + // passes for a removed generation — both sides name the same removed wallet — + // and nothing further down re-checks: `broadcast_finalized_transaction` goes + // straight to the broadcaster with no manager lookup. Without this, two + // retained handles push a deleted wallet's transaction onto the network, + // where it can conflict with inputs a re-created generation has since + // selected (`dashpay/platform#4185`). + // + // The gate makes this atomic rather than check-then-act: a teardown takes the + // exclusive side, so it cannot interleave between the check and the send. + // Scoped per generation, so this send — up to the broadcaster's timeout — + // blocks only THIS wallet's teardown, never an unrelated wallet's. + let (_lifecycle, wallet_is_live) = runtime().block_on(async { + let gate = wallet.generation_payment_guard().await; + let live = wallet.is_current_generation().await; + (gate, live) + }); + if !wallet_is_live { + runtime().block_on(finalized.wallet.abandon_transaction(&finalized.transaction)); + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + "wallet is no longer registered in the manager (removed or re-created); the \ + transaction was NOT broadcast and its reservation was reconciled" + .to_string(), + ); + } + let result = runtime().block_on( finalized .wallet 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 f3980a4d6d7..19c49986e39 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 @@ -94,6 +94,11 @@ impl From for AccountTypePreference { /// On success `out_transaction_handle` receives an opaque V2 handle. Consume /// it with `core_wallet_broadcast_signed_transaction_v2` or /// `core_wallet_abandon_signed_transaction_v2`. +/// +/// If the host removes (or re-creates) this wallet while the external signer is +/// running, no handle is published: the build's reservation is reconciled and +/// this returns `NotFound` (98), the same code the deferred-token sibling +/// `core_wallet_signed_payment_finalize` uses for that case. #[no_mangle] #[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn core_wallet_tx_builder_finalize( @@ -130,6 +135,43 @@ pub unsafe extern "C" fn core_wallet_tx_builder_finalize( &signer, )); let finalized = unwrap_result_or_return!(finalized); + + // Publishing the V2 handle is gated exactly like the deferred-token sibling + // below (`core_wallet_signed_payment_finalize`). `finalize_transaction` drops + // the wallet-manager write lock before awaiting the (external, possibly slow) + // signer, so the host can have removed this wallet while we were signing — + // and that removal's V2-handle sweep has then ALREADY run. Inserting now + // would publish a live handle for a removed generation that no later sweep + // catches, and `core_wallet_broadcast_signed_transaction_v2` would happily + // push it to the network: its `is_same_generation` check compares two + // handles, and a removed generation matches itself (`dashpay/platform#4185`). + // + // Hold THIS generation's lifecycle gate across BOTH the liveness check and + // the insert, so a teardown cannot interleave between them. Acquired AFTER + // the signer await, never around it: holding it across an open signing prompt + // would stall this wallet's teardown for as long as the user takes, and the + // check makes that unnecessary. + let (_lifecycle, wallet_is_live) = runtime().block_on(async { + let gate = wallet.core().generation_payment_guard().await; + let live = wallet.core().is_current_generation().await; + (gate, live) + }); + if !wallet_is_live { + // No handle was published, so nothing would ever release this build's + // reservation. Reconcile it here: the release is generation-bound, so on + // a genuine removal it is a logged no-op (the `ReservationSet` died with + // the generation), and on a re-create it correctly declines to touch the + // new generation's inputs. + runtime().block_on(wallet.core().abandon_transaction(&finalized)); + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + "wallet is no longer registered in the manager (removed or re-created while the \ + transaction was being signed); no transaction handle was published and its \ + reservation was reconciled" + .to_string(), + ); + } + *out_transaction_handle = CORE_SIGNED_TRANSACTION_V2_STORAGE.insert(FFICoreSignedTransactionV2 { wallet: wallet.core().clone(), @@ -225,16 +267,14 @@ pub unsafe extern "C" fn core_wallet_signed_payment_finalize( // invariant that dropping tokens makes stale handles inert // (`dashpay/platform#4185`). // - // Take the lifecycle gate (shared — concurrent payments are unaffected) and - // hold it across BOTH the liveness check and the synchronous `register`, so - // a teardown cannot interleave between them. Deliberately acquired AFTER the - // signer await rather than around it: holding it across an open signing - // prompt would stall every wallet's teardown for as long as the user takes, - // and the check below makes that unnecessary. + // Take THIS wallet generation's lifecycle gate (shared — concurrent payments + // are unaffected) and hold it across BOTH the liveness check and the + // synchronous `register`, so a teardown cannot interleave between them. + // Deliberately acquired AFTER the signer await rather than around it: holding + // it across an open signing prompt would stall this wallet's teardown for as + // long as the user takes, and the check below makes that unnecessary. let (_lifecycle, wallet_is_live) = runtime().block_on(async { - let gate = crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY - .lifecycle_read() - .await; + let gate = wallet.core().generation_payment_guard().await; let live = wallet.core().is_current_generation().await; (gate, live) }); diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index a249dc3fe0b..23daa81d5cc 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -519,45 +519,45 @@ pub(crate) async fn remove_wallet_and_tear_down_generation< manager: &platform_wallet::PlatformWalletManager

, wallet_id: &[u8; 32], ) -> Result<(), platform_wallet::PlatformWalletError> { - // Take the deferred-payment lifecycle gate for the WHOLE teardown, before - // touching the manager. Two things follow, and both are load-bearing - // (`dashpay/platform#4185`): + // The removal and the sweep below are ONE linearization point, taken under + // the REMOVED GENERATION'S OWN lifecycle gate. `remove_wallet_with_teardown` + // owns that gate, so the ordering cannot be got wrong here — or by any other + // caller, including a direct Rust embedder that never goes through this FFI. // - // * The manager removal and the registry sweep below become ONE step. They - // used to be two, with the removal's own `.await`s (shielded-coordinator - // and identity-sync unregistration) sitting in the gap — a concurrent - // `core_wallet_signed_payment_broadcast` on a retained handle would find - // its entry still registered, pass `is_same_generation` (a removed - // generation matches itself), skip the age guard (`last_processed_height` - // is `None` once the wallet is gone, which the guard maps to "not - // expired"), and reach the broadcaster — pushing a removed wallet's - // payment onto the network. + // What the single step buys (`dashpay/platform#4185`): the removal's own + // `.await`s (shielded-coordinator and identity-sync unregistration) used to + // sit in a gap between the removal and the sweep, and a concurrent + // `core_wallet_signed_payment_broadcast` on a retained handle slipped through + // it — its entry was still registered, `is_same_generation` passes (a removed + // generation matches itself), the age guard is skipped (`last_processed_height` + // is `None` once the wallet is gone, which the guard maps to "not expired"), + // and it reached the broadcaster, pushing a removed wallet's payment onto the + // network. // - // * Acquiring it WAITS for in-flight payment operations. A finalize that is - // mid-signature holds the shared side (`finalize_transaction` drops the - // manager write lock before awaiting the signer, so nothing else stops - // it), so it runs to its liveness check and either registers before we - // start — and is swept below — or observes the removal and abandons. - // Either way it can no longer insert a token AFTER the sweep has run. - let _teardown = crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY - .lifecycle_write() - .await; - - let removed = manager.remove_wallet(wallet_id).await?; - - // 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)); + // Acquiring the gate waits for this generation's payment operations that have + // entered their liveness-check/publish section. It does NOT wait for one still + // awaiting an external signer: those take the gate only after the signature + // returns (see `core_wallet_signed_payment_finalize` and + // `core_wallet_tx_builder_finalize`), deliberately, so an open signing prompt + // cannot stall teardown. Such a late finalizer instead observes the removed + // generation at its own liveness check and abandons rather than publishing. + manager + .remove_wallet_with_teardown(wallet_id, |removed| { + // 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)); + }) + .await?; Ok(()) } @@ -1108,8 +1108,9 @@ mod remove_wallet_lifecycle_tests { .expect("wallet present"); let core = wallet.core().clone(); - // The finalizer enters the gate (as the FFI does after signing). - let in_flight = SIGNED_PAYMENT_REGISTRY.lifecycle_read().await; + // The finalizer enters THIS generation's gate (as the FFI does after + // signing). + let in_flight = core.generation_payment_guard().await; let teardown = { let manager = Arc::clone(&manager); @@ -1144,4 +1145,290 @@ mod remove_wallet_lifecycle_tests { assert_token_is_gone(token, &core).await; }); } + + // --------------------------------------------------------------------- + // V2 finalized-transaction-handle path (`dashpay/platform#4185` review). + // + // The registry-token path above was gated first; the V2 path + // (`core_wallet_tx_builder_finalize` → `CORE_SIGNED_TRANSACTION_V2_STORAGE` + // → `core_wallet_broadcast_signed_transaction_v2`) reaches the SAME + // broadcaster through a retained handle and was left ungated. Its + // `is_same_generation` check compares two HANDLES, and a removed generation + // matches itself, so two retained handles pushed a deleted wallet's + // transaction onto the network. + // --------------------------------------------------------------------- + + /// Publish a V2 finalized-transaction handle for `core`'s generation, the + /// way `core_wallet_tx_builder_finalize` does. + fn publish_v2_handle( + core: &CoreWallet, + ) -> Handle { + crate::handle::CORE_SIGNED_TRANSACTION_V2_STORAGE.insert( + crate::core_wallet::FFICoreSignedTransactionV2 { + wallet: core.clone(), + transaction: SignedCoreTransaction::new_for_test( + dummy_tx(), + 0, + AccountTypePreference::BIP44, + 0, + 0, + None, + core.test_generation_marker(), + ), + }, + ) + } + + /// Requirement: a V2 handle whose wallet generation was removed must be + /// refused BEFORE the network, exactly as the registry-token path is. + /// + /// Deterministic. The setup reproduces the late-finalizer publication + /// directly: publish the handle AFTER teardown has already swept, which is + /// what an ungated `core_wallet_tx_builder_finalize` does when the host + /// removes the wallet during the signer await. + /// + /// Before the fix this handle was fully actionable — the caller handle and + /// the embedded originating handle name the same removed generation, so + /// `is_same_generation` passes, and `broadcast_finalized_transaction` goes + /// straight to the broadcaster with no manager lookup at all — so the + /// transaction went to the network. + #[test] + fn broadcasting_a_v2_handle_for_a_removed_wallet_is_refused_before_the_network() { + let _registry = crate::core_wallet::signed_payment::registry_test_guard(); + + // The extern "C" entry points call `runtime().block_on` themselves, so + // they must be invoked from OUTSIDE a runtime context — do the async + // setup first, then call across the boundary. + let core = runtime().block_on(async { + let (manager, wallet_id) = test_platform_wallet_manager().await; + let wallet = manager + .get_wallet(&wallet_id) + .await + .expect("wallet present"); + let core = wallet.core().clone(); + + remove_wallet_and_tear_down_generation(&manager, &wallet_id) + .await + .expect("remove succeeds"); + assert!( + !core.is_current_generation().await, + "the retained handle must observe its generation as gone" + ); + core + }); + + let transaction_handle = publish_v2_handle(&core); + let core_handle = crate::handle::CORE_WALLET_STORAGE.insert(core.clone()); + + let mut out_txid: *mut std::os::raw::c_char = std::ptr::null_mut(); + let result = unsafe { + crate::core_wallet::core_wallet_broadcast_signed_transaction_v2( + core_handle, + transaction_handle, + &mut out_txid, + ) + }; + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::NotFound, + "a V2 handle whose wallet was removed must be refused without a send" + ); + assert!( + out_txid.is_null(), + "no txid may be produced for a refused broadcast" + ); + + // Refusing still CONSUMES the handle: the generation is gone, so there is + // nothing to reconcile and nothing to retry. + assert!( + crate::handle::CORE_SIGNED_TRANSACTION_V2_STORAGE + .remove(transaction_handle) + .is_none(), + "the refused V2 handle must have been consumed" + ); + } + + /// Requirement: a teardown WAITS for an in-flight V2 operation on that + /// generation, then sweeps its handle — so a V2 handle can never be published + /// into an already-swept storage and outlive its wallet. + /// + /// Deterministic. The held shared guard stands in for + /// `core_wallet_tx_builder_finalize` sitting between its liveness check and + /// its insert, or `core_wallet_broadcast_signed_transaction_v2` sitting + /// between its liveness check and the send. Before the fix the V2 path took no + /// gate at all: the teardown ran to completion — sweep included — while the + /// finalizer was signing, and the handle it then published was permanently + /// outside any sweep and fully broadcastable. + #[test] + fn teardown_waits_for_an_in_flight_v2_operation_and_then_sweeps_its_handle() { + let _registry = crate::core_wallet::signed_payment::registry_test_guard(); + + let (core, transaction_handle) = runtime().block_on(async { + let (manager, wallet_id) = test_platform_wallet_manager().await; + let wallet = manager + .get_wallet(&wallet_id) + .await + .expect("wallet present"); + let core = wallet.core().clone(); + + // The V2 operation enters this generation's gate (as the FFI does + // after signing). + let in_flight = core.generation_payment_guard().await; + + let teardown = { + let manager = Arc::clone(&manager); + tokio::spawn(async move { + remove_wallet_and_tear_down_generation(&manager, &wallet_id).await + }) + }; + + // Teardown must block on the exclusive side of the gate. + tokio::time::sleep(Duration::from_millis(200)).await; + assert!( + !teardown.is_finished(), + "teardown must wait for the in-flight V2 operation to leave the gate" + ); + + // Because teardown is still waiting, the operation's liveness check + // sees a live wallet and its publish is legitimate. + assert!( + core.is_current_generation().await, + "the wallet must still be live while a V2 operation holds the gate" + ); + let transaction_handle = publish_v2_handle(&core); + + drop(in_flight); + teardown + .await + .expect("teardown task") + .expect("remove succeeds"); + + assert!(!core.is_current_generation().await); + (core, transaction_handle) + }); + + // The teardown that was waiting swept the handle the V2 operation + // published — the invariant the gate exists to restore. + assert!( + crate::handle::CORE_SIGNED_TRANSACTION_V2_STORAGE + .remove(transaction_handle) + .is_none(), + "the in-flight V2 operation's handle must have been swept by teardown" + ); + drop(core); + } + + /// Requirement: the lifecycle gate belongs to shared wallet-generation state, + /// so removal driven through the PUBLIC Rust API is excluded too — not just + /// removal driven through this crate's FFI wrapper. + /// + /// Deterministic. The held shared guard stands in for any payment operation + /// sitting between its liveness check and the action that check authorizes (a + /// register, or a send). `PlatformWalletManager` is public and + /// `SignedPaymentRegistry` is re-exported from `platform-wallet`, so a direct + /// Rust embedder reaches `remove_wallet` without ever touching + /// [`remove_wallet_and_tear_down_generation`]. While the gate lived on the + /// FFI's process-global registry singleton, that path took the write side + /// nowhere at all: removal ran straight through, and the payment then acted on + /// a generation the manager had already dropped. + #[test] + fn public_remove_wallet_waits_for_an_in_flight_payment_on_that_generation() { + let _registry = crate::core_wallet::signed_payment::registry_test_guard(); + + runtime().block_on(async { + let (manager, wallet_id) = test_platform_wallet_manager().await; + let wallet = manager + .get_wallet(&wallet_id) + .await + .expect("wallet present"); + let core = wallet.core().clone(); + + // A payment operation on this generation is in flight. + let in_flight = core.generation_payment_guard().await; + + let remover = { + let manager = Arc::clone(&manager); + tokio::spawn(async move { manager.remove_wallet(&wallet_id).await.map(|_| ()) }) + }; + + tokio::time::sleep(Duration::from_millis(200)).await; + assert!( + !remover.is_finished(), + "PlatformWalletManager::remove_wallet must wait for an in-flight payment on the \ + generation it is removing — the public removal path takes no lifecycle exclusion" + ); + + // Because the removal is still waiting, the in-flight payment's + // liveness check sees a live wallet and its action is legitimate. + assert!( + core.is_current_generation().await, + "the wallet must still be live while a payment holds its generation gate" + ); + + drop(in_flight); + remover + .await + .expect("remover task") + .expect("remove succeeds"); + assert!(!core.is_current_generation().await); + }); + } + + /// Requirement: the gate is scoped to ONE generation, so a slow payment on one + /// wallet cannot stall an unrelated wallet's teardown. + /// + /// Deterministic, and the reason the gate could not stay on the FFI's + /// process-global `SIGNED_PAYMENT_REGISTRY` singleton. A deferred broadcast + /// holds the shared side across an SPV send — up to the broadcaster's timeout + /// — and tokio's `RwLock` is write-preferring, so on one global lock that send + /// blocked teardown, and every payment operation queued behind the waiting + /// writer, for every unrelated wallet in the process. + #[test] + fn an_in_flight_payment_does_not_block_an_unrelated_wallets_teardown() { + let _registry = crate::core_wallet::signed_payment::registry_test_guard(); + + runtime().block_on(async { + let (manager_a, wallet_id_a) = test_platform_wallet_manager().await; + let (manager_b, wallet_id_b) = test_platform_wallet_manager().await; + + let core_a = manager_a + .get_wallet(&wallet_id_a) + .await + .expect("wallet A present") + .core() + .clone(); + let core_b = manager_b + .get_wallet(&wallet_id_b) + .await + .expect("wallet B present") + .core() + .clone(); + assert!( + !core_a.is_same_generation(&core_b), + "the fixture must produce two distinct generations" + ); + + // Wallet A has a payment in flight, holding A's gate. + let in_flight_a = core_a.generation_payment_guard().await; + + // Wallet B's teardown must not care. + let teardown_b = tokio::time::timeout( + Duration::from_secs(5), + remove_wallet_and_tear_down_generation(&manager_b, &wallet_id_b), + ) + .await + .expect( + "an unrelated wallet's teardown must not wait on wallet A's in-flight payment — \ + the lifecycle gate is not scoped to the wallet generation", + ); + teardown_b.expect("remove B succeeds"); + + // A is untouched and still live; B is gone. + assert!(core_a.is_current_generation().await); + assert!(!core_b.is_current_generation().await); + + drop(in_flight_a); + }); + } } diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index c746fb802b6..65f410f3395 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use crate::changeset::{ClientStartState, ClientWalletStartState, PlatformWalletPersistence}; use crate::error::PlatformWalletError; -use crate::wallet::core::WalletBalance; +use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; use crate::wallet::PlatformWallet; @@ -77,7 +77,7 @@ impl PlatformWalletManager

{ tracked_asset_locks.extend(account_locks); } - let balance = Arc::new(WalletBalance::new()); + let generation = Arc::new(WalletGeneration::new()); // Mirror the inner `ManagedWalletInfo.balance` (already // recomputed from the freshly-loaded UTXO set on the FFI // side via `update_balance`) into the lock-free `Arc` the @@ -88,7 +88,7 @@ impl PlatformWalletManager

{ // step has to live inside `platform_wallet` rather than // the FFI loader. let core_balance = &wallet_info.balance; - balance.set( + generation.set( core_balance.confirmed(), core_balance.unconfirmed(), core_balance.immature(), @@ -96,7 +96,7 @@ impl PlatformWalletManager

{ ); let platform_info = PlatformWalletInfo { core_wallet: wallet_info, - balance: Arc::clone(&balance), + generation: Arc::clone(&generation), identity_manager: IdentityManager::from(identity_manager), tracked_asset_locks, }; @@ -156,7 +156,7 @@ impl PlatformWalletManager

{ Arc::clone(&self.sdk), wallet_id, Arc::clone(&self.wallet_manager), - balance, + generation, Arc::clone(&self.lock_notify), Arc::clone(&persister_dyn), broadcaster, diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 938e0e790e2..28dd80a33de 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -16,7 +16,7 @@ use crate::changeset::{ PlatformWalletPersistence, ProviderKeyAccountEntry, WalletMetadataEntry, }; use crate::error::PlatformWalletError; -use crate::wallet::core::WalletBalance; +use crate::wallet::core::WalletGeneration; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; use crate::wallet::PlatformWallet; @@ -183,7 +183,7 @@ impl PlatformWalletManager

{ // place below, BEFORE the address-pool snapshot is taken. let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, birth_height); - let balance = Arc::new(WalletBalance::new()); + let generation = Arc::new(WalletGeneration::new()); // Snapshot per-account xpubs and address-pool entries BEFORE // the wallet / managed-info are moved into insert_wallet. The @@ -333,7 +333,7 @@ impl PlatformWalletManager

{ let platform_info = PlatformWalletInfo { core_wallet: wallet_info, - balance: Arc::clone(&balance), + generation: Arc::clone(&generation), identity_manager: crate::wallet::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), }; @@ -446,7 +446,7 @@ impl PlatformWalletManager

{ Arc::clone(&self.sdk), wallet_id, Arc::clone(&self.wallet_manager), - balance, + generation, Arc::clone(&self.lock_notify), persister_dyn, broadcaster, @@ -567,10 +567,91 @@ impl PlatformWalletManager

{ } /// Remove a wallet from the manager. + /// + /// Runs under the removed generation's lifecycle gate — see + /// [`remove_wallet_with_teardown`](Self::remove_wallet_with_teardown), of + /// which this is the no-extra-teardown case. pub async fn remove_wallet( &self, wallet_id: &WalletId, ) -> Result, PlatformWalletError> { + self.remove_wallet_with_teardown(wallet_id, |_| {}).await + } + + /// Remove a wallet from the manager and run `tear_down` on the removed + /// wallet — both under that generation's exclusive lifecycle gate, as one + /// linearization point. + /// + /// # Why the gate lives here rather than in the caller + /// + /// Removing the generation and tearing down the deferred state that names it + /// (the [`SignedPaymentRegistry`](crate::SignedPaymentRegistry) tokens and + /// the FFI's finalized-transaction handles) must be indivisible. If they are + /// two steps, a retained handle can broadcast in the gap: the removal's own + /// `.await`s (shielded-coordinator and identity-sync unregistration) sit + /// inside it, `CoreWallet::is_same_generation` passes for a removed + /// generation (a removed generation matches itself), and the reservation age + /// guard is disabled once `last_processed_height` returns `None`. So a + /// payment for a wallet the host already deleted reaches the network + /// (`dashpay/platform#4185`). + /// + /// Taking the gate *inside* this method rather than leaving it to the caller + /// is deliberate: `PlatformWalletManager` is public and `SignedPaymentRegistry` + /// is re-exported, so a direct Rust embedder that never goes through the FFI + /// would otherwise remove wallets with no exclusion at all, and could + /// interleave between a payment operation's liveness check and its register + /// or network action. `tear_down` is the hook that lets the FFI layer sweep + /// its own process-global handle storages inside the same critical section + /// without the gate ever being optional. + /// + /// `tear_down` is synchronous by design — it runs while the gate is held, and + /// every sweep it needs (`remove_entries_for_wallet`, + /// `HandleStorage::remove_matching`) is a synchronous map retain. + /// + /// ## Lock ordering + /// + /// The generation gate is always taken BEFORE the manager locks. The lookup + /// that finds the gate takes `wallets` briefly and **drops it before** + /// awaiting the gate, so no manager lock is ever held across a gate + /// acquisition; payment operations likewise take the gate and only then await + /// the manager. The order is total, so the two cannot deadlock. + pub async fn remove_wallet_with_teardown( + &self, + wallet_id: &WalletId, + tear_down: F, + ) -> Result, PlatformWalletError> + where + F: FnOnce(&Arc), + { + // Find the generation registered under `wallet_id` and take ITS gate. + // Re-validated after acquisition because the wallet could have been + // removed and re-created under the same id while we waited: in that case + // we hold the OLD generation's gate, which excludes nothing relevant to + // the new one, so retry against the generation that is actually current. + let _teardown = loop { + let generation = { + let wallets = self.wallets.read().await; + match wallets.get(wallet_id) { + None => { + return Err(PlatformWalletError::WalletNotFound(hex::encode(wallet_id))) + } + Some(wallet) => Arc::clone(wallet.generation()), + } + }; + let guard = generation.teardown_guard().await; + let still_current = { + let wallets = self.wallets.read().await; + wallets + .get(wallet_id) + .is_some_and(|wallet| Arc::ptr_eq(wallet.generation(), &generation)) + }; + if still_current { + break guard; + } + // Drop this generation's guard and re-resolve. + drop(guard); + }; + let owned_identity_ids: Vec = { let mut wm = self.wallet_manager.write().await; let ids = match wm.get_wallet_info(wallet_id) { @@ -640,6 +721,12 @@ impl PlatformWalletManager

{ .await; } + // Still under the generation's teardown gate: any deferred state naming + // this generation is dropped in the same critical section as the removal + // itself, so no payment operation can observe the wallet as live and then + // act on it after this returns. + tear_down(&removed); + Ok(removed) } } diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index d3aa9d6f594..a7c4dcba2db 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -32,7 +32,7 @@ use tokio::sync::RwLock; #[cfg(test)] use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; -use crate::wallet::core::WalletBalance; +use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; @@ -183,7 +183,7 @@ pub(crate) async fn funded_wallet_manager( ) -> ( Arc>>, WalletId, - Arc, + Arc, WalletSigner, ) { funded_wallet_manager_with_outputs(account_type, &[10_000_000]).await @@ -198,7 +198,7 @@ pub(crate) async fn funded_wallet_manager_with_outputs( ) -> ( Arc>>, WalletId, - Arc, + Arc, WalletSigner, ) { let mut ctx = TestWalletContext::new_random(); @@ -248,10 +248,10 @@ pub(crate) async fn funded_wallet_manager_with_outputs( wallet: ctx.wallet.clone(), }; - let balance = Arc::new(WalletBalance::new()); + let generation = Arc::new(WalletGeneration::new()); let info = PlatformWalletInfo { core_wallet: ctx.managed_wallet, - balance: Arc::clone(&balance), + generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), }; @@ -259,7 +259,7 @@ pub(crate) async fn funded_wallet_manager_with_outputs( 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) + (Arc::new(RwLock::new(wm)), wallet_id, generation, signer) } /// Like [`funded_wallet_manager`] but funds the wallet's CoinJoin account 0 @@ -275,7 +275,7 @@ pub(crate) async fn funded_wallet_manager_with_outputs( pub(crate) async fn funded_coinjoin_wallet_manager() -> ( Arc>>, WalletId, - Arc, + Arc, WalletSigner, ) { let mut ctx = TestWalletContext::new_random(); @@ -319,10 +319,10 @@ pub(crate) async fn funded_coinjoin_wallet_manager() -> ( wallet: ctx.wallet.clone(), }; - let balance = Arc::new(WalletBalance::new()); + let generation = Arc::new(WalletGeneration::new()); let info = PlatformWalletInfo { core_wallet: ctx.managed_wallet, - balance: Arc::clone(&balance), + generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), }; @@ -330,7 +330,7 @@ pub(crate) async fn funded_coinjoin_wallet_manager() -> ( 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) + (Arc::new(RwLock::new(wm)), wallet_id, generation, signer) } /// Funded SPV-backed Core wallet for downstream FFI lifecycle tests. The SPV @@ -341,7 +341,7 @@ pub async fn funded_spv_core_wallet( crate::CoreWallet, WalletSigner, ) { - let (manager, wallet_id, balance, signer) = funded_wallet_manager(account_type).await; + let (manager, wallet_id, generation, signer) = funded_wallet_manager(account_type).await; let spv = Arc::new(crate::spv::SpvRuntime::new( Arc::clone(&manager), Arc::new(crate::events::PlatformEventManager::new(Vec::new())), @@ -349,7 +349,7 @@ pub async fn funded_spv_core_wallet( let broadcaster = Arc::new(crate::broadcaster::SpvBroadcaster::new(spv)); let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); ( - crate::CoreWallet::new(sdk, manager, wallet_id, broadcaster, balance), + crate::CoreWallet::new(sdk, manager, wallet_id, broadcaster, generation), signer, ) } diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index bbe87893d6b..993ccff4134 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -359,7 +359,7 @@ impl PlatformWalletInfo { // Mirror the recomputed balance into the lock-free Arc that the // UI reads. let core_balance = &self.core_wallet.balance; - self.balance.set( + self.generation.set( core_balance.confirmed(), core_balance.unconfirmed(), core_balance.immature(), @@ -389,7 +389,7 @@ mod tests { ReceivedContactRequestKey, SentContactRequestKey, TokenBalanceChangeSet, }; use crate::wallet::asset_lock::tracked::AssetLockStatus; - use crate::wallet::core::WalletBalance; + use crate::wallet::core::WalletGeneration; use crate::wallet::identity::state::managed_identity::ManagedIdentity; use crate::wallet::identity::IdentityManager; use crate::wallet::identity::{ContactRequest, EstablishedContact}; @@ -410,7 +410,7 @@ mod tests { fn empty_info(wallet: &Wallet) -> PlatformWalletInfo { PlatformWalletInfo { core_wallet: ManagedWalletInfo::from_wallet(wallet, 0), - balance: std::sync::Arc::new(WalletBalance::new()), + generation: std::sync::Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index 536f669a868..6083f298227 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -467,7 +467,7 @@ mod tests { use crate::test_support::{funded_wallet_manager, AlwaysRejectedBroadcaster}; use crate::wallet::asset_lock::manager::AssetLockManager; use crate::wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; - use crate::wallet::core::WalletBalance; + use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::PlatformWalletInfo; @@ -740,7 +740,7 @@ mod tests { let restored_wallet = Wallet::new_external_signable(Network::Testnet, wallet_id, accounts); let mut restored_info = PlatformWalletInfo { core_wallet: ManagedWalletInfo::from_wallet(&restored_wallet, 0), - balance: Arc::new(WalletBalance::new()), + generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), }; diff --git a/packages/rs-platform-wallet/src/wallet/core/generation.rs b/packages/rs-platform-wallet/src/wallet/core/generation.rs new file mode 100644 index 00000000000..5d70488443a --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/core/generation.rs @@ -0,0 +1,138 @@ +//! Per-wallet-*generation* shared state: the identity marker every handle to +//! one generation shares, and that generation's lifecycle gate. + +use std::ops::Deref; +use std::sync::Arc; + +use tokio::sync::{OwnedRwLockWriteGuard, RwLock, RwLockReadGuard}; + +use super::balance::WalletBalance; + +/// The state one wallet *generation* shares across every handle that names it. +/// +/// A "generation" is one live in-memory instance of a logical wallet. Removing a +/// wallet and re-creating it under the same `wallet_id` produces a *different* +/// generation: same id, same shared multi-wallet `WalletManager` `Arc`, fresh +/// `WalletGeneration`. `PlatformWalletManager` builds exactly one of these per +/// registration and clones the `Arc` into `PlatformWalletInfo`, `PlatformWallet` +/// and `CoreWallet`, so `Arc::ptr_eq` on it is the canonical generation identity +/// (see [`CoreWallet::is_same_generation`](super::CoreWallet::is_same_generation)). +/// +/// # Why the balance and the lifecycle gate live in the *same* object +/// +/// They are one indivisible fact — "which generation is this?" — and splitting +/// them into two `Arc`s threaded separately through ~15 construction sites would +/// let a future site clone the identity marker but mint a *fresh* gate. Two +/// handles would then compare as the same generation while excluding each other +/// through different locks: teardown would take one gate, an in-flight payment +/// would hold the other, and the exclusion would silently vanish with nothing to +/// fail. Keeping them in one `Arc` makes that divergence unrepresentable — +/// same generation is the same gate, by construction (`dashpay/platform#4185`). +/// +/// [`Deref`] to [`WalletBalance`] keeps every existing lock-free balance read +/// (`generation.confirmed()`, `info.balance.locked()`, …) working unchanged. +#[derive(Debug)] +pub struct WalletGeneration { + /// Lock-free balance for UI reads. Updated from `ManagedWalletInfo` after + /// each SPV block/mempool processing and RPC refresh. + balance: WalletBalance, + /// This generation's lifecycle gate — held across whole *operations* rather + /// than around individual state mutations. + /// + /// Shared side ([`payment_guard`](Self::payment_guard)): any operation that + /// will publish an ownership handle for this generation, or push one of its + /// transactions to the network, after observing that the generation is still + /// live. Exclusive side ([`teardown_guard`](Self::teardown_guard)): removing + /// the generation from the manager and sweeping its deferred state. + /// + /// This is deliberately **per generation** rather than one process-global + /// lock. A deferred broadcast holds the shared side across an SPV send + /// (seconds, up to the broadcaster's timeout); with a single global lock that + /// send would block teardown — and, because tokio's `RwLock` is + /// write-preferring, every subsequent payment operation — for *every + /// unrelated wallet* in the process. Scoped here, one wallet's slow send + /// only ever excludes that same wallet's teardown, which is exactly the pair + /// that must not interleave. + /// + /// Held in its own `Arc` so [`teardown_guard`](Self::teardown_guard) can hand + /// back an *owned* guard: the remover resolves which generation is current in + /// a retry loop, and the guard must outlive the loop iteration that produced + /// the `Arc` it came from. + lifecycle: Arc>, +} + +impl Default for WalletGeneration { + fn default() -> Self { + Self::new() + } +} + +impl WalletGeneration { + /// A fresh generation: zeroed balance, uncontended gate. + pub fn new() -> Self { + Self { + balance: WalletBalance::new(), + lifecycle: Arc::new(RwLock::new(())), + } + } + + /// This generation's lock-free balance. + pub fn balance(&self) -> &WalletBalance { + &self.balance + } + + /// Enter this generation's lifecycle gate as a *payment* operation. + /// + /// Shared: any number of payment operations on this generation (and every + /// operation on every *other* generation) run concurrently. What it excludes + /// is this generation's own teardown ([`teardown_guard`](Self::teardown_guard)), + /// which is what makes a liveness observation + /// ([`CoreWallet::is_current_generation`](super::CoreWallet::is_current_generation)) + /// safe to act on: held across both the check and the action it gates, a + /// removal cannot interleave between them. + /// + /// Callers must hold it across the check *and* the publication/network step, + /// and must not already hold it (the `RwLock` is not reentrant, and because + /// tokio's is write-preferring a queued teardown would deadlock the + /// re-entry). + /// + /// # Lock ordering + /// + /// Always taken BEFORE the wallet-manager `RwLock`, never while holding it. + /// Teardown takes it and then awaits the manager write lock; payment + /// operations take it and then await the manager read lock. The order is + /// total, so the two locks cannot deadlock. + pub async fn payment_guard(&self) -> RwLockReadGuard<'_, ()> { + self.lifecycle.read().await + } + + /// Enter this generation's lifecycle gate as a *teardown*. + /// + /// Exclusive against every payment operation on this generation. Removal + /// holds it across BOTH the manager removal and the deferred-state sweep, so + /// the two are one linearization point rather than two steps with a window + /// between them that a retained handle could broadcast through + /// (`dashpay/platform#4185`). + /// + /// Acquiring it waits for payment operations that have already entered their + /// liveness-check/publish section. It does **not** wait for an operation + /// still awaiting an external signer: those acquire the gate only *after* + /// the signature returns, precisely so an open signing prompt cannot stall + /// teardown. Such a late finalizer then observes the removed generation at + /// its liveness check and abandons instead of publishing. + /// + /// Returns an *owned* guard so it can outlive the `Arc` + /// binding it was taken from — the remover resolves the current generation in + /// a retry loop and must carry the guard out of the iteration that found it. + pub async fn teardown_guard(&self) -> OwnedRwLockWriteGuard<()> { + Arc::clone(&self.lifecycle).write_owned().await + } +} + +impl Deref for WalletGeneration { + type Target = WalletBalance; + + fn deref(&self) -> &WalletBalance { + &self.balance + } +} diff --git a/packages/rs-platform-wallet/src/wallet/core/mod.rs b/packages/rs-platform-wallet/src/wallet/core/mod.rs index 5481362ae8b..ba84b77f21e 100644 --- a/packages/rs-platform-wallet/src/wallet/core/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/core/mod.rs @@ -1,10 +1,12 @@ pub mod balance; pub mod balance_handler; mod broadcast; +pub mod generation; mod transaction; pub mod wallet; pub use balance::WalletBalance; pub use balance_handler::BalanceUpdateHandler; +pub use generation::WalletGeneration; pub use transaction::SignedCoreTransaction; pub use wallet::CoreWallet; diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index ccc9d7227c3..32603873dae 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -19,7 +19,7 @@ use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePr use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::{Account, DerivationPath, ReservationToken, Utxo}; -use super::{CoreWallet, WalletBalance}; +use super::{CoreWallet, WalletGeneration}; use crate::broadcaster::TransactionBroadcaster; use crate::PlatformWalletError; @@ -95,7 +95,7 @@ pub struct SignedCoreTransaction { /// the owner, submit A's transaction through B's broadcaster, and run B's /// cleanup while A's real reservation leaked until its TTL /// (`dashpay/platform#4185`). - origin_generation: Arc, + origin_generation: Arc, } impl SignedCoreTransaction { @@ -138,7 +138,7 @@ impl SignedCoreTransaction { /// [`origin_generation`](Self::origin_generation) field docs). Borrowed, not /// consumed, so the check can run before /// [`into_registered_parts`](Self::into_registered_parts) takes ownership. - pub(crate) fn origin_generation(&self) -> &Arc { + pub(crate) fn origin_generation(&self) -> &Arc { &self.origin_generation } @@ -193,7 +193,7 @@ impl SignedCoreTransaction { funding_account_index: u32, reservation_height: u32, reservation_token: Option, - origin_generation: Arc, + origin_generation: Arc, ) -> Self { Self { transaction, @@ -429,7 +429,7 @@ impl CoreWallet { ); return; }; - if !Arc::ptr_eq(&info.balance, self.generation()) { + if !Arc::ptr_eq(&info.generation, 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. diff --git a/packages/rs-platform-wallet/src/wallet/core/wallet.rs b/packages/rs-platform-wallet/src/wallet/core/wallet.rs index 629a534f441..fbf2c7684e0 100644 --- a/packages/rs-platform-wallet/src/wallet/core/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/core/wallet.rs @@ -3,9 +3,10 @@ use std::sync::Arc; use super::balance::WalletBalance; +use super::generation::WalletGeneration; use dashcore::Address as DashAddress; -use tokio::sync::RwLock; +use tokio::sync::{RwLock, RwLockReadGuard}; use key_wallet::managed_account::address_pool::KeySource; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; @@ -33,8 +34,10 @@ pub struct CoreWallet { /// Injected broadcaster — delegates to SPV or DAPI depending on how /// the wallet was constructed by `PlatformWalletManager`. pub(crate) broadcaster: Arc, - /// Lock-free balance for UI reads. - balance: Arc, + /// This handle's wallet *generation*: the lock-free balance the UI reads and + /// that generation's lifecycle gate, in the one `Arc` every handle to the + /// generation shares. + generation: Arc, } impl CoreWallet { @@ -43,20 +46,20 @@ impl CoreWallet { wallet_manager: Arc>>, wallet_id: WalletId, broadcaster: Arc, - balance: Arc, + generation: Arc, ) -> Self { Self { sdk, wallet_manager, wallet_id, broadcaster, - balance, + generation, } } /// Lock-free balance snapshot for UI reads. pub fn balance(&self) -> &WalletBalance { - &self.balance + self.generation.balance() } /// Wallet id this `CoreWallet` operates on. Exposed so FFI @@ -72,15 +75,14 @@ impl CoreWallet { /// /// 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). + /// [`Arc`](WalletGeneration); a wallet removed and + /// re-created under the same `wallet_id` gets a fresh one. `Arc::ptr_eq` on + /// it 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 that `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 @@ -94,18 +96,35 @@ impl CoreWallet { ) -> bool { self.wallet_id == other.wallet_id && Arc::ptr_eq(&self.wallet_manager, &other.wallet_manager) - && Arc::ptr_eq(&self.balance, &other.balance) + && Arc::ptr_eq(&self.generation, &other.generation) } - /// 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 + /// This handle's [`WalletGeneration`] `Arc` — the generation-identity marker + /// (see [`is_same_generation`](Self::is_same_generation)). The manager stores + /// the same `Arc` in `PlatformWalletInfo.generation`, 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(crate) fn generation(&self) -> &Arc { + &self.generation + } + + /// Enter THIS generation's lifecycle gate as a payment operation — see + /// [`WalletGeneration::payment_guard`]. + /// + /// Every path that publishes an ownership handle for this generation (a + /// registry token, a V2 finalized-transaction handle) or pushes one of its + /// transactions to the network must hold this across both its + /// [`is_current_generation`](Self::is_current_generation) check and the + /// action that check authorizes. Without it the check is a bare + /// point-in-time observation and a teardown can complete in the gap + /// (`dashpay/platform#4185`). + /// + /// Scoped to this generation, so holding it across a slow SPV send blocks + /// only this wallet's teardown — never an unrelated wallet's. + pub async fn generation_payment_guard(&self) -> RwLockReadGuard<'_, ()> { + self.generation.payment_guard().await } /// This handle's per-generation identity marker, cloned — for tests (and @@ -116,8 +135,8 @@ impl CoreWallet { /// as the production `finalize_transaction` path binds a token to the /// finalizing wallet. #[cfg(any(test, feature = "test-utils"))] - pub fn test_generation_marker(&self) -> Arc { - Arc::clone(&self.balance) + pub fn test_generation_marker(&self) -> Arc { + Arc::clone(&self.generation) } pub async fn set_gap_limit( @@ -367,25 +386,25 @@ impl CoreWallet { /// and therefore cannot see either way a generation stops being current: /// /// * **Removed** (`platform_wallet_manager_remove_wallet`). A retained - /// handle keeps `wallet_id`, the shared manager `Arc`, and its own balance - /// `Arc` alive, so two handles to the removed generation still compare - /// equal to each other. Only a lookup against the manager can tell that - /// nothing is registered under the id any more. + /// handle keeps `wallet_id`, the shared manager `Arc`, and its own + /// [`WalletGeneration`] `Arc` alive, so two handles to the removed + /// generation still compare equal to each other. Only a lookup against the + /// manager can tell that nothing is registered under the id any more. /// * **Re-created** under the same id. `wallet_id` and the manager `Arc` are - /// preserved; only the balance `Arc` is fresh. + /// preserved; only the `WalletGeneration` `Arc` is fresh. /// /// Both cases mean the same thing to a deferred payment: the accounts — /// and therefore the `ReservationSet` holding its funding inputs — that this /// handle names are no longer the wallet's live state, so acting on them /// would spend against state the manager no longer owns. Callers that must /// be atomic against a concurrent teardown take - /// [`SignedPaymentRegistry::lifecycle_read`](crate::SignedPaymentRegistry::lifecycle_read) - /// around the check and the action it gates; on its own this is a point-in- - /// time observation (`dashpay/platform#4185`). + /// [`generation_payment_guard`](Self::generation_payment_guard) around the + /// check and the action it gates; on its own this is a point-in-time + /// observation (`dashpay/platform#4185`). pub async fn is_current_generation(&self) -> bool { let wm = self.wallet_manager.read().await; wm.get_wallet_info(&self.wallet_id) - .is_some_and(|info| Arc::ptr_eq(&info.balance, self.generation())) + .is_some_and(|info| Arc::ptr_eq(&info.generation, self.generation())) } } @@ -407,7 +426,7 @@ impl Clone for CoreWallet { wallet_manager: Arc::clone(&self.wallet_manager), wallet_id: self.wallet_id, broadcaster: Arc::clone(&self.broadcaster), - balance: Arc::clone(&self.balance), + generation: Arc::clone(&self.generation), } } } @@ -418,21 +437,21 @@ mod tests { use key_wallet::account::account_type::StandardAccountType; - use super::WalletBalance; + use super::WalletGeneration; 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 + /// aliases of one generation share the per-generation `WalletGeneration` `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 + /// same multi-wallet `WalletManager` `Arc` but a fresh generation `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 + /// tell them apart — the generation `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) = + let (manager, wallet_id, generation, _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); @@ -442,10 +461,10 @@ mod tests { Arc::clone(&manager), wallet_id, Arc::clone(&broadcaster), - Arc::clone(&balance), + Arc::clone(&generation), ); - // A clone is an alias of the SAME generation (shares the balance Arc). + // A clone is an alias of the SAME generation (shares the generation Arc). let alias = generation_a.clone(); assert!( generation_a.is_same_generation(&alias), @@ -454,19 +473,19 @@ mod tests { assert!(alias.is_same_generation(&generation_a)); // A re-created generation: SAME manager Arc + SAME wallet_id, fresh - // per-generation balance Arc. + // per-generation `WalletGeneration` Arc. let generation_b = CoreWallet::new( sdk, Arc::clone(&manager), wallet_id, broadcaster, - Arc::new(WalletBalance::new()), + Arc::new(WalletGeneration::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 + // Sanity: it is ONLY the generation 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()); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index fc96fd6ead2..5df388e7bae 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -3338,7 +3338,7 @@ mod sweep_tests { use super::*; use crate::broadcaster::SpvBroadcaster; use crate::changeset::{ContactChangeSet, PlatformWalletChangeSet, SentContactRequestKey}; - use crate::wallet::core::WalletBalance; + use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; use crate::wallet::persister::{NoPlatformPersistence, WalletPersister}; use crate::wallet::platform_wallet::PlatformWalletInfo; @@ -3362,7 +3362,7 @@ mod sweep_tests { fn empty_info(wallet: &Wallet) -> PlatformWalletInfo { PlatformWalletInfo { core_wallet: ManagedWalletInfo::from_wallet(wallet, 0), - balance: Arc::new(WalletBalance::new()), + generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index cfb310ea597..d23a9eb7dbb 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -12,7 +12,7 @@ use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; use super::asset_lock::manager::AssetLockManager; use super::asset_lock::tracked::TrackedAssetLock; -use super::core::{CoreWallet, WalletBalance}; +use super::core::{CoreWallet, WalletBalance, WalletGeneration}; use super::identity::{IdentityManager, IdentityWallet}; use super::persister::WalletPersister; use super::platform_addresses::PlatformAddressWallet; @@ -40,14 +40,16 @@ pub type WalletId = [u8; 32]; /// Lives inside `WalletManager.wallet_infos`. The `Wallet` /// key material is in `WalletManager.wallets` — NOT inside this struct. /// -/// `WalletBalance` is stored as `Arc` for lock-free UI reads. +/// The per-generation state (lock-free balance + lifecycle gate) is stored as +/// `Arc`; `Arc::ptr_eq` on it is this wallet's generation identity. pub struct PlatformWalletInfo { /// Core wallet metadata, accounts, UTXOs, balances. /// Delegates `WalletInfoInterface` methods. pub core_wallet: ManagedWalletInfo, - /// Lock-free balance for UI reads. Updated from `ManagedWalletInfo` after - /// each SPV block/mempool processing and RPC refresh. - pub balance: Arc, + /// This wallet generation's shared state: the lock-free balance for UI reads + /// (updated from `ManagedWalletInfo` after each SPV block/mempool processing + /// and RPC refresh) and the generation's lifecycle gate. + pub generation: Arc, pub identity_manager: IdentityManager, pub tracked_asset_locks: BTreeMap, } @@ -79,8 +81,8 @@ pub struct PlatformWallet { pub(crate) asset_locks: Arc>, /// Per-wallet persistence handle. persister: WalletPersister, - /// Lock-free balance for UI reads, cloned from `PlatformWalletInfo.balance`. - pub(crate) balance: Arc, + /// This generation's shared state, cloned from `PlatformWalletInfo.generation`. + pub(crate) generation: Arc, /// Per-account Orchard keysets, populated by [`bind_shielded`]. /// `None` until bind has run; remains `None` for `WatchOnly` /// / `ExternalSignable` wallets that have never had a @@ -185,8 +187,14 @@ impl PlatformWallet { } /// Get the lock-free balance for UI reads. - pub fn balance(&self) -> &Arc { - &self.balance + pub fn balance(&self) -> &WalletBalance { + self.generation.balance() + } + + /// This wallet's [`WalletGeneration`] `Arc` — its generation identity and + /// lifecycle gate. See [`CoreWallet::is_same_generation`]. + pub fn generation(&self) -> &Arc { + &self.generation } /// Get a reference to the per-wallet persistence handle. @@ -411,7 +419,7 @@ impl PlatformWallet { sdk: Arc, wallet_id: WalletId, wallet_manager: Arc>>, - balance: Arc, + generation: Arc, lock_notify: Arc, persister: Arc, broadcaster: Arc, @@ -426,7 +434,7 @@ impl PlatformWallet { Arc::clone(&wallet_manager), wallet_id, Arc::clone(&broadcaster), - Arc::clone(&balance), + Arc::clone(&generation), ); // Asset-lock broadcaster is pinned to `SpvBroadcaster`; the @@ -475,7 +483,7 @@ impl PlatformWallet { platform, asset_locks, persister: wallet_persister, - balance, + generation, #[cfg(feature = "shielded")] shielded_keys: Arc::new(RwLock::new(None)), #[cfg(feature = "shielded")] @@ -1604,7 +1612,7 @@ impl Clone for PlatformWallet { platform: self.platform.clone(), asset_locks: self.asset_locks.clone(), persister: self.persister.clone(), - balance: self.balance.clone(), + generation: self.generation.clone(), #[cfg(feature = "shielded")] shielded_keys: self.shielded_keys.clone(), #[cfg(feature = "shielded")] diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs index 9feb25bb043..62b1cef00ea 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -37,7 +37,7 @@ impl WalletInfoInterface for PlatformWalletInfo { let inner = ManagedWalletInfo::from_wallet(wallet, birth_height); Self { core_wallet: inner, - balance: std::sync::Arc::new(super::core::WalletBalance::new()), + generation: std::sync::Arc::new(super::core::WalletGeneration::new()), identity_manager: super::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), } @@ -49,7 +49,7 @@ impl WalletInfoInterface for PlatformWalletInfo { let inner = ManagedWalletInfo::from_wallet_with_name(wallet, name, birth_height); Self { core_wallet: inner, - balance: std::sync::Arc::new(super::core::WalletBalance::new()), + generation: std::sync::Arc::new(super::core::WalletGeneration::new()), identity_manager: super::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), } 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 5e00696ccc6..fa638fcea95 100644 --- a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -67,7 +67,6 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; -use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; use dashcore::{Transaction, Txid}; use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; @@ -286,19 +285,6 @@ struct RegisteredPayment { pub struct SignedPaymentRegistry { next_token: AtomicU64, entries: Mutex>>, - /// Wallet-generation lifecycle gate, held across whole *operations* rather - /// than around individual map mutations — see - /// [`lifecycle_read`](Self::lifecycle_read) / - /// [`lifecycle_write`](Self::lifecycle_write). - /// - /// `entries` alone cannot provide this. It is a `std::sync::Mutex` that is - /// deliberately dropped before every `.await`, so it can only make a single - /// map mutation atomic — it cannot span a teardown (which awaits the manager - /// write lock plus shielded/identity unregistration) or a broadcast (which - /// awaits the network). Without a second, `await`-capable lock the - /// remove-then-sweep sequence and a concurrent broadcast interleave freely - /// (`dashpay/platform#4185`). - lifecycle: RwLock<()>, } impl Default for SignedPaymentRegistry { @@ -315,58 +301,9 @@ impl SignedPaymentRegistry { // null-handle convention). next_token: AtomicU64::new(1), entries: Mutex::new(HashMap::new()), - lifecycle: RwLock::new(()), } } - /// Enter the lifecycle gate as a *payment* operation — a broadcast, a - /// release, or a finalize→register sequence. - /// - /// Shared: any number of payment operations run concurrently, exactly as - /// before. What the guard excludes is a wallet-generation teardown - /// ([`lifecycle_write`](Self::lifecycle_write)), which is what makes a - /// generation-liveness observation - /// ([`CoreWallet::is_current_generation`]) safe to act on: a removal cannot - /// interleave between the check and the action the guard spans. - /// - /// Exposed (rather than only taken internally) because the finalize→register - /// sequence spans two crates: the FFI holds this guard across its liveness - /// check and the synchronous [`register`](Self::register), which is the only - /// way to stop an in-flight finalizer from inserting a token *after* - /// teardown already swept the registry. [`broadcast`](Self::broadcast) and - /// [`release`](Self::release) take it themselves, so a caller must NOT hold - /// it across those (the `RwLock` is not reentrant and tokio's is - /// write-preferring, so a pending teardown would deadlock the re-entry). - pub async fn lifecycle_read(&self) -> RwLockReadGuard<'_, ()> { - self.lifecycle.read().await - } - - /// Enter the lifecycle gate as a wallet-generation *teardown*. - /// - /// Exclusive against every payment operation. The FFI's - /// `platform_wallet_manager_remove_wallet` holds this across BOTH the - /// manager removal and the subsequent - /// [`remove_entries_for_wallet`](Self::remove_entries_for_wallet) sweep, so - /// the two are one linearization point rather than two independent steps - /// with a window between them (`dashpay/platform#4185`). - /// - /// Acquiring it also *waits for* in-flight payment operations to finish, so - /// a finalizer that is mid-signature when the host removes the wallet - /// completes and reconciles its own reservation before the sweep runs — - /// rather than registering a token into an already-swept registry. - /// - /// ## Lock ordering - /// - /// This gate is always taken BEFORE the wallet-manager `RwLock`, never - /// after: teardown takes it and then awaits `PlatformWalletManager:: - /// remove_wallet` (which takes the manager write lock); payment operations - /// take it and then await the manager read lock. Nothing in the wallet crate - /// acquires the gate while already holding a manager lock, so the two-lock - /// order is total and cannot deadlock. - pub async fn lifecycle_write(&self) -> RwLockWriteGuard<'_, ()> { - self.lifecycle.write().await - } - /// 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 @@ -430,11 +367,19 @@ impl SignedPaymentRegistry { /// generation, defeating the documented teardown invariant that dropping /// tokens makes stale handles inert. /// - /// Callers must therefore hold [`lifecycle_read`](Self::lifecycle_read) - /// across `CoreWallet::is_current_generation` and this call, and abandon the - /// payment (releasing its reservation) when the wallet is gone. The FFI's - /// `core_wallet_signed_payment_finalize` is the production caller and does - /// exactly that. + /// Callers must therefore hold + /// [`CoreWallet::generation_payment_guard`] — the finalizing generation's own + /// lifecycle gate — across `CoreWallet::is_current_generation` and this call, + /// and abandon the payment (releasing its reservation) when the wallet is + /// gone. The FFI's `core_wallet_signed_payment_finalize` is the production + /// caller and does exactly that. + /// + /// The gate is acquired **after** the external signer returns, not around it: + /// holding a generation's gate across an open signing prompt would stall that + /// wallet's teardown for as long as the user takes, and the liveness check + /// makes it unnecessary. A finalizer whose wallet was torn down mid-signature + /// therefore observes the missing generation at its check and abandons + /// instead of registering. pub fn register( &self, core: CoreWallet, @@ -497,12 +442,23 @@ impl SignedPaymentRegistry { // 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. - // Hold the lifecycle gate for the whole operation. A wallet-generation - // teardown needs the exclusive side, so it cannot interleave between the - // liveness check below and the send: either the wallet is gone before we - // enter (our entry was already swept → `StaleToken`), or it stays live - // until we leave. Shared, so concurrent payments are unaffected. - let _lifecycle = self.lifecycle_read().await; + // + // Hold `current`'s OWN generation lifecycle gate for the whole operation. + // That generation's teardown needs the exclusive side, so it cannot + // interleave between the liveness check below and the send: either the + // wallet is gone before we enter (our entry was already swept → + // `StaleToken`), or it stays live until we leave. Shared, so concurrent + // payments — on this generation and on every other — are unaffected, and + // scoped per generation, so holding it across the network send below + // blocks only THIS wallet's teardown rather than every wallet's + // (`dashpay/platform#4185`). + // + // Taking `current`'s gate rather than the entry's is sound because the + // only path that proceeds past the check below is one where + // `entry.core.is_same_generation(current)` held — i.e. they are the same + // generation and therefore the same gate. A mismatched caller returns + // without touching the entry or the network. + let _lifecycle = current.generation_payment_guard().await; let entry = { let mut entries = self.lock(); @@ -606,14 +562,31 @@ 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) { - // Same lifecycle gate as `broadcast`: the reconciliation below reads the - // manager to bind its release to a live generation, so a teardown must - // not interleave between taking the entry and acting on it. - let _lifecycle = self.lifecycle_read().await; + // Same per-generation lifecycle gate as `broadcast`: the reconciliation + // below reads the manager to bind its release to a live generation, so + // that generation's teardown must not interleave between taking the entry + // and acting on it. + // + // No wallet handle is threaded in, so the gate has to come from the entry + // itself. PEEK the entry's generation without consuming it, drop the map + // lock (a `std::sync::Mutex` — it must never be held across an `.await`), + // take that generation's gate, and only then consume. Both ways the peek + // can go stale are already the correct outcome: if a teardown swept the + // entry, or a concurrent release/broadcast consumed it, the `remove` + // below returns `None` and this is the documented idempotent no-op. + let generation = { + let entries = self.lock(); + match entries.get(&token) { + // Unknown / already consumed — idempotent no-op. + None => return, + Some(entry) => Arc::clone(entry.core.generation()), + } + }; + let _lifecycle = generation.payment_guard().await; let entry = { self.lock().remove(&token) }; let Some(entry) = entry else { - // Unknown / already consumed — idempotent no-op. + // Swept or consumed while we were acquiring the gate — no-op. return; }; Self::reconcile_removed_entry(entry).await; @@ -632,16 +605,22 @@ impl SignedPaymentRegistry { /// against a re-created generation's inputs — this is the teardown half of /// the single generation policy the deferred paths share. /// - /// # Must be called under [`lifecycle_write`](Self::lifecycle_write) + /// # Must be called under the removed generation's [`WalletGeneration::teardown_guard`] /// /// Dropping the tokens is only half of teardown; the other half is the /// manager removal itself, and the two are one atomic step only if the - /// caller holds the exclusive lifecycle gate across BOTH. Sweeping without - /// it leaves two windows a payment operation slips through — a broadcast - /// between the removal and this sweep still finds its entry, and an - /// in-flight finalizer registers a fresh token *after* this sweep has run + /// caller holds that generation's exclusive lifecycle gate across BOTH. + /// Sweeping without it leaves two windows a payment operation slips through — + /// a broadcast between the removal and this sweep still finds its entry, and + /// an in-flight finalizer registers a fresh token *after* this sweep has run /// (`dashpay/platform#4185`). This function cannot take the gate itself: it /// is synchronous, and the removal it must be atomic with is `async`. + /// + /// [`PlatformWalletManager::remove_wallet_with_teardown`](crate::PlatformWalletManager::remove_wallet_with_teardown) + /// is the supported way to satisfy this: it holds the gate across the removal + /// and runs the sweep as its teardown hook, so the ordering cannot be got + /// wrong by a caller — including a direct Rust embedder that never goes + /// through the FFI. pub fn remove_entries_for_wallet(&self, wallet: &CoreWallet) -> usize { let mut entries = self.lock(); let before = entries.len(); @@ -1748,7 +1727,7 @@ mod tests { 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()); + info.generation = Arc::new(crate::wallet::core::WalletGeneration::new()); } /// Regression for the non-atomic generation-validation + cleanup: a token's diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index b3f38aa67ad..03ec23505a5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -104,14 +104,23 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// owner's token. NOT retryable through this handle: rebuild the payment. case errorReservationWalletMismatch = 36 /// The named thing does not exist. Besides the handle/lookup failures this - /// has always covered, the deferred (BIP70/BIP270) payment calls report the - /// wallet-was-REMOVED case here: a signed-payment broadcast refuses a token - /// whose wallet is no longer registered in the manager, and a signed-payment - /// finalize refuses to register a payment whose wallet was removed while it - /// was being signed (reconciling its reservation first). Distinct from - /// `errorReservationWalletMismatch` (36), where a *different* live generation - /// answers to the same id. The call did NOT touch the network and is NOT - /// retryable — the wallet is gone. + /// has always covered, BOTH deferred-send paths report the + /// wallet-was-REMOVED case here. + /// + /// Deferred (BIP70/BIP270) *token* path: a signed-payment broadcast refuses + /// a token whose wallet is no longer registered in the manager, and a + /// signed-payment finalize refuses to register a payment whose wallet was + /// removed while it was being signed. + /// + /// Finalized-transaction *handle* (V2) path: `finalizeAtomic` publishes no + /// handle when the wallet was removed or re-created during signing, and + /// `broadcastTransactionWithOutcome(_: FinalizedCoreTransaction)` refuses a + /// handle whose generation is gone. + /// + /// Every one of these reconciles the build's UTXO reservation before + /// returning. Distinct from `errorReservationWalletMismatch` (36), where a + /// *different* live generation answers to the same id. The call did NOT touch + /// the network and is NOT retryable — the wallet is gone. case notFound = 98 case errorUnknown = 99 From b0ac945b4fa7a96e261a8206009d2d8b90c0bb92 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:05:51 -0400 Subject: [PATCH 30/47] fix(platform-wallet): remove the wallet generation by identity, not by key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `remove_wallet_with_teardown` validated generation G1 under G1's lifecycle gate, then removed it from the two manager maps in two independently locked stages. Registration takes no gate at all — `register_wallet` mints its own `WalletGeneration` — so from the moment the inner-manager removal frees the id, a concurrent same-id registration can publish a different generation G2 into `wallet_manager` and then into `self.wallets`, with no happens-before edge to the remover's own `self.wallets` acquisition. A remover descheduled in that gap resumed into a map naming G2 and removed the entry BY KEY: it evicted a live wallet (still registered in the inner manager, so invisible and unremovable through the public map), returned it to the caller, and handed it to `tear_down` — which sweeps that generation's registry tokens and V2 finalized-transaction handles while holding only G1's gate, i.e. with G2's payment operations not excluded. That exclusion is the one property the gate exists to provide. Retain the `Arc` validated under the gate and remove the public-map entry only while it still pointer-matches that generation, so the removed handle, the returned handle and the `tear_down` argument are all the one generation this call validated. The inner-manager removal needs no such check: G1 can only leave `wallet_manager` through this method (which requires G1's gate) or through a rollback for an insert that could not have happened while G1 occupied the id. Regression test `removal_leaves_a_generation_registered_during_it_intact` drives the real `create_wallet_from_seed_bytes` -> `register_wallet` path from a `cfg(test)` rendezvous fired in the exact window, so the interleaving is pinned with no sleep and no completion-order race. Against the previous code it fails on all three load-bearing assertions: the returned generation, the `tear_down` argument, and the survival of the re-registered wallet in the public map. Refs dashpay/platform#4185 Co-Authored-By: Claude Opus 4.8 --- .../src/manager/wallet_lifecycle.rs | 306 +++++++++++++++++- 1 file changed, 295 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 28dd80a33de..23086a8e848 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -51,6 +51,33 @@ fn parse_mnemonic_any_language(phrase: &str) -> Result { Err("phrase does not match any supported BIP-39 wordlist") } +/// Test-only rendezvous fired inside [`PlatformWalletManager::remove_wallet_with_teardown`], +/// between the inner-manager removal and the public-map removal. +/// +/// That window is exactly where a concurrent same-id `register_wallet` can +/// publish a NEW generation into both maps — the id is free in the inner +/// manager from the moment the removal above completes, and nothing gates +/// registration. Reproducing it deterministically from outside is not possible: +/// the window is bounded by two *different* locks, and the only lock a test +/// could hold to park the remover inside it (`self.wallets`) is the same lock +/// the registration must acquire to publish, so parking the remover would also +/// block the registration — and `tokio`'s `RwLock` hands the writer queue out +/// in FIFO order, which puts the remover first. A rendezvous is therefore the +/// only way to pin this ordering without a sleep or a completion-order race. +/// +/// Compiled under `cfg(test)` only: neither this static nor its call site +/// exists in a production build, and it is not part of any public API. +#[cfg(test)] +pub(crate) type RemoveWalletMidpointHook = Box< + dyn Fn(&WalletId) -> std::pin::Pin + Send>> + + Send + + Sync, +>; + +#[cfg(test)] +pub(crate) static REMOVE_WALLET_MIDPOINT_HOOK: std::sync::Mutex> = + std::sync::Mutex::new(None); + impl PlatformWalletManager

{ /// Create a PlatformWallet from a BIP39 mnemonic phrase. /// @@ -615,6 +642,40 @@ impl PlatformWalletManager

{ /// awaiting the gate, so no manager lock is ever held across a gate /// acquisition; payment operations likewise take the gate and only then await /// the manager. The order is total, so the two cannot deadlock. + /// + /// ## Removal is by generation identity, not by key + /// + /// The gate excludes *payment operations on this generation*. It does not + /// exclude a fresh **registration** under the same `wallet_id`: + /// [`register_wallet`](Self::register_wallet) mints its own + /// [`WalletGeneration`] and takes no gate at all, by design — a create must + /// never queue behind an unrelated wallet's teardown. + /// + /// So once this method has removed generation G1 from the inner + /// `wallet_manager`, the id is free and a concurrent registration can publish + /// a *different* generation G2 into both maps before this method reaches its + /// own `self.wallets` removal — the two removals are separately locked, with + /// no happens-before edge between them and the registration. Removing by key + /// there would take G2 out of the public map (leaving it registered in the + /// inner manager, invisible and unremovable) and hand G2 to `tear_down`, + /// which would sweep G2's registry tokens and V2 handles while holding only + /// G1's gate — i.e. with G2's payment operations *not* excluded, which is the + /// exact property this gate exists to provide. + /// + /// The `Arc` validated under the gate is therefore retained, + /// and the public-map entry is removed only while it still names that same + /// generation. Both maps, the returned handle and the `tear_down` argument + /// are then all that one generation (`dashpay/platform#4185`). The one + /// remaining id-keyed step is the shielded coordinator detach below, which + /// has no generation concept at all; a generation that has just been + /// registered has not run `bind_shielded` yet, so it holds no coordinator + /// entry to detach. + /// + /// The inner-manager removal needs no such check: G1 can only leave + /// `wallet_manager` through this method (which requires G1's gate, held here) + /// or through a registration/load rollback for an insert that could not have + /// happened while G1 occupied the id — so while the gate is held and before + /// the removal below, the inner entry is still G1 by construction. pub async fn remove_wallet_with_teardown( &self, wallet_id: &WalletId, @@ -628,29 +689,34 @@ impl PlatformWalletManager

{ // removed and re-created under the same id while we waited: in that case // we hold the OLD generation's gate, which excludes nothing relevant to // the new one, so retry against the generation that is actually current. - let _teardown = loop { - let generation = { + // + // The validated handle is carried out of the loop: it is both what this + // call returns and tears down, and the identity every mutation below is + // matched against. + let (removed, _teardown) = loop { + let candidate = { let wallets = self.wallets.read().await; match wallets.get(wallet_id) { None => { return Err(PlatformWalletError::WalletNotFound(hex::encode(wallet_id))) } - Some(wallet) => Arc::clone(wallet.generation()), + Some(wallet) => Arc::clone(wallet), } }; - let guard = generation.teardown_guard().await; + let guard = candidate.generation().teardown_guard().await; let still_current = { let wallets = self.wallets.read().await; wallets .get(wallet_id) - .is_some_and(|wallet| Arc::ptr_eq(wallet.generation(), &generation)) + .is_some_and(|wallet| Arc::ptr_eq(wallet.generation(), candidate.generation())) }; if still_current { - break guard; + break (candidate, guard); } // Drop this generation's guard and re-resolve. drop(guard); }; + let generation = Arc::clone(removed.generation()); let owned_identity_ids: Vec = { let mut wm = self.wallet_manager.write().await; @@ -679,12 +745,41 @@ impl PlatformWalletManager

{ ids }; - let removed = { + // Test-only rendezvous: the window a concurrent same-id registration can + // publish a new generation into. See `REMOVE_WALLET_MIDPOINT_HOOK`. + #[cfg(test)] + { + let pending = REMOVE_WALLET_MIDPOINT_HOOK + .lock() + .expect("remove-wallet midpoint hook mutex") + .as_ref() + .map(|hook| hook(wallet_id)); + if let Some(rendezvous) = pending { + rendezvous.await; + } + } + + // Remove the public-map entry only while it still names the generation + // validated under the gate. A concurrent same-id registration could have + // published a NEW generation here in the window since the inner removal + // above freed the id (see the "Removal is by generation identity" note on + // this method); removing by key would evict that live wallet and hand it + // to `tear_down` under the wrong gate. + { let mut wallets = self.wallets.write().await; - wallets - .remove(wallet_id) - .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(wallet_id)))? - }; + let entry_is_ours = wallets + .get(wallet_id) + .is_some_and(|wallet| Arc::ptr_eq(wallet.generation(), &generation)); + if entry_is_ours { + wallets.remove(wallet_id); + } else { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + "remove_wallet: a new generation was registered under this id while the \ + previous one was being removed; leaving the new registration in place" + ); + } + } // Detach the wallet's shielded state from the network // coordinator. After the Phase-2b refactor the coordinator @@ -945,3 +1040,192 @@ mod register_wallet_duplicate_tests { ); } } + +/// Removal versus a same-id re-registration that lands *during* the removal +/// (`dashpay/platform#4185` review). +/// +/// The invariant: `remove_wallet_with_teardown` removes, returns and tears down +/// exactly the wallet generation it validated under that generation's lifecycle +/// gate — never a different generation that appeared under the same +/// `wallet_id` while the removal was in progress. +#[cfg(test)] +mod remove_versus_recreate_tests { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; + + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::Network; + + use super::REMOVE_WALLET_MIDPOINT_HOOK; + use crate::test_support::test_platform_wallet_manager; + use crate::wallet::core::WalletGeneration; + use crate::wallet::PlatformWallet; + + /// The mnemonic `test_platform_wallet_manager` builds its wallet from, so + /// re-registering from the same seed collides on the same network-scoped + /// `wallet_id` — which is the whole point of the scenario. + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + + /// Clears [`REMOVE_WALLET_MIDPOINT_HOOK`] on drop, including on panic, so a + /// failing assertion can never leave the hook armed for another test in the + /// same binary. + struct MidpointHookGuard; + + impl Drop for MidpointHookGuard { + fn drop(&mut self) { + if let Ok(mut slot) = REMOVE_WALLET_MIDPOINT_HOOK.lock() { + *slot = None; + } + } + } + + /// Requirement: a wallet generation registered while a removal is in flight + /// must survive that removal — in BOTH maps — and the removal must return + /// and tear down the generation it actually validated. + /// + /// Deterministic by construction: the re-registration runs from a rendezvous + /// fired inside the removal, in the exact window between the inner-manager + /// removal and the public-map removal, so there is no completion order to + /// race and no sleep. The registration itself is the real + /// `create_wallet_from_seed_bytes` → `register_wallet` path, publishing into + /// the inner `WalletManager` and then `self.wallets` in the production + /// order. + /// + /// Why that window is reachable in production: the removal frees the id in + /// the inner manager and only then acquires `self.wallets` — two separately + /// locked stages with no happens-before edge to a concurrent registration, + /// which takes no lifecycle gate at all (it mints its own generation). A + /// remover descheduled in that gap resumes into a map that already names the + /// new generation. + /// + /// Before the fix the removal took the public-map entry by KEY: it evicted + /// the freshly registered generation — leaving it registered in the inner + /// manager but invisible and unremovable through `self.wallets` — returned + /// it to the caller, and handed it to `tear_down`, which sweeps that + /// generation's registry tokens and V2 finalized-transaction handles while + /// holding only the OLD generation's gate. The new generation's in-flight + /// payment operations were therefore not excluded, which is the one property + /// the gate exists to provide. + #[tokio::test] + async fn removal_leaves_a_generation_registered_during_it_intact() { + let (manager, wallet_id) = test_platform_wallet_manager().await; + let original = manager + .get_wallet(&wallet_id) + .await + .expect("fixture wallet is registered"); + + // Filled by the rendezvous with the generation the re-registration + // publishes, so the assertions can name it rather than infer it. + let recreated: Arc>>> = Arc::new(Mutex::new(None)); + + let _hook_guard = MidpointHookGuard; + { + let manager_for_hook = Arc::clone(&manager); + let recreated_slot = Arc::clone(&recreated); + // One-shot: the re-registration must not recurse into a later + // removal, and no other test in this binary may see the hook. + let fired = AtomicBool::new(false); + *REMOVE_WALLET_MIDPOINT_HOOK + .lock() + .expect("midpoint hook mutex") = Some(Box::new(move |id| { + let already_fired = fired.swap(true, Ordering::SeqCst); + let manager = Arc::clone(&manager_for_hook); + let recreated_slot = Arc::clone(&recreated_slot); + let id = *id; + Box::pin(async move { + if already_fired { + return; + } + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + .expect("valid test mnemonic"); + let seed_bytes = mnemonic.to_seed(""); + // The real registration path: inner `WalletManager` first, + // then `self.wallets`. `Some(0)` skips the SPV-tip lookup. + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed_bytes, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect( + "the id is free in the inner manager at this point, so a same-seed \ + re-registration must succeed", + ); + assert_eq!(wallet.wallet_id(), id, "the fixture seeds must collide"); + *recreated_slot.lock().expect("recreated slot") = Some(wallet); + }) + })); + } + + // Capture what teardown was actually handed. + let torn_down: Arc>>> = Arc::new(Mutex::new(None)); + let torn_down_slot = Arc::clone(&torn_down); + + let removed = manager + .remove_wallet_with_teardown(&wallet_id, move |wallet| { + *torn_down_slot.lock().expect("torn-down slot") = + Some(Arc::clone(wallet.generation())); + }) + .await + .expect("removal of the validated generation succeeds"); + + let recreated = recreated + .lock() + .expect("recreated slot") + .clone() + .expect("the rendezvous must have re-registered the wallet"); + assert!( + !Arc::ptr_eq(original.generation(), recreated.generation()), + "the fixture must produce two distinct generations under one wallet id" + ); + + // 1. The removal returns the generation it validated under the gate. + assert!( + Arc::ptr_eq(removed.generation(), original.generation()), + "remove_wallet_with_teardown returned a generation it never validated — it took the \ + public-map entry by key and got the generation registered during the removal" + ); + + // 2. …and tears down that same generation. Sweeping the other one here + // would run without holding ITS gate, so its in-flight payment + // operations would not be excluded. + let torn_down = torn_down + .lock() + .expect("torn-down slot") + .clone() + .expect("tear_down must have run"); + assert!( + Arc::ptr_eq(&torn_down, original.generation()), + "tear_down was handed a generation whose lifecycle gate this removal does not hold" + ); + + // 3. The generation registered during the removal is still published. + let still_registered = manager + .get_wallet(&wallet_id) + .await + .expect("a wallet registered during a removal must remain in the public map"); + assert!( + Arc::ptr_eq(still_registered.generation(), recreated.generation()), + "the public map must still name the generation the registration published" + ); + + // 4. …and both maps agree about it: `is_current_generation` compares the + // handle against the inner `WalletManager`, so this fails if the + // removal evicted it from one map only. + assert!( + recreated.core().is_current_generation().await, + "the re-registered generation must be live in both the inner manager and the public \ + map — evicting it from one leaves an invisible, unremovable wallet" + ); + + // 5. The removed generation is gone. + assert!( + !original.core().is_current_generation().await, + "the validated generation must be gone from the inner manager" + ); + } +} From a9008b2b8240785452d1ed2a12c346afa12666e0 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:57:24 -0400 Subject: [PATCH 31/47] fix(platform-wallet): move the deferred-token trio off the codes #4268 claimed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dashpay/platform#4268 merged `ErrorShutdownIncomplete = 27` into the v4.2-dev FFI ABI, colliding with this PR's `ErrorStaleReservationToken = 27`. Renumber the deferred build/broadcast trio to the contiguous block 34-36, which sits above every code currently claimed by a merged commit or an open PR: 27 ErrorShutdownIncomplete MERGED, #4268 29 ErrorAssetLockInsufficientFunds #4184 31 ErrorSigningKeyUnavailable #4183, #4259 32 ErrorTransactionBuild #4247, #4256 33 ErrorTransactionSigning #4256 28 and 30 are vacated and return to the free pool. Applied across the Rust enum, the FFI/JNI rustdoc, the Kotlin mapping + KDoc + tests, and the Swift mirror (which has no compile-time cross-ABI check, so it was verified by grep). Also addresses three review suggestions: * `PlatformWalletInfo::generation` is now `pub(crate)`. It was publicly assignable through `state_mut()` / `state_mut_blocking()`, so downstream safe code could swap the `Arc` while `PlatformWallet` and `CoreWallet` kept the original — splitting the generation identity `Arc::ptr_eq` compares, which would make `is_current_generation()` reject a live wallet, turn generation-bound reservation cleanup into a no-op, and let teardown exclude through a different lifecycle gate than the payments it must fence. All construction and mutation sites are already inside the crate. * `buildSignedPayment` now runs under `opWithCleanupOnCancellation`. Native finalization mints the token before the blocking JNI call returns, so `withContext`'s prompt-cancellation handoff could discard the completed `SignedCoreTransaction` and leave the reservation to the GC Cleaner or the TTL. The discarded result is now closed deterministically. * Native code 26 (`ErrorTransactionBroadcastRejected`) no longer falls through to `PlatformWallet.Generic`. It maps to a dedicated `TransactionBroadcastRejected` subtype so callers can tell a definitively rejected, consumed-and-released payment (rebuild it) from an unrelated generic wallet failure, with its non-retry-in-place semantics pinned in `DashSdkErrorTest`. Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/errors/DashSdkError.kt | 31 +++++++++++++++++-- .../dashsdk/ffi/WalletManagerNative.kt | 6 ++-- .../dashsdk/wallet/ManagedPlatformWallet.kt | 25 +++++++++++---- .../dashsdk/errors/DashSdkErrorTest.kt | 22 +++++++++++-- .../src/core_wallet/signed_payment.rs | 8 ++--- packages/rs-platform-wallet-ffi/src/error.rs | 26 +++++++++++----- .../src/wallet/platform_wallet.rs | 14 ++++++++- .../rs-unified-sdk-jni/src/wallet_manager.rs | 6 ++-- .../PlatformWallet/PlatformWalletResult.swift | 13 +++++--- 9 files changed, 116 insertions(+), 35 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 8f8d546438e..22329edc795 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 @@ -240,7 +240,25 @@ sealed class DashSdkError( PlatformWallet(message, cause) /** - * `ErrorStaleReservationToken` (native code 26). A deferred + * `ErrorTransactionBroadcastRejected` (native code 26). Core + * DEFINITIVELY rejected the core transaction: it is not on the network + * and will not get there. The build's UTXO reservation was released and, + * on the deferred (BIP70/BIP270) path, the token was consumed at the + * same time — so the inputs are spendable again and the token is gone. + * + * The definitive counterpart to [TransactionBroadcastUnconfirmed] (20), + * whose outcome is AMBIGUOUS and which therefore keeps its inputs + * reserved. Because the reservation and token are already gone, this is + * NOT retryable in place: address the rejection reason carried in the + * message, then rebuild with + * [buildSignedPayment][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.buildSignedPayment] + * (deferred) or re-issue the send. + */ + class TransactionBroadcastRejected(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + + /** + * `ErrorStaleReservationToken` (native code 34). A deferred * (BIP70/BIP270) [broadcastSigned][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.broadcastSigned] * token has outlived its funding reservation's lifetime: key-wallet's * TTL may already have swept and re-selected the inputs, so acting on it @@ -257,7 +275,7 @@ sealed class DashSdkError( PlatformWallet(message, cause) /** - * `ErrorReservationTokenConsumed` (native code 28). A deferred + * `ErrorReservationTokenConsumed` (native code 35). 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 @@ -270,7 +288,7 @@ sealed class DashSdkError( PlatformWallet(message, cause) /** - * `ErrorReservationWalletMismatch` (native code 30). A deferred + * `ErrorReservationWalletMismatch` (native code 36). 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 @@ -402,6 +420,13 @@ sealed class DashSdkError( 23 -> PlatformWallet.AssetLockNotTracked(message, cause) // ErrorAssetLockNotTracked 24 -> PlatformWallet.AssetLockAlreadyConsumed(message, cause) // ErrorAssetLockAlreadyConsumed 25 -> PlatformWallet.AssetLockFundingMismatch(message, cause) // ErrorAssetLockFundingMismatch + 26 -> PlatformWallet.TransactionBroadcastRejected(message, cause) // ErrorTransactionBroadcastRejected + // The deferred-token trio sits at the contiguous block 34-36 because + // 27-33 are claimed elsewhere: 27 ErrorShutdownIncomplete + // (dashpay/platform#4268, merged), 29 ErrorAssetLockInsufficientFunds + // (#4184), 31 ErrorSigningKeyUnavailable (#4183/#4259), 32 + // ErrorTransactionBuild (#4247/#4256), 33 ErrorTransactionSigning + // (#4256). See packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md. 34 -> PlatformWallet.StaleReservationToken(message, cause) // ErrorStaleReservationToken 35 -> PlatformWallet.ReservationTokenConsumed(message, cause) // ErrorReservationTokenConsumed 36 -> PlatformWallet.ReservationWalletMismatch(message, cause) // ErrorReservationWalletMismatch 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 cc801169c5e..74a40120c25 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 @@ -273,9 +273,9 @@ internal object WalletManagerNative { * `core_wallet_signed_payment_broadcast` — broadcast the payment behind * [token], reconciling its reservation on failure and consuming the token. * Rather than double-broadcasting, an unusable token throws one of three - * sibling codes — `ErrorStaleReservationToken` (27, aged out), - * `ErrorReservationTokenConsumed` (28, already consumed/unknown), or - * `ErrorReservationWalletMismatch` (30, different wallet generation). + * sibling codes — `ErrorStaleReservationToken` (34, aged out), + * `ErrorReservationTokenConsumed` (35, already consumed/unknown), or + * `ErrorReservationWalletMismatch` (36, different wallet generation). * [coreHandle] must resolve to the wallet the token was minted against. * Returns the txid as a lowercase hex string. */ 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 bc2ae6f34d4..71afc830d39 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 @@ -294,11 +294,14 @@ class ManagedPlatformWallet internal constructor( * * 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. + * nor released is never orphaned. If a cancellation discards the result + * *after* the blocking native registration already minted the token, this + * call closes it deterministically on the way out (the gate's + * cancellation-cleanup handoff) rather than leaving the reservation to the + * GC backstop or the reservation TTL. Otherwise the backstop releases on GC, + * or the caller releases via 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 @@ -314,7 +317,17 @@ class ManagedPlatformWallet internal constructor( coreSignerHandle: Long, accountType: AccountType = AccountType.BIP44, accountIndex: Int = 0, - ): SignedCoreTransaction = gate.op { + ): SignedCoreTransaction = gate.opWithCleanupOnCancellation( + // Native finalization mints the token and transfers reservation ownership + // to it before the blocking JNI call returns, so the token already exists + // by the time `withContext` dispatches back to the caller. That handoff is + // a prompt-cancellation point: if the caller was cancelled while JNI ran, + // the completed SignedCoreTransaction is discarded before anyone can hold + // it, leaving only the GC/NativeCleaner backstop — the reservation would + // then sit until an unpredictable GC cycle or the reservation TTL. + // Closing the discarded result releases the token deterministically. + cleanup = { payment: SignedCoreTransaction -> payment.close() }, + ) { require(accountIndex >= 0) { "accountIndex must be non-negative, got $accountIndex" } require(recipients.isNotEmpty()) { "recipients must not be empty" } require(recipients.all { it.second > 0 }) { 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 6162e1eea06..57f758e2868 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 @@ -116,9 +116,25 @@ class DashSdkErrorTest { // The message must warn against retrying (distinct from the anchor case). assertTrue(broadcastUnconfirmed.message!!.contains("do NOT retry")) + // Definitive broadcast rejection (26) must reach callers as its own type, + // NOT as Generic: it is the definitive counterpart to the ambiguous + // TransactionBroadcastUnconfirmed (20), and on the deferred path the + // reservation was released and the token consumed — so it is not + // retryable in place, it must be rebuilt. + val rejected = DashSdkError.fromNative(DashSDKException(offset + 26, "bad-txns-inputs-spent")) + assertTrue( + "code 26 must not fall through to Generic", + rejected is DashSdkError.PlatformWallet.TransactionBroadcastRejected, + ) + assertFalse( + "TransactionBroadcastRejected must NOT be retryable in place (rebuild the payment)", + rejected.isRetryable, + ) + assertEquals("bad-txns-inputs-spent", rejected.message) + // Deferred build/broadcast: the three sibling reservation-token failures // map to three distinct typed errors, none retryable. - val agedOut = DashSdkError.fromNative(DashSDKException(offset + 27, "stale token 7")) + val agedOut = DashSdkError.fromNative(DashSDKException(offset + 34, "stale token 7")) assertTrue(agedOut is DashSdkError.PlatformWallet.StaleReservationToken) assertFalse( "StaleReservationToken must NOT be retryable (rebuild the payment)", @@ -126,7 +142,7 @@ class DashSdkErrorTest { ) assertEquals("stale token 7", agedOut.message) - val consumed = DashSdkError.fromNative(DashSDKException(offset + 28, "already broadcast")) + val consumed = DashSdkError.fromNative(DashSDKException(offset + 35, "already broadcast")) assertTrue(consumed is DashSdkError.PlatformWallet.ReservationTokenConsumed) assertFalse( "ReservationTokenConsumed must NOT be retryable (rebuild the payment)", @@ -135,7 +151,7 @@ class DashSdkErrorTest { assertEquals("already broadcast", consumed.message) val walletMismatch = - DashSdkError.fromNative(DashSDKException(offset + 30, "different generation")) + DashSdkError.fromNative(DashSDKException(offset + 36, "different generation")) assertTrue(walletMismatch is DashSdkError.PlatformWallet.ReservationWalletMismatch) assertFalse( "ReservationWalletMismatch must NOT be retryable (rebuild the payment)", 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 bab65bbfcd0..f352b3329f6 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 @@ -62,11 +62,11 @@ pub(crate) fn registry_test_guard() -> std::sync::MutexGuard<'static, ()> { /// /// The token is consumed atomically before the send, so a repeated or /// concurrent broadcast of the same token gets `ErrorReservationTokenConsumed` -/// (28) rather than a second send. `core_handle` must resolve to the same wallet +/// (35) rather than a second send. `core_handle` must resolve to the same wallet /// *generation* the token was minted against; a wallet re-created under the same -/// id yields `ErrorReservationWalletMismatch` (30). A token whose reservation +/// id yields `ErrorReservationWalletMismatch` (36). A token whose reservation /// may already have aged out of key-wallet's TTL yields -/// `ErrorStaleReservationToken` (27). These three deferred-token failures are +/// `ErrorStaleReservationToken` (34). These three deferred-token failures are /// distinct codes so a host can message each precisely. Writes `out_txid` (a /// heap C string freed with `core_wallet_free_address`) on success. /// @@ -118,7 +118,7 @@ pub unsafe extern "C" fn core_wallet_signed_payment_broadcast( // generation to broadcast through. Reported as the existing `NotFound` // (98) rather than a new code: it is exactly the "the thing you named // does not exist" case 98 already means, and both hosts already map it. - // Distinct from `ErrorReservationWalletMismatch` (30), where a DIFFERENT + // Distinct from `ErrorReservationWalletMismatch` (36), where a DIFFERENT // live generation answers to the same id. Did NOT touch the network and // is NOT retryable — the wallet is gone. Err(e @ SignedPaymentError::WalletRemoved(_)) => { diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 33dfea98186..359a85d1cfa 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -215,13 +215,6 @@ pub enum PlatformWalletFFIResultCode { /// join instead of erroring. Swift mirror: /// `PlatformWalletResultCode.errorShutdownIncomplete`. ErrorShutdownIncomplete = 27, - // Codes 28-30 are NOT claimed here. 28 and 30 are reserved (vacated by the - // deferred-payment reservation-token trio on dashpay/platform#4185/#4256 - // when it moved to 34-36) and 29 belongs to ErrorAssetLockInsufficientFunds - // on the asset-lock funding branch (dashpay/platform#4184). Allocating any - // of them here too would merge without a textual conflict and silently - // misclassify across hosts. See - // packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md. /// A state transition could not be signed because the signer has no /// usable private key for the requested public key — the stored blob is /// missing, stranded, or written under a different Keystore/Keychain @@ -235,6 +228,22 @@ pub enum PlatformWalletFFIResultCode { /// wallet-operation failure. Not retryable as-is — the key must be /// (re-)derived first. ErrorSigningKeyUnavailable = 31, + + // Codes 27-33 are claimed outside this PR and MUST NOT be reused here. + // The deferred-token trio below therefore occupies the contiguous block + // 34-36. Current owners (see ERROR_CODE_REGISTRY.md, dashpay/platform#4261): + // + // 27 ErrorShutdownIncomplete MERGED on v4.2-dev (dashpay/platform#4268) + // 28 (free — vacated by this PR) + // 29 ErrorAssetLockInsufficientFunds dashpay/platform#4184 + // 30 (free — vacated by this PR) + // 31 ErrorSigningKeyUnavailable dashpay/platform#4183, #4259 + // 32 ErrorTransactionBuild dashpay/platform#4247, #4256 + // 33 ErrorTransactionSigning dashpay/platform#4256 + // + // This trio previously sat at 26-28, then 27/28/30. It moved to 34-36 after + // #4268 merged `ErrorShutdownIncomplete = 27` into the v4.2-dev ABI; the + // contiguous block above every current claim ends the renumbering churn. /// 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 @@ -264,6 +273,7 @@ pub enum PlatformWalletFFIResultCode { /// 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 = 36, /// The named thing does not exist. @@ -280,7 +290,7 @@ pub enum PlatformWalletFFIResultCode { /// refuses to register a payment whose wallet was removed while it was being /// signed — reconciling that build's reservation before returning. Neither /// touched the network. Contrast [`Self::ErrorReservationWalletMismatch`] - /// (30), where a DIFFERENT live generation answers to the same wallet id; + /// (36), where a DIFFERENT live generation answers to the same wallet id; /// here there is no live generation at all, so there is nothing to retry /// against (`dashpay/platform#4185`). NotFound = 98, diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index d23a9eb7dbb..bf28a640c2d 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -49,7 +49,19 @@ pub struct PlatformWalletInfo { /// This wallet generation's shared state: the lock-free balance for UI reads /// (updated from `ManagedWalletInfo` after each SPV block/mempool processing /// and RPC refresh) and the generation's lifecycle gate. - pub generation: Arc, + /// + /// Deliberately `pub(crate)`, not `pub`: this `Arc` *is* the generation + /// identity that `Arc::ptr_eq` compares, and `PlatformWalletInfo` is + /// reachable mutably from outside the crate through + /// [`PlatformWallet::state_mut`] / [`PlatformWallet::state_mut_blocking`]. + /// A public field would let safe downstream code drop a fresh `Arc` in here + /// while `PlatformWallet` and `CoreWallet` keep the original, splitting the + /// identity: `is_current_generation()` would then reject the still-live + /// wallet, generation-bound reservation cleanup would become a no-op, and + /// teardown would exclude through a different lifecycle gate than the + /// payment operations it has to fence. Read it through + /// [`PlatformWallet::generation`]; it is assigned only at construction. + pub(crate) generation: Arc, pub identity_manager: IdentityManager, pub tracked_asset_locks: BTreeMap, } diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 02bef8d6079..070d81357c7 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1365,9 +1365,9 @@ 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. Rather than double-broadcasting, an unusable token -/// throws one of three sibling codes: `ErrorStaleReservationToken` (27, aged -/// out), `ErrorReservationTokenConsumed` (28, unknown / already broadcast / -/// already released), or `ErrorReservationWalletMismatch` (30, different wallet +/// throws one of three sibling codes: `ErrorStaleReservationToken` (34, aged +/// out), `ErrorReservationTokenConsumed` (35, unknown / already broadcast / +/// already released), or `ErrorReservationWalletMismatch` (36, different wallet /// generation). `coreHandle` must resolve to the wallet the token was minted /// against. Returns the txid as a lowercase hex string. #[no_mangle] diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 03ec23505a5..14f5890544f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -76,15 +76,20 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// (Not returned by `destroy`: Rust owns the callback contexts, so a /// straggling worker is memory-safe and merely logged there.) case errorShutdownIncomplete = 27 - // Raw values 28-30 are NOT claimed here: 28 and 30 are reserved (vacated by - // the deferred-payment reservation-token trio on dashpay/platform#4185 / - // #4256 when it moved to 34-36) and 29 belongs to the asset-lock funding - // shortfall on dashpay/platform#4184. /// A state transition could not be signed because the signer has no /// usable private key for the requested public key — restored from the /// structured signer completion code (dashpay/platform#4060 finding 7). /// Route to key repair; not retryable as-is. case errorSigningKeyUnavailable = 31 + // Codes 27-33 are claimed outside this PR and must not be reused here: + // 27 errorShutdownIncomplete (dashpay/platform#4268, merged), 29 + // errorAssetLockInsufficientFunds (#4184), 31 errorSigningKeyUnavailable + // (#4183/#4259), 32 errorTransactionBuild (#4247/#4256), 33 + // errorTransactionSigning (#4256); 28 and 30 are free. The deferred-token + // trio therefore occupies the contiguous block 34-36. These raw values + // MUST match `PlatformWalletFFIResultCode` in + // packages/rs-platform-wallet-ffi/src/error.rs — there is no compile-time + // check across the ABI. See ERROR_CODE_REGISTRY.md (#4261). /// 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 From 0e15cc315743542f77a33139abeacb450156433c Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:09:22 -0400 Subject: [PATCH 32/47] fix(kotlin-sdk): stop the stray 98 arm from shadowing PlatformWallet.NotFound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase onto v4.2-dev left `fromPlatformWalletNative` with TWO arms for platform-wallet code 98: this PR's original `7, 8, 98 -> NotFound(...)` and the merged-upstream `PLATFORM_WALLET_NOT_FOUND_CODE -> PlatformWallet.NotFound`. Kotlin's `when` takes the first matching branch, so 98 kept resolving to the top-level `DashSdkError.NotFound` and the second arm was dead code. That regressed the merged upstream behaviour and broke three assertions in `DashSdkErrorTest` (`platformWalletCodesMapToPlatformWalletSubtree`, `platformWalletNotFoundCodeMapsToTypedWalletNotFound`, `platformWalletNotFoundConvertsAtThePublicBoundary`), which all require offset + 98 to surface as the wallet-family `PlatformWallet.NotFound` and NOT as the top-level `NotFound` reserved for rs-sdk-ffi codes 7/8. Drop the stray `98,` so the 7/8 arm is exactly the rs-sdk-ffi pair, and move this PR's deferred-send documentation onto the arm that actually handles 98. The Rust side is unchanged and stays the source of truth: `PlatformWalletFFIResultCode::NotFound = 98` (packages/rs-platform-wallet-ffi/src/error.rs:296), listed as the terminal sentinel in ERROR_CODE_REGISTRY.md. No discriminant was renumbered. The rest of the branch already expected the corrected mapping — see the `PlatformWalletManager` KDoc, which documents this path as `DashSdkError.PlatformWallet.NotFound` (native code 98). Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/errors/DashSdkError.kt | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 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 22329edc795..b41b1a83a44 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 @@ -386,8 +386,19 @@ sealed class DashSdkError( } 7, // ErrorIdentityNotFound 8, // ErrorContactNotFound - // NotFound. Handle/Option lookup failures, plus the - // wallet-was-REMOVED case on BOTH deferred-send paths: + -> NotFound(message, cause) + // 98 (PlatformWalletFFIResultCode::NotFound, the blanket Option → + // result miss) stays inside the wallet-error family as the typed + // PlatformWallet.NotFound — exact Swift parity + // (PlatformWalletError.notFound) — rather than collapsing into the + // top-level NotFound that rs-sdk-ffi codes 7/8 map to. Dashpay's + // managed-identity local reads are unaffected: they intercept the + // RAW code via translateManagedIdentityNotFoundToZero (#4051) + // before this mapping ever runs. BREAKING for Kotlin hosts that + // caught DashSdkError.NotFound from platform-wallet operations. + // + // 98 is also what the wallet-was-REMOVED case returns on BOTH + // deferred-send paths: // * deferred (BIP70/BIP270) TOKEN path — a signed-payment broadcast // whose wallet is no longer registered in the manager, or a // signed-payment finalize whose wallet was removed while it was @@ -399,17 +410,6 @@ sealed class DashSdkError( // Nothing was broadcast, and unlike ReservationWalletMismatch (36) // no other live generation holds the payment either — so it is not // retryable. See dashpay/platform#4185. - 98, - -> NotFound(message, cause) - // 98 (PlatformWalletFFIResultCode::NotFound, the blanket Option → - // result miss) stays inside the wallet-error family as the typed - // PlatformWallet.NotFound — exact Swift parity - // (PlatformWalletError.notFound) — rather than collapsing into the - // top-level NotFound that rs-sdk-ffi codes 7/8 map to. Dashpay's - // managed-identity local reads are unaffected: they intercept the - // RAW code via translateManagedIdentityNotFoundToZero (#4051) - // before this mapping ever runs. BREAKING for Kotlin hosts that - // caught DashSdkError.NotFound from platform-wallet operations. PLATFORM_WALLET_NOT_FOUND_CODE -> PlatformWallet.NotFound(message, cause) 16 -> PlatformWallet.ShieldedBroadcastFailed(message, cause) // ErrorShieldedBroadcastFailed From 822c9096f849446d3b976d10fbba661c10bc30e9 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:49:09 -0400 Subject: [PATCH 33/47] feat(platform-wallet): union-funding build_signed_payment core primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CoreWallet::build_signed_payment — a first-class "send" primitive that selects inputs across the UNION of every signable funds account (BIP44 + BIP32 + CoinJoin + DashPay receiving), builds and signs a standard L1 payment, and returns the signed transaction plus its fee and change amount. This is the build-only half of the Android send-path cutover: during the dashj->SDK transition the app keeps dashj's transaction bookkeeping (maybeCommitTx drives CrowdNode, memos, confidence listeners), so the SDK must build + sign from the bound wallet and hand back the bytes while dashj commits/broadcasts. The method therefore does NOT broadcast and does NOT persist a debit — the only state touched is the in-memory ReservationSet that set_funding/build_signed use to stop a concurrent SDK build from re-selecting the same coins (released when the spend is later observed by sync or by the reservation-TTL backstop). Reuses the shielded asset-lock union-funding machinery (all_funding_accounts + spendable_utxos + a spanning path resolver + LargestFirst selection to keep CoinJoin's many small denominations from blowing up BranchAndBound), but excludes watch-only DashpayExternalAccounts (a contact's addresses, which this wallet cannot sign). Fee and change are derived from the transaction itself (inputs - outputs), the always self-consistent ground truth, rather than from build_signed's signed-size fee recomputation which can drift by a few duffs from what is actually paid. Adds the typed PaymentInsufficientFunds { available, required } error so a shortfall carries the exact union-wide selectable total. Tests: correct output/change/fee, BIP44+CoinJoin union selection, typed union shortfall, watch-only exclusion, and input validation. cargo test -p platform-wallet --lib: 442 passed. Co-Authored-By: Claude Fable 5 (cherry picked from commit 38012d149f66a13c21efb81d8fff271ece8708d4) --- packages/rs-platform-wallet/src/error.rs | 13 + packages/rs-platform-wallet/src/lib.rs | 1 + .../rs-platform-wallet/src/wallet/core/mod.rs | 2 + .../src/wallet/core/send.rs | 619 ++++++++++++++++++ 4 files changed, 635 insertions(+) create mode 100644 packages/rs-platform-wallet/src/wallet/core/send.rs diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index b3abb044109..cb470010268 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -71,6 +71,19 @@ pub enum PlatformWalletError { #[error("Asset lock transaction failed: {0}")] AssetLockTransaction(String), + /// A general Core L1 payment build (`CoreWallet::build_signed_payment`) + /// could not cover the requested outputs plus fee from the union of the + /// wallet's *signable* funds accounts (BIP44 + BIP32 + CoinJoin + DashPay + /// receiving; watch-only DashPay external accounts are excluded). `available` + /// is the total selectable value across those accounts, `required` the + /// outputs-plus-fee target — carried as exact duff amounts (instead of being + /// flattened into a string) so callers can render a precise shortfall. + #[error( + "payment coin selection is short: available {available} duffs, \ + required {required} duffs" + )] + PaymentInsufficientFunds { available: u64, required: u64 }, + #[error("Transaction broadcast failed: {0}")] TransactionBroadcast(String), diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index efde6e58b83..c1cb9275444 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -56,6 +56,7 @@ pub use spv::SpvRuntime; pub use wallet::asset_lock::manager::AssetLockManager; pub use wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; pub use wallet::asset_lock::AssetLockFunding; +pub use wallet::core::SignedCorePayment; pub use wallet::core::WalletBalance; pub use wallet::core::{CoreWallet, SignedCoreTransaction}; pub use wallet::signed_payment_registry::{ diff --git a/packages/rs-platform-wallet/src/wallet/core/mod.rs b/packages/rs-platform-wallet/src/wallet/core/mod.rs index ba84b77f21e..1e15136cc24 100644 --- a/packages/rs-platform-wallet/src/wallet/core/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/core/mod.rs @@ -2,11 +2,13 @@ pub mod balance; pub mod balance_handler; mod broadcast; pub mod generation; +mod send; mod transaction; pub mod wallet; pub use balance::WalletBalance; pub use balance_handler::BalanceUpdateHandler; pub use generation::WalletGeneration; +pub use send::SignedCorePayment; pub use transaction::SignedCoreTransaction; pub use wallet::CoreWallet; diff --git a/packages/rs-platform-wallet/src/wallet/core/send.rs b/packages/rs-platform-wallet/src/wallet/core/send.rs new file mode 100644 index 00000000000..4d74fa37fc7 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/core/send.rs @@ -0,0 +1,619 @@ +//! General Core L1 payment building. +//! +//! [`CoreWallet::build_signed_payment`] is the first-class "send" primitive: +//! it selects inputs across every **signable** funds account, builds and signs +//! a standard payment transaction, and returns the **signed serialized bytes** +//! plus the computed fee and change amount — WITHOUT broadcasting and WITHOUT +//! persisting a debit. +//! +//! ## Why build-only / no-broadcast +//! +//! During the dashj→SDK transition the Android app keeps its own transaction +//! bookkeeping (dashj's `maybeCommitTx` drives CrowdNode, memos, and confidence +//! listeners). The app therefore wants the SDK to *build + sign* a payment from +//! the bound wallet and hand back the raw bytes, then commit + broadcast them +//! through dashj itself. Post-transition a separate SDK-broadcast mode will own +//! broadcasting and the debit persistence that goes with it; this primitive is +//! the permanent, generally-useful "give me signed bytes" half of that split. +//! +//! ## Persistence semantics (deliberate) +//! +//! Building does **not** persist a debit and does not write UTXOs, balances, or +//! transaction records back to the wallet. The only in-memory mutation is the +//! key-wallet `ReservationSet` bookkeeping that `set_funding` + +//! `TransactionBuilder::build_signed` perform on the primary funding account: +//! the selected inputs are marked *reserved* so a concurrent SDK build does not +//! re-select the same coins. That reservation is in-memory only (never +//! serialized) and is released when the spend is later processed back into the +//! wallet by sync, or by the reservation-TTL backstop, or explicitly via +//! [`ManagedCoreFundsAccount::release_reservation`] for an abandoned build. No +//! balance is debited until the transaction actually confirms — exactly what +//! the transition flow needs, since dashj owns commit/broadcast. +//! +//! [`ManagedCoreFundsAccount::release_reservation`]: +//! key_wallet::managed_account::ManagedCoreFundsAccount::release_reservation + +use std::collections::{HashMap, HashSet}; + +use dashcore::{Address as DashAddress, OutPoint, Transaction}; +use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::managed_account::ManagedCoreFundsAccount; +use key_wallet::ManagedAccountType; +use key_wallet::signer::Signer; +use key_wallet::wallet::managed_wallet_info::coin_selection::{SelectionError, SelectionStrategy}; +use key_wallet::wallet::managed_wallet_info::fee::FeeRate; +use key_wallet::wallet::managed_wallet_info::transaction_builder::{BuilderError, TransactionBuilder}; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::Utxo; + +use crate::broadcaster::TransactionBroadcaster; +use crate::error::PlatformWalletError; +use crate::wallet::core::CoreWallet; + +/// key-wallet's default fee rate (duffs per kB). Matches the asset-lock +/// builder's `DEFAULT_FEE_PER_KB` and `FeeRate::normal()`. +const DEFAULT_FEE_PER_KB: u64 = 1000; + +/// The BIP44 account that supplies the change output (and whose reservation +/// ledger gates concurrent primary-account builds). The union of every other +/// signable funds account is added as explicit inputs on top of it. +const PRIMARY_BIP44_ACCOUNT_INDEX: u32 = 0; + +/// A built-and-signed Core L1 payment, ready to be committed/broadcast by the +/// caller (dashj during the transition, or a later SDK-broadcast mode). +#[derive(Debug, Clone)] +pub struct SignedCorePayment { + /// The signed transaction. Serialize with + /// [`consensus::serialize`](dashcore::consensus::serialize) for the raw + /// wire bytes the caller hands to its broadcaster. + pub transaction: Transaction, + /// The fee paid, in duffs, computed from the encoded size of the *signed* + /// transaction. + pub fee: u64, + /// Duffs returned to the wallet's change address (0 when the build produced + /// no change output — an exact-match selection or a dust-only remainder + /// folded into the fee). + pub change_amount: u64, +} + +/// True for funds accounts the bound wallet cannot sign for. Only +/// `DashpayExternalAccount`s are watch-only: they hold a *contact's* receiving +/// addresses (we keep the contact's xpub to build payments *to* them and to +/// watch that side), so their UTXOs must never be selected as spend inputs — +/// signing would fail because no private key of ours derives them. Every other +/// funds account (BIP44/BIP32/CoinJoin/DashPay receiving) is derived from our +/// own seed and is signable. +fn is_watch_only_funds_account(account: &ManagedCoreFundsAccount) -> bool { + matches!( + account.managed_account_type(), + ManagedAccountType::DashpayExternalAccount { .. } + ) +} + +impl CoreWallet { + /// Build and sign a standard Core L1 payment to `outputs`, funding it from + /// the union of every **signable** funds account, and return the signed + /// transaction plus its fee and change amount. Does **not** broadcast and + /// does **not** persist a debit (see the module docs for the persistence + /// contract). + /// + /// ## Coin selection — union of signable accounts + /// + /// Inputs are selected across BIP44 + BIP32 + CoinJoin + DashPay-receiving + /// accounts (the wallet-wide spendable set), reusing the same union-funding + /// machinery the shielded asset-lock path uses + /// ([`AssetLockManager::build_asset_lock_tx_from_all_funding_accounts`]): + /// BIP44 account 0 is the PRIMARY account (it supplies the change output and + /// its reservation ledger gates concurrent primary-account builds), and the + /// spendable UTXOs of every other signable account are added as explicit + /// builder inputs. Watch-only `DashpayExternalAccount`s are excluded — their + /// coins belong to a contact and cannot be signed by this wallet. + /// + /// `LargestFirst` selection is used deliberately (not the builder default + /// `BranchAndBound`): a CoinJoin account can hold many small mixed + /// denominations, and `BranchAndBound`'s exact-match subset-sum is + /// exponential over them (the same hang the asset-lock union path avoids). + /// `LargestFirst`'s linear greedy accumulator also minimizes the input + /// count — fewer signer round-trips and a smaller tx/fee. + /// + /// ## Parameters + /// + /// * `outputs` — the recipient `(address, amount_duffs)` pairs. Must be + /// non-empty and every amount must be positive. + /// * `fee_per_kb` — fee rate in duffs/kB, or `None` for the default + /// (`1000`). + /// * `signer` — the ECDSA signer that produces each input's P2PKH signature + /// (the Keychain/Keystore-backed `MnemonicResolverCoreSigner` in + /// production). No private key crosses the boundary. + /// + /// [`AssetLockManager::build_asset_lock_tx_from_all_funding_accounts`]: + /// crate::wallet::asset_lock + pub async fn build_signed_payment( + &self, + outputs: Vec<(DashAddress, u64)>, + fee_per_kb: Option, + signer: &S, + ) -> Result { + if outputs.is_empty() { + return Err(PlatformWalletError::TransactionBuild( + "at least one output is required".to_string(), + )); + } + if outputs.iter().any(|(_, amount)| *amount == 0) { + return Err(PlatformWalletError::TransactionBuild( + "every output amount must be greater than zero".to_string(), + )); + } + let outputs_total: u64 = outputs.iter().map(|(_, amount)| *amount).sum(); + + let mut wm = self.wallet_manager.write().await; + let (wallet, info) = wm + .get_wallet_and_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + + let height = info.core_wallet.last_processed_height(); + let fee_rate = FeeRate::new(fee_per_kb.unwrap_or(DEFAULT_FEE_PER_KB)); + + // The PRIMARY account (change destination). Clone the xpub-bearing + // account so no immutable borrow of `wallet` is held across the mutable + // `info` borrow / signer await below. + let primary_account = wallet + .get_bip44_account(PRIMARY_BIP44_ACCOUNT_INDEX) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "BIP44 account {PRIMARY_BIP44_ACCOUNT_INDEX} not found for payment funding" + )) + })? + .clone(); + + // Snapshot the primary account's spendable outpoints so the union sweep + // does not double-add them: `set_funding` already seeds them, and + // `add_inputs` must contribute only the OTHER signable accounts. + let primary_outpoints: HashSet = info + .core_wallet + .accounts + .standard_bip44_accounts + .get(&PRIMARY_BIP44_ACCOUNT_INDEX) + .map(|a| { + a.spendable_utxos(height) + .into_iter() + .map(|u| u.outpoint) + .collect() + }) + .unwrap_or_default(); + + // Single immutable pass over every signable funds account, building: + // (a) an owned `Address -> DerivationPath` resolver spanning all + // signable inputs, so signing resolves a key for an input drawn + // from any account; + // (b) the explicit extra inputs (all signable non-primary accounts); + // (c) an `OutPoint -> value` map for the post-build change figure; + // (d) the total selectable value, for a typed shortfall error. + let mut path_map: HashMap = HashMap::new(); + let mut input_value: HashMap = HashMap::new(); + let mut extra_inputs: Vec = Vec::new(); + let mut selectable_value: u64 = 0; + for account in info.core_wallet.accounts.all_funding_accounts() { + if is_watch_only_funds_account(account) { + continue; + } + for utxo in account.spendable_utxos(height) { + selectable_value = selectable_value.saturating_add(utxo.value()); + input_value.insert(utxo.outpoint, utxo.value()); + if let Some(path) = account.address_derivation_path(&utxo.address) { + path_map.insert(utxo.address.clone(), path); + } + if !primary_outpoints.contains(&utxo.outpoint) { + extra_inputs.push(utxo.clone()); + } + } + } + + // Seed the primary account (inputs + change address + reservations), + // append the union of the other signable accounts' inputs, then add the + // real recipient outputs. The `&mut` borrow of the primary account is + // scoped to this block; the returned builder owns cloned inputs / + // reservations / change address, so no account borrow is held across + // the signer await below. + let builder = { + let primary_funds = info + .core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&PRIMARY_BIP44_ACCOUNT_INDEX) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "managed BIP44 account {PRIMARY_BIP44_ACCOUNT_INDEX} not found for \ + payment funding" + )) + })?; + let mut builder = TransactionBuilder::new() + .set_fee_rate(fee_rate) + .set_current_height(height) + // See the doc-comment: LargestFirst, not the default + // BranchAndBound, to keep CoinJoin's many small denominations + // from blowing up the exact-match subset-sum search. + .set_selection_strategy(SelectionStrategy::LargestFirst) + .set_funding(primary_funds, &primary_account) + .add_inputs(extra_inputs); + for (address, amount) in &outputs { + builder = builder.add_output(address, *amount); + } + builder + }; + + let (transaction, _estimated_fee) = builder + .build_signed(signer, move |addr| path_map.get(&addr).cloned()) + .await + .map_err(|e| map_send_builder_error(e, selectable_value, outputs_total))?; + + // Derive fee and change from the transaction itself — the ground truth + // that is always self-consistent (`fee + outputs + change == inputs`). + // We do NOT use `build_signed`'s returned fee: it recomputes the fee + // from the *signed* size, but the change output was already sized with + // the pre-sign estimate, and ECDSA signatures vary in encoded length — + // so the recomputed figure can differ by a few duffs from the fee the + // wallet actually pays (`inputs − outputs`). + // + // `total_out` is the sum of every output; the only non-recipient output + // a plain payment (no special payload) can carry is the single change + // output back to the primary account, so `change = total_out − outputs`. + // Any selected input we somehow can't price (impossible — every + // spendable UTXO was recorded above) counts as 0, so `fee` is over- + // rather than under-reported. + let selected_input_value: u64 = transaction + .input + .iter() + .map(|txin| input_value.get(&txin.previous_output).copied().unwrap_or(0)) + .sum(); + let total_out: u64 = transaction.output.iter().map(|o| o.value).sum(); + let fee = selected_input_value.saturating_sub(total_out); + let change_amount = total_out.saturating_sub(outputs_total); + + Ok(SignedCorePayment { + transaction, + fee, + change_amount, + }) + } +} + +/// Map a key-wallet [`BuilderError`] to a [`PlatformWalletError`], promoting the +/// two shortfall shapes to the typed [`PlatformWalletError::PaymentInsufficientFunds`] +/// so the exact `available`/`required` duff amounts survive. The builder's own +/// `InsufficientFunds` figures cover only what the primary-account selector saw, +/// so we substitute the union-wide selectable total (`available`) and the +/// outputs-plus-fee-ish target — `required` is at least the outputs total; a +/// coin-selection error already carries the fee-inclusive figure, which we +/// prefer when present. +fn map_send_builder_error( + error: BuilderError, + union_available: u64, + outputs_total: u64, +) -> PlatformWalletError { + match error { + BuilderError::InsufficientFunds { required, .. } => { + PlatformWalletError::PaymentInsufficientFunds { + available: union_available, + required: required.max(outputs_total), + } + } + BuilderError::CoinSelection(SelectionError::InsufficientFunds { required, .. }) => { + PlatformWalletError::PaymentInsufficientFunds { + available: union_available, + required: required.max(outputs_total), + } + } + BuilderError::CoinSelection(SelectionError::NoUtxosAvailable) => { + PlatformWalletError::PaymentInsufficientFunds { + available: union_available, + required: outputs_total, + } + } + other => PlatformWalletError::TransactionBuild(format!("payment build failed: {other}")), + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + use std::sync::Arc; + + use dashcore::hashes::Hash; + use dashcore::{Address as DashAddress, Network, OutPoint, TxOut, Txid}; + use key_wallet::account::account_type::StandardAccountType; + use key_wallet::account::AccountType; + use key_wallet::managed_account::ManagedCoreFundsAccount; + use key_wallet::Utxo; + + use crate::test_support::{ + funded_wallet_manager, split_funded_wallet_manager, AlwaysRejectedBroadcaster, + }; + use crate::wallet::core::balance::WalletBalance; + use crate::wallet::core::CoreWallet; + use crate::wallet::platform_wallet::WalletId; + use crate::PlatformWalletError; + + use super::SignedCorePayment; + + /// A `CoreWallet` over a manager fixture. The send path never broadcasts, + /// so the broadcaster is irrelevant (and the balance handle is unused by + /// build — a fresh one is fine for the split fixtures that don't return it). + fn core_wallet( + wallet_manager: Arc>>, + wallet_id: WalletId, + balance: Arc, + ) -> CoreWallet { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + CoreWallet::new( + sdk, + wallet_manager, + wallet_id, + Arc::new(AlwaysRejectedBroadcaster), + balance, + ) + } + + fn recipient(seed: u8) -> DashAddress { + DashAddress::dummy(Network::Testnet, seed as usize) + } + + /// Every input of a signed tx must carry a non-empty scriptSig (proof each + /// selected input was actually signed by the per-account resolver). + fn assert_all_inputs_signed(payment: &SignedCorePayment) { + for (i, txin) in payment.transaction.input.iter().enumerate() { + assert!( + !txin.script_sig.is_empty(), + "input {i} was left unsigned (empty scriptSig)" + ); + } + } + + /// A single-account BIP44 payment: the recipient output is present with the + /// exact value, a fee is charged, and the change amount is exactly + /// selected_input − output − fee (here the whole 0.1 DASH rides on one + /// input, so change ≈ 0.1 − amount − fee). + #[tokio::test] + async fn bip44_payment_has_correct_output_change_and_fee() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(wm, wallet_id, balance); + + let to = recipient(42); + let amount = 1_000_000u64; + let payment = core + .build_signed_payment(vec![(to.clone(), amount)], None, &signer) + .await + .expect("build should succeed with 0.1 DASH funded"); + + // Recipient output present with the exact value. + let recipient_out = payment + .transaction + .output + .iter() + .find(|o| o.script_pubkey == to.script_pubkey()); + assert_eq!( + recipient_out.map(|o| o.value), + Some(amount), + "recipient output must carry the requested amount" + ); + + // A fee was charged and change is exactly input − output − fee. + assert!(payment.fee > 0, "a non-zero fee should be charged"); + assert_eq!( + payment.change_amount, + 10_000_000 - amount - payment.fee, + "change must be the single input minus the output minus the fee" + ); + // The change output pays the leftover back to the wallet. + assert!( + payment + .transaction + .output + .iter() + .any(|o| o.value == payment.change_amount), + "a change output equal to change_amount should exist" + ); + assert_all_inputs_signed(&payment); + } + + /// Coin selection spans the UNION of signable funds accounts: a payment + /// that exceeds either the BIP44 slice or the CoinJoin slice alone pulls + /// inputs from BOTH, and every mixed-account input is signed. + #[tokio::test] + async fn payment_funds_from_bip44_and_coinjoin_union() { + // 0.09 DASH on BIP44, 0.09 on CoinJoin; ask 0.15 → needs both. + let (wm, wallet_id, signer) = split_funded_wallet_manager(9_000_000, 9_000_000).await; + + // Snapshot each account's outpoints before building. + let (bip44_ops, coinjoin_ops): (HashSet, HashSet) = { + let guard = wm.read().await; + let (_, info) = guard.get_wallet_and_info(&wallet_id).expect("wallet present"); + let bip44 = info + .core_wallet + .accounts + .standard_bip44_accounts + .get(&0) + .map(|a| a.utxos.keys().copied().collect()) + .unwrap_or_default(); + let coinjoin = info + .core_wallet + .accounts + .coinjoin_accounts + .get(&0) + .map(|a| a.utxos.keys().copied().collect()) + .unwrap_or_default(); + (bip44, coinjoin) + }; + + let core = core_wallet(wm, wallet_id, Arc::new(WalletBalance::new())); + let payment = core + .build_signed_payment(vec![(recipient(7), 15_000_000)], None, &signer) + .await + .expect("0.15 DASH must be fundable from the 0.18 DASH union"); + + let spent: HashSet = payment + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + assert!( + spent.iter().any(|op| bip44_ops.contains(op)), + "at least one BIP44 input should be selected" + ); + assert!( + spent.iter().any(|op| coinjoin_ops.contains(op)), + "at least one CoinJoin input should be selected" + ); + assert_all_inputs_signed(&payment); + } + + /// A shortfall across the whole signable union surfaces as the typed + /// [`PlatformWalletError::PaymentInsufficientFunds`], with `available` + /// reflecting the union total (not just the primary BIP44 slice). + #[tokio::test] + async fn union_shortfall_is_typed() { + let (wm, wallet_id, signer) = split_funded_wallet_manager(9_000_000, 9_000_000).await; + let core = core_wallet(wm, wallet_id, Arc::new(WalletBalance::new())); + + let result = core + .build_signed_payment(vec![(recipient(7), 100_000_000)], None, &signer) + .await; + + match result { + Err(PlatformWalletError::PaymentInsufficientFunds { + available, + required, + }) => { + assert!( + (9_000_000..=18_000_000).contains(&available), + "available {available} should reflect the union (9M<..<=18M)" + ); + assert!( + required >= 100_000_000, + "required {required} should be at least the requested amount" + ); + } + other => panic!("expected PaymentInsufficientFunds, got {other:?}"), + } + } + + /// A watch-only `DashpayExternalAccount` (a contact's addresses, which this + /// wallet cannot sign) is EXCLUDED from coin selection: its UTXO is never + /// spent, and its value is not counted toward the selectable total. + #[tokio::test] + async fn watch_only_external_account_is_excluded() { + // BIP44 holds 0.1 DASH; a watch-only external account holds 1.0 DASH. + let (wm, wallet_id, _balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + + let watch_only_outpoint = OutPoint { + txid: Txid::from_byte_array([0x9au8; 32]), + vout: 0, + }; + { + let mut guard = wm.write().await; + let (wallet, info) = guard + .get_wallet_mut_and_info_mut(&wallet_id) + .expect("wallet present"); + + // Reuse the wallet's own BIP44 xpub as a stand-in "contact xpub": + // the exclusion happens before any address derivation, so any valid + // xpub suffices to construct the funds-bearing external account. + let contact_xpub = wallet + .accounts + .standard_bip44_accounts + .get(&0) + .expect("bip44 account 0") + .account_xpub; + let account_type = AccountType::DashpayExternalAccount { + index: 0, + user_identity_id: [1u8; 32], + friend_identity_id: [2u8; 32], + }; + let account = key_wallet::Account { + parent_wallet_id: Some(wallet_id), + account_type, + network: Network::Testnet, + account_xpub: contact_xpub, + is_watch_only: true, + }; + let mut managed = ManagedCoreFundsAccount::from_account(&account); + + // Insert a large spendable UTXO directly (arbitrary address — the + // account is skipped before its addresses are ever consulted). + let addr = recipient(200); + let utxo = Utxo { + outpoint: watch_only_outpoint, + txout: TxOut { + value: 100_000_000, + script_pubkey: addr.script_pubkey(), + }, + address: addr, + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + managed.utxos.insert(utxo.outpoint, utxo); + info.core_wallet + .accounts + .insert_funds_bearing_account(managed) + .expect("insert watch-only external account"); + } + + let core = core_wallet(wm, wallet_id, Arc::new(WalletBalance::new())); + + // Ask for 0.5 DASH: covered only if the 1.0-DASH watch-only UTXO were + // spendable. Since it is excluded, the build must fail — and the + // reported `available` must be just the 0.1-DASH BIP44 slice. + let result = core + .build_signed_payment(vec![(recipient(7), 50_000_000)], None, &signer) + .await; + match result { + Err(PlatformWalletError::PaymentInsufficientFunds { available, .. }) => { + assert_eq!( + available, 10_000_000, + "watch-only value must be excluded from the selectable total" + ); + } + other => panic!("expected PaymentInsufficientFunds, got {other:?}"), + } + + // And a payment that the 0.1-DASH BIP44 slice CAN cover must never spend + // the watch-only outpoint. + let payment = core + .build_signed_payment(vec![(recipient(7), 1_000_000)], None, &signer) + .await + .expect("0.01 DASH is fundable from the BIP44 slice alone"); + assert!( + payment + .transaction + .input + .iter() + .all(|i| i.previous_output != watch_only_outpoint), + "the watch-only UTXO must never be selected as an input" + ); + assert_all_inputs_signed(&payment); + } + + /// Input validation: empty outputs and zero-amount outputs are rejected + /// before any wallet work. + #[tokio::test] + async fn rejects_empty_and_zero_outputs() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(wm, wallet_id, balance); + + let empty = core.build_signed_payment(vec![], None, &signer).await; + assert!(matches!(empty, Err(PlatformWalletError::TransactionBuild(_)))); + + let zero = core + .build_signed_payment(vec![(recipient(7), 0)], None, &signer) + .await; + assert!(matches!(zero, Err(PlatformWalletError::TransactionBuild(_)))); + } +} From a3418013080fe6e8331a46ffe6e90388c6b91d2a Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:49:34 -0400 Subject: [PATCH 34/47] feat(sdk): plumb build_signed_payment through FFI, JNI, and Kotlin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the union-funding send primitive as a public Kotlin suspend fun that returns the signed raw transaction bytes (plus fee + change) WITHOUT broadcasting — the last SDK gap for the Android wallet's send-path cutover. - FFI (rs-platform-wallet-ffi): core_wallet_build_signed_payment, a one-shot call over CoreWallet::build_signed_payment. Recipients cross as a big-endian blob (u32 count; per row u32 addrLen, addr utf8, u64 amount), each address parsed + network-checked; returns the consensus-serialized signed bytes via out-pointers plus out_fee/out_change, freed with core_wallet_free_payment_bytes. cbindgen exports both automatically. - JNI (rs-unified-sdk-jni): WalletManagerNative.coreWalletBuildSignedPayment returns a byte[] packed big-endian as (u64 fee, u64 change, tx bytes); the FFI-owned bytes are freed before returning. - Kotlin (kotlin-sdk): ManagedPlatformWallet.buildSignedPayment(recipients, coreSignerHandle, feePerKb) -> SignedCorePayment(txBytes, fee, change), serialized under the same shared per-wallet coreSendMutex as sendToAddresses so a concurrent build cannot select the same UTXO. Unlike sendToAddresses it neither broadcasts nor picks a funding account (coin selection auto-spans every signable account). ManagedCoreWallet gains the matching native call on the transient core handle. cargo check/test green across platform-wallet-ffi (149) and rs-unified-sdk-jni (2); :sdk:compileDebugKotlin BUILD SUCCESSFUL. Co-Authored-By: Claude Fable 5 (cherry picked from commit d18c9c0b4082ff13e7a7771fd8ca280c0a88a915) [port to v4.2-dev] ManagedPlatformWallet.buildSignedPayment is serialized through the manager's TeardownGate (gate.op {}), matching sendToAddresses and every other native op on this branch, instead of the pre-refactor per-wallet `coreSendMutex` (which no longer exists on v4.2-dev). Concurrent builds still cannot select the same UTXO: CoreWallet::build_signed_payment holds the wallet-manager write lock across coin selection and signing. --- .../dashsdk/ffi/WalletManagerNative.kt | 21 +++ .../dashsdk/wallet/ManagedCoreWallet.kt | 20 +++ .../dashsdk/wallet/ManagedPlatformWallet.kt | 106 +++++++++++ .../src/core_wallet/mod.rs | 2 + .../src/core_wallet/send.rs | 169 ++++++++++++++++++ .../rs-unified-sdk-jni/src/wallet_manager.rs | 91 ++++++++++ 6 files changed, 409 insertions(+) create mode 100644 packages/rs-platform-wallet-ffi/src/core_wallet/send.rs 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 74a40120c25..ba08512c6ad 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 @@ -214,6 +214,27 @@ internal object WalletManagerNative { */ external fun platformWalletGetCore(walletHandle: Long): Long + /** + * `core_wallet_build_signed_payment` — build + sign a standard L1 payment + * funded from the UNION of the wallet's signable funds accounts (watch-only + * DashPay external accounts excluded), WITHOUT broadcasting. + * + * [coreHandle] is a core-wallet handle from [platformWalletGetCore]. + * [outputsBlob] encodes the recipients big-endian as `u32 count` then per + * row `u32 addrLen, addr utf8, u64 amount`. [feePerKb] is duffs/kB (0 = + * default). [coreSignerHandle] is the manager's `MnemonicResolverHandle`. + * + * Returns a `byte[]` packed big-endian as `u64 fee, u64 change,` then the + * consensus-serialized signed transaction bytes (0-length / null after + * throwing). Does NOT broadcast and does NOT persist a debit. + */ + external fun coreWalletBuildSignedPayment( + coreHandle: Long, + outputsBlob: ByteArray, + feePerKb: Long, + coreSignerHandle: Long, + ): ByteArray + /** * `core_wallet_broadcast_transaction` — broadcast a transaction built by * [coreTxBuilderBuildSigned]. [accountType]/[accountIndex] identify the 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..47d34e02f28 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 @@ -68,6 +68,26 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { internal fun broadcastSignedPayment(token: Long): String = WalletManagerNative.coreWalletBroadcastSignedPayment(handle, token) + /** + * Build + sign a standard L1 payment funded from the UNION of the wallet's + * signable funds accounts, WITHOUT broadcasting. Returns the packed native + * result (`u64 fee, u64 change,` then the signed tx bytes, big-endian) — + * decoded by [ManagedPlatformWallet.buildSignedPayment]. See that method + * for the full contract; drive this through it (it serializes concurrent + * builds), not directly. + */ + internal fun buildSignedPayment( + outputsBlob: ByteArray, + feePerKb: Long, + coreSignerHandle: Long, + ): ByteArray = + WalletManagerNative.coreWalletBuildSignedPayment( + handle, + outputsBlob, + feePerKb, + coreSignerHandle, + ) + 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 71afc830d39..e16b106d597 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 @@ -360,6 +360,77 @@ class ManagedPlatformWallet internal constructor( } } + /** + * A built-and-signed Core L1 payment that was NOT broadcast — the output of + * [buildSignedPayment]. [txBytes] is the consensus-serialized signed + * transaction the caller commits/broadcasts itself (dashj during the + * dashj→SDK transition; the SDK's own broadcast afterwards). [fee] and + * [change] are duffs. + */ + data class SignedCorePayment( + val txBytes: ByteArray, + val fee: Long, + val change: Long, + ) { + override fun equals(other: Any?): Boolean = + other is SignedCorePayment && + txBytes.contentEquals(other.txBytes) && + fee == other.fee && + change == other.change + + override fun hashCode(): Int = + (31 * txBytes.contentHashCode() + fee.hashCode()) * 31 + change.hashCode() + } + + /** + * Build and sign a Core L1 payment to [recipients], funding it from the + * UNION of this wallet's signable funds accounts (BIP44 + BIP32 + CoinJoin + * + DashPay receiving; watch-only DashPay external accounts excluded), and + * return the signed raw transaction bytes plus the fee and change — + * **WITHOUT broadcasting**. + * + * This is the transition-era "give me signed bytes" primitive: the Android + * wallet hands [SignedCorePayment.txBytes] to dashj for commit + broadcast + * (keeping dashj's `maybeCommitTx` bookkeeping — CrowdNode, memos, + * confidence listeners), while the SDK owns coin selection and signing. It + * does not broadcast and does not persist a debit; the selected inputs are + * only reserved in memory (released when the spend is later observed by sync + * or by the reservation-TTL backstop). Coin selection auto-spans every + * signable account, so — unlike [sendToAddresses] — the caller does not + * pick a funding account. + * + * Runs through the manager's [TeardownGate] like every other native op. + * A concurrent build cannot select the same UTXO because the underlying + * `build_signed_payment` holds the wallet-manager write lock across coin + * selection and signing (the same native serialization [sendToAddresses] + * relies on). + * + * @param recipients `(address, amountDuffs)` pairs; must be non-empty and + * every amount positive. + * @param coreSignerHandle the manager's `MnemonicResolverHandle` + * (`PlatformWalletManager.mnemonicResolverHandle`); no private key crosses + * the boundary. + * @param feePerKb fee rate in duffs/kB, or 0 for the SDK default. + */ + suspend fun buildSignedPayment( + recipients: List>, + coreSignerHandle: Long, + feePerKb: Long = 0, + ): SignedCorePayment = gate.op { + require(recipients.isNotEmpty()) { "recipients must not be empty" } + require(recipients.all { it.second > 0 }) { "every recipient amount must be positive" } + require(feePerKb >= 0) { "feePerKb must be non-negative, got $feePerKb" } + + val outputsBlob = encodePaymentOutputs(recipients) + mapNativeErrors { + coreWallet().use { core -> + decodeSignedPayment( + core.buildSignedPayment(outputsBlob, feePerKb, coreSignerHandle), + ) + } + } + } + /** * Broadcast the deferred payment behind [token] (from [buildSignedPayment]) * and return its broadcast txid — the "merchant server acked" arm. Consumes @@ -890,6 +961,41 @@ class ManagedPlatformWallet internal constructor( return out.toByteArray() } + /** + * Encode [recipients] to the payment-outputs blob + * `core_wallet_build_signed_payment` reads: `u32 count` then per row + * `u32 addrLen, addr utf8 bytes, u64 amount` (all big-endian, matching + * `DataOutputStream`'s wire order and the Rust `from_be_bytes` decoder). + */ + private fun encodePaymentOutputs(recipients: List>): ByteArray { + val out = java.io.ByteArrayOutputStream() + val dos = java.io.DataOutputStream(out) + dos.writeInt(recipients.size) + for ((address, amount) in recipients) { + val addrBytes = address.toByteArray(Charsets.UTF_8) + dos.writeInt(addrBytes.size) + dos.write(addrBytes) + dos.writeLong(amount) + } + return out.toByteArray() + } + + /** + * Decode the packed [SignedCorePayment] the native build returns: + * `u64 fee, u64 change,` then the signed transaction bytes (big-endian). + */ + private fun decodeSignedPayment(packed: ByteArray): SignedCorePayment { + require(packed.size >= 16) { + "signed-payment result too short (${packed.size} bytes, need >= 16)" + } + val buffer = java.nio.ByteBuffer.wrap(packed) // big-endian by default + val fee = buffer.long + val change = buffer.long + val txBytes = ByteArray(buffer.remaining()) + buffer.get(txBytes) + return SignedCorePayment(txBytes = txBytes, fee = fee, change = change) + } + /** * Encode [recipients] to the funding-recipients blob the FFI reads: * `u32 rowCount` then per row `u8 addressType, u8[20] hash, 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 01c0cf4167a..2cf51776602 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs @@ -4,12 +4,14 @@ mod addresses; mod broadcast; +mod send; pub(crate) mod signed_payment; mod transaction_builder; mod wallet; pub use addresses::*; pub use broadcast::*; +pub use send::*; pub use signed_payment::*; pub use transaction_builder::*; pub use wallet::*; diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs new file mode 100644 index 00000000000..90724a7ca07 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs @@ -0,0 +1,169 @@ +//! FFI binding for the union-funding "build a signed payment" primitive. +//! +//! Unlike the step-by-step `core_wallet_tx_builder_*` builder (which funds from +//! a single caller-chosen account), this is a one-shot call that funds a +//! standard L1 payment from the UNION of every signable funds account +//! (BIP44 + BIP32 + CoinJoin + DashPay receiving; watch-only DashPay external +//! accounts are excluded) and returns the **signed serialized transaction +//! bytes** plus the computed fee and change amount. It does NOT broadcast and +//! does NOT persist a debit — the caller commits/broadcasts the returned bytes +//! itself (dashj during the Android transition; a later SDK-broadcast mode +//! afterwards). See `platform_wallet::wallet::core::send` for the semantics. + +use crate::error::*; +use crate::handle::{Handle, CORE_WALLET_STORAGE}; +use crate::runtime::runtime; +use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; +use dashcore::Address as DashAddress; +use platform_wallet::PlatformWalletError; +use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle}; +use std::str::FromStr; + +/// Decode the recipients blob the caller passes to +/// [`core_wallet_build_signed_payment`]. Layout (big-endian): +/// +/// ```text +/// u32 count +/// count × ( u32 address_len, address_len bytes (UTF-8), u64 amount_duffs ) +/// ``` +/// +/// Each address is parsed and checked against `network`; a malformed blob or a +/// wrong-network / unparseable address is a decode error. +fn decode_payment_outputs( + blob: &[u8], + network: dashcore::Network, +) -> Result, PlatformWalletError> { + let err = |m: String| PlatformWalletError::TransactionBuild(m); + let mut cursor = 0usize; + let read_u32 = |buf: &[u8], at: &mut usize| -> Result { + let end = *at + 4; + if end > buf.len() { + return Err(PlatformWalletError::TransactionBuild( + "truncated recipients blob (u32)".to_string(), + )); + } + let v = u32::from_be_bytes([buf[*at], buf[*at + 1], buf[*at + 2], buf[*at + 3]]); + *at = end; + Ok(v) + }; + let read_u64 = |buf: &[u8], at: &mut usize| -> Result { + let end = *at + 8; + if end > buf.len() { + return Err(PlatformWalletError::TransactionBuild( + "truncated recipients blob (u64)".to_string(), + )); + } + let mut b = [0u8; 8]; + b.copy_from_slice(&buf[*at..end]); + *at = end; + Ok(u64::from_be_bytes(b)) + }; + + let count = read_u32(blob, &mut cursor)? as usize; + let mut outputs = Vec::with_capacity(count); + for _ in 0..count { + let addr_len = read_u32(blob, &mut cursor)? as usize; + let end = cursor + addr_len; + if end > blob.len() { + return Err(err("truncated recipients blob (address)".to_string())); + } + let addr_str = std::str::from_utf8(&blob[cursor..end]) + .map_err(|e| err(format!("recipient address is not valid UTF-8: {e}")))?; + cursor = end; + let amount = read_u64(blob, &mut cursor)?; + + let parsed = DashAddress::from_str(addr_str) + .map_err(|e| err(format!("invalid recipient address {addr_str:?}: {e}")))?; + let address = parsed + .require_network(network) + .map_err(|e| err(format!("recipient address {addr_str:?} network mismatch: {e}")))?; + outputs.push((address, amount)); + } + Ok(outputs) +} + +/// Build and sign a standard L1 payment from the wallet's signable funds +/// accounts (union coin selection) and return the signed bytes + fee + change. +/// +/// * `handle` — a core-wallet handle (`platform_wallet_get_core`). +/// * `outputs_blob`/`outputs_blob_len` — the recipients, encoded as documented +/// on [`decode_payment_outputs`]. +/// * `fee_per_kb` — fee rate in duffs/kB, or `0` for the default (1000). +/// * `core_signer_handle` — the caller's `MnemonicResolverHandle`; ownership is +/// retained by the caller (this function does NOT destroy it). +/// * `out_tx_bytes`/`out_tx_len` — receive the consensus-serialized signed +/// transaction. Free with [`core_wallet_free_payment_bytes`]. +/// * `out_fee` — receives the fee paid, in duffs. +/// * `out_change` — receives the change returned to the wallet, in duffs (0 if +/// the build produced no change output). +/// +/// # Safety +/// All pointers must be valid; `outputs_blob` must be readable for +/// `outputs_blob_len` bytes; the out-pointers must be writable. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn core_wallet_build_signed_payment( + handle: Handle, + outputs_blob: *const u8, + outputs_blob_len: usize, + fee_per_kb: u64, + core_signer_handle: *mut MnemonicResolverHandle, + out_tx_bytes: *mut *mut u8, + out_tx_len: *mut usize, + out_fee: *mut u64, + out_change: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(outputs_blob); + check_ptr!(core_signer_handle); + check_ptr!(out_tx_bytes); + check_ptr!(out_tx_len); + check_ptr!(out_fee); + check_ptr!(out_change); + + let blob = std::slice::from_raw_parts(outputs_blob, outputs_blob_len); + let signer_addr = core_signer_handle as usize; + let fee = if fee_per_kb == 0 { + None + } else { + Some(fee_per_kb) + }; + + let option = CORE_WALLET_STORAGE.with_item(handle, |wallet| { + let network = wallet.network(); + let outputs = decode_payment_outputs(blob, network)?; + let wallet_id = wallet.wallet_id(); + // SAFETY: `signer_addr` came from `core_signer_handle`, which the caller + // pinned alive for this call; the `MnemonicResolverCoreSigner` lives + // only on this stack frame and is dropped before returning. + let signer = MnemonicResolverCoreSigner::new( + signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ); + runtime().block_on(wallet.build_signed_payment(outputs, fee, &signer)) + }); + + let result = unwrap_option_or_return!(option); + let payment = unwrap_result_or_return!(result); + + let serialized = dashcore::consensus::serialize(&payment.transaction); + let len = serialized.len(); + *out_tx_bytes = Box::into_raw(serialized.into_boxed_slice()) as *mut u8; + *out_tx_len = len; + *out_fee = payment.fee; + *out_change = payment.change_amount; + + PlatformWalletFFIResult::ok() +} + +/// Free the signed-payment bytes returned by [`core_wallet_build_signed_payment`]. +/// +/// # Safety +/// `bytes`/`len` must be the exact pair written to `out_tx_bytes`/`out_tx_len` +/// by [`core_wallet_build_signed_payment`] (or null / 0). +#[no_mangle] +pub unsafe extern "C" fn core_wallet_free_payment_bytes(bytes: *mut u8, len: usize) { + if !bytes.is_null() && len > 0 { + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(bytes, len)); + } +} diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 070d81357c7..75dd0a39d87 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1006,6 +1006,97 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c }) } +/// `core_wallet_build_signed_payment` — build + sign a standard L1 payment +/// funded from the UNION of the wallet's signable funds accounts (BIP44 + +/// BIP32 + CoinJoin + DashPay receiving; watch-only DashPay external accounts +/// excluded), and return the result WITHOUT broadcasting. +/// +/// `core_handle` is the transient core-wallet `Handle` from +/// [platformWalletGetCore]. `outputs_blob` is the recipients, encoded +/// big-endian as `u32 count` then per row `u32 addrLen, addr utf8, u64 amount` +/// (`ManagedPlatformWallet.encodePaymentOutputs`). `fee_per_kb` is duffs/kB, or +/// 0 for the default. `core_signer_handle` is the manager's +/// `MnemonicResolverHandle`. +/// +/// Returns a `byte[]` packed big-endian as `u64 fee, u64 change,` then the +/// consensus-serialized signed transaction bytes (`fee` and `change` in duffs), +/// or null after throwing. The FFI-owned tx bytes are freed here before +/// returning; Kotlin decodes the packed array via +/// `ManagedPlatformWallet.decodeSignedPayment`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreWalletBuildSignedPayment( + mut env: JNIEnv, + _class: JClass, + core_handle: jlong, + outputs_blob: JByteArray, + fee_per_kb: jlong, + core_signer_handle: jlong, +) -> jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + if core_handle == 0 { + throw_sdk_exception(env, 1, "core handle is 0"); + return ptr::null_mut(); + } + if core_signer_handle == 0 { + throw_sdk_exception(env, 1, "coreSignerHandle is 0"); + return ptr::null_mut(); + } + if fee_per_kb < 0 { + throw_sdk_exception(env, 1, "feePerKb must be non-negative"); + return ptr::null_mut(); + } + let blob = match env.convert_byte_array(&outputs_blob) { + Ok(b) => b, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, "outputs byte[] was invalid"); + return ptr::null_mut(); + } + }; + + let mut out_tx_bytes: *mut u8 = ptr::null_mut(); + let mut out_tx_len: usize = 0; + let mut out_fee: u64 = 0; + let mut out_change: u64 = 0; + let result = unsafe { + platform_wallet_ffi::core_wallet_build_signed_payment( + core_handle as Handle, + blob.as_ptr(), + blob.len(), + fee_per_kb as u64, + core_signer_handle as *mut rs_sdk_ffi::MnemonicResolverHandle, + &mut out_tx_bytes, + &mut out_tx_len, + &mut out_fee, + &mut out_change, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + + // Copy the FFI-owned tx bytes out, then free them, then pack the + // metadata-prefixed result for Kotlin. `fee` and `change` are written + // big-endian ahead of the raw tx bytes. + let tx_bytes: &[u8] = if out_tx_bytes.is_null() || out_tx_len == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(out_tx_bytes, out_tx_len) } + }; + let mut packed = Vec::with_capacity(16 + tx_bytes.len()); + packed.extend_from_slice(&out_fee.to_be_bytes()); + packed.extend_from_slice(&out_change.to_be_bytes()); + packed.extend_from_slice(tx_bytes); + unsafe { + platform_wallet_ffi::core_wallet_free_payment_bytes(out_tx_bytes, out_tx_len); + } + + env.byte_array_from_slice(&packed) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + /// `platform_wallet_get_core` — resolve the transient core-wallet `Handle` /// (as `jlong`) from a `PlatformWallet` handle, for [coreWalletBroadcastTransaction]. /// Free with [coreWalletDestroy]. Returns 0 after throwing. From 57fde151f271b43c7296ccc44387a72b4cb94031 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:49:35 -0400 Subject: [PATCH 35/47] fix(platform-wallet): re-scope build_signed_payment to single-account funding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_signed_payment funded from the union of all signable funds accounts (BIP44 + CoinJoin + …) with BIP44 change — the privacy-domain-crossing design blocked on #4184 (shumkov, 2026-07-21) and replaced there by single-account selection. This code predated that re-scope. - add funding_path: Option; None = unmixed BIP44 account 0, Some(path) = strictly the one funds account whose account path matches. No union, no cross-account accumulation; shortfall returns PaymentInsufficientFunds for that account only. Change routes to BIP44 (explicit change addr when a non-Standard account funds). - new wallet::funding_privacy guardrail: crate-wide static test fails the build if any wallet-wide funds-account iteration lacks a PRIVACY-DOMAIN-OK marker. - replace the union-asserting test with default-never-crosses-domains and explicit-path-selects-strictly tests. - review-fix hardening: bounded FFI allocation + checked cursor math, output-total overflow guard, fee-rate bound, typed PaymentInsufficientFunds (code 22). - thread funding_path through FFI/JNI/Kotlin (null = unmixed BIP44). Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/ffi/WalletManagerNative.kt | 11 +- .../dashsdk/wallet/ManagedCoreWallet.kt | 4 +- .../dashsdk/wallet/ManagedPlatformWallet.kt | 30 +- .../src/core_wallet/send.rs | 101 ++- packages/rs-platform-wallet-ffi/src/error.rs | 12 +- packages/rs-platform-wallet-ffi/src/utils.rs | 38 ++ .../rs-platform-wallet/src/test_support.rs | 88 +++ .../src/wallet/core/send.rs | 628 +++++++++++++----- .../src/wallet/funding_privacy.rs | 359 ++++++++++ packages/rs-platform-wallet/src/wallet/mod.rs | 1 + packages/rs-unified-sdk-jni/src/funding.rs | 34 + .../rs-unified-sdk-jni/src/wallet_manager.rs | 30 +- 12 files changed, 1125 insertions(+), 211 deletions(-) create mode 100644 packages/rs-platform-wallet/src/wallet/funding_privacy.rs 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 ba08512c6ad..7debdf09ce9 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 @@ -216,13 +216,19 @@ internal object WalletManagerNative { /** * `core_wallet_build_signed_payment` — build + sign a standard L1 payment - * funded from the UNION of the wallet's signable funds accounts (watch-only - * DashPay external accounts excluded), WITHOUT broadcasting. + * funded from ONE of the wallet's signable funds accounts, WITHOUT + * broadcasting. * * [coreHandle] is a core-wallet handle from [platformWalletGetCore]. * [outputsBlob] encodes the recipients big-endian as `u32 count` then per * row `u32 addrLen, addr utf8, u64 amount`. [feePerKb] is duffs/kB (0 = * default). [coreSignerHandle] is the manager's `MnemonicResolverHandle`. + * [fundingPath] is an optional UTF-8 BIP32 derivation-path string + * (dashpay/platform#4184) naming the single funds account whose UTXOs fund + * the payment: null (the default) funds from the unmixed BIP44 account; an + * explicit account-level path (e.g. the DIP-9 CoinJoin account path) funds + * strictly from that one account, with no union across accounts and no + * consent gate. * * Returns a `byte[]` packed big-endian as `u64 fee, u64 change,` then the * consensus-serialized signed transaction bytes (0-length / null after @@ -233,6 +239,7 @@ internal object WalletManagerNative { outputsBlob: ByteArray, feePerKb: Long, coreSignerHandle: Long, + fundingPath: String?, ): ByteArray /** 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 47d34e02f28..28b4d891bb0 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 @@ -69,7 +69,7 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { WalletManagerNative.coreWalletBroadcastSignedPayment(handle, token) /** - * Build + sign a standard L1 payment funded from the UNION of the wallet's + * Build + sign a standard L1 payment funded from ONE of the wallet's * signable funds accounts, WITHOUT broadcasting. Returns the packed native * result (`u64 fee, u64 change,` then the signed tx bytes, big-endian) — * decoded by [ManagedPlatformWallet.buildSignedPayment]. See that method @@ -80,12 +80,14 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { outputsBlob: ByteArray, feePerKb: Long, coreSignerHandle: Long, + fundingPath: String?, ): ByteArray = WalletManagerNative.coreWalletBuildSignedPayment( handle, outputsBlob, feePerKb, coreSignerHandle, + fundingPath, ) override fun close() { 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 e16b106d597..f1a2672b977 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 @@ -383,11 +383,9 @@ class ManagedPlatformWallet internal constructor( } /** - * Build and sign a Core L1 payment to [recipients], funding it from the - * UNION of this wallet's signable funds accounts (BIP44 + BIP32 + CoinJoin - * + DashPay receiving; watch-only DashPay external accounts excluded), and - * return the signed raw transaction bytes plus the fee and change — - * **WITHOUT broadcasting**. + * Build and sign a Core L1 payment to [recipients], funding it from a + * **single** funds account, and return the signed raw transaction bytes + * plus the fee and change — **WITHOUT broadcasting**. * * This is the transition-era "give me signed bytes" primitive: the Android * wallet hands [SignedCorePayment.txBytes] to dashj for commit + broadcast @@ -395,9 +393,20 @@ class ManagedPlatformWallet internal constructor( * confidence listeners), while the SDK owns coin selection and signing. It * does not broadcast and does not persist a debit; the selected inputs are * only reserved in memory (released when the spend is later observed by sync - * or by the reservation-TTL backstop). Coin selection auto-spans every - * signable account, so — unlike [sendToAddresses] — the caller does not - * pick a funding account. + * or by the reservation-TTL backstop). + * + * **Funding-domain isolation (dashpay/platform#4184).** Coin selection never + * spans accounts. [fundingPath] names the one funds account to draw from; + * `null` (the default) draws from the unmixed BIP44 account. Passing an + * explicit account-level path — e.g. the DIP-9 CoinJoin account path — spends + * previously-mixed coins deliberately, and only those. Unioning ordinary, + * CoinJoin, and DashPay-receiving coins into one transaction would + * irreversibly link those privacy domains on chain, so it is never done + * implicitly: if the named account cannot cover the payment this throws + * [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.CoreInsufficientFunds] + * rather than reaching into another account — whose `available` figure is + * that ONE account's balance, so the actionable response is to pick a + * different [fundingPath], not to retry the same one. * * Runs through the manager's [TeardownGate] like every other native op. * A concurrent build cannot select the same UTXO because the underlying @@ -411,11 +420,14 @@ class ManagedPlatformWallet internal constructor( * (`PlatformWalletManager.mnemonicResolverHandle`); no private key crosses * the boundary. * @param feePerKb fee rate in duffs/kB, or 0 for the SDK default. + * @param fundingPath optional UTF-8 BIP32 derivation-path string naming the + * single funds account to fund from; `null` = the unmixed BIP44 account. */ suspend fun buildSignedPayment( recipients: List>, coreSignerHandle: Long, feePerKb: Long = 0, + fundingPath: String? = null, ): SignedCorePayment = gate.op { require(recipients.isNotEmpty()) { "recipients must not be empty" } require(recipients.all { it.second > 0 }) { "every recipient amount must be positive" } @@ -425,7 +437,7 @@ class ManagedPlatformWallet internal constructor( mapNativeErrors { coreWallet().use { core -> decodeSignedPayment( - core.buildSignedPayment(outputsBlob, feePerKb, coreSignerHandle), + core.buildSignedPayment(outputsBlob, feePerKb, coreSignerHandle, fundingPath), ) } } diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs index 90724a7ca07..57033e0376b 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs @@ -1,24 +1,34 @@ -//! FFI binding for the union-funding "build a signed payment" primitive. +//! FFI binding for the single-account "build a signed payment" primitive. //! -//! Unlike the step-by-step `core_wallet_tx_builder_*` builder (which funds from -//! a single caller-chosen account), this is a one-shot call that funds a -//! standard L1 payment from the UNION of every signable funds account -//! (BIP44 + BIP32 + CoinJoin + DashPay receiving; watch-only DashPay external -//! accounts are excluded) and returns the **signed serialized transaction -//! bytes** plus the computed fee and change amount. It does NOT broadcast and -//! does NOT persist a debit — the caller commits/broadcasts the returned bytes -//! itself (dashj during the Android transition; a later SDK-broadcast mode -//! afterwards). See `platform_wallet::wallet::core::send` for the semantics. +//! Like the step-by-step `core_wallet_tx_builder_*` builder, this funds from a +//! single caller-chosen account — but as a one-shot call that also signs, and +//! it names the account by BIP32 derivation path (so a DIP-9 CoinJoin or +//! DashPay-receiving account can be selected, not just BIP44/BIP32). It returns +//! the **signed serialized transaction bytes** plus the computed fee and change +//! amount. It does NOT broadcast and does NOT persist a debit — the caller +//! commits/broadcasts the returned bytes itself (dashj during the Android +//! transition; a later SDK-broadcast mode afterwards). +//! +//! Coin selection never unions funding accounts: `funding_path` names exactly +//! one, defaulting to the unmixed BIP44 account. See +//! `platform_wallet::wallet::funding_privacy` for the invariant and +//! `platform_wallet::wallet::core::send` for the semantics. use crate::error::*; use crate::handle::{Handle, CORE_WALLET_STORAGE}; use crate::runtime::runtime; +use crate::utils::parse_optional_derivation_path; use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; use dashcore::Address as DashAddress; use platform_wallet::PlatformWalletError; use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle}; use std::str::FromStr; +/// Smallest number of bytes one encoded output row can occupy: `u32 addr_len` +/// (4) + at least one address byte + `u64 amount` (8). Used to reject an +/// impossible `count` before any allocation. +const MIN_ENCODED_OUTPUT_LEN: usize = 4 + 1 + 8; + /// Decode the recipients blob the caller passes to /// [`core_wallet_build_signed_payment`]. Layout (big-endian): /// @@ -35,38 +45,49 @@ fn decode_payment_outputs( ) -> Result, PlatformWalletError> { let err = |m: String| PlatformWalletError::TransactionBuild(m); let mut cursor = 0usize; + // Checked cursor arithmetic throughout: `cursor + n` on a 32-bit target + // (Android armeabi-v7a) can overflow and panic inside this `extern "C"` + // frame, where the JNI guard cannot safely recover it. let read_u32 = |buf: &[u8], at: &mut usize| -> Result { - let end = *at + 4; - if end > buf.len() { - return Err(PlatformWalletError::TransactionBuild( - "truncated recipients blob (u32)".to_string(), - )); - } + let end = at.checked_add(4).filter(|e| *e <= buf.len()).ok_or_else(|| { + PlatformWalletError::TransactionBuild("truncated recipients blob (u32)".to_string()) + })?; let v = u32::from_be_bytes([buf[*at], buf[*at + 1], buf[*at + 2], buf[*at + 3]]); *at = end; Ok(v) }; let read_u64 = |buf: &[u8], at: &mut usize| -> Result { - let end = *at + 8; - if end > buf.len() { - return Err(PlatformWalletError::TransactionBuild( - "truncated recipients blob (u64)".to_string(), - )); - } + let end = at.checked_add(8).filter(|e| *e <= buf.len()).ok_or_else(|| { + PlatformWalletError::TransactionBuild("truncated recipients blob (u64)".to_string()) + })?; let mut b = [0u8; 8]; b.copy_from_slice(&buf[*at..end]); *at = end; Ok(u64::from_be_bytes(b)) }; + // Bound `count` by what the blob could actually contain BEFORE reserving. + // `count` is a caller-controlled `u32`: passing `u32::MAX` in a four-byte + // blob would otherwise ask `Vec::with_capacity` for ~64 GiB and take the + // process-aborting allocation-failure path instead of returning this + // decode error. let count = read_u32(blob, &mut cursor)? as usize; - let mut outputs = Vec::with_capacity(count); + let max_possible = blob.len().saturating_sub(cursor) / MIN_ENCODED_OUTPUT_LEN; + if count > max_possible { + return Err(err(format!( + "recipients blob declares {count} outputs but holds at most {max_possible}" + ))); + } + let mut outputs = Vec::new(); + outputs + .try_reserve_exact(count) + .map_err(|e| err(format!("cannot allocate {count} recipient outputs: {e}")))?; for _ in 0..count { let addr_len = read_u32(blob, &mut cursor)? as usize; - let end = cursor + addr_len; - if end > blob.len() { - return Err(err("truncated recipients blob (address)".to_string())); - } + let end = cursor + .checked_add(addr_len) + .filter(|e| *e <= blob.len()) + .ok_or_else(|| err("truncated recipients blob (address)".to_string()))?; let addr_str = std::str::from_utf8(&blob[cursor..end]) .map_err(|e| err(format!("recipient address is not valid UTF-8: {e}")))?; cursor = end; @@ -82,8 +103,8 @@ fn decode_payment_outputs( Ok(outputs) } -/// Build and sign a standard L1 payment from the wallet's signable funds -/// accounts (union coin selection) and return the signed bytes + fee + change. +/// Build and sign a standard L1 payment from ONE of the wallet's signable funds +/// accounts and return the signed bytes + fee + change. /// /// * `handle` — a core-wallet handle (`platform_wallet_get_core`). /// * `outputs_blob`/`outputs_blob_len` — the recipients, encoded as documented @@ -91,6 +112,14 @@ fn decode_payment_outputs( /// * `fee_per_kb` — fee rate in duffs/kB, or `0` for the default (1000). /// * `core_signer_handle` — the caller's `MnemonicResolverHandle`; ownership is /// retained by the caller (this function does NOT destroy it). +/// * `funding_path_ptr`/`funding_path_len` — an optional UTF-8 BIP32 +/// derivation-path string (e.g. `"m/44'/5'/0'"`) naming the SINGLE funds +/// account whose UTXOs fund the payment (dashpay/platform#4184). Pass +/// `null` / `0` for the default — the unmixed BIP44 account. Pass an explicit +/// account-level path (e.g. the DIP-9 CoinJoin account path) to spend +/// previously-mixed coins deliberately. There is no union across accounts and +/// no consent gate: exactly one funding source participates, and if it cannot +/// cover the payment the call fails with the typed insufficient-funds code. /// * `out_tx_bytes`/`out_tx_len` — receive the consensus-serialized signed /// transaction. Free with [`core_wallet_free_payment_bytes`]. /// * `out_fee` — receives the fee paid, in duffs. @@ -99,7 +128,9 @@ fn decode_payment_outputs( /// /// # Safety /// All pointers must be valid; `outputs_blob` must be readable for -/// `outputs_blob_len` bytes; the out-pointers must be writable. +/// `outputs_blob_len` bytes; `funding_path_ptr`, when non-null, must point to +/// `funding_path_len` readable bytes for the duration of the call; the +/// out-pointers must be writable. #[no_mangle] #[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn core_wallet_build_signed_payment( @@ -108,6 +139,8 @@ pub unsafe extern "C" fn core_wallet_build_signed_payment( outputs_blob_len: usize, fee_per_kb: u64, core_signer_handle: *mut MnemonicResolverHandle, + funding_path_ptr: *const u8, + funding_path_len: usize, out_tx_bytes: *mut *mut u8, out_tx_len: *mut usize, out_fee: *mut u64, @@ -120,6 +153,11 @@ pub unsafe extern "C" fn core_wallet_build_signed_payment( check_ptr!(out_fee); check_ptr!(out_change); + let funding_path = match parse_optional_derivation_path(funding_path_ptr, funding_path_len) { + Ok(p) => p, + Err(result) => return result, + }; + let blob = std::slice::from_raw_parts(outputs_blob, outputs_blob_len); let signer_addr = core_signer_handle as usize; let fee = if fee_per_kb == 0 { @@ -131,6 +169,7 @@ pub unsafe extern "C" fn core_wallet_build_signed_payment( let option = CORE_WALLET_STORAGE.with_item(handle, |wallet| { let network = wallet.network(); let outputs = decode_payment_outputs(blob, network)?; + let funding_path = funding_path.clone(); let wallet_id = wallet.wallet_id(); // SAFETY: `signer_addr` came from `core_signer_handle`, which the caller // pinned alive for this call; the `MnemonicResolverCoreSigner` lives @@ -140,7 +179,7 @@ pub unsafe extern "C" fn core_wallet_build_signed_payment( wallet_id, network, ); - runtime().block_on(wallet.build_signed_payment(outputs, fee, &signer)) + runtime().block_on(wallet.build_signed_payment(outputs, fee, &signer, funding_path)) }); let result = unwrap_option_or_return!(option); diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 359a85d1cfa..06301aad680 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -444,7 +444,17 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::AddressNonceMismatch { .. } => { PlatformWalletFFIResultCode::ErrorAddressNonceMismatch } - PlatformWalletError::CoreInsufficientFunds { .. } => { + // Both Core-send selector shortfalls share code 22: the atomic + // builder's (`CoreInsufficientFunds`) and the one-shot signed-payment + // primitive's (`PaymentInsufficientFunds`). Without this second arm + // the payment shortfall flattened to `ErrorUnknown`, so the typed + // `available`/`required` amounts `build_signed_payment` computes + // never reached the host as an actionable code — and after the + // dashpay/platform#4184 re-scope those amounts are SINGLE-ACCOUNT + // figures the host must be able to act on (the signal is "pick a + // different funding account", not "retry the same one"). + PlatformWalletError::CoreInsufficientFunds { .. } + | PlatformWalletError::PaymentInsufficientFunds { .. } => { PlatformWalletFFIResultCode::ErrorCoreInsufficientFunds } PlatformWalletError::AssetLockNotTracked(..) => { diff --git a/packages/rs-platform-wallet-ffi/src/utils.rs b/packages/rs-platform-wallet-ffi/src/utils.rs index bf84c88170e..9d62627bb41 100644 --- a/packages/rs-platform-wallet-ffi/src/utils.rs +++ b/packages/rs-platform-wallet-ffi/src/utils.rs @@ -2,6 +2,44 @@ use crate::error::*; use crate::{check_ptr, unwrap_result_or_return}; use std::os::raw::{c_char, c_uchar}; +/// Decode an OPTIONAL BIP32 derivation-path string from a raw `(ptr, len)` pair +/// over the C ABI — the shared `funding_path` decoder for every entry point +/// that names a SINGLE funding account (dashpay/platform#4184). +/// +/// A null pointer or zero length is `None` (the default: fund from the unmixed +/// BIP44 account). Otherwise the bytes are parsed as a UTF-8 BIP32 path (e.g. +/// `"m/44'/5'/0'"`); invalid UTF-8 or a malformed path is a hard +/// `ErrorInvalidParameter` — never a silent fallback to the default account, +/// which would fund the transaction from coins the caller did not choose. +/// +/// # Safety +/// `ptr`, when non-null, must point to `len` readable bytes for the duration of +/// the call. +pub(crate) unsafe fn parse_optional_derivation_path( + ptr: *const u8, + len: usize, +) -> Result, PlatformWalletFFIResult> { + use std::str::FromStr; + if ptr.is_null() || len == 0 { + return Ok(None); + } + let bytes = std::slice::from_raw_parts(ptr, len); + let text = std::str::from_utf8(bytes).map_err(|e| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("funding_path is not valid UTF-8: {e}"), + ) + })?; + key_wallet::bip32::DerivationPath::from_str(text) + .map(Some) + .map_err(|e| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("invalid funding_path derivation path {text:?}: {e}"), + ) + }) +} + /// RAII guard that scrubs a `secp256k1::SecretKey`'s scalar on drop. `from_slice` /// allocates a 32-byte scalar copy of the caller's private key, and `SecretKey` /// has no `Drop` wipe of its own — so without this the copy would survive on the diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index a7c4dcba2db..db455e9ca7a 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -333,6 +333,94 @@ pub(crate) async fn funded_coinjoin_wallet_manager() -> ( (Arc::new(RwLock::new(wm)), wallet_id, generation, signer) } +/// Builds a testnet wallet manager whose balance is split across TWO privacy +/// domains: `bip44_duffs` on BIP44 account 0 and `coinjoin_duffs` on the DIP-9 +/// CoinJoin account 0. Lets the funding-domain tests prove that coin selection +/// never crosses from one account into the other. +/// +/// Returns the manager, the wallet id, and a soft signer over the wallet's seed +/// (which can derive keys for BOTH accounts, so per-account signing can be +/// exercised end-to-end). +#[cfg(test)] +pub(crate) async fn split_funded_wallet_manager( + bip44_duffs: u64, + coinjoin_duffs: u64, +) -> ( + Arc>>, + WalletId, + WalletSigner, +) { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait as _; + + let mut ctx = TestWalletContext::new_random(); + + // Fund BIP44 account 0 (the default funding account) at its pre-derived + // receive address. + let bip44_tx = Transaction::dummy(&ctx.receive_address, 0..1, &[bip44_duffs]); + let bip44_result = ctx + .check_transaction( + &bip44_tx, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 1, + BlockHash::all_zeros(), + 1_700_000_000, + )), + ) + .await; + assert!( + bip44_result.is_relevant && bip44_result.is_new_transaction, + "BIP44 funding tx should be recognized" + ); + + // Derive a fresh CoinJoin receive address (registering it in the CoinJoin + // pool so the checker recognizes the funding), then fund CoinJoin account 0. + let coinjoin_xpub = ctx + .wallet + .get_coinjoin_account(0) + .expect("default wallet has CoinJoin account 0") + .account_xpub; + // CoinJoin is a single-pool (non-standard) account, so it derives via + // `next_address` rather than `next_receive_address`. + let coinjoin_address = ctx + .managed_wallet + .first_coinjoin_managed_account_mut() + .expect("default wallet has a managed CoinJoin account 0") + .next_address(Some(&coinjoin_xpub), true) + .expect("CoinJoin receive address"); + let coinjoin_tx = Transaction::dummy(&coinjoin_address, 0..1, &[coinjoin_duffs]); + let coinjoin_result = ctx + .check_transaction( + &coinjoin_tx, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 2, + BlockHash::all_zeros(), + 1_700_000_100, + )), + ) + .await; + assert!( + coinjoin_result.is_relevant && coinjoin_result.is_new_transaction, + "CoinJoin funding tx should be recognized" + ); + + let signer = WalletSigner { + wallet: ctx.wallet.clone(), + }; + + let balance = Arc::new(WalletBalance::new()); + let info = PlatformWalletInfo { + core_wallet: ctx.managed_wallet, + 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, 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/send.rs b/packages/rs-platform-wallet/src/wallet/core/send.rs index 4d74fa37fc7..f4d4e3a554b 100644 --- a/packages/rs-platform-wallet/src/wallet/core/send.rs +++ b/packages/rs-platform-wallet/src/wallet/core/send.rs @@ -1,11 +1,19 @@ //! General Core L1 payment building. //! //! [`CoreWallet::build_signed_payment`] is the first-class "send" primitive: -//! it selects inputs across every **signable** funds account, builds and signs +//! it selects inputs from **one** caller-named funds account, builds and signs //! a standard payment transaction, and returns the **signed serialized bytes** //! plus the computed fee and change amount — WITHOUT broadcasting and WITHOUT //! persisting a debit. //! +//! ## Funding-domain isolation +//! +//! Selection is confined to a single funding account, defaulting to the unmixed +//! BIP44 account — never a union across accounts. See +//! [`crate::wallet::funding_privacy`] for the invariant, the +//! dashpay/platform#4073 → #4184 history behind it, and the guardrail that +//! enforces it. +//! //! ## Why build-only / no-broadcast //! //! During the dashj→SDK transition the Android app keeps its own transaction @@ -21,9 +29,14 @@ //! Building does **not** persist a debit and does not write UTXOs, balances, or //! transaction records back to the wallet. The only in-memory mutation is the //! key-wallet `ReservationSet` bookkeeping that `set_funding` + -//! `TransactionBuilder::build_signed` perform on the primary funding account: -//! the selected inputs are marked *reserved* so a concurrent SDK build does not -//! re-select the same coins. That reservation is in-memory only (never +//! `TransactionBuilder::build_signed` perform on the **selected** funding +//! account: the selected inputs are marked *reserved* so a concurrent SDK build +//! does not re-select the same coins. Because selection is confined to one +//! account, every selected input is reserved in the ledger that all funding +//! paths consult for that account — there are no unreserved "secondary-account" +//! inputs (dashpay/platform#4247 review finding, now structurally impossible). +//! +//! That reservation is in-memory only (never //! serialized) and is released when the spend is later processed back into the //! wallet by sync, or by the reservation-TTL backstop, or explicitly via //! [`ManagedCoreFundsAccount::release_reservation`] for an abandoned build. No @@ -33,31 +46,51 @@ //! [`ManagedCoreFundsAccount::release_reservation`]: //! key_wallet::managed_account::ManagedCoreFundsAccount::release_reservation -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use dashcore::{Address as DashAddress, OutPoint, Transaction}; +use key_wallet::bip32::DerivationPath; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::managed_account::ManagedCoreFundsAccount; -use key_wallet::ManagedAccountType; use key_wallet::signer::Signer; use key_wallet::wallet::managed_wallet_info::coin_selection::{SelectionError, SelectionStrategy}; use key_wallet::wallet::managed_wallet_info::fee::FeeRate; use key_wallet::wallet::managed_wallet_info::transaction_builder::{BuilderError, TransactionBuilder}; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; -use key_wallet::Utxo; use crate::broadcaster::TransactionBroadcaster; use crate::error::PlatformWalletError; use crate::wallet::core::CoreWallet; +use crate::wallet::funding_privacy::is_signable_funding_account; /// key-wallet's default fee rate (duffs per kB). Matches the asset-lock /// builder's `DEFAULT_FEE_PER_KB` and `FeeRate::normal()`. const DEFAULT_FEE_PER_KB: u64 = 1000; -/// The BIP44 account that supplies the change output (and whose reservation -/// ledger gates concurrent primary-account builds). The union of every other -/// signable funds account is added as explicit inputs on top of it. -const PRIMARY_BIP44_ACCOUNT_INDEX: u32 = 0; +/// Consensus cap on any single amount this primitive will accept or aggregate. +const MAX_MONEY: u64 = dashcore::blockdata::constants::MAX_MONEY; + +/// Upper bound on the caller-supplied fee rate, in duffs/kB. +/// +/// Derived so that even a maximum-size standard transaction cannot produce a +/// fee above [`MAX_MONEY`]: Dash's standard-transaction limit is 100_000 bytes, +/// i.e. 100 kB, and `FeeRate::calculate_fee` computes +/// `sat_per_kb * size_bytes / 1000` — so `MAX_MONEY / 100` also keeps the +/// intermediate `sat_per_kb * size_bytes` product (≤ 2.1e18) inside `u64`. +const MAX_FEE_PER_KB: u64 = MAX_MONEY / 100; + +/// The unmixed BIP44 account this primitive is pinned to, in both of its roles: +/// +/// * the **default funding account** — where `funding_path: None` selects from; +/// * the **change sink** — key-wallet derives change addresses only for +/// *Standard* accounts, so a payment funded from an explicitly-named +/// non-Standard account (CoinJoin / DashPay-receiving) must route change +/// here. See [`crate::wallet::funding_privacy`]. +/// +/// Account 0 rather than a caller-chosen index: the transition-era send path +/// has exactly one BIP44 account, and the asset-lock builder's `account_index` +/// serves the same pinned role there. +const BIP44_ACCOUNT_INDEX: u32 = 0; /// A built-and-signed Core L1 payment, ready to be committed/broadcast by the /// caller (dashj during the transition, or a later SDK-broadcast mode). @@ -76,45 +109,44 @@ pub struct SignedCorePayment { pub change_amount: u64, } -/// True for funds accounts the bound wallet cannot sign for. Only -/// `DashpayExternalAccount`s are watch-only: they hold a *contact's* receiving -/// addresses (we keep the contact's xpub to build payments *to* them and to -/// watch that side), so their UTXOs must never be selected as spend inputs — -/// signing would fail because no private key of ours derives them. Every other -/// funds account (BIP44/BIP32/CoinJoin/DashPay receiving) is derived from our -/// own seed and is signable. -fn is_watch_only_funds_account(account: &ManagedCoreFundsAccount) -> bool { - matches!( - account.managed_account_type(), - ManagedAccountType::DashpayExternalAccount { .. } - ) -} - impl CoreWallet { /// Build and sign a standard Core L1 payment to `outputs`, funding it from - /// the union of every **signable** funds account, and return the signed - /// transaction plus its fee and change amount. Does **not** broadcast and - /// does **not** persist a debit (see the module docs for the persistence + /// the **single** funds account named by `funding_path`, and return the + /// signed transaction plus its fee and change amount. Does **not** broadcast + /// and does **not** persist a debit (see the module docs for the persistence /// contract). /// - /// ## Coin selection — union of signable accounts + /// ## Coin selection — one account, never a union + /// + /// Inputs come from exactly one funds account: `None` (the default) funds + /// from the unmixed BIP44 account at [`BIP44_ACCOUNT_INDEX`], and + /// `Some(path)` funds strictly from the one funds account whose + /// account-level derivation path equals `path` (e.g. the DIP-9 CoinJoin + /// account, to spend previously-mixed coins deliberately). There is **no + /// union across accounts and no privacy-domain consent gate** — the caller + /// names exactly one funding source, so there is nothing to consent to. If + /// that account cannot cover the payment (+ fee) the build fails with + /// [`PlatformWalletError::PaymentInsufficientFunds`] rather than silently + /// topping up from another account; that failure is the point, not a + /// limitation. See [`crate::wallet::funding_privacy`] for why + /// (dashpay/platform#4073, blocked and re-scoped by #4184). /// - /// Inputs are selected across BIP44 + BIP32 + CoinJoin + DashPay-receiving - /// accounts (the wallet-wide spendable set), reusing the same union-funding - /// machinery the shielded asset-lock path uses - /// ([`AssetLockManager::build_asset_lock_tx_from_all_funding_accounts`]): - /// BIP44 account 0 is the PRIMARY account (it supplies the change output and - /// its reservation ledger gates concurrent primary-account builds), and the - /// spendable UTXOs of every other signable account are added as explicit - /// builder inputs. Watch-only `DashpayExternalAccount`s are excluded — their - /// coins belong to a contact and cannot be signed by this wallet. + /// Watch-only `DashpayExternalAccount`s can never fund a payment — their + /// coins belong to a contact and the local mnemonic holds no key for + /// them — so naming one explicitly is refused rather than silently ignored. + /// + /// Change routes to the BIP44 account at [`BIP44_ACCOUNT_INDEX`], which for + /// the default funding path is the funding account itself. When an explicit + /// non-Standard account (CoinJoin / DashPay-receiving) funds the payment, + /// key-wallet cannot derive change on it at all, so the BIP44 sink is + /// structural — the same change model the asset-lock builder uses. /// /// `LargestFirst` selection is used deliberately (not the builder default /// `BranchAndBound`): a CoinJoin account can hold many small mixed /// denominations, and `BranchAndBound`'s exact-match subset-sum is - /// exponential over them (the same hang the asset-lock union path avoids). - /// `LargestFirst`'s linear greedy accumulator also minimizes the input - /// count — fewer signer round-trips and a smaller tx/fee. + /// exponential over them. `LargestFirst`'s linear greedy accumulator also + /// minimizes the input count — fewer signer round-trips and a smaller + /// tx/fee. /// /// ## Parameters /// @@ -125,14 +157,15 @@ impl CoreWallet { /// * `signer` — the ECDSA signer that produces each input's P2PKH signature /// (the Keychain/Keystore-backed `MnemonicResolverCoreSigner` in /// production). No private key crosses the boundary. - /// - /// [`AssetLockManager::build_asset_lock_tx_from_all_funding_accounts`]: - /// crate::wallet::asset_lock + /// * `funding_path` — the account-level derivation path of the SINGLE funds + /// account whose UTXOs fund the payment. `None` (the default) funds from + /// the unmixed BIP44 account (dashpay/platform#4184). pub async fn build_signed_payment( &self, outputs: Vec<(DashAddress, u64)>, fee_per_kb: Option, signer: &S, + funding_path: Option, ) -> Result { if outputs.is_empty() { return Err(PlatformWalletError::TransactionBuild( @@ -144,7 +177,37 @@ impl CoreWallet { "every output amount must be greater than zero".to_string(), )); } - let outputs_total: u64 = outputs.iter().map(|(_, amount)| *amount).sum(); + + // Checked aggregation, bounded by MAX_MONEY. key-wallet sums the same + // amounts with unchecked `u64` arithmetic while building, so an + // unchecked total here would wrap in release builds (four outputs of + // `1 << 62` sum to exactly 2^64) and let selection fund only the fee + // while retaining four enormous outputs — a signed transaction + // consensus rejects, with meaningless fee/change metadata. In an + // overflow-checking build the same input panics inside the `extern "C"` + // FFI frame, where the JNI guard cannot recover it. + let outputs_total = outputs + .iter() + .try_fold(0u64, |total, (_, amount)| total.checked_add(*amount)) + .filter(|total| *total <= MAX_MONEY) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "output amounts overflow or exceed MAX_MONEY ({MAX_MONEY} duffs)" + )) + })?; + + // Bound the caller-supplied fee rate for the same reason: key-wallet's + // `FeeRate::calculate_fee` computes `sat_per_kb * size_bytes` with + // unchecked `u64` multiplication, so a rate near `u64::MAX` (the public + // Kotlin/FFI APIs accept any non-negative `Long`) panics in an + // overflow-checking Android build, or wraps in release — turning an + // astronomical requested rate into a tiny fee. + let fee_per_kb = fee_per_kb.unwrap_or(DEFAULT_FEE_PER_KB); + if fee_per_kb > MAX_FEE_PER_KB { + return Err(PlatformWalletError::TransactionBuild(format!( + "fee rate {fee_per_kb} duffs/kB exceeds the maximum {MAX_FEE_PER_KB}" + ))); + } let mut wm = self.wallet_manager.write().await; let (wallet, info) = wm @@ -152,81 +215,177 @@ impl CoreWallet { .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; let height = info.core_wallet.last_processed_height(); - let fee_rate = FeeRate::new(fee_per_kb.unwrap_or(DEFAULT_FEE_PER_KB)); + let network = info.core_wallet.network(); + let fee_rate = FeeRate::new(fee_per_kb); - // The PRIMARY account (change destination). Clone the xpub-bearing - // account so no immutable borrow of `wallet` is held across the mutable - // `info` borrow / signer await below. - let primary_account = wallet - .get_bip44_account(PRIMARY_BIP44_ACCOUNT_INDEX) + // ------------------------------------------------------------------ + // REGRESSION NOTE (dashpay/platform#4073 → #4184 → #4247) + // + // This selection block previously unioned every signable funds account + // and ran LargestFirst over the combined set, with BIP44 change — which + // irreversibly links ordinary, CoinJoin, and DashPay-receiving coins in + // one on-chain transaction. Reviewer shumkov blocked exactly that on + // PR #4184 (2026-07-21); the single-selected-account redesign (commit + // 4d3e1322bc) was signed off 2026-07-23. + // + // The send-raw-tx code was written on an older integration line BEFORE + // that re-scope, and shipped the blocked union behavior into the general + // send path — with a test asserting the union as correct behavior. A + // compile-clean, review-passed change is NOT sufficient evidence of + // correctness here. + // + // INVARIANT: single selected account; never union funding accounts; + // default unmixed BIP44. See `crate::wallet::funding_privacy` and its + // guardrail tests. + // ------------------------------------------------------------------ + + // Resolve the account-level path of the unmixed BIP44 account: both the + // default funding source and the change sink. + let bip44_path = info + .core_wallet + .accounts + .standard_bip44_accounts + .get(&BIP44_ACCOUNT_INDEX) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "BIP44 account {BIP44_ACCOUNT_INDEX} not found for payment funding" + )) + })? + .managed_account_type() + .to_account_type() + .derivation_path(network) + .map_err(|e| { + PlatformWalletError::TransactionBuild(format!( + "failed to derive the unmixed BIP44 account-level path: {e}" + )) + })?; + let funding_path = funding_path.unwrap_or_else(|| bip44_path.clone()); + let funds_from_change_account = funding_path == bip44_path; + + // The xpub-bearing BIP44 account: the change sink, and the fallback + // signing-side account. Cloned so no immutable borrow of `wallet` is + // held across the mutable `info` borrow below. + let bip44_acc = wallet + .get_bip44_account(BIP44_ACCOUNT_INDEX) .ok_or_else(|| { PlatformWalletError::TransactionBuild(format!( - "BIP44 account {PRIMARY_BIP44_ACCOUNT_INDEX} not found for payment funding" + "BIP44 account {BIP44_ACCOUNT_INDEX} not found for payment change routing" )) })? .clone(); - // Snapshot the primary account's spendable outpoints so the union sweep - // does not double-add them: `set_funding` already seeds them, and - // `add_inputs` must contribute only the OTHER signable accounts. - let primary_outpoints: HashSet = info - .core_wallet - .accounts - .standard_bip44_accounts - .get(&PRIMARY_BIP44_ACCOUNT_INDEX) - .map(|a| { - a.spendable_utxos(height) - .into_iter() - .map(|u| u.outpoint) - .collect() + // Derive an explicit BIP44 change address ONLY when the funding account + // is not the BIP44 sink itself: `set_funding` already derives change on + // the funding account, which is correct (and consumes no extra pool + // index) in the default case, but fails and is swallowed to `None` for a + // non-Standard CoinJoin / DashPay account. Taken before the funding + // account's `&mut` below — two accounts of the same collection cannot + // both be borrowed mutably at once. + let change_addr: Option = if funds_from_change_account { + None + } else { + let change_acc = info + .core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&BIP44_ACCOUNT_INDEX) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "managed BIP44 account {BIP44_ACCOUNT_INDEX} not found for payment \ + change routing" + )) + })?; + Some( + change_acc + .next_change_address(Some(&bip44_acc.account_xpub), true) + .map_err(|e| { + PlatformWalletError::TransactionBuild(format!( + "failed to derive change address on BIP44 account \ + {BIP44_ACCOUNT_INDEX}: {e}" + )) + })?, + ) + }; + + // The funding account's OWN wallet-level `Account`. `set_funding` calls + // `funds_acc.next_change_address(Some(&acc.account_xpub))` before the + // `set_change_address` override, so `acc` must be the funding account — + // passing the BIP44 xpub for an explicitly-selected BIP32 account would + // record a change entry derived from the wrong xpub into that account's + // pool (dashpay/platform#4184 review). Falls back to `bip44_acc` when no + // wallet-level account matches, preserving the default behavior. + let funding_wallet_acc = wallet + .all_accounts() + .into_iter() + .find(|a| { + a.derivation_path() + .map(|p| p == funding_path) + .unwrap_or(false) }) - .unwrap_or_default(); + .unwrap_or(&bip44_acc); - // Single immutable pass over every signable funds account, building: - // (a) an owned `Address -> DerivationPath` resolver spanning all - // signable inputs, so signing resolves a key for an input drawn - // from any account; - // (b) the explicit extra inputs (all signable non-primary accounts); - // (c) an `OutPoint -> value` map for the post-build change figure; - // (d) the total selectable value, for a typed shortfall error. - let mut path_map: HashMap = HashMap::new(); - let mut input_value: HashMap = HashMap::new(); - let mut extra_inputs: Vec = Vec::new(); - let mut selectable_value: u64 = 0; - for account in info.core_wallet.accounts.all_funding_accounts() { - if is_watch_only_funds_account(account) { + // Locate the ONE managed funds account whose account-level path equals + // `funding_path`, MUTABLY, so `set_funding` reserves the selected inputs + // in that account's OWN reservation ledger. Watch-only + // `DashpayExternalAccount`s are never fundable (the local mnemonic + // cannot sign them) — refuse even when named explicitly. + // + // PRIVACY-DOMAIN-OK: this iterates funds accounts only to LOOK ONE UP by + // derivation path. Exactly one account is selected and it alone funds + // the transaction; nothing is accumulated across accounts. + let mut selected: Option<&mut ManagedCoreFundsAccount> = None; + for acc in info.core_wallet.accounts.all_funding_accounts_mut() { + let acc_path = acc + .managed_account_type() + .to_account_type() + .derivation_path(network) + .map_err(|e| { + PlatformWalletError::TransactionBuild(format!( + "failed to derive account-level path for a funds account: {e}" + )) + })?; + if acc_path != funding_path { continue; } - for utxo in account.spendable_utxos(height) { - selectable_value = selectable_value.saturating_add(utxo.value()); - input_value.insert(utxo.outpoint, utxo.value()); - if let Some(path) = account.address_derivation_path(&utxo.address) { - path_map.insert(utxo.address.clone(), path); - } - if !primary_outpoints.contains(&utxo.outpoint) { - extra_inputs.push(utxo.clone()); - } + if !is_signable_funding_account(acc.managed_account_type()) { + return Err(PlatformWalletError::TransactionBuild(format!( + "funding derivation path {funding_path} names a watch-only account whose \ + coins the local wallet cannot sign; choose a signable funds account" + ))); + } + selected = Some(acc); + break; + } + let selected = selected.ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "no spendable funds account matches funding derivation path {funding_path}" + )) + })?; + + // One immutable pass over the SELECTED account, building: + // (a) an owned `Address -> DerivationPath` resolver, so signing can + // resolve a key for every selected input without holding an + // account borrow across the signer await; + // (b) an `OutPoint -> value` map for the post-build fee/change figures; + // (c) the account's selectable total, for a typed shortfall error. + let mut path_map: HashMap = HashMap::new(); + let mut input_value: HashMap = HashMap::new(); + let mut selectable_value: u64 = 0; + for utxo in selected.spendable_utxos(height) { + selectable_value = selectable_value.saturating_add(utxo.value()); + input_value.insert(utxo.outpoint, utxo.value()); + if let Some(path) = selected.address_derivation_path(&utxo.address) { + path_map.insert(utxo.address.clone(), path); } } - // Seed the primary account (inputs + change address + reservations), - // append the union of the other signable accounts' inputs, then add the - // real recipient outputs. The `&mut` borrow of the primary account is - // scoped to this block; the returned builder owns cloned inputs / - // reservations / change address, so no account borrow is held across - // the signer await below. + // Seed the selected account (inputs + reservations + its own change + // address), override the change sink when the funding account cannot + // derive change, then add the recipient outputs. The `&mut` borrow ends + // with `set_funding`; the returned builder owns cloned inputs / + // reservations / change address, so no account borrow is held across the + // signer await below. let builder = { - let primary_funds = info - .core_wallet - .accounts - .standard_bip44_accounts - .get_mut(&PRIMARY_BIP44_ACCOUNT_INDEX) - .ok_or_else(|| { - PlatformWalletError::TransactionBuild(format!( - "managed BIP44 account {PRIMARY_BIP44_ACCOUNT_INDEX} not found for \ - payment funding" - )) - })?; let mut builder = TransactionBuilder::new() .set_fee_rate(fee_rate) .set_current_height(height) @@ -234,8 +393,10 @@ impl CoreWallet { // BranchAndBound, to keep CoinJoin's many small denominations // from blowing up the exact-match subset-sum search. .set_selection_strategy(SelectionStrategy::LargestFirst) - .set_funding(primary_funds, &primary_account) - .add_inputs(extra_inputs); + .set_funding(selected, funding_wallet_acc); + if let Some(addr) = change_addr { + builder = builder.set_change_address(addr); + } for (address, amount) in &outputs { builder = builder.add_output(address, *amount); } @@ -257,7 +418,7 @@ impl CoreWallet { // // `total_out` is the sum of every output; the only non-recipient output // a plain payment (no special payload) can carry is the single change - // output back to the primary account, so `change = total_out − outputs`. + // output back to the BIP44 sink, so `change = total_out − outputs`. // Any selected input we somehow can't price (impossible — every // spendable UTXO was recorded above) counts as 0, so `fee` is over- // rather than under-reported. @@ -280,33 +441,36 @@ impl CoreWallet { /// Map a key-wallet [`BuilderError`] to a [`PlatformWalletError`], promoting the /// two shortfall shapes to the typed [`PlatformWalletError::PaymentInsufficientFunds`] -/// so the exact `available`/`required` duff amounts survive. The builder's own -/// `InsufficientFunds` figures cover only what the primary-account selector saw, -/// so we substitute the union-wide selectable total (`available`) and the -/// outputs-plus-fee-ish target — `required` is at least the outputs total; a -/// coin-selection error already carries the fee-inclusive figure, which we -/// prefer when present. +/// so the exact `available`/`required` duff amounts survive. +/// +/// `available` is the **selected account's** spendable total, deliberately — +/// never a wallet-wide figure. Reporting a wallet-wide "available" against a +/// single-account shortfall would invite the caller to retry with a larger +/// amount that can only succeed by crossing privacy domains, which this +/// primitive will not do (see [`crate::wallet::funding_privacy`]). `required` is +/// at least the outputs total; a coin-selection error already carries the +/// fee-inclusive figure, which we prefer when present. fn map_send_builder_error( error: BuilderError, - union_available: u64, + available_in_account: u64, outputs_total: u64, ) -> PlatformWalletError { match error { BuilderError::InsufficientFunds { required, .. } => { PlatformWalletError::PaymentInsufficientFunds { - available: union_available, + available: available_in_account, required: required.max(outputs_total), } } BuilderError::CoinSelection(SelectionError::InsufficientFunds { required, .. }) => { PlatformWalletError::PaymentInsufficientFunds { - available: union_available, + available: available_in_account, required: required.max(outputs_total), } } BuilderError::CoinSelection(SelectionError::NoUtxosAvailable) => { PlatformWalletError::PaymentInsufficientFunds { - available: union_available, + available: available_in_account, required: outputs_total, } } @@ -323,6 +487,7 @@ mod tests { use dashcore::{Address as DashAddress, Network, OutPoint, TxOut, Txid}; use key_wallet::account::account_type::StandardAccountType; use key_wallet::account::AccountType; + use key_wallet::bip32::DerivationPath; use key_wallet::managed_account::ManagedCoreFundsAccount; use key_wallet::Utxo; @@ -369,6 +534,40 @@ mod tests { } } + /// Snapshot the BIP44 and CoinJoin outpoints of a split fixture, plus the + /// CoinJoin account's account-level derivation path (the `funding_path` a + /// caller passes to spend previously-mixed coins deliberately). + async fn split_account_outpoints_and_coinjoin_path( + wm: &Arc>>, + wallet_id: &WalletId, + ) -> (HashSet, HashSet, DerivationPath) { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + + let guard = wm.read().await; + let (_, info) = guard.get_wallet_and_info(wallet_id).expect("wallet present"); + let network = info.core_wallet.network(); + let bip44 = info + .core_wallet + .accounts + .standard_bip44_accounts + .get(&0) + .map(|a| a.utxos.keys().copied().collect()) + .unwrap_or_default(); + let coinjoin_acc = info + .core_wallet + .accounts + .coinjoin_accounts + .get(&0) + .expect("coinjoin account 0 present"); + let coinjoin = coinjoin_acc.utxos.keys().copied().collect(); + let path = coinjoin_acc + .managed_account_type() + .to_account_type() + .derivation_path(network) + .expect("coinjoin account-level path"); + (bip44, coinjoin, path) + } + /// A single-account BIP44 payment: the recipient output is present with the /// exact value, a fee is charged, and the change amount is exactly /// selected_input − output − fee (here the whole 0.1 DASH rides on one @@ -382,7 +581,7 @@ mod tests { let to = recipient(42); let amount = 1_000_000u64; let payment = core - .build_signed_payment(vec![(to.clone(), amount)], None, &signer) + .build_signed_payment(vec![(to.clone(), amount)], None, &signer, None) .await .expect("build should succeed with 0.1 DASH funded"); @@ -417,40 +616,102 @@ mod tests { assert_all_inputs_signed(&payment); } - /// Coin selection spans the UNION of signable funds accounts: a payment - /// that exceeds either the BIP44 slice or the CoinJoin slice alone pulls - /// inputs from BOTH, and every mixed-account input is signed. + /// **Replaces `payment_funds_from_bip44_and_coinjoin_union`**, which asserted + /// the blocked union behavior as correct (dashpay/platform#4247; see the + /// regression note in `build_signed_payment`). + /// + /// The DEFAULT funding path must never select CoinJoin (or any other + /// non-BIP44 domain) coins, even when BIP44 alone cannot cover the payment. + /// Failing is the correct outcome: a shortfall is reported as a typed error + /// rather than silently satisfied by crossing a privacy domain, because the + /// cross-domain link would be irreversible while the failure is merely + /// retryable with an explicit `funding_path`. #[tokio::test] - async fn payment_funds_from_bip44_and_coinjoin_union() { - // 0.09 DASH on BIP44, 0.09 on CoinJoin; ask 0.15 → needs both. + async fn default_funding_never_selects_other_domains() { + // 0.09 DASH on BIP44, 0.09 on CoinJoin; ask 0.15 → only a union covers it. let (wm, wallet_id, signer) = split_funded_wallet_manager(9_000_000, 9_000_000).await; + let core = core_wallet(wm, wallet_id, Arc::new(WalletBalance::new())); - // Snapshot each account's outpoints before building. - let (bip44_ops, coinjoin_ops): (HashSet, HashSet) = { - let guard = wm.read().await; - let (_, info) = guard.get_wallet_and_info(&wallet_id).expect("wallet present"); - let bip44 = info - .core_wallet - .accounts - .standard_bip44_accounts - .get(&0) - .map(|a| a.utxos.keys().copied().collect()) - .unwrap_or_default(); - let coinjoin = info - .core_wallet - .accounts - .coinjoin_accounts - .get(&0) - .map(|a| a.utxos.keys().copied().collect()) - .unwrap_or_default(); - (bip44, coinjoin) - }; + let result = core + .build_signed_payment(vec![(recipient(7), 15_000_000)], None, &signer, None) + .await; + + match result { + Err(PlatformWalletError::PaymentInsufficientFunds { + available, + required, + }) => { + assert_eq!( + available, 9_000_000, + "available must reflect ONLY the BIP44 account, never the \ + wallet-wide union" + ); + assert!( + required >= 15_000_000, + "required {required} should be at least the requested amount" + ); + } + other => panic!( + "the default path must not union BIP44 with CoinJoin — expected \ + PaymentInsufficientFunds, got {other:?}" + ), + } + } + + /// The default path funds happily from BIP44 when BIP44 alone suffices, and + /// still leaves the CoinJoin coins untouched. + #[tokio::test] + async fn default_funding_selects_strictly_within_bip44() { + // 0.2 DASH on BIP44, 0.09 on CoinJoin; ask 0.15 → BIP44 alone covers it. + let (wm, wallet_id, signer) = split_funded_wallet_manager(20_000_000, 9_000_000).await; + let (bip44_ops, coinjoin_ops, _) = + split_account_outpoints_and_coinjoin_path(&wm, &wallet_id).await; + + let core = core_wallet(wm, wallet_id, Arc::new(WalletBalance::new())); + let payment = core + .build_signed_payment(vec![(recipient(7), 15_000_000)], None, &signer, None) + .await + .expect("0.15 DASH is fundable from the 0.2 DASH BIP44 account"); + + let spent: HashSet = payment + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + assert!( + spent.iter().all(|op| bip44_ops.contains(op)), + "every input must come from BIP44, spent {spent:?}" + ); + assert!( + !spent.iter().any(|op| coinjoin_ops.contains(op)), + "the default path must never reach CoinJoin coins, spent {spent:?}" + ); + assert_all_inputs_signed(&payment); + } + + /// An explicitly-passed CoinJoin path selects strictly from that account and + /// nothing else — the caller-consented, single-domain half of the #4184 + /// contract. Change still lands on BIP44 because key-wallet cannot derive a + /// change address on a non-Standard account; that is structural, not a + /// co-spend. + #[tokio::test] + async fn explicit_coinjoin_path_selects_only_coinjoin() { + // 0.09 DASH on BIP44 (short), 0.2 on CoinJoin; take 0.15 from CoinJoin. + let (wm, wallet_id, signer) = split_funded_wallet_manager(9_000_000, 20_000_000).await; + let (bip44_ops, coinjoin_ops, coinjoin_path) = + split_account_outpoints_and_coinjoin_path(&wm, &wallet_id).await; let core = core_wallet(wm, wallet_id, Arc::new(WalletBalance::new())); let payment = core - .build_signed_payment(vec![(recipient(7), 15_000_000)], None, &signer) + .build_signed_payment( + vec![(recipient(7), 15_000_000)], + None, + &signer, + Some(coinjoin_path), + ) .await - .expect("0.15 DASH must be fundable from the 0.18 DASH union"); + .expect("the named CoinJoin account covers 0.15 DASH"); let spent: HashSet = payment .transaction @@ -458,27 +719,41 @@ mod tests { .iter() .map(|i| i.previous_output) .collect(); + assert!(!spent.is_empty(), "the payment must have selected inputs"); assert!( - spent.iter().any(|op| bip44_ops.contains(op)), - "at least one BIP44 input should be selected" + spent.iter().all(|op| coinjoin_ops.contains(op)), + "every input must come from the named CoinJoin account, spent {spent:?}" ); assert!( - spent.iter().any(|op| coinjoin_ops.contains(op)), - "at least one CoinJoin input should be selected" + !spent.iter().any(|op| bip44_ops.contains(op)), + "an explicit CoinJoin path must not pull BIP44 inputs, spent {spent:?}" + ); + // Change is returned to the transparent BIP44 sink. + assert!( + payment.change_amount > 0, + "spending a 0.2 DASH UTXO for 0.15 DASH must leave change" ); assert_all_inputs_signed(&payment); } - /// A shortfall across the whole signable union surfaces as the typed + /// A shortfall inside the SELECTED account surfaces as the typed /// [`PlatformWalletError::PaymentInsufficientFunds`], with `available` - /// reflecting the union total (not just the primary BIP44 slice). + /// reflecting only that account — never a wallet-wide union total, which + /// would invite a retry that can only succeed by crossing domains. #[tokio::test] - async fn union_shortfall_is_typed() { + async fn selected_account_shortfall_is_typed() { let (wm, wallet_id, signer) = split_funded_wallet_manager(9_000_000, 9_000_000).await; + let (_, _, coinjoin_path) = + split_account_outpoints_and_coinjoin_path(&wm, &wallet_id).await; let core = core_wallet(wm, wallet_id, Arc::new(WalletBalance::new())); let result = core - .build_signed_payment(vec![(recipient(7), 100_000_000)], None, &signer) + .build_signed_payment( + vec![(recipient(7), 100_000_000)], + None, + &signer, + Some(coinjoin_path), + ) .await; match result { @@ -486,9 +761,9 @@ mod tests { available, required, }) => { - assert!( - (9_000_000..=18_000_000).contains(&available), - "available {available} should reflect the union (9M<..<=18M)" + assert_eq!( + available, 9_000_000, + "available must reflect only the named CoinJoin account" ); assert!( required >= 100_000_000, @@ -499,6 +774,32 @@ mod tests { } } + /// A `funding_path` that names no funds account is a hard error — never a + /// silent fallback to the default account, which would fund the payment + /// from coins the caller did not choose. + #[tokio::test] + async fn unknown_funding_path_is_rejected() { + use std::str::FromStr; + + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(wm, wallet_id, balance); + + let nowhere = DerivationPath::from_str("m/44'/5'/77'").expect("valid path"); + let result = core + .build_signed_payment( + vec![(recipient(7), 1_000_000)], + None, + &signer, + Some(nowhere), + ) + .await; + assert!( + matches!(result, Err(PlatformWalletError::TransactionBuild(_))), + "an unmatched funding path must fail, got {result:?}" + ); + } + /// A watch-only `DashpayExternalAccount` (a contact's addresses, which this /// wallet cannot sign) is EXCLUDED from coin selection: its UTXO is never /// spent, and its value is not counted toward the selectable total. @@ -568,10 +869,11 @@ mod tests { let core = core_wallet(wm, wallet_id, Arc::new(WalletBalance::new())); // Ask for 0.5 DASH: covered only if the 1.0-DASH watch-only UTXO were - // spendable. Since it is excluded, the build must fail — and the - // reported `available` must be just the 0.1-DASH BIP44 slice. + // spendable. It is on a different domain from the default BIP44 funding + // path, so the default send can never reach it — the build must fail + // with the 0.1-DASH BIP44 slice as `available`. let result = core - .build_signed_payment(vec![(recipient(7), 50_000_000)], None, &signer) + .build_signed_payment(vec![(recipient(7), 50_000_000)], None, &signer, None) .await; match result { Err(PlatformWalletError::PaymentInsufficientFunds { available, .. }) => { @@ -586,7 +888,7 @@ mod tests { // And a payment that the 0.1-DASH BIP44 slice CAN cover must never spend // the watch-only outpoint. let payment = core - .build_signed_payment(vec![(recipient(7), 1_000_000)], None, &signer) + .build_signed_payment(vec![(recipient(7), 1_000_000)], None, &signer, None) .await .expect("0.01 DASH is fundable from the BIP44 slice alone"); assert!( @@ -608,11 +910,11 @@ mod tests { funded_wallet_manager(StandardAccountType::BIP44Account).await; let core = core_wallet(wm, wallet_id, balance); - let empty = core.build_signed_payment(vec![], None, &signer).await; + let empty = core.build_signed_payment(vec![], None, &signer, None).await; assert!(matches!(empty, Err(PlatformWalletError::TransactionBuild(_)))); let zero = core - .build_signed_payment(vec![(recipient(7), 0)], None, &signer) + .build_signed_payment(vec![(recipient(7), 0)], None, &signer, None) .await; assert!(matches!(zero, Err(PlatformWalletError::TransactionBuild(_)))); } diff --git a/packages/rs-platform-wallet/src/wallet/funding_privacy.rs b/packages/rs-platform-wallet/src/wallet/funding_privacy.rs new file mode 100644 index 00000000000..a665410fb51 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/funding_privacy.rs @@ -0,0 +1,359 @@ +//! **Funding-domain isolation** — the privacy invariant every L1 spend path in +//! this crate must satisfy, plus the automated guardrail that enforces it. +//! +//! # The invariant +//! +//! > A single L1 transaction must draw its funding inputs from **exactly one** +//! > funds account (one derivation domain). No spend path may union across +//! > funding accounts, and never implicitly. +//! +//! A wallet's funds accounts are separate *privacy domains*: ordinary BIP44 / +//! BIP32 coins, DIP-9 **CoinJoin** (previously-mixed) coins, and +//! **DashPay-receiving** coins each carry a different linkability story. Any +//! transaction that spends inputs from two of them publishes, irreversibly and +//! on chain, that the same entity controls both — and shielding those coins +//! afterwards cannot undo the link, because the link is already in the L1 +//! transaction graph. +//! +//! # History — why this module exists +//! +//! * **dashpay/platform#4073** — shielding failed on wallets whose balance sat +//! on the DIP-9 CoinJoin path, because asset-lock coin selection only ever +//! reached BIP44 account 0. +//! * The **first** fix unioned every funding account and ran `LargestFirst` +//! over the combined candidate set. Reviewer `shumkov` **blocked** it on +//! dashpay/platform#4184 (2026-07-21): largest-first over a union can combine +//! ordinary, CoinJoin, and DashPay-receiving coins into one transaction with +//! BIP44 change, irreversibly linking the domains. +//! * **dashpay/platform#4184** (commit `4d3e1322bc`, signed off 2026-07-23) is +//! the approved resolution: a single optional derivation-path parameter that +//! names the ONE funding account, **defaulting to the non-mixed BIP44 +//! account**. No union, and no consent gate — because the caller names +//! exactly one funding source, there is nothing to consent to. +//! +//! The regression this module guards against is real and already happened once: +//! `CoreWallet::build_signed_payment` (dashpay/platform#4247) was written +//! against the pre-re-scope design and shipped the blocked union behavior into +//! the general send path. +//! +//! # How to comply +//! +//! A spend path takes `funding_path: Option`, resolves `None` +//! to the unmixed BIP44 account's account-level path, locates the **one** +//! managed funds account whose account-level path equals it, and points the +//! key-wallet `TransactionBuilder`'s `set_funding` at that account alone. If +//! that account cannot cover the spend, the build **fails** with a typed +//! shortfall — it must never top up from a second account. See +//! [`CoreWallet::build_signed_payment`](crate::wallet::core::CoreWallet::build_signed_payment) +//! for the reference implementation on this branch. +//! +//! Change is the one structural exception, and it is not a co-spend: key-wallet +//! derives change addresses only for *Standard* (BIP44/BIP32) accounts, so a +//! transaction funded from a non-Standard account (CoinJoin / DashPay +//! receiving) must route its change to the BIP44 account. That is inherent to +//! spending those coins at all, happens only when the caller explicitly named +//! the non-default account, and is the behavior #4184 approved. +//! +//! # The guardrail +//! +//! `all_funding_accounts()` / `all_funding_accounts_mut()` live in the pinned +//! `key-wallet` fork, so the invariant cannot be documented at their +//! definition. Instead, [`guardrail::every_union_iteration_is_privacy_reviewed`] +//! scans this crate's own sources and fails if any use of those iterators is +//! not preceded by an explicit `PRIVACY-DOMAIN-OK:` review marker, and +//! [`guardrail::no_spend_entry_point_unions_by_default`] asserts behaviorally +//! that the send path does not cross domains. See the `guardrail` module docs +//! for why the pair is sufficient. + +use key_wallet::ManagedAccountType; + +/// Whether a funds account can be *signed for* by the local mnemonic, and may +/// therefore fund a spend at all. +/// +/// Only `DashpayExternalAccount`s are watch-only: they hold a **contact's** +/// receiving addresses (we keep the contact's xpub to build payments *to* them +/// and to watch that side), so no private key of ours derives their UTXOs and +/// signing them would fail. Every other funds account +/// (BIP44 / BIP32 / CoinJoin / DashPay-receiving) comes from our own seed. +/// +/// This is a **signability** filter, not a privacy filter — it is orthogonal to +/// the module-level funding-domain invariant, and passing it does NOT make an +/// account eligible to be unioned with another. A watch-only account must be +/// refused even when a caller names its derivation path explicitly. +pub(crate) fn is_signable_funding_account(managed_type: &ManagedAccountType) -> bool { + !matches!( + managed_type, + ManagedAccountType::DashpayExternalAccount { .. } + ) +} + +#[cfg(test)] +mod guardrail { + //! Automated enforcement of the funding-domain invariant. + //! + //! Two complementary tests, because either alone has a blind spot: + //! + //! * [`no_spend_entry_point_unions_by_default`] is **behavioral**. It + //! proves, over a wallet whose balance is split across two domains, that + //! the general send entry point cannot fund a transaction neither domain + //! covers alone. This catches a semantic regression in an *existing* + //! entry point even if it is written without ever naming + //! `all_funding_accounts` (e.g. by iterating the account maps directly). + //! Its blind spot: it only knows about the entry points listed in it, so + //! a *newly added* spend path is invisible to it. + //! + //! On this branch (the isolated `send-raw-tx` feature line) the only + //! funding-domain-sensitive spend entry point is + //! [`CoreWallet::build_signed_payment`]: the asset-lock builder here is + //! still the pre-#4184 per-`account_index` model and does not accept a + //! `funding_path`, so it is out of scope for this behavioral test. + //! + //! * [`every_union_iteration_is_privacy_reviewed`] is **static**, and + //! covers exactly that blind spot. `all_funding_accounts()` / + //! `all_funding_accounts_mut()` are the only wallet-wide funds-account + //! iterators key-wallet exposes, so a new unparameterized union spend + //! path essentially has to call one of them. This test fails unless each + //! such call is preceded by an explicit `PRIVACY-DOMAIN-OK:` marker + //! comment, which forces the author to state why the call does not + //! union — and makes the marker show up in the review diff, which is how + //! the #4247 regression should have been caught. + //! + //! Scope is this crate's `src/` on purpose: coin selection happens only + //! here. The FFI / JNI layers above merely forward a `funding_path` and + //! cannot select coins themselves. + + use std::collections::HashSet; + use std::path::{Path, PathBuf}; + use std::sync::Arc; + + use dashcore::{Address as DashAddress, Network, OutPoint}; + + use crate::test_support::{split_funded_wallet_manager, AlwaysRejectedBroadcaster}; + use crate::wallet::core::balance::WalletBalance; + use crate::wallet::core::CoreWallet; + use crate::PlatformWalletError; + + // -- static guard -------------------------------------------------------- + + /// The wallet-wide funds-account iterators. Any use of these in a spend + /// path is a potential cross-domain union. + const UNION_ITERATORS: [&str; 2] = ["all_funding_accounts(", "all_funding_accounts_mut("]; + + /// The marker a call site must carry to certify it was reviewed against the + /// funding-domain invariant. + const REVIEW_MARKER: &str = "PRIVACY-DOMAIN-OK"; + + /// How many lines above a call site the marker may sit. Wide enough for the + /// explanatory comment block a legitimate use needs, narrow enough that one + /// marker cannot silently cover an unrelated call added later. + const MARKER_LOOKBACK_LINES: usize = 15; + + /// Collect every `.rs` file under `dir`, recursively. + fn rust_sources(dir: &Path, out: &mut Vec) { + let entries = std::fs::read_dir(dir) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", dir.display())); + for entry in entries { + let path = entry.expect("readable dir entry").path(); + if path.is_dir() { + rust_sources(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } + } + + /// Every use of key-wallet's wallet-wide funds-account iterators must be + /// explicitly privacy-reviewed. + /// + /// Fails with the offending `file:line` and the invariant restated, so an + /// author who reintroduces an unparameterized `all_funding_accounts()` + /// spend path (the dashpay/platform#4247 regression) is told exactly what + /// rule they tripped and where the contract lives. + /// + /// Comment lines are ignored (prose may name the iterators freely), as is + /// this file — it names them in the constants above. + #[test] + fn every_union_iteration_is_privacy_reviewed() { + let src = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = Vec::new(); + rust_sources(&src, &mut files); + assert!( + !files.is_empty(), + "found no Rust sources under {} — the guardrail would silently pass", + src.display() + ); + + let this_file = Path::new(file!()) + .file_name() + .expect("this file has a name") + .to_owned(); + + let mut unreviewed = Vec::new(); + for file in &files { + if file.file_name() == Some(this_file.as_os_str()) { + continue; + } + let text = std::fs::read_to_string(file) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", file.display())); + let lines: Vec<&str> = text.lines().collect(); + for (i, line) in lines.iter().enumerate() { + // Prose is free to discuss the iterators. + if line.trim_start().starts_with("//") { + continue; + } + if !UNION_ITERATORS.iter().any(|needle| line.contains(needle)) { + continue; + } + let from = i.saturating_sub(MARKER_LOOKBACK_LINES); + let reviewed = lines[from..=i].iter().any(|l| l.contains(REVIEW_MARKER)); + if !reviewed { + unreviewed.push(format!( + " {}:{}: {}", + file.strip_prefix(&src).unwrap_or(file).display(), + i + 1, + line.trim() + )); + } + } + } + + assert!( + unreviewed.is_empty(), + "FUNDING-DOMAIN INVARIANT: unreviewed use of key-wallet's wallet-wide \ + funds-account iterator(s):\n{}\n\n\ + A single L1 transaction must draw its inputs from EXACTLY ONE funds \ + account. Unioning ordinary BIP44/BIP32, CoinJoin, and DashPay-receiving \ + coins into one transaction irreversibly links those privacy domains on \ + chain (dashpay/platform#4073, blocked and re-scoped by #4184; regressed \ + once already in #4247).\n\n\ + If your call site does NOT select coins across accounts (e.g. it is \ + looking one account up by derivation path, or reading balances), add a \ + comment containing `{REVIEW_MARKER}:` within {MARKER_LOOKBACK_LINES} \ + lines above it saying why. If it DOES select across accounts, it is the \ + bug — take a `funding_path: Option` instead and fund \ + from the one named account, defaulting to unmixed BIP44. See \ + `wallet::funding_privacy`.", + unreviewed.join("\n"), + ); + } + + // -- behavioral guard ---------------------------------------------------- + + /// Duffs on BIP44 account 0 in the split fixture. + const BIP44_DUFFS: u64 = 9_000_000; + /// Duffs on DIP-9 CoinJoin account 0 in the split fixture. + const COINJOIN_DUFFS: u64 = 9_000_000; + /// More than either domain holds, less than their sum — fundable ONLY by a + /// cross-domain union. The send entry point must refuse it. + const CROSS_DOMAIN_ONLY: u64 = 15_000_000; + + /// The general L1 send entry point may not fund a transaction that requires + /// coins from more than one funding account. + /// + /// The fixture splits the balance evenly across two privacy domains (BIP44 + /// and DIP-9 CoinJoin) and asks the default (unmixed BIP44) send path for an + /// amount that neither domain covers alone but their union does. A + /// union-funding path succeeds here; a compliant single-domain path fails. + /// Failing is the CORRECT outcome: selection is confined to the named + /// account, and a shortfall surfaces as a typed insufficient-funds error + /// rather than silently reaching into a second domain. + #[tokio::test] + async fn no_spend_entry_point_unions_by_default() { + let (wm, wallet_id, signer) = + split_funded_wallet_manager(BIP44_DUFFS, COINJOIN_DUFFS).await; + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let core = CoreWallet::new( + sdk, + wm, + wallet_id, + Arc::new(AlwaysRejectedBroadcaster), + Arc::new(WalletBalance::new()), + ); + let payment = core + .build_signed_payment( + vec![(DashAddress::dummy(Network::Testnet, 7), CROSS_DOMAIN_ONLY)], + None, + &signer, + None, + ) + .await; + assert!( + matches!( + payment, + Err(PlatformWalletError::PaymentInsufficientFunds { .. }) + ), + "build_signed_payment must not union BIP44 with CoinJoin, got {payment:?}" + ); + } + + /// The flip side of the invariant: naming a domain explicitly confines + /// selection to it and to nothing else. Asks for an amount BIP44 alone + /// could not cover, from a CoinJoin account that can — and asserts no BIP44 + /// input rides along. + #[tokio::test] + async fn an_explicit_domain_selects_strictly_within_itself() { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + + // 0.09 DASH BIP44, 0.2 DASH CoinJoin; take 0.15 DASH from CoinJoin. + let (wm, wallet_id, signer) = split_funded_wallet_manager(9_000_000, 20_000_000).await; + + let (bip44_ops, coinjoin_ops, coinjoin_path) = { + let guard = wm.read().await; + let (_, info) = guard.get_wallet_and_info(&wallet_id).expect("wallet present"); + let network = info.core_wallet.network(); + let bip44: HashSet = info + .core_wallet + .accounts + .standard_bip44_accounts + .get(&0) + .map(|a| a.utxos.keys().copied().collect()) + .unwrap_or_default(); + let coinjoin_acc = info + .core_wallet + .accounts + .coinjoin_accounts + .get(&0) + .expect("coinjoin account 0 present"); + let coinjoin: HashSet = coinjoin_acc.utxos.keys().copied().collect(); + let path = coinjoin_acc + .managed_account_type() + .to_account_type() + .derivation_path(network) + .expect("coinjoin account-level path"); + (bip44, coinjoin, path) + }; + + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let core = CoreWallet::new( + sdk, + wm, + wallet_id, + Arc::new(AlwaysRejectedBroadcaster), + Arc::new(WalletBalance::new()), + ); + let payment = core + .build_signed_payment( + vec![(DashAddress::dummy(Network::Testnet, 7), CROSS_DOMAIN_ONLY)], + None, + &signer, + Some(coinjoin_path), + ) + .await + .expect("the named CoinJoin account covers 0.15 DASH"); + + let spent: HashSet = payment + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + assert!( + spent.iter().all(|op| coinjoin_ops.contains(op)), + "every input must come from the named CoinJoin account, spent {spent:?}" + ); + assert!( + !spent.iter().any(|op| bip44_ops.contains(op)), + "an explicitly-named domain must not pull BIP44 inputs, spent {spent:?}" + ); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/mod.rs b/packages/rs-platform-wallet/src/wallet/mod.rs index 96e11a5ae67..3143a4ea39e 100644 --- a/packages/rs-platform-wallet/src/wallet/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/mod.rs @@ -2,6 +2,7 @@ pub mod apply; pub mod asset_lock; pub mod core; pub mod core_address_key; +pub mod funding_privacy; pub mod identity; pub mod persister; pub mod platform_addresses; diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index f8dc82f050a..dd8f2d5bfb8 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -258,6 +258,40 @@ fn read_cstring_opt( } } +/// Strict variant of [`read_cstring_opt`] for parameters where a JNI read +/// failure must be surfaced rather than silently degraded. JVM null (or an +/// empty string) is still `Ok(None)` (the natural "unset" default), but a +/// genuine `get_string` error THROWS and returns `Err(())` instead of falling +/// back to `None`. Used for money-source parameters such as `fundingPath`, +/// where degrading to the default account would spend the wrong coins. +pub(crate) fn read_cstring_opt_strict( + env: &mut JNIEnv, + s: &JString, + field: &str, +) -> Result, ()> { + if s.is_null() { + return Ok(None); + } + let owned: String = match env.get_string(s) { + Ok(v) => v.into(), + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, &format!("{field} string was invalid")); + return Err(()); + } + }; + if owned.is_empty() { + return Ok(None); + } + match std::ffi::CString::new(owned) { + Ok(c) => Ok(Some(c)), + Err(_) => { + throw_sdk_exception(env, 1, &format!("{field} contained an interior NUL")); + Err(()) + } + } +} + /// The default Orchard payment address for `account` on the wallet's bound /// shielded sub-wallet — bridges `platform_wallet_manager_shielded_default_address`. /// Returns the 43 raw bytes (11-byte diversifier + 32-byte pk_d) as a diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 75dd0a39d87..5288ef3af69 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1007,16 +1007,20 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c } /// `core_wallet_build_signed_payment` — build + sign a standard L1 payment -/// funded from the UNION of the wallet's signable funds accounts (BIP44 + -/// BIP32 + CoinJoin + DashPay receiving; watch-only DashPay external accounts -/// excluded), and return the result WITHOUT broadcasting. +/// funded from ONE of the wallet's signable funds accounts, and return the +/// result WITHOUT broadcasting. /// /// `core_handle` is the transient core-wallet `Handle` from /// [platformWalletGetCore]. `outputs_blob` is the recipients, encoded /// big-endian as `u32 count` then per row `u32 addrLen, addr utf8, u64 amount` /// (`ManagedPlatformWallet.encodePaymentOutputs`). `fee_per_kb` is duffs/kB, or /// 0 for the default. `core_signer_handle` is the manager's -/// `MnemonicResolverHandle`. +/// `MnemonicResolverHandle`. `funding_path` is an optional UTF-8 BIP32 +/// derivation-path string (dashpay/platform#4184) naming the SINGLE funds +/// account whose UTXOs fund the payment: null (the default) funds from the +/// unmixed BIP44 account; an explicit account-level path (e.g. the DIP-9 +/// CoinJoin account path) funds strictly from that one account, with no union +/// across accounts and no consent gate. /// /// Returns a `byte[]` packed big-endian as `u64 fee, u64 change,` then the /// consensus-serialized signed transaction bytes (`fee` and `change` in duffs), @@ -1031,6 +1035,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c outputs_blob: JByteArray, fee_per_kb: jlong, core_signer_handle: jlong, + funding_path: JString, ) -> jbyteArray { guard(&mut env, ptr::null_mut(), |env| { if core_handle == 0 { @@ -1053,6 +1058,21 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c return ptr::null_mut(); } }; + // Optional BIP32 derivation-path string naming the single funds account + // (null = the unmixed BIP44 account). Passed to the FFI as UTF-8 bytes + // (without the trailing NUL) + length. Uses the STRICT reader: a genuine + // read error must throw, not silently degrade this money-source param to + // the default BIP44 account (which would spend the wrong coins). + let funding_path = + match crate::funding::read_cstring_opt_strict(env, &funding_path, "fundingPath") { + Ok(v) => v, + Err(()) => return ptr::null_mut(), + }; + let (funding_path_ptr, funding_path_len) = + funding_path.as_ref().map_or((ptr::null(), 0usize), |c| { + let b = c.as_bytes(); + (b.as_ptr(), b.len()) + }); let mut out_tx_bytes: *mut u8 = ptr::null_mut(); let mut out_tx_len: usize = 0; @@ -1065,6 +1085,8 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c blob.len(), fee_per_kb as u64, core_signer_handle as *mut rs_sdk_ffi::MnemonicResolverHandle, + funding_path_ptr, + funding_path_len, &mut out_tx_bytes, &mut out_tx_len, &mut out_fee, From 298de9afd140a80c36771bf71451b1bfe520503b Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:04:52 -0400 Subject: [PATCH 36/47] feat(platform-wallet): expose account-level derivationPath in accountBalances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds derivation_path (Option) to AccountBalanceRow, the FFI AccountBalanceEntryFFI (appended, ABI-additive), and the JNI JSON ("derivationPath": string|null). Computed from the same AccountType::derivation_path(network) the funding-path spend selector compares against, so the emitted string is byte-identical to what build_signed_payment expects — the app passes it verbatim to spend a DashPay receival account. Null for account types with no derivable path. Co-Authored-By: Claude Opus 4.8 --- .../src/core_wallet_types.rs | 13 +++++++++ packages/rs-platform-wallet-ffi/src/wallet.rs | 22 ++++++++++++++ .../src/manager/accessors.rs | 29 +++++++++++++++++-- packages/rs-unified-sdk-jni/src/dashpay.rs | 11 ++++++- 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index 20e5e82ac26..c3edadfa519 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -491,6 +491,17 @@ fn account_index_of(at: &key_wallet::account::AccountType) -> u32 { /// account. The pool counts are meaningful for both funds and keys /// variants; the explorer surfaces them as the headline number on /// keys-only rows where balance reads zero by construction. +/// +/// `derivation_path` is a heap-owned, NUL-terminated UTF-8 C string +/// carrying the account-level derivation path (e.g. the DIP-15 +/// `m/9'/coinType'/15'/{index}'/0x/0x` for a +/// DashPay receiving-funds account). It is `null` for account types that +/// have no derivable account-level path. Freed by +/// [`platform_wallet_manager_free_account_balances`]. Appended as the +/// LAST field so the layout change is purely additive — existing readers +/// of the earlier fields are byte-compatible, but any consumer that +/// indexes the array by struct stride (iOS/Android bindings) must be +/// regenerated against this definition before use. #[repr(C)] pub struct AccountBalanceEntryFFI { pub type_tag: crate::wallet_restore_types::AccountTypeTagFFI, @@ -506,6 +517,8 @@ pub struct AccountBalanceEntryFFI { pub locked: u64, pub keys_used: u32, pub keys_total: u32, + /// Heap-owned NUL-terminated UTF-8 derivation-path string, or `null`. + pub derivation_path: *const c_char, } // --------------------------------------------------------------------------- diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index df0f6fc9a6f..a17d9e97e4d 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -167,6 +167,16 @@ pub unsafe extern "C" fn platform_wallet_manager_get_account_balances( locked: row.balance.locked(), keys_used: row.keys_used, keys_total: row.keys_total, + // Account-level derivation-path string → heap-owned C + // string, or null when the account type has no path. + // `CString::new` only fails on an interior NUL, which a + // derivation path never contains; `.ok()` degrades that + // impossible case to null rather than panicking. + derivation_path: row + .derivation_path + .and_then(|s| std::ffi::CString::new(s).ok()) + .map(|c| c.into_raw() as *const std::os::raw::c_char) + .unwrap_or(std::ptr::null()), } }) .collect(); @@ -191,6 +201,18 @@ pub unsafe extern "C" fn platform_wallet_manager_free_account_balances( count: usize, ) { if !entries.is_null() && count > 0 { + // Reclaim each entry's heap-owned derivation-path C string before + // dropping the array backing store (mirrors the `into_raw` in the + // producer above). + let slice = std::slice::from_raw_parts_mut(entries, count); + for e in slice.iter_mut() { + if !e.derivation_path.is_null() { + let _ = std::ffi::CString::from_raw( + e.derivation_path as *mut std::os::raw::c_char, + ); + e.derivation_path = std::ptr::null(); + } + } let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(entries, count)); } } diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 7dfc444c833..327082a76ae 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -75,12 +75,23 @@ pub struct PlatformAddressSyncConfigSnapshot { /// rather than a positional tuple so adding the next field /// (`pool_count`, `last_used_height`, …) doesn't ripple through every /// destructuring site. -#[derive(Debug, Clone, Copy)] +/// +/// `derivation_path` is the account-level derivation path STRING (e.g. +/// the DIP-15 `m/9'/coinType'/15'/{index}'/0x/0x` for a DashPay receiving-funds account), produced by the SAME +/// `AccountType::derivation_path(network)` call that +/// [`CoreWallet::build_signed_payment`](crate::wallet::core::CoreWallet::build_signed_payment)'s +/// funds-account selector compares against — so a caller can round-trip +/// this exact string back in as `funding_path` to spend from this single +/// account with no divergence risk. `None` for account types that have +/// no derivable account-level path. +#[derive(Debug, Clone)] pub struct AccountBalanceRow { pub account_type: AccountType, pub balance: WalletCoreBalance, pub keys_used: u32, pub keys_total: u32, + pub derivation_path: Option, } /// Snapshot of [`IdentitySyncManager`] tunables / queue depth, returned @@ -383,6 +394,11 @@ impl PlatformWalletManager

{ let Some(info) = wm.get_wallet_info(wallet_id) else { return Vec::new(); }; + // Same `Network` the funds-account selector in + // `CoreWallet::build_signed_payment` derives paths under, so the + // emitted `derivation_path` string is byte-identical to what the + // selector compares against (see `AccountBalanceRow` docs). + let network = info.core_wallet.network(); info.core_wallet .accounts .all_accounts() @@ -405,11 +421,20 @@ impl PlatformWalletManager

{ let pool_total = pool.addresses.len() as u32; (used + pool_used, total + pool_total) }); + let account_type = account.managed_account_type().to_account_type(); + // The single source of truth: the identical call the + // spend-selector uses. `Ok(path)` for account types with + // a derivable account-level path (Standard/BIP44, + // CoinJoin, DashPay receiving-funds, …); `Err` (→ `None`) + // for the ones that have none. + let derivation_path = + account_type.derivation_path(network).ok().map(|p| p.to_string()); AccountBalanceRow { - account_type: account.managed_account_type().to_account_type(), + account_type, balance, keys_used, keys_total, + derivation_path, } }) .collect() diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index 725532e7ca7..30922cf2cbe 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -356,12 +356,20 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_walletM if !entries.is_null() && count > 0 { let items = unsafe { std::slice::from_raw_parts(entries, count) }; for e in items { + // Account-level derivation-path string (the value a caller + // hands back to `build_signed_payment` as `funding_path` + // to spend this single account). Null C-string → JSON + // `null`; a present path is JSON-escaped. + let derivation_path_json = unsafe { opt_cstr(e.derivation_path) } + .map(|s| json_string(&s)) + .unwrap_or_else(|| "null".to_string()); rows.push(format!( "{{\"typeTag\":{},\"standardTag\":{},\"index\":{},\ \"registrationIndex\":{},\"keyClass\":{},\ \"userIdentityId\":{},\"friendIdentityId\":{},\ \"confirmed\":{},\"unconfirmed\":{},\"immature\":{},\ - \"locked\":{},\"keysUsed\":{},\"keysTotal\":{}}}", + \"locked\":{},\"keysUsed\":{},\"keysTotal\":{},\ + \"derivationPath\":{}}}", e.type_tag as u8, e.standard_tag as u8, e.index, @@ -375,6 +383,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_walletM e.locked, e.keys_used, e.keys_total, + derivation_path_json, )); } } From 340358f8c6e77f93d21e47cf380eb6ec239a3bbb Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Fri, 31 Jul 2026 18:59:06 -0400 Subject: [PATCH 37/47] fix(platform-wallet): dust + size bounds, typed build errors, and tests for the send hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review findings on dashpay/platform#4247. Dust outputs (BLOCKING). `TransactionBuilder::add_output` applies no relay policy, so a one-duff recipient produced fully signed bytes for a transaction every standard node rejects as nonstandard — from a primitive documented as building a *standard* payment for later broadcast. Each recipient amount is now checked against its OWN destination script's `dust_value()` (546 duffs for P2PKH), before the wallet lock, before any input is reserved and before the signer is called. Fee/size overflow (BLOCKING). The `MAX_FEE_PER_KB = MAX_MONEY / 100` bound assumed the transaction stayed under the 100 kB standard limit, which the method never enforced; key-wallet's `calculate_fee` then multiplies `sat_per_kb * size_bytes` unchecked and overflows at ~878 kB — reachable both by a ~25.8k-recipient list and by a funding account with a few thousand small denominations. Two-sided fix: * the recipient count is bounded at build time against `MAX_STANDARD_TX_SIZE` (derived from `dashcore::policy::MAX_STANDARD_TX_WEIGHT / 4`), with checked arithmetic mirroring key-wallet's own base-size formula; * `MAX_FEE_PER_KB` is re-derived as `u64::MAX / u32::MAX`, which makes the product unrepresentable-free for ANY size a `u32` can express and therefore does not depend on the input count. ~43 DASH/kB is still three orders of magnitude above any legitimate rate; * the signed transaction is re-measured and refused if it exceeds the standard limit. Typed build errors. `PlatformWalletError::TransactionBuild` had no FFI arm, so every `funding_path` failure — "no spendable funds account matches" and "names a watch-only account", the two failure modes the single-account design rests on — reached Kotlin as `Generic(99)` and could only be told apart by string-matching. Adds `ErrorTransactionBuild = 32` (27-31 are claimed by sibling v4.1 stack PRs, so this needs no renumbering whichever order they land) plus the Kotlin `PlatformWallet.TransactionBuild` type. Tests for the six hardening fixes, which shipped with no coverage. `core_wallet/send.rs` had no test module at all; it now covers the `count` bound, `try_reserve_exact`, checked cursor math at every field boundary, UTF-8, and wrong-network address rejection. Also covers MAX_MONEY aggregation, the fee-rate bound, `parse_optional_derivation_path`, dust rejection (including that a refused request reserves nothing), and the new size bound. Also: corrects the stale `PaymentInsufficientFunds` doc, which still described the pre-#4184 union semantics; corrects an inverted fee-direction comment; adds the missing non-empty assertion to a funding-privacy guardrail test; and runs `cargo fmt` over the five files that failed `--check`. platform-wallet 510 passed, platform-wallet-ffi 222 passed, 0 failed. Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/errors/DashSdkError.kt | 19 + .../dashsdk/errors/DashSdkErrorTest.kt | 25 ++ .../src/core_wallet/broadcast.rs | 17 +- .../src/core_wallet/send.rs | 228 +++++++++- packages/rs-platform-wallet-ffi/src/error.rs | 104 +++++ packages/rs-platform-wallet-ffi/src/utils.rs | 54 +++ packages/rs-platform-wallet-ffi/src/wallet.rs | 4 +- packages/rs-platform-wallet/src/error.rs | 19 +- .../src/manager/accessors.rs | 6 +- .../src/wallet/core/send.rs | 388 +++++++++++++++++- .../src/wallet/funding_privacy.rs | 9 +- 11 files changed, 836 insertions(+), 37 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 b41b1a83a44..c616b3117e1 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 @@ -89,6 +89,24 @@ sealed class DashSdkError( class CoreInsufficientFunds(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorTransactionBuild` (native code 32). A Core transaction could + * not be assembled from the request. The REQUEST is at fault, so a + * verbatim retry fails identically — the caller must change it. + * + * It is what every non-shortfall `buildSignedPayment` rejection + * surfaces as: a `fundingPath` matching no spendable funds account or + * naming a watch-only one (the two failure modes the single-account + * send design rests on, dashpay/platform#4184), a request breaching a + * monetary bound (MAX_MONEY total, max fee rate, a below-dust + * recipient, an over-100 kB recipient list), or a malformed recipients + * blob. Before this code existed they all arrived as [Generic] with + * native code 99 and could only be told apart by string-matching the + * message. The specific cause is still in [message]. + */ + class TransactionBuild(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + class AssetLockNotTracked(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) @@ -421,6 +439,7 @@ sealed class DashSdkError( 24 -> PlatformWallet.AssetLockAlreadyConsumed(message, cause) // ErrorAssetLockAlreadyConsumed 25 -> PlatformWallet.AssetLockFundingMismatch(message, cause) // ErrorAssetLockFundingMismatch 26 -> PlatformWallet.TransactionBroadcastRejected(message, cause) // ErrorTransactionBroadcastRejected + 32 -> PlatformWallet.TransactionBuild(message, cause) // ErrorTransactionBuild // The deferred-token trio sits at the contiguous block 34-36 because // 27-33 are claimed elsewhere: 27 ErrorShutdownIncomplete // (dashpay/platform#4268, merged), 29 ErrorAssetLockInsufficientFunds 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 57f758e2868..1f8f41137b8 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 @@ -160,6 +160,31 @@ class DashSdkErrorTest { assertEquals("different generation", walletMismatch.message) } + /** + * `ErrorTransactionBuild` (32) must reach callers as its own type. Every + * non-shortfall `buildSignedPayment` rejection maps to it — including the + * two failure modes the single-account send design rests on, an unmatched + * `fundingPath` and a watch-only one. They previously arrived as + * [DashSdkError.PlatformWallet.Generic] with code 99, distinguishable only + * by string-matching the message (dashpay/platform#4247 review). + */ + @Test + fun transactionBuildFailuresGetTheirOwnType() { + val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET + val message = "no spendable funds account matches funding derivation path m/44'/5'/7'" + + val mapped = DashSdkError.fromNative(DashSDKException(offset + 32, message)) + assertTrue( + "code 32 must not fall through to Generic", + mapped is DashSdkError.PlatformWallet.TransactionBuild, + ) + assertEquals(message, mapped.message) + assertFalse( + "the request itself is at fault, so a verbatim retry cannot help", + mapped.isRetryable, + ) + } + @Test fun unmappedPlatformWalletCodesFallBackToGeneric() { val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs index ee1f064b184..85713eb8e88 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -274,14 +274,27 @@ mod outcome_tests { ); } + /// An error raised BEFORE the transaction reached the network must not + /// report a txid — the caller would otherwise track a transaction that was + /// never broadcast. The code itself is whatever the blanket `From` impl + /// maps the variant to; `TransactionBuild` gained a dedicated + /// `ErrorTransactionBuild` code (dashpay/platform#4247 review) instead of + /// flattening to `ErrorUnknown`, and the txid contract is unchanged by + /// that. #[test] fn operational_error_does_not_carry_a_txid() { let outcome = classify_broadcast_result( Err(PlatformWalletError::TransactionBuild("invalid".to_string())), txid(4), ); - assert_eq!(outcome.0, None); - assert_eq!(outcome.1.code, PlatformWalletFFIResultCode::ErrorUnknown); + assert_eq!( + outcome.0, None, + "a pre-broadcast failure must report no txid" + ); + assert_eq!( + outcome.1.code, + PlatformWalletFFIResultCode::ErrorTransactionBuild + ); } } diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs index 57033e0376b..103f9c0eb68 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs @@ -49,17 +49,23 @@ fn decode_payment_outputs( // (Android armeabi-v7a) can overflow and panic inside this `extern "C"` // frame, where the JNI guard cannot safely recover it. let read_u32 = |buf: &[u8], at: &mut usize| -> Result { - let end = at.checked_add(4).filter(|e| *e <= buf.len()).ok_or_else(|| { - PlatformWalletError::TransactionBuild("truncated recipients blob (u32)".to_string()) - })?; + let end = at + .checked_add(4) + .filter(|e| *e <= buf.len()) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild("truncated recipients blob (u32)".to_string()) + })?; let v = u32::from_be_bytes([buf[*at], buf[*at + 1], buf[*at + 2], buf[*at + 3]]); *at = end; Ok(v) }; let read_u64 = |buf: &[u8], at: &mut usize| -> Result { - let end = at.checked_add(8).filter(|e| *e <= buf.len()).ok_or_else(|| { - PlatformWalletError::TransactionBuild("truncated recipients blob (u64)".to_string()) - })?; + let end = at + .checked_add(8) + .filter(|e| *e <= buf.len()) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild("truncated recipients blob (u64)".to_string()) + })?; let mut b = [0u8; 8]; b.copy_from_slice(&buf[*at..end]); *at = end; @@ -95,9 +101,11 @@ fn decode_payment_outputs( let parsed = DashAddress::from_str(addr_str) .map_err(|e| err(format!("invalid recipient address {addr_str:?}: {e}")))?; - let address = parsed - .require_network(network) - .map_err(|e| err(format!("recipient address {addr_str:?} network mismatch: {e}")))?; + let address = parsed.require_network(network).map_err(|e| { + err(format!( + "recipient address {addr_str:?} network mismatch: {e}" + )) + })?; outputs.push((address, amount)); } Ok(outputs) @@ -206,3 +214,205 @@ pub unsafe extern "C" fn core_wallet_free_payment_bytes(bytes: *mut u8, len: usi let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(bytes, len)); } } + +/// Decoder hardening tests. +/// +/// `decode_payment_outputs` parses a caller-controlled blob inside an +/// `extern "C"` frame, where a panic or an allocation abort cannot be recovered +/// by the JNI guard. Every bound below was a blocking review finding on +/// dashpay/platform#4247 and shipped without coverage; these pin them so a +/// later cleanup cannot quietly reintroduce `Vec::with_capacity(count)` or +/// unchecked cursor arithmetic. +#[cfg(test)] +mod tests { + use super::*; + use dashcore::Network; + + /// Encode one recipient row in the wire layout `decode_payment_outputs` + /// documents: `u32 addr_len`, the UTF-8 address, `u64 amount`. + fn row(address: &str, amount: u64) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(&(address.len() as u32).to_be_bytes()); + v.extend_from_slice(address.as_bytes()); + v.extend_from_slice(&amount.to_be_bytes()); + v + } + + fn blob(rows: &[(&str, u64)]) -> Vec { + let mut v = (rows.len() as u32).to_be_bytes().to_vec(); + for (a, amt) in rows { + v.extend_from_slice(&row(a, *amt)); + } + v + } + + fn testnet_address(id: usize) -> String { + DashAddress::dummy(Network::Testnet, id).to_string() + } + + #[test] + fn decodes_a_well_formed_blob() { + let (a, b) = (testnet_address(1), testnet_address(2)); + let decoded = decode_payment_outputs( + &blob(&[(a.as_str(), 1_000_000), (b.as_str(), 546)]), + Network::Testnet, + ) + .expect("a well-formed blob decodes"); + + assert_eq!(decoded.len(), 2); + assert_eq!(decoded[0].0.to_string(), a); + assert_eq!(decoded[0].1, 1_000_000); + assert_eq!(decoded[1].0.to_string(), b); + assert_eq!(decoded[1].1, 546); + } + + #[test] + fn zero_outputs_decode_to_an_empty_vec() { + let decoded = decode_payment_outputs(&0u32.to_be_bytes(), Network::Testnet) + .expect("an empty list is a decode success"); + assert!( + decoded.is_empty(), + "emptiness is rejected upstream, not here" + ); + } + + /// THE allocation blocker: a four-byte blob declaring `u32::MAX` outputs + /// must produce a decode error, never a ~64 GiB `Vec::with_capacity` that + /// takes Rust's process-aborting allocation-failure path inside + /// `extern "C"`. + #[test] + fn an_impossible_count_is_rejected_without_allocating() { + for count in [u32::MAX, u32::MAX / 2, 1_000_000, 1] { + let err = decode_payment_outputs(&count.to_be_bytes(), Network::Testnet) + .expect_err("a header-only blob cannot hold any output"); + let PlatformWalletError::TransactionBuild(m) = err else { + panic!("expected a decode error for count {count}"); + }; + assert!( + m.contains("holds at most 0"), + "count {count} must be bounded by the blob length, got {m:?}" + ); + } + } + + /// The bound is computed from the remaining bytes, so a count that merely + /// overstates a non-empty blob is refused too. + #[test] + fn a_count_exceeding_the_rows_present_is_rejected() { + let a = testnet_address(1); + let mut b = blob(&[(a.as_str(), 1_000)]); + b[0..4].copy_from_slice(&9u32.to_be_bytes()); + + let err = decode_payment_outputs(&b, Network::Testnet).expect_err("9 rows are not present"); + assert!( + matches!(err, PlatformWalletError::TransactionBuild(ref m) if m.contains("declares 9")), + "got {err:?}" + ); + } + + /// Checked cursor arithmetic: a truncated blob is a clean error at every + /// field boundary, never an out-of-bounds slice or a `cursor + len` + /// overflow panic (reachable on 32-bit Android targets). + #[test] + fn truncation_at_any_boundary_is_a_clean_error() { + let a = testnet_address(1); + let full = blob(&[(a.as_str(), 1_000)]); + + for cut in 1..full.len() { + let err = decode_payment_outputs(&full[..cut], Network::Testnet) + .expect_err("a truncated blob must not decode"); + assert!( + matches!(err, PlatformWalletError::TransactionBuild(_)), + "truncation at {cut} must be a decode error, got {err:?}" + ); + } + + // The full blob still decodes, so the loop above proved truncation is + // the cause rather than the fixture being malformed. + assert!(decode_payment_outputs(&full, Network::Testnet).is_ok()); + } + + /// A declared address length far beyond the blob must not panic on + /// `cursor + addr_len`. + #[test] + fn an_absurd_address_length_is_rejected() { + let mut b = 1u32.to_be_bytes().to_vec(); + b.extend_from_slice(&u32::MAX.to_be_bytes()); + b.extend_from_slice(&[0u8; 16]); + + let err = decode_payment_outputs(&b, Network::Testnet) + .expect_err("an address longer than the blob must not decode"); + assert!( + matches!(err, PlatformWalletError::TransactionBuild(_)), + "got {err:?}" + ); + } + + #[test] + fn a_non_utf8_address_is_rejected() { + let mut b = 1u32.to_be_bytes().to_vec(); + b.extend_from_slice(&4u32.to_be_bytes()); + b.extend_from_slice(&[0xff, 0xfe, 0xfd, 0xfc]); + b.extend_from_slice(&1_000u64.to_be_bytes()); + + let err = decode_payment_outputs(&b, Network::Testnet).expect_err("invalid UTF-8"); + assert!( + matches!(err, PlatformWalletError::TransactionBuild(ref m) if m.contains("UTF-8")), + "got {err:?}" + ); + } + + #[test] + fn an_unparseable_address_is_rejected() { + let err = decode_payment_outputs(&blob(&[("not-an-address", 1_000)]), Network::Testnet) + .expect_err("garbage is not an address"); + assert!( + matches!(err, PlatformWalletError::TransactionBuild(ref m) if m.contains("invalid recipient address")), + "got {err:?}" + ); + } + + /// Network confusion is a funds-loss shape: a mainnet address accepted on a + /// testnet wallet (or the reverse) sends real coins to an address the user + /// did not intend. + #[test] + fn a_wrong_network_address_is_rejected() { + let mainnet = DashAddress::dummy(Network::Mainnet, 1).to_string(); + let err = decode_payment_outputs(&blob(&[(mainnet.as_str(), 1_000)]), Network::Testnet) + .expect_err("a mainnet address must not decode for a testnet wallet"); + assert!( + matches!(err, PlatformWalletError::TransactionBuild(ref m) if m.contains("network mismatch")), + "got {err:?}" + ); + + // …and it decodes fine against its own network, proving the rejection + // is the network check rather than the address being malformed. + assert!( + decode_payment_outputs(&blob(&[(mainnet.as_str(), 1_000)]), Network::Mainnet).is_ok() + ); + } + + /// `core_wallet_free_payment_bytes` tolerates the null/zero pair its own + /// documented contract permits. + #[test] + fn freeing_null_payment_bytes_is_a_no_op() { + unsafe { + core_wallet_free_payment_bytes(std::ptr::null_mut(), 0); + core_wallet_free_payment_bytes(std::ptr::null_mut(), 32); + } + } + + /// Round-trip through the real allocation path: what + /// `core_wallet_build_signed_payment` hands out is what + /// `core_wallet_free_payment_bytes` takes back. + #[test] + fn payment_bytes_round_trip_through_the_free_function() { + let payload = vec![7u8; 128]; + let len = payload.len(); + let ptr = Box::into_raw(payload.into_boxed_slice()) as *mut u8; + unsafe { + assert_eq!(std::slice::from_raw_parts(ptr, len), [7u8; 128]); + core_wallet_free_payment_bytes(ptr, len); + } + } +} diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 06301aad680..0d7f286c695 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -228,6 +228,33 @@ pub enum PlatformWalletFFIResultCode { /// wallet-operation failure. Not retryable as-is — the key must be /// (re-)derived first. ErrorSigningKeyUnavailable = 31, + /// Maps `PlatformWalletError::TransactionBuild`. A Core transaction could + /// not be assembled from the request — the request itself is at fault, and + /// the host must change it rather than retry it verbatim. It is the code + /// every `build_signed_payment` rejection lands on: + /// + /// * the named `funding_path` matches no spendable funds account, or names + /// a watch-only one whose coins the local mnemonic cannot sign — the two + /// failure modes the single-account design rests on + /// (dashpay/platform#4184); + /// * the request violates a monetary bound (`MAX_MONEY` output total, + /// `MAX_FEE_PER_KB` fee rate, a below-dust recipient output, or an + /// over-`MAX_STANDARD_TX_SIZE` recipient list); + /// * the recipients blob failed to decode. + /// + /// These previously flattened to `ErrorUnknown` (99), reaching Kotlin as + /// `DashSdkError.PlatformWallet.Generic` and leaving the host to + /// string-match the message to tell a bad funding path from a bad amount + /// (dashpay/platform#4247 review). The specific cause still travels in the + /// result `message` via the typed `Display`. + /// + /// Numbering: 27–31 are claimed by sibling v4.1 stack PRs + /// (`ErrorStaleReservationToken`/`ErrorReservationTokenConsumed` #4185, + /// `ErrorAssetLockInsufficientFunds` #4184, + /// `ErrorReservationWalletMismatch`, `ErrorSigningKeyUnavailable`), so this + /// takes the first slot free on every branch of that stack and needs no + /// renumbering whatever order they land in. + ErrorTransactionBuild = 32, // Codes 27-33 are claimed outside this PR and MUST NOT be reused here. // The deferred-token trio below therefore occupies the contiguous block @@ -457,6 +484,18 @@ impl From for PlatformWalletFFIResult { | PlatformWalletError::PaymentInsufficientFunds { .. } => { PlatformWalletFFIResultCode::ErrorCoreInsufficientFunds } + // Every `build_signed_payment` rejection that is not a shortfall + // arrives here: an unmatched or watch-only `funding_path`, a + // monetary-bound violation (MAX_MONEY / MAX_FEE_PER_KB / dust / + // MAX_STANDARD_TX_SIZE), or a recipients-blob decode failure. + // Without this arm they all flattened to `ErrorUnknown` (99), so + // the two failure modes the single-account design rests on — + // "that path names no spendable account" and "that path is + // watch-only" — were distinguishable only by string-matching the + // message (dashpay/platform#4247 review). + PlatformWalletError::TransactionBuild(..) => { + PlatformWalletFFIResultCode::ErrorTransactionBuild + } PlatformWalletError::AssetLockNotTracked(..) => { PlatformWalletFFIResultCode::ErrorAssetLockNotTracked } @@ -806,6 +845,71 @@ mod tests { } } + /// The one-shot payment primitive's shortfall shares code 22 with the + /// atomic builder's. Pinned separately from + /// `atomic_core_insufficient_funds_maps_to_dedicated_code`, which only ever + /// constructs `CoreInsufficientFunds`: if a cleanup dropped + /// `PaymentInsufficientFunds` from that arm it would silently fall through + /// to `ErrorUnknown` and no existing test would notice. + #[test] + fn payment_insufficient_funds_shares_the_core_shortfall_code() { + let result: PlatformWalletFFIResult = PlatformWalletError::PaymentInsufficientFunds { + available: 9_000_000, + required: 15_000_000, + } + .into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorCoreInsufficientFunds + ); + let msg = unsafe { std::ffi::CStr::from_ptr(result.message) }.to_string_lossy(); + assert!( + msg.contains("9000000") && msg.contains("15000000"), + "the single-account available/required duffs must survive in the \ + message: {msg}" + ); + } + + /// Every `build_signed_payment` rejection that is not a shortfall is a + /// `TransactionBuild`, and must reach the host as its own code rather than + /// `ErrorUnknown` (99) — otherwise "that funding path names no spendable + /// account" and "that funding path is watch-only", the two failure modes + /// the single-account design rests on, are distinguishable only by + /// string-matching (dashpay/platform#4247 review). + #[test] + fn transaction_build_failures_map_to_a_dedicated_code() { + for message in [ + "no spendable funds account matches funding derivation path m/44'/5'/7'", + "funding derivation path m/9'/5'/4'/0' names a watch-only account", + "output amounts overflow or exceed MAX_MONEY", + "fee rate 99999999999 duffs/kB exceeds the maximum", + "recipients blob declares 4294967295 outputs but holds at most 0", + ] { + let result: PlatformWalletFFIResult = + PlatformWalletError::TransactionBuild(message.to_string()).into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorTransactionBuild, + "{message:?} must not flatten to ErrorUnknown" + ); + let rendered = unsafe { std::ffi::CStr::from_ptr(result.message) }.to_string_lossy(); + assert!( + rendered.contains(message), + "the specific cause must survive in the message: {rendered}" + ); + } + } + + /// The new code must not silently collide with a sibling v4.1 stack PR's + /// (27–31 are claimed; see the variant's doc comment). + #[test] + fn transaction_build_code_is_thirty_two() { + assert_eq!( + PlatformWalletFFIResultCode::ErrorTransactionBuild as i32, + 32 + ); + } + #[test] fn asset_lock_recovery_failures_map_to_stable_codes() { use dashcore::OutPoint; diff --git a/packages/rs-platform-wallet-ffi/src/utils.rs b/packages/rs-platform-wallet-ffi/src/utils.rs index 9d62627bb41..9e8b0eae25a 100644 --- a/packages/rs-platform-wallet-ffi/src/utils.rs +++ b/packages/rs-platform-wallet-ffi/src/utils.rs @@ -263,6 +263,60 @@ pub unsafe extern "C" fn platform_wallet_pubkey_hash_from_private_key( mod tests { use super::*; + /// `parse_optional_derivation_path` turns the caller's optional + /// `funding_path` bytes into the single-account selector the send primitive + /// funds from (dashpay/platform#4184). Null/empty means "the default + /// account", so the null case must stay distinguishable from a parse + /// failure — mapping a malformed path to `None` would silently fund from + /// BIP44 instead of the account the caller named. + #[test] + fn optional_derivation_path_treats_null_and_empty_as_default() { + for (ptr, len) in [ + (std::ptr::null::(), 0usize), + (std::ptr::null::(), 12usize), + (b"m/44'/5'/0'".as_ptr(), 0usize), + ] { + let parsed = unsafe { parse_optional_derivation_path(ptr, len) } + .expect("null/empty is not an error"); + assert!(parsed.is_none(), "null/empty must mean the default account"); + } + } + + #[test] + fn optional_derivation_path_parses_account_level_paths() { + use std::str::FromStr; + + for text in ["m/44'/5'/0'", "m/9'/5'/4'/0'", "m/44'/1'/7'"] { + let parsed = unsafe { parse_optional_derivation_path(text.as_ptr(), text.len()) } + .expect("a valid BIP32 path parses"); + assert_eq!( + parsed, + Some(key_wallet::bip32::DerivationPath::from_str(text).expect("valid")), + "{text} must round-trip verbatim" + ); + } + } + + /// A malformed path is an error, never a silent `None`. + #[test] + fn optional_derivation_path_rejects_garbage() { + for text in ["not a path", "m/44'/5'/zzz", "///"] { + let err = unsafe { parse_optional_derivation_path(text.as_ptr(), text.len()) } + .expect_err("garbage must not parse"); + assert_eq!(err.code, PlatformWalletFFIResultCode::ErrorInvalidParameter); + } + } + + #[test] + fn optional_derivation_path_rejects_non_utf8() { + let bytes = [0xffu8, 0xfe, 0xfd]; + let err = unsafe { parse_optional_derivation_path(bytes.as_ptr(), bytes.len()) } + .expect_err("invalid UTF-8 must not parse"); + assert_eq!(err.code, PlatformWalletFFIResultCode::ErrorInvalidParameter); + let msg = unsafe { std::ffi::CStr::from_ptr(err.message) }.to_string_lossy(); + assert!(msg.contains("UTF-8"), "got {msg}"); + } + #[test] fn test_hash160_matches_known_vector() { use dashcore::hashes::Hash; diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index a17d9e97e4d..8ce14575a17 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -207,9 +207,7 @@ pub unsafe extern "C" fn platform_wallet_manager_free_account_balances( let slice = std::slice::from_raw_parts_mut(entries, count); for e in slice.iter_mut() { if !e.derivation_path.is_null() { - let _ = std::ffi::CString::from_raw( - e.derivation_path as *mut std::os::raw::c_char, - ); + let _ = std::ffi::CString::from_raw(e.derivation_path as *mut std::os::raw::c_char); e.derivation_path = std::ptr::null(); } } diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index cb470010268..d5d7263dfcd 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -72,12 +72,19 @@ pub enum PlatformWalletError { AssetLockTransaction(String), /// A general Core L1 payment build (`CoreWallet::build_signed_payment`) - /// could not cover the requested outputs plus fee from the union of the - /// wallet's *signable* funds accounts (BIP44 + BIP32 + CoinJoin + DashPay - /// receiving; watch-only DashPay external accounts are excluded). `available` - /// is the total selectable value across those accounts, `required` the - /// outputs-plus-fee target — carried as exact duff amounts (instead of being - /// flattened into a string) so callers can render a precise shortfall. + /// could not cover the requested outputs plus fee from the **one** funds + /// account named by `funding_path` (defaulting to the unmixed BIP44 + /// account). `available` is that single selected account's spendable total + /// and `required` its outputs-plus-fee target — carried as exact duff + /// amounts (instead of being flattened into a string) so callers can render + /// a precise shortfall. + /// + /// Both figures are deliberately SINGLE-ACCOUNT, never a wallet-wide union: + /// reporting a cross-account total against a single-account shortfall would + /// invite a retry that can only succeed by linking privacy domains, which + /// this primitive will not do. The actionable signal is "fund from a + /// different account", not "retry the same one with a smaller amount" + /// (dashpay/platform#4073 → #4184; see `crate::wallet::funding_privacy`). #[error( "payment coin selection is short: available {available} duffs, \ required {required} duffs" diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 327082a76ae..5ef4bc5a349 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -427,8 +427,10 @@ impl PlatformWalletManager

{ // a derivable account-level path (Standard/BIP44, // CoinJoin, DashPay receiving-funds, …); `Err` (→ `None`) // for the ones that have none. - let derivation_path = - account_type.derivation_path(network).ok().map(|p| p.to_string()); + let derivation_path = account_type + .derivation_path(network) + .ok() + .map(|p| p.to_string()); AccountBalanceRow { account_type, balance, diff --git a/packages/rs-platform-wallet/src/wallet/core/send.rs b/packages/rs-platform-wallet/src/wallet/core/send.rs index f4d4e3a554b..ec589cc7e81 100644 --- a/packages/rs-platform-wallet/src/wallet/core/send.rs +++ b/packages/rs-platform-wallet/src/wallet/core/send.rs @@ -55,7 +55,9 @@ use key_wallet::managed_account::ManagedCoreFundsAccount; use key_wallet::signer::Signer; use key_wallet::wallet::managed_wallet_info::coin_selection::{SelectionError, SelectionStrategy}; use key_wallet::wallet::managed_wallet_info::fee::FeeRate; -use key_wallet::wallet::managed_wallet_info::transaction_builder::{BuilderError, TransactionBuilder}; +use key_wallet::wallet::managed_wallet_info::transaction_builder::{ + BuilderError, TransactionBuilder, +}; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use crate::broadcaster::TransactionBroadcaster; @@ -70,14 +72,50 @@ const DEFAULT_FEE_PER_KB: u64 = 1000; /// Consensus cap on any single amount this primitive will accept or aggregate. const MAX_MONEY: u64 = dashcore::blockdata::constants::MAX_MONEY; +/// Dash's standard-transaction size limit, in bytes. A transaction above this +/// is non-standard and will not relay, so building one is never useful. +/// +/// Derived from `dashcore::policy::MAX_STANDARD_TX_WEIGHT` (400_000 weight +/// units) rather than hard-coded: Dash has no segwit, so weight is exactly +/// 4× size and the byte limit is `MAX_STANDARD_TX_WEIGHT / 4` = 100_000. +const MAX_STANDARD_TX_SIZE: usize = (dashcore::policy::MAX_STANDARD_TX_WEIGHT / 4) as usize; + +/// Encoded size of one P2PKH output, matching key-wallet's `TX_OUTPUT_SIZE`. +const TX_OUTPUT_SIZE: usize = 34; + +/// Encoded size of one signed P2PKH input, matching the `148` key-wallet passes +/// to `select_coins_with_size`. +const TX_INPUT_SIZE: usize = 148; + +/// Largest a Bitcoin/Dash varint can encode to. Used instead of the exact +/// varint width so the size estimate never comes in under key-wallet's. +const MAX_VARINT_SIZE: usize = 9; + /// Upper bound on the caller-supplied fee rate, in duffs/kB. /// -/// Derived so that even a maximum-size standard transaction cannot produce a -/// fee above [`MAX_MONEY`]: Dash's standard-transaction limit is 100_000 bytes, -/// i.e. 100 kB, and `FeeRate::calculate_fee` computes -/// `sat_per_kb * size_bytes / 1000` — so `MAX_MONEY / 100` also keeps the -/// intermediate `sat_per_kb * size_bytes` product (≤ 2.1e18) inside `u64`. -const MAX_FEE_PER_KB: u64 = MAX_MONEY / 100; +/// `FeeRate::calculate_fee` computes `sat_per_kb * size_bytes` with **unchecked** +/// `u64` multiplication (key-wallet `managed_wallet_info/fee.rs`), and the public +/// Kotlin/FFI APIs accept any non-negative `Long` — so an unbounded rate panics +/// in an overflow-checking Android build, or wraps in release, silently turning +/// an astronomical requested rate into a tiny fee. +/// +/// The bound is derived so the product cannot overflow **for any transaction +/// size expressible in a `u32`** (~4.3 GB): with `sat_per_kb ≤ u64::MAX / +/// u32::MAX`, `sat_per_kb * size_bytes ≤ u64::MAX` whenever +/// `size_bytes ≤ u32::MAX`. That deliberately does NOT depend on the input +/// count. An earlier `MAX_MONEY / 100` bound assumed the transaction stayed +/// under [`MAX_STANDARD_TX_SIZE`], which this method never enforced — leaving +/// the product to overflow at ~878 kB, reachable both by an oversized recipient +/// list and by a CoinJoin account with a few thousand small denominations +/// (dashpay/platform#4247 and #4256 review). Since size is bounded by `u32` +/// long before it is bounded by policy, tying the bound to `u32::MAX` closes +/// the overflow unconditionally. +/// +/// ~4.29e9 duffs/kB is ~43 DASH/kB — three orders of magnitude above any +/// legitimate rate (the default is 1_000), so nothing real is rejected. The +/// maximum fee this permits on a standard-size transaction is +/// `MAX_FEE_PER_KB * 100` ≈ 4_295 DASH, still far below [`MAX_MONEY`]. +const MAX_FEE_PER_KB: u64 = u64::MAX / u32::MAX as u64; /// The unmixed BIP44 account this primitive is pinned to, in both of its roles: /// @@ -178,6 +216,64 @@ impl CoreWallet { )); } + // Bound the recipient count so the transaction stays relayable AND so + // key-wallet's unchecked `sat_per_kb * size_bytes` fee arithmetic cannot + // be driven to overflow from the output side. `outputs.len()` is the one + // caller-controlled size dimension (~25.8k recipients still fits in a + // practical JNI blob); the input count is wallet-owned and key-wallet + // caps it separately. + // + // Mirrors key-wallet's own base-size formula so the estimate is the one + // the builder will actually use: 8 bytes of version/type/locktime, a + // 1-byte input-count varint, the output-count varint (≤ 9, taken at its + // maximum so this never under-estimates), 34 bytes per P2PKH output, + // and 34 for the change output. Every step is checked — + // `outputs.len() * 34` is an unchecked `usize` multiply inside + // key-wallet. Room for at least one 148-byte input is required, since a + // transaction with no inputs cannot be funded. + let outputs_count = outputs.len(); + let base_size = outputs_count + .checked_mul(TX_OUTPUT_SIZE) + .and_then(|s| s.checked_add(8 + 1 + MAX_VARINT_SIZE + TX_OUTPUT_SIZE)) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "{outputs_count} recipients overflow the transaction size calculation" + )) + })?; + if base_size.saturating_add(TX_INPUT_SIZE) > MAX_STANDARD_TX_SIZE { + return Err(PlatformWalletError::TransactionBuild(format!( + "{outputs_count} recipients need {base_size} bytes of outputs, leaving no \ + room for inputs within the {MAX_STANDARD_TX_SIZE}-byte standard \ + transaction limit" + ))); + } + + // Reject below-dust recipients. `TransactionBuilder::add_output` applies + // no relay policy at all — it copies the requested amount straight into + // the `TxOut` — so without this a one-duff recipient produced a fully + // signed transaction that every standard node rejects as nonstandard, + // from a primitive documented as building a *standard* payment for + // later broadcast (dashpay/platform#4247 review). Checked per output + // against its OWN destination script, not a shared constant: the + // threshold is script-shaped (546 duffs for P2PKH, less for P2SH), + // which is also why key-wallet's hard-coded 546 change-dust literal is + // not reusable here. + // + // After the count bound so an absurd recipient list is rejected before + // this loop runs a script serialization per output, and before the + // wallet lock is taken, before any input is reserved, and before the + // signer is called — a request that can never relay must not tie up + // coins or prompt the user for a keystore signature. + for (address, amount) in &outputs { + let dust = address.script_pubkey().dust_value().to_sat(); + if *amount < dust { + return Err(PlatformWalletError::TransactionBuild(format!( + "output {amount} duffs to {address} is below the {dust}-duff dust \ + threshold for its script type; such a transaction cannot be relayed" + ))); + } + } + // Checked aggregation, bounded by MAX_MONEY. key-wallet sums the same // amounts with unchecked `u64` arithmetic while building, so an // unchecked total here would wrap in release builds (four outputs of @@ -420,8 +516,9 @@ impl CoreWallet { // a plain payment (no special payload) can carry is the single change // output back to the BIP44 sink, so `change = total_out − outputs`. // Any selected input we somehow can't price (impossible — every - // spendable UTXO was recorded above) counts as 0, so `fee` is over- - // rather than under-reported. + // spendable UTXO was recorded above) counts as 0, which LOWERS + // `selected_input_value` and therefore lowers the `saturating_sub` + // result: `fee` would be UNDER-reported, not over-. let selected_input_value: u64 = transaction .input .iter() @@ -431,6 +528,22 @@ impl CoreWallet { let fee = selected_input_value.saturating_sub(total_out); let change_amount = total_out.saturating_sub(outputs_total); + // Belt-and-braces: the pre-build check bounded only the output side, + // because the input count is not knowable until coin selection has run. + // Measure the transaction we actually built and refuse to hand back + // bytes that cannot relay. In practice this fires only for a request + // whose recipient list already passed the output-side bound but whose + // funding account then contributed enough small inputs to push the + // whole transaction over the limit. + let signed_size = transaction.size(); + if signed_size > MAX_STANDARD_TX_SIZE { + return Err(PlatformWalletError::TransactionBuild(format!( + "the signed transaction is {signed_size} bytes, over the \ + {MAX_STANDARD_TX_SIZE}-byte standard transaction limit; it would not relay. \ + Send a smaller amount (fewer inputs) or fewer recipients" + ))); + } + Ok(SignedCorePayment { transaction, fee, @@ -505,7 +618,13 @@ mod tests { /// so the broadcaster is irrelevant (and the balance handle is unused by /// build — a fresh one is fine for the split fixtures that don't return it). fn core_wallet( - wallet_manager: Arc>>, + wallet_manager: Arc< + tokio::sync::RwLock< + key_wallet_manager::WalletManager< + crate::wallet::platform_wallet::PlatformWalletInfo, + >, + >, + >, wallet_id: WalletId, balance: Arc, ) -> CoreWallet { @@ -538,13 +657,21 @@ mod tests { /// CoinJoin account's account-level derivation path (the `funding_path` a /// caller passes to spend previously-mixed coins deliberately). async fn split_account_outpoints_and_coinjoin_path( - wm: &Arc>>, + wm: &Arc< + tokio::sync::RwLock< + key_wallet_manager::WalletManager< + crate::wallet::platform_wallet::PlatformWalletInfo, + >, + >, + >, wallet_id: &WalletId, ) -> (HashSet, HashSet, DerivationPath) { use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; let guard = wm.read().await; - let (_, info) = guard.get_wallet_and_info(wallet_id).expect("wallet present"); + let (_, info) = guard + .get_wallet_and_info(wallet_id) + .expect("wallet present"); let network = info.core_wallet.network(); let bip44 = info .core_wallet @@ -911,11 +1038,244 @@ mod tests { let core = core_wallet(wm, wallet_id, balance); let empty = core.build_signed_payment(vec![], None, &signer, None).await; - assert!(matches!(empty, Err(PlatformWalletError::TransactionBuild(_)))); + assert!(matches!( + empty, + Err(PlatformWalletError::TransactionBuild(_)) + )); let zero = core .build_signed_payment(vec![(recipient(7), 0)], None, &signer, None) .await; - assert!(matches!(zero, Err(PlatformWalletError::TransactionBuild(_)))); + assert!(matches!( + zero, + Err(PlatformWalletError::TransactionBuild(_)) + )); + } + + /// A positive-but-below-dust recipient must be refused. `add_output` applies + /// no relay policy, so before this check the primitive happily returned + /// fully signed bytes for a transaction every standard node rejects as + /// nonstandard (dashpay/platform#4247 review). 546 duffs is the P2PKH + /// threshold `Script::dust_value()` computes. + #[tokio::test] + async fn below_dust_outputs_are_rejected() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(wm, wallet_id, balance); + + let to = recipient(42); + let dust = to.script_pubkey().dust_value().to_sat(); + assert_eq!(dust, 546, "P2PKH dust threshold"); + + for amount in [1u64, dust - 1] { + let result = core + .build_signed_payment(vec![(to.clone(), amount)], None, &signer, None) + .await; + match result { + Err(PlatformWalletError::TransactionBuild(m)) => assert!( + m.contains("dust"), + "the rejection must name dust as the cause, got {m:?}" + ), + other => panic!("{amount} duffs is below dust and must be refused, got {other:?}"), + } + } + + // A dust-sized output hidden among valid ones is caught too — the check + // is per output, not just on the first. + let mixed = core + .build_signed_payment( + vec![ + (recipient(1), 1_000_000), + (recipient(2), 5), + (recipient(3), 1_000_000), + ], + None, + &signer, + None, + ) + .await; + assert!( + matches!(mixed, Err(PlatformWalletError::TransactionBuild(ref m)) if m.contains("dust")), + "a below-dust output among valid ones must still be refused, got {mixed:?}" + ); + + // Exactly at the threshold is valid and still builds. + let at_threshold = core + .build_signed_payment(vec![(to, dust)], None, &signer, None) + .await + .expect("an output exactly at the dust threshold is standard"); + assert_all_inputs_signed(&at_threshold); + } + + /// Rejecting a below-dust request must not cost the caller anything: it + /// happens before the wallet lock, so no input is reserved and the very + /// next legitimate build still finds the account's coins selectable. + #[tokio::test] + async fn a_rejected_dust_request_reserves_nothing() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(wm, wallet_id, balance); + + for _ in 0..3 { + assert!(core + .build_signed_payment(vec![(recipient(9), 100)], None, &signer, None) + .await + .is_err()); + } + + let payment = core + .build_signed_payment(vec![(recipient(9), 1_000_000)], None, &signer, None) + .await + .expect("refused dust requests must not have reserved the account's UTXOs"); + assert_all_inputs_signed(&payment); + } + + /// The output total is aggregated with checked arithmetic and bounded by + /// `MAX_MONEY`. Four outputs of `1 << 62` sum to exactly 2^64: unchecked, + /// that wraps to zero in release builds and lets selection fund only the + /// fee while retaining four enormous outputs — a signed transaction + /// consensus rejects, with meaningless fee/change metadata. + #[tokio::test] + async fn output_total_overflow_and_max_money_are_rejected() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(wm, wallet_id, balance); + + let wrapping = vec![ + (recipient(1), 1u64 << 62), + (recipient(2), 1u64 << 62), + (recipient(3), 1u64 << 62), + (recipient(4), 1u64 << 62), + ]; + match core + .build_signed_payment(wrapping, None, &signer, None) + .await + { + Err(PlatformWalletError::TransactionBuild(m)) => assert!( + m.contains("MAX_MONEY"), + "a wrapping total must be refused as a monetary-bound breach, got {m:?}" + ), + other => panic!("4 × (1 << 62) wraps to zero and must be refused, got {other:?}"), + } + + // A single in-range-but-over-MAX_MONEY amount is refused as well. + let over = core + .build_signed_payment( + vec![(recipient(1), super::MAX_MONEY + 1)], + None, + &signer, + None, + ) + .await; + assert!( + matches!(over, Err(PlatformWalletError::TransactionBuild(ref m)) if m.contains("MAX_MONEY")), + "an amount over MAX_MONEY must be refused, got {over:?}" + ); + + // MAX_MONEY itself is within bounds, so it passes validation and fails + // later on funds — proving the bound is inclusive, not off by one. + let at_max = core + .build_signed_payment(vec![(recipient(1), super::MAX_MONEY)], None, &signer, None) + .await; + assert!( + matches!( + at_max, + Err(PlatformWalletError::PaymentInsufficientFunds { .. }) + ), + "MAX_MONEY exactly must pass the bound and fail on funds, got {at_max:?}" + ); + } + + /// The fee rate is bounded before it reaches key-wallet, whose + /// `calculate_fee` multiplies `sat_per_kb * size_bytes` unchecked — a rate + /// near `u64::MAX` (the Kotlin/FFI APIs accept any non-negative `Long`) + /// panics in an overflow-checking build or wraps in release. + #[tokio::test] + async fn excessive_fee_rates_are_rejected() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(wm, wallet_id, balance); + + for rate in [u64::MAX, u64::MAX / 2, super::MAX_FEE_PER_KB + 1] { + let result = core + .build_signed_payment(vec![(recipient(7), 1_000_000)], Some(rate), &signer, None) + .await; + match result { + Err(PlatformWalletError::TransactionBuild(m)) => assert!( + m.contains("fee rate"), + "the rejection must name the fee rate, got {m:?}" + ), + other => panic!("fee rate {rate} must be refused, got {other:?}"), + } + } + + // A sane rate still works, so the bound isn't rejecting real traffic. + let ok = core + .build_signed_payment(vec![(recipient(7), 1_000_000)], Some(5_000), &signer, None) + .await + .expect("5000 duffs/kB is an ordinary rate"); + assert!(ok.fee > 0); + } + + /// The fee-rate bound must make key-wallet's unchecked + /// `sat_per_kb * size_bytes` product unrepresentable-free for ANY + /// transaction size a `u32` can express — which is the point of deriving it + /// from `u32::MAX` rather than from the standard size limit. A cleanup that + /// loosened it back to `MAX_MONEY / 100` would overflow at ~878 kB, which a + /// funding account with a few thousand small denominations can reach. + #[test] + fn max_fee_rate_cannot_overflow_key_wallets_fee_product() { + for size in [super::MAX_STANDARD_TX_SIZE as u64, 878_434, u32::MAX as u64] { + assert!( + super::MAX_FEE_PER_KB.checked_mul(size).is_some(), + "MAX_FEE_PER_KB * {size} must not overflow u64" + ); + } + // And it stays permissive enough to be irrelevant in practice. + assert!( + super::MAX_FEE_PER_KB > 1_000_000, + "the bound must sit far above any legitimate duffs/kB rate" + ); + } + + /// An oversized recipient list is refused before any wallet work. ~25.8k + /// recipients fit in a practical JNI blob and would drive key-wallet's + /// estimated size past the point where the fee product overflows, as well + /// as producing a transaction far too large to relay. + #[tokio::test] + async fn oversized_recipient_lists_are_rejected() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(wm, wallet_id, balance); + + // Smallest count whose outputs alone leave no room for a single input + // within the 100 kB standard limit. + let over = (super::MAX_STANDARD_TX_SIZE - super::TX_INPUT_SIZE) / super::TX_OUTPUT_SIZE; + let outputs: Vec<_> = (0..over) + .map(|i| (recipient((i % 250) as u8), 1_000u64)) + .collect(); + let result = core + .build_signed_payment(outputs, None, &signer, None) + .await; + match result { + Err(PlatformWalletError::TransactionBuild(m)) => assert!( + m.contains("standard") && m.contains("recipients"), + "the rejection must cite the standard size limit, got {m:?}" + ), + other => panic!("{over} recipients must be refused, got {other:?}"), + } + + // The 25.8k figure from the review is refused by the same bound. + let huge: Vec<_> = (0..25_835) + .map(|i| (recipient((i % 250) as u8), 1_000u64)) + .collect(); + assert!( + matches!( + core.build_signed_payment(huge, Some(super::MAX_FEE_PER_KB), &signer, None) + .await, + Err(PlatformWalletError::TransactionBuild(_)) + ), + "the review's 25,835-recipient overflow case must be refused" + ); } } diff --git a/packages/rs-platform-wallet/src/wallet/funding_privacy.rs b/packages/rs-platform-wallet/src/wallet/funding_privacy.rs index a665410fb51..b9b2653928c 100644 --- a/packages/rs-platform-wallet/src/wallet/funding_privacy.rs +++ b/packages/rs-platform-wallet/src/wallet/funding_privacy.rs @@ -299,7 +299,9 @@ mod guardrail { let (bip44_ops, coinjoin_ops, coinjoin_path) = { let guard = wm.read().await; - let (_, info) = guard.get_wallet_and_info(&wallet_id).expect("wallet present"); + let (_, info) = guard + .get_wallet_and_info(&wallet_id) + .expect("wallet present"); let network = info.core_wallet.network(); let bip44: HashSet = info .core_wallet @@ -347,6 +349,11 @@ mod guardrail { .iter() .map(|i| i.previous_output) .collect(); + // `all` is vacuously true on an empty set, so without this the + // guardrail would pass while proving nothing. The sibling + // `explicit_coinjoin_path_selects_only_coinjoin` in `wallet::core::send` + // already asserts it. + assert!(!spent.is_empty(), "the payment must have selected inputs"); assert!( spent.iter().all(|op| coinjoin_ops.contains(op)), "every input must come from the named CoinJoin account, spent {spent:?}" From 6918bd7071efe9df64ebad087874851e9a8375ae Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Fri, 31 Jul 2026 19:20:00 -0400 Subject: [PATCH 38/47] fix(platform-wallet): fail closed when no wallet-level account matches the funding path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `build_signed_payment` / `finalize_signed_payment_from_funding_path` resolve the funding account twice: once in `wallet.all_accounts()` for the xpub `set_funding` derives change from, and once in the managed-account collection for the UTXOs and the reservation ledger. The first lookup fell back to the BIP44 account when it found no match, while the second can still resolve a CoinJoin or DashPay receival account — so a disagreement between them silently handed `set_funding` another account's xpub and recorded a change entry derived from it into the funding account's address pool. That is the same silent-fallback shape #4184 removed from the selector itself. Refusing is the only safe answer: the two lookups disagreeing is a wallet-state bug, not something to paper over with BIP44. Verified not to narrow any real path before changing it: `all_accounts()` does enumerate CoinJoin and DashPay receiving-funds accounts, so the whole send suite — including the receival and explicit-CoinJoin tests — passes with the fallback removed. It was dead code on every exercised path. Raised by shumkov (#4247) and CodeRabbit (#4256). Co-Authored-By: Claude Opus 4.8 --- .../src/wallet/core/send.rs | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/send.rs b/packages/rs-platform-wallet/src/wallet/core/send.rs index ec589cc7e81..acc38d918bd 100644 --- a/packages/rs-platform-wallet/src/wallet/core/send.rs +++ b/packages/rs-platform-wallet/src/wallet/core/send.rs @@ -408,8 +408,22 @@ impl CoreWallet { // `set_change_address` override, so `acc` must be the funding account — // passing the BIP44 xpub for an explicitly-selected BIP32 account would // record a change entry derived from the wrong xpub into that account's - // pool (dashpay/platform#4184 review). Falls back to `bip44_acc` when no - // wallet-level account matches, preserving the default behavior. + // pool (dashpay/platform#4184 review). + // + // FAILS CLOSED. This previously fell back to `bip44_acc` when no + // wallet-level account matched, which is the same silent-fallback shape + // #4184 removed from the selector: the managed-account lookup below can + // still resolve a CoinJoin or DashPay receival account, so the fallback + // would hand `set_funding` another account's xpub and record a change + // entry derived from it into the funding account's pool. Refusing is the + // only safe answer — the two lookups disagreeing is a wallet-state bug, + // not something to paper over with BIP44 (dashpay/platform#4247 and + // #4256 review). + // + // Verified not to narrow any real path: `all_accounts()` does enumerate + // CoinJoin and DashPay receiving-funds accounts, so every send test — + // including the explicit-CoinJoin one — passes with the fallback + // removed. It was dead code on every exercised path. let funding_wallet_acc = wallet .all_accounts() .into_iter() @@ -418,7 +432,11 @@ impl CoreWallet { .map(|p| p == funding_path) .unwrap_or(false) }) - .unwrap_or(&bip44_acc); + .ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "no wallet-level account matches funding derivation path {funding_path}; refusing to fund with another account's xpub" + )) + })?; // Locate the ONE managed funds account whose account-level path equals // `funding_path`, MUTABLY, so `set_funding` reserves the selected inputs From 0bdcd5e0a3a6eadb3a7387f641da4be91474b7c4 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:47:12 -0400 Subject: [PATCH 39/47] feat(kotlin-sdk): expose release_payment_reservation for abandoned builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `build_signed_payment` reserves its selected inputs and leaves them reserved on success, expecting a broadcast to follow. Nothing across Rust/FFI/JNI/Kotlin let a caller that declines to commit give those coins back, so an abandoned build stranded them for RESERVATION_TTL_BLOCKS (24, ~1h) — and indefinitely while the wallet has no processed height, since `ReservationSet::sweep` early-returns at height 0 and therefore never reclaims a pre-sync reservation. A single abandoned build on a freshly restored wallet could strand the whole balance for the life of the process. Adds a standalone release across all four layers: CoreWallet::release_payment_reservation(&Transaction, Option) core_wallet_release_payment_reservation (FFI) coreWalletReleasePaymentReservation (JNI) ManagedPlatformWallet.releasePaymentReservation (Kotlin) The transaction is the ownership signal: a reserved outpoint is skipped by every other build's coin selection, so no concurrent build can hold a reservation on any input of the transaction being released — releasing its inputs releases precisely this build's own reservation and can never free a competing build's coins. Same signal the internal `release_reservation_after_rejected_broadcast` cleanup already uses. The release consults no height, so it works pre-sync where the TTL backstop cannot. It is idempotent (per-outpoint map removal) and a silent no-op after a successful broadcast — it cannot resurrect a spent coin, since selection reads the UTXO set that sync already updated — so callers can wire it into an unconditional cleanup path. Also routes the build's default-funding-account resolution through the shared `bip44_account_path` helper, so a release and its build can never disagree about what `funding_path: None` means. Tests: release-then-reselect (with the second-build failure pinned as a precondition so it can't pass vacuously), release twice, release after a processed broadcast, release against the wrong account frees nothing, unknown funding path is refused, and the height-0 case — 30 build attempts prove the TTL never fires there, then the explicit release frees the inputs. Addresses review item 3 on dashpay/platform#4247. The reviewer's suggestion to unify this with #4256's reservation-token finalize is tracked separately rather than done here, to avoid reshaping an API the Android app already calls and hard-coupling this PR to #4185. Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/ffi/WalletManagerNative.kt | 29 ++ .../dashsdk/wallet/ManagedCoreWallet.kt | 9 + .../dashsdk/wallet/ManagedPlatformWallet.kt | 49 ++ .../src/core_wallet/send.rs | 72 +++ .../src/wallet/core/send.rs | 492 +++++++++++++++++- .../rs-unified-sdk-jni/src/wallet_manager.rs | 76 +++ 6 files changed, 708 insertions(+), 19 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 7debdf09ce9..826d1f7c77d 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 @@ -242,6 +242,35 @@ internal object WalletManagerNative { fundingPath: String?, ): ByteArray + /** + * `core_wallet_release_payment_reservation` — release the UTXO reservation + * a [coreWalletBuildSignedPayment] call took, for a build that will NOT be + * broadcast. + * + * `build_signed_payment` leaves its selected inputs reserved on success, + * expecting a broadcast to follow. A caller that abandons the build must + * call this or the coins stay unselectable until key-wallet's 24-block TTL + * backstop reclaims them — and that backstop never fires before the first + * sync completes (`ReservationSet::sweep` early-returns at height 0), so an + * abandoned build on a freshly restored wallet can otherwise strand the + * whole balance for the life of the process (dashpay/platform#4247 review). + * This call consults no height. + * + * [coreHandle] is a core-wallet handle from [platformWalletGetCore]. + * [txBytes] is the consensus-serialized signed transaction exactly as + * [coreWalletBuildSignedPayment] returned it — the transaction is the + * ownership signal, so only that build's own inputs are released. + * [fundingPath] must be the SAME optional path the build was given (null = + * the unmixed BIP44 account). + * + * Idempotent, and a silent no-op after a successful broadcast. + */ + external fun coreWalletReleasePaymentReservation( + coreHandle: Long, + txBytes: ByteArray, + fundingPath: String?, + ) + /** * `core_wallet_broadcast_transaction` — broadcast a transaction built by * [coreTxBuilderBuildSigned]. [accountType]/[accountIndex] identify the 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 28b4d891bb0..320b3f0a9d1 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 @@ -90,6 +90,15 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { fundingPath, ) + /** + * Release the UTXO reservation a [buildSignedPayment] call took, for a + * build that will not be broadcast. See + * [ManagedPlatformWallet.releasePaymentReservation] for the full contract; + * drive this through it, not directly. + */ + internal fun releasePaymentReservation(txBytes: ByteArray, fundingPath: String?) = + WalletManagerNative.coreWalletReleasePaymentReservation(handle, txBytes, fundingPath) + 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 f1a2672b977..280170cd184 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 @@ -521,6 +521,55 @@ class ManagedPlatformWallet internal constructor( } } + /** + * Release the UTXO reservation a [buildSignedPayment] call took, for a + * build this caller has decided **not** to broadcast. + * + * [buildSignedPayment] deliberately leaves its selected inputs reserved on + * success, because the expected next step is a broadcast. If the build is + * abandoned instead — the user backed out of the confirmation screen, an + * upstream check failed, the send screen is being torn down — this must be + * called or those coins stay unselectable. + * + * **Why it matters (dashpay/platform#4247 review).** Without it the inputs + * are stranded until key-wallet's TTL backstop reclaims them 24 blocks + * (~1 hour) later. Worse, that backstop does not merely run late but does + * not run at all before the first sync completes: the sweep early-returns + * while the wallet has no processed height, so a reservation taken at + * height 0 is never reclaimed for the life of the process. A single + * abandoned build on a freshly restored wallet could otherwise strand the + * entire balance. This call consults no height, so it is the one release + * path that works pre-sync. + * + * **Releases only this build's own inputs.** The transaction is the + * ownership signal: a reserved outpoint is skipped by every other build's + * coin selection, so no concurrent build can hold a reservation on any + * input of [txBytes]. + * + * **Idempotent, and safe after a broadcast.** Calling it twice, or on a + * transaction that was in fact broadcast, is a silent no-op rather than an + * error, and it cannot resurrect a spent coin — coin selection reads the + * UTXO set, from which sync removes the spend independently of any + * reservation. That makes it safe in an unconditional `finally` without + * tracking whether the broadcast succeeded. + * + * @param txBytes [SignedCorePayment.txBytes] from the build being + * abandoned. + * @param fundingPath the **same** [fundingPath] the build was given, so the + * release lands on the account holding the reservation; `null` means the + * unmixed BIP44 account, exactly as it does for the build. A path naming + * a different account is harmless but frees nothing. + */ + suspend fun releasePaymentReservation( + txBytes: ByteArray, + fundingPath: String? = null, + ): Unit = gate.op { + require(txBytes.isNotEmpty()) { "txBytes must not be empty" } + mapNativeErrors { + coreWallet().use { core -> core.releasePaymentReservation(txBytes, fundingPath) } + } + } + /** * 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/rs-platform-wallet-ffi/src/core_wallet/send.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs index 103f9c0eb68..f570ac3e9df 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs @@ -215,6 +215,78 @@ pub unsafe extern "C" fn core_wallet_free_payment_bytes(bytes: *mut u8, len: usi } } +/// Release the UTXO reservation a [`core_wallet_build_signed_payment`] call +/// took, for a build the caller has decided NOT to broadcast. +/// +/// `build_signed_payment` leaves its selected inputs reserved on success, +/// because the expected next step is a broadcast. A caller that abandons the +/// build instead — the user backed out, an upstream check failed, the app is +/// tearing down — must call this, or those coins stay unselectable until +/// key-wallet's 24-block TTL backstop reclaims them. Before the first sync +/// completes that backstop never fires at all (`ReservationSet::sweep` +/// early-returns at height 0), so without this call a single abandoned build +/// on a freshly restored wallet can strand the whole balance for the life of +/// the process (dashpay/platform#4247 review). This call consults no height. +/// +/// Releases ONLY this build's own inputs: the transaction is the ownership +/// signal, since a reserved outpoint is skipped by every other build's coin +/// selection, so no concurrent build can hold a reservation on any input of +/// `tx_bytes`. +/// +/// Idempotent, and a silent no-op when the transaction was in fact broadcast — +/// safe to wire into an unconditional cleanup path without tracking whether the +/// broadcast succeeded. +/// +/// * `handle` — a core-wallet handle (`platform_wallet_get_core`). +/// * `tx_bytes`/`tx_bytes_len` — the consensus-serialized signed transaction +/// exactly as `core_wallet_build_signed_payment` returned it. +/// * `funding_path_ptr`/`funding_path_len` — the SAME optional funding path the +/// build was given, so the release lands on the account holding the +/// reservation. `null` / `0` means the unmixed BIP44 account, as it does for +/// the build. A path naming a different account is harmless but frees +/// nothing. +/// +/// # Safety +/// `tx_bytes` must be readable for `tx_bytes_len` bytes; `funding_path_ptr`, +/// when non-null, must point to `funding_path_len` readable bytes for the +/// duration of the call. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_release_payment_reservation( + handle: Handle, + tx_bytes: *const u8, + tx_bytes_len: usize, + funding_path_ptr: *const u8, + funding_path_len: usize, +) -> PlatformWalletFFIResult { + check_ptr!(tx_bytes); + + let funding_path = match parse_optional_derivation_path(funding_path_ptr, funding_path_len) { + Ok(p) => p, + Err(result) => return result, + }; + + let raw = std::slice::from_raw_parts(tx_bytes, tx_bytes_len); + let transaction: dashcore::Transaction = + match dashcore::consensus::deserialize(raw).map_err(|e| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("tx_bytes is not a consensus-serialized transaction: {e}"), + ) + }) { + Ok(tx) => tx, + Err(result) => return result, + }; + + let option = CORE_WALLET_STORAGE.with_item(handle, |wallet| { + runtime().block_on(wallet.release_payment_reservation(&transaction, funding_path.clone())) + }); + + let result = unwrap_option_or_return!(option); + unwrap_result_or_return!(result); + + PlatformWalletFFIResult::ok() +} + /// Decoder hardening tests. /// /// `decode_payment_outputs` parses a caller-controlled blob inside an diff --git a/packages/rs-platform-wallet/src/wallet/core/send.rs b/packages/rs-platform-wallet/src/wallet/core/send.rs index acc38d918bd..18a5e2ca8e6 100644 --- a/packages/rs-platform-wallet/src/wallet/core/send.rs +++ b/packages/rs-platform-wallet/src/wallet/core/send.rs @@ -39,10 +39,21 @@ //! That reservation is in-memory only (never //! serialized) and is released when the spend is later processed back into the //! wallet by sync, or by the reservation-TTL backstop, or explicitly via -//! [`ManagedCoreFundsAccount::release_reservation`] for an abandoned build. No +//! [`CoreWallet::release_payment_reservation`] for an abandoned build. No //! balance is debited until the transaction actually confirms — exactly what //! the transition flow needs, since dashj owns commit/broadcast. //! +//! ## Abandoning a build +//! +//! A caller that builds and then decides not to broadcast MUST call +//! [`CoreWallet::release_payment_reservation`] with the transaction it was +//! handed. Without it the selected inputs stay reserved until the TTL backstop +//! fires 24 blocks later — and, critically, **forever** while the wallet has no +//! processed height: key-wallet's `ReservationSet::sweep` early-returns at +//! height 0, so a build made before the first sync completes can strand the +//! whole balance for the life of the process (dashpay/platform#4247 review). +//! The explicit release is height-independent and closes that hole. +//! //! [`ManagedCoreFundsAccount::release_reservation`]: //! key_wallet::managed_account::ManagedCoreFundsAccount::release_reservation @@ -337,24 +348,7 @@ impl CoreWallet { // Resolve the account-level path of the unmixed BIP44 account: both the // default funding source and the change sink. - let bip44_path = info - .core_wallet - .accounts - .standard_bip44_accounts - .get(&BIP44_ACCOUNT_INDEX) - .ok_or_else(|| { - PlatformWalletError::TransactionBuild(format!( - "BIP44 account {BIP44_ACCOUNT_INDEX} not found for payment funding" - )) - })? - .managed_account_type() - .to_account_type() - .derivation_path(network) - .map_err(|e| { - PlatformWalletError::TransactionBuild(format!( - "failed to derive the unmixed BIP44 account-level path: {e}" - )) - })?; + let bip44_path = bip44_account_path(info, network)?; let funding_path = funding_path.unwrap_or_else(|| bip44_path.clone()); let funds_from_change_account = funding_path == bip44_path; @@ -568,6 +562,142 @@ impl CoreWallet { change_amount, }) } + + /// Release the UTXO reservation that a previous [`build_signed_payment`] + /// took, for a build the caller has decided **not** to broadcast. + /// + /// [`build_signed_payment`] deliberately leaves its selected inputs + /// reserved on success, because the expected next step is a broadcast. A + /// caller that abandons the build instead — the user backed out of the + /// confirmation screen, an upstream check failed, the app is tearing + /// down — must say so, or those coins stay unselectable. + /// + /// ## Why this is needed (dashpay/platform#4247 review) + /// + /// Without an explicit release the inputs are stranded until key-wallet's + /// TTL backstop reclaims them `RESERVATION_TTL_BLOCKS` (24) blocks later, + /// roughly an hour. Worse, that backstop is not merely slow but *absent* + /// before the first sync completes: `ReservationSet::sweep` early-returns + /// when the current height is 0, so a reservation taken at height 0 is + /// never reclaimed for the life of the process. A single abandoned build + /// on a freshly restored wallet could therefore strand the entire balance + /// indefinitely. This method takes no height and consults none, so it is + /// the one release path that works pre-sync. + /// + /// ## What is released — only this build's own inputs + /// + /// The transaction *is* the ownership signal. `build_signed_payment` + /// reserves exactly the outpoints it selected, and a reserved outpoint is + /// skipped by every subsequent coin selection — so no concurrent build can + /// hold a reservation on any input of `transaction`. Releasing precisely + /// `transaction`'s inputs therefore releases precisely this build's own + /// reservation and can never free a competing in-flight build's coins. + /// (The same signal already backs the internal + /// [`release_reservation_after_rejected_broadcast`] cleanup.) + /// + /// [`release_reservation_after_rejected_broadcast`]: + /// crate::wallet::reservations::release_reservation_after_rejected_broadcast + /// + /// ## Idempotent, and safe after a broadcast + /// + /// Releasing is a per-outpoint map removal, so calling this twice — or on + /// a transaction that was in fact broadcast — is a silent no-op rather + /// than an error. It cannot resurrect a spent coin: coin selection reads + /// the UTXO set, and a broadcast spend is removed from that set by sync + /// independently of any reservation. That makes the release safe to wire + /// into an unconditional cleanup path (a `finally`, a teardown hook) + /// without the caller having to track whether the broadcast succeeded. + /// + /// ## Parameters + /// + /// * `transaction` — the transaction [`build_signed_payment`] returned + /// (`SignedCorePayment::transaction`), or the same bytes deserialized. + /// * `funding_path` — the **same** `funding_path` the build was given, so + /// the release lands on the account that holds the reservation. `None` + /// means the unmixed BIP44 account, exactly as it does for the build. + /// Passing a path that names a different account is harmless: that + /// account's ledger holds none of these outpoints, so nothing is + /// released. + /// + /// [`build_signed_payment`]: CoreWallet::build_signed_payment + pub async fn release_payment_reservation( + &self, + transaction: &Transaction, + funding_path: Option, + ) -> Result<(), PlatformWalletError> { + // `release_reservation` takes `&self` and no manager entry is mutated, + // so a read lock suffices — abandoning a build must not serialize + // against concurrent sends (same reasoning as the rejected-broadcast + // cleanup in `crate::wallet::reservations`). + let wm = self.wallet_manager.read().await; + let (_, info) = wm + .get_wallet_and_info(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + + let network = info.core_wallet.network(); + let funding_path = match funding_path { + Some(path) => path, + None => bip44_account_path(info, network)?, + }; + + // PRIVACY-DOMAIN-OK: iterates funds accounts only to LOOK ONE UP by + // derivation path, exactly as the build does. Nothing is accumulated + // across accounts and only the named account's ledger is touched. + for account in info.core_wallet.accounts.all_funding_accounts() { + let account_path = account + .managed_account_type() + .to_account_type() + .derivation_path(network) + .map_err(|e| { + PlatformWalletError::TransactionBuild(format!( + "failed to derive account-level path for a funds account: {e}" + )) + })?; + if account_path == funding_path { + account.release_reservation(transaction); + return Ok(()); + } + } + + // An unresolvable path is a caller error worth reporting, and is NOT + // the idempotent case: repeat releases resolve the account fine and + // no-op inside it. Watch-only accounts are not filtered out here the + // way the build filters them — releasing is not a spend, and a + // watch-only account can never have been funded a build to abandon. + Err(PlatformWalletError::TransactionBuild(format!( + "no funds account matches funding derivation path {funding_path}; \ + the build to abandon must be released against the account that funded it" + ))) + } +} + +/// Account-level derivation path of the unmixed BIP44 account at +/// [`BIP44_ACCOUNT_INDEX`] — the default funding source and the change sink. +/// +/// Shared by the build and the release paths so both resolve `funding_path: +/// None` to the same account; a release that disagreed with its build would +/// silently fail to free anything. +fn bip44_account_path( + info: &crate::wallet::platform_wallet::PlatformWalletInfo, + network: dashcore::Network, +) -> Result { + info.core_wallet + .accounts + .standard_bip44_accounts + .get(&BIP44_ACCOUNT_INDEX) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "BIP44 account {BIP44_ACCOUNT_INDEX} not found for payment funding" + )) + })? + .managed_account_type() + .to_account_type() + .derivation_path(network) + .map_err(|e| { + PlatformWalletError::TransactionBuild(format!( + "failed to derive the unmixed BIP44 account-level path: {e}" + )) + }) } /// Map a key-wallet [`BuilderError`] to a [`PlatformWalletError`], promoting the @@ -1296,4 +1426,328 @@ mod tests { "the review's 25,835-recipient overflow case must be refused" ); } + + // ------------------------------------------------------------------ + // Abandoning a build — `release_payment_reservation` + // + // `funded_wallet_manager` puts the WHOLE balance on a single UTXO, so + // "the reservation was released" and "the reservation was not released" + // are cleanly distinguishable: while that one input is reserved the next + // build has nothing to select and fails, and the moment it is released + // the next build succeeds. Every test below turns on that signal. + // ------------------------------------------------------------------ + + type TestWalletManager = Arc< + tokio::sync::RwLock< + key_wallet_manager::WalletManager, + >, + >; + + /// Force the wallet's `last_processed_height`. Lets a test reproduce the + /// pre-sync state (height 0) in which key-wallet's TTL sweep early-returns + /// and therefore never reclaims anything. + async fn set_last_processed_height(wm: &TestWalletManager, wallet_id: &WalletId, height: u32) { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let mut guard = wm.write().await; + let (_, info) = guard + .get_wallet_and_info_mut(wallet_id) + .expect("wallet present"); + info.core_wallet.update_last_processed_height(height); + } + + /// The BIP44 account-0 outpoints currently in the wallet's UTXO set. + async fn bip44_outpoints(wm: &TestWalletManager, wallet_id: &WalletId) -> HashSet { + let guard = wm.read().await; + let (_, info) = guard + .get_wallet_and_info(wallet_id) + .expect("wallet present"); + info.core_wallet + .accounts + .standard_bip44_accounts + .get(&0) + .map(|a| a.utxos.keys().copied().collect()) + .unwrap_or_default() + } + + /// Process `tx` back into the wallet as a chain-locked spend — what sync + /// does after a real broadcast confirms, removing the spent input from the + /// UTXO set. + async fn process_spend( + wm: &TestWalletManager, + wallet_id: &WalletId, + tx: &dashcore::Transaction, + ) { + use dashcore::BlockHash; + use key_wallet::transaction_checking::{ + BlockInfo, TransactionContext, WalletTransactionChecker, + }; + + let mut guard = wm.write().await; + let (wallet, info) = guard + .get_wallet_mut_and_info_mut(wallet_id) + .expect("wallet present"); + info.core_wallet + .check_core_transaction( + tx, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 2, + BlockHash::all_zeros(), + 1_700_000_100, + )), + wallet, + true, + true, + ) + .await; + } + + /// The core contract: a build reserves its inputs (proved by the second + /// build failing), and abandoning it makes exactly those inputs selectable + /// again immediately — no TTL wait. + #[tokio::test] + async fn abandoning_a_build_makes_its_inputs_selectable_again() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(Arc::clone(&wm), wallet_id, balance); + + let payment = core + .build_signed_payment(vec![(recipient(4), 1_000_000)], None, &signer, None) + .await + .expect("the funded account covers the payment"); + let reserved: HashSet = payment + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + assert!(!reserved.is_empty(), "the build must have selected inputs"); + + // Precondition: the reservation is real and it is what blocks a + // second build. Without this the test could pass vacuously. + assert!( + matches!( + core.build_signed_payment(vec![(recipient(4), 1_000_000)], None, &signer, None) + .await, + Err(PlatformWalletError::PaymentInsufficientFunds { .. }) + ), + "the first build's reservation must block a second build" + ); + + core.release_payment_reservation(&payment.transaction, None) + .await + .expect("abandoning a build must succeed"); + + let after = core + .build_signed_payment(vec![(recipient(4), 1_000_000)], None, &signer, None) + .await + .expect("the abandoned build's inputs must be selectable again"); + let reselected: HashSet = after + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + assert_eq!( + reselected, reserved, + "the rebuild must reselect exactly the released inputs" + ); + assert_all_inputs_signed(&after); + } + + /// Releasing twice is a no-op, not an error: the second call resolves the + /// funding account fine and removes outpoints that are already gone. This + /// is what lets a caller wire the release into an unconditional cleanup + /// path without tracking whether it already ran. + #[tokio::test] + async fn abandoning_twice_is_a_no_op() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(Arc::clone(&wm), wallet_id, balance); + + let payment = core + .build_signed_payment(vec![(recipient(5), 1_000_000)], None, &signer, None) + .await + .expect("the funded account covers the payment"); + + for attempt in 0..3 { + core.release_payment_reservation(&payment.transaction, None) + .await + .unwrap_or_else(|e| panic!("release attempt {attempt} must be a no-op, got {e:?}")); + } + + // Still exactly one release's worth of effect: the coins are free. + core.build_signed_payment(vec![(recipient(5), 1_000_000)], None, &signer, None) + .await + .expect("repeated releases must leave the inputs selectable"); + } + + /// Releasing after the transaction was actually broadcast and confirmed is + /// a no-op, and critically cannot resurrect the spent coin: coin selection + /// reads the UTXO set, from which sync has already removed the spend, so + /// the released reservation has nothing to expose. A caller that always + /// releases in a `finally` therefore cannot double-spend itself. + #[tokio::test] + async fn abandoning_after_broadcast_is_a_no_op() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(Arc::clone(&wm), wallet_id, balance); + + // Send the entire balance so the confirmed spend leaves no change to + // fund a follow-up build — any later success could only come from a + // resurrected input. + let payment = core + .build_signed_payment(vec![(recipient(6), 9_900_000)], None, &signer, None) + .await + .expect("the funded account covers the payment"); + let spent: HashSet = payment + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + + // Stand in for the caller broadcasting and sync observing it. + process_spend(&wm, &wallet_id, &payment.transaction).await; + + core.release_payment_reservation(&payment.transaction, None) + .await + .expect("releasing after a broadcast must be a silent no-op, not an error"); + + let live = bip44_outpoints(&wm, &wallet_id).await; + assert!( + spent.iter().all(|op| !live.contains(op)), + "the release must not resurrect the spent inputs {spent:?} into the UTXO set {live:?}" + ); + } + + /// The height-0 case shumkov flagged: before the first sync completes the + /// wallet's processed height is 0, and key-wallet's `ReservationSet::sweep` + /// early-returns at height 0 — so the TTL backstop never fires and an + /// abandoned build strands the balance for the life of the process. This + /// pins both halves: the TTL genuinely cannot recover it, and the explicit + /// release can. + #[tokio::test] + async fn abandoning_releases_at_height_zero_where_the_ttl_never_fires() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + // Pre-sync: no processed height yet. The funding UTXO is non-coinbase, + // so it stays spendable at height 0 — only the sweep is disabled. + set_last_processed_height(&wm, &wallet_id, 0).await; + let core = core_wallet(Arc::clone(&wm), wallet_id, balance); + + let payment = core + .build_signed_payment(vec![(recipient(8), 1_000_000)], None, &signer, None) + .await + .expect("a pre-sync wallet can still build from a confirmed UTXO"); + assert!( + !payment.transaction.input.is_empty(), + "the build must have selected — and so reserved — inputs at height 0" + ); + + // The TTL backstop is inert here: even far beyond RESERVATION_TTL_BLOCKS + // worth of build attempts, the height-0 reservation is never swept, so + // the coins stay stranded. This is the bug, reproduced. + for _ in 0..30 { + assert!( + matches!( + core.build_signed_payment(vec![(recipient(8), 1_000_000)], None, &signer, None) + .await, + Err(PlatformWalletError::PaymentInsufficientFunds { .. }) + ), + "at height 0 the TTL sweep must never reclaim the reservation" + ); + } + + // The explicit release consults no height, so it works where the TTL + // cannot. + core.release_payment_reservation(&payment.transaction, None) + .await + .expect("the release must not depend on a processed height"); + + core.build_signed_payment(vec![(recipient(8), 1_000_000)], None, &signer, None) + .await + .expect("releasing at height 0 must free the stranded inputs"); + } + + /// A release aimed at the wrong account frees nothing — the reservation + /// lives in the funding account's own ledger. Guards the "releases ONLY + /// its own build's inputs" property against a path-confusion regression. + #[tokio::test] + async fn releasing_against_another_account_frees_nothing() { + let (wm, wallet_id, signer) = split_funded_wallet_manager(9_000_000, 20_000_000).await; + let (_, _, coinjoin_path) = + split_account_outpoints_and_coinjoin_path(&wm, &wallet_id).await; + let core = core_wallet(Arc::clone(&wm), wallet_id, Arc::new(WalletBalance::new())); + + // Fund from CoinJoin, then try to release against the BIP44 default. + let payment = core + .build_signed_payment( + vec![(recipient(7), 15_000_000)], + None, + &signer, + Some(coinjoin_path.clone()), + ) + .await + .expect("the named CoinJoin account covers 0.15 DASH"); + + core.release_payment_reservation(&payment.transaction, None) + .await + .expect("a mismatched release resolves the account and simply frees nothing"); + assert!( + matches!( + core.build_signed_payment( + vec![(recipient(7), 15_000_000)], + None, + &signer, + Some(coinjoin_path.clone()), + ) + .await, + Err(PlatformWalletError::PaymentInsufficientFunds { .. }) + ), + "releasing against BIP44 must not free the CoinJoin account's reservation" + ); + + // The correctly-aimed release does free it. + core.release_payment_reservation(&payment.transaction, Some(coinjoin_path.clone())) + .await + .expect("releasing against the funding account must succeed"); + core.build_signed_payment( + vec![(recipient(7), 15_000_000)], + None, + &signer, + Some(coinjoin_path), + ) + .await + .expect("the CoinJoin inputs must be selectable again"); + } + + /// An unresolvable funding path is reported rather than silently treated + /// as "nothing to release" — a caller passing a bad path would otherwise + /// believe it had cleaned up. + #[tokio::test] + async fn releasing_with_an_unknown_funding_path_is_rejected() { + use std::str::FromStr; + + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(Arc::clone(&wm), wallet_id, balance); + + let payment = core + .build_signed_payment(vec![(recipient(3), 1_000_000)], None, &signer, None) + .await + .expect("the funded account covers the payment"); + + let bogus = DerivationPath::from_str("m/44'/5'/77'").expect("valid path"); + match core + .release_payment_reservation(&payment.transaction, Some(bogus)) + .await + { + Err(PlatformWalletError::TransactionBuild(m)) => assert!( + m.contains("no funds account matches"), + "the rejection must name the unresolvable path, got {m:?}" + ), + other => panic!("an unknown funding path must be refused, got {other:?}"), + } + } } diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 5288ef3af69..ebef85df6cc 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1119,6 +1119,82 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c }) } +/// `core_wallet_release_payment_reservation` — release the UTXO reservation a +/// [coreWalletBuildSignedPayment] call took, for a build that will NOT be +/// broadcast. +/// +/// `build_signed_payment` leaves its selected inputs reserved on success, +/// expecting a broadcast to follow. A caller that abandons the build instead +/// must call this or the coins stay unselectable until key-wallet's 24-block +/// TTL backstop reclaims them — and that backstop never fires before the first +/// sync completes (`ReservationSet::sweep` early-returns at height 0), so an +/// abandoned build on a freshly restored wallet can otherwise strand the whole +/// balance for the life of the process (dashpay/platform#4247 review). This +/// call consults no height. +/// +/// `core_handle` is the transient core-wallet `Handle` from +/// [platformWalletGetCore]. `tx_bytes` is the consensus-serialized signed +/// transaction exactly as [coreWalletBuildSignedPayment] returned it — the +/// transaction is the ownership signal, so only this build's own inputs are +/// released. `funding_path` must be the SAME optional path the build was given +/// (null = the unmixed BIP44 account). +/// +/// Idempotent, and a silent no-op after a successful broadcast, so it is safe +/// in an unconditional cleanup path. Throws only on an invalid handle, +/// undecodable transaction bytes, or an unresolvable funding path. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreWalletReleasePaymentReservation( + mut env: JNIEnv, + _class: JClass, + core_handle: jlong, + tx_bytes: JByteArray, + funding_path: JString, +) { + guard(&mut env, (), |env| { + if core_handle == 0 { + throw_sdk_exception(env, 1, "core handle is 0"); + return; + } + let raw = match env.convert_byte_array(&tx_bytes) { + Ok(b) => b, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, "txBytes byte[] was invalid"); + return; + } + }; + if raw.is_empty() { + throw_sdk_exception(env, 1, "txBytes must not be empty"); + return; + } + // STRICT reader, matching the build: a genuine read error must throw + // rather than silently degrade to the default BIP44 account, which + // would release against the wrong ledger and leave the real + // reservation stranded — the exact failure this export exists to fix. + let funding_path = + match crate::funding::read_cstring_opt_strict(env, &funding_path, "fundingPath") { + Ok(v) => v, + Err(()) => return, + }; + let (funding_path_ptr, funding_path_len) = + funding_path.as_ref().map_or((ptr::null(), 0usize), |c| { + let b = c.as_bytes(); + (b.as_ptr(), b.len()) + }); + + let result = unsafe { + platform_wallet_ffi::core_wallet_release_payment_reservation( + core_handle as Handle, + raw.as_ptr(), + raw.len(), + funding_path_ptr, + funding_path_len, + ) + }; + take_pwffi_error(env, result); + }) +} + /// `platform_wallet_get_core` — resolve the transient core-wallet `Handle` /// (as `jlong`) from a `PlatformWallet` handle, for [coreWalletBroadcastTransaction]. /// Free with [coreWalletDestroy]. Returns 0 after throwing. From 04820410868c7fa322590a185bb65b0898dc69e3 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:46:31 -0400 Subject: [PATCH 40/47] docs(ffi): drop the redundant "later ... afterwards" in the send module doc Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-ffi/src/core_wallet/send.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs index f570ac3e9df..527df0a05c3 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs @@ -7,7 +7,7 @@ //! the **signed serialized transaction bytes** plus the computed fee and change //! amount. It does NOT broadcast and does NOT persist a debit — the caller //! commits/broadcasts the returned bytes itself (dashj during the Android -//! transition; a later SDK-broadcast mode afterwards). +//! transition; an SDK-broadcast mode afterwards). //! //! Coin selection never unions funding accounts: `funding_path` names exactly //! one, defaulting to the unmixed BIP44 account. See From f034df713a758b9bfad8839647db2ab50728a028 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:34:01 -0400 Subject: [PATCH 41/47] fix(platform-wallet): adopt #4185's WalletGeneration in the send-path test fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restack adaptation only — no production-code change. This branch now sits on top of #4185 (port/v4.1/split-build-broadcast) rather than beside it. #4185 replaced the bare `Arc` generation marker with `Arc` (balance + that generation's lifecycle gate in one Arc, so "same generation" and "same gate" cannot diverge), and renamed `PlatformWalletInfo::balance` to `::generation`. The send-path test fixtures added here still built wallets the old way, so they no longer compiled against the new base. Point them at the shared `WalletGeneration` the rest of the crate already uses: * `core/send.rs` — the local `core_wallet` fixture takes `Arc`; `funded_wallet_manager` already hands one back. * `wallet/funding_privacy.rs` — same, for its two fixtures. * `test_support.rs` — the DashPay split fixture populates `PlatformWalletInfo::generation`. cargo test -p platform-wallet -p platform-wallet-ffi: 537 + 230 pass. --- .../rs-platform-wallet/src/test_support.rs | 4 ++-- .../src/wallet/core/send.rs | 21 +++++++++---------- .../src/wallet/funding_privacy.rs | 6 +++--- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index db455e9ca7a..9c10f0e0909 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -407,10 +407,10 @@ pub(crate) async fn split_funded_wallet_manager( wallet: ctx.wallet.clone(), }; - let balance = Arc::new(WalletBalance::new()); + let generation = Arc::new(WalletGeneration::new()); let info = PlatformWalletInfo { core_wallet: ctx.managed_wallet, - balance, + generation, identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), }; diff --git a/packages/rs-platform-wallet/src/wallet/core/send.rs b/packages/rs-platform-wallet/src/wallet/core/send.rs index 18a5e2ca8e6..c08a85d5bc9 100644 --- a/packages/rs-platform-wallet/src/wallet/core/send.rs +++ b/packages/rs-platform-wallet/src/wallet/core/send.rs @@ -755,15 +755,14 @@ mod tests { use crate::test_support::{ funded_wallet_manager, split_funded_wallet_manager, AlwaysRejectedBroadcaster, }; - use crate::wallet::core::balance::WalletBalance; - use crate::wallet::core::CoreWallet; + use crate::wallet::core::{CoreWallet, WalletGeneration}; use crate::wallet::platform_wallet::WalletId; use crate::PlatformWalletError; use super::SignedCorePayment; /// A `CoreWallet` over a manager fixture. The send path never broadcasts, - /// so the broadcaster is irrelevant (and the balance handle is unused by + /// so the broadcaster is irrelevant (and the generation handle is unused by /// build — a fresh one is fine for the split fixtures that don't return it). fn core_wallet( wallet_manager: Arc< @@ -774,7 +773,7 @@ mod tests { >, >, wallet_id: WalletId, - balance: Arc, + generation: Arc, ) -> CoreWallet { let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); CoreWallet::new( @@ -782,7 +781,7 @@ mod tests { wallet_manager, wallet_id, Arc::new(AlwaysRejectedBroadcaster), - balance, + generation, ) } @@ -905,7 +904,7 @@ mod tests { async fn default_funding_never_selects_other_domains() { // 0.09 DASH on BIP44, 0.09 on CoinJoin; ask 0.15 → only a union covers it. let (wm, wallet_id, signer) = split_funded_wallet_manager(9_000_000, 9_000_000).await; - let core = core_wallet(wm, wallet_id, Arc::new(WalletBalance::new())); + let core = core_wallet(wm, wallet_id, Arc::new(WalletGeneration::new())); let result = core .build_signed_payment(vec![(recipient(7), 15_000_000)], None, &signer, None) @@ -942,7 +941,7 @@ mod tests { let (bip44_ops, coinjoin_ops, _) = split_account_outpoints_and_coinjoin_path(&wm, &wallet_id).await; - let core = core_wallet(wm, wallet_id, Arc::new(WalletBalance::new())); + let core = core_wallet(wm, wallet_id, Arc::new(WalletGeneration::new())); let payment = core .build_signed_payment(vec![(recipient(7), 15_000_000)], None, &signer, None) .await @@ -977,7 +976,7 @@ mod tests { let (bip44_ops, coinjoin_ops, coinjoin_path) = split_account_outpoints_and_coinjoin_path(&wm, &wallet_id).await; - let core = core_wallet(wm, wallet_id, Arc::new(WalletBalance::new())); + let core = core_wallet(wm, wallet_id, Arc::new(WalletGeneration::new())); let payment = core .build_signed_payment( vec![(recipient(7), 15_000_000)], @@ -1020,7 +1019,7 @@ mod tests { let (wm, wallet_id, signer) = split_funded_wallet_manager(9_000_000, 9_000_000).await; let (_, _, coinjoin_path) = split_account_outpoints_and_coinjoin_path(&wm, &wallet_id).await; - let core = core_wallet(wm, wallet_id, Arc::new(WalletBalance::new())); + let core = core_wallet(wm, wallet_id, Arc::new(WalletGeneration::new())); let result = core .build_signed_payment( @@ -1141,7 +1140,7 @@ mod tests { .expect("insert watch-only external account"); } - let core = core_wallet(wm, wallet_id, Arc::new(WalletBalance::new())); + let core = core_wallet(wm, wallet_id, Arc::new(WalletGeneration::new())); // Ask for 0.5 DASH: covered only if the 1.0-DASH watch-only UTXO were // spendable. It is on a different domain from the default BIP44 funding @@ -1678,7 +1677,7 @@ mod tests { let (wm, wallet_id, signer) = split_funded_wallet_manager(9_000_000, 20_000_000).await; let (_, _, coinjoin_path) = split_account_outpoints_and_coinjoin_path(&wm, &wallet_id).await; - let core = core_wallet(Arc::clone(&wm), wallet_id, Arc::new(WalletBalance::new())); + let core = core_wallet(Arc::clone(&wm), wallet_id, Arc::new(WalletGeneration::new())); // Fund from CoinJoin, then try to release against the BIP44 default. let payment = core diff --git a/packages/rs-platform-wallet/src/wallet/funding_privacy.rs b/packages/rs-platform-wallet/src/wallet/funding_privacy.rs index b9b2653928c..0e76adeb9f4 100644 --- a/packages/rs-platform-wallet/src/wallet/funding_privacy.rs +++ b/packages/rs-platform-wallet/src/wallet/funding_privacy.rs @@ -129,7 +129,7 @@ mod guardrail { use dashcore::{Address as DashAddress, Network, OutPoint}; use crate::test_support::{split_funded_wallet_manager, AlwaysRejectedBroadcaster}; - use crate::wallet::core::balance::WalletBalance; + use crate::wallet::core::WalletGeneration; use crate::wallet::core::CoreWallet; use crate::PlatformWalletError; @@ -267,7 +267,7 @@ mod guardrail { wm, wallet_id, Arc::new(AlwaysRejectedBroadcaster), - Arc::new(WalletBalance::new()), + Arc::new(WalletGeneration::new()), ); let payment = core .build_signed_payment( @@ -331,7 +331,7 @@ mod guardrail { wm, wallet_id, Arc::new(AlwaysRejectedBroadcaster), - Arc::new(WalletBalance::new()), + Arc::new(WalletGeneration::new()), ); let payment = core .build_signed_payment( From 9ea2942adcd4a12ccaa4d6f4f4c8cf53f76fdaec Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:52:50 -0400 Subject: [PATCH 42/47] fix(swift-sdk): mirror ErrorTransactionBuild (32) into the Swift result space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ErrorTransactionBuild = 32` was added to the Rust FFI enum and to the Kotlin decoder by this PR, but not to the Swift mirror. Swift decodes an unlisted raw value through the `default:` arm of `PlatformWalletResultCode .init(ffi:)`, so every typed build rejection this PR introduced — an unresolvable or watch-only `fundingPath`, a breached monetary bound, a malformed recipients blob — reached iOS as `errorUnknown` (99) with only the message string to distinguish it. That is the exact failure the code was split out of `ErrorUnknown` to end, reintroduced on the other host. Adds the enum case, the `init(ffi:)` mapping, and the `PlatformWalletError.transactionBuild` arm. There is no compile-time check that the raw values match Rust, so the numbering comment is kept in sync with the registry (dashpay/platform#4261) and now records only the codes this PR does NOT own. Verified against the cbindgen header: PLATFORM_WALLET_FFI_RESULT_CODE_ ERROR_TRANSACTION_BUILD = 32. No other exhaustive switch over PlatformWalletResultCode / PlatformWalletError exists in the Swift SDK. --- .../PlatformWallet/PlatformWalletResult.swift | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 14f5890544f..f2766f92389 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -84,12 +84,19 @@ public enum PlatformWalletResultCode: Int32, Sendable { // Codes 27-33 are claimed outside this PR and must not be reused here: // 27 errorShutdownIncomplete (dashpay/platform#4268, merged), 29 // errorAssetLockInsufficientFunds (#4184), 31 errorSigningKeyUnavailable - // (#4183/#4259), 32 errorTransactionBuild (#4247/#4256), 33 - // errorTransactionSigning (#4256); 28 and 30 are free. The deferred-token + // (#4183/#4259); 28 and 30 are vacated-but-reserved. The deferred-token // trio therefore occupies the contiguous block 34-36. These raw values // MUST match `PlatformWalletFFIResultCode` in // packages/rs-platform-wallet-ffi/src/error.rs — there is no compile-time // check across the ABI. See ERROR_CODE_REGISTRY.md (#4261). + /// A Core transaction could not be assembled from the request: a + /// `fundingPath` matching no spendable funds account or naming a + /// watch-only one, a request breaching a monetary bound (MAX_MONEY total, + /// max fee rate, a below-dust recipient, an over-100 kB recipient list), or + /// a malformed recipients blob. The REQUEST is at fault, so a verbatim + /// retry fails identically — the caller must change it. Nothing was + /// reserved or broadcast. + case errorTransactionBuild = 32 /// 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 @@ -189,6 +196,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorShutdownIncomplete case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SIGNING_KEY_UNAVAILABLE: self = .errorSigningKeyUnavailable + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_TRANSACTION_BUILD: + self = .errorTransactionBuild case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_STALE_RESERVATION_TOKEN: self = .errorStaleReservationToken case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_RESERVATION_TOKEN_CONSUMED: @@ -310,6 +319,11 @@ public enum PlatformWalletError: LocalizedError { /// Core definitively rejected the transaction and its input reservation /// was released. Unlike `transactionBroadcastUnconfirmed`, retry is safe. case transactionBroadcastRejected(String) + /// A Core transaction could not be assembled from the request — an + /// unresolvable or watch-only funding path, a breached monetary bound, or a + /// malformed recipients blob. The REQUEST is at fault: a verbatim retry + /// fails identically. Nothing was reserved or broadcast. + case transactionBuild(String) /// Definitively-failed address-nonce race (shield, or identity /// top-up-from-addresses): Platform rejected the transition because the /// submitted address nonce raced its expected value. The transition did @@ -370,6 +384,7 @@ public enum PlatformWalletError: LocalizedError { .shieldedNoRecordedAnchor(let m), .transactionBroadcastUnconfirmed(let m), .transactionBroadcastRejected(let m), + .transactionBuild(let m), .addressNonceMismatch(let m), .shutdownIncomplete(let m), .signingKeyUnavailable(let m), @@ -414,6 +429,8 @@ public enum PlatformWalletError: LocalizedError { self = .transactionBroadcastUnconfirmed(detail) case .errorTransactionBroadcastRejected: self = .transactionBroadcastRejected(detail) + case .errorTransactionBuild: + self = .transactionBuild(detail) case .errorAddressNonceMismatch: self = .addressNonceMismatch(detail) case .errorShutdownIncomplete: From d319572bd85243bf3800e25d228323d1714142b8 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:55:31 -0400 Subject: [PATCH 43/47] style(platform-wallet): rustfmt the restacked send-path test fixtures `cargo fmt --check` fallout from the `WalletGeneration` adaptation two commits down: the shorter type name let two fixture signatures fit differently. No behaviour change. --- packages/rs-platform-wallet/src/wallet/core/send.rs | 6 +++++- packages/rs-platform-wallet/src/wallet/funding_privacy.rs | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/send.rs b/packages/rs-platform-wallet/src/wallet/core/send.rs index c08a85d5bc9..022c57ae492 100644 --- a/packages/rs-platform-wallet/src/wallet/core/send.rs +++ b/packages/rs-platform-wallet/src/wallet/core/send.rs @@ -1677,7 +1677,11 @@ mod tests { let (wm, wallet_id, signer) = split_funded_wallet_manager(9_000_000, 20_000_000).await; let (_, _, coinjoin_path) = split_account_outpoints_and_coinjoin_path(&wm, &wallet_id).await; - let core = core_wallet(Arc::clone(&wm), wallet_id, Arc::new(WalletGeneration::new())); + let core = core_wallet( + Arc::clone(&wm), + wallet_id, + Arc::new(WalletGeneration::new()), + ); // Fund from CoinJoin, then try to release against the BIP44 default. let payment = core diff --git a/packages/rs-platform-wallet/src/wallet/funding_privacy.rs b/packages/rs-platform-wallet/src/wallet/funding_privacy.rs index 0e76adeb9f4..73c86d9329b 100644 --- a/packages/rs-platform-wallet/src/wallet/funding_privacy.rs +++ b/packages/rs-platform-wallet/src/wallet/funding_privacy.rs @@ -129,8 +129,8 @@ mod guardrail { use dashcore::{Address as DashAddress, Network, OutPoint}; use crate::test_support::{split_funded_wallet_manager, AlwaysRejectedBroadcaster}; - use crate::wallet::core::WalletGeneration; use crate::wallet::core::CoreWallet; + use crate::wallet::core::WalletGeneration; use crate::PlatformWalletError; // -- static guard -------------------------------------------------------- From 189f066eadfd0170adea7e503a28c621f338cb69 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:19:12 -0400 Subject: [PATCH 44/47] fix(platform-wallet): owner-guard the raw-payment reservation release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three blocking review findings on the send-raw-tx path, all in the build/abandon reservation lifecycle. 1. Post-signing size rejection stranded its selected inputs. `build_signed` had already reserved them when the `signed_size > MAX_STANDARD_TX_SIZE` check ran, and the error path returned without releasing and without handing back the transaction the caller would need to release it itself. The TTL backstop is no fallback: `ReservationSet::sweep` early-returns at height 0, so on a freshly restored wallet those coins were stranded for the process lifetime. The path now releases before returning. 2. Abandonment could release another build's inputs. `release_reservation` removes entries by outpoint with no owner check. Once key-wallet's TTL sweeps a reservation, a concurrent build can legitimately re-reserve the same outpoint under a new token, and a late release then freed THAT build's inputs — a double-spend window. The build now keeps the `ReservationToken` from `build_signed_reserved` and the release is owner-guarded via `release_reservation_if_owner`, plus generation-bound the way `release_transaction_reservation` is. The doc block claiming the transaction alone was a sufficient ownership signal ("no concurrent build can hold a reservation on any input") is corrected: that reasoning ignored the TTL sweep and is refuted by key-wallet's own docs, which state the platform layer cannot make this safe on its own. The "safe after a broadcast / wire it into an unconditional cleanup path" contract is also removed. This primitive does not broadcast, so between the caller's successful broadcast and sync processing that spend the inputs are still in the UTXO set and the reservation is the only thing keeping a second build off them. Callers must release only builds they did NOT broadcast. The key-wallet token is deliberately unforgeable (private counter, no public constructor) and so cannot cross the C ABI. It is threaded to the host as an opaque handle from a bounded FIFO table, mirroring how `SignedPaymentRegistry` keeps its funding token Rust-side while a u64 payment handle crosses the boundary. An unknown handle is refused rather than downgraded to the unguarded release. `split_funded_wallet_manager` now returns its real `WalletGeneration`. Callers previously invented a fresh one, which silently makes every generation-bound assertion vacuous. Tests: the size-rejection path leaves no reservation held; an owner-guarded release cannot free a re-reserved input (with the unguarded release as the control that proves the guard is what closes it); releasing a broadcast build before sync reopens its inputs. Co-Authored-By: Claude Opus 4.8 --- .../src/core_wallet/mod.rs | 1 + .../src/core_wallet/payment_reservation.rs | 307 +++++++++ .../src/core_wallet/send.rs | 66 +- .../rs-platform-wallet/src/test_support.rs | 10 +- .../src/wallet/core/send.rs | 597 +++++++++++++++--- .../src/wallet/funding_privacy.rs | 10 +- 6 files changed, 886 insertions(+), 105 deletions(-) create mode 100644 packages/rs-platform-wallet-ffi/src/core_wallet/payment_reservation.rs 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 2cf51776602..4ca697bb418 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs @@ -4,6 +4,7 @@ mod addresses; mod broadcast; +pub(crate) mod payment_reservation; mod send; pub(crate) mod signed_payment; mod transaction_builder; diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/payment_reservation.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/payment_reservation.rs new file mode 100644 index 00000000000..277a5fd9da1 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/payment_reservation.rs @@ -0,0 +1,307 @@ +//! ABI-side handle table for the raw-payment path's key-wallet reservation +//! tokens. +//! +//! # Why a table and not just the token +//! +//! [`core_wallet_release_payment_reservation`](super::core_wallet_release_payment_reservation) +//! must be *owner-guarded*: it may only free inputs the abandoned build still +//! owns, or it can free a reservation key-wallet's TTL swept and a concurrent +//! build re-took — a double-spend window (dashpay/platform#4247 review). +//! The proof of ownership is key-wallet's `ReservationToken`, and that type +//! **cannot cross the C ABI**: it is deliberately opaque, with a private counter +//! and no public constructor, precisely so a caller cannot forge one and release +//! another build's inputs. Serializing it to a `u64` and rebuilding it on the way +//! back in is therefore not available — and would defeat the point if it were. +//! +//! So the token stays in Rust and an opaque, table-allocated +//! [`PaymentReservationHandle`] crosses the boundary in its place. This is the +//! same indirection `SignedPaymentRegistry` uses for the deferred-broadcast path +//! (its `ReservationToken` payment handle is a `u64` over the ABI while the +//! key-wallet funding token it guards stays Rust-side); the two token spaces are +//! deliberately separate types so a handle from one can never be presented to the +//! other. +//! +//! Forgery-resistance is preserved by construction: an unknown handle resolves to +//! nothing, so a fabricated `u64` releases nothing rather than acting on some +//! other build's token. +//! +//! # Lifetime and bounding +//! +//! Entries are **not** consumed by a release. Two reasons: the release stays +//! idempotent (a second release re-resolves the same token, which by then owns +//! nothing — a no-op inside key-wallet rather than an "unknown handle" error), +//! and the happy path never releases at all. That second point is what forces a +//! bound: this primitive does not broadcast — dashj does — so the SDK is never +//! told that a build went out on the wire, and a build → broadcast payment simply +//! leaves its entry behind. +//! +//! The table is therefore a fixed-capacity FIFO ring of [`MAX_ENTRIES`]: minting +//! entry N evicts entry N − [`MAX_ENTRIES`]. Reaching eviction takes +//! [`MAX_ENTRIES`] builds that were never released, i.e. builds that were +//! broadcast — whose reservations are backed by genuinely spent coins and whose +//! handles are of no further use. A build that is going to be abandoned is +//! abandoned within seconds of being made, so eviction cannot realistically +//! overtake one. +//! +//! An evicted or unknown handle is **refused**, never silently downgraded to the +//! unguarded release. Downgrading is exactly the behavior this change removes. +//! +//! In-memory only, like every reservation in this stack: a crash drops the table +//! and the underlying `ReservationSet` together, so nothing leaks across a +//! restart. + +use std::collections::{HashMap, VecDeque}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, MutexGuard}; + +use key_wallet::ReservationToken as FundingReservationToken; +use once_cell::sync::Lazy; + +/// Capacity of the FIFO ring. Sized so that reaching eviction requires thousands +/// of un-released builds in one process lifetime — several orders of magnitude +/// past any real send volume between app launches — while costing a fixed few +/// tens of kilobytes. +const MAX_ENTRIES: usize = 4096; + +/// Opaque, process-unique handle standing in for a key-wallet +/// [`FundingReservationToken`] across the C ABI. +/// +/// `0` is reserved and never minted: it is the "this build reserved nothing" +/// sentinel, matching the FFI's null-handle convention. A distinct newtype rather +/// than a bare `u64` so it can never be confused with the deferred path's +/// `platform_wallet::ReservationToken` payment handle, which is a different token +/// space entirely. +#[repr(transparent)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct PaymentReservationHandle(u64); + +impl PaymentReservationHandle { + /// The sentinel meaning "the build took no reservation". + pub(crate) const NONE: Self = Self(0); + + /// The raw wire value handed across the ABI. + pub(crate) const fn as_u64(self) -> u64 { + self.0 + } +} + +impl From for PaymentReservationHandle { + fn from(value: u64) -> Self { + Self(value) + } +} + +/// A handle the table does not (or no longer) know: forged, already evicted, or +/// minted by a previous process. Releasing against it is refused. +#[derive(Debug)] +pub(crate) struct UnknownReservationHandle(pub(crate) PaymentReservationHandle); + +/// The interior state: the live tokens plus the insertion order that drives +/// eviction. +struct Entries { + tokens: HashMap, + /// Handles in mint order. The front is the oldest and is evicted first. + order: VecDeque, +} + +/// Bounded FIFO table of the reservation tokens currently addressable from the +/// host. +/// +/// Generic over the stored token purely so the bounding/eviction/forgery logic +/// can be unit-tested: a real [`FundingReservationToken`] has no public +/// constructor (by design), so a test cannot fabricate one. The production +/// instantiation is [`PAYMENT_RESERVATIONS`]. +pub(crate) struct PaymentReservationTable { + next_handle: AtomicU64, + entries: Mutex>, +} + +impl PaymentReservationTable { + /// A fresh, empty table. Not `const` — `HashMap::new` is not a const fn — so + /// the process-global below is a `Lazy` rather than a plain `static`, exactly + /// like `SIGNED_PAYMENT_REGISTRY`. + pub(crate) fn new() -> Self { + Self { + // Start at 1 so `PaymentReservationHandle::NONE` is never minted. + next_handle: AtomicU64::new(1), + entries: Mutex::new(Entries { + tokens: HashMap::new(), + order: VecDeque::new(), + }), + } + } + + /// Lock the table, recovering from a poisoned mutex rather than panicking. + /// This is one process-global, so propagating a poison would permanently + /// disable the abandon path for every wallet; the guarded state is a map plus + /// a queue with no invariant a partial write could break. Mirrors + /// `SignedPaymentRegistry::lock` and key-wallet's `ReservationSet::lock`. + fn lock(&self) -> MutexGuard<'_, Entries> { + self.entries + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// Record `token` and return the handle that addresses it, evicting the + /// oldest entry if the table is full. `None` mints nothing and yields + /// [`PaymentReservationHandle::NONE`]. + pub(crate) fn stash(&self, token: Option) -> PaymentReservationHandle { + let Some(token) = token else { + return PaymentReservationHandle::NONE; + }; + let handle = PaymentReservationHandle(self.next_handle.fetch_add(1, Ordering::SeqCst)); + + let mut entries = self.lock(); + entries.tokens.insert(handle, token); + entries.order.push_back(handle); + while entries.order.len() > MAX_ENTRIES { + if let Some(oldest) = entries.order.pop_front() { + entries.tokens.remove(&oldest); + } + } + handle + } + + /// Resolve `handle` to the token it addresses, WITHOUT consuming it — see the + /// module docs on why the release must stay idempotent. + /// + /// [`PaymentReservationHandle::NONE`] resolves to `Ok(None)`: the build took + /// no reservation, so there is nothing to guard and nothing to free. Any + /// other unrecognised handle is an error rather than a silent `None`, because + /// a `None` here would downgrade the caller to the unguarded release this + /// change exists to remove. + pub(crate) fn resolve( + &self, + handle: PaymentReservationHandle, + ) -> Result, UnknownReservationHandle> { + if handle == PaymentReservationHandle::NONE { + return Ok(None); + } + self.lock() + .tokens + .get(&handle) + .copied() + .map(Some) + .ok_or(UnknownReservationHandle(handle)) + } + + /// Number of live entries. Test-only introspection. + #[cfg(test)] + fn len(&self) -> usize { + self.lock().tokens.len() + } +} + +/// Process-global table backing `core_wallet_build_signed_payment` / +/// `core_wallet_release_payment_reservation`. +pub(crate) static PAYMENT_RESERVATIONS: Lazy> = + Lazy::new(PaymentReservationTable::new); + +#[cfg(test)] +mod tests { + use super::*; + + /// A fresh table per test — the process-global one is shared with every other + /// test in the binary and cannot support exact-count assertions. `u64` stands + /// in for the real token, whose constructor is private by design. + fn table() -> PaymentReservationTable { + PaymentReservationTable::new() + } + + #[test] + fn the_none_sentinel_round_trips_without_minting() { + let t = table(); + let handle = t.stash(None); + assert_eq!(handle, PaymentReservationHandle::NONE); + assert_eq!(handle.as_u64(), 0); + assert_eq!(t.len(), 0, "the sentinel must not occupy an entry"); + assert!( + matches!(t.resolve(handle), Ok(None)), + "the sentinel resolves to 'no token', not an error" + ); + } + + /// A fabricated handle must resolve to an error, never to some other build's + /// token and never to a silent `None` — a `None` would downgrade the caller + /// to the unguarded release that reopens the double-spend window. + #[test] + fn an_unknown_handle_is_refused() { + let t = table(); + t.stash(Some(11)); + for forged in [2u64, 99, u64::MAX] { + assert!( + matches!( + t.resolve(PaymentReservationHandle::from(forged)), + Err(UnknownReservationHandle(_)) + ), + "handle {forged} was never minted and must be refused" + ); + } + } + + /// Handles are unique and never collide with the `NONE` sentinel, so a stale + /// handle can always be told apart from a live one. + #[test] + fn minted_handles_are_unique_and_never_zero() { + let t = table(); + let mut seen = std::collections::HashSet::new(); + for i in 0..1_000u64 { + let h = t.stash(Some(i)); + assert_ne!(h, PaymentReservationHandle::NONE); + assert!(seen.insert(h), "handle {h:?} was minted twice"); + } + } + + /// The ring is bounded: past `MAX_ENTRIES` the oldest handles are evicted and + /// then refused, while every newer one stays resolvable. + #[test] + fn the_table_is_bounded_and_evicts_oldest_first() { + let t = table(); + let first: Vec<_> = (0..MAX_ENTRIES as u64).map(|i| t.stash(Some(i))).collect(); + assert_eq!(t.len(), MAX_ENTRIES); + assert!( + t.resolve(first[0]).is_ok(), + "nothing is evicted while the table is merely full" + ); + + // One more mint evicts exactly one — the oldest. + let newest = t.stash(Some(9_999)); + assert_eq!(t.len(), MAX_ENTRIES, "capacity must stay fixed"); + assert!( + matches!(t.resolve(first[0]), Err(UnknownReservationHandle(_))), + "the oldest handle must have been evicted" + ); + assert!( + t.resolve(first[1]).is_ok() && t.resolve(newest).is_ok(), + "every other handle must still resolve" + ); + } + + /// Resolving does NOT consume, which is what keeps the release idempotent. + #[test] + fn resolving_does_not_consume_the_handle() { + let t = table(); + let handle = t.stash(Some(42)); + for attempt in 0..5 { + assert!( + matches!(t.resolve(handle), Ok(Some(42))), + "resolve attempt {attempt} must still find the token" + ); + } + assert_eq!(t.len(), 1); + } + + /// Each stashed token is addressed by its own handle — no aliasing, which is + /// what stops one build's release from presenting another build's token. + #[test] + fn handles_address_their_own_token() { + let t = table(); + let handles: Vec<_> = (0..16u64).map(|i| t.stash(Some(i * 7))).collect(); + for (i, handle) in handles.iter().enumerate() { + assert!( + matches!(t.resolve(*handle), Ok(Some(v)) if v == i as u64 * 7), + "handle {i} resolved to the wrong token" + ); + } + } +} diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs index 527df0a05c3..5023dde514d 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs @@ -14,6 +14,9 @@ //! `platform_wallet::wallet::funding_privacy` for the invariant and //! `platform_wallet::wallet::core::send` for the semantics. +use crate::core_wallet::payment_reservation::{ + PaymentReservationHandle, UnknownReservationHandle, PAYMENT_RESERVATIONS, +}; use crate::error::*; use crate::handle::{Handle, CORE_WALLET_STORAGE}; use crate::runtime::runtime; @@ -133,6 +136,13 @@ fn decode_payment_outputs( /// * `out_fee` — receives the fee paid, in duffs. /// * `out_change` — receives the change returned to the wallet, in duffs (0 if /// the build produced no change output). +/// * `out_reservation_handle` — receives the opaque handle standing in for the +/// key-wallet reservation token this build stamped on its inputs, or `0` if it +/// reserved nothing. Pass it back to +/// [`core_wallet_release_payment_reservation`] to abandon the build; it is what +/// makes that release owner-guarded. Not a pointer and nothing to free — see +/// [`payment_reservation`](crate::core_wallet::payment_reservation) for why the +/// token itself cannot cross this boundary. /// /// # Safety /// All pointers must be valid; `outputs_blob` must be readable for @@ -153,6 +163,7 @@ pub unsafe extern "C" fn core_wallet_build_signed_payment( out_tx_len: *mut usize, out_fee: *mut u64, out_change: *mut u64, + out_reservation_handle: *mut u64, ) -> PlatformWalletFFIResult { check_ptr!(outputs_blob); check_ptr!(core_signer_handle); @@ -160,6 +171,7 @@ pub unsafe extern "C" fn core_wallet_build_signed_payment( check_ptr!(out_tx_len); check_ptr!(out_fee); check_ptr!(out_change); + check_ptr!(out_reservation_handle); let funding_path = match parse_optional_derivation_path(funding_path_ptr, funding_path_len) { Ok(p) => p, @@ -199,6 +211,12 @@ pub unsafe extern "C" fn core_wallet_build_signed_payment( *out_tx_len = len; *out_fee = payment.fee; *out_change = payment.change_amount; + // Park the key-wallet token behind an opaque handle: it is not + // ABI-representable (private counter, no public constructor — deliberately, + // so it cannot be forged), so the host carries the handle instead. + *out_reservation_handle = PAYMENT_RESERVATIONS + .stash(payment.reservation_token) + .as_u64(); PlatformWalletFFIResult::ok() } @@ -228,14 +246,19 @@ pub unsafe extern "C" fn core_wallet_free_payment_bytes(bytes: *mut u8, len: usi /// on a freshly restored wallet can strand the whole balance for the life of /// the process (dashpay/platform#4247 review). This call consults no height. /// -/// Releases ONLY this build's own inputs: the transaction is the ownership -/// signal, since a reserved outpoint is skipped by every other build's coin -/// selection, so no concurrent build can hold a reservation on any input of -/// `tx_bytes`. +/// Releases ONLY inputs this build still owns. The guard is +/// `reservation_handle`, not the transaction: between the build and this call +/// key-wallet's TTL can sweep the reservation and a concurrent build can +/// re-reserve the same outpoint, at which point releasing by outpoint alone would +/// free the OTHER build's inputs and open a double-spend window +/// (dashpay/platform#4247 review). /// -/// Idempotent, and a silent no-op when the transaction was in fact broadcast — -/// safe to wire into an unconditional cleanup path without tracking whether the -/// broadcast succeeded. +/// FOR ABANDONED BUILDS ONLY — do NOT call this after a successful broadcast. +/// This primitive does not broadcast, so between the caller's broadcast and sync +/// processing that spend the inputs are still in the UTXO set and the reservation +/// is the only thing keeping a second build off them. Releasing there invites a +/// conflicting transaction. Repeated releases of the same abandoned build are +/// idempotent; that is the only sense in which this is safe to call twice. /// /// * `handle` — a core-wallet handle (`platform_wallet_get_core`). /// * `tx_bytes`/`tx_bytes_len` — the consensus-serialized signed transaction @@ -245,6 +268,11 @@ pub unsafe extern "C" fn core_wallet_free_payment_bytes(bytes: *mut u8, len: usi /// reservation. `null` / `0` means the unmixed BIP44 account, as it does for /// the build. A path naming a different account is harmless but frees /// nothing. +/// * `reservation_handle` — the SAME value +/// `core_wallet_build_signed_payment` wrote to `out_reservation_handle`. `0` +/// means "that build reserved nothing" and releases nothing. Any other +/// unrecognised value is refused with `ErrorInvalidParameter` rather than +/// downgraded to an unguarded release. /// /// # Safety /// `tx_bytes` must be readable for `tx_bytes_len` bytes; `funding_path_ptr`, @@ -257,6 +285,7 @@ pub unsafe extern "C" fn core_wallet_release_payment_reservation( tx_bytes_len: usize, funding_path_ptr: *const u8, funding_path_len: usize, + reservation_handle: u64, ) -> PlatformWalletFFIResult { check_ptr!(tx_bytes); @@ -265,6 +294,23 @@ pub unsafe extern "C" fn core_wallet_release_payment_reservation( Err(result) => return result, }; + let reservation_token = + match PAYMENT_RESERVATIONS.resolve(PaymentReservationHandle::from(reservation_handle)) { + Ok(token) => token, + Err(UnknownReservationHandle(unknown)) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!( + "reservation handle {} is unknown: it was never issued by \ + core_wallet_build_signed_payment, belongs to a previous process, or \ + has aged out of the handle table. Refusing rather than releasing \ + unguarded, which could free a concurrent build's inputs", + unknown.as_u64() + ), + ); + } + }; + let raw = std::slice::from_raw_parts(tx_bytes, tx_bytes_len); let transaction: dashcore::Transaction = match dashcore::consensus::deserialize(raw).map_err(|e| { @@ -278,7 +324,11 @@ pub unsafe extern "C" fn core_wallet_release_payment_reservation( }; let option = CORE_WALLET_STORAGE.with_item(handle, |wallet| { - runtime().block_on(wallet.release_payment_reservation(&transaction, funding_path.clone())) + runtime().block_on(wallet.release_payment_reservation( + &transaction, + funding_path.clone(), + reservation_token, + )) }); let result = unwrap_option_or_return!(option); diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 9c10f0e0909..02cd24401ce 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -348,6 +348,7 @@ pub(crate) async fn split_funded_wallet_manager( ) -> ( Arc>>, WalletId, + Arc, WalletSigner, ) { use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait as _; @@ -407,10 +408,15 @@ pub(crate) async fn split_funded_wallet_manager( wallet: ctx.wallet.clone(), }; + // Returned, not discarded: a `CoreWallet` built over a *different* + // `WalletGeneration` than the one registered here is a foreign generation, + // and every generation-bound path (above all the owner-guarded reservation + // release) correctly no-ops against it. Handing the real handle back is what + // keeps callers from silently testing a no-op. let generation = Arc::new(WalletGeneration::new()); let info = PlatformWalletInfo { core_wallet: ctx.managed_wallet, - generation, + generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), }; @@ -418,7 +424,7 @@ pub(crate) async fn split_funded_wallet_manager( 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, signer) + (Arc::new(RwLock::new(wm)), wallet_id, generation, signer) } /// Funded SPV-backed Core wallet for downstream FFI lifecycle tests. The SPV diff --git a/packages/rs-platform-wallet/src/wallet/core/send.rs b/packages/rs-platform-wallet/src/wallet/core/send.rs index 022c57ae492..8df235bfa95 100644 --- a/packages/rs-platform-wallet/src/wallet/core/send.rs +++ b/packages/rs-platform-wallet/src/wallet/core/send.rs @@ -29,10 +29,10 @@ //! Building does **not** persist a debit and does not write UTXOs, balances, or //! transaction records back to the wallet. The only in-memory mutation is the //! key-wallet `ReservationSet` bookkeeping that `set_funding` + -//! `TransactionBuilder::build_signed` perform on the **selected** funding -//! account: the selected inputs are marked *reserved* so a concurrent SDK build -//! does not re-select the same coins. Because selection is confined to one -//! account, every selected input is reserved in the ledger that all funding +//! `TransactionBuilder::build_signed_reserved` perform on the **selected** +//! funding account: the selected inputs are marked *reserved* so a concurrent +//! SDK build does not re-select the same coins. Because selection is confined to +//! one account, every selected input is reserved in the ledger that all funding //! paths consult for that account — there are no unreserved "secondary-account" //! inputs (dashpay/platform#4247 review finding, now structurally impossible). //! @@ -45,19 +45,26 @@ //! //! ## Abandoning a build //! -//! A caller that builds and then decides not to broadcast MUST call +//! A caller that builds and then decides **not** to broadcast MUST call //! [`CoreWallet::release_payment_reservation`] with the transaction it was -//! handed. Without it the selected inputs stay reserved until the TTL backstop -//! fires 24 blocks later — and, critically, **forever** while the wallet has no -//! processed height: key-wallet's `ReservationSet::sweep` early-returns at -//! height 0, so a build made before the first sync completes can strand the -//! whole balance for the life of the process (dashpay/platform#4247 review). -//! The explicit release is height-independent and closes that hole. +//! handed *and* the [`SignedCorePayment::reservation_token`] that came with it. +//! Without it the selected inputs stay reserved until the TTL backstop fires 24 +//! blocks later — and, critically, **forever** while the wallet has no processed +//! height: key-wallet's `ReservationSet::sweep` early-returns at height 0, so a +//! build made before the first sync completes can strand the whole balance for +//! the life of the process (dashpay/platform#4247 review). The explicit release +//! is height-independent and closes that hole. //! -//! [`ManagedCoreFundsAccount::release_reservation`]: -//! key_wallet::managed_account::ManagedCoreFundsAccount::release_reservation +//! The release is **owner-guarded** by that token, and it is for abandoned +//! builds ONLY — never for one that was broadcast. Both restrictions are +//! load-bearing; see [`CoreWallet::release_payment_reservation`] for the two +//! concrete hazards they close (dashpay/platform#4247 review). +//! +//! [`ManagedCoreFundsAccount::release_reservation_if_owner`]: +//! key_wallet::managed_account::ManagedCoreFundsAccount::release_reservation_if_owner use std::collections::HashMap; +use std::sync::Arc; use dashcore::{Address as DashAddress, OutPoint, Transaction}; use key_wallet::bip32::DerivationPath; @@ -70,6 +77,12 @@ use key_wallet::wallet::managed_wallet_info::transaction_builder::{ BuilderError, TransactionBuilder, }; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +// key-wallet's per-build UTXO-reservation token. Aliased exactly as +// `signed_payment_registry` aliases it, so it never blurs with that module's own +// `ReservationToken` (an opaque *payment handle*): this one identifies the +// reserved **inputs**, and is the proof of ownership an owner-guarded release +// presents. +use key_wallet::ReservationToken as FundingReservationToken; use crate::broadcaster::TransactionBroadcaster; use crate::error::PlatformWalletError; @@ -156,6 +169,21 @@ pub struct SignedCorePayment { /// no change output — an exact-match selection or a dust-only remainder /// folded into the fee). pub change_amount: u64, + /// The key-wallet [`FundingReservationToken`] stamped onto the inputs this + /// build reserved (`None` only if the funding account carried no reservation + /// set, which the send path never produces). + /// + /// This is the **proof of ownership** the abandon path must present to + /// [`CoreWallet::release_payment_reservation`]. Carrying it is not + /// bookkeeping: between this build and its release, key-wallet's TTL sweep + /// can reclaim the reservation and a concurrent build can re-reserve the very + /// same outpoint under a *new* token. A release by outpoint alone would then + /// free that other build's inputs and re-open the double-spend window the + /// reservation exists to close, so the token is what makes the release a + /// no-op for any input that has since changed hands + /// (dashpay/platform#4247 review; the mechanism is documented on + /// key-wallet's `ReservationSet::release_if_owner`). + pub reservation_token: Option, } impl CoreWallet { @@ -511,8 +539,15 @@ impl CoreWallet { builder }; - let (transaction, _estimated_fee) = builder - .build_signed(signer, move |addr| path_map.get(&addr).cloned()) + // `build_signed_reserved`, not `build_signed`: the latter drops the + // `ReservationToken` it was handed, and this build must keep it. The + // token is both the abandon path's proof of ownership + // (`SignedCorePayment::reservation_token`) and what lets the + // size-rejection path below free exactly its own inputs. key-wallet + // already releases owner-guarded if the signer itself fails, so the only + // post-reservation failure left to handle here is that size check. + let (transaction, _estimated_fee, reservation_token) = builder + .build_signed_reserved(signer, move |addr| path_map.get(&addr).cloned()) .await .map_err(|e| map_send_builder_error(e, selectable_value, outputs_total))?; @@ -549,6 +584,47 @@ impl CoreWallet { // whole transaction over the limit. let signed_size = transaction.size(); if signed_size > MAX_STANDARD_TX_SIZE { + // RELEASE BEFORE RETURNING (dashpay/platform#4247 review). + // + // The inputs are already reserved by the time this check runs, and + // this is the one error path that can reach it. Returning bare would + // strand them twice over: the caller never receives the transaction, + // so it has nothing to hand `release_payment_reservation`, and the + // TTL backstop is not a fallback — `ReservationSet::sweep` + // early-returns at height 0, so on a freshly restored wallet the + // coins would sit unselectable for the life of the process. Since + // the failure is "too many small inputs", the stranded amount is + // typically the whole account. + // + // Owner-guarded even though nothing can have interleaved (the + // manager write lock has been held continuously since selection, and + // both the TTL sweep and any re-reservation need it): the guard costs + // nothing and keeps this path correct by construction rather than by + // an argument about the current locking, which is exactly the + // reasoning that failed review elsewhere in this module. A failure to + // resolve the account is logged, not propagated — it must not mask + // the size error the caller actually needs to see. + match release_in_funding_account( + info, + network, + &funding_path, + &transaction, + reservation_token, + ) { + Ok(true) => {} + Ok(false) => tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + %funding_path, + "oversized-payment cleanup could not find the funding account to \ + release into; its inputs stay reserved until the TTL backstop" + ), + Err(e) => tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + %funding_path, + "oversized-payment cleanup failed to release the reservation: {e}" + ), + } + return Err(PlatformWalletError::TransactionBuild(format!( "the signed transaction is {signed_size} bytes, over the \ {MAX_STANDARD_TX_SIZE}-byte standard transaction limit; it would not relay. \ @@ -560,6 +636,7 @@ impl CoreWallet { transaction, fee, change_amount, + reservation_token, }) } @@ -584,29 +661,61 @@ impl CoreWallet { /// indefinitely. This method takes no height and consults none, so it is /// the one release path that works pre-sync. /// - /// ## What is released — only this build's own inputs + /// ## What is released — only inputs this build still owns + /// + /// The release is **owner-guarded** by `reservation_token`: key-wallet frees + /// an outpoint only while that token is still its recorded owner. + /// + /// An earlier revision of this contract argued the transaction alone was a + /// sufficient ownership signal — "a reserved outpoint is skipped by every + /// subsequent coin selection, so no concurrent build can hold a reservation + /// on any input of `transaction`". **That reasoning was wrong**, and the + /// unguarded release it justified was a real double-spend window + /// (dashpay/platform#4247 review). It overlooks the TTL sweep: key-wallet + /// reclaims a reservation `RESERVATION_TTL_BLOCKS` after it was stamped, at + /// which point the outpoint *is* selectable again and a concurrent build can + /// legitimately re-reserve it under a new token. A late release by outpoint + /// then frees that other build's inputs and lets coin selection hand them to + /// a second transaction. The sweep happens inside key-wallet, invisibly, so + /// this layer cannot detect it — which is precisely why key-wallet's own + /// docs state the platform layer "cannot make this safe on its own" and + /// expose [`release_reservation_if_owner`] for it. With the token the stale + /// release is simply a no-op. + /// + /// [`release_reservation_if_owner`]: + /// key_wallet::managed_account::ManagedCoreFundsAccount::release_reservation_if_owner + /// + /// The release is additionally bound to this handle's own wallet + /// *generation*: a wallet removed and re-created under the same id has a + /// fresh `ReservationSet`, and releasing into it could free the NEW + /// generation's reservation. A generation mismatch is therefore a no-op — + /// the original generation's reservation ceased to exist with it. (Same + /// guard, same reasoning as + /// [`release_transaction_reservation`](CoreWallet::release_transaction_reservation).) /// - /// The transaction *is* the ownership signal. `build_signed_payment` - /// reserves exactly the outpoints it selected, and a reserved outpoint is - /// skipped by every subsequent coin selection — so no concurrent build can - /// hold a reservation on any input of `transaction`. Releasing precisely - /// `transaction`'s inputs therefore releases precisely this build's own - /// reservation and can never free a competing in-flight build's coins. - /// (The same signal already backs the internal - /// [`release_reservation_after_rejected_broadcast`] cleanup.) + /// ## Idempotent — but for ABANDONED builds only, never after a broadcast /// - /// [`release_reservation_after_rejected_broadcast`]: - /// crate::wallet::reservations::release_reservation_after_rejected_broadcast + /// Calling this twice is a silent no-op: the second call resolves the + /// account fine and the token no longer owns anything. /// - /// ## Idempotent, and safe after a broadcast + /// It is **not** safe to wire into an unconditional cleanup path (a + /// `finally`, a teardown hook) that runs regardless of whether the caller + /// broadcast. A previous revision of this contract advertised exactly that, + /// on the grounds that a broadcast spend "is removed from the UTXO set by + /// sync independently of any reservation". The gap is the interval *before* + /// sync observes it: this primitive does not broadcast, so between the + /// caller's successful external broadcast and sync processing that spend + /// back into the wallet, the inputs are still in the UTXO set and the + /// reservation is the only thing keeping a second build off them. Releasing + /// in that window re-opens them for selection and invites a conflicting + /// transaction the network will reject as a double-spend + /// (dashpay/platform#4247 review). /// - /// Releasing is a per-outpoint map removal, so calling this twice — or on - /// a transaction that was in fact broadcast — is a silent no-op rather - /// than an error. It cannot resurrect a spent coin: coin selection reads - /// the UTXO set, and a broadcast spend is removed from that set by sync - /// independently of any reservation. That makes the release safe to wire - /// into an unconditional cleanup path (a `finally`, a teardown hook) - /// without the caller having to track whether the broadcast succeeded. + /// The rule for callers is therefore: release a build you decided **not** to + /// broadcast; never release one you did. Once sync has processed the spend + /// the release is harmless again — the inputs have left the UTXO set — but + /// nothing tells the caller when that moment arrives, so "did I broadcast + /// it?" is the condition to branch on, and it is one the caller always knows. /// /// ## Parameters /// @@ -618,45 +727,53 @@ impl CoreWallet { /// Passing a path that names a different account is harmless: that /// account's ledger holds none of these outpoints, so nothing is /// released. + /// * `reservation_token` — the **same** + /// [`SignedCorePayment::reservation_token`] the build returned. Passing + /// `None` falls back to the unguarded by-outpoint release and is reserved + /// for a build that genuinely reserved nothing; do not pass `None` merely + /// because the token was inconvenient to carry — that reinstates the race + /// above. /// /// [`build_signed_payment`]: CoreWallet::build_signed_payment pub async fn release_payment_reservation( &self, transaction: &Transaction, funding_path: Option, + reservation_token: Option, ) -> Result<(), PlatformWalletError> { - // `release_reservation` takes `&self` and no manager entry is mutated, - // so a read lock suffices — abandoning a build must not serialize - // against concurrent sends (same reasoning as the rejected-broadcast - // cleanup in `crate::wallet::reservations`). + // `release_reservation_if_owner` takes `&self` and no manager entry is + // mutated, so a read lock suffices — abandoning a build must not + // serialize against concurrent sends (same reasoning as the + // rejected-broadcast cleanup in `crate::wallet::reservations`). The read + // lock also makes the generation check below atomic against a + // recreation, which needs the write lock. let wm = self.wallet_manager.read().await; let (_, info) = wm .get_wallet_and_info(&self.wallet_id) .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + // The wallet registered under this id is the same generation as `self` + // iff their per-generation `Arc`s are pointer-equal — `wallet_id` alone + // survives a remove-then-recreate, so it cannot tell them apart. + if !Arc::ptr_eq(&info.generation, self.generation()) { + tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + "skipping payment reservation release: the wallet was re-created under the \ + same id, so this build's reservation no longer exists and releasing would \ + act on the new generation's ledger" + ); + return Ok(()); + } + let network = info.core_wallet.network(); let funding_path = match funding_path { Some(path) => path, None => bip44_account_path(info, network)?, }; - // PRIVACY-DOMAIN-OK: iterates funds accounts only to LOOK ONE UP by - // derivation path, exactly as the build does. Nothing is accumulated - // across accounts and only the named account's ledger is touched. - for account in info.core_wallet.accounts.all_funding_accounts() { - let account_path = account - .managed_account_type() - .to_account_type() - .derivation_path(network) - .map_err(|e| { - PlatformWalletError::TransactionBuild(format!( - "failed to derive account-level path for a funds account: {e}" - )) - })?; - if account_path == funding_path { - account.release_reservation(transaction); - return Ok(()); - } + if release_in_funding_account(info, network, &funding_path, transaction, reservation_token)? + { + return Ok(()); } // An unresolvable path is a caller error worth reporting, and is NOT @@ -671,6 +788,52 @@ impl CoreWallet { } } +/// Release `transaction`'s input reservation inside the ONE funds account whose +/// account-level path is `funding_path`, owner-guarded by `token`. +/// +/// Returns `true` when an account matched and was asked to release, `false` when +/// `funding_path` names no funds account in this wallet — the caller decides +/// whether that is an error (the explicit abandon path) or a warning (the +/// oversized-build cleanup, which must not mask the size error). +/// +/// Shared by both release sites so the guard can never be applied on one and +/// forgotten on the other. `Some(token)` is the normal case and is what closes +/// the sweep/re-reserve race (dashpay/platform#4247 review); `None` falls back to +/// the unconditional by-outpoint release and is only correct for a build that +/// reserved nothing, in which case there is nothing to free anyway. +/// +/// PRIVACY-DOMAIN-OK: iterates funds accounts only to LOOK ONE UP by derivation +/// path, exactly as the build does. Nothing is accumulated across accounts and +/// only the named account's ledger is touched. +fn release_in_funding_account( + info: &crate::wallet::platform_wallet::PlatformWalletInfo, + network: dashcore::Network, + funding_path: &DerivationPath, + transaction: &Transaction, + token: Option, +) -> Result { + for account in info.core_wallet.accounts.all_funding_accounts() { + let account_path = account + .managed_account_type() + .to_account_type() + .derivation_path(network) + .map_err(|e| { + PlatformWalletError::TransactionBuild(format!( + "failed to derive account-level path for a funds account: {e}" + )) + })?; + if account_path != *funding_path { + continue; + } + match token { + Some(token) => account.release_reservation_if_owner(transaction, token), + None => account.release_reservation(transaction), + } + return Ok(true); + } + Ok(false) +} + /// Account-level derivation path of the unmixed BIP44 account at /// [`BIP44_ACCOUNT_INDEX`] — the default funding source and the change sink. /// @@ -762,8 +925,14 @@ mod tests { use super::SignedCorePayment; /// A `CoreWallet` over a manager fixture. The send path never broadcasts, - /// so the broadcaster is irrelevant (and the generation handle is unused by - /// build — a fresh one is fine for the split fixtures that don't return it). + /// so the broadcaster is irrelevant. + /// + /// `generation` MUST be the handle the fixture registered, never a fresh + /// `WalletGeneration::new()`: `release_payment_reservation` is + /// generation-bound and silently no-ops against a foreign generation, so a + /// fabricated handle turns every release assertion vacuous. Both fixtures + /// return their real handle for exactly this reason + /// (dashpay/platform#4247 review). fn core_wallet( wallet_manager: Arc< tokio::sync::RwLock< @@ -903,8 +1072,9 @@ mod tests { #[tokio::test] async fn default_funding_never_selects_other_domains() { // 0.09 DASH on BIP44, 0.09 on CoinJoin; ask 0.15 → only a union covers it. - let (wm, wallet_id, signer) = split_funded_wallet_manager(9_000_000, 9_000_000).await; - let core = core_wallet(wm, wallet_id, Arc::new(WalletGeneration::new())); + let (wm, wallet_id, generation, signer) = + split_funded_wallet_manager(9_000_000, 9_000_000).await; + let core = core_wallet(wm, wallet_id, generation); let result = core .build_signed_payment(vec![(recipient(7), 15_000_000)], None, &signer, None) @@ -937,11 +1107,12 @@ mod tests { #[tokio::test] async fn default_funding_selects_strictly_within_bip44() { // 0.2 DASH on BIP44, 0.09 on CoinJoin; ask 0.15 → BIP44 alone covers it. - let (wm, wallet_id, signer) = split_funded_wallet_manager(20_000_000, 9_000_000).await; + let (wm, wallet_id, generation, signer) = + split_funded_wallet_manager(20_000_000, 9_000_000).await; let (bip44_ops, coinjoin_ops, _) = split_account_outpoints_and_coinjoin_path(&wm, &wallet_id).await; - let core = core_wallet(wm, wallet_id, Arc::new(WalletGeneration::new())); + let core = core_wallet(wm, wallet_id, generation); let payment = core .build_signed_payment(vec![(recipient(7), 15_000_000)], None, &signer, None) .await @@ -972,11 +1143,12 @@ mod tests { #[tokio::test] async fn explicit_coinjoin_path_selects_only_coinjoin() { // 0.09 DASH on BIP44 (short), 0.2 on CoinJoin; take 0.15 from CoinJoin. - let (wm, wallet_id, signer) = split_funded_wallet_manager(9_000_000, 20_000_000).await; + let (wm, wallet_id, generation, signer) = + split_funded_wallet_manager(9_000_000, 20_000_000).await; let (bip44_ops, coinjoin_ops, coinjoin_path) = split_account_outpoints_and_coinjoin_path(&wm, &wallet_id).await; - let core = core_wallet(wm, wallet_id, Arc::new(WalletGeneration::new())); + let core = core_wallet(wm, wallet_id, generation); let payment = core .build_signed_payment( vec![(recipient(7), 15_000_000)], @@ -1016,10 +1188,11 @@ mod tests { /// would invite a retry that can only succeed by crossing domains. #[tokio::test] async fn selected_account_shortfall_is_typed() { - let (wm, wallet_id, signer) = split_funded_wallet_manager(9_000_000, 9_000_000).await; + let (wm, wallet_id, generation, signer) = + split_funded_wallet_manager(9_000_000, 9_000_000).await; let (_, _, coinjoin_path) = split_account_outpoints_and_coinjoin_path(&wm, &wallet_id).await; - let core = core_wallet(wm, wallet_id, Arc::new(WalletGeneration::new())); + let core = core_wallet(wm, wallet_id, generation); let result = core .build_signed_payment( @@ -1080,7 +1253,7 @@ mod tests { #[tokio::test] async fn watch_only_external_account_is_excluded() { // BIP44 holds 0.1 DASH; a watch-only external account holds 1.0 DASH. - let (wm, wallet_id, _balance, signer) = + let (wm, wallet_id, generation, signer) = funded_wallet_manager(StandardAccountType::BIP44Account).await; let watch_only_outpoint = OutPoint { @@ -1140,7 +1313,7 @@ mod tests { .expect("insert watch-only external account"); } - let core = core_wallet(wm, wallet_id, Arc::new(WalletGeneration::new())); + let core = core_wallet(wm, wallet_id, generation); // Ask for 0.5 DASH: covered only if the 1.0-DASH watch-only UTXO were // spendable. It is on a different domain from the default BIP44 funding @@ -1533,7 +1706,7 @@ mod tests { "the first build's reservation must block a second build" ); - core.release_payment_reservation(&payment.transaction, None) + core.release_payment_reservation(&payment.transaction, None, payment.reservation_token) .await .expect("abandoning a build must succeed"); @@ -1555,9 +1728,12 @@ mod tests { } /// Releasing twice is a no-op, not an error: the second call resolves the - /// funding account fine and removes outpoints that are already gone. This - /// is what lets a caller wire the release into an unconditional cleanup - /// path without tracking whether it already ran. + /// funding account fine and the token no longer owns anything. This is what + /// lets a caller release without tracking whether it already ran. + /// + /// Note the scope: idempotent across *repeated releases of the same + /// abandoned build*. It does NOT license releasing a build that was + /// broadcast — see `releasing_a_broadcast_build_before_sync_reopens_its_inputs`. #[tokio::test] async fn abandoning_twice_is_a_no_op() { let (wm, wallet_id, balance, signer) = @@ -1570,7 +1746,7 @@ mod tests { .expect("the funded account covers the payment"); for attempt in 0..3 { - core.release_payment_reservation(&payment.transaction, None) + core.release_payment_reservation(&payment.transaction, None, payment.reservation_token) .await .unwrap_or_else(|e| panic!("release attempt {attempt} must be a no-op, got {e:?}")); } @@ -1581,11 +1757,16 @@ mod tests { .expect("repeated releases must leave the inputs selectable"); } - /// Releasing after the transaction was actually broadcast and confirmed is - /// a no-op, and critically cannot resurrect the spent coin: coin selection - /// reads the UTXO set, from which sync has already removed the spend, so - /// the released reservation has nothing to expose. A caller that always - /// releases in a `finally` therefore cannot double-spend itself. + /// Releasing **after sync has processed** a broadcast spend is a no-op, and + /// cannot resurrect the spent coin: coin selection reads the UTXO set, from + /// which sync has already removed the spend, so the released reservation has + /// nothing to expose. + /// + /// This pins the *post-sync* half only. It deliberately no longer claims the + /// release is safe in an unconditional `finally`: the dangerous window is + /// BEFORE sync observes the broadcast, which + /// `releasing_a_broadcast_build_before_sync_reopens_its_inputs` covers + /// (dashpay/platform#4247 review). #[tokio::test] async fn abandoning_after_broadcast_is_a_no_op() { let (wm, wallet_id, balance, signer) = @@ -1609,7 +1790,7 @@ mod tests { // Stand in for the caller broadcasting and sync observing it. process_spend(&wm, &wallet_id, &payment.transaction).await; - core.release_payment_reservation(&payment.transaction, None) + core.release_payment_reservation(&payment.transaction, None, payment.reservation_token) .await .expect("releasing after a broadcast must be a silent no-op, not an error"); @@ -1660,7 +1841,7 @@ mod tests { // The explicit release consults no height, so it works where the TTL // cannot. - core.release_payment_reservation(&payment.transaction, None) + core.release_payment_reservation(&payment.transaction, None, payment.reservation_token) .await .expect("the release must not depend on a processed height"); @@ -1674,14 +1855,11 @@ mod tests { /// its own build's inputs" property against a path-confusion regression. #[tokio::test] async fn releasing_against_another_account_frees_nothing() { - let (wm, wallet_id, signer) = split_funded_wallet_manager(9_000_000, 20_000_000).await; + let (wm, wallet_id, generation, signer) = + split_funded_wallet_manager(9_000_000, 20_000_000).await; let (_, _, coinjoin_path) = split_account_outpoints_and_coinjoin_path(&wm, &wallet_id).await; - let core = core_wallet( - Arc::clone(&wm), - wallet_id, - Arc::new(WalletGeneration::new()), - ); + let core = core_wallet(Arc::clone(&wm), wallet_id, generation); // Fund from CoinJoin, then try to release against the BIP44 default. let payment = core @@ -1694,7 +1872,7 @@ mod tests { .await .expect("the named CoinJoin account covers 0.15 DASH"); - core.release_payment_reservation(&payment.transaction, None) + core.release_payment_reservation(&payment.transaction, None, payment.reservation_token) .await .expect("a mismatched release resolves the account and simply frees nothing"); assert!( @@ -1712,9 +1890,13 @@ mod tests { ); // The correctly-aimed release does free it. - core.release_payment_reservation(&payment.transaction, Some(coinjoin_path.clone())) - .await - .expect("releasing against the funding account must succeed"); + core.release_payment_reservation( + &payment.transaction, + Some(coinjoin_path.clone()), + payment.reservation_token, + ) + .await + .expect("releasing against the funding account must succeed"); core.build_signed_payment( vec![(recipient(7), 15_000_000)], None, @@ -1743,7 +1925,11 @@ mod tests { let bogus = DerivationPath::from_str("m/44'/5'/77'").expect("valid path"); match core - .release_payment_reservation(&payment.transaction, Some(bogus)) + .release_payment_reservation( + &payment.transaction, + Some(bogus), + payment.reservation_token, + ) .await { Err(PlatformWalletError::TransactionBuild(m)) => assert!( @@ -1753,4 +1939,235 @@ mod tests { other => panic!("an unknown funding path must be refused, got {other:?}"), } } + + /// FINDING 1 (dashpay/platform#4247 review): the post-signing size check + /// runs *after* `build_signed_reserved` has already reserved the selected + /// inputs, so returning bare stranded them — the caller never receives the + /// transaction and so has nothing to hand the release, and the TTL backstop + /// is no fallback (it never fires at height 0 at all). + /// + /// Reached exactly as the review described: a recipient list that passes the + /// output-side bound, funded by an account whose many small UTXOs then push + /// the signed transaction over the standard limit. + /// + /// The assertion is the one that matters — after the rejection every input + /// must be selectable again, proved by an ordinary payment succeeding and + /// reselecting them. + #[tokio::test] + async fn an_oversized_signed_transaction_strands_no_reservation() { + use crate::test_support::funded_wallet_manager_with_outputs; + + // Eight 0.005-DASH UTXOs: enough small inputs that funding the recipient + // list below needs most of them, and none of them alone can. + let (wm, wallet_id, balance, signer) = + funded_wallet_manager_with_outputs(StandardAccountType::BIP44Account, &[500_000; 8]) + .await; + let all_inputs = bip44_outpoints(&wm, &wallet_id).await; + assert_eq!(all_inputs.len(), 8, "the fixture must provide 8 UTXOs"); + + let core = core_wallet(Arc::clone(&wm), wallet_id, balance); + + // The largest recipient count the OUTPUT-side pre-check still admits: + // one below the smallest count that leaves no room for a single input. + // The build therefore gets past every pre-flight bound, selects, signs, + // and only then measures over the limit. + let admitted = + (super::MAX_STANDARD_TX_SIZE - super::TX_INPUT_SIZE) / super::TX_OUTPUT_SIZE - 1; + let outputs: Vec<_> = (0..admitted) + .map(|i| (recipient((i % 250) as u8), 1_000u64)) + .collect(); + + match core + .build_signed_payment(outputs, None, &signer, None) + .await + { + Err(PlatformWalletError::TransactionBuild(m)) => assert!( + m.contains("the signed transaction is") && m.contains("would not relay"), + "this must be the POST-signing size rejection (the path that holds a \ + reservation), not a pre-flight bound, got {m:?}" + ), + other => panic!( + "{admitted} recipients over 8 small inputs must exceed the standard size \ + limit after signing, got {other:?}" + ), + } + + // THE REGRESSION: before the fix the rejected build kept its inputs + // reserved, so this ordinary payment failed with PaymentInsufficientFunds + // even though the wallet plainly held 0.04 DASH. + let after = core + .build_signed_payment(vec![(recipient(11), 3_000_000)], None, &signer, None) + .await + .expect("a rejected oversized build must leave every input selectable"); + let reselected: HashSet = after + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + assert!( + !reselected.is_empty() && reselected.is_subset(&all_inputs), + "the follow-up build must reselect the released inputs, got {reselected:?}" + ); + } + + /// FINDING 2 (dashpay/platform#4247 review): the release must be + /// owner-guarded, because a build's reservation can be swept by key-wallet's + /// TTL and the same outpoint legitimately re-reserved by a *different* build + /// before the first build gets around to releasing. + /// + /// This reproduces that window end to end — reserve, age past + /// `RESERVATION_TTL_BLOCKS`, let a second build re-take the outpoint — and + /// pins both directions: + /// + /// * releasing build A **with its token** leaves build B's reservation + /// intact (the fix); + /// * releasing the very same transaction **without** a token frees it (the + /// old behavior), so the guard is demonstrably what closes the window + /// rather than something else in the path. + /// + /// Freeing B's inputs is a double-spend window: coin selection would hand + /// them straight to a third transaction while B is still in flight. + #[tokio::test] + async fn an_owner_guarded_release_cannot_free_a_re_reserved_input() { + const TTL_BLOCKS: u32 = 24; + + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + set_last_processed_height(&wm, &wallet_id, 10).await; + let core = core_wallet(Arc::clone(&wm), wallet_id, balance); + + // Build A reserves the wallet's single UTXO at height 10. + let build_a = core + .build_signed_payment(vec![(recipient(21), 1_000_000)], None, &signer, None) + .await + .expect("the funded account covers the payment"); + assert!( + build_a.reservation_token.is_some(), + "a funded build must stamp a reservation token" + ); + + // Age past the TTL. The next build's `reserved()` call sweeps A's entry, + // so A's outpoint becomes selectable again — inside key-wallet, with no + // signal to this layer. + set_last_processed_height(&wm, &wallet_id, 10 + TTL_BLOCKS).await; + + // Build B re-reserves that very outpoint under a NEW token. + let build_b = core + .build_signed_payment(vec![(recipient(22), 1_000_000)], None, &signer, None) + .await + .expect("the swept reservation must let a second build select the same coin"); + let b_inputs: HashSet = build_b + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + let a_inputs: HashSet = build_a + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + assert_eq!( + a_inputs, b_inputs, + "the test is only meaningful if B re-reserved exactly A's inputs" + ); + assert_ne!( + build_a.reservation_token, build_b.reservation_token, + "the re-reservation must mint a fresh token — that difference IS the guard" + ); + + // A is abandoned late, WITH its token. B's reservation must survive. + core.release_payment_reservation(&build_a.transaction, None, build_a.reservation_token) + .await + .expect("a stale owner-guarded release is a no-op, not an error"); + + assert!( + matches!( + core.build_signed_payment(vec![(recipient(23), 1_000_000)], None, &signer, None) + .await, + Err(PlatformWalletError::PaymentInsufficientFunds { .. }) + ), + "releasing A must not free the inputs B is still holding — doing so would let \ + coin selection double-spend B's in-flight transaction" + ); + + // Control: the unguarded release (what this path did before the fix) + // DOES free B's reservation, which is the bug. + core.release_payment_reservation(&build_a.transaction, None, None) + .await + .expect("the unguarded release resolves the account fine"); + core.build_signed_payment(vec![(recipient(24), 1_000_000)], None, &signer, None) + .await + .expect( + "the unguarded release frees B's inputs — the double-spend window the token \ + closes", + ); + } + + /// FINDING 2, second clause (dashpay/platform#4247 review): the contract + /// used to advertise the release as "safe after a broadcast" and therefore + /// safe to wire into an unconditional `finally`. It is not. + /// + /// This primitive does not broadcast, so between the caller's successful + /// external broadcast and sync processing that spend back into the wallet, + /// the inputs are STILL in the UTXO set and the reservation is the only + /// thing keeping a second build off them. This pins the consequence of + /// releasing in that window: the next build reselects the same inputs and + /// produces a conflicting transaction. + /// + /// It is a documentation-contract test — the behavior it shows is inherent + /// to releasing, which is exactly why the fix is that callers must not + /// release a build they broadcast. + #[tokio::test] + async fn releasing_a_broadcast_build_before_sync_reopens_its_inputs() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(Arc::clone(&wm), wallet_id, balance); + + let broadcast = core + .build_signed_payment(vec![(recipient(31), 1_000_000)], None, &signer, None) + .await + .expect("the funded account covers the payment"); + + // The caller hands these bytes to dashj, which broadcasts them + // successfully. Sync has NOT yet processed the spend, so `process_spend` + // is deliberately NOT called here — that is the whole window. + + core.release_payment_reservation(&broadcast.transaction, None, broadcast.reservation_token) + .await + .expect( + "the release itself succeeds — nothing at this layer knows about the broadcast", + ); + + let conflicting = core + .build_signed_payment(vec![(recipient(32), 1_000_000)], None, &signer, None) + .await + .expect("releasing re-opened the still-unspent inputs to selection"); + + let broadcast_inputs: HashSet = broadcast + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + let conflicting_inputs: HashSet = conflicting + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + assert_eq!( + broadcast_inputs, conflicting_inputs, + "the second build spends the already-broadcast inputs" + ); + assert_ne!( + broadcast.transaction.txid(), + conflicting.transaction.txid(), + "two distinct transactions now spend the same coins — a double-spend the network \ + will reject, which is why releasing after a broadcast is NOT safe and the \ + 'unconditional cleanup path' guidance was removed" + ); + } } diff --git a/packages/rs-platform-wallet/src/wallet/funding_privacy.rs b/packages/rs-platform-wallet/src/wallet/funding_privacy.rs index 73c86d9329b..64c0631e59a 100644 --- a/packages/rs-platform-wallet/src/wallet/funding_privacy.rs +++ b/packages/rs-platform-wallet/src/wallet/funding_privacy.rs @@ -130,7 +130,6 @@ mod guardrail { use crate::test_support::{split_funded_wallet_manager, AlwaysRejectedBroadcaster}; use crate::wallet::core::CoreWallet; - use crate::wallet::core::WalletGeneration; use crate::PlatformWalletError; // -- static guard -------------------------------------------------------- @@ -259,7 +258,7 @@ mod guardrail { /// rather than silently reaching into a second domain. #[tokio::test] async fn no_spend_entry_point_unions_by_default() { - let (wm, wallet_id, signer) = + let (wm, wallet_id, generation, signer) = split_funded_wallet_manager(BIP44_DUFFS, COINJOIN_DUFFS).await; let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); let core = CoreWallet::new( @@ -267,7 +266,7 @@ mod guardrail { wm, wallet_id, Arc::new(AlwaysRejectedBroadcaster), - Arc::new(WalletGeneration::new()), + generation, ); let payment = core .build_signed_payment( @@ -295,7 +294,8 @@ mod guardrail { use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; // 0.09 DASH BIP44, 0.2 DASH CoinJoin; take 0.15 DASH from CoinJoin. - let (wm, wallet_id, signer) = split_funded_wallet_manager(9_000_000, 20_000_000).await; + let (wm, wallet_id, generation, signer) = + split_funded_wallet_manager(9_000_000, 20_000_000).await; let (bip44_ops, coinjoin_ops, coinjoin_path) = { let guard = wm.read().await; @@ -331,7 +331,7 @@ mod guardrail { wm, wallet_id, Arc::new(AlwaysRejectedBroadcaster), - Arc::new(WalletGeneration::new()), + generation, ); let payment = core .build_signed_payment( From 7eebbc1afe625f0c229f7141c9d17b377324ba6b Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:19:30 -0400 Subject: [PATCH 45/47] fix(kotlin-sdk): release a cancelled buildSignedPayment's reservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `buildSignedPayment` ran the blocking JNI build through `gate.op`, i.e. plain `withContext(Dispatchers.IO)`. Native signing cannot observe cancellation once started and reserves the funding UTXOs before it returns, so a caller cancelled mid-build had the completed `SignedCorePayment` discarded by prompt cancellation — taking with it the tx bytes and reservation handle that are the only way to release. The reservation then sat until the TTL backstop, or forever at height 0 where the sweep never runs. Switched to `gate.opWithCleanupOnCancellation`, the same handoff guard the token-owning `buildSignedPayment` overload already uses, with a synchronous best-effort release of the discarded payment. The cleanup runs from a `finally` and never throws: replacing the caller's `CancellationException` with an unrelated native error would be worse than the reservation it is trying to reclaim. Also threads the reservation handle through the Kotlin surface, which is what makes the release owner-guarded: - `SignedCorePayment` carries `reservationHandle`. - `decodeSignedPayment` reads the extended native blob (`u64 fee, u64 change, u64 reservationHandle`, then tx bytes). - `releasePaymentReservation` takes the handle; a new overload takes the `SignedCorePayment` itself so bytes and handle cannot be mismatched between two in-flight builds. The KDoc claiming the release is "safe after a broadcast" and therefore safe in an unconditional `finally` is removed — releasing between a successful broadcast and sync observing it reopens the inputs to selection. Callers branch on whether they broadcast. Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/ffi/WalletManagerNative.kt | 25 ++- .../dashsdk/wallet/ManagedCoreWallet.kt | 29 +++- .../dashsdk/wallet/ManagedPlatformWallet.kt | 158 ++++++++++++++++-- .../rs-unified-sdk-jni/src/wallet_manager.rs | 56 +++++-- 4 files changed, 220 insertions(+), 48 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 826d1f7c77d..83d5e1c7d0f 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 @@ -230,9 +230,16 @@ internal object WalletManagerNative { * strictly from that one account, with no union across accounts and no * consent gate. * - * Returns a `byte[]` packed big-endian as `u64 fee, u64 change,` then the + * Returns a `byte[]` packed big-endian as + * `u64 fee, u64 change, u64 reservationHandle,` then the * consensus-serialized signed transaction bytes (0-length / null after * throwing). Does NOT broadcast and does NOT persist a debit. + * + * `reservationHandle` is the opaque stand-in for the key-wallet reservation + * token this build stamped on its inputs (0 = it reserved nothing); it must + * be passed back to [coreWalletReleasePaymentReservation] to abandon the + * build. The token itself cannot cross the ABI — it has no public + * constructor, by design, so that it cannot be forged. */ external fun coreWalletBuildSignedPayment( coreHandle: Long, @@ -258,17 +265,23 @@ internal object WalletManagerNative { * * [coreHandle] is a core-wallet handle from [platformWalletGetCore]. * [txBytes] is the consensus-serialized signed transaction exactly as - * [coreWalletBuildSignedPayment] returned it — the transaction is the - * ownership signal, so only that build's own inputs are released. - * [fundingPath] must be the SAME optional path the build was given (null = - * the unmixed BIP44 account). + * [coreWalletBuildSignedPayment] returned it. [fundingPath] must be the SAME + * optional path the build was given (null = the unmixed BIP44 account). + * [reservationHandle] must be the SAME handle that build returned: it is the + * ownership proof that stops this release from freeing a concurrent build's + * inputs after key-wallet's TTL swept this build's reservation and that other + * build re-reserved the same outpoint (dashpay/platform#4247 review). An + * unrecognised non-zero handle throws rather than releasing unguarded. * - * Idempotent, and a silent no-op after a successful broadcast. + * FOR ABANDONED BUILDS ONLY — never after a successful broadcast. Repeated + * releases of the same abandoned build are idempotent; that is the only sense + * in which this is safe to call twice. */ external fun coreWalletReleasePaymentReservation( coreHandle: Long, txBytes: ByteArray, fundingPath: String?, + reservationHandle: 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 320b3f0a9d1..677cd1d4962 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,10 +71,11 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { /** * Build + sign a standard L1 payment funded from ONE of the wallet's * signable funds accounts, WITHOUT broadcasting. Returns the packed native - * result (`u64 fee, u64 change,` then the signed tx bytes, big-endian) — - * decoded by [ManagedPlatformWallet.buildSignedPayment]. See that method - * for the full contract; drive this through it (it serializes concurrent - * builds), not directly. + * result (`u64 fee, u64 change, u64 reservationHandle,` then the signed tx + * bytes, big-endian) — decoded by + * [ManagedPlatformWallet.buildSignedPayment]. See that method for the full + * contract; drive this through it (it serializes concurrent builds), not + * directly. */ internal fun buildSignedPayment( outputsBlob: ByteArray, @@ -92,12 +93,22 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { /** * Release the UTXO reservation a [buildSignedPayment] call took, for a - * build that will not be broadcast. See - * [ManagedPlatformWallet.releasePaymentReservation] for the full contract; - * drive this through it, not directly. + * build that will not be broadcast. [reservationHandle] must be the handle + * that build returned — it is what makes the release owner-guarded. See + * [ManagedPlatformWallet.releasePaymentReservation] for the full contract + * (including why this must NOT be called after a broadcast); drive this + * through it, not directly. */ - internal fun releasePaymentReservation(txBytes: ByteArray, fundingPath: String?) = - WalletManagerNative.coreWalletReleasePaymentReservation(handle, txBytes, fundingPath) + internal fun releasePaymentReservation( + txBytes: ByteArray, + fundingPath: String?, + reservationHandle: Long, + ) = WalletManagerNative.coreWalletReleasePaymentReservation( + handle, + txBytes, + fundingPath, + reservationHandle, + ) 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 280170cd184..115f581e2cc 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 @@ -371,15 +371,33 @@ class ManagedPlatformWallet internal constructor( val txBytes: ByteArray, val fee: Long, val change: Long, + /** + * Opaque handle for the key-wallet reservation token this build stamped + * on its inputs (0 = it reserved nothing). + * + * Pass it to [releasePaymentReservation] together with [txBytes] when + * abandoning the build. It is the ownership proof that stops the release + * from freeing a *different* build's reservation on the same outpoint — + * possible whenever key-wallet's TTL swept this build's reservation and + * another build re-took it (dashpay/platform#4247 review). The token + * itself never crosses the native boundary: it has no public constructor + * precisely so it cannot be forged. + * + * Not a resource: there is nothing to close, and holding a stale handle + * is harmless — it simply stops matching. + */ + val reservationHandle: Long, ) { override fun equals(other: Any?): Boolean = other is SignedCorePayment && txBytes.contentEquals(other.txBytes) && fee == other.fee && - change == other.change + change == other.change && + reservationHandle == other.reservationHandle override fun hashCode(): Int = - (31 * txBytes.contentHashCode() + fee.hashCode()) * 31 + change.hashCode() + ((31 * txBytes.contentHashCode() + fee.hashCode()) * 31 + change.hashCode()) * 31 + + reservationHandle.hashCode() } /** @@ -414,6 +432,17 @@ class ManagedPlatformWallet internal constructor( * selection and signing (the same native serialization [sendToAddresses] * relies on). * + * **Cancellation.** The blocking native build cannot observe cancellation + * once it has started, and it reserves the funding UTXOs before returning. + * If the caller is cancelled while it runs, `withContext` applies prompt + * cancellation and discards the completed [SignedCorePayment] — including + * the [SignedCorePayment.txBytes] and [SignedCorePayment.reservationHandle] + * that are the only way to release that reservation. It would then sit until + * key-wallet's TTL backstop, or forever at height 0 where the sweep never + * runs. This call therefore releases a discarded result on the way out, so a + * cancelled send strands nothing (dashpay/platform#4247 review). The cleanup + * is best-effort and never masks the cancellation. + * * @param recipients `(address, amountDuffs)` pairs; must be non-empty and * every amount positive. * @param coreSignerHandle the manager's `MnemonicResolverHandle` @@ -428,7 +457,19 @@ class ManagedPlatformWallet internal constructor( coreSignerHandle: Long, feePerKb: Long = 0, fundingPath: String? = null, - ): SignedCorePayment = gate.op { + ): SignedCorePayment = gate.opWithCleanupOnCancellation( + // The native build reserves the funding UTXOs before it returns, so by + // the time `withContext` dispatches back to the caller the reservation + // already exists. That handoff is a prompt-cancellation point: a caller + // cancelled while JNI ran never receives the payment, and with it never + // receives the tx bytes + reservation handle that are the ONLY way to + // release. Releasing the discarded payment here is the deterministic + // alternative to waiting out a TTL that, at height 0, never fires. + // Mirrors the token-owning `buildSignedPayment` overload above. + cleanup = { payment: SignedCorePayment -> + releaseDiscardedPayment(payment, fundingPath) + }, + ) { require(recipients.isNotEmpty()) { "recipients must not be empty" } require(recipients.all { it.second > 0 }) { "every recipient amount must be positive" } require(feePerKb >= 0) { "feePerKb must be non-negative, got $feePerKb" } @@ -443,6 +484,42 @@ class ManagedPlatformWallet internal constructor( } } + /** + * Best-effort, synchronous release of a [buildSignedPayment] result that + * coroutine cancellation discarded before the caller could take ownership. + * + * Blocking rather than suspending on purpose: it runs from + * [opWithCleanupOnCancellation]'s `finally`, where the coroutine is already + * cancelled and any suspension would be refused. The underlying native call + * is a fast in-memory map operation, so blocking that thread is not a real + * cost. + * + * Never throws. A failure here would replace the caller's + * `CancellationException` with an unrelated error thrown out of a `finally`, + * and the reservation is recoverable by other means (TTL, or an explicit + * release once sync has a height) whereas a corrupted cancellation signal is + * not. `Throwable` rather than `Exception` because the native boundary can + * surface `UnsatisfiedLinkError` and friends. + */ + private fun releaseDiscardedPayment(payment: SignedCorePayment, fundingPath: String?) { + try { + coreWallet().use { core -> + core.releasePaymentReservation( + payment.txBytes, + fundingPath, + payment.reservationHandle, + ) + } + } catch (t: Throwable) { + android.util.Log.w( + "ManagedPlatformWallet", + "failed to release the reservation of a cancelled buildSignedPayment; " + + "its inputs stay reserved until the TTL backstop", + t, + ) + } + } + /** * Broadcast the deferred payment behind [token] (from [buildSignedPayment]) * and return its broadcast txid — the "merchant server acked" arm. Consumes @@ -541,17 +618,28 @@ class ManagedPlatformWallet internal constructor( * entire balance. This call consults no height, so it is the one release * path that works pre-sync. * - * **Releases only this build's own inputs.** The transaction is the - * ownership signal: a reserved outpoint is skipped by every other build's - * coin selection, so no concurrent build can hold a reservation on any - * input of [txBytes]. + * **Releases only inputs this build still owns.** The guard is + * [reservationHandle], not the transaction. An earlier version of this + * contract claimed the transaction alone sufficed, because "a reserved + * outpoint is skipped by every other build's coin selection". That was + * wrong: key-wallet's TTL sweep reclaims a reservation after 24 blocks, and + * from that moment a concurrent build can legitimately re-reserve the very + * same outpoint. A release by outpoint would then free the OTHER build's + * inputs and let coin selection hand them to a second transaction + * (dashpay/platform#4247 review). * - * **Idempotent, and safe after a broadcast.** Calling it twice, or on a - * transaction that was in fact broadcast, is a silent no-op rather than an - * error, and it cannot resurrect a spent coin — coin selection reads the - * UTXO set, from which sync removes the spend independently of any - * reservation. That makes it safe in an unconditional `finally` without - * tracking whether the broadcast succeeded. + * **For abandoned builds ONLY — never call this after a broadcast.** The + * previous contract advertised it as safe in an unconditional `finally`, + * reasoning that sync removes a broadcast spend from the UTXO set anyway. + * The gap is the window *before* sync observes it: this SDK does not + * broadcast (dashj does), so between a successful broadcast and sync + * processing that spend the inputs are still in the UTXO set, and the + * reservation is the only thing keeping a second build off them. Releasing + * there invites a conflicting transaction. Branch on "did I broadcast it?" — + * something the caller always knows — not on a `finally`. + * + * **Idempotent** across repeated releases of the *same abandoned build*: + * the second call finds the token owns nothing and does nothing. * * @param txBytes [SignedCorePayment.txBytes] from the build being * abandoned. @@ -559,17 +647,44 @@ class ManagedPlatformWallet internal constructor( * release lands on the account holding the reservation; `null` means the * unmixed BIP44 account, exactly as it does for the build. A path naming * a different account is harmless but frees nothing. + * @param reservationHandle the **same** + * [SignedCorePayment.reservationHandle] that build returned. An + * unrecognised non-zero handle throws rather than falling back to an + * unguarded release. */ suspend fun releasePaymentReservation( txBytes: ByteArray, fundingPath: String? = null, + reservationHandle: Long, ): Unit = gate.op { require(txBytes.isNotEmpty()) { "txBytes must not be empty" } mapNativeErrors { - coreWallet().use { core -> core.releasePaymentReservation(txBytes, fundingPath) } + coreWallet().use { core -> + core.releasePaymentReservation(txBytes, fundingPath, reservationHandle) + } } } + /** + * Release the reservation [payment] holds — the object form of + * [releasePaymentReservation], and the one to prefer: it pairs the + * transaction bytes with their own reservation handle, so the two can never + * be mismatched between two in-flight builds. + * + * Same contract: for a build being **abandoned**, never one that was + * broadcast. + * + * @param fundingPath the **same** path [buildSignedPayment] was given. + */ + suspend fun releasePaymentReservation( + payment: SignedCorePayment, + fundingPath: String? = null, + ): Unit = releasePaymentReservation( + txBytes = payment.txBytes, + fundingPath = fundingPath, + reservationHandle = payment.reservationHandle, + ) + /** * The wallet's Platform-payment addresses that currently hold credits, * each as a [FundingInput] whose `credits` is the full cached balance — @@ -1043,18 +1158,25 @@ class ManagedPlatformWallet internal constructor( /** * Decode the packed [SignedCorePayment] the native build returns: - * `u64 fee, u64 change,` then the signed transaction bytes (big-endian). + * `u64 fee, u64 change, u64 reservationHandle,` then the signed transaction + * bytes (big-endian). */ private fun decodeSignedPayment(packed: ByteArray): SignedCorePayment { - require(packed.size >= 16) { - "signed-payment result too short (${packed.size} bytes, need >= 16)" + require(packed.size >= 24) { + "signed-payment result too short (${packed.size} bytes, need >= 24)" } val buffer = java.nio.ByteBuffer.wrap(packed) // big-endian by default val fee = buffer.long val change = buffer.long + val reservationHandle = buffer.long val txBytes = ByteArray(buffer.remaining()) buffer.get(txBytes) - return SignedCorePayment(txBytes = txBytes, fee = fee, change = change) + return SignedCorePayment( + txBytes = txBytes, + fee = fee, + change = change, + reservationHandle = reservationHandle, + ) } /** diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index ebef85df6cc..d71935924c6 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1022,11 +1022,17 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c /// CoinJoin account path) funds strictly from that one account, with no union /// across accounts and no consent gate. /// -/// Returns a `byte[]` packed big-endian as `u64 fee, u64 change,` then the -/// consensus-serialized signed transaction bytes (`fee` and `change` in duffs), -/// or null after throwing. The FFI-owned tx bytes are freed here before -/// returning; Kotlin decodes the packed array via -/// `ManagedPlatformWallet.decodeSignedPayment`. +/// Returns a `byte[]` packed big-endian as +/// `u64 fee, u64 change, u64 reservationHandle,` then the consensus-serialized +/// signed transaction bytes (`fee` and `change` in duffs), or null after +/// throwing. The FFI-owned tx bytes are freed here before returning; Kotlin +/// decodes the packed array via `ManagedPlatformWallet.decodeSignedPayment`. +/// +/// `reservationHandle` is the opaque stand-in for the key-wallet reservation +/// token this build stamped on its inputs (0 = it reserved nothing). It must be +/// handed back to [coreWalletReleasePaymentReservation] to abandon the build: +/// that is what makes the release owner-guarded, and the token itself cannot +/// cross the ABI (it has no public constructor, by design). #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreWalletBuildSignedPayment( mut env: JNIEnv, @@ -1078,6 +1084,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c let mut out_tx_len: usize = 0; let mut out_fee: u64 = 0; let mut out_change: u64 = 0; + let mut out_reservation_handle: u64 = 0; let result = unsafe { platform_wallet_ffi::core_wallet_build_signed_payment( core_handle as Handle, @@ -1091,6 +1098,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c &mut out_tx_len, &mut out_fee, &mut out_change, + &mut out_reservation_handle, ) }; if take_pwffi_error(env, result) { @@ -1098,16 +1106,17 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c } // Copy the FFI-owned tx bytes out, then free them, then pack the - // metadata-prefixed result for Kotlin. `fee` and `change` are written - // big-endian ahead of the raw tx bytes. + // metadata-prefixed result for Kotlin. `fee`, `change` and the + // reservation handle are written big-endian ahead of the raw tx bytes. let tx_bytes: &[u8] = if out_tx_bytes.is_null() || out_tx_len == 0 { &[] } else { unsafe { std::slice::from_raw_parts(out_tx_bytes, out_tx_len) } }; - let mut packed = Vec::with_capacity(16 + tx_bytes.len()); + let mut packed = Vec::with_capacity(24 + tx_bytes.len()); packed.extend_from_slice(&out_fee.to_be_bytes()); packed.extend_from_slice(&out_change.to_be_bytes()); + packed.extend_from_slice(&out_reservation_handle.to_be_bytes()); packed.extend_from_slice(tx_bytes); unsafe { platform_wallet_ffi::core_wallet_free_payment_bytes(out_tx_bytes, out_tx_len); @@ -1134,14 +1143,21 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c /// /// `core_handle` is the transient core-wallet `Handle` from /// [platformWalletGetCore]. `tx_bytes` is the consensus-serialized signed -/// transaction exactly as [coreWalletBuildSignedPayment] returned it — the -/// transaction is the ownership signal, so only this build's own inputs are -/// released. `funding_path` must be the SAME optional path the build was given -/// (null = the unmixed BIP44 account). +/// transaction exactly as [coreWalletBuildSignedPayment] returned it. +/// `funding_path` must be the SAME optional path the build was given (null = the +/// unmixed BIP44 account). `reservation_handle` must be the SAME handle that +/// build returned: it is the ownership proof that keeps this release from +/// freeing a concurrent build's inputs after key-wallet's TTL swept and that +/// build re-reserved the same outpoint (dashpay/platform#4247 review). An +/// unrecognised non-zero handle is refused rather than released unguarded. /// -/// Idempotent, and a silent no-op after a successful broadcast, so it is safe -/// in an unconditional cleanup path. Throws only on an invalid handle, -/// undecodable transaction bytes, or an unresolvable funding path. +/// FOR ABANDONED BUILDS ONLY — never call this after a successful broadcast. +/// Until sync processes the spend the inputs are still in the UTXO set, and the +/// reservation is the only thing keeping a second build off them. Repeated +/// releases of the same abandoned build are idempotent; that is the only sense in +/// which it is safe to call twice. Throws on an invalid handle, undecodable +/// transaction bytes, an unknown reservation handle, or an unresolvable funding +/// path. #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreWalletReleasePaymentReservation( mut env: JNIEnv, @@ -1149,12 +1165,21 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c core_handle: jlong, tx_bytes: JByteArray, funding_path: JString, + reservation_handle: jlong, ) { guard(&mut env, (), |env| { if core_handle == 0 { throw_sdk_exception(env, 1, "core handle is 0"); return; } + // Handles are minted from a monotonic counter starting at 1, so a + // negative `jlong` can only be a caller error. Reject it here rather than + // letting `as u64` wrap it into a huge value that the table would refuse + // with a much less obvious message. + if reservation_handle < 0 { + throw_sdk_exception(env, 1, "reservationHandle must be non-negative"); + return; + } let raw = match env.convert_byte_array(&tx_bytes) { Ok(b) => b, Err(_) => { @@ -1189,6 +1214,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c raw.len(), funding_path_ptr, funding_path_len, + reservation_handle as u64, ) }; take_pwffi_error(env, result); From 7cd1bf9e4fa56946a4afe86b00ffe460db60e837 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:04:04 -0400 Subject: [PATCH 46/47] fix(kotlin-sdk): release reservation on JNI pack failure; harden deferred-payment FFI out-params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five review findings on the split build/broadcast + atomic-finalize surface (dashpay/platform#4247): - wallet_manager.rs: the build-signed-payment JNI export dropped the funding reservation on the byte_array_from_slice failure path — the native build had already reserved its inputs and returned the handle inside the packed blob, but on an alloc failure the export returned null without releasing, stranding the coins until key-wallet TTL (which never fires pre-first-sync). Now mirrors the sibling send export: releases via core_wallet_release_payment_reservation before returning null. Highest-impact of the set (latent fund availability). - transaction_builder.rs: core_wallet_signed_payment_finalize wrote only out_token up front; the other five out-params (out_fee/out_txid/out_tx/ out_bytes_ptr/out_bytes_len) were set only in the success block, so a caller ignoring the result code could read a dangling out_tx.tx_bytes after an error return. Zero-initialize all six at entry. - signed_payment.rs: core_wallet_signed_payment_broadcast wrote *out_txid only in the Ok arm; null-initialize at entry so the five error arms leave a defined value. - ManagedPlatformWallet.kt: fromRegisterBlob read two u32 length prefixes and allocated ByteArrays without bounds/negative-length guards. The blob comes from our own JNI packer (not attacker-reachable), but a malformed layout now fails with a precise message naming the field instead of an opaque NegativeArraySize/BufferUnderflow. - error.rs: two doc comments still described the deferred-token trio as occupying the 27-31 window; it moved to 34-36. Corrected both to defer to the authoritative in-file allocation block and ERROR_CODE_REGISTRY.md (dashpay/platform#4261). Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/wallet/ManagedPlatformWallet.kt | 26 +++++++++++---- .../src/core_wallet/signed_payment.rs | 6 ++++ .../src/core_wallet/transaction_builder.rs | 16 +++++++++ packages/rs-platform-wallet-ffi/src/error.rs | 21 ++++++++---- .../rs-unified-sdk-jni/src/wallet_manager.rs | 33 +++++++++++++++++-- 5 files changed, 86 insertions(+), 16 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 115f581e2cc..ee0a5f5b6f2 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 @@ -257,14 +257,28 @@ class ManagedPlatformWallet internal constructor( */ internal fun fromRegisterBlob(blob: ByteArray): SignedCoreTransaction { val buffer = java.nio.ByteBuffer.wrap(blob) // big-endian by default + // The blob is produced by our own JNI packer, not an attacker, so + // a malformed layout is a bug rather than a reachable hostile + // input. Validate the two length prefixes anyway: a negative + // length (a u32 high bit read as a signed Int) would make + // `ByteArray(len)` throw NegativeArraySizeException, and a length + // past the buffer would throw BufferUnderflowException — both + // opaque. `require` turns either into a precise, greppable message + // naming the field and the offending value. + fun readLengthPrefixedBytes(field: String): ByteArray { + val len = buffer.int + require(len in 0..buffer.remaining()) { + "register blob: $field length $len out of range " + + "0..${buffer.remaining()} (blob is ${blob.size} bytes)" + } + val bytes = ByteArray(len) + buffer.get(bytes) + return bytes + } 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) + val txidBytes = readLengthPrefixedBytes("txid") + val rawTxBytes = readLengthPrefixedBytes("txBytes") return SignedCoreTransaction( txidHex = String(txidBytes, Charsets.UTF_8), rawTxBytes = rawTxBytes, 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 f352b3329f6..528c5c28e47 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 @@ -79,6 +79,12 @@ pub unsafe extern "C" fn core_wallet_signed_payment_broadcast( out_txid: *mut *mut c_char, ) -> PlatformWalletFFIResult { check_ptr!(out_txid); + // Define the out-param before any fallible work. `*out_txid` is written only + // in the Ok arm below, so all five error returns (unknown handle, broadcast + // failure, interior-NUL txid) would otherwise leave the caller's pointer at + // whatever it allocated. Null-initializing means a caller that frees + // `out_txid` after an error frees a null, not an indeterminate pointer. + *out_txid = std::ptr::null_mut(); let core = unwrap_option_or_return!(CORE_WALLET_STORAGE.with_item(core_handle, |w| w.clone())); 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 19c49986e39..4a77e744548 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 @@ -230,7 +230,23 @@ pub unsafe extern "C" fn core_wallet_signed_payment_finalize( check_ptr!(out_tx); check_ptr!(out_bytes_ptr); check_ptr!(out_bytes_len); + // Zero-initialize EVERY out-param up front. The success block at the bottom + // writes all six, but the many error returns in between (build failure, + // signing failure, txid encoding) previously left five of them untouched, + // so a caller that does not check the result code would read whatever it had + // allocated — a dangling `out_tx.tx_bytes`/`out_bytes_ptr` in particular is a + // use-after-free hazard. Defined defaults make every error path leave a null + // transaction, a null txid, and zero fee/length instead. *out_token = 0; + *out_fee = 0; + *out_txid = std::ptr::null_mut(); + *out_tx = FFICoreTransaction { + tx_bytes: std::ptr::null_mut(), + tx_len: 0, + fee: 0, + }; + *out_bytes_ptr = std::ptr::null(); + *out_bytes_len = 0; // `finalize_transaction` consumes the builder: reclaim both heap boxes up // front so they are freed on every return path below. diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 0d7f286c695..148e0320e03 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -248,12 +248,17 @@ pub enum PlatformWalletFFIResultCode { /// (dashpay/platform#4247 review). The specific cause still travels in the /// result `message` via the typed `Display`. /// - /// Numbering: 27–31 are claimed by sibling v4.1 stack PRs - /// (`ErrorStaleReservationToken`/`ErrorReservationTokenConsumed` #4185, - /// `ErrorAssetLockInsufficientFunds` #4184, - /// `ErrorReservationWalletMismatch`, `ErrorSigningKeyUnavailable`), so this - /// takes the first slot free on every branch of that stack and needs no - /// renumbering whatever order they land in. + /// Numbering: this code is **32**. The authoritative in-file allocation map + /// is the comment block immediately below this variant (and + /// `ERROR_CODE_REGISTRY.md`, dashpay/platform#4261); do not restate positions + /// here, they drift. In short: 27–33 are claimed across the merged ABI and + /// the sibling v4.1 stack (27 `ErrorShutdownIncomplete`, 29 + /// `ErrorAssetLockInsufficientFunds`, 31 `ErrorSigningKeyUnavailable`, 33 + /// `ErrorTransactionSigning`), 28 and 30 are vacated-but-reserved, and the + /// deferred-token trio `ErrorStaleReservationToken` / + /// `ErrorReservationTokenConsumed` / `ErrorReservationWalletMismatch` sits at + /// **34–36**, not in the 27–31 window an earlier revision of this comment + /// placed it in. ErrorTransactionBuild = 32, // Codes 27-33 are claimed outside this PR and MUST NOT be reused here. @@ -901,7 +906,9 @@ mod tests { } /// The new code must not silently collide with a sibling v4.1 stack PR's - /// (27–31 are claimed; see the variant's doc comment). + /// (27–33 are claimed across the merged ABI and the stack; the deferred-token + /// trio sits at 34–36 — see the variant's doc comment and + /// `ERROR_CODE_REGISTRY.md`). #[test] fn transaction_build_code_is_thirty_two() { assert_eq!( diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index d71935924c6..af5b7204264 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1122,9 +1122,36 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c platform_wallet_ffi::core_wallet_free_payment_bytes(out_tx_bytes, out_tx_len); } - env.byte_array_from_slice(&packed) - .map(|a| a.into_raw()) - .unwrap_or(ptr::null_mut()) + match env.byte_array_from_slice(&packed) { + Ok(a) => a.into_raw(), + Err(_) => { + // The native build already RESERVED its selected inputs and + // handed back the reservation handle inside `packed`, which + // Kotlin will now never receive. Left alone, those coins sit + // unselectable until key-wallet's 24-block TTL backstop — and + // that backstop never fires before the first sync completes + // (`ReservationSet::sweep` early-returns at height 0), so an + // alloc failure here can strand a freshly restored wallet's + // whole balance. Release the reservation before returning null, + // exactly as the sibling send-payment export does in its Err + // arm. `packed` is `fee|change|handle` (24 BE bytes) then the + // raw tx, so the tx bytes are `packed[24..]`; the funding path + // and handle are still in scope. + let _ = env.exception_clear(); + let tx = &packed[24..]; + let _ = unsafe { + platform_wallet_ffi::core_wallet_release_payment_reservation( + core_handle as Handle, + tx.as_ptr(), + tx.len(), + funding_path_ptr, + funding_path_len, + out_reservation_handle, + ) + }; + ptr::null_mut() + } + } }) } From 5752e211acc1924654c45f4ccc65a6ced561d7ad Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:17:19 -0400 Subject: [PATCH 47/47] fix(kotlin-sdk): release the minted token on the finalize null-txid path Second half of the post-commit reservation-leak cluster (CodeRabbit, #4247). core_wallet_signed_payment_finalize registers the payment and mints its reservation token before returning; the JNI export then checks out_txid, and on the (contract-violating) null-txid arm it freed the transaction but left the token holding its inputs until the TTL sweep (which never fires pre-first-sync). Release the token via core_wallet_signed_payment_release before throwing, matching the sibling register-blob path Err arm. Co-Authored-By: Claude Opus 4.8 --- packages/rs-unified-sdk-jni/src/wallet_manager.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index af5b7204264..9103283977a 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1559,6 +1559,13 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c return ptr::null_mut(); } if out_txid.is_null() { + // finalize returned Ok, so it already minted and registered the + // reservation token — but with no txid there is nothing to hand back + // to Kotlin, and the token would otherwise sit holding its inputs + // until the TTL sweep (which never fires pre-first-sync). Release the + // token as well as freeing the transaction before throwing, matching + // the sibling register-blob path's Err arm below. + unsafe { platform_wallet_ffi::core_wallet_signed_payment_release(token) }; unsafe { platform_wallet_ffi::core_wallet_transaction_free(out_tx) }; throw_sdk_exception(env, 1, "finalize returned a NULL txid"); return ptr::null_mut();