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 de41a05412..e8d55cf3d3 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -176,6 +176,56 @@ sealed class DashSdkError( cause, ) + /** + * `ErrorStaleReservationToken` (native code 26). A payment's funding + * reservation has outlived its lifetime: key-wallet's TTL may already + * have swept and re-selected the inputs, so acting on it could touch a + * newer, unrelated reservation. The call did NOT touch the network. + * NOT retryable in place — rebuild the payment. + * + * The code is shared by BOTH deferred-payment surfaces (the messages + * distinguish them): a deferred (BIP70/BIP270) + * [broadcastSigned][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.broadcastSigned] + * token, rebuilt with + * [buildSignedPayment][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.buildSignedPayment]; + * and a token-less V2 finalized handle whose + * [broadcastTransaction][org.dashfoundation.dashsdk.wallet.ManagedCoreWallet.broadcastTransaction] + * aged past the same reservation bound (abandon still works at any age). + * + * Sibling of the other two deferred-token failures this code used to + * conflate: [ReservationTokenConsumed] (unknown / already broadcast / + * 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 @@ -245,6 +295,9 @@ sealed class DashSdkError( 23 -> PlatformWallet.AssetLockNotTracked(message, cause) // ErrorAssetLockNotTracked 24 -> PlatformWallet.AssetLockAlreadyConsumed(message, cause) // ErrorAssetLockAlreadyConsumed 25 -> PlatformWallet.AssetLockFundingMismatch(message, cause) // ErrorAssetLockFundingMismatch + 26 -> PlatformWallet.StaleReservationToken(message, cause) // ErrorStaleReservationToken + 27 -> PlatformWallet.ReservationTokenConsumed(message, cause) // ErrorReservationTokenConsumed + 28 -> PlatformWallet.ReservationWalletMismatch(message, cause) // ErrorReservationWalletMismatch else -> PlatformWallet.Generic(code, message, cause) } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt index 0dfbbedc89..b99c3734c6 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt @@ -246,6 +246,45 @@ internal object WalletManagerNative { */ external fun coreTransactionFree(tx: Long) + /** + * `core_wallet_signed_payment_finalize` — atomically fund, reserve, sign, + * AND register a builder for deferred (BIP70/BIP270) submission in one + * 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 a big-endian BLOB 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. + * 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 + + /** + * `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/CoreTransactionBuilder.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/CoreTransactionBuilder.kt index 9a988601d3..df72543f23 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 8a0e661d0e..770d6b29f2 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt @@ -40,14 +40,34 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { tx.accountIndex, ) - /** Consume and broadcast a V2 finalized transaction. */ + /** + * Consume and broadcast a V2 finalized transaction. A handle held past the + * reservation age bound throws the typed + * [StaleReservationToken][org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.StaleReservationToken] + * (native code 26, shared with the deferred-token surface) instead of + * broadcasting against inputs key-wallet's TTL may have re-selected. + * + * On that refusal the handle has **already been consumed** by this call, so + * a follow-up [abandonTransaction] is an invalid-handle error, not a recovery + * path — there is nothing left to release, and the aged reservation is left + * for key-wallet's TTL to reclaim (releasing it by outpoint could free a + * newer build's reservation). Recover by rebuilding the transaction. + */ fun broadcastTransaction(tx: FinalizedCoreTransaction): String = WalletManagerNative.coreWalletBroadcastSignedTransactionV2( handle, tx.takeForBroadcast(), ) - /** Consume without sending and release the selected inputs immediately. */ + /** + * Consume a finalized transaction without sending. Below the reservation age + * bound this releases the selected inputs immediately so a rebuild can + * reselect them. If the handle has aged past the bound the by-outpoint + * release is skipped — key-wallet's TTL may already have swept and + * re-reserved the outpoint, so releasing it could free a newer build's + * reservation — and the aged reservation is left for the TTL to reclaim; the + * handle is torn down either way. + */ fun abandonTransaction(tx: FinalizedCoreTransaction) { WalletManagerNative.coreWalletAbandonSignedTransactionV2( handle, @@ -55,6 +75,19 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable { ) } + /** + * 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) + 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 ba05a9ff7f..12befcc7cf 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt @@ -172,6 +172,258 @@ 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. + * + * **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 + * 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 (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 && + 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)" + + /** 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 + * 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 + 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, + ) + } + } + } + + /** + * 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* → 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. + * + * 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. + * + * @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 = 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 }) { + "every recipient amount must be positive" + } + val builderAccountType = when (accountType) { + AccountType.BIP44 -> CoreTransactionBuilder.AccountType.BIP44 + AccountType.BIP32 -> CoreTransactionBuilder.AccountType.BIP32 + } + mapNativeErrors { + // 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) + } + builder.finalizeSignedPayment( + this@ManagedPlatformWallet, + builderAccountType, + accountIndex, + coreSignerHandle, + ) + } + } + } + + /** + * Broadcast the deferred payment behind [token] (from [buildSignedPayment]) + * and return its broadcast txid — the "merchant server acked" arm. Consumes + * 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). + * + * 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 { + coreWallet().use { core -> core.broadcastSignedPayment(token) } + } + } + + /** + * 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 + * 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) + } + } + } + + /** + * 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 — 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 f8e397cade..d469010135 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -103,6 +103,33 @@ class DashSdkErrorTest { ) // The message must warn against retrying (distinct from the anchor case). assertTrue(broadcastUnconfirmed.message!!.contains("do NOT retry")) + + // 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)", + agedOut.isRetryable, + ) + 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/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 0000000000..c32011891e --- /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()) + } +} 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 9792c014ab..ab73172041 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -32,11 +32,14 @@ pub unsafe extern "C" fn core_wallet_broadcast_signed_transaction_v2( "invalid core wallet handle".to_string(), ); }; - if wallet.wallet_id() != finalized.wallet.wallet_id() { + // Same generation identity the registry-token path uses: reject a caller + // handle that names a different wallet generation (e.g. a re-created wallet + // under the same id) before acting through the embedded originating wallet. + if !wallet.is_same_generation(&finalized.wallet) { runtime().block_on(finalized.wallet.abandon_transaction(&finalized.transaction)); return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, - "transaction was finalized by a different wallet".to_string(), + "transaction was finalized by a different wallet generation".to_string(), ); } let result = runtime().block_on( @@ -71,7 +74,8 @@ pub unsafe extern "C" fn core_wallet_abandon_signed_transaction_v2( "invalid core wallet handle".to_string(), ); }; - if wallet.wallet_id() != transaction.wallet.wallet_id() { + // Same generation identity as the broadcast path / registry-token path. + if !wallet.is_same_generation(&transaction.wallet) { runtime().block_on( transaction .wallet @@ -79,7 +83,7 @@ pub unsafe extern "C" fn core_wallet_abandon_signed_transaction_v2( ); return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, - "transaction was finalized by a different wallet".to_string(), + "transaction was finalized by a different wallet generation".to_string(), ); } runtime().block_on( @@ -210,6 +214,27 @@ mod tests { runtime().block_on(core.abandon_transaction(&retry)); } + /// Prove the funding reservation is *still held*: a fresh finalize of the + /// same size cannot reselect the single fixture UTXO, so it fails at the + /// build stage. Used to show an aged abandon/free skipped the by-outpoint + /// release (leaving the input reserved for key-wallet's TTL). + fn assert_still_reserved(core: &TestCore, signer: &WalletSigner, tag: u8) { + let rebuild = runtime().block_on(core.finalize_transaction( + TransactionBuilder::new().add_output( + &Address::dummy(Network::Testnet, usize::from(tag)), + 1_000_000, + ), + AccountTypePreference::BIP44, + 0, + signer, + )); + assert!( + rebuild.is_err(), + "aged abandon/free must skip the release, leaving the input reserved; \ + got {rebuild:?}" + ); + } + #[test] fn double_free_is_safe_and_releases_reservation() { let (core, signer) = @@ -249,6 +274,56 @@ mod tests { CORE_WALLET_STORAGE.remove(other_handle); } + /// The deinit/GC backstop (`core_wallet_signed_transaction_v2_free`) is the + /// exact path shumkov flagged: a `FinalizedCoreTransaction` never broadcast + /// or abandoned, freed by the host GC long after finalize. If the reservation + /// has aged past the guard bound the free must **skip** the by-outpoint + /// release — key-wallet's TTL may already have swept and re-reserved the + /// outpoint, and releasing it would free that newer build's reservation. The + /// handle is still torn down (the storage entry is removed) so a re-free is a + /// safe no-op. + #[test] + fn aged_v2_free_skips_reservation_release() { + let (core, signer) = + runtime().block_on(funded_spv_core_wallet(StandardAccountType::BIP44Account)); + let transaction_handle = insert(&core, finalize(&core, &signer, 48)); + + // Age the pinned handle past the guard bound (still below the TTL, so the + // reservation is provably still held — only the software guard trips). + runtime().block_on(platform_wallet::test_support::age_core_past_reservation_guard(&core)); + + core_wallet_signed_transaction_v2_free(transaction_handle); + + // The aged free skipped the release: the input is still reserved. + assert_still_reserved(&core, &signer, 49); + // Handle is gone regardless — a re-free is a harmless no-op. + core_wallet_signed_transaction_v2_free(transaction_handle); + } + + /// The FFI broadcast/abandon *failure* paths (invalid or wrong-generation + /// wallet handle) route their cleanup through `abandon_transaction`, so they + /// inherit the same age guard: when the handle has aged out, the failure-path + /// cleanup must skip the by-outpoint release rather than free a possibly + /// re-reserved outpoint. + #[test] + fn aged_failure_path_abandon_skips_reservation_release() { + let (origin, signer) = + runtime().block_on(funded_spv_core_wallet(StandardAccountType::BIP44Account)); + let transaction_handle = insert(&origin, finalize(&origin, &signer, 50)); + + runtime().block_on(platform_wallet::test_support::age_core_past_reservation_guard(&origin)); + + // Invalid wallet handle → routes through abandon_transaction, then returns + // ErrorInvalidHandle. The embedded aged reservation must be left alone. + let invalid = + unsafe { core_wallet_abandon_signed_transaction_v2(u64::MAX, transaction_handle) }; + assert_eq!( + invalid.code, + PlatformWalletFFIResultCode::ErrorInvalidHandle + ); + assert_still_reserved(&origin, &signer, 51); + } + #[test] fn abandon_then_free_or_broadcast_cannot_reconsume_handle() { let (core, signer) = diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs index 8e12ebc178..01c0cf4167 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; +pub(crate) 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 0000000000..29c792bd64 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs @@ -0,0 +1,112 @@ +//! 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 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. +pub(crate) static SIGNED_PAYMENT_REGISTRY: Lazy> = + Lazy::new(SignedPaymentRegistry::new); + +/// 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 +/// failure, and consume the token. +/// +/// The token is consumed atomically before the send, so a repeated or +/// concurrent broadcast of the same token gets `ErrorReservationTokenConsumed` +/// (27) 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` (28). A token whose reservation +/// may already have aged out of key-wallet's TTL yields +/// `ErrorStaleReservationToken` (26). (These three used to be conflated under +/// code 26.) 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() + } + // 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), + } +} + +/// 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 8b79efcf2a..429b66df4c 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; @@ -39,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, } @@ -134,6 +138,144 @@ 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(), + // 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()), + ), + ); + + *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-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 7d01177b5b..e951b362e9 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -168,6 +168,46 @@ pub enum PlatformWalletFFIResultCode { /// Existing-lock recovery attempted to use a lock for the wrong funding /// family or bound identity index. ErrorAssetLockFundingMismatch = 25, + /// Maps `SignedPaymentError::StaleReservationToken` from the deferred + /// build → broadcast/release core-send lifecycle (`core_wallet_signed_payment_*`): + /// the token has outlived the registry's `RESERVATION_MAX_AGE_BLOCKS` bound + /// and its funding reservation may already have been swept and re-selected by + /// key-wallet's TTL, so acting on it could touch a newer, unrelated + /// reservation. The operation did NOT touch the network. NOT retryable in + /// place — the host must rebuild the payment. + /// + /// Sibling codes split out the other two deferred-token failures that this + /// code used to conflate: [`Self::ErrorReservationTokenConsumed`] (27, + /// unknown / already broadcast / already released) and + /// [`Self::ErrorReservationWalletMismatch`] (28, minted against a different + /// wallet generation). All three are non-retryable-in-place and none touched + /// the network; they are distinct codes so a host can message each precisely. + /// + /// Also maps `PlatformWalletError::StaleReservation` from the atomic V2 + /// finalized-transaction handle path + /// (`core_wallet_broadcast_signed_transaction_v2`): a pinned handle whose + /// funding reservation aged past the SAME `RESERVATION_MAX_AGE_BLOCKS` bound + /// carries the identical "may already have been swept — rebuild" meaning, so + /// the two surfaces intentionally share this one code. The V2 handle carries + /// no numeric reservation token, hence a distinct (token-less) wallet-error + /// variant behind the same FFI code. Abandon/free of a V2 handle never + /// surfaces this — releasing an aged reservation is always allowed. + ErrorStaleReservationToken = 26, + + /// Maps `SignedPaymentError::StaleToken`. The deferred reservation token is + /// unknown, already broadcast, or already released — the guard that turns a + /// double-broadcast (or a broadcast after release) into a typed error + /// instead of a second send. Did NOT touch the network; NOT retryable + /// (rebuild the payment). Release is idempotent and never surfaces this. + ErrorReservationTokenConsumed = 27, + + /// Maps `SignedPaymentError::WalletMismatch`. The deferred reservation token + /// was minted against a different wallet *generation* than the one it is + /// being broadcast through (e.g. a wallet re-created under the same id); its + /// reservation lives in that other generation's `ReservationSet`. Did NOT + /// touch the network and did NOT consume the rightful owner's token; NOT + /// retryable through this handle (rebuild the payment). + ErrorReservationWalletMismatch = 28, NotFound = 98, // Used exclusively for all the Option that are retuned as errors ErrorUnknown = 99, @@ -308,6 +348,14 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::TransactionBroadcastUnconfirmed(..) => { PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed } + // The V2 finalized-transaction handle path's age guard. Shares the + // `ErrorStaleReservationToken` code with the deferred registry-token + // sibling (`SignedPaymentError::StaleReservationToken`): both mean + // "the funding reservation may already have been swept — rebuild", + // and neither touched the network. See the code's doc note. + PlatformWalletError::StaleReservation => { + PlatformWalletFFIResultCode::ErrorStaleReservationToken + } // A definitively-failed address-nonce race (reaches the blanket impl // via identity `top_up_from_addresses` → `?`/`.into()`). Exposing // provided/expected nonce as structured out-fields is INTENTIONALLY @@ -762,6 +810,34 @@ mod tests { assert_eq!(msg, rendered, "Display payload must survive verbatim"); } + /// The V2 finalized-transaction handle age guard + /// (`core_wallet_broadcast_signed_transaction_v2` → `broadcast_finalized_transaction`) + /// surfaces `PlatformWalletError::StaleReservation` through the blanket + /// `From` impl, which must reuse the deferred registry-token path's + /// `ErrorStaleReservationToken` (26) code rather than flattening to + /// `ErrorUnknown` — the two surfaces share the "reservation may have been + /// swept; rebuild" meaning and this one code. The typed Display rendering + /// survives across the boundary as the message. + #[test] + fn stale_reservation_maps_to_shared_stale_reservation_code() { + let err = PlatformWalletError::StaleReservation; + let rendered = err.to_string(); + let result: PlatformWalletFFIResult = err.into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorStaleReservationToken, + "StaleReservation must reuse the registry-token stale code (rendered: {rendered})" + ); + assert!(!result.message.is_null()); + let msg = unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned(); + assert_eq!( + msg, rendered, + "Display payload must survive the FFI boundary verbatim" + ); + } + /// `AddressNonceMismatch` maps to the dedicated `ErrorAddressNonceMismatch` /// FFI code through the blanket `From` impl (the path identity /// `top_up_from_addresses` takes via `?`/`.into()`) rather than flattening diff --git a/packages/rs-platform-wallet-ffi/src/handle.rs b/packages/rs-platform-wallet-ffi/src/handle.rs index f343e4ccc9..b4eba259f9 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, @@ -78,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 ed90edaad7..aac69794ec 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -454,7 +454,23 @@ pub unsafe extern "C" fn platform_wallet_manager_remove_wallet( }); let result = unwrap_option_or_return!(option); match result { - Ok(_) => PlatformWalletFFIResult::ok(), + Ok(removed) => { + // Generation teardown: the wallet and its accounts' `ReservationSet`s + // are now gone from the manager, so the deferred-payment reservations + // cease to exist — there is nothing to reconcile. DROP (do not + // release) this generation's registry tokens and its finalized-tx V2 + // handles. This is the teardown half of the single generation policy + // both deferred paths share: it makes any stale handle to the removed + // generation inert, so a later destroy/release of a lingering handle + // can never release-by-outpoint against a re-created generation's + // inputs. + let core = removed.core(); + crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY + .remove_entries_for_wallet(core); + crate::handle::CORE_SIGNED_TRANSACTION_V2_STORAGE + .remove_matching(|tx| tx.wallet.is_same_generation(core)); + PlatformWalletFFIResult::ok() + } // Idempotency: a wallet that's already gone is the success // state callers want. Everything else is a real failure. Err(platform_wallet::PlatformWalletError::WalletNotFound(_)) => { diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index 8ffd78a896..29fe4be476 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -390,6 +390,121 @@ 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 { - PLATFORM_WALLET_STORAGE.remove(handle); + // 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. + // + // 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 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), + ); + } PlatformWalletFFIResult::ok() } + +#[cfg(test)] +mod destroy_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; + + 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() { + // 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 + // `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(), + AccountTypePreference::BIP44, + 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); + (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/error.rs b/packages/rs-platform-wallet/src/error.rs index 6e11514bea..b73202c49a 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -90,6 +90,27 @@ pub enum PlatformWalletError { )] TransactionBroadcastUnconfirmed(String), + /// A finalized V2 transaction handle + /// (`core_wallet_tx_builder_finalize` → `broadcast_finalized_transaction`) + /// was held long enough that its funding reservation may already have been + /// swept and re-selected by key-wallet's TTL: the wallet's + /// `last_processed_height` advanced at least + /// [`RESERVATION_MAX_AGE_BLOCKS`](crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS) + /// blocks past the height the reservation was stamped at + /// ([`SignedCoreTransaction::reservation_height`](crate::SignedCoreTransaction::reservation_height)). + /// Broadcasting it could spend against a newer, unrelated reservation, so it + /// is refused **before** touching the network — NOT retryable in place, the + /// caller must rebuild the payment. Abandoning/freeing the handle stays + /// allowed at any age (releasing an old reservation is always safe). + /// + /// This is the V2 handle-path sibling of the deferred registry-token + /// [`SignedPaymentError::StaleReservationToken`](crate::SignedPaymentError::StaleReservationToken); + /// both share the same age bound and the FFI `ErrorStaleReservationToken` + /// code. Carries no token — the handle path is keyed by an opaque handle, + /// not a numeric reservation token. + #[error("finalized transaction reservation has outlived its lifetime; rebuild the payment")] + StaleReservation, + #[error("Transaction building failed: {0}")] TransactionBuild(String), diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index e91c5ccee0..273ba9e82a 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/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 8fd146dc77..109466a3b2 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( @@ -277,3 +353,109 @@ pub async fn funded_spv_core_wallet( signer, ) } + +/// Advance `core`'s `last_processed_height` to just past the reservation age +/// guard bound ([`RESERVATION_MAX_AGE_BLOCKS`](crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS)) +/// but below key-wallet's `ReservationSet` TTL, so a handle finalized at the +/// current height ages enough to trip the software guard while its underlying +/// reservation is provably still held (no key-wallet sweep yet). Returns the new +/// height. +/// +/// FFI lifecycle tests use this to exercise the aged abandon/free skip-release +/// path — the deinit/GC backstop and the broadcast/abandon failure paths that +/// route their cleanup through `abandon_transaction`. +pub async fn age_core_past_reservation_guard(core: &crate::CoreWallet) -> u32 +where + B: crate::broadcaster::TransactionBroadcaster + ?Sized, +{ + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let stamped = core + .last_processed_height() + .await + .expect("wallet present in manager"); + let target = stamped + crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS + 2; + { + let mut wm = core.wallet_manager.write().await; + let (_, info) = wm + .get_wallet_and_info_mut(&core.wallet_id()) + .expect("wallet present in manager"); + info.core_wallet.update_last_processed_height(target); + } + target +} + +/// No-op persister satisfying [`PlatformWalletManager`] construction for tests +/// that need a full [`PlatformWallet`] but no real persistence pipeline. +pub struct NoopTestPersister; + +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) +} diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 0f3d7fd1f0..6e9fbd5673 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -1,18 +1,49 @@ 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; +use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; +use crate::wallet::reservations::{broadcast_releasing_on_rejection, reservation_expired}; use crate::{CoreWallet, PlatformWalletError}; impl CoreWallet { /// Broadcast an atomically finalized transaction. A definitive rejection /// releases its reservation; an ambiguous `MaybeSent` outcome retains it. + /// + /// # Reservation age guard + /// + /// A V2 finalized-transaction handle can be pinned by the host for an + /// arbitrary time between `finalize` and this broadcast. If the wallet's + /// `last_processed_height` advances at least + /// [`RESERVATION_MAX_AGE_BLOCKS`](crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS) + /// blocks past the height the funding reservation was stamped at + /// ([`SignedCoreTransaction::reservation_height`]), key-wallet's own + /// `ReservationSet` TTL could already have swept those inputs and let an + /// unrelated build re-select them. Broadcasting then would spend against a + /// newer, unrelated reservation, so the send is refused with + /// [`PlatformWalletError::StaleReservation`] **before** the broadcaster is + /// touched — mirroring the deferred registry token's + /// [`broadcast`](crate::SignedPaymentRegistry::broadcast) guard, off the + /// same bound and the same `last_processed_height` clock, and running after + /// the FFI layer's generation-identity check just as the registry does. + /// The stale reservation is deliberately left for key-wallet's TTL to + /// reclaim rather than released by outpoint here (which could free a newer + /// build's reservation); the caller must rebuild the payment. Abandon/free + /// ([`abandon_transaction`](Self::abandon_transaction)) honor the same bound: + /// once aged they too skip the by-outpoint release and leave the outpoint for + /// the TTL, because releasing a swept-and-re-reserved outpoint could free a + /// newer build's reservation — only below the bound do they release. pub async fn broadcast_finalized_transaction( &self, transaction: &SignedCoreTransaction, ) -> Result { + if reservation_expired( + Some(transaction.reservation_height()), + self.last_processed_height().await, + ) { + return Err(PlatformWalletError::StaleReservation); + } match self.broadcaster.broadcast(transaction.transaction()).await { Ok(txid) => Ok(txid), Err(error) => { @@ -86,6 +117,48 @@ impl CoreWallet { .await .map_err(Into::into) } + + /// Broadcast a raw signed `transaction` for the deferred-payment + /// [`SignedPaymentRegistry`](crate::SignedPaymentRegistry), reconciling the + /// funding reservation on failure. + /// + /// 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. + /// + /// 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). + /// + /// `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: AccountTypePreference, + account_index: u32, + transaction: &Transaction, + ) -> 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()) + } + } + } } #[cfg(test)] @@ -98,14 +171,17 @@ mod tests { use key_wallet::signer::Signer; use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy; use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder; + use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use crate::broadcaster::TransactionBroadcaster; use crate::test_support::{ - funded_wallet_manager, AlwaysMaybeSentBroadcaster, RejectFirstBroadcaster, WalletSigner, + funded_wallet_manager, AlwaysMaybeSentBroadcaster, AlwaysOkBroadcaster, + RejectFirstBroadcaster, WalletSigner, }; use crate::wallet::core::CoreWallet; - use crate::PlatformWalletError; + use crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS; + use crate::{PlatformWalletError, SignedCoreTransaction}; /// Builds a testnet `CoreWallet` over the shared funded fixture and a /// 1_000_000-duff payment to a dummy recipient. @@ -187,6 +263,218 @@ mod tests { Ok(tx) } + /// Atomically fund + reserve + sign a `SignedCoreTransaction` the way the V2 + /// handle path (`core_wallet_tx_builder_finalize`) does, capturing the + /// reservation's stamp height on the returned handle. + async fn finalize_tx( + core: &CoreWallet, + account_type: AccountTypePreference, + outputs: &[(DashAddress, u64)], + signer: &WalletSigner, + ) -> SignedCoreTransaction { + let mut builder = TransactionBuilder::new(); + for (addr, amount) in outputs { + builder = builder.add_output(addr, *amount); + } + try_finalize_tx(core, account_type, outputs, signer) + .await + .expect("finalize should succeed") + } + + /// Like [`finalize_tx`] but surfaces the build error instead of panicking — + /// used to prove a *rebuild* fails when a still-held reservation keeps its + /// inputs out of the selectable pool. + async fn try_finalize_tx( + core: &CoreWallet, + account_type: AccountTypePreference, + outputs: &[(DashAddress, u64)], + signer: &WalletSigner, + ) -> Result { + let mut builder = TransactionBuilder::new(); + for (addr, amount) in outputs { + builder = builder.add_output(addr, *amount); + } + core.finalize_transaction(builder, account_type, 0, signer) + .await + } + + /// Force the wallet's `last_processed_height` forward, simulating chain + /// progress between `finalize` and a later broadcast of the pinned V2 + /// handle — the window in which key-wallet's `ReservationSet` TTL can sweep + /// the funding reservation. Same clock the age guard reads. + async fn advance_processed_height( + core: &CoreWallet, + height: u32, + ) { + let mut wm = core.wallet_manager.write().await; + let (_, info) = wm + .get_wallet_and_info_mut(&core.wallet_id()) + .expect("wallet present in manager"); + info.core_wallet.update_last_processed_height(height); + } + + /// A freshly finalized V2 handle — no chain progress since `finalize` — + /// broadcasts normally: the age guard does not trip. + #[tokio::test] + async fn fresh_finalized_handle_broadcasts() { + for account_type in [AccountTypePreference::BIP44, AccountTypePreference::BIP32] { + let (core, signer, outputs) = funded_core_wallet( + account_type_standard(account_type), + Arc::new(AlwaysOkBroadcaster), + ) + .await; + let finalized = finalize_tx(&core, account_type, &outputs, &signer).await; + let sent = core.broadcast_finalized_transaction(&finalized).await; + assert!( + sent.is_ok(), + "a fresh handle must broadcast for {account_type:?}, got {sent:?}" + ); + } + } + + /// A V2 handle pinned while the wallet syncs past `RESERVATION_MAX_AGE_BLOCKS` + /// beyond its reservation stamp must be refused with `StaleReservation` + /// (never a send — the broadcaster is `AlwaysOk`, so a leaked send would + /// surface as `Ok`). An aged abandon/free must then **skip** the by-outpoint + /// release: key-wallet's TTL may already have swept and re-reserved the + /// outpoint, so releasing it could free a newer build's reservation. We prove + /// the skip by showing the input is still reserved after abandon — an + /// immediate rebuild cannot reselect it. + #[tokio::test] + async fn aged_finalized_handle_refuses_broadcast_and_skips_release() { + for account_type in [AccountTypePreference::BIP44, AccountTypePreference::BIP32] { + let (core, signer, outputs) = funded_core_wallet( + account_type_standard(account_type), + Arc::new(AlwaysOkBroadcaster), + ) + .await; + let stamped = core + .last_processed_height() + .await + .expect("last processed height"); + let finalized = finalize_tx(&core, account_type, &outputs, &signer).await; + + // Advance past the guard bound (stay below key-wallet's 24-block TTL, + // so the reservation is provably still held — only our guard tripped). + advance_processed_height(&core, stamped + RESERVATION_MAX_AGE_BLOCKS + 2).await; + + let sent = core.broadcast_finalized_transaction(&finalized).await; + assert!( + matches!(sent, Err(PlatformWalletError::StaleReservation)), + "an aged handle must refuse with StaleReservation for \ + {account_type:?}, got {sent:?}" + ); + + // Aged abandon skips the by-outpoint release. The reservation is left + // for key-wallet's TTL, so the input stays reserved and a rebuild + // cannot reselect it (it would surface as a build/insufficient-funds + // error). + core.abandon_transaction(&finalized).await; + let rebuilt = try_finalize_tx(&core, account_type, &outputs, &signer).await; + assert!( + rebuilt.is_err(), + "aged abandon must skip the release, leaving the input reserved \ + for {account_type:?}, got {rebuilt:?}" + ); + } + } + + /// Below the guard bound the reservation is provably still ours (no sweep + /// possible yet), so abandon/free **do** release by outpoint — returning the + /// inputs so an immediate rebuild reselects them. This is the mirror of the + /// aged skip case. + #[tokio::test] + async fn below_bound_finalized_handle_abandon_releases() { + for account_type in [AccountTypePreference::BIP44, AccountTypePreference::BIP32] { + let (core, signer, outputs) = funded_core_wallet( + account_type_standard(account_type), + Arc::new(AlwaysOkBroadcaster), + ) + .await; + let stamped = core + .last_processed_height() + .await + .expect("last processed height"); + let finalized = finalize_tx(&core, account_type, &outputs, &signer).await; + + // Aged, but one shy of the guard bound: still below both the guard and + // the TTL, so the reservation is unambiguously ours to release. + advance_processed_height(&core, stamped + RESERVATION_MAX_AGE_BLOCKS - 1).await; + + core.abandon_transaction(&finalized).await; + + // The release freed the input: an immediate rebuild reselects it. + let rebuilt = try_finalize_tx(&core, account_type, &outputs, &signer).await; + assert!( + rebuilt.is_ok(), + "below-bound abandon must release the input so a rebuild reselects \ + it for {account_type:?}, got {rebuilt:?}" + ); + core.abandon_transaction(&rebuilt.expect("rebuild")).await; + } + } + + /// The guard boundary is exact: `current - stamped >= RESERVATION_MAX_AGE_BLOCKS` + /// refuses, one block below still broadcasts — for both standard account + /// types, like the fresh/aged tests. + #[tokio::test] + async fn finalized_handle_age_guard_boundary_is_exact() { + for account_type in [AccountTypePreference::BIP44, AccountTypePreference::BIP32] { + // One below the bound: still fresh enough to broadcast. + let (below_core, below_signer, below_outputs) = funded_core_wallet( + account_type_standard(account_type), + Arc::new(AlwaysOkBroadcaster), + ) + .await; + let below_stamped = below_core + .last_processed_height() + .await + .expect("last processed height"); + let below = finalize_tx(&below_core, account_type, &below_outputs, &below_signer).await; + advance_processed_height(&below_core, below_stamped + RESERVATION_MAX_AGE_BLOCKS - 1) + .await; + assert!( + below_core + .broadcast_finalized_transaction(&below) + .await + .is_ok(), + "one block below the bound must still broadcast ({account_type:?})" + ); + + // Exactly at the bound: refused. + let (at_core, at_signer, at_outputs) = funded_core_wallet( + account_type_standard(account_type), + Arc::new(AlwaysOkBroadcaster), + ) + .await; + let at_stamped = at_core + .last_processed_height() + .await + .expect("last processed height"); + let at = finalize_tx(&at_core, account_type, &at_outputs, &at_signer).await; + advance_processed_height(&at_core, at_stamped + RESERVATION_MAX_AGE_BLOCKS).await; + assert!( + matches!( + at_core.broadcast_finalized_transaction(&at).await, + Err(PlatformWalletError::StaleReservation) + ), + "exactly at the bound must refuse with StaleReservation ({account_type:?})" + ); + } + } + + /// Map a builder `AccountTypePreference` (BIP44/BIP32 only in these tests) + /// to the `StandardAccountType` the funded fixture is keyed by. + fn account_type_standard(account_type: AccountTypePreference) -> StandardAccountType { + match account_type { + AccountTypePreference::BIP44 => StandardAccountType::BIP44Account, + AccountTypePreference::BIP32 => StandardAccountType::BIP32Account, + AccountTypePreference::CoinJoin => { + unreachable!("coinjoin funding not exercised by these tests") + } + } + } + /// A pre-send broadcast rejection must release the UTXO reservation taken /// while building the transaction, so an immediate retry can reselect those /// inputs instead of failing with spurious insufficient funds until the TTL diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 81b2e8a824..af9ba2eb1d 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; @@ -20,6 +21,7 @@ use key_wallet::{Account, DerivationPath, Utxo}; use super::CoreWallet; use crate::broadcaster::TransactionBroadcaster; +use crate::wallet::reservations::reservation_expired; use crate::PlatformWalletError; fn map_builder_error( @@ -59,6 +61,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 +88,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 +144,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 +220,7 @@ impl CoreWallet { } }; - (unsigned, fee, selected, paths) + (unsigned, fee, selected, paths, height) }; let signed = match signer @@ -223,11 +242,45 @@ impl CoreWallet { fee, funding_account_type: account_type, funding_account_index: account_index, + reservation_height: height, }) } /// Release a finalized transaction that the caller has chosen not to send. + /// + /// # Reservation age guard + /// + /// This is the abandon/free arm of the V2 finalized-transaction handle — + /// including the FFI broadcast/abandon *failure* paths (invalid or + /// wrong-generation wallet handle) that route their cleanup here, and the + /// host-language deinit/GC backstop + /// (`core_wallet_signed_transaction_v2_free`). A pinned handle can reach it + /// long after `finalize`, so it honors the **same** age bound as + /// [`broadcast_finalized_transaction`](Self::broadcast_finalized_transaction), + /// off the same shared [`reservation_expired`] predicate and the same + /// `last_processed_height` clock. + /// + /// Once the reservation has aged past + /// [`RESERVATION_MAX_AGE_BLOCKS`](crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS), + /// key-wallet's `ReservationSet` TTL may already have swept its outpoint and + /// let an unrelated build re-reserve it; releasing by outpoint now would free + /// that newer build's reservation (`ReservationSet::release` is + /// unconditional). So the aged path **skips** the by-outpoint release and + /// leaves the outpoint for the TTL to reclaim, dropping only the handle — + /// exactly the policy the deferred registry's `reconcile_removed_entry` + /// applies. Below the bound the reservation is provably still ours and is + /// released so an immediate rebuild can reselect the inputs. pub async fn abandon_transaction(&self, transaction: &SignedCoreTransaction) { + if reservation_expired( + Some(transaction.reservation_height), + self.last_processed_height().await, + ) { + // Aged past the shared bound: the outpoint may have been swept and + // re-reserved by an unrelated build. Leave it for key-wallet's TTL; + // releasing it here could free that newer reservation. The handle + // itself is already dropped by the caller (FFI storage removal). + return; + } self.release_transaction_reservation( transaction.funding_account_type, transaction.funding_account_index, @@ -242,19 +295,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 8cc0488ade..832df6bc2f 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; @@ -66,6 +67,47 @@ 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) + } + + /// 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, @@ -286,6 +328,25 @@ impl CoreWallet { pub fn network(&self) -> key_wallet::Network { self.sdk.network } + + /// Current last-processed block height for this wallet, or `None` if the + /// wallet is no longer present in the manager. + /// + /// 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 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.last_processed_height()) + } } impl std::fmt::Debug for CoreWallet { @@ -310,3 +371,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 + )); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/mod.rs b/packages/rs-platform-wallet/src/wallet/mod.rs index 1963422be7..e8ae111513 100644 --- a/packages/rs-platform-wallet/src/wallet/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/mod.rs @@ -11,6 +11,7 @@ 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; @@ -25,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/reservations.rs b/packages/rs-platform-wallet/src/wallet/reservations.rs index 096a7c2af1..045d4c596e 100644 --- a/packages/rs-platform-wallet/src/wallet/reservations.rs +++ b/packages/rs-platform-wallet/src/wallet/reservations.rs @@ -22,6 +22,61 @@ use tokio::sync::RwLock; use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; +/// Maximum age, in `last_processed_height` blocks, of a held funding +/// reservation before an operation that would *consume* it (broadcast) is +/// refused. Shared by the two deferred/split core-send surfaces so they bound a +/// reservation's lifetime against the same TTL with one number: +/// +/// * the deferred build → broadcast/release registry +/// ([`SignedPaymentRegistry`](crate::SignedPaymentRegistry)), and +/// * the atomic V2 finalized-transaction handle path +/// (`core_wallet_tx_builder_finalize` → +/// `broadcast_finalized_transaction`). +/// +/// Kept strictly below key-wallet's `RESERVATION_TTL_BLOCKS` (24, ~1h at the +/// mainnet block target): a `build_signed` / `finalize_transaction` reservation +/// is stamped at the wallet's `last_processed_height` (via `set_current_height`) +/// and swept by a later `reserve`/`reserved` call — itself stamped with the same +/// `last_processed_height` clock — once it is `RESERVATION_TTL_BLOCKS` old, +/// silently returning the outpoint to the selectable pool where an unrelated +/// build can re-select and re-reserve it. `ReservationSet::release` removes an +/// outpoint unconditionally, with no ownership/generation check, so acting on a +/// reservation that was already swept could free (or broadcast against) a newer, +/// unrelated one. Refusing at this lower bound guarantees the guard always trips +/// **before** the underlying reservation could have been swept, leaving a margin +/// for `last_processed_height` to lag a few blocks behind the true tip. +pub(crate) const RESERVATION_MAX_AGE_BLOCKS: u32 = 20; + +/// Whether a reservation stamped at `registered_height` is too old to act on +/// at `current_height` (see [`RESERVATION_MAX_AGE_BLOCKS`]). Unknown heights +/// (the wallet was gone at register or is gone now) disable the guard — the +/// wallet-mismatch / account-lookup / generation-identity paths already reject +/// those cases. +/// +/// Both *consuming* (broadcasting) and *releasing by outpoint* (abandon/free) a +/// stale reservation are refused. Once the outpoint may already have been swept +/// by key-wallet's TTL and re-reserved by an unrelated build, broadcasting would +/// spend against that newer reservation and releasing would free it — +/// `ReservationSet::release` removes an outpoint unconditionally, with no +/// ownership/generation check. An aged reservation is therefore left for +/// key-wallet's TTL to reclaim: the guarded broadcast +/// ([`broadcast_finalized_transaction`](crate::CoreWallet::broadcast_finalized_transaction)) +/// returns `StaleReservation`, and the guarded abandon/free paths (the registry's +/// `reconcile_removed_entry` and +/// [`abandon_transaction`](crate::CoreWallet::abandon_transaction)) tear the +/// handle/registry entry down without touching the `ReservationSet`. +pub(crate) fn reservation_expired( + registered_height: Option, + current_height: Option, +) -> bool { + match (registered_height, current_height) { + (Some(registered), Some(current)) => { + current.saturating_sub(registered) >= RESERVATION_MAX_AGE_BLOCKS + } + _ => false, + } +} + /// Broadcast `tx` and reconcile the funding account's UTXO reservation on /// failure. /// diff --git a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs new file mode 100644 index 0000000000..be41355d29 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -0,0 +1,1668 @@ +//! 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) 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`] — 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 *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. 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`](crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS)). Once the +//! wallet's `last_processed_height` has advanced far enough past the height at +//! which `build_signed` / `finalize_transaction` stamped the reservation that +//! key-wallet's own `ReservationSet` TTL could have swept and re-selected the +//! 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 +//! +//! 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::{Mutex, MutexGuard}; + +use dashcore::{Transaction, Txid}; +use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; + +use crate::broadcaster::TransactionBroadcaster; +use crate::wallet::core::CoreWallet; +// The age bound and its predicate are shared with the atomic V2 finalized- +// transaction handle path (`broadcast_finalized_transaction`), so both surfaces +// measure a reservation's lifetime against key-wallet's TTL with one number. +use crate::wallet::reservations::reservation_expired; +use crate::PlatformWalletError; + +/// Opaque handle to a registered, signed-but-unsent payment. Minted by +/// [`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 token has outlived + /// [`RESERVATION_MAX_AGE_BLOCKS`](crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS), so its underlying + /// UTXO reservation may already have been swept by key-wallet's TTL and + /// re-selected by an unrelated build. Acting on it (broadcast or release) + /// could touch a newer reservation, so it is refused and the caller must + /// rebuild the payment. + #[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 + /// 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 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 + /// 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`](crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS)). `None` when + /// the wallet was not resolvable at registration, which disables the age + /// guard for this entry. + registered_height: Option, +} + +/// 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()), + } + } + + /// 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 `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. + /// + /// `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`](crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS). + pub async fn register( + &self, + core: CoreWallet, + tx: Transaction, + account_type: AccountTypePreference, + account_index: u32, + registered_height: Option, + ) -> ReservationToken { + let token = self.next_token.fetch_add(1, Ordering::SeqCst); + self.lock().insert( + token, + RegisteredPayment { + core, + tx, + account_type, + account_index, + registered_height, + }, + ); + token + } + + /// Broadcast the payment behind `token`, reconciling its UTXO reservation on + /// failure, then consume the token. + /// + /// 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 + /// kept — the same policy as the non-deferred send path. + pub async fn broadcast( + &self, + token: ReservationToken, + current: &CoreWallet, + ) -> Result { + // 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 + // 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, + ) { + return Err(SignedPaymentError::StaleReservationToken(token)); + } + + // One releasing-broadcast path for every funding variant, CoinJoin + // included: a definitive rejection releases the reservation for an + // immediate rebuild, an ambiguous outcome keeps it, and the release is + // bound to the token's own wallet generation. + let txid = entry + .core + .broadcast_payment_releasing_reservation( + entry.account_type, + entry.account_index, + &entry.tx, + ) + .await?; + 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 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, + entry.core.last_processed_height().await, + ) { + return; + } + entry + .core + .release_transaction_reservation(entry.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. + /// + /// 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 = { self.lock().remove(&token) }; + let Some(entry) = entry else { + // Unknown / already consumed — idempotent no-op. + return; + }; + 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. + /// + /// 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| !entry.core.is_same_generation(wallet)); + before - entries.len() + } + + /// 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. + #[cfg(any(test, feature = "test-utils"))] + pub fn outstanding(&self) -> usize { + self.lock().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::transaction_building::AccountTypePreference; + 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::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS; + use crate::PlatformWalletError; + + /// The [`AccountTypePreference`] a `build_signed_tx` funding account maps to + /// — the registry now retains the full account handle (CoinJoin included), + /// 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, + } + } + + /// 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)]) + } + + /// 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. + 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"); + // 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 + .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, + AccountTypePreference::BIP44, + 0, + core.last_processed_height().await, + ) + .await; + 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, + preference(account_type), + 0, + core.last_processed_height().await, + ) + .await; + + // 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:?}" + ); + } + } + + /// 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] + 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, + AccountTypePreference::BIP44, + 0, + core.last_processed_height().await, + ) + .await; + + 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, + AccountTypePreference::BIP44, + 0, + core.last_processed_height().await, + ) + .await; + + 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, + AccountTypePreference::BIP44, + 0, + core.last_processed_height().await, + ) + .await; + + 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, + AccountTypePreference::BIP44, + 0, + core_a.last_processed_height().await, + ) + .await; + + 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(), + 1, + "a mismatched broadcast must NOT consume the rightful owner's token" + ); + } + + /// 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, + AccountTypePreference::BIP44, + 0, + core.last_processed_height().await, + ) + .await; + + 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, + AccountTypePreference::BIP44, + 0, + core.last_processed_height().await, + ) + .await; + + 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 { + let height = core.last_processed_height().await; + registry + .register(core, tx, AccountTypePreference::BIP44, 0, height) + .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); + } + + /// 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_last_processed_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 + .last_processed_height() + .await + .expect("last processed height"); + 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; + + // 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_processed_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 + .last_processed_height() + .await + .expect("last processed height"); + 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; + + 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"); + + // 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, + AccountTypePreference::BIP44, + 0, + core.last_processed_height().await, + ) + .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(), + 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 + /// 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, + AccountTypePreference::BIP44, + 0, + core_a.last_processed_height().await, + ) + .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, + AccountTypePreference::BIP44, + 0, + core_b.last_processed_height().await, + ) + .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:?}" + ); + + // 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, + AccountTypePreference::BIP44, + 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 + /// 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, + AccountTypePreference::BIP44, + 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` + /// 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, + AccountTypePreference::BIP44, + 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" + ); + } + + /// 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:?}" + ); + } +} diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 8e0f3e676b..f772b7e357 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1232,6 +1232,200 @@ 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: +// [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_finalize` — atomically fund, reserve, sign, and +/// register a builder for deferred (BIP70/BIP270) submission in ONE native +/// 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 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)] +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. 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, + _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`). diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index e741d96244..45ef6996dd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -66,6 +66,24 @@ public enum PlatformWalletResultCode: Int32, Sendable { case errorAssetLockNotTracked = 23 case errorAssetLockAlreadyConsumed = 24 case errorAssetLockFundingMismatch = 25 + /// A deferred (BIP70/BIP270) reservation token has outlived its funding + /// reservation's lifetime: key-wallet's TTL may already have swept and + /// re-selected the inputs, so acting on it could touch a newer, unrelated + /// reservation. The call did NOT touch the network. NOT retryable in place — + /// rebuild the payment. + case errorStaleReservationToken = 26 + /// A deferred reservation token is unknown, already broadcast, or already + /// released — the guard that turns a double-broadcast (or a broadcast after + /// release) into a typed error instead of a second send. The call did NOT + /// touch the network. NOT retryable: rebuild the payment. (Release is + /// idempotent and never surfaces this.) + case errorReservationTokenConsumed = 27 + /// A deferred reservation token was minted against a different wallet + /// *generation* than the one broadcasting it (e.g. a wallet re-created under + /// the same id); its reservation lives in that other generation's reservation + /// set. The call did NOT touch the network and did NOT consume the rightful + /// owner's token. NOT retryable through this handle: rebuild the payment. + case errorReservationWalletMismatch = 28 case notFound = 98 case errorUnknown = 99 @@ -123,6 +141,12 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorAssetLockAlreadyConsumed case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ASSET_LOCK_FUNDING_MISMATCH: self = .errorAssetLockFundingMismatch + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_STALE_RESERVATION_TOKEN: + self = .errorStaleReservationToken + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_RESERVATION_TOKEN_CONSUMED: + self = .errorReservationTokenConsumed + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_RESERVATION_WALLET_MISMATCH: + self = .errorReservationWalletMismatch case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -242,6 +266,21 @@ public enum PlatformWalletError: LocalizedError { /// to retry, and the retry re-fetches the address nonce so the mismatch /// self-heals. The submitted/expected nonce values are in the message. case addressNonceMismatch(String) + /// A deferred (BIP70/BIP270) reservation token has outlived its funding + /// reservation's lifetime — key-wallet's TTL may already have swept and + /// re-selected the inputs. Nothing was broadcast. NOT retryable in place; + /// rebuild the payment. Sibling of `reservationTokenConsumed` and + /// `reservationWalletMismatch`, which this code used to conflate. + case staleReservationToken(String) + /// A deferred reservation token is unknown, already broadcast, or already + /// released — the double-broadcast guard. Nothing was broadcast. NOT + /// retryable; rebuild the payment. + case reservationTokenConsumed(String) + /// A deferred reservation token was minted against a different wallet + /// generation than the one broadcasting it (e.g. a wallet re-created under + /// the same id). Nothing was broadcast and the rightful owner's token was + /// not consumed. NOT retryable through this handle; rebuild the payment. + case reservationWalletMismatch(String) case notFound(String) case unknown(String) @@ -263,6 +302,8 @@ public enum PlatformWalletError: LocalizedError { .shieldedNoRecordedAnchor(let m), .transactionBroadcastUnconfirmed(let m), .addressNonceMismatch(let m), + .staleReservationToken(let m), .reservationTokenConsumed(let m), + .reservationWalletMismatch(let m), .notFound(let m), .unknown(let m): return m } @@ -302,6 +343,12 @@ public enum PlatformWalletError: LocalizedError { self = .transactionBroadcastUnconfirmed(detail) case .errorAddressNonceMismatch: self = .addressNonceMismatch(detail) + case .errorStaleReservationToken: + self = .staleReservationToken(detail) + case .errorReservationTokenConsumed: + self = .reservationTokenConsumed(detail) + case .errorReservationWalletMismatch: + self = .reservationWalletMismatch(detail) case .notFound: self = .notFound(detail) case .errorUnknown: self = .unknown(detail) }