diff --git a/Cargo.lock b/Cargo.lock index f80a97e568..cef4c9f761 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1229,7 +1229,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1660,7 +1660,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=e99959ced0062159d629930f488374e29f63c42b#e99959ced0062159d629930f488374e29f63c42b" dependencies = [ "bincode", "bincode_derive", @@ -1671,7 +1671,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=e99959ced0062159d629930f488374e29f63c42b#e99959ced0062159d629930f488374e29f63c42b" dependencies = [ "dash-network", ] @@ -1748,7 +1748,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=e99959ced0062159d629930f488374e29f63c42b#e99959ced0062159d629930f488374e29f63c42b" dependencies = [ "async-trait", "chrono", @@ -1777,7 +1777,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=e99959ced0062159d629930f488374e29f63c42b#e99959ced0062159d629930f488374e29f63c42b" dependencies = [ "anyhow", "base64-compat", @@ -1803,12 +1803,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=e99959ced0062159d629930f488374e29f63c42b#e99959ced0062159d629930f488374e29f63c42b" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=e99959ced0062159d629930f488374e29f63c42b#e99959ced0062159d629930f488374e29f63c42b" dependencies = [ "dashcore-rpc-json", "hex", @@ -1821,7 +1821,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=e99959ced0062159d629930f488374e29f63c42b#e99959ced0062159d629930f488374e29f63c42b" dependencies = [ "bincode", "dashcore", @@ -1836,7 +1836,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=e99959ced0062159d629930f488374e29f63c42b#e99959ced0062159d629930f488374e29f63c42b" dependencies = [ "bincode", "dashcore-private", @@ -2472,7 +2472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2533,7 +2533,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2902,7 +2902,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=e99959ced0062159d629930f488374e29f63c42b#e99959ced0062159d629930f488374e29f63c42b" [[package]] name = "glob" @@ -3837,7 +3837,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4093,7 +4093,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=e99959ced0062159d629930f488374e29f63c42b#e99959ced0062159d629930f488374e29f63c42b" dependencies = [ "aes", "async-trait", @@ -4122,7 +4122,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=e99959ced0062159d629930f488374e29f63c42b#e99959ced0062159d629930f488374e29f63c42b" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4138,7 +4138,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=e99959ced0062159d629930f488374e29f63c42b#e99959ced0062159d629930f488374e29f63c42b" dependencies = [ "async-trait", "bincode", @@ -4649,7 +4649,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5746,7 +5746,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6553,7 +6553,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6566,7 +6566,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6625,7 +6625,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -7485,7 +7485,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -8934,7 +8934,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index bce6e76df1..fe310e44e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,14 +52,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } +dashcore = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" } +dash-network-seeds = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" } +dash-spv = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" } +key-wallet = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" } +key-wallet-ffi = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" } +key-wallet-manager = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" } +dash-network = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" } +dashcore-rpc = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" } tokio-metrics = "0.5" 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..a962e96970 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -89,6 +89,53 @@ sealed class DashSdkError( class CoreInsufficientFunds(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorTransactionBuild` (native code 32). A Core transaction could + * not be assembled from the request. The REQUEST is at fault, so a + * verbatim retry fails identically — the caller must change it. + * + * It is what every `buildSignedPayment` rejection that is neither a + * shortfall nor a signing failure ([TransactionSigning]) surfaces as: + * a `fundingPath` matching no spendable funds account or + * naming a watch-only one (the two failure modes the single-account + * send design rests on, dashpay/platform#4184), a request breaching a + * monetary bound (MAX_MONEY total, max fee rate, a below-dust + * recipient, an over-100 kB recipient list), or a malformed recipients + * blob. Before this code existed they all arrived as [Generic] with + * native code 99 and could only be told apart by string-matching the + * message. The specific cause is still in [message]. + */ + class TransactionBuild(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + + /** + * `ErrorTransactionSigning` (native code 33). The request was valid and + * the transaction was fully assembled — only the input signatures could + * not be produced, typically because the Keystore/Keychain mnemonic is + * locked or missing. + * + * Unlike [TransactionBuild], the REQUEST is fine: the native layer + * released this build's input reservation before returning, so once the + * signer is usable again the caller may resubmit the identical + * recipients, amount, fee and funding path. Surface this as "unlock to + * continue", never as "this payment is invalid" + * (dashpay/platform#4256). + * + * Retryable in the same sense as [ShieldedNoRecordedAnchor]: nothing + * was committed and the reservation was released, so the retry + * succeeds once its precondition is met. Gate it on the unlock — do + * not spin on it. + * + * Distinct from the sibling stack's `SigningKeyUnavailable` (native + * code 31, dashpay/platform#4183), which is a Platform + * state-transition signer failure reporting a specific identity key + * that must be re-derived. + */ + class TransactionSigning(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) { + override val isRetryable: Boolean get() = true + } + class AssetLockNotTracked(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) @@ -176,6 +223,49 @@ sealed class DashSdkError( cause, ) + /** + * `ErrorStaleReservationToken` (native code 34). A deferred + * (BIP70/BIP270) [broadcastSigned][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.broadcastSigned] + * token has outlived its funding reservation's lifetime: key-wallet's + * TTL may already have swept and re-selected the inputs, so acting on it + * could touch a newer, unrelated reservation. The call did NOT touch the + * network. NOT retryable in place — rebuild the payment with + * [buildSignedPayment][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.buildSignedPayment]. + * + * 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 35). A deferred + * (BIP70/BIP270) [broadcastSigned][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.broadcastSigned] + * token is unknown, already broadcast, or already released — the guard + * that turns a double-broadcast (or a broadcast after release) into a + * 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 36). A deferred + * (BIP70/BIP270) [broadcastSigned][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.broadcastSigned] + * token was minted against a different wallet *generation* than the one + * broadcasting it (e.g. a wallet re-created under the same id); its + * 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 +335,16 @@ sealed class DashSdkError( 23 -> PlatformWallet.AssetLockNotTracked(message, cause) // ErrorAssetLockNotTracked 24 -> PlatformWallet.AssetLockAlreadyConsumed(message, cause) // ErrorAssetLockAlreadyConsumed 25 -> PlatformWallet.AssetLockFundingMismatch(message, cause) // ErrorAssetLockFundingMismatch + 32 -> PlatformWallet.TransactionBuild(message, cause) // ErrorTransactionBuild + 33 -> PlatformWallet.TransactionSigning(message, cause) // ErrorTransactionSigning + // The deferred-token trio sits at the contiguous block 34-36 because + // 27-33 are claimed elsewhere: 27 ErrorShutdownIncomplete + // (dashpay/platform#4268, merged), 29 ErrorAssetLockInsufficientFunds + // (#4184), 31 ErrorSigningKeyUnavailable (#4183/#4259), 32/33 above. + // See packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md. + 34 -> PlatformWallet.StaleReservationToken(message, cause) // ErrorStaleReservationToken + 35 -> PlatformWallet.ReservationTokenConsumed(message, cause) // ErrorReservationTokenConsumed + 36 -> PlatformWallet.ReservationWalletMismatch(message, cause) // ErrorReservationWalletMismatch 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..8b5dd59b56 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 @@ -211,6 +211,61 @@ internal object WalletManagerNative { */ external fun platformWalletGetCore(walletHandle: Long): Long + /** + * `core_wallet_build_signed_payment` — build + sign a standard L1 payment + * funded from ONE of the wallet's signable funds accounts, WITHOUT + * broadcasting. + * + * [coreHandle] is a core-wallet handle from [platformWalletGetCore]. + * [outputsBlob] encodes the recipients big-endian as `u32 count` then per + * row `u32 addrLen, addr utf8, u64 amount`. [feePerKb] is duffs/kB (0 = + * default). [coreSignerHandle] is the manager's `MnemonicResolverHandle`. + * [fundingPath] is an optional UTF-8 BIP32 derivation-path string + * (dashpay/platform#4184) naming the single funds account whose UTXOs fund + * the payment: null (the default) funds from the unmixed BIP44 account; an + * explicit account-level path (e.g. the DIP-9 CoinJoin account path) funds + * strictly from that one account, with no union across accounts and no + * consent gate. + * + * Returns a `byte[]` packed big-endian as `u64 fee, u64 change,` then the + * consensus-serialized signed transaction bytes (0-length / null after + * throwing). Does NOT broadcast and does NOT persist a debit. + */ + external fun coreWalletBuildSignedPayment( + coreHandle: Long, + outputsBlob: ByteArray, + feePerKb: Long, + coreSignerHandle: Long, + fundingPath: String?, + ): ByteArray + + /** + * `core_wallet_build_signed_payment_with_token` — build + sign a standard L1 + * payment funded from ONE of the wallet's signable funds accounts AND + * register it for deferred submission, returning a reservation token. + * + * The bridge between [coreWalletBuildSignedPayment] (selects by derivation + * path, so it can reach a **DashPay receiving-funds** account, but returns + * raw bytes with no token and never broadcasts) and + * [coreWalletFinalizeSignedPayment] (mints a token and can broadcast, but + * selects only BIP44 / BIP32 / CoinJoin). Parameters are exactly + * [coreWalletBuildSignedPayment]'s. + * + * On success the funding UTXOs are RESERVED and owned by the returned token: + * consume it with [coreWalletBroadcastSignedPayment] or + * [coreWalletReleaseSignedPayment]. + * + * Returns a big-endian BLOB: `u64 token, u64 feeDuffs, u64 changeDuffs, + * u32 txidLen, txid utf8, u32 txBytesLen, txBytes`. + */ + external fun coreWalletBuildSignedPaymentWithToken( + coreHandle: Long, + outputsBlob: ByteArray, + feePerKb: Long, + coreSignerHandle: Long, + fundingPath: String?, + ): ByteArray + /** * `core_wallet_broadcast_transaction` — broadcast a transaction built by * [coreTxBuilderBuildSigned]. [accountType]/[accountIndex] identify the @@ -246,6 +301,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` (34, aged out), + * `ErrorReservationTokenConsumed` (35, already consumed/unknown), or + * `ErrorReservationWalletMismatch` (36, different wallet generation). + * [coreHandle] must resolve to the wallet the token was minted against. + * Returns the txid as a lowercase hex string. + */ + 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..9e26077ec4 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt @@ -55,6 +55,63 @@ 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) + + /** + * Build + sign a standard L1 payment funded from ONE of the wallet's + * signable funds accounts, WITHOUT broadcasting. Returns the packed native + * result (`u64 fee, u64 change,` then the signed tx bytes, big-endian) — + * decoded by [ManagedPlatformWallet.buildSignedPayment]. See that method + * for the full contract; drive this through it (it serializes concurrent + * builds), not directly. + */ + internal fun buildSignedPayment( + outputsBlob: ByteArray, + feePerKb: Long, + coreSignerHandle: Long, + fundingPath: String?, + ): ByteArray = + WalletManagerNative.coreWalletBuildSignedPayment( + handle, + outputsBlob, + feePerKb, + coreSignerHandle, + fundingPath, + ) + + /** + * Build + sign a payment from ONE signable funds account AND register it for + * deferred submission. Returns the packed native result (`u64 token, + * u64 fee, u64 change, u32 txidLen, txid, u32 txLen, txBytes`, big-endian) — + * decoded by [ManagedPlatformWallet.buildSignedPaymentWithToken]. See that + * method for the full contract, including the reservation the returned token + * owns; drive this through it, not directly. + */ + internal fun buildSignedPaymentWithToken( + outputsBlob: ByteArray, + feePerKb: Long, + coreSignerHandle: Long, + fundingPath: String?, + ): ByteArray = + WalletManagerNative.coreWalletBuildSignedPaymentWithToken( + handle, + outputsBlob, + feePerKb, + coreSignerHandle, + fundingPath, + ) + override fun close() { cleanable.clean() } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt index ba05a9ff7f..18ffb51bb4 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,454 @@ 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). + * @property changeDuffs duffs returned to the wallet's BIP44 change address, + * 0 when the build produced no change output. Reported only by + * [buildSignedPaymentWithToken]; the builder-driven [buildSignedPayment] + * path leaves it 0 because its native call returns no change figure. + */ + class SignedCoreTransaction internal constructor( + val txidHex: String, + val rawTxBytes: ByteArray, + val feeDuffs: Long, + val reservationToken: Long, + val changeDuffs: Long = 0, + ) : 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 && + changeDuffs == other.changeDuffs + + override fun hashCode(): Int { + var result = txidHex.hashCode() + result = 31 * result + rawTxBytes.contentHashCode() + result = 31 * result + feeDuffs.hashCode() + result = 31 * result + reservationToken.hashCode() + result = 31 * result + changeDuffs.hashCode() + return result + } + + override fun toString(): String = + "SignedCoreTransaction(txidHex=$txidHex, feeDuffs=$feeDuffs, " + + "changeDuffs=$changeDuffs, 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, + ) + } + + /** + * Decode the big-endian native BLOB the funding-path build-and- + * register FFI returns: `u64 token, u64 feeDuffs, u64 changeDuffs, + * u32 txidLen, txid utf8, u32 txBytesLen, txBytes`. + * + * A distinct decoder from [fromRegisterBlob], not an extension of + * it: the change field sits between the fee and the txid, so the two + * layouts diverge from byte 16 onward and reading one with the + * other's decoder would silently mis-frame the txid and tx bytes. + */ + internal fun fromPaymentRegisterBlob(blob: ByteArray): SignedCoreTransaction { + val buffer = java.nio.ByteBuffer.wrap(blob) // big-endian by default + val token = buffer.long + val feeDuffs = buffer.long + val changeDuffs = 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, + changeDuffs = changeDuffs, + ) + } + } + } + + /** + * 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, + ) + } + } + } + + /** + * A built-and-signed Core L1 payment that was NOT broadcast — the output of + * [buildSignedPayment]. [txBytes] is the consensus-serialized signed + * transaction the caller commits/broadcasts itself (dashj during the + * dashj→SDK transition; the SDK's own broadcast afterwards). [fee] and + * [change] are duffs. + */ + data class SignedCorePayment( + val txBytes: ByteArray, + val fee: Long, + val change: Long, + ) { + override fun equals(other: Any?): Boolean = + other is SignedCorePayment && + txBytes.contentEquals(other.txBytes) && + fee == other.fee && + change == other.change + + override fun hashCode(): Int = + (31 * txBytes.contentHashCode() + fee.hashCode()) * 31 + change.hashCode() + } + + /** + * Build and sign a Core L1 payment to [recipients], funding it from a + * **single** funds account, and return the signed raw transaction bytes + * plus the fee and change — **WITHOUT broadcasting**. + * + * This is the transition-era "give me signed bytes" primitive: the Android + * wallet hands [SignedCorePayment.txBytes] to dashj for commit + broadcast + * (keeping dashj's `maybeCommitTx` bookkeeping — CrowdNode, memos, + * confidence listeners), while the SDK owns coin selection and signing. It + * does not broadcast and does not persist a debit; the selected inputs are + * only reserved in memory (released when the spend is later observed by sync + * or by the reservation-TTL backstop). + * + * **Funding-domain isolation (dashpay/platform#4184).** Coin selection never + * spans accounts. [fundingPath] names the one funds account to draw from; + * `null` (the default) draws from the unmixed BIP44 account. Passing an + * explicit account-level path — e.g. the DIP-9 CoinJoin account path — spends + * previously-mixed coins deliberately, and only those. Unioning ordinary, + * CoinJoin, and DashPay-receiving coins into one transaction would + * irreversibly link those privacy domains on chain, so it is never done + * implicitly: if the named account cannot cover the payment this throws + * [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.CoreInsufficientFunds] + * rather than reaching into another account — whose `available` figure is + * that ONE account's balance, so the actionable response is to pick a + * different [fundingPath], not to retry the same one. + * + * Runs through the manager's [TeardownGate] like every other native op. + * A concurrent build cannot select the same UTXO because the underlying + * `build_signed_payment` holds the wallet-manager write lock across coin + * selection and signing (the same native serialization [sendToAddresses] + * relies on). + * + * @param recipients `(address, amountDuffs)` pairs; must be non-empty and + * every amount positive. + * @param coreSignerHandle the manager's `MnemonicResolverHandle` + * (`PlatformWalletManager.mnemonicResolverHandle`); no private key crosses + * the boundary. + * @param feePerKb fee rate in duffs/kB, or 0 for the SDK default. + * @param fundingPath optional UTF-8 BIP32 derivation-path string naming the + * single funds account to fund from; `null` = the unmixed BIP44 account. + */ + suspend fun buildSignedPayment( + recipients: List>, + coreSignerHandle: Long, + feePerKb: Long = 0, + fundingPath: String? = null, + ): SignedCorePayment = gate.op { + require(recipients.isNotEmpty()) { "recipients must not be empty" } + require(recipients.all { it.second > 0 }) { "every recipient amount must be positive" } + require(feePerKb >= 0) { "feePerKb must be non-negative, got $feePerKb" } + + val outputsBlob = encodePaymentOutputs(recipients) + mapNativeErrors { + coreWallet().use { core -> + decodeSignedPayment( + core.buildSignedPayment(outputsBlob, feePerKb, coreSignerHandle, fundingPath), + ) + } + } + } + + /** + * Build and sign a Core L1 payment to [recipients] from a **single** funds + * account named by [fundingPath], reserve the funding UTXOs, and return a + * [SignedCoreTransaction] whose [SignedCoreTransaction.reservationToken] + * later drives [broadcastSigned] (send it) or [releaseReservation] (abandon + * it). + * + * The bridge between the SDK's two previously-disconnected send flows: + * + * * [buildSignedPayment] selects by derivation path — the only selector that + * can reach a **DashPay receiving-funds** account — but returns raw bytes + * with no token and never broadcasts. + * * The [buildSignedPayment] overload taking an `accountType` mints a token + * and can broadcast, but its selector only knows BIP44 / BIP32 / CoinJoin, + * so it cannot spend a receival balance at all. + * + * This method is the first with both halves: pass the receival account's + * account-level derivation path (the `derivationPath` the account-balance + * enumeration reports for that account, which is produced by the very same + * Rust call the selector compares against) and then broadcast the token. + * + * **The returned object OWNS the reservation.** It is [AutoCloseable] with a + * [NativeCleaner] GC backstop, so a token that is neither broadcast nor + * released is never orphaned. Prefer the object-taking + * [broadcastSigned]/[releaseReservation] overloads over passing the bare + * `Long`: extracting the token and dropping the object races GC, which can + * release the reservation mid-broadcast. + * + * Funding-domain isolation is unchanged from [buildSignedPayment] and is + * enforced by the same Rust selector: exactly ONE account funds the payment, + * never a union; change routes to the unmixed BIP44 account (structural — + * key-wallet derives change only for Standard accounts); watch-only DashPay + * *external* accounts (a contact's coins) are refused even when named + * explicitly; and a shortfall throws + * [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.CoreInsufficientFunds] + * with the ONE account's balance as `available` rather than reaching into + * another domain. + * + * Process-death note: the reservation is in-memory, so an app crash between + * this call and [broadcastSigned] returns the UTXOs to spendable on restart + * — the same property dashj has. + * + * @param recipients `(address, amountDuffs)` pairs; must be non-empty and + * every amount positive. + * @param coreSignerHandle the manager's `MnemonicResolverHandle`; no private + * key crosses the boundary. + * @param feePerKb fee rate in duffs/kB, or 0 for the SDK default. + * @param fundingPath account-level BIP32 derivation-path string naming the + * single funds account to spend from; `null` = the unmixed BIP44 account. + */ + suspend fun buildSignedPaymentWithToken( + recipients: List>, + coreSignerHandle: Long, + feePerKb: Long = 0, + fundingPath: String? = null, + ): SignedCoreTransaction = gate.op { + require(recipients.isNotEmpty()) { "recipients must not be empty" } + require(recipients.all { it.second > 0 }) { "every recipient amount must be positive" } + require(feePerKb >= 0) { "feePerKb must be non-negative, got $feePerKb" } + + val outputsBlob = encodePaymentOutputs(recipients) + mapNativeErrors { + coreWallet().use { core -> + SignedCoreTransaction.fromPaymentRegisterBlob( + core.buildSignedPaymentWithToken( + outputsBlob, + feePerKb, + coreSignerHandle, + fundingPath, + ), + ) + } + } + } + + /** + * 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 — @@ -617,6 +1065,41 @@ class ManagedPlatformWallet internal constructor( return out.toByteArray() } + /** + * Encode [recipients] to the payment-outputs blob + * `core_wallet_build_signed_payment` reads: `u32 count` then per row + * `u32 addrLen, addr utf8 bytes, u64 amount` (all big-endian, matching + * `DataOutputStream`'s wire order and the Rust `from_be_bytes` decoder). + */ + private fun encodePaymentOutputs(recipients: List>): ByteArray { + val out = java.io.ByteArrayOutputStream() + val dos = java.io.DataOutputStream(out) + dos.writeInt(recipients.size) + for ((address, amount) in recipients) { + val addrBytes = address.toByteArray(Charsets.UTF_8) + dos.writeInt(addrBytes.size) + dos.write(addrBytes) + dos.writeLong(amount) + } + return out.toByteArray() + } + + /** + * Decode the packed [SignedCorePayment] the native build returns: + * `u64 fee, u64 change,` then the signed transaction bytes (big-endian). + */ + private fun decodeSignedPayment(packed: ByteArray): SignedCorePayment { + require(packed.size >= 16) { + "signed-payment result too short (${packed.size} bytes, need >= 16)" + } + val buffer = java.nio.ByteBuffer.wrap(packed) // big-endian by default + val fee = buffer.long + val change = buffer.long + val txBytes = ByteArray(buffer.remaining()) + buffer.get(txBytes) + return SignedCorePayment(txBytes = txBytes, fee = fee, change = change) + } + /** * Encode [recipients] to the funding-recipients blob the FFI reads: * `u32 rowCount` then per row `u8 addressType, u8[20] hash, diff --git a/packages/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..1e3962fdbc 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,88 @@ 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 + 34, "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 + 35, "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 + 36, "different generation")) + assertTrue(walletMismatch is DashSdkError.PlatformWallet.ReservationWalletMismatch) + assertFalse( + "ReservationWalletMismatch must NOT be retryable (rebuild the payment)", + walletMismatch.isRetryable, + ) + assertEquals("different generation", walletMismatch.message) + } + + /** + * `ErrorTransactionBuild` (32) must reach callers as its own type. Every + * non-shortfall `buildSignedPayment` rejection maps to it — including the + * two failure modes the single-account send design rests on, an unmatched + * `fundingPath` and a watch-only one. They previously arrived as + * [DashSdkError.PlatformWallet.Generic] with code 99, distinguishable only + * by string-matching the message (dashpay/platform#4247 review). + */ + @Test + fun transactionBuildFailuresGetTheirOwnType() { + val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET + val message = "no spendable funds account matches funding derivation path m/44'/5'/7'" + + val mapped = DashSdkError.fromNative(DashSDKException(offset + 32, message)) + assertTrue( + "code 32 must not fall through to Generic", + mapped is DashSdkError.PlatformWallet.TransactionBuild, + ) + assertEquals(message, mapped.message) + assertFalse( + "the request itself is at fault, so a verbatim retry cannot help", + mapped.isRetryable, + ) + } + + /** + * `ErrorTransactionSigning` (33) must be its own type with the OPPOSITE + * retry contract to [DashSdkError.PlatformWallet.TransactionBuild] (32). + * Signing failures used to fold into 32, so a locked Keychain told the + * host its payment was invalid and a retry was pointless — when in fact + * the native layer released the inputs and the identical request succeeds + * after an unlock (dashpay/platform#4256 review). + */ + @Test + fun signingFailuresAreRetryableAndNotRequestInvalid() { + val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET + val message = "payment signing failed: mnemonic unavailable: keychain is locked" + + val mapped = DashSdkError.fromNative(DashSDKException(offset + 33, message)) + assertTrue( + "code 33 must not fall through to Generic", + mapped is DashSdkError.PlatformWallet.TransactionSigning, + ) + assertFalse( + "a signing failure must not be typed as a request-invalid build", + mapped is DashSdkError.PlatformWallet.TransactionBuild, + ) + assertEquals(message, mapped.message) + assertTrue( + "the request was valid and its inputs were released, so retrying " + + "after an unlock can succeed", + mapped.isRetryable, + ) } @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 26fa825fa5..3144b33c4d 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -47,11 +47,14 @@ pub unsafe extern "C" fn core_wallet_broadcast_signed_transaction_v2( "invalid core wallet handle".to_string(), ); }; - if wallet.wallet_id() != finalized.wallet.wallet_id() { + // Same generation identity the registry-token path uses: reject a caller + // handle that names a different wallet generation (e.g. a re-created wallet + // under the same id) before acting through the embedded originating wallet. + if !wallet.is_same_generation(&finalized.wallet) { runtime().block_on(finalized.wallet.abandon_transaction(&finalized.transaction)); return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, - "transaction was finalized by a different wallet".to_string(), + "transaction was finalized by a different wallet generation".to_string(), ); } let local_txid = finalized.transaction.transaction().txid(); @@ -90,7 +93,8 @@ pub unsafe extern "C" fn core_wallet_abandon_signed_transaction_v2( "invalid core wallet handle".to_string(), ); }; - if wallet.wallet_id() != transaction.wallet.wallet_id() { + // Same generation identity as the broadcast path / registry-token path. + if !wallet.is_same_generation(&transaction.wallet) { runtime().block_on( transaction .wallet @@ -98,7 +102,7 @@ pub unsafe extern "C" fn core_wallet_abandon_signed_transaction_v2( ); return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, - "transaction was finalized by a different wallet".to_string(), + "transaction was finalized by a different wallet generation".to_string(), ); } runtime().block_on( @@ -236,14 +240,27 @@ mod outcome_tests { ); } + /// An error raised BEFORE the transaction reached the network must not + /// report a txid — the caller would otherwise track a transaction that was + /// never broadcast. The code itself is whatever the blanket `From` impl + /// maps the variant to; `TransactionBuild` gained a dedicated + /// `ErrorTransactionBuild` code (dashpay/platform#4247 review) instead of + /// flattening to `ErrorUnknown`, and the txid contract is unchanged by + /// that. #[test] fn operational_error_does_not_carry_a_txid() { let outcome = classify_broadcast_result( Err(PlatformWalletError::TransactionBuild("invalid".to_string())), txid(4), ); - assert_eq!(outcome.0, None); - assert_eq!(outcome.1.code, PlatformWalletFFIResultCode::ErrorUnknown); + assert_eq!( + outcome.0, None, + "a pre-broadcast failure must report no txid" + ); + assert_eq!( + outcome.1.code, + PlatformWalletFFIResultCode::ErrorTransactionBuild + ); } } diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs index 8e12ebc178..2cf5177660 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,14 @@ mod addresses; mod broadcast; +mod send; +pub(crate) mod signed_payment; mod transaction_builder; mod wallet; pub use addresses::*; pub use broadcast::*; +pub use send::*; +pub use signed_payment::*; pub use transaction_builder::*; pub use wallet::*; diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs new file mode 100644 index 0000000000..2d25f59cb5 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs @@ -0,0 +1,549 @@ +//! FFI binding for the single-account "build a signed payment" primitive. +//! +//! Like the step-by-step `core_wallet_tx_builder_*` builder, this funds from a +//! single caller-chosen account — but as a one-shot call that also signs, and +//! it names the account by BIP32 derivation path (so a DIP-9 CoinJoin or +//! DashPay-receiving account can be selected, not just BIP44/BIP32). It returns +//! the **signed serialized transaction bytes** plus the computed fee and change +//! amount. It does NOT broadcast and does NOT persist a debit — the caller +//! commits/broadcasts the returned bytes itself (dashj during the Android +//! transition; a later SDK-broadcast mode afterwards). +//! +//! Coin selection never unions funding accounts: `funding_path` names exactly +//! one, defaulting to the unmixed BIP44 account. See +//! `platform_wallet::wallet::funding_privacy` for the invariant and +//! `platform_wallet::wallet::core::send` for the semantics. + +use crate::error::*; +use crate::handle::{Handle, CORE_WALLET_STORAGE}; +use crate::runtime::runtime; +use crate::utils::parse_optional_derivation_path; +use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; +use dashcore::Address as DashAddress; +use platform_wallet::PlatformWalletError; +use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle}; +use std::ffi::CString; +use std::os::raw::c_char; +use std::str::FromStr; + +/// Smallest number of bytes one encoded output row can occupy: `u32 addr_len` +/// (4) + at least one address byte + `u64 amount` (8). Used to reject an +/// impossible `count` before any allocation. +const MIN_ENCODED_OUTPUT_LEN: usize = 4 + 1 + 8; + +/// Decode the recipients blob the caller passes to +/// [`core_wallet_build_signed_payment`]. Layout (big-endian): +/// +/// ```text +/// u32 count +/// count × ( u32 address_len, address_len bytes (UTF-8), u64 amount_duffs ) +/// ``` +/// +/// Each address is parsed and checked against `network`; a malformed blob or a +/// wrong-network / unparseable address is a decode error. +fn decode_payment_outputs( + blob: &[u8], + network: dashcore::Network, +) -> Result, PlatformWalletError> { + let err = |m: String| PlatformWalletError::TransactionBuild(m); + let mut cursor = 0usize; + // Checked cursor arithmetic throughout: `cursor + n` on a 32-bit target + // (Android armeabi-v7a) can overflow and panic inside this `extern "C"` + // frame, where the JNI guard cannot safely recover it. + let read_u32 = |buf: &[u8], at: &mut usize| -> Result { + let end = at + .checked_add(4) + .filter(|e| *e <= buf.len()) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild("truncated recipients blob (u32)".to_string()) + })?; + let v = u32::from_be_bytes([buf[*at], buf[*at + 1], buf[*at + 2], buf[*at + 3]]); + *at = end; + Ok(v) + }; + let read_u64 = |buf: &[u8], at: &mut usize| -> Result { + let end = at + .checked_add(8) + .filter(|e| *e <= buf.len()) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild("truncated recipients blob (u64)".to_string()) + })?; + let mut b = [0u8; 8]; + b.copy_from_slice(&buf[*at..end]); + *at = end; + Ok(u64::from_be_bytes(b)) + }; + + // Bound `count` by what the blob could actually contain BEFORE reserving. + // `count` is a caller-controlled `u32`: passing `u32::MAX` in a four-byte + // blob would otherwise ask `Vec::with_capacity` for ~64 GiB and take the + // process-aborting allocation-failure path instead of returning this + // decode error. + let count = read_u32(blob, &mut cursor)? as usize; + let max_possible = blob.len().saturating_sub(cursor) / MIN_ENCODED_OUTPUT_LEN; + if count > max_possible { + return Err(err(format!( + "recipients blob declares {count} outputs but holds at most {max_possible}" + ))); + } + let mut outputs = Vec::new(); + outputs + .try_reserve_exact(count) + .map_err(|e| err(format!("cannot allocate {count} recipient outputs: {e}")))?; + for _ in 0..count { + let addr_len = read_u32(blob, &mut cursor)? as usize; + let end = cursor + .checked_add(addr_len) + .filter(|e| *e <= blob.len()) + .ok_or_else(|| err("truncated recipients blob (address)".to_string()))?; + let addr_str = std::str::from_utf8(&blob[cursor..end]) + .map_err(|e| err(format!("recipient address is not valid UTF-8: {e}")))?; + cursor = end; + let amount = read_u64(blob, &mut cursor)?; + + let parsed = DashAddress::from_str(addr_str) + .map_err(|e| err(format!("invalid recipient address {addr_str:?}: {e}")))?; + let address = parsed.require_network(network).map_err(|e| { + err(format!( + "recipient address {addr_str:?} network mismatch: {e}" + )) + })?; + outputs.push((address, amount)); + } + Ok(outputs) +} + +/// Build and sign a standard L1 payment from ONE of the wallet's signable funds +/// accounts and return the signed bytes + fee + change. +/// +/// * `handle` — a core-wallet handle (`platform_wallet_get_core`). +/// * `outputs_blob`/`outputs_blob_len` — the recipients, encoded as documented +/// on [`decode_payment_outputs`]. +/// * `fee_per_kb` — fee rate in duffs/kB, or `0` for the default (1000). +/// * `core_signer_handle` — the caller's `MnemonicResolverHandle`; ownership is +/// retained by the caller (this function does NOT destroy it). +/// * `funding_path_ptr`/`funding_path_len` — an optional UTF-8 BIP32 +/// derivation-path string (e.g. `"m/44'/5'/0'"`) naming the SINGLE funds +/// account whose UTXOs fund the payment (dashpay/platform#4184). Pass +/// `null` / `0` for the default — the unmixed BIP44 account. Pass an explicit +/// account-level path (e.g. the DIP-9 CoinJoin account path) to spend +/// previously-mixed coins deliberately. There is no union across accounts and +/// no consent gate: exactly one funding source participates, and if it cannot +/// cover the payment the call fails with the typed insufficient-funds code. +/// * `out_tx_bytes`/`out_tx_len` — receive the consensus-serialized signed +/// transaction. Free with [`core_wallet_free_payment_bytes`]. +/// * `out_fee` — receives the fee paid, in duffs. +/// * `out_change` — receives the change returned to the wallet, in duffs (0 if +/// the build produced no change output). +/// +/// # Safety +/// All pointers must be valid; `outputs_blob` must be readable for +/// `outputs_blob_len` bytes; `funding_path_ptr`, when non-null, must point to +/// `funding_path_len` readable bytes for the duration of the call; the +/// out-pointers must be writable. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn core_wallet_build_signed_payment( + handle: Handle, + outputs_blob: *const u8, + outputs_blob_len: usize, + fee_per_kb: u64, + core_signer_handle: *mut MnemonicResolverHandle, + funding_path_ptr: *const u8, + funding_path_len: usize, + out_tx_bytes: *mut *mut u8, + out_tx_len: *mut usize, + out_fee: *mut u64, + out_change: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(outputs_blob); + check_ptr!(core_signer_handle); + check_ptr!(out_tx_bytes); + check_ptr!(out_tx_len); + check_ptr!(out_fee); + check_ptr!(out_change); + + let funding_path = match parse_optional_derivation_path(funding_path_ptr, funding_path_len) { + Ok(p) => p, + Err(result) => return result, + }; + + let blob = std::slice::from_raw_parts(outputs_blob, outputs_blob_len); + let signer_addr = core_signer_handle as usize; + let fee = if fee_per_kb == 0 { + None + } else { + Some(fee_per_kb) + }; + + let option = CORE_WALLET_STORAGE.with_item(handle, |wallet| { + let network = wallet.network(); + let outputs = decode_payment_outputs(blob, network)?; + let funding_path = funding_path.clone(); + let wallet_id = wallet.wallet_id(); + // SAFETY: `signer_addr` came from `core_signer_handle`, which the caller + // pinned alive for this call; the `MnemonicResolverCoreSigner` lives + // only on this stack frame and is dropped before returning. + let signer = MnemonicResolverCoreSigner::new( + signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ); + runtime().block_on(wallet.build_signed_payment(outputs, fee, &signer, funding_path)) + }); + + let result = unwrap_option_or_return!(option); + let payment = unwrap_result_or_return!(result); + + let serialized = dashcore::consensus::serialize(&payment.transaction); + let len = serialized.len(); + *out_tx_bytes = Box::into_raw(serialized.into_boxed_slice()) as *mut u8; + *out_tx_len = len; + *out_fee = payment.fee; + *out_change = payment.change_amount; + + PlatformWalletFFIResult::ok() +} + +/// Build and sign a standard L1 payment from ONE of the wallet's signable funds +/// accounts — named by derivation path, so a **DashPay receiving-funds** account +/// is reachable — and REGISTER it for deferred submission, returning a +/// reservation token alongside the signed bytes. +/// +/// This is [`core_wallet_build_signed_payment`] joined to the deferred +/// broadcast/release lifecycle. The two were disconnected: that function selects +/// by derivation path (the only selector that reaches a receival account) but +/// hands back raw bytes with no token and no broadcast, while +/// [`core_wallet_signed_payment_finalize`](super::transaction_builder::core_wallet_signed_payment_finalize) +/// mints a token but selects only BIP44 / BIP32 / CoinJoin. Coin selection, +/// change routing, and the funding-domain invariant are unchanged — this shares +/// the exact selector, it does not add a second one. +/// +/// On success the funding UTXOs are RESERVED and owned by `out_token`: broadcast +/// it with +/// [`core_wallet_signed_payment_broadcast`](super::signed_payment::core_wallet_signed_payment_broadcast) +/// or release it with +/// [`core_wallet_signed_payment_release`](super::signed_payment::core_wallet_signed_payment_release). +/// Dropping the token strands the reservation until key-wallet's TTL. +/// +/// Parameters match [`core_wallet_build_signed_payment`], plus: +/// * `out_token` — the reservation token for the later broadcast/release. +/// * `out_txid` — a heap C string (lowercase hex) freed with +/// `core_wallet_free_address`. +/// +/// # Safety +/// As [`core_wallet_build_signed_payment`]; additionally every new out-pointer +/// must be writable. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn core_wallet_build_signed_payment_with_token( + handle: Handle, + outputs_blob: *const u8, + outputs_blob_len: usize, + fee_per_kb: u64, + core_signer_handle: *mut MnemonicResolverHandle, + funding_path_ptr: *const u8, + funding_path_len: usize, + out_token: *mut u64, + out_txid: *mut *mut c_char, + out_tx_bytes: *mut *mut u8, + out_tx_len: *mut usize, + out_fee: *mut u64, + out_change: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(outputs_blob); + check_ptr!(core_signer_handle); + check_ptr!(out_token); + check_ptr!(out_txid); + check_ptr!(out_tx_bytes); + check_ptr!(out_tx_len); + check_ptr!(out_fee); + check_ptr!(out_change); + *out_token = 0; + + let funding_path = match parse_optional_derivation_path(funding_path_ptr, funding_path_len) { + Ok(p) => p, + Err(result) => return result, + }; + + let blob = std::slice::from_raw_parts(outputs_blob, outputs_blob_len); + let fee = if fee_per_kb == 0 { + None + } else { + Some(fee_per_kb) + }; + + // Clone the wallet out of storage rather than working inside `with_item`: + // registering the payment needs an owned `CoreWallet` (the registry captures + // the exact generation whose ReservationSet holds the inputs), exactly as + // `core_wallet_signed_payment_broadcast` does. + let core = unwrap_option_or_return!(CORE_WALLET_STORAGE.with_item(handle, |w| w.clone())); + let network = core.network(); + let outputs = unwrap_result_or_return!(decode_payment_outputs(blob, network)); + + // SAFETY: the caller pinned `core_signer_handle` alive for this call; the + // signer lives only on this stack frame and is dropped before returning. + let signer = MnemonicResolverCoreSigner::new(core_signer_handle, core.wallet_id(), network); + + let payment = unwrap_result_or_return!(runtime().block_on( + core.finalize_signed_payment_from_funding_path(outputs, fee, &signer, funding_path) + )); + + // From here the inputs are RESERVED and nothing owns that reservation yet. + // Do the one fallible marshalling step BEFORE registering, and release on + // failure — after `register` there is a token to release with, but before it + // a failure would strand the reservation until the TTL backstop. Mirrors + // `core_wallet_signed_payment_finalize`. A txid hex never contains a NUL, but + // the impossible case is still handled rather than leaked. + let c_txid = match CString::new(payment.transaction.txid().to_string()) { + Ok(s) => s, + Err(_) => { + runtime().block_on(core.abandon_payment(payment)); + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorUtf8Conversion, + "txid string contained an interior NUL".to_string(), + ); + } + }; + + let serialized = dashcore::consensus::serialize(&payment.transaction); + let len = serialized.len(); + + // Register the reserved+signed payment. `register_funded_by` — not + // `register` — because the funding account is named by derivation path: a + // DashPay receiving-funds account has no `AccountTypePreference`, and + // registering it as BIP44 would make a later release free BIP44's inputs + // instead of the receival account's. + let token = runtime().block_on( + crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY.register_funded_by( + core.clone(), + payment.transaction.clone(), + payment.funding.clone(), + Some(payment.reservation_height), + payment.reservation_token, + ), + ); + + *out_tx_bytes = Box::into_raw(serialized.into_boxed_slice()) as *mut u8; + *out_tx_len = len; + *out_token = token; + *out_fee = payment.fee; + *out_change = payment.change_amount; + *out_txid = c_txid.into_raw(); + + PlatformWalletFFIResult::ok() +} + +/// Free the signed-payment bytes returned by [`core_wallet_build_signed_payment`]. +/// +/// # Safety +/// `bytes`/`len` must be the exact pair written to `out_tx_bytes`/`out_tx_len` +/// by [`core_wallet_build_signed_payment`] (or null / 0). +#[no_mangle] +pub unsafe extern "C" fn core_wallet_free_payment_bytes(bytes: *mut u8, len: usize) { + if !bytes.is_null() && len > 0 { + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(bytes, len)); + } +} + +/// Decoder hardening tests. +/// +/// `decode_payment_outputs` parses a caller-controlled blob inside an +/// `extern "C"` frame, where a panic or an allocation abort cannot be recovered +/// by the JNI guard. Every bound below was a blocking review finding on +/// dashpay/platform#4247 and shipped without coverage; these pin them so a +/// later cleanup cannot quietly reintroduce `Vec::with_capacity(count)` or +/// unchecked cursor arithmetic. +#[cfg(test)] +mod tests { + use super::*; + use dashcore::Network; + + /// Encode one recipient row in the wire layout `decode_payment_outputs` + /// documents: `u32 addr_len`, the UTF-8 address, `u64 amount`. + fn row(address: &str, amount: u64) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(&(address.len() as u32).to_be_bytes()); + v.extend_from_slice(address.as_bytes()); + v.extend_from_slice(&amount.to_be_bytes()); + v + } + + fn blob(rows: &[(&str, u64)]) -> Vec { + let mut v = (rows.len() as u32).to_be_bytes().to_vec(); + for (a, amt) in rows { + v.extend_from_slice(&row(a, *amt)); + } + v + } + + fn testnet_address(id: usize) -> String { + DashAddress::dummy(Network::Testnet, id).to_string() + } + + #[test] + fn decodes_a_well_formed_blob() { + let (a, b) = (testnet_address(1), testnet_address(2)); + let decoded = decode_payment_outputs( + &blob(&[(a.as_str(), 1_000_000), (b.as_str(), 546)]), + Network::Testnet, + ) + .expect("a well-formed blob decodes"); + + assert_eq!(decoded.len(), 2); + assert_eq!(decoded[0].0.to_string(), a); + assert_eq!(decoded[0].1, 1_000_000); + assert_eq!(decoded[1].0.to_string(), b); + assert_eq!(decoded[1].1, 546); + } + + #[test] + fn zero_outputs_decode_to_an_empty_vec() { + let decoded = decode_payment_outputs(&0u32.to_be_bytes(), Network::Testnet) + .expect("an empty list is a decode success"); + assert!( + decoded.is_empty(), + "emptiness is rejected upstream, not here" + ); + } + + /// THE allocation blocker: a four-byte blob declaring `u32::MAX` outputs + /// must produce a decode error, never a ~64 GiB `Vec::with_capacity` that + /// takes Rust's process-aborting allocation-failure path inside + /// `extern "C"`. + #[test] + fn an_impossible_count_is_rejected_without_allocating() { + for count in [u32::MAX, u32::MAX / 2, 1_000_000, 1] { + let err = decode_payment_outputs(&count.to_be_bytes(), Network::Testnet) + .expect_err("a header-only blob cannot hold any output"); + let PlatformWalletError::TransactionBuild(m) = err else { + panic!("expected a decode error for count {count}"); + }; + assert!( + m.contains("holds at most 0"), + "count {count} must be bounded by the blob length, got {m:?}" + ); + } + } + + /// The bound is computed from the remaining bytes, so a count that merely + /// overstates a non-empty blob is refused too. + #[test] + fn a_count_exceeding_the_rows_present_is_rejected() { + let a = testnet_address(1); + let mut b = blob(&[(a.as_str(), 1_000)]); + b[0..4].copy_from_slice(&9u32.to_be_bytes()); + + let err = decode_payment_outputs(&b, Network::Testnet).expect_err("9 rows are not present"); + assert!( + matches!(err, PlatformWalletError::TransactionBuild(ref m) if m.contains("declares 9")), + "got {err:?}" + ); + } + + /// Checked cursor arithmetic: a truncated blob is a clean error at every + /// field boundary, never an out-of-bounds slice or a `cursor + len` + /// overflow panic (reachable on 32-bit Android targets). + #[test] + fn truncation_at_any_boundary_is_a_clean_error() { + let a = testnet_address(1); + let full = blob(&[(a.as_str(), 1_000)]); + + for cut in 1..full.len() { + let err = decode_payment_outputs(&full[..cut], Network::Testnet) + .expect_err("a truncated blob must not decode"); + assert!( + matches!(err, PlatformWalletError::TransactionBuild(_)), + "truncation at {cut} must be a decode error, got {err:?}" + ); + } + + // The full blob still decodes, so the loop above proved truncation is + // the cause rather than the fixture being malformed. + assert!(decode_payment_outputs(&full, Network::Testnet).is_ok()); + } + + /// A declared address length far beyond the blob must not panic on + /// `cursor + addr_len`. + #[test] + fn an_absurd_address_length_is_rejected() { + let mut b = 1u32.to_be_bytes().to_vec(); + b.extend_from_slice(&u32::MAX.to_be_bytes()); + b.extend_from_slice(&[0u8; 16]); + + let err = decode_payment_outputs(&b, Network::Testnet) + .expect_err("an address longer than the blob must not decode"); + assert!( + matches!(err, PlatformWalletError::TransactionBuild(_)), + "got {err:?}" + ); + } + + #[test] + fn a_non_utf8_address_is_rejected() { + let mut b = 1u32.to_be_bytes().to_vec(); + b.extend_from_slice(&4u32.to_be_bytes()); + b.extend_from_slice(&[0xff, 0xfe, 0xfd, 0xfc]); + b.extend_from_slice(&1_000u64.to_be_bytes()); + + let err = decode_payment_outputs(&b, Network::Testnet).expect_err("invalid UTF-8"); + assert!( + matches!(err, PlatformWalletError::TransactionBuild(ref m) if m.contains("UTF-8")), + "got {err:?}" + ); + } + + #[test] + fn an_unparseable_address_is_rejected() { + let err = decode_payment_outputs(&blob(&[("not-an-address", 1_000)]), Network::Testnet) + .expect_err("garbage is not an address"); + assert!( + matches!(err, PlatformWalletError::TransactionBuild(ref m) if m.contains("invalid recipient address")), + "got {err:?}" + ); + } + + /// Network confusion is a funds-loss shape: a mainnet address accepted on a + /// testnet wallet (or the reverse) sends real coins to an address the user + /// did not intend. + #[test] + fn a_wrong_network_address_is_rejected() { + let mainnet = DashAddress::dummy(Network::Mainnet, 1).to_string(); + let err = decode_payment_outputs(&blob(&[(mainnet.as_str(), 1_000)]), Network::Testnet) + .expect_err("a mainnet address must not decode for a testnet wallet"); + assert!( + matches!(err, PlatformWalletError::TransactionBuild(ref m) if m.contains("network mismatch")), + "got {err:?}" + ); + + // …and it decodes fine against its own network, proving the rejection + // is the network check rather than the address being malformed. + assert!( + decode_payment_outputs(&blob(&[(mainnet.as_str(), 1_000)]), Network::Mainnet).is_ok() + ); + } + + /// `core_wallet_free_payment_bytes` tolerates the null/zero pair its own + /// documented contract permits. + #[test] + fn freeing_null_payment_bytes_is_a_no_op() { + unsafe { + core_wallet_free_payment_bytes(std::ptr::null_mut(), 0); + core_wallet_free_payment_bytes(std::ptr::null_mut(), 32); + } + } + + /// Round-trip through the real allocation path: what + /// `core_wallet_build_signed_payment` hands out is what + /// `core_wallet_free_payment_bytes` takes back. + #[test] + fn payment_bytes_round_trip_through_the_free_function() { + let payload = vec![7u8; 128]; + let len = payload.len(); + let ptr = Box::into_raw(payload.into_boxed_slice()) as *mut u8; + unsafe { + assert_eq!(std::slice::from_raw_parts(ptr, len), [7u8; 128]); + core_wallet_free_payment_bytes(ptr, len); + } + } +} diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs new file mode 100644 index 0000000000..cbfbc530c1 --- /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` +/// (35) rather than a second send. `core_handle` must resolve to the same wallet +/// *generation* the token was minted against; a wallet re-created under the same +/// id yields `ErrorReservationWalletMismatch` (36). A token whose reservation +/// may already have aged out of key-wallet's TTL yields +/// `ErrorStaleReservationToken` (34). These three deferred-token failures are +/// distinct codes so a host can message each precisely. Writes `out_txid` (a +/// heap C string freed with `core_wallet_free_address`) on success. +/// +/// # 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..1b586a4e23 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,148 @@ 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()), + // The key-wallet reservation token finalize stamped onto the funding + // inputs, so a later broadcast-reject or release frees only inputs + // this build still owns (owner-guarded; `dashpay/platform#4185`). + finalized.reservation_token(), + ), + ); + + *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 44532de863..3c1448effe 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -172,6 +172,116 @@ pub enum PlatformWalletFFIResultCode { /// rejected the transaction, so its UTXO reservation was released and the /// host may safely retry after addressing the rejection reason. ErrorTransactionBroadcastRejected = 26, + /// Maps `PlatformWalletError::TransactionBuild`. A Core transaction could + /// not be assembled from the request — the request itself is at fault, and + /// the host must change it rather than retry it verbatim. It is the code + /// every `build_signed_payment` rejection lands on EXCEPT a signing + /// failure, which is [`Self::ErrorTransactionSigning`] (33): + /// + /// * the named `funding_path` matches no spendable funds account, or names + /// a watch-only one whose coins the local mnemonic cannot sign — the two + /// failure modes the single-account design rests on + /// (dashpay/platform#4184); + /// * the request violates a monetary bound (`MAX_MONEY` output total, + /// `MAX_FEE_PER_KB` fee rate, a below-dust recipient output, or an + /// over-`MAX_STANDARD_TX_SIZE` recipient list); + /// * the recipients blob failed to decode. + /// + /// These previously flattened to `ErrorUnknown` (99), reaching Kotlin as + /// `DashSdkError.PlatformWallet.Generic` and leaving the host to + /// string-match the message to tell a bad funding path from a bad amount + /// (dashpay/platform#4247 review). The specific cause still travels in the + /// result `message` via the typed `Display`. + /// + /// Numbering: 27 is `ErrorShutdownIncomplete`, MERGED into the v4.2-dev ABI + /// by dashpay/platform#4268; 29 is `ErrorAssetLockInsufficientFunds` (#4184) + /// and 31 is `ErrorSigningKeyUnavailable` (#4183/#4259). 32 was chosen as + /// the first slot free on every branch of the sibling v4.1 stack and is + /// unaffected by #4268 — it needs no renumbering whatever order they land + /// in. See `packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md`. + ErrorTransactionBuild = 32, + + /// Maps `PlatformWalletError::TransactionSigning`. The request was valid + /// and the transaction was fully assembled — only the input signatures + /// could not be produced. The host should repair the signer (unlock the + /// Keychain/Keystore, restore the mnemonic, fix the resolver callback) and + /// may then resubmit the IDENTICAL request: key-wallet + /// `release_if_owner`-releases this build's owner-stamped input + /// reservation before returning, so the coins are selectable again. + /// + /// Split out of [`Self::ErrorTransactionBuild`] (32), whose contract is the + /// opposite — "the request is at fault, a verbatim retry fails + /// identically". That was false for the production + /// `MnemonicResolverCoreSigner`, where a locked or missing Keychain + /// mnemonic surfaces as `BuilderError::SigningFailed` + /// (dashpay/platform#4256 review). + /// + /// Numbering: 27–32 are all claimed — 27 `ErrorShutdownIncomplete` (MERGED, + /// dashpay/platform#4268), 29 `ErrorAssetLockInsufficientFunds` (#4184), 31 + /// `ErrorSigningKeyUnavailable` (#4183/#4259), 32 `ErrorTransactionBuild` + /// (#4247) — so this takes 33, the first slot free on every branch of the + /// sibling v4.1 stack. The deferred-token trio below vacated 27/28/30 for + /// the contiguous block 34–36 once #4268 merged 27; 28 and 30 are free but + /// deliberately left unclaimed rather than back-filled. + /// + /// 30 was previously described here as reserved for #4184's + /// `ErrorAssetLockCrossDomainConsentRequired`. That code does not exist on + /// any branch — #4184 dropped it in a re-scope. See + /// `packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md`. + /// + /// The review suggested reusing 31. That code is genuinely reserved, but + /// for a *different* contract: #4183's `ErrorSigningKeyUnavailable` is a + /// **state-transition** failure asserting that the signer holds no usable + /// private key for a requested public key, restored from the typed + /// `DashSDKSignerErrorCode::SigningKeyUnavailable` completion code. This + /// code is a **Core L1 input** signing failure with no such provenance: + /// `BuilderError::SigningFailed` also covers an unresolved input + /// derivation path, a sighash computation failure, and a malformed + /// signature encoding, so mapping it onto 31 would assert "the key is + /// unavailable" for failures that are nothing of the kind. Kept separate + /// so neither contract has to be weakened; maintainers reconciling the + /// 27–36 range may still choose to collapse the two. + ErrorTransactionSigning = 33, + + // The deferred-token trio below moved from 27/28/30 to the contiguous block + // 34-36 after dashpay/platform#4268 merged `ErrorShutdownIncomplete = 27` + // into the v4.2-dev ABI. 34-36 sits above every code claimed by a merged + // commit or an open PR, so it ends the renumbering churn; 28 and 30 are + // vacated and return to the free pool. Owners of 27-33 are documented on + // `ErrorTransactionBuild` / `ErrorTransactionSigning` above and in + // `packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md` + // (dashpay/platform#4261). + /// Maps `SignedPaymentError::StaleReservationToken` from the deferred + /// build → broadcast/release core-send lifecycle (`core_wallet_signed_payment_*`): + /// the token has outlived the registry's `RESERVATION_MAX_AGE_BLOCKS` bound + /// and its funding reservation may already have been swept and re-selected by + /// key-wallet's TTL, so acting on it could touch a newer, unrelated + /// reservation. The operation did NOT touch the network. NOT retryable in + /// place — the host must rebuild the payment. + /// + /// Sibling codes split out the other two deferred-token failures that this + /// code used to conflate: [`Self::ErrorReservationTokenConsumed`] (35, + /// unknown / already broadcast / already released) and + /// [`Self::ErrorReservationWalletMismatch`] (36, minted against a different + /// wallet generation). All three are non-retryable-in-place and none touched + /// the network; they are distinct codes so a host can message each precisely. + ErrorStaleReservationToken = 34, + + /// Maps `SignedPaymentError::StaleToken`. The deferred reservation token is + /// unknown, already broadcast, or already released — the guard that turns a + /// double-broadcast (or a broadcast after release) into a typed error + /// instead of a second send. Did NOT touch the network; NOT retryable + /// (rebuild the payment). Release is idempotent and never surfaces this. + ErrorReservationTokenConsumed = 35, + + /// Maps `SignedPaymentError::WalletMismatch`. The deferred reservation token + /// was minted against a different wallet *generation* than the one it is + /// being broadcast through (e.g. a wallet re-created under the same id); its + /// reservation lives in that other generation's `ReservationSet`. Did NOT + /// touch the network and did NOT consume the rightful owner's token; NOT + /// retryable through this handle (rebuild the payment). + /// + ErrorReservationWalletMismatch = 36, NotFound = 98, // Used exclusively for all the Option that are retuned as errors ErrorUnknown = 99, @@ -324,9 +434,39 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::AddressNonceMismatch { .. } => { PlatformWalletFFIResultCode::ErrorAddressNonceMismatch } - PlatformWalletError::CoreInsufficientFunds { .. } => { + // Both Core-send selector shortfalls share code 22: the atomic + // builder's (`CoreInsufficientFunds`) and the one-shot signed-payment + // primitive's (`PaymentInsufficientFunds`). Without this second arm + // the payment shortfall flattened to `ErrorUnknown`, so the typed + // `available`/`required` amounts `build_signed_payment` computes + // never reached the host as an actionable code — and after the + // dashpay/platform#4184 re-scope those amounts are SINGLE-ACCOUNT + // figures the host must be able to act on (the signal is "pick a + // different funding account", not "retry the same one"). + PlatformWalletError::CoreInsufficientFunds { .. } + | PlatformWalletError::PaymentInsufficientFunds { .. } => { PlatformWalletFFIResultCode::ErrorCoreInsufficientFunds } + // A valid request whose signatures could not be produced. Must NOT + // share `ErrorTransactionBuild`'s "retrying cannot help" contract: + // the reservation was released and unlocking the signer makes the + // identical request succeed (dashpay/platform#4256 review). + PlatformWalletError::TransactionSigning(..) => { + PlatformWalletFFIResultCode::ErrorTransactionSigning + } + // Every `build_signed_payment` rejection that is neither a shortfall + // nor a signing failure arrives here: an unmatched or watch-only + // `funding_path`, a monetary-bound violation (MAX_MONEY / + // MAX_FEE_PER_KB / dust / MAX_STANDARD_TX_SIZE), or a + // recipients-blob decode failure. + // Without this arm they all flattened to `ErrorUnknown` (99), so + // the two failure modes the single-account design rests on — + // "that path names no spendable account" and "that path is + // watch-only" — were distinguishable only by string-matching the + // message (dashpay/platform#4247 review). + PlatformWalletError::TransactionBuild(..) => { + PlatformWalletFFIResultCode::ErrorTransactionBuild + } PlatformWalletError::AssetLockNotTracked(..) => { PlatformWalletFFIResultCode::ErrorAssetLockNotTracked } @@ -636,6 +776,120 @@ mod tests { } } + /// The one-shot payment primitive's shortfall shares code 22 with the + /// atomic builder's. Pinned separately from + /// `atomic_core_insufficient_funds_maps_to_dedicated_code`, which only ever + /// constructs `CoreInsufficientFunds`: if a cleanup dropped + /// `PaymentInsufficientFunds` from that arm it would silently fall through + /// to `ErrorUnknown` and no existing test would notice. + #[test] + fn payment_insufficient_funds_shares_the_core_shortfall_code() { + let result: PlatformWalletFFIResult = PlatformWalletError::PaymentInsufficientFunds { + available: 9_000_000, + required: 15_000_000, + } + .into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorCoreInsufficientFunds + ); + let msg = unsafe { std::ffi::CStr::from_ptr(result.message) }.to_string_lossy(); + assert!( + msg.contains("9000000") && msg.contains("15000000"), + "the single-account available/required duffs must survive in the \ + message: {msg}" + ); + } + + /// Every `build_signed_payment` rejection that is not a shortfall is a + /// `TransactionBuild`, and must reach the host as its own code rather than + /// `ErrorUnknown` (99) — otherwise "that funding path names no spendable + /// account" and "that funding path is watch-only", the two failure modes + /// the single-account design rests on, are distinguishable only by + /// string-matching (dashpay/platform#4247 review). + #[test] + fn transaction_build_failures_map_to_a_dedicated_code() { + for message in [ + "no spendable funds account matches funding derivation path m/44'/5'/7'", + "funding derivation path m/9'/5'/4'/0' names a watch-only account", + "output amounts overflow or exceed MAX_MONEY", + "fee rate 99999999999 duffs/kB exceeds the maximum", + "recipients blob declares 4294967295 outputs but holds at most 0", + ] { + let result: PlatformWalletFFIResult = + PlatformWalletError::TransactionBuild(message.to_string()).into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorTransactionBuild, + "{message:?} must not flatten to ErrorUnknown" + ); + let rendered = unsafe { std::ffi::CStr::from_ptr(result.message) }.to_string_lossy(); + assert!( + rendered.contains(message), + "the specific cause must survive in the message: {rendered}" + ); + } + } + + /// The new code must not silently collide with a sibling v4.1 stack PR's + /// (27–31 are claimed; see the variant's doc comment). + #[test] + fn transaction_build_code_is_thirty_two() { + assert_eq!( + PlatformWalletFFIResultCode::ErrorTransactionBuild as i32, + 32 + ); + } + + /// A signing failure must NOT arrive as `ErrorTransactionBuild`, whose + /// contract promises the request itself is invalid and a verbatim retry + /// cannot succeed. A locked or missing Keychain mnemonic is the common + /// cause, and key-wallet releases the input reservation on that path, so + /// the identical request succeeds after an unlock (dashpay/platform#4256 + /// review). + #[test] + fn signing_failures_do_not_share_the_request_invalid_code() { + for message in [ + "payment signing failed: mnemonic unavailable: keychain is locked", + "payment signing failed: resolver callback returned 0", + "payment signing failed: no derivation path for input address \ + yWrbmMHFj9xTUJTS7Nb1Y2WJmwLbCzTLDX", + ] { + let result: PlatformWalletFFIResult = + PlatformWalletError::TransactionSigning(message.to_string()).into(); + assert_ne!( + result.code, + PlatformWalletFFIResultCode::ErrorTransactionBuild, + "{message:?} is retryable after a signer repair and must not \ + claim the request-invalid contract" + ); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorTransactionSigning + ); + let rendered = unsafe { std::ffi::CStr::from_ptr(result.message) }.to_string_lossy(); + assert!( + rendered.contains(message), + "the specific cause must survive in the message: {rendered}" + ); + } + } + + /// 27–32 are claimed across the sibling v4.1 stack (see the variant doc), + /// so the signing code takes 33. Pinned so a rebase that renumbers the + /// range cannot silently move a code the hosts already switch on. + #[test] + fn transaction_signing_code_is_thirty_three() { + assert_eq!( + PlatformWalletFFIResultCode::ErrorTransactionSigning as i32, + 33 + ); + assert_ne!( + PlatformWalletFFIResultCode::ErrorTransactionSigning as i32, + PlatformWalletFFIResultCode::ErrorTransactionBuild as i32, + ); + } + #[test] fn asset_lock_recovery_failures_map_to_stable_codes() { use dashcore::OutPoint; 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/utils.rs b/packages/rs-platform-wallet-ffi/src/utils.rs index bf84c88170..9e8b0eae25 100644 --- a/packages/rs-platform-wallet-ffi/src/utils.rs +++ b/packages/rs-platform-wallet-ffi/src/utils.rs @@ -2,6 +2,44 @@ use crate::error::*; use crate::{check_ptr, unwrap_result_or_return}; use std::os::raw::{c_char, c_uchar}; +/// Decode an OPTIONAL BIP32 derivation-path string from a raw `(ptr, len)` pair +/// over the C ABI — the shared `funding_path` decoder for every entry point +/// that names a SINGLE funding account (dashpay/platform#4184). +/// +/// A null pointer or zero length is `None` (the default: fund from the unmixed +/// BIP44 account). Otherwise the bytes are parsed as a UTF-8 BIP32 path (e.g. +/// `"m/44'/5'/0'"`); invalid UTF-8 or a malformed path is a hard +/// `ErrorInvalidParameter` — never a silent fallback to the default account, +/// which would fund the transaction from coins the caller did not choose. +/// +/// # Safety +/// `ptr`, when non-null, must point to `len` readable bytes for the duration of +/// the call. +pub(crate) unsafe fn parse_optional_derivation_path( + ptr: *const u8, + len: usize, +) -> Result, PlatformWalletFFIResult> { + use std::str::FromStr; + if ptr.is_null() || len == 0 { + return Ok(None); + } + let bytes = std::slice::from_raw_parts(ptr, len); + let text = std::str::from_utf8(bytes).map_err(|e| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("funding_path is not valid UTF-8: {e}"), + ) + })?; + key_wallet::bip32::DerivationPath::from_str(text) + .map(Some) + .map_err(|e| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("invalid funding_path derivation path {text:?}: {e}"), + ) + }) +} + /// RAII guard that scrubs a `secp256k1::SecretKey`'s scalar on drop. `from_slice` /// allocates a 32-byte scalar copy of the caller's private key, and `SecretKey` /// has no `Drop` wipe of its own — so without this the copy would survive on the @@ -225,6 +263,60 @@ pub unsafe extern "C" fn platform_wallet_pubkey_hash_from_private_key( mod tests { use super::*; + /// `parse_optional_derivation_path` turns the caller's optional + /// `funding_path` bytes into the single-account selector the send primitive + /// funds from (dashpay/platform#4184). Null/empty means "the default + /// account", so the null case must stay distinguishable from a parse + /// failure — mapping a malformed path to `None` would silently fund from + /// BIP44 instead of the account the caller named. + #[test] + fn optional_derivation_path_treats_null_and_empty_as_default() { + for (ptr, len) in [ + (std::ptr::null::(), 0usize), + (std::ptr::null::(), 12usize), + (b"m/44'/5'/0'".as_ptr(), 0usize), + ] { + let parsed = unsafe { parse_optional_derivation_path(ptr, len) } + .expect("null/empty is not an error"); + assert!(parsed.is_none(), "null/empty must mean the default account"); + } + } + + #[test] + fn optional_derivation_path_parses_account_level_paths() { + use std::str::FromStr; + + for text in ["m/44'/5'/0'", "m/9'/5'/4'/0'", "m/44'/1'/7'"] { + let parsed = unsafe { parse_optional_derivation_path(text.as_ptr(), text.len()) } + .expect("a valid BIP32 path parses"); + assert_eq!( + parsed, + Some(key_wallet::bip32::DerivationPath::from_str(text).expect("valid")), + "{text} must round-trip verbatim" + ); + } + } + + /// A malformed path is an error, never a silent `None`. + #[test] + fn optional_derivation_path_rejects_garbage() { + for text in ["not a path", "m/44'/5'/zzz", "///"] { + let err = unsafe { parse_optional_derivation_path(text.as_ptr(), text.len()) } + .expect_err("garbage must not parse"); + assert_eq!(err.code, PlatformWalletFFIResultCode::ErrorInvalidParameter); + } + } + + #[test] + fn optional_derivation_path_rejects_non_utf8() { + let bytes = [0xffu8, 0xfe, 0xfd]; + let err = unsafe { parse_optional_derivation_path(bytes.as_ptr(), bytes.len()) } + .expect_err("invalid UTF-8 must not parse"); + assert_eq!(err.code, PlatformWalletFFIResultCode::ErrorInvalidParameter); + let msg = unsafe { std::ffi::CStr::from_ptr(err.message) }.to_string_lossy(); + assert!(msg.contains("UTF-8"), "got {msg}"); + } + #[test] fn test_hash160_matches_known_vector() { use dashcore::hashes::Hash; diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index 8ffd78a896..54450e6e62 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -390,6 +390,124 @@ 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, + // The dummy tx reserved nothing, so there is no funding token + // to owner-guard against — the destroy sweep drops the entry. + None, + ) + .await; + assert_eq!(SIGNED_PAYMENT_REGISTRY.outstanding(), baseline + 1); + (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..954ce2d54f 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -71,6 +71,26 @@ pub enum PlatformWalletError { #[error("Asset lock transaction failed: {0}")] AssetLockTransaction(String), + /// A general Core L1 payment build (`CoreWallet::build_signed_payment`) + /// could not cover the requested outputs plus fee from the **one** funds + /// account named by `funding_path` (defaulting to the unmixed BIP44 + /// account). `available` is that single selected account's spendable total + /// and `required` its outputs-plus-fee target — carried as exact duff + /// amounts (instead of being flattened into a string) so callers can render + /// a precise shortfall. + /// + /// Both figures are deliberately SINGLE-ACCOUNT, never a wallet-wide union: + /// reporting a cross-account total against a single-account shortfall would + /// invite a retry that can only succeed by linking privacy domains, which + /// this primitive will not do. The actionable signal is "fund from a + /// different account", not "retry the same one with a smaller amount" + /// (dashpay/platform#4073 → #4184; see `crate::wallet::funding_privacy`). + #[error( + "payment coin selection is short: available {available} duffs, \ + required {required} duffs" + )] + PaymentInsufficientFunds { available: u64, required: u64 }, + #[error("Transaction broadcast failed: {0}")] TransactionBroadcast(String), @@ -90,6 +110,22 @@ pub enum PlatformWalletError { )] TransactionBroadcastUnconfirmed(String), + /// The request was valid and the transaction was fully assembled — only + /// producing the input signatures failed. Kept OUT of + /// [`Self::TransactionBuild`] deliberately: that variant's contract is + /// "the request is at fault, a verbatim retry fails identically", which is + /// false here. The production `MnemonicResolverCoreSigner` raises + /// `BuilderError::SigningFailed` when the Keychain/Keystore mnemonic is + /// locked, missing, or the resolver callback fails, and key-wallet + /// `release_if_owner`-releases this build's owner-stamped input + /// reservation on that path — so the identical recipients, amount, fee and + /// funding path succeed once the signer is usable again + /// (dashpay/platform#4256 review). + /// + /// Retryable AFTER repairing the signer, never by resubmitting blindly. + #[error("Transaction signing failed: {0}")] + TransactionSigning(String), + #[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..40ea0feff7 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -57,7 +57,11 @@ pub use wallet::asset_lock::manager::AssetLockManager; pub use wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; pub use wallet::asset_lock::AssetLockFunding; pub use wallet::core::WalletBalance; -pub use wallet::core::{CoreWallet, SignedCoreTransaction}; +pub use wallet::core::{CoreWallet, FundingAccountRef, SignedCoreTransaction}; +pub use wallet::core::{FinalizedCorePayment, SignedCorePayment}; +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..58b4f4d306 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,385 @@ 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) +} + +/// Builds a testnet wallet manager whose balance is split across TWO privacy +/// domains: `bip44_duffs` on BIP44 account 0 and `coinjoin_duffs` on the DIP-9 +/// CoinJoin account 0. Lets the funding-domain tests prove that coin selection +/// never crosses from one account into the other. +/// +/// Returns the manager, the wallet id, and a soft signer over the wallet's seed +/// (which can derive keys for BOTH accounts, so per-account signing can be +/// exercised end-to-end). +#[cfg(test)] +pub(crate) async fn split_funded_wallet_manager( + bip44_duffs: u64, + coinjoin_duffs: u64, +) -> ( + Arc>>, + WalletId, + WalletSigner, +) { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait as _; + + let mut ctx = TestWalletContext::new_random(); + + // Fund BIP44 account 0 (the default funding account) at its pre-derived + // receive address. + let bip44_tx = Transaction::dummy(&ctx.receive_address, 0..1, &[bip44_duffs]); + let bip44_result = ctx + .check_transaction( + &bip44_tx, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 1, + BlockHash::all_zeros(), + 1_700_000_000, + )), + ) + .await; + assert!( + bip44_result.is_relevant && bip44_result.is_new_transaction, + "BIP44 funding tx should be recognized" + ); + + // Derive a fresh CoinJoin receive address (registering it in the CoinJoin + // pool so the checker recognizes the funding), then fund CoinJoin account 0. + let coinjoin_xpub = ctx + .wallet + .get_coinjoin_account(0) + .expect("default wallet has CoinJoin account 0") + .account_xpub; + // CoinJoin is a single-pool (non-standard) account, so it derives via + // `next_address` rather than `next_receive_address`. + let coinjoin_address = ctx + .managed_wallet + .first_coinjoin_managed_account_mut() + .expect("default wallet has a managed CoinJoin account 0") + .next_address(Some(&coinjoin_xpub), true) + .expect("CoinJoin receive address"); + let coinjoin_tx = Transaction::dummy(&coinjoin_address, 0..1, &[coinjoin_duffs]); + let coinjoin_result = ctx + .check_transaction( + &coinjoin_tx, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 2, + BlockHash::all_zeros(), + 1_700_000_100, + )), + ) + .await; + assert!( + coinjoin_result.is_relevant && coinjoin_result.is_new_transaction, + "CoinJoin funding tx should be recognized" + ); + + let signer = WalletSigner { + wallet: ctx.wallet.clone(), + }; + + let balance = Arc::new(WalletBalance::new()); + let info = PlatformWalletInfo { + core_wallet: ctx.managed_wallet, + balance, + identity_manager: IdentityManager::new(), + tracked_asset_locks: BTreeMap::new(), + }; + + let mut wm = WalletManager::::new(Network::Testnet); + let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); + + (Arc::new(RwLock::new(wm)), wallet_id, signer) +} + +/// Which DashPay funds account arm a [`split_funded_wallet_manager_dashpay`] +/// fixture provisions. The two arms differ only in which collection they land +/// in and the DIP-15 derivation order (user/friend vs friend/user), but both +/// are fund-bearing and both are covered by the vendored asset-lock router fix +/// (`get_relevant_account_types(AssetLock)` lists `DashpayReceivingFunds` AND +/// `DashpayExternalAccount` alongside `CoinJoin`). +/// +/// Kept verbatim from #4184 so the two copies reconcile cleanly when both +/// land: only the `ReceivingFunds` arm has a consumer in THIS crate's tests +/// (the finalize funding-path tests), while #4184's asset-lock tests drive +/// the watch-only `ExternalAccount` arm — hence the `dead_code` allowance +/// rather than trimming the variant. +#[cfg(test)] +#[allow(dead_code)] +#[derive(Clone, Copy, Debug)] +pub(crate) enum DashpayLeg { + /// Incoming DashPay funds account (`user_id/friend_id`). + ReceivingFunds, + /// DashPay external (watch-only-style) account (`friend_id/user_id`). + ExternalAccount, +} + +/// An account-level xpub whose private keys the wallet under test does NOT +/// hold — derived from a SEPARATE random wallet. Models a DashPay contact's +/// decrypted xpub, from which production builds the watch-only +/// `DashpayExternalAccount` (`is_watch_only: true`, +/// `wallet/identity/network/contacts.rs`). Any well-formed testnet account +/// xpub serves as a single-pool account key; using a FOREIGN one makes the +/// account unsignable by the local seed exactly as it is in production, so an +/// asset-lock builder that (wrongly) selected its UTXOs would sign them with +/// the local mnemonic's key and produce an invalid input signature. +#[cfg(test)] +fn foreign_contact_account_xpub() -> ExtendedPubKey { + let foreign = TestWalletContext::new_random(); + foreign + .wallet + .accounts + .standard_bip44_accounts + .get(&0) + .expect("foreign wallet has BIP44 account 0") + .account_xpub +} + +/// Builds a testnet wallet manager whose balance is SPLIT across BIP44 account +/// 0 (`bip44_duffs`) and a DashPay funds account (`dashpay_duffs`) — the +/// DashPay analogue of [`split_funded_wallet_manager`]'s BIP44 + CoinJoin split. +/// `leg` selects which DashPay account type carries the mixed slice. +/// +/// This exercises the DashPay legs of the vendored asset-lock router fix that +/// the CoinJoin fixture does not reach: `get_relevant_account_types(AssetLock)` +/// covers `CoinJoin`, `DashpayReceivingFunds`, AND `DashpayExternalAccount`, so +/// an asset lock funded from a DashPay UTXO must have that input debited by the +/// `check_core_transaction` scan (dashpay/platform#4073, dashpay/dash-wallet#1507). +/// +/// `WalletAccountCreationOptions::Default` does not create DashPay accounts, so +/// this provisions one — identity ids are arbitrary-but-distinct test vectors — +/// on BOTH the signing `Wallet` and the `ManagedWalletInfo`, derives a fresh +/// receive address from its single pool (registering it so the checker +/// recognizes the funding), and funds it, mirroring how +/// [`split_funded_wallet_manager`] funds the CoinJoin account. +/// +/// Signability of the DashPay input matches production per arm (see the inline +/// note in the body): the `ReceivingFunds` account is derived from our own +/// seed and is signable end-to-end; the `ExternalAccount` account is watch-only +/// (its xpub is a contact's, from a foreign seed), so the local signer CANNOT +/// sign its UTXOs — the asset-lock builder must exclude them. +#[cfg(test)] +pub(crate) async fn split_funded_wallet_manager_dashpay( + bip44_duffs: u64, + dashpay_duffs: u64, + leg: DashpayLeg, +) -> ( + Arc>>, + WalletId, + WalletSigner, +) { + use key_wallet::account::account_collection::DashpayAccountKey; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::managed_account_operations::ManagedAccountOperations; + use key_wallet::AccountType; + + let mut ctx = TestWalletContext::new_random(); + + // Fund BIP44 account 0 (the primary) at its pre-derived receive address. + let bip44_tx = Transaction::dummy(&ctx.receive_address, 0..1, &[bip44_duffs]); + let bip44_result = ctx + .check_transaction( + &bip44_tx, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 1, + BlockHash::all_zeros(), + 1_700_000_000, + )), + ) + .await; + assert!( + bip44_result.is_relevant && bip44_result.is_new_transaction, + "BIP44 funding tx should be recognized" + ); + + // Provision a DashPay funds account of the requested arm on the wallet and + // mirror it into the managed side. The identity ids are arbitrary distinct + // test vectors; distinct ids keep the receiving (user/friend) and external + // (friend/user) derivations on different keys/paths. + let user_identity_id = [0x11u8; 32]; + let friend_identity_id = [0x22u8; 32]; + let account_type = match leg { + DashpayLeg::ReceivingFunds => AccountType::DashpayReceivingFunds { + index: 0, + user_identity_id, + friend_identity_id, + }, + DashpayLeg::ExternalAccount => AccountType::DashpayExternalAccount { + index: 0, + user_identity_id, + friend_identity_id, + }, + }; + // Provision the DashPay funds account of the requested arm, matching how + // production derives each so the local signer's capability is faithful: + // + // * `ReceivingFunds` is OURS. Production derives it from our own + // friendship xpub (`register_contact_account`, `is_watch_only: false`), + // so the local seed CAN sign it. `add_account(_, None)` models that by + // deriving the account from this wallet's own root xpriv. + // + // * `ExternalAccount` is the CONTACT's, WATCH-ONLY. Production builds it + // from the contact's decrypted xpub (`register_dashpay_external_account`, + // `is_watch_only: true`), whose private keys live under a DIFFERENT seed + // the wallet does not hold. Model that faithfully: derive the account + // xpub from a SEPARATE random wallet and insert it via + // `add_account(_, Some(xpub))`, which stores the account + // `is_watch_only: true`. The old shortcut — `add_account(_, None)` for + // BOTH arms — derived the external account from our OWN seed, making it + // locally signable and MASKING the union-funding bug (an asset lock + // would silently spend the contact's coins with a wrong-key, invalid + // signature). This arm now proves the builder excludes it. + match leg { + DashpayLeg::ReceivingFunds => { + ctx.wallet + .add_account(account_type, None) + .expect("add DashPay receiving account to wallet"); + } + DashpayLeg::ExternalAccount => { + let foreign_xpub = foreign_contact_account_xpub(); + ctx.wallet + .add_account(account_type, Some(foreign_xpub)) + .expect("add watch-only DashPay external account to wallet"); + } + } + ctx.managed_wallet + .add_managed_account(&ctx.wallet, account_type) + .expect("mirror DashPay account into managed wallet"); + + // Derive a fresh DashPay receive address from the single-pool managed + // account, then fund it. DashPay accounts are single-pool (like CoinJoin), + // so they derive via `next_address` rather than `next_receive_address`. + let key = DashpayAccountKey { + index: 0, + user_identity_id, + friend_identity_id, + }; + let dashpay_xpub = match leg { + DashpayLeg::ReceivingFunds => ctx.wallet.accounts.dashpay_receival_accounts.get(&key), + DashpayLeg::ExternalAccount => ctx.wallet.accounts.dashpay_external_accounts.get(&key), + } + .expect("DashPay account present in wallet") + .account_xpub; + let dashpay_address = { + let managed = match leg { + DashpayLeg::ReceivingFunds => ctx + .managed_wallet + .accounts + .dashpay_receival_accounts + .get_mut(&key), + DashpayLeg::ExternalAccount => ctx + .managed_wallet + .accounts + .dashpay_external_accounts + .get_mut(&key), + } + .expect("managed DashPay account present"); + managed + .next_address(Some(&dashpay_xpub), true) + .expect("DashPay receive address") + }; + let dashpay_tx = Transaction::dummy(&dashpay_address, 0..1, &[dashpay_duffs]); + let dashpay_result = ctx + .check_transaction( + &dashpay_tx, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 2, + BlockHash::all_zeros(), + 1_700_000_100, + )), + ) + .await; + assert!( + dashpay_result.is_relevant && dashpay_result.is_new_transaction, + "DashPay funding tx should be recognized" + ); + + let signer = WalletSigner { + wallet: ctx.wallet.clone(), + }; + + let balance = Arc::new(WalletBalance::new()); + let info = PlatformWalletInfo { + core_wallet: ctx.managed_wallet, + balance, + identity_manager: IdentityManager::new(), + tracked_asset_locks: BTreeMap::new(), + }; + + let mut wm = WalletManager::::new(Network::Testnet); + let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); + + (Arc::new(RwLock::new(wm)), wallet_id, signer) +} + /// Funded SPV-backed Core wallet for downstream FFI lifecycle tests. The SPV /// runtime is intentionally not started; abandon/free only need wallet state. pub async fn funded_spv_core_wallet( @@ -277,3 +661,78 @@ pub async fn funded_spv_core_wallet( signer, ) } + +/// No-op persister satisfying [`PlatformWalletManager`] construction for tests +/// that need a full [`PlatformWallet`] but no real persistence pipeline. +pub struct NoopTestPersister; + +impl crate::changeset::PlatformWalletPersistence for NoopTestPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: crate::changeset::PlatformWalletChangeSet, + ) -> Result<(), crate::changeset::PersistenceError> { + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), crate::changeset::PersistenceError> { + Ok(()) + } + + fn load( + &self, + ) -> Result { + Ok(crate::changeset::ClientStartState::default()) + } +} + +struct NoopTestEventHandler; +impl crate::events::EventHandler for NoopTestEventHandler {} +impl crate::events::PlatformEventHandler for NoopTestEventHandler {} + +/// Build a full [`PlatformWallet`] over a mock SDK and a no-op persister, wired +/// through a real [`PlatformWalletManager`] so its `wallet_manager` `Arc` and +/// `wallet_id` are production-shaped. Returns the manager (which the caller must +/// keep alive — it owns the wallet-event adapter task and the registered +/// `Arc`) alongside the wallet id. +/// +/// Used by FFI-layer tests that need genuine `PlatformWallet` aliases, e.g. the +/// `platform_wallet_destroy` final-alias registry-sweep gating. +pub async fn test_platform_wallet_manager() -> ( + Arc>, + WalletId, +) { + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + + // Canonical all-`abandon` BIP-39 test vector. + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let persister = Arc::new(NoopTestPersister); + let event_handler: Arc = + Arc::new(NoopTestEventHandler); + let manager = Arc::new(crate::PlatformWalletManager::new( + sdk, + persister, + event_handler, + )); + + let mnemonic = + Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let seed_bytes = mnemonic.to_seed(""); + // `Some(0)` skips the SPV birth-height lookup so the create never hits the + // network. + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed_bytes, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("create test wallet"); + let wallet_id = wallet.wallet_id(); + (manager, wallet_id) +} diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 0f3d7fd1f0..7a8a06c2e9 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -1,14 +1,24 @@ use dashcore::Transaction; use key_wallet::account::account_type::StandardAccountType; +use key_wallet::ReservationToken; +use super::transaction::FundingAccountRef; use super::SignedCoreTransaction; -use crate::broadcaster::TransactionBroadcaster; +use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; use crate::wallet::reservations::broadcast_releasing_on_rejection; use crate::{CoreWallet, PlatformWalletError}; impl CoreWallet { /// Broadcast an atomically finalized transaction. A definitive rejection /// releases its reservation; an ambiguous `MaybeSent` outcome retains it. + /// + /// The release is owner-guarded by the finalized transaction's + /// [`reservation_token`](SignedCoreTransaction::reservation_token): the + /// broadcast is `.await`ed, and during that await key-wallet's TTL sweep can + /// reclaim this build's reservation and a concurrent build re-reserve the + /// same inputs under a new token. Releasing by outpoint alone would then + /// free that other build's inputs (the `dashpay/platform#4185` double-spend + /// window); presenting the token frees only inputs this build still owns. pub async fn broadcast_finalized_transaction( &self, transaction: &SignedCoreTransaction, @@ -21,6 +31,7 @@ impl CoreWallet { transaction.funding_account_type(), transaction.funding_account_index(), transaction.transaction(), + transaction.reservation_token(), ) .await; } @@ -86,6 +97,59 @@ 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 a [`FundingAccountRef`] 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 — and a DashPay-receival-funded one, which has no + /// `AccountTypePreference` at all and is reachable only by derivation path. + /// + /// 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) AND — via `token` — only on inputs this + /// build still owns. The deferred registry can hold the reservation across a + /// long build→broadcast gap, so a TTL sweep re-reserving the same inputs + /// under a new token is a real risk; the owner guard closes the + /// `dashpay/platform#4185` release/re-reserve race. + /// + /// `funding` identifies the ONE funding account the build selected from — + /// either key-wallet's standard variant + index, or (for a payment built by + /// [`build_signed_payment`](Self::build_signed_payment) / + /// [`finalize_signed_payment_from_funding_path`](Self::finalize_signed_payment_from_funding_path)) + /// the account-level derivation path, the only form that can name a DashPay + /// receiving-funds account. `token` is the [`ReservationToken`] that build + /// stamped (`SignedCoreTransaction::reservation_token`), `None` only when the + /// build reserved nothing. + pub(crate) async fn broadcast_payment_releasing_reservation( + &self, + funding: &FundingAccountRef, + transaction: &Transaction, + token: Option, + ) -> Result { + match self.broadcaster.broadcast(transaction).await { + Ok(txid) => Ok(txid), + Err(error) => { + if matches!(error, BroadcastError::Rejected { .. }) { + self.release_reservation_for(funding, transaction, token) + .await; + } + Err(error.into()) + } + } + } } #[cfg(test)] diff --git a/packages/rs-platform-wallet/src/wallet/core/mod.rs b/packages/rs-platform-wallet/src/wallet/core/mod.rs index 5481362ae8..7922bac400 100644 --- a/packages/rs-platform-wallet/src/wallet/core/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/core/mod.rs @@ -1,10 +1,12 @@ pub mod balance; pub mod balance_handler; mod broadcast; -mod transaction; +mod send; +pub(crate) mod transaction; pub mod wallet; pub use balance::WalletBalance; pub use balance_handler::BalanceUpdateHandler; -pub use transaction::SignedCoreTransaction; +pub use send::{FinalizedCorePayment, SignedCorePayment}; +pub use transaction::{FundingAccountRef, SignedCoreTransaction}; pub use wallet::CoreWallet; diff --git a/packages/rs-platform-wallet/src/wallet/core/send.rs b/packages/rs-platform-wallet/src/wallet/core/send.rs new file mode 100644 index 0000000000..36ecc06d44 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/core/send.rs @@ -0,0 +1,2113 @@ +//! General Core L1 payment building. +//! +//! [`CoreWallet::build_signed_payment`] is the first-class "send" primitive: +//! it selects inputs from **one** caller-named funds account, builds and signs +//! a standard payment transaction, and returns the **signed serialized bytes** +//! plus the computed fee and change amount — WITHOUT broadcasting and WITHOUT +//! persisting a debit. +//! +//! ## Funding-domain isolation +//! +//! Selection is confined to a single funding account, defaulting to the unmixed +//! BIP44 account — never a union across accounts. See +//! [`crate::wallet::funding_privacy`] for the invariant, the +//! dashpay/platform#4073 → #4184 history behind it, and the guardrail that +//! enforces it. +//! +//! ## Why build-only / no-broadcast +//! +//! During the dashj→SDK transition the Android app keeps its own transaction +//! bookkeeping (dashj's `maybeCommitTx` drives CrowdNode, memos, and confidence +//! listeners). The app therefore wants the SDK to *build + sign* a payment from +//! the bound wallet and hand back the raw bytes, then commit + broadcast them +//! through dashj itself. Post-transition a separate SDK-broadcast mode will own +//! broadcasting and the debit persistence that goes with it; this primitive is +//! the permanent, generally-useful "give me signed bytes" half of that split. +//! +//! ## Persistence semantics (deliberate) +//! +//! Building does **not** persist a debit and does not write UTXOs, balances, or +//! transaction records back to the wallet. The only in-memory mutation is the +//! key-wallet `ReservationSet` bookkeeping that `set_funding` + +//! `TransactionBuilder::build_signed` perform on the **selected** funding +//! account: the selected inputs are marked *reserved* so a concurrent SDK build +//! does not re-select the same coins. Because selection is confined to one +//! account, every selected input is reserved in the ledger that all funding +//! paths consult for that account — there are no unreserved "secondary-account" +//! inputs (dashpay/platform#4247 review finding, now structurally impossible). +//! +//! That reservation is in-memory only (never +//! serialized) and is released when the spend is later processed back into the +//! wallet by sync, or by the reservation-TTL backstop, or explicitly via +//! [`ManagedCoreFundsAccount::release_reservation`] for an abandoned build. No +//! balance is debited until the transaction actually confirms — exactly what +//! the transition flow needs, since dashj owns commit/broadcast. +//! +//! [`ManagedCoreFundsAccount::release_reservation`]: +//! key_wallet::managed_account::ManagedCoreFundsAccount::release_reservation + +use std::collections::HashMap; + +use dashcore::{Address as DashAddress, OutPoint, Transaction}; +use key_wallet::bip32::DerivationPath; +use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::managed_account::ManagedCoreFundsAccount; +use key_wallet::signer::Signer; +use key_wallet::wallet::managed_wallet_info::coin_selection::{SelectionError, SelectionStrategy}; +use key_wallet::wallet::managed_wallet_info::fee::FeeRate; +use key_wallet::wallet::managed_wallet_info::transaction_builder::{ + BuilderError, TransactionBuilder, +}; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::ReservationToken as KeyWalletReservationToken; + +use crate::broadcaster::TransactionBroadcaster; +use crate::error::PlatformWalletError; +use crate::wallet::core::transaction::FundingAccountRef; +use crate::wallet::core::CoreWallet; +use crate::wallet::funding_privacy::is_signable_funding_account; + +/// key-wallet's default fee rate (duffs per kB). Matches the asset-lock +/// builder's `DEFAULT_FEE_PER_KB` and `FeeRate::normal()`. +const DEFAULT_FEE_PER_KB: u64 = 1000; + +/// Consensus cap on any single amount this primitive will accept or aggregate. +const MAX_MONEY: u64 = dashcore::blockdata::constants::MAX_MONEY; + +/// Dash's standard-transaction size limit, in bytes. A transaction above this +/// is non-standard and will not relay, so building one is never useful. +/// +/// Derived from `dashcore::policy::MAX_STANDARD_TX_WEIGHT` (400_000 weight +/// units) rather than hard-coded: Dash has no segwit, so weight is exactly +/// 4× size and the byte limit is `MAX_STANDARD_TX_WEIGHT / 4` = 100_000. +const MAX_STANDARD_TX_SIZE: usize = (dashcore::policy::MAX_STANDARD_TX_WEIGHT / 4) as usize; + +/// Encoded size of one P2PKH output, matching key-wallet's `TX_OUTPUT_SIZE`. +const TX_OUTPUT_SIZE: usize = 34; + +/// Encoded size of one signed P2PKH input, matching the `148` key-wallet passes +/// to `select_coins_with_size`. +const TX_INPUT_SIZE: usize = 148; + +/// Largest a Bitcoin/Dash varint can encode to. Used instead of the exact +/// varint width so the size estimate never comes in under key-wallet's. +const MAX_VARINT_SIZE: usize = 9; + +/// Upper bound on the caller-supplied fee rate, in duffs/kB. +/// +/// `FeeRate::calculate_fee` computes `sat_per_kb * size_bytes` with **unchecked** +/// `u64` multiplication (key-wallet `managed_wallet_info/fee.rs`), and the public +/// Kotlin/FFI APIs accept any non-negative `Long` — so an unbounded rate panics +/// in an overflow-checking Android build, or wraps in release, silently turning +/// an astronomical requested rate into a tiny fee. +/// +/// The bound is derived so the product cannot overflow **for any transaction +/// size expressible in a `u32`** (~4.3 GB): with `sat_per_kb ≤ u64::MAX / +/// u32::MAX`, `sat_per_kb * size_bytes ≤ u64::MAX` whenever +/// `size_bytes ≤ u32::MAX`. That deliberately does NOT depend on the input +/// count. An earlier `MAX_MONEY / 100` bound assumed the transaction stayed +/// under [`MAX_STANDARD_TX_SIZE`], which this method never enforced — leaving +/// the product to overflow at ~878 kB, reachable both by an oversized recipient +/// list and by a CoinJoin account with a few thousand small denominations +/// (dashpay/platform#4247 and #4256 review). Since size is bounded by `u32` +/// long before it is bounded by policy, tying the bound to `u32::MAX` closes +/// the overflow unconditionally. +/// +/// ~4.29e9 duffs/kB is ~43 DASH/kB — three orders of magnitude above any +/// legitimate rate (the default is 1_000), so nothing real is rejected. The +/// maximum fee this permits on a standard-size transaction is +/// `MAX_FEE_PER_KB * 100` ≈ 4_295 DASH, still far below [`MAX_MONEY`]. +const MAX_FEE_PER_KB: u64 = u64::MAX / u32::MAX as u64; + +/// The unmixed BIP44 account this primitive is pinned to, in both of its roles: +/// +/// * the **default funding account** — where `funding_path: None` selects from; +/// * the **change sink** — key-wallet derives change addresses only for +/// *Standard* accounts, so a payment funded from an explicitly-named +/// non-Standard account (CoinJoin / DashPay-receiving) must route change +/// here. See [`crate::wallet::funding_privacy`]. +/// +/// Account 0 rather than a caller-chosen index: the transition-era send path +/// has exactly one BIP44 account, and the asset-lock builder's `account_index` +/// serves the same pinned role there. +const BIP44_ACCOUNT_INDEX: u32 = 0; + +/// A built-and-signed Core L1 payment that ALSO carries the bookkeeping a +/// deferred (BIP70/BIP270-style) submission needs: which single account funded +/// it, the height its reservation was stamped at, and the key-wallet token +/// stamped onto the reserved inputs. +/// +/// This is the shape [`CoreWallet::finalize_signed_payment_from_funding_path`] +/// returns, and the bridge between the two previously-disconnected send flows: +/// the funding-path selector (the only one that can reach a +/// `DashpayReceivingFunds` account) and the reservation registry that mints +/// broadcast/release tokens. +/// +/// Holding one of these means the selected inputs are **reserved**. Either +/// register it with [`SignedPaymentRegistry::register_funded_by`] — which takes +/// over that responsibility and returns a token — or release it with +/// [`CoreWallet::abandon_payment`]. Dropping it without doing either strands the +/// reservation until key-wallet's TTL backstop reclaims it. +/// +/// ## Not `Clone` — it is a linear obligation +/// +/// The reservation it holds must be discharged exactly once. While this derived +/// `Clone`, a safe caller could abandon one copy and register another, or mint +/// two registry tokens able to broadcast the same transaction — one release +/// would free inputs the other copy still believed it owned +/// (dashpay/platform#4256 review). [`CoreWallet::abandon_payment`] now consumes +/// the value for the same reason: abandoning it ends its life, so a +/// register-after-abandon cannot compile. +/// +/// The fields stay public: `register_funded_by` belongs to the reservation +/// registry (dashpay/platform#4185) and still takes the transaction, funding +/// ref, height and token as separate arguments, so making them private would +/// only add accessors without closing the mismatched-pieces hole. Folding those +/// four parameters into one consuming `register(payment)` is the real fix and +/// belongs on #4185, which owns that API. +/// +/// [`SignedPaymentRegistry::register_funded_by`]: +/// crate::SignedPaymentRegistry::register_funded_by +#[derive(Debug)] +pub struct FinalizedCorePayment { + /// The signed transaction. + pub transaction: Transaction, + /// The fee paid, in duffs — derived from the transaction itself + /// (`inputs − outputs`), the same ground truth [`SignedCorePayment::fee`] + /// uses. + pub fee: u64, + /// Duffs returned to the wallet's BIP44 change address (0 when the build + /// produced no change output). + pub change_amount: u64, + /// The ONE account the inputs were selected from and reserved in, as its + /// RESOLVED account-level derivation path — never the caller's `None`. A + /// later release must name this account, not the default BIP44 one. + pub funding: FundingAccountRef, + /// The wallet's `last_processed_height` captured in the funding critical + /// section, i.e. the exact clock `set_current_height` stamped the + /// reservation with. The registry's age guard must baseline off this — see + /// [`SignedCoreTransaction::reservation_height`](crate::SignedCoreTransaction::reservation_height). + pub reservation_height: u32, + /// The key-wallet [`ReservationToken`](key_wallet::ReservationToken) stamped + /// onto the selected inputs, so a later release is *owner-guarded* and frees + /// only inputs this build still owns (`dashpay/platform#4185`). `None` only + /// if the build reserved nothing, which the funded path never does. + pub reservation_token: Option, +} + +/// A built-and-signed Core L1 payment, ready to be committed/broadcast by the +/// caller (dashj during the transition, or a later SDK-broadcast mode). +#[derive(Debug, Clone)] +pub struct SignedCorePayment { + /// The signed transaction. Serialize with + /// [`consensus::serialize`](dashcore::consensus::serialize) for the raw + /// wire bytes the caller hands to its broadcaster. + pub transaction: Transaction, + /// The fee paid, in duffs, computed from the encoded size of the *signed* + /// transaction. + pub fee: u64, + /// Duffs returned to the wallet's change address (0 when the build produced + /// no change output — an exact-match selection or a dust-only remainder + /// folded into the fee). + pub change_amount: u64, +} + +impl CoreWallet { + /// Build and sign a standard Core L1 payment to `outputs`, funding it from + /// the **single** funds account named by `funding_path`, and return the + /// signed transaction plus its fee and change amount. Does **not** broadcast + /// and does **not** persist a debit (see the module docs for the persistence + /// contract). + /// + /// ## Coin selection — one account, never a union + /// + /// Inputs come from exactly one funds account: `None` (the default) funds + /// from the unmixed BIP44 account at [`BIP44_ACCOUNT_INDEX`], and + /// `Some(path)` funds strictly from the one funds account whose + /// account-level derivation path equals `path` (e.g. the DIP-9 CoinJoin + /// account, to spend previously-mixed coins deliberately). There is **no + /// union across accounts and no privacy-domain consent gate** — the caller + /// names exactly one funding source, so there is nothing to consent to. If + /// that account cannot cover the payment (+ fee) the build fails with + /// [`PlatformWalletError::PaymentInsufficientFunds`] rather than silently + /// topping up from another account; that failure is the point, not a + /// limitation. See [`crate::wallet::funding_privacy`] for why + /// (dashpay/platform#4073, blocked and re-scoped by #4184). + /// + /// Watch-only `DashpayExternalAccount`s can never fund a payment — their + /// coins belong to a contact and the local mnemonic holds no key for + /// them — so naming one explicitly is refused rather than silently ignored. + /// + /// Change routes to the BIP44 account at [`BIP44_ACCOUNT_INDEX`], which for + /// the default funding path is the funding account itself. When an explicit + /// non-Standard account (CoinJoin / DashPay-receiving) funds the payment, + /// key-wallet cannot derive change on it at all, so the BIP44 sink is + /// structural — the same change model the asset-lock builder uses. + /// + /// `LargestFirst` selection is used deliberately (not the builder default + /// `BranchAndBound`): a CoinJoin account can hold many small mixed + /// denominations, and `BranchAndBound`'s exact-match subset-sum is + /// exponential over them. `LargestFirst`'s linear greedy accumulator also + /// minimizes the input count — fewer signer round-trips and a smaller + /// tx/fee. + /// + /// ## Parameters + /// + /// * `outputs` — the recipient `(address, amount_duffs)` pairs. Must be + /// non-empty and every amount must be positive. + /// * `fee_per_kb` — fee rate in duffs/kB, or `None` for the default + /// (`1000`). + /// * `signer` — the ECDSA signer that produces each input's P2PKH signature + /// (the Keychain/Keystore-backed `MnemonicResolverCoreSigner` in + /// production). No private key crosses the boundary. + /// * `funding_path` — the account-level derivation path of the SINGLE funds + /// account whose UTXOs fund the payment. `None` (the default) funds from + /// the unmixed BIP44 account (dashpay/platform#4184). + pub async fn build_signed_payment( + &self, + outputs: Vec<(DashAddress, u64)>, + fee_per_kb: Option, + signer: &S, + funding_path: Option, + ) -> Result { + let finalized = self + .finalize_signed_payment_from_funding_path(outputs, fee_per_kb, signer, funding_path) + .await?; + Ok(SignedCorePayment { + transaction: finalized.transaction, + fee: finalized.fee, + change_amount: finalized.change_amount, + }) + } + + /// [`build_signed_payment`](Self::build_signed_payment) with the deferred + /// submission bookkeeping retained: identical selection, change routing, + /// signing, and fee/change accounting, but the result also carries the ONE + /// account the build reserved into, the height its reservation was stamped + /// at, and key-wallet's owner token for those inputs. + /// + /// This is the bridge that lets a **DashPay receiving-funds** balance reach + /// the reservation/broadcast lifecycle. That lifecycle's other entry point, + /// [`finalize_transaction`](Self::finalize_transaction), selects accounts by + /// key-wallet's `AccountTypePreference` (BIP44 / BIP32 / CoinJoin), which has + /// no variant for a receival account — so before this method a receival + /// balance could be *signed* (here) or *broadcast with a token* (there), but + /// never both. Register the result with + /// [`SignedPaymentRegistry::register_funded_by`](crate::SignedPaymentRegistry::register_funded_by) + /// to mint the token the broadcast/release pair consumes. + /// + /// ## Reservation ownership — the caller MUST discharge it + /// + /// On success the selected inputs are reserved in the funding account's own + /// ledger. Exactly one of these must follow, or the reservation is stranded + /// until key-wallet's TTL backstop reclaims it (the funds are not lost, but + /// they are unspendable meanwhile): + /// + /// * register it — the registry then owns the release; or + /// * [`abandon_payment`](Self::abandon_payment) it. + /// + /// Every invariant [`build_signed_payment`](Self::build_signed_payment) + /// documents holds unchanged, because that method is now a projection of + /// this one: exactly one funding account (never a union), change to the + /// unmixed BIP44 account, watch-only accounts refused even when named + /// explicitly, and a shortfall reported against the SELECTED account alone. + pub async fn finalize_signed_payment_from_funding_path( + &self, + outputs: Vec<(DashAddress, u64)>, + fee_per_kb: Option, + signer: &S, + funding_path: Option, + ) -> Result { + if outputs.is_empty() { + return Err(PlatformWalletError::TransactionBuild( + "at least one output is required".to_string(), + )); + } + if outputs.iter().any(|(_, amount)| *amount == 0) { + return Err(PlatformWalletError::TransactionBuild( + "every output amount must be greater than zero".to_string(), + )); + } + + // Bound the recipient count so the transaction stays relayable AND so + // key-wallet's unchecked `sat_per_kb * size_bytes` fee arithmetic cannot + // be driven to overflow from the output side. `outputs.len()` is the one + // caller-controlled size dimension (~25.8k recipients still fits in a + // practical JNI blob); the input count is wallet-owned and key-wallet + // caps it separately. + // + // Mirrors key-wallet's own base-size formula so the estimate is the one + // the builder will actually use: 8 bytes of version/type/locktime, a + // 1-byte input-count varint, the output-count varint (≤ 9, taken at its + // maximum so this never under-estimates), 34 bytes per P2PKH output, + // and 34 for the change output. Every step is checked — + // `outputs.len() * 34` is an unchecked `usize` multiply inside + // key-wallet. Room for at least one 148-byte input is required, since a + // transaction with no inputs cannot be funded. + let outputs_count = outputs.len(); + let base_size = outputs_count + .checked_mul(TX_OUTPUT_SIZE) + .and_then(|s| s.checked_add(8 + 1 + MAX_VARINT_SIZE + TX_OUTPUT_SIZE)) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "{outputs_count} recipients overflow the transaction size calculation" + )) + })?; + if base_size.saturating_add(TX_INPUT_SIZE) > MAX_STANDARD_TX_SIZE { + return Err(PlatformWalletError::TransactionBuild(format!( + "{outputs_count} recipients need {base_size} bytes of outputs, leaving no \ + room for inputs within the {MAX_STANDARD_TX_SIZE}-byte standard \ + transaction limit" + ))); + } + + // Reject below-dust recipients. `TransactionBuilder::add_output` applies + // no relay policy at all — it copies the requested amount straight into + // the `TxOut` — so without this a one-duff recipient produced a fully + // signed transaction that every standard node rejects as nonstandard, + // from a primitive documented as building a *standard* payment for + // later broadcast (dashpay/platform#4247 review). Checked per output + // against its OWN destination script, not a shared constant: the + // threshold is script-shaped (546 duffs for P2PKH, less for P2SH), + // which is also why key-wallet's hard-coded 546 change-dust literal is + // not reusable here. + // + // After the count bound so an absurd recipient list is rejected before + // this loop runs a script serialization per output, and before the + // wallet lock is taken, before any input is reserved, and before the + // signer is called — a request that can never relay must not tie up + // coins or prompt the user for a keystore signature. + for (address, amount) in &outputs { + let dust = address.script_pubkey().dust_value().to_sat(); + if *amount < dust { + return Err(PlatformWalletError::TransactionBuild(format!( + "output {amount} duffs to {address} is below the {dust}-duff dust \ + threshold for its script type; such a transaction cannot be relayed" + ))); + } + } + + // Checked aggregation, bounded by MAX_MONEY. key-wallet sums the same + // amounts with unchecked `u64` arithmetic while building, so an + // unchecked total here would wrap in release builds (four outputs of + // `1 << 62` sum to exactly 2^64) and let selection fund only the fee + // while retaining four enormous outputs — a signed transaction + // consensus rejects, with meaningless fee/change metadata. In an + // overflow-checking build the same input panics inside the `extern "C"` + // FFI frame, where the JNI guard cannot recover it. + let outputs_total = outputs + .iter() + .try_fold(0u64, |total, (_, amount)| total.checked_add(*amount)) + .filter(|total| *total <= MAX_MONEY) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "output amounts overflow or exceed MAX_MONEY ({MAX_MONEY} duffs)" + )) + })?; + + // Bound the caller-supplied fee rate for the same reason: key-wallet's + // `FeeRate::calculate_fee` computes `sat_per_kb * size_bytes` with + // unchecked `u64` multiplication, so a rate near `u64::MAX` (the public + // Kotlin/FFI APIs accept any non-negative `Long`) panics in an + // overflow-checking Android build, or wraps in release — turning an + // astronomical requested rate into a tiny fee. + let fee_per_kb = fee_per_kb.unwrap_or(DEFAULT_FEE_PER_KB); + if fee_per_kb > MAX_FEE_PER_KB { + return Err(PlatformWalletError::TransactionBuild(format!( + "fee rate {fee_per_kb} duffs/kB exceeds the maximum {MAX_FEE_PER_KB}" + ))); + } + + let mut wm = self.wallet_manager.write().await; + let (wallet, info) = wm + .get_wallet_and_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + + let height = info.core_wallet.last_processed_height(); + let network = info.core_wallet.network(); + let fee_rate = FeeRate::new(fee_per_kb); + + // ------------------------------------------------------------------ + // REGRESSION NOTE (dashpay/platform#4073 → #4184 → #4247) + // + // This selection block previously unioned every signable funds account + // and ran LargestFirst over the combined set, with BIP44 change — which + // irreversibly links ordinary, CoinJoin, and DashPay-receiving coins in + // one on-chain transaction. Reviewer shumkov blocked exactly that on + // PR #4184 (2026-07-21); the single-selected-account redesign (commit + // 4d3e1322bc) was signed off 2026-07-23. + // + // The send-raw-tx code was written on an older integration line BEFORE + // that re-scope, and shipped the blocked union behavior into the general + // send path — with a test asserting the union as correct behavior. A + // compile-clean, review-passed change is NOT sufficient evidence of + // correctness here. + // + // INVARIANT: single selected account; never union funding accounts; + // default unmixed BIP44. See `crate::wallet::funding_privacy` and its + // guardrail tests. + // ------------------------------------------------------------------ + + // Resolve the account-level path of the unmixed BIP44 account: both the + // default funding source and the change sink. + let bip44_path = info + .core_wallet + .accounts + .standard_bip44_accounts + .get(&BIP44_ACCOUNT_INDEX) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "BIP44 account {BIP44_ACCOUNT_INDEX} not found for payment funding" + )) + })? + .managed_account_type() + .to_account_type() + .derivation_path(network) + .map_err(|e| { + PlatformWalletError::TransactionBuild(format!( + "failed to derive the unmixed BIP44 account-level path: {e}" + )) + })?; + let funding_path = funding_path.unwrap_or_else(|| bip44_path.clone()); + let funds_from_change_account = funding_path == bip44_path; + + // The xpub-bearing BIP44 account: the change sink, and the fallback + // signing-side account. Cloned so no immutable borrow of `wallet` is + // held across the mutable `info` borrow below. + let bip44_acc = wallet + .get_bip44_account(BIP44_ACCOUNT_INDEX) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "BIP44 account {BIP44_ACCOUNT_INDEX} not found for payment change routing" + )) + })? + .clone(); + + // Derive an explicit BIP44 change address ONLY when the funding account + // is not the BIP44 sink itself: `set_funding` already derives change on + // the funding account, which is correct (and consumes no extra pool + // index) in the default case, but fails and is swallowed to `None` for a + // non-Standard CoinJoin / DashPay account. Taken before the funding + // account's `&mut` below — two accounts of the same collection cannot + // both be borrowed mutably at once. + let change_addr: Option = if funds_from_change_account { + None + } else { + let change_acc = info + .core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&BIP44_ACCOUNT_INDEX) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "managed BIP44 account {BIP44_ACCOUNT_INDEX} not found for payment \ + change routing" + )) + })?; + Some( + change_acc + .next_change_address(Some(&bip44_acc.account_xpub), true) + .map_err(|e| { + PlatformWalletError::TransactionBuild(format!( + "failed to derive change address on BIP44 account \ + {BIP44_ACCOUNT_INDEX}: {e}" + )) + })?, + ) + }; + + // The funding account's OWN wallet-level `Account`. `set_funding` calls + // `funds_acc.next_change_address(Some(&acc.account_xpub))` before the + // `set_change_address` override, so `acc` must be the funding account — + // passing the BIP44 xpub for an explicitly-selected BIP32 account would + // record a change entry derived from the wrong xpub into that account's + // pool (dashpay/platform#4184 review). + // + // FAILS CLOSED. This previously fell back to `bip44_acc` when no + // wallet-level account matched, which is the same silent-fallback shape + // #4184 removed from the selector: the managed-account lookup below can + // still resolve a CoinJoin or DashPay receival account, so the fallback + // would hand `set_funding` another account's xpub and record a change + // entry derived from it into the funding account's pool. Refusing is the + // only safe answer — the two lookups disagreeing is a wallet-state bug, + // not something to paper over with BIP44 (dashpay/platform#4256 review). + // + // Verified not to narrow any real path: `all_accounts()` does enumerate + // CoinJoin and DashPay receiving-funds accounts, so every send test — + // including the receival and explicit-CoinJoin ones — passes with the + // fallback removed. It was dead code on every exercised path. + let funding_wallet_acc = wallet + .all_accounts() + .into_iter() + .find(|a| { + a.derivation_path() + .map(|p| p == funding_path) + .unwrap_or(false) + }) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "no wallet-level account matches funding derivation path {funding_path}; refusing to fund with another account's xpub" + )) + })?; + + // Locate the ONE managed funds account whose account-level path equals + // `funding_path`, MUTABLY, so `set_funding` reserves the selected inputs + // in that account's OWN reservation ledger. Watch-only + // `DashpayExternalAccount`s are never fundable (the local mnemonic + // cannot sign them) — refuse even when named explicitly. + // + // PRIVACY-DOMAIN-OK: this iterates funds accounts only to LOOK ONE UP by + // derivation path. Exactly one account is selected and it alone funds + // the transaction; nothing is accumulated across accounts. + let mut selected: Option<&mut ManagedCoreFundsAccount> = None; + for acc in info.core_wallet.accounts.all_funding_accounts_mut() { + let acc_path = acc + .managed_account_type() + .to_account_type() + .derivation_path(network) + .map_err(|e| { + PlatformWalletError::TransactionBuild(format!( + "failed to derive account-level path for a funds account: {e}" + )) + })?; + if acc_path != funding_path { + continue; + } + if !is_signable_funding_account(acc.managed_account_type()) { + return Err(PlatformWalletError::TransactionBuild(format!( + "funding derivation path {funding_path} names a watch-only account whose \ + coins the local wallet cannot sign; choose a signable funds account" + ))); + } + selected = Some(acc); + break; + } + let selected = selected.ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "no spendable funds account matches funding derivation path {funding_path}" + )) + })?; + + // One immutable pass over the SELECTED account, building: + // (a) an owned `Address -> DerivationPath` resolver, so signing can + // resolve a key for every selected input without holding an + // account borrow across the signer await; + // (b) an `OutPoint -> value` map for the post-build fee/change figures; + // (c) the account's selectable total, for a typed shortfall error. + let mut path_map: HashMap = HashMap::new(); + let mut input_value: HashMap = HashMap::new(); + let mut selectable_value: u64 = 0; + for utxo in selected.spendable_utxos(height) { + selectable_value = selectable_value.saturating_add(utxo.value()); + input_value.insert(utxo.outpoint, utxo.value()); + if let Some(path) = selected.address_derivation_path(&utxo.address) { + path_map.insert(utxo.address.clone(), path); + } + } + + // Seed the selected account (inputs + reservations + its own change + // address), override the change sink when the funding account cannot + // derive change, then add the recipient outputs. The `&mut` borrow ends + // with `set_funding`; the returned builder owns cloned inputs / + // reservations / change address, so no account borrow is held across the + // signer await below. + let builder = { + let mut builder = TransactionBuilder::new() + .set_fee_rate(fee_rate) + .set_current_height(height) + // See the doc-comment: LargestFirst, not the default + // BranchAndBound, to keep CoinJoin's many small denominations + // from blowing up the exact-match subset-sum search. + .set_selection_strategy(SelectionStrategy::LargestFirst) + .set_funding(selected, funding_wallet_acc); + if let Some(addr) = change_addr { + builder = builder.set_change_address(addr); + } + for (address, amount) in &outputs { + builder = builder.add_output(address, *amount); + } + builder + }; + + // `build_signed_reserved`, not `build_signed`: identical build, but it + // also hands back the key-wallet ReservationToken it stamped onto the + // selected inputs. `build_signed` is literally this call with the token + // discarded — which is exactly what left a deferred payment unable to + // release owner-guarded. Keeping the token is what makes a registered + // (tokened) payment releasable without risking a TTL-swept, re-reserved + // input belonging to another build (`dashpay/platform#4185`). + let (transaction, _estimated_fee, reservation_token) = builder + .build_signed_reserved(signer, move |addr| path_map.get(&addr).cloned()) + .await + .map_err(|e| map_send_builder_error(e, selectable_value, outputs_total))?; + + // The manager write lock MUST span the build: `set_funding` only + // snapshots the account's UTXOs and clones the `Arc` — + // coin selection and `reserve()` both happen inside + // `build_signed_reserved` (key-wallet `assemble_unsigned`). Dropping the + // guard before this call would let two concurrent finalizes each + // snapshot the same unreserved UTXO under their own lock hold and then + // both select and reserve it, yielding two signed transactions that + // spend the same coin. See the reviewer note on this method for why the + // narrower "reserve under the lock, sign outside" split is not available + // here (key-wallet's `assemble_unsigned` is private and there is no + // `build_unsigned_reserved`). + // + // Released HERE, before any of the release paths below: the reservation + // now exists and is owner-stamped, so nothing further needs exclusivity, + // and `abandon_payment` re-acquires the manager lock itself — calling it + // under this guard would deadlock (tokio's `RwLock` is not reentrant). + drop(wm); + + // Derive fee and change from the transaction itself — the ground truth + // that is always self-consistent (`fee + outputs + change == inputs`). + // We do NOT use `build_signed`'s returned fee: it recomputes the fee + // from the *signed* size, but the change output was already sized with + // the pre-sign estimate, and ECDSA signatures vary in encoded length — + // so the recomputed figure can differ by a few duffs from the fee the + // wallet actually pays (`inputs − outputs`). + // + // `total_out` is the sum of every output; the only non-recipient output + // a plain payment (no special payload) can carry is the single change + // output back to the BIP44 sink, so `change = total_out − outputs`. + // Any selected input we somehow can't price (impossible — every + // spendable UTXO was recorded above) counts as 0, which LOWERS + // `selected_input_value` and therefore lowers the `saturating_sub` + // result: `fee` would be UNDER-reported, not over-. + let selected_input_value: u64 = transaction + .input + .iter() + .map(|txin| input_value.get(&txin.previous_output).copied().unwrap_or(0)) + .sum(); + let total_out: u64 = transaction.output.iter().map(|o| o.value).sum(); + let fee = selected_input_value.saturating_sub(total_out); + let change_amount = total_out.saturating_sub(outputs_total); + + let payment = FinalizedCorePayment { + transaction, + fee, + change_amount, + // The RESOLVED path, never the caller's `None`: a release must name + // the account the inputs are actually reserved in. For a default + // build that is the unmixed BIP44 account's own path, so the release + // still lands on BIP44 — but by the same identity the selector used, + // not by a separate assumption that could drift. + funding: FundingAccountRef::Path(funding_path), + reservation_height: height, + reservation_token, + }; + + // Belt-and-braces: the pre-build bound covered only the output side, + // because the input count is not knowable until coin selection has run. + // Measure the transaction actually built and refuse to hand back bytes + // that cannot relay. In practice this fires only when a recipient list + // that passed the output-side bound is then funded by enough small + // inputs to push the whole transaction over the limit. + // + // The reservation is discharged before returning: this is a failure + // between the build and a successful `register_funded_by`, exactly the + // case `abandon_payment` documents. Without it a request that can never + // relay would strand the account's coins until the TTL backstop. + let signed_size = payment.transaction.size(); + if signed_size > MAX_STANDARD_TX_SIZE { + self.abandon_payment(payment).await; + return Err(PlatformWalletError::TransactionBuild(format!( + "the signed transaction is {signed_size} bytes, over the \ + {MAX_STANDARD_TX_SIZE}-byte standard transaction limit; it would not relay. \ + Send a smaller amount (fewer inputs) or fewer recipients" + ))); + } + + Ok(payment) + } + + /// Release the funding reservation of a + /// [`FinalizedCorePayment`] the caller has decided not to submit — the + /// path-funded counterpart of + /// [`abandon_transaction`](Self::abandon_transaction). + /// + /// Owner-guarded and generation-bound, like every other release here. Use it + /// on any failure between the build and a successful + /// [`register_funded_by`](crate::SignedPaymentRegistry::register_funded_by); + /// once registered, the registry owns the release instead. + /// + /// Takes the payment **by value**: discharging the reservation ends the + /// obligation, so the type system now rejects registering (or abandoning) + /// the same payment afterwards rather than leaving it to review + /// (dashpay/platform#4256). + pub async fn abandon_payment(&self, payment: FinalizedCorePayment) { + self.release_reservation_for( + &payment.funding, + &payment.transaction, + payment.reservation_token, + ) + .await; + } +} + +/// Map a key-wallet [`BuilderError`] to a [`PlatformWalletError`], promoting the +/// two shortfall shapes to the typed [`PlatformWalletError::PaymentInsufficientFunds`] +/// so the exact `available`/`required` duff amounts survive. +/// +/// `available` is the **selected account's** spendable total, deliberately — +/// never a wallet-wide figure. Reporting a wallet-wide "available" against a +/// single-account shortfall would invite the caller to retry with a larger +/// amount that can only succeed by crossing privacy domains, which this +/// primitive will not do (see [`crate::wallet::funding_privacy`]). `required` is +/// at least the outputs total; a coin-selection error already carries the +/// fee-inclusive figure, which we prefer when present. +/// +/// [`BuilderError::SigningFailed`] is likewise promoted, to +/// [`PlatformWalletError::TransactionSigning`]. Everything left over is a +/// genuine request rejection and becomes [`PlatformWalletError::TransactionBuild`], +/// whose contract — "change the request; a verbatim retry fails identically" — +/// only holds once signing has been split out. +fn map_send_builder_error( + error: BuilderError, + available_in_account: u64, + outputs_total: u64, +) -> PlatformWalletError { + match error { + BuilderError::InsufficientFunds { required, .. } => { + PlatformWalletError::PaymentInsufficientFunds { + available: available_in_account, + required: required.max(outputs_total), + } + } + BuilderError::CoinSelection(SelectionError::InsufficientFunds { required, .. }) => { + PlatformWalletError::PaymentInsufficientFunds { + available: available_in_account, + required: required.max(outputs_total), + } + } + BuilderError::CoinSelection(SelectionError::NoUtxosAvailable) => { + PlatformWalletError::PaymentInsufficientFunds { + available: available_in_account, + required: outputs_total, + } + } + // Signing is NOT a build rejection: the request was accepted and the + // transaction was fully assembled, and key-wallet already released this + // build's owner-stamped reservation, so the identical request succeeds + // once the signer works again. Folding it into `TransactionBuild` told + // the host "your request is invalid, retrying cannot help" for what is + // usually just a locked Keychain (dashpay/platform#4256 review). + BuilderError::SigningFailed(detail) => { + PlatformWalletError::TransactionSigning(format!("payment signing failed: {detail}")) + } + other => PlatformWalletError::TransactionBuild(format!("payment build failed: {other}")), + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + use std::sync::Arc; + + use dashcore::hashes::Hash; + use dashcore::{Address as DashAddress, Network, OutPoint, TxOut, Txid}; + use key_wallet::account::account_type::StandardAccountType; + use key_wallet::account::AccountType; + use key_wallet::bip32::DerivationPath; + use key_wallet::managed_account::ManagedCoreFundsAccount; + use key_wallet::Utxo; + + use async_trait::async_trait; + use dashcore::secp256k1::{ecdsa, PublicKey}; + use key_wallet::signer::{Signer, SignerMethod}; + + use crate::test_support::{ + funded_wallet_manager, split_funded_wallet_manager, split_funded_wallet_manager_dashpay, + AlwaysRejectedBroadcaster, DashpayLeg, WalletSigner, + }; + use crate::wallet::core::balance::WalletBalance; + use crate::wallet::core::CoreWallet; + use crate::wallet::platform_wallet::WalletId; + use crate::PlatformWalletError; + + use super::{FundingAccountRef, SignedCorePayment}; + + /// A `CoreWallet` over a manager fixture. The send path never broadcasts, + /// so the broadcaster is irrelevant (and the balance handle is unused by + /// build — a fresh one is fine for the split fixtures that don't return it). + fn core_wallet( + wallet_manager: Arc< + tokio::sync::RwLock< + key_wallet_manager::WalletManager< + crate::wallet::platform_wallet::PlatformWalletInfo, + >, + >, + >, + wallet_id: WalletId, + balance: Arc, + ) -> CoreWallet { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + CoreWallet::new( + sdk, + wallet_manager, + wallet_id, + Arc::new(AlwaysRejectedBroadcaster), + balance, + ) + } + + fn recipient(seed: u8) -> DashAddress { + DashAddress::dummy(Network::Testnet, seed as usize) + } + + /// Every input of a signed tx must carry a non-empty scriptSig (proof each + /// selected input was actually signed by the per-account resolver). + fn assert_all_inputs_signed(payment: &SignedCorePayment) { + for (i, txin) in payment.transaction.input.iter().enumerate() { + assert!( + !txin.script_sig.is_empty(), + "input {i} was left unsigned (empty scriptSig)" + ); + } + } + + /// Snapshot the BIP44 and CoinJoin outpoints of a split fixture, plus the + /// CoinJoin account's account-level derivation path (the `funding_path` a + /// caller passes to spend previously-mixed coins deliberately). + async fn split_account_outpoints_and_coinjoin_path( + wm: &Arc< + tokio::sync::RwLock< + key_wallet_manager::WalletManager< + crate::wallet::platform_wallet::PlatformWalletInfo, + >, + >, + >, + wallet_id: &WalletId, + ) -> (HashSet, HashSet, DerivationPath) { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + + let guard = wm.read().await; + let (_, info) = guard + .get_wallet_and_info(wallet_id) + .expect("wallet present"); + let network = info.core_wallet.network(); + let bip44 = info + .core_wallet + .accounts + .standard_bip44_accounts + .get(&0) + .map(|a| a.utxos.keys().copied().collect()) + .unwrap_or_default(); + let coinjoin_acc = info + .core_wallet + .accounts + .coinjoin_accounts + .get(&0) + .expect("coinjoin account 0 present"); + let coinjoin = coinjoin_acc.utxos.keys().copied().collect(); + let path = coinjoin_acc + .managed_account_type() + .to_account_type() + .derivation_path(network) + .expect("coinjoin account-level path"); + (bip44, coinjoin, path) + } + + /// A single-account BIP44 payment: the recipient output is present with the + /// exact value, a fee is charged, and the change amount is exactly + /// selected_input − output − fee (here the whole 0.1 DASH rides on one + /// input, so change ≈ 0.1 − amount − fee). + #[tokio::test] + async fn bip44_payment_has_correct_output_change_and_fee() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(wm, wallet_id, balance); + + let to = recipient(42); + let amount = 1_000_000u64; + let payment = core + .build_signed_payment(vec![(to.clone(), amount)], None, &signer, None) + .await + .expect("build should succeed with 0.1 DASH funded"); + + // Recipient output present with the exact value. + let recipient_out = payment + .transaction + .output + .iter() + .find(|o| o.script_pubkey == to.script_pubkey()); + assert_eq!( + recipient_out.map(|o| o.value), + Some(amount), + "recipient output must carry the requested amount" + ); + + // A fee was charged and change is exactly input − output − fee. + assert!(payment.fee > 0, "a non-zero fee should be charged"); + assert_eq!( + payment.change_amount, + 10_000_000 - amount - payment.fee, + "change must be the single input minus the output minus the fee" + ); + // The change output pays the leftover back to the wallet. + assert!( + payment + .transaction + .output + .iter() + .any(|o| o.value == payment.change_amount), + "a change output equal to change_amount should exist" + ); + assert_all_inputs_signed(&payment); + } + + /// **Replaces `payment_funds_from_bip44_and_coinjoin_union`**, which asserted + /// the blocked union behavior as correct (dashpay/platform#4247; see the + /// regression note in `build_signed_payment`). + /// + /// The DEFAULT funding path must never select CoinJoin (or any other + /// non-BIP44 domain) coins, even when BIP44 alone cannot cover the payment. + /// Failing is the correct outcome: a shortfall is reported as a typed error + /// rather than silently satisfied by crossing a privacy domain, because the + /// cross-domain link would be irreversible while the failure is merely + /// retryable with an explicit `funding_path`. + #[tokio::test] + async fn default_funding_never_selects_other_domains() { + // 0.09 DASH on BIP44, 0.09 on CoinJoin; ask 0.15 → only a union covers it. + let (wm, wallet_id, signer) = split_funded_wallet_manager(9_000_000, 9_000_000).await; + let core = core_wallet(wm, wallet_id, Arc::new(WalletBalance::new())); + + let result = core + .build_signed_payment(vec![(recipient(7), 15_000_000)], None, &signer, None) + .await; + + match result { + Err(PlatformWalletError::PaymentInsufficientFunds { + available, + required, + }) => { + assert_eq!( + available, 9_000_000, + "available must reflect ONLY the BIP44 account, never the \ + wallet-wide union" + ); + assert!( + required >= 15_000_000, + "required {required} should be at least the requested amount" + ); + } + other => panic!( + "the default path must not union BIP44 with CoinJoin — expected \ + PaymentInsufficientFunds, got {other:?}" + ), + } + } + + /// The default path funds happily from BIP44 when BIP44 alone suffices, and + /// still leaves the CoinJoin coins untouched. + #[tokio::test] + async fn default_funding_selects_strictly_within_bip44() { + // 0.2 DASH on BIP44, 0.09 on CoinJoin; ask 0.15 → BIP44 alone covers it. + let (wm, wallet_id, signer) = split_funded_wallet_manager(20_000_000, 9_000_000).await; + let (bip44_ops, coinjoin_ops, _) = + split_account_outpoints_and_coinjoin_path(&wm, &wallet_id).await; + + let core = core_wallet(wm, wallet_id, Arc::new(WalletBalance::new())); + let payment = core + .build_signed_payment(vec![(recipient(7), 15_000_000)], None, &signer, None) + .await + .expect("0.15 DASH is fundable from the 0.2 DASH BIP44 account"); + + let spent: HashSet = payment + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + assert!( + spent.iter().all(|op| bip44_ops.contains(op)), + "every input must come from BIP44, spent {spent:?}" + ); + assert!( + !spent.iter().any(|op| coinjoin_ops.contains(op)), + "the default path must never reach CoinJoin coins, spent {spent:?}" + ); + assert_all_inputs_signed(&payment); + } + + /// An explicitly-passed CoinJoin path selects strictly from that account and + /// nothing else — the caller-consented, single-domain half of the #4184 + /// contract. Change still lands on BIP44 because key-wallet cannot derive a + /// change address on a non-Standard account; that is structural, not a + /// co-spend. + #[tokio::test] + async fn explicit_coinjoin_path_selects_only_coinjoin() { + // 0.09 DASH on BIP44 (short), 0.2 on CoinJoin; take 0.15 from CoinJoin. + let (wm, wallet_id, signer) = split_funded_wallet_manager(9_000_000, 20_000_000).await; + let (bip44_ops, coinjoin_ops, coinjoin_path) = + split_account_outpoints_and_coinjoin_path(&wm, &wallet_id).await; + + let core = core_wallet(wm, wallet_id, Arc::new(WalletBalance::new())); + let payment = core + .build_signed_payment( + vec![(recipient(7), 15_000_000)], + None, + &signer, + Some(coinjoin_path), + ) + .await + .expect("the named CoinJoin account covers 0.15 DASH"); + + let spent: HashSet = payment + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + assert!(!spent.is_empty(), "the payment must have selected inputs"); + assert!( + spent.iter().all(|op| coinjoin_ops.contains(op)), + "every input must come from the named CoinJoin account, spent {spent:?}" + ); + assert!( + !spent.iter().any(|op| bip44_ops.contains(op)), + "an explicit CoinJoin path must not pull BIP44 inputs, spent {spent:?}" + ); + // Change is returned to the transparent BIP44 sink. + assert!( + payment.change_amount > 0, + "spending a 0.2 DASH UTXO for 0.15 DASH must leave change" + ); + assert_all_inputs_signed(&payment); + } + + /// Models a locked Keychain: key derivation still works (so the payment + /// assembles normally and the request is provably valid), but no signature + /// can be produced. This is exactly what `MnemonicResolverCoreSigner` does + /// when the mnemonic is locked, missing, or the resolver callback fails. + struct LockedSigner(WalletSigner); + + #[async_trait] + impl Signer for LockedSigner { + type Error = String; + + fn supported_methods(&self) -> &[SignerMethod] { + &[SignerMethod::Digest] + } + + async fn sign_ecdsa( + &self, + _path: &DerivationPath, + _sighash: [u8; 32], + ) -> Result<(ecdsa::Signature, PublicKey), Self::Error> { + Err("mnemonic unavailable: keychain is locked".to_string()) + } + + async fn public_key(&self, path: &DerivationPath) -> Result { + self.0.public_key(path).await + } + } + + /// A signing failure must NOT be reported as `TransactionBuild`, whose + /// contract is "the request is invalid, a verbatim retry fails + /// identically". It is the opposite: the request assembled fine, and the + /// SAME recipients/amount/fee/funding path succeed once the signer is + /// unlocked. Folding the two together told hosts to make the user edit a + /// payment that was never wrong (dashpay/platform#4256 review). + #[tokio::test] + async fn signing_failure_is_not_reported_as_an_invalid_request() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(wm, wallet_id, balance); + + let result = core + .build_signed_payment( + vec![(recipient(3), 1_000_000)], + None, + &LockedSigner(signer), + None, + ) + .await; + + match result { + Err(PlatformWalletError::TransactionSigning(message)) => { + assert!( + message.contains("keychain is locked"), + "the signer's own reason must survive for the host to act \ + on: {message}" + ); + } + Err(PlatformWalletError::TransactionBuild(message)) => panic!( + "signing failures must not claim the request-invalid contract \ + (got TransactionBuild: {message})" + ), + other => panic!("expected TransactionSigning, got {other:?}"), + } + } + + /// The same funds must be spendable immediately after a signing failure: + /// key-wallet releases the owner-stamped reservation on that path, which + /// is *why* the retry-after-unlock contract of `TransactionSigning` holds. + /// If the inputs stayed reserved, the honest code would be an + /// unconfirmed/stranded one instead. + #[tokio::test] + async fn signing_failure_leaves_the_inputs_spendable() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(wm, wallet_id, balance); + + let locked = core + .build_signed_payment( + vec![(recipient(3), 1_000_000)], + None, + &LockedSigner(signer.clone()), + None, + ) + .await; + assert!( + matches!(locked, Err(PlatformWalletError::TransactionSigning(_))), + "precondition: the locked signer must fail at signing, got {locked:?}" + ); + + // The identical request, once the signer works again. + let payment = core + .build_signed_payment(vec![(recipient(3), 1_000_000)], None, &signer, None) + .await + .expect("the same request must succeed after the signer recovers"); + assert!( + !payment.transaction.input.is_empty(), + "the retry must select the inputs the failed build released" + ); + } + + /// A shortfall inside the SELECTED account surfaces as the typed + /// [`PlatformWalletError::PaymentInsufficientFunds`], with `available` + /// reflecting only that account — never a wallet-wide union total, which + /// would invite a retry that can only succeed by crossing domains. + #[tokio::test] + async fn selected_account_shortfall_is_typed() { + let (wm, wallet_id, signer) = split_funded_wallet_manager(9_000_000, 9_000_000).await; + let (_, _, coinjoin_path) = + split_account_outpoints_and_coinjoin_path(&wm, &wallet_id).await; + let core = core_wallet(wm, wallet_id, Arc::new(WalletBalance::new())); + + let result = core + .build_signed_payment( + vec![(recipient(7), 100_000_000)], + None, + &signer, + Some(coinjoin_path), + ) + .await; + + match result { + Err(PlatformWalletError::PaymentInsufficientFunds { + available, + required, + }) => { + assert_eq!( + available, 9_000_000, + "available must reflect only the named CoinJoin account" + ); + assert!( + required >= 100_000_000, + "required {required} should be at least the requested amount" + ); + } + other => panic!("expected PaymentInsufficientFunds, got {other:?}"), + } + } + + /// A `funding_path` that names no funds account is a hard error — never a + /// silent fallback to the default account, which would fund the payment + /// from coins the caller did not choose. + #[tokio::test] + async fn unknown_funding_path_is_rejected() { + use std::str::FromStr; + + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(wm, wallet_id, balance); + + let nowhere = DerivationPath::from_str("m/44'/5'/77'").expect("valid path"); + let result = core + .build_signed_payment( + vec![(recipient(7), 1_000_000)], + None, + &signer, + Some(nowhere), + ) + .await; + assert!( + matches!(result, Err(PlatformWalletError::TransactionBuild(_))), + "an unmatched funding path must fail, got {result:?}" + ); + } + + /// A watch-only `DashpayExternalAccount` (a contact's addresses, which this + /// wallet cannot sign) is EXCLUDED from coin selection: its UTXO is never + /// spent, and its value is not counted toward the selectable total. + #[tokio::test] + async fn watch_only_external_account_is_excluded() { + // BIP44 holds 0.1 DASH; a watch-only external account holds 1.0 DASH. + let (wm, wallet_id, _balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + + let watch_only_outpoint = OutPoint { + txid: Txid::from_byte_array([0x9au8; 32]), + vout: 0, + }; + { + let mut guard = wm.write().await; + let (wallet, info) = guard + .get_wallet_mut_and_info_mut(&wallet_id) + .expect("wallet present"); + + // Reuse the wallet's own BIP44 xpub as a stand-in "contact xpub": + // the exclusion happens before any address derivation, so any valid + // xpub suffices to construct the funds-bearing external account. + let contact_xpub = wallet + .accounts + .standard_bip44_accounts + .get(&0) + .expect("bip44 account 0") + .account_xpub; + let account_type = AccountType::DashpayExternalAccount { + index: 0, + user_identity_id: [1u8; 32], + friend_identity_id: [2u8; 32], + }; + let account = key_wallet::Account { + parent_wallet_id: Some(wallet_id), + account_type, + network: Network::Testnet, + account_xpub: contact_xpub, + is_watch_only: true, + }; + let mut managed = ManagedCoreFundsAccount::from_account(&account); + + // Insert a large spendable UTXO directly (arbitrary address — the + // account is skipped before its addresses are ever consulted). + let addr = recipient(200); + let utxo = Utxo { + outpoint: watch_only_outpoint, + txout: TxOut { + value: 100_000_000, + script_pubkey: addr.script_pubkey(), + }, + address: addr, + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + managed.utxos.insert(utxo.outpoint, utxo); + info.core_wallet + .accounts + .insert_funds_bearing_account(managed) + .expect("insert watch-only external account"); + } + + let core = core_wallet(wm, wallet_id, Arc::new(WalletBalance::new())); + + // Ask for 0.5 DASH: covered only if the 1.0-DASH watch-only UTXO were + // spendable. It is on a different domain from the default BIP44 funding + // path, so the default send can never reach it — the build must fail + // with the 0.1-DASH BIP44 slice as `available`. + let result = core + .build_signed_payment(vec![(recipient(7), 50_000_000)], None, &signer, None) + .await; + match result { + Err(PlatformWalletError::PaymentInsufficientFunds { available, .. }) => { + assert_eq!( + available, 10_000_000, + "watch-only value must be excluded from the selectable total" + ); + } + other => panic!("expected PaymentInsufficientFunds, got {other:?}"), + } + + // And a payment that the 0.1-DASH BIP44 slice CAN cover must never spend + // the watch-only outpoint. + let payment = core + .build_signed_payment(vec![(recipient(7), 1_000_000)], None, &signer, None) + .await + .expect("0.01 DASH is fundable from the BIP44 slice alone"); + assert!( + payment + .transaction + .input + .iter() + .all(|i| i.previous_output != watch_only_outpoint), + "the watch-only UTXO must never be selected as an input" + ); + assert_all_inputs_signed(&payment); + } + + /// The wallet's OWN per-generation balance handle. + /// + /// Every reservation release is generation-bound: it acts only if the + /// `CoreWallet`'s balance `Arc` is pointer-equal to the one registered under + /// the wallet id (`Arc::ptr_eq` in `release_reservation_for`). The split + /// fixtures don't hand their balance back, so a `CoreWallet` built with a + /// fresh `WalletBalance::new()` is — correctly — treated as a *different* + /// generation and every release is skipped. Tests that assert release + /// behaviour must therefore build on this handle, not a fresh one. + async fn wallet_generation( + wm: &Arc< + tokio::sync::RwLock< + key_wallet_manager::WalletManager< + crate::wallet::platform_wallet::PlatformWalletInfo, + >, + >, + >, + wallet_id: &WalletId, + ) -> Arc { + let guard = wm.read().await; + let (_, info) = guard + .get_wallet_and_info(wallet_id) + .expect("wallet present"); + Arc::clone(&info.balance) + } + + /// Snapshot a DashPay fixture's BIP44 outpoints, its DashPay + /// receiving-funds outpoints, and that receival account's account-level + /// derivation path — the `funding_path` a caller round-trips from the + /// account-balance enumeration to spend a receival balance. + async fn dashpay_outpoints_and_receival_path( + wm: &Arc< + tokio::sync::RwLock< + key_wallet_manager::WalletManager< + crate::wallet::platform_wallet::PlatformWalletInfo, + >, + >, + >, + wallet_id: &WalletId, + ) -> (HashSet, HashSet, DerivationPath) { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + + let guard = wm.read().await; + let (_, info) = guard + .get_wallet_and_info(wallet_id) + .expect("wallet present"); + let network = info.core_wallet.network(); + let bip44 = info + .core_wallet + .accounts + .standard_bip44_accounts + .get(&0) + .map(|a| a.utxos.keys().copied().collect()) + .unwrap_or_default(); + let receival_acc = info + .core_wallet + .accounts + .dashpay_receival_accounts + .values() + .next() + .expect("DashPay receiving-funds account present"); + let receival = receival_acc.utxos.keys().copied().collect(); + let path = receival_acc + .managed_account_type() + .to_account_type() + .derivation_path(network) + .expect("DashPay receiving-funds account-level path"); + (bip44, receival, path) + } + + /// **The DashPay receival-spend bridge.** A payment finalized from a DashPay + /// receiving-funds account must (a) select strictly within that account, + /// (b) sign every input, (c) route change to the BIP44 sink, and (d) come + /// back with the reservation bookkeeping a deferred broadcast needs — the + /// resolved funding account and key-wallet's owner token. + /// + /// Before this path existed the two halves were disconnected: + /// `finalize_transaction` mints reservation tokens but selects by + /// `AccountTypePreference`, which has NO variant for a receival account, so a + /// receival balance could be signed or tokened but never both. + #[tokio::test] + async fn receival_funding_path_selects_signs_and_reserves_in_that_account() { + // 0.09 DASH on BIP44 (cannot cover 0.15), 0.2 on the receival account. + let (wm, wallet_id, signer) = + split_funded_wallet_manager_dashpay(9_000_000, 20_000_000, DashpayLeg::ReceivingFunds) + .await; + let (bip44_ops, receival_ops, receival_path) = + dashpay_outpoints_and_receival_path(&wm, &wallet_id).await; + + let core = core_wallet(wm, wallet_id, Arc::new(WalletBalance::new())); + let payment = core + .finalize_signed_payment_from_funding_path( + vec![(recipient(7), 15_000_000)], + None, + &signer, + Some(receival_path.clone()), + ) + .await + .expect("the named DashPay receival account covers 0.15 DASH"); + + // (a) Single-account selection: only receival inputs, never BIP44's. + let spent: HashSet = payment + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + assert!(!spent.is_empty(), "the payment must have selected inputs"); + assert!( + spent.iter().all(|op| receival_ops.contains(op)), + "every input must come from the named receival account, spent {spent:?}" + ); + assert!( + !spent.iter().any(|op| bip44_ops.contains(op)), + "a receival-funded payment must not pull BIP44 inputs, spent {spent:?}" + ); + + // (b) Every input signed by the receival account's own derivation path. + for (i, txin) in payment.transaction.input.iter().enumerate() { + assert!( + !txin.script_sig.is_empty(), + "input {i} was left unsigned (empty scriptSig)" + ); + } + + // (c) Change lands on the BIP44 sink — key-wallet derives change only + // for Standard accounts, so this is structural, not a co-spend. + assert!( + payment.change_amount > 0, + "spending a 0.2 DASH UTXO for 0.15 DASH must leave change" + ); + assert!( + payment + .transaction + .output + .iter() + .any(|o| o.value == payment.change_amount), + "a change output equal to change_amount should exist" + ); + + // (d) The deferred bookkeeping: the RESOLVED receival path (never the + // caller's `None`, never a BIP44 default) and key-wallet's owner token. + match &payment.funding { + FundingAccountRef::Path(path) => assert_eq!( + *path, receival_path, + "the funding account must be recorded as the receival path it \ + actually selected from" + ), + other => panic!("expected a path-named funding account, got {other:?}"), + } + assert!( + payment.reservation_token.is_some(), + "a funded build must stamp a key-wallet reservation token so a later \ + release is owner-guarded" + ); + } + + /// The reservation a receival-funded payment takes must be recorded against + /// the RECEIVAL account — and releasing the registry token must give those + /// exact inputs back. + /// + /// This is the funds-critical half: if the registry recorded the funding + /// account as BIP44 (the only thing an `AccountTypePreference` could say + /// about a receival account), the release would free BIP44's reservation and + /// leave the receival coins locked until key-wallet's TTL — while an + /// unrelated BIP44 build lost its inputs. + #[tokio::test] + async fn receival_reservation_is_held_and_released_against_the_receival_account() { + use crate::wallet::signed_payment_registry::SignedPaymentRegistry; + + let (wm, wallet_id, signer) = + split_funded_wallet_manager_dashpay(9_000_000, 20_000_000, DashpayLeg::ReceivingFunds) + .await; + let (_, _, receival_path) = dashpay_outpoints_and_receival_path(&wm, &wallet_id).await; + // The wallet's OWN generation handle — releases are generation-bound and + // are (correctly) skipped for a foreign one. See [`wallet_generation`]. + let generation = wallet_generation(&wm, &wallet_id).await; + let core = core_wallet(wm, wallet_id, generation); + + let payment = core + .finalize_signed_payment_from_funding_path( + vec![(recipient(7), 15_000_000)], + None, + &signer, + Some(receival_path.clone()), + ) + .await + .expect("first receival build succeeds"); + + let registry: SignedPaymentRegistry = + SignedPaymentRegistry::new(); + let token = registry + .register_funded_by( + core.clone(), + payment.transaction.clone(), + payment.funding.clone(), + Some(payment.reservation_height), + payment.reservation_token, + ) + .await; + assert_eq!(registry.outstanding(), 1, "the token must be registered"); + + // The reservation is HELD: the receival account has a single UTXO, so a + // second build from it cannot re-select the reserved input. + let blocked = core + .finalize_signed_payment_from_funding_path( + vec![(recipient(8), 15_000_000)], + None, + &signer, + Some(receival_path.clone()), + ) + .await; + assert!( + matches!( + blocked, + Err(PlatformWalletError::PaymentInsufficientFunds { .. }) + ), + "the reserved receival input must not be re-selectable while the \ + token is outstanding, got {blocked:?}" + ); + + // Releasing the token returns those exact inputs to the receival + // account's own selectable pool. + registry.release(token).await; + assert_eq!(registry.outstanding(), 0, "release must drop the token"); + let after_release = core + .finalize_signed_payment_from_funding_path( + vec![(recipient(9), 15_000_000)], + None, + &signer, + Some(receival_path.clone()), + ) + .await; + assert!( + after_release.is_ok(), + "releasing the token must return the receival inputs to spendable, \ + got {after_release:?}" + ); + + // And the same reconciliation runs on a definitively rejected broadcast: + // register the rebuilt payment, broadcast it through the always-rejecting + // broadcaster, and confirm the reservation was released for an immediate + // rebuild rather than stranded until the TTL backstop. + let rebuilt = after_release.expect("rebuilt payment"); + let token = registry + .register_funded_by( + core.clone(), + rebuilt.transaction.clone(), + rebuilt.funding.clone(), + Some(rebuilt.reservation_height), + rebuilt.reservation_token, + ) + .await; + let broadcast = registry.broadcast(token, &core).await; + assert!( + broadcast.is_err(), + "the always-rejecting broadcaster must surface a failure" + ); + assert_eq!( + registry.outstanding(), + 0, + "a consumed token must not remain registered" + ); + assert!( + core.finalize_signed_payment_from_funding_path( + vec![(recipient(10), 15_000_000)], + None, + &signer, + Some(receival_path), + ) + .await + .is_ok(), + "a definitively rejected broadcast must release the receival \ + reservation for an immediate rebuild" + ); + } + + /// `abandon_payment` is the other way to discharge a finalized payment's + /// reservation — the one a host takes when marshalling fails between the + /// build and a successful `register_funded_by` (the FFI's `CString::new` + /// arm). It must give the receival account's inputs back without the + /// registry ever being involved. + /// + /// It also pins the linear contract: `abandon_payment` consumes the + /// payment, so registering it afterwards cannot compile + /// (dashpay/platform#4256 review). + #[tokio::test] + async fn abandon_payment_releases_the_reservation_without_the_registry() { + let (wm, wallet_id, signer) = + split_funded_wallet_manager_dashpay(9_000_000, 20_000_000, DashpayLeg::ReceivingFunds) + .await; + let (_, _, receival_path) = dashpay_outpoints_and_receival_path(&wm, &wallet_id).await; + let generation = wallet_generation(&wm, &wallet_id).await; + let core = core_wallet(wm, wallet_id, generation); + + let payment = core + .finalize_signed_payment_from_funding_path( + vec![(recipient(7), 15_000_000)], + None, + &signer, + Some(receival_path.clone()), + ) + .await + .expect("first receival build succeeds"); + assert!( + payment.reservation_token.is_some(), + "a funded build must stamp an owner token" + ); + + // Held: the receival account's only UTXO is reserved. + let blocked = core + .finalize_signed_payment_from_funding_path( + vec![(recipient(8), 15_000_000)], + None, + &signer, + Some(receival_path.clone()), + ) + .await; + assert!( + matches!( + blocked, + Err(PlatformWalletError::PaymentInsufficientFunds { .. }) + ), + "the reserved receival input must not be re-selectable, got {blocked:?}" + ); + + // Abandon consumes the payment and releases by RESOLVED path, so the + // inputs return to the receival account — not to BIP44. + core.abandon_payment(payment).await; + + assert!( + core.finalize_signed_payment_from_funding_path( + vec![(recipient(9), 15_000_000)], + None, + &signer, + Some(receival_path), + ) + .await + .is_ok(), + "abandon_payment must return the receival inputs to spendable" + ); + } + + /// A recipient list over the standard size limit is refused on the + /// FINALIZE path before anything is reserved. + /// + /// This is the review's overflow case: ~25.8k recipients at the maximum + /// accepted fee rate drove key-wallet's estimated size past the point where + /// its unchecked `sat_per_kb * size_bytes` product wraps. The funds-critical + /// part is the second half — the refusal must not leave the account's coins + /// reserved, or an oversized request would strand a real balance until the + /// TTL backstop. + #[tokio::test] + async fn an_oversized_finalize_is_refused_and_reserves_nothing() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(wm, wallet_id, balance); + + let huge: Vec<_> = (0..25_835) + .map(|i| (recipient((i % 250) as u8), 1_000u64)) + .collect(); + let result = core + .finalize_signed_payment_from_funding_path( + huge, + Some(super::MAX_FEE_PER_KB), + &signer, + None, + ) + .await; + match result { + Err(PlatformWalletError::TransactionBuild(m)) => assert!( + m.contains("standard"), + "the rejection must cite the standard size limit, got {m:?}" + ), + other => panic!("25,835 recipients must be refused, got {other:?}"), + } + + // Nothing was reserved: an ordinary payment still funds immediately. + let ok = core + .finalize_signed_payment_from_funding_path( + vec![(recipient(7), 1_000_000)], + None, + &signer, + None, + ) + .await + .expect("a refused oversized request must not reserve any input"); + assert!(ok.reservation_token.is_some()); + core.abandon_payment(ok).await; + } + + /// The post-build size check: a recipient list that PASSES the output-side + /// bound can still produce an over-limit transaction once coin selection + /// adds enough inputs. 900 recipients (~30.6 kB of outputs) funded by 500 + /// small UTXOs pulls ~496 inputs (~73.4 kB), landing just over 100 kB. + /// + /// Two things are asserted, both funds-critical: + /// + /// * the build is refused rather than returning bytes that cannot relay; + /// * the refusal RELEASES the reservation it had already taken. This is the + /// only path that abandons in-function, so it is also the proof that + /// dropping the manager guard before `abandon_payment` is required — + /// tokio's `RwLock` is not reentrant, and abandoning under the write guard + /// would deadlock here rather than fail. + #[tokio::test] + async fn an_over_limit_signed_transaction_is_refused_and_releases_its_reservation() { + let (wm, wallet_id, balance, signer) = + crate::test_support::funded_wallet_manager_with_outputs( + StandardAccountType::BIP44Account, + &[1_000_000; 500], + ) + .await; + let core = core_wallet(wm, wallet_id, balance); + + let many: Vec<_> = (0..900) + .map(|i| (recipient((i % 250) as u8), 550_000u64)) + .collect(); + let result = core + .finalize_signed_payment_from_funding_path(many, None, &signer, None) + .await; + + match result { + Err(PlatformWalletError::TransactionBuild(m)) => assert!( + m.contains("standard transaction limit"), + "the refusal must cite the standard size limit, got {m:?}" + ), + other => panic!( + "a signed transaction over the standard size limit must be \ + refused, got {other:?}" + ), + } + + // The reservation was discharged, so the account's UTXOs are selectable + // again immediately — not stranded until the TTL backstop. + let after = core + .finalize_signed_payment_from_funding_path( + vec![(recipient(7), 1_000_000)], + None, + &signer, + None, + ) + .await + .expect("the refused over-limit build must have released its inputs"); + core.abandon_payment(after).await; + } + + /// The DEFAULT (`funding_path: None`) build must record and release its + /// reservation against the unmixed BIP44 account too. + /// + /// `None` resolves to BIP44's own account-level path, so the release goes + /// through the by-path lookup rather than the `AccountTypePreference` one — + /// a different code path from every pre-existing release test, and the one + /// the Kotlin default (`fundingPath = null`) will take on every ordinary + /// send. If the two lookups disagreed about which account BIP44's path + /// names, an ordinary send's reservation would never be released. + #[tokio::test] + async fn default_funding_reservation_is_held_and_released_on_bip44() { + use crate::wallet::signed_payment_registry::SignedPaymentRegistry; + + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(wm, wallet_id, balance); + + let payment = core + .finalize_signed_payment_from_funding_path( + vec![(recipient(7), 1_000_000)], + None, + &signer, + None, + ) + .await + .expect("0.01 DASH is fundable from the 0.1 DASH BIP44 account"); + assert!( + matches!(payment.funding, FundingAccountRef::Path(_)), + "the default build must record the RESOLVED BIP44 path, got {:?}", + payment.funding + ); + + let registry: SignedPaymentRegistry = + SignedPaymentRegistry::new(); + let token = registry + .register_funded_by( + core.clone(), + payment.transaction.clone(), + payment.funding.clone(), + Some(payment.reservation_height), + payment.reservation_token, + ) + .await; + + // Held: the fixture's single BIP44 UTXO is reserved. + assert!( + core.finalize_signed_payment_from_funding_path( + vec![(recipient(8), 1_000_000)], + None, + &signer, + None, + ) + .await + .is_err(), + "the reserved BIP44 input must not be re-selectable while the token \ + is outstanding" + ); + + // Released: the by-path lookup found the same BIP44 account the + // selector funded from. + registry.release(token).await; + assert!( + core.finalize_signed_payment_from_funding_path( + vec![(recipient(9), 1_000_000)], + None, + &signer, + None, + ) + .await + .is_ok(), + "releasing the token must return the BIP44 input to spendable" + ); + } + + /// Input validation: empty outputs and zero-amount outputs are rejected + /// before any wallet work. + #[tokio::test] + async fn rejects_empty_and_zero_outputs() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(wm, wallet_id, balance); + + let empty = core.build_signed_payment(vec![], None, &signer, None).await; + assert!(matches!( + empty, + Err(PlatformWalletError::TransactionBuild(_)) + )); + + let zero = core + .build_signed_payment(vec![(recipient(7), 0)], None, &signer, None) + .await; + assert!(matches!( + zero, + Err(PlatformWalletError::TransactionBuild(_)) + )); + } + + /// A positive-but-below-dust recipient must be refused. `add_output` applies + /// no relay policy, so before this check the primitive happily returned + /// fully signed bytes for a transaction every standard node rejects as + /// nonstandard (dashpay/platform#4247 review). 546 duffs is the P2PKH + /// threshold `Script::dust_value()` computes. + #[tokio::test] + async fn below_dust_outputs_are_rejected() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(wm, wallet_id, balance); + + let to = recipient(42); + let dust = to.script_pubkey().dust_value().to_sat(); + assert_eq!(dust, 546, "P2PKH dust threshold"); + + for amount in [1u64, dust - 1] { + let result = core + .build_signed_payment(vec![(to.clone(), amount)], None, &signer, None) + .await; + match result { + Err(PlatformWalletError::TransactionBuild(m)) => assert!( + m.contains("dust"), + "the rejection must name dust as the cause, got {m:?}" + ), + other => panic!("{amount} duffs is below dust and must be refused, got {other:?}"), + } + } + + // A dust-sized output hidden among valid ones is caught too — the check + // is per output, not just on the first. + let mixed = core + .build_signed_payment( + vec![ + (recipient(1), 1_000_000), + (recipient(2), 5), + (recipient(3), 1_000_000), + ], + None, + &signer, + None, + ) + .await; + assert!( + matches!(mixed, Err(PlatformWalletError::TransactionBuild(ref m)) if m.contains("dust")), + "a below-dust output among valid ones must still be refused, got {mixed:?}" + ); + + // Exactly at the threshold is valid and still builds. + let at_threshold = core + .build_signed_payment(vec![(to, dust)], None, &signer, None) + .await + .expect("an output exactly at the dust threshold is standard"); + assert_all_inputs_signed(&at_threshold); + } + + /// Rejecting a below-dust request must not cost the caller anything: it + /// happens before the wallet lock, so no input is reserved and the very + /// next legitimate build still finds the account's coins selectable. + #[tokio::test] + async fn a_rejected_dust_request_reserves_nothing() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(wm, wallet_id, balance); + + for _ in 0..3 { + assert!(core + .build_signed_payment(vec![(recipient(9), 100)], None, &signer, None) + .await + .is_err()); + } + + let payment = core + .build_signed_payment(vec![(recipient(9), 1_000_000)], None, &signer, None) + .await + .expect("refused dust requests must not have reserved the account's UTXOs"); + assert_all_inputs_signed(&payment); + } + + /// The output total is aggregated with checked arithmetic and bounded by + /// `MAX_MONEY`. Four outputs of `1 << 62` sum to exactly 2^64: unchecked, + /// that wraps to zero in release builds and lets selection fund only the + /// fee while retaining four enormous outputs — a signed transaction + /// consensus rejects, with meaningless fee/change metadata. + #[tokio::test] + async fn output_total_overflow_and_max_money_are_rejected() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(wm, wallet_id, balance); + + let wrapping = vec![ + (recipient(1), 1u64 << 62), + (recipient(2), 1u64 << 62), + (recipient(3), 1u64 << 62), + (recipient(4), 1u64 << 62), + ]; + match core + .build_signed_payment(wrapping, None, &signer, None) + .await + { + Err(PlatformWalletError::TransactionBuild(m)) => assert!( + m.contains("MAX_MONEY"), + "a wrapping total must be refused as a monetary-bound breach, got {m:?}" + ), + other => panic!("4 × (1 << 62) wraps to zero and must be refused, got {other:?}"), + } + + // A single in-range-but-over-MAX_MONEY amount is refused as well. + let over = core + .build_signed_payment( + vec![(recipient(1), super::MAX_MONEY + 1)], + None, + &signer, + None, + ) + .await; + assert!( + matches!(over, Err(PlatformWalletError::TransactionBuild(ref m)) if m.contains("MAX_MONEY")), + "an amount over MAX_MONEY must be refused, got {over:?}" + ); + + // MAX_MONEY itself is within bounds, so it passes validation and fails + // later on funds — proving the bound is inclusive, not off by one. + let at_max = core + .build_signed_payment(vec![(recipient(1), super::MAX_MONEY)], None, &signer, None) + .await; + assert!( + matches!( + at_max, + Err(PlatformWalletError::PaymentInsufficientFunds { .. }) + ), + "MAX_MONEY exactly must pass the bound and fail on funds, got {at_max:?}" + ); + } + + /// The fee rate is bounded before it reaches key-wallet, whose + /// `calculate_fee` multiplies `sat_per_kb * size_bytes` unchecked — a rate + /// near `u64::MAX` (the Kotlin/FFI APIs accept any non-negative `Long`) + /// panics in an overflow-checking build or wraps in release. + #[tokio::test] + async fn excessive_fee_rates_are_rejected() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(wm, wallet_id, balance); + + for rate in [u64::MAX, u64::MAX / 2, super::MAX_FEE_PER_KB + 1] { + let result = core + .build_signed_payment(vec![(recipient(7), 1_000_000)], Some(rate), &signer, None) + .await; + match result { + Err(PlatformWalletError::TransactionBuild(m)) => assert!( + m.contains("fee rate"), + "the rejection must name the fee rate, got {m:?}" + ), + other => panic!("fee rate {rate} must be refused, got {other:?}"), + } + } + + // A sane rate still works, so the bound isn't rejecting real traffic. + let ok = core + .build_signed_payment(vec![(recipient(7), 1_000_000)], Some(5_000), &signer, None) + .await + .expect("5000 duffs/kB is an ordinary rate"); + assert!(ok.fee > 0); + } + + /// The fee-rate bound must make key-wallet's unchecked + /// `sat_per_kb * size_bytes` product unrepresentable-free for ANY + /// transaction size a `u32` can express — which is the point of deriving it + /// from `u32::MAX` rather than from the standard size limit. A cleanup that + /// loosened it back to `MAX_MONEY / 100` would overflow at ~878 kB, which a + /// funding account with a few thousand small denominations can reach. + #[test] + fn max_fee_rate_cannot_overflow_key_wallets_fee_product() { + for size in [super::MAX_STANDARD_TX_SIZE as u64, 878_434, u32::MAX as u64] { + assert!( + super::MAX_FEE_PER_KB.checked_mul(size).is_some(), + "MAX_FEE_PER_KB * {size} must not overflow u64" + ); + } + // And it stays permissive enough to be irrelevant in practice. + assert!( + super::MAX_FEE_PER_KB > 1_000_000, + "the bound must sit far above any legitimate duffs/kB rate" + ); + } + + /// An oversized recipient list is refused before any wallet work. ~25.8k + /// recipients fit in a practical JNI blob and would drive key-wallet's + /// estimated size past the point where the fee product overflows, as well + /// as producing a transaction far too large to relay. + #[tokio::test] + async fn oversized_recipient_lists_are_rejected() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(wm, wallet_id, balance); + + // Smallest count whose outputs alone leave no room for a single input + // within the 100 kB standard limit. + let over = (super::MAX_STANDARD_TX_SIZE - super::TX_INPUT_SIZE) / super::TX_OUTPUT_SIZE; + let outputs: Vec<_> = (0..over) + .map(|i| (recipient((i % 250) as u8), 1_000u64)) + .collect(); + let result = core + .build_signed_payment(outputs, None, &signer, None) + .await; + match result { + Err(PlatformWalletError::TransactionBuild(m)) => assert!( + m.contains("standard") && m.contains("recipients"), + "the rejection must cite the standard size limit, got {m:?}" + ), + other => panic!("{over} recipients must be refused, got {other:?}"), + } + + // The 25.8k figure from the review is refused by the same bound. + let huge: Vec<_> = (0..25_835) + .map(|i| (recipient((i % 250) as u8), 1_000u64)) + .collect(); + assert!( + matches!( + core.build_signed_payment(huge, Some(super::MAX_FEE_PER_KB), &signer, None) + .await, + Err(PlatformWalletError::TransactionBuild(_)) + ), + "the review's 25,835-recipient overflow case must be refused" + ); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 81b2e8a824..58ad5a3bdb 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; @@ -16,7 +17,7 @@ use key_wallet::wallet::managed_wallet_info::transaction_builder::{ }; use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; -use key_wallet::{Account, DerivationPath, Utxo}; +use key_wallet::{Account, DerivationPath, ReservationToken, Utxo}; use super::CoreWallet; use crate::broadcaster::TransactionBroadcaster; @@ -50,6 +51,50 @@ fn map_builder_error( PlatformWalletError::TransactionBuild(error.to_string()) } +/// Identifies the **one** funds account whose key-wallet `ReservationSet` holds +/// a build's selected inputs, so a later release — or a definitively rejected +/// broadcast — can find that exact account again. +/// +/// Two shapes, because this crate has two selectors that name accounts +/// differently: +/// +/// * [`Standard`](Self::Standard) — [`CoreWallet::finalize_transaction`]'s +/// selector, which reaches an account by key-wallet's +/// [`AccountTypePreference`] variant + index. It can only ever name +/// BIP44 / BIP32 / DIP-9 CoinJoin. +/// * [`Path`](Self::Path) — [`CoreWallet::build_signed_payment`]'s selector, +/// which reaches the one funds account whose **account-level derivation +/// path** equals the caller-named path. This is the only form that can name a +/// DashPay receiving-funds account: `AccountTypePreference` has no variant for +/// it, which is exactly why the deferred (tokened) send path could not spend a +/// DashPay receival balance before this type existed. +/// +/// Both arms name exactly ONE account. This type cannot express a union, so it +/// cannot weaken the funding-domain invariant — see +/// [`crate::wallet::funding_privacy`]. +#[derive(Debug, Clone)] +pub enum FundingAccountRef { + /// A key-wallet standard-shaped account: BIP44 / BIP32 / CoinJoin at + /// `account_index`. + Standard { + account_type: AccountTypePreference, + account_index: u32, + }, + /// The single funds account whose account-level derivation path equals this + /// one — the only form that can name a DashPay receiving-funds account. + Path(DerivationPath), +} + +impl FundingAccountRef { + /// The standard-shaped reference for `account_type` at `account_index`. + pub fn standard(account_type: AccountTypePreference, account_index: u32) -> Self { + Self::Standard { + account_type, + account_index, + } + } +} + /// A signed Core transaction whose selected inputs remain reserved until it is /// broadcast, explicitly abandoned, observed by sync, or reclaimed by the /// reservation TTL. @@ -59,6 +104,26 @@ 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, + /// The key-wallet [`ReservationToken`] stamped onto the selected inputs when + /// `build_unsigned_reserved` reserved them, or `None` when the build took no + /// reservation (no reservation set attached — not reached on the funded + /// finalize path). Held so an abandoned or definitively-rejected send + /// releases the reservation *owner-guarded*: after this build's inputs may + /// have been swept by key-wallet's TTL and re-reserved by a concurrent build + /// under a new token, releasing by outpoint alone would free that other + /// build's inputs (the `dashpay/platform#4185` double-spend window). + /// [`ManagedCoreFundsAccount::release_reservation_if_owner`] releases only + /// inputs still owned by this token, closing that window. + reservation_token: Option, } impl SignedCoreTransaction { @@ -77,6 +142,23 @@ 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 + } + + /// The key-wallet [`ReservationToken`] the funding inputs were reserved + /// under (`None` if the build reserved nothing). The broadcast/abandon + /// release paths present it to + /// [`ManagedCoreFundsAccount::release_reservation_if_owner`] so a rejected + /// or abandoned send frees only reservations this build still owns. + pub fn reservation_token(&self) -> Option { + self.reservation_token + } } fn account( @@ -125,7 +207,7 @@ impl CoreWallet { account_index: u32, signer: &S, ) -> Result { - let (unsigned, fee, selected, paths) = { + let (unsigned, fee, selected, paths, height, reservation_token) = { let mut manager = self.wallet_manager.write().await; let (wallet, info) = manager .get_wallet_and_info_mut(&self.wallet_id) @@ -147,13 +229,18 @@ impl CoreWallet { )) })?; - // `set_funding` observes ReservationSet and `build_unsigned` - // records its selection. There is no await between them and the - // manager write guard prevents another finalizer interleaving. - let (unsigned, fee) = builder + // `set_funding` observes ReservationSet and `build_unsigned_reserved` + // records its selection AND returns the token stamped onto the + // reserved inputs. There is no await between them and the manager + // write guard prevents another finalizer interleaving. The token + // rides in `SignedCoreTransaction` so a later abandon or rejected + // broadcast releases *only* the inputs this build still owns, even + // if a TTL sweep re-reserved them under a new token meanwhile + // (`dashpay/platform#4185`). + let (unsigned, fee, reservation_token) = builder .set_current_height(height) .set_funding(managed, &account) - .build_unsigned() + .build_unsigned_reserved() .map_err(|error| map_builder_error(error, account_type, account_index))?; let selected: Vec = match unsigned @@ -201,7 +288,7 @@ impl CoreWallet { } }; - (unsigned, fee, selected, paths) + (unsigned, fee, selected, paths, height, reservation_token) }; let signed = match signer @@ -212,8 +299,18 @@ impl CoreWallet { { Ok(signed) => signed, Err(error) => { - self.release_transaction_reservation(account_type, account_index, &unsigned) - .await; + // Signing awaited an (external) signer with the manager lock + // dropped, so key-wallet's TTL sweep could have reclaimed this + // build's reservation and a concurrent build re-taken the same + // inputs under a new token. Release owner-guarded so we free + // only what this build still owns. + self.release_transaction_reservation( + account_type, + account_index, + &unsigned, + reservation_token, + ) + .await; return Err(PlatformWalletError::TransactionBuild(error.to_string())); } }; @@ -223,6 +320,8 @@ impl CoreWallet { fee, funding_account_type: account_type, funding_account_index: account_index, + reservation_height: height, + reservation_token, }) } @@ -232,29 +331,134 @@ impl CoreWallet { transaction.funding_account_type, transaction.funding_account_index, &transaction.transaction, + transaction.reservation_token, ) .await; } + /// Release the funding reservation `transaction` holds, bound to this + /// handle's own wallet *generation*. + /// + /// `token` is the [`ReservationToken`] the build stamped onto the inputs + /// (`SignedCoreTransaction::reservation_token`). When present the release is + /// *owner-guarded* — it frees only inputs still owned by that token, so a + /// reservation key-wallet's TTL swept and a concurrent build re-took is left + /// untouched (`dashpay/platform#4185`). When `None` (the build reserved + /// nothing) it falls back to the unconditional by-outpoint release; that + /// path is never reached for a funded finalize, which always reserves. pub(crate) async fn release_transaction_reservation( &self, account_type: AccountTypePreference, account_index: u32, transaction: &Transaction, + token: Option, ) { + self.release_reservation_for( + &FundingAccountRef::standard(account_type, account_index), + transaction, + token, + ) + .await + } + + /// The [`FundingAccountRef`]-general form of + /// [`release_transaction_reservation`](Self::release_transaction_reservation). + /// + /// Identical semantics — generation-bound, owner-guarded — but the funding + /// account may also be named by its account-level derivation path + /// ([`FundingAccountRef::Path`]), which is the only way to reach a DashPay + /// receiving-funds account. The path arm resolves the account with the SAME + /// `AccountType::derivation_path(network)` comparison + /// [`CoreWallet::build_signed_payment`] used to select it, so the release + /// lands in the very `ReservationSet` the build reserved into — never BIP44 + /// by default. + pub(crate) async fn release_reservation_for( + &self, + funding: &FundingAccountRef, + transaction: &Transaction, + token: Option, + ) { + // 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" + ?funding, + "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), + ?funding, + "skipping reservation release: wallet was re-created under the same id \ + (different generation) since the token was minted" + ); + return; + } + // Resolve the ONE account the build reserved into. The path arm repeats + // `build_signed_payment`'s own account-level-path comparison so the + // release cannot drift to a different account than the one that holds + // the inputs. + // + // PRIVACY-DOMAIN-OK: this iterates funds accounts only to LOOK ONE UP by + // derivation path, for a reservation release. It selects no coins and + // accumulates nothing across accounts. + let resolved = match funding { + FundingAccountRef::Standard { + account_type, + account_index, + } => managed_account(&info.core_wallet.accounts, *account_type, *account_index), + FundingAccountRef::Path(path) => { + let network = info.core_wallet.network(); + info.core_wallet + .accounts + .all_funding_accounts() + .into_iter() + .find(|acc| { + acc.managed_account_type() + .to_account_type() + .derivation_path(network) + .map(|p| p == *path) + .unwrap_or(false) + }) + } + }; + match resolved { + // Owner-guarded when the build stamped a token: even within this + // generation, a TTL sweep between build and release could have + // re-reserved the same outpoints under a new token, and an + // unconditional release would free that newer reservation. With the + // token key-wallet frees only inputs this build still owns. `None` + // (no reservation taken) falls back to the unconditional release. + Some(managed) => match token { + Some(token) => managed.release_reservation_if_owner(transaction, token), + None => managed.release_reservation(transaction), + }, + None => tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + ?funding, + "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..1a9f7ccade 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), `dashpay/platform#4185`) + /// and the V2 finalized-transaction handle path (`dashpay/platform#4196`) — + /// so neither acts on a re-created wallet's `ReservationSet` while an old + /// handle still names the old generation. + pub fn is_same_generation( + &self, + other: &CoreWallet, + ) -> 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/funding_privacy.rs b/packages/rs-platform-wallet/src/wallet/funding_privacy.rs new file mode 100644 index 0000000000..b9b2653928 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/funding_privacy.rs @@ -0,0 +1,366 @@ +//! **Funding-domain isolation** — the privacy invariant every L1 spend path in +//! this crate must satisfy, plus the automated guardrail that enforces it. +//! +//! # The invariant +//! +//! > A single L1 transaction must draw its funding inputs from **exactly one** +//! > funds account (one derivation domain). No spend path may union across +//! > funding accounts, and never implicitly. +//! +//! A wallet's funds accounts are separate *privacy domains*: ordinary BIP44 / +//! BIP32 coins, DIP-9 **CoinJoin** (previously-mixed) coins, and +//! **DashPay-receiving** coins each carry a different linkability story. Any +//! transaction that spends inputs from two of them publishes, irreversibly and +//! on chain, that the same entity controls both — and shielding those coins +//! afterwards cannot undo the link, because the link is already in the L1 +//! transaction graph. +//! +//! # History — why this module exists +//! +//! * **dashpay/platform#4073** — shielding failed on wallets whose balance sat +//! on the DIP-9 CoinJoin path, because asset-lock coin selection only ever +//! reached BIP44 account 0. +//! * The **first** fix unioned every funding account and ran `LargestFirst` +//! over the combined candidate set. Reviewer `shumkov` **blocked** it on +//! dashpay/platform#4184 (2026-07-21): largest-first over a union can combine +//! ordinary, CoinJoin, and DashPay-receiving coins into one transaction with +//! BIP44 change, irreversibly linking the domains. +//! * **dashpay/platform#4184** (commit `4d3e1322bc`, signed off 2026-07-23) is +//! the approved resolution: a single optional derivation-path parameter that +//! names the ONE funding account, **defaulting to the non-mixed BIP44 +//! account**. No union, and no consent gate — because the caller names +//! exactly one funding source, there is nothing to consent to. +//! +//! The regression this module guards against is real and already happened once: +//! `CoreWallet::build_signed_payment` (dashpay/platform#4247) was written +//! against the pre-re-scope design and shipped the blocked union behavior into +//! the general send path. +//! +//! # How to comply +//! +//! A spend path takes `funding_path: Option`, resolves `None` +//! to the unmixed BIP44 account's account-level path, locates the **one** +//! managed funds account whose account-level path equals it, and points the +//! key-wallet `TransactionBuilder`'s `set_funding` at that account alone. If +//! that account cannot cover the spend, the build **fails** with a typed +//! shortfall — it must never top up from a second account. See +//! [`CoreWallet::build_signed_payment`](crate::wallet::core::CoreWallet::build_signed_payment) +//! for the reference implementation on this branch. +//! +//! Change is the one structural exception, and it is not a co-spend: key-wallet +//! derives change addresses only for *Standard* (BIP44/BIP32) accounts, so a +//! transaction funded from a non-Standard account (CoinJoin / DashPay +//! receiving) must route its change to the BIP44 account. That is inherent to +//! spending those coins at all, happens only when the caller explicitly named +//! the non-default account, and is the behavior #4184 approved. +//! +//! # The guardrail +//! +//! `all_funding_accounts()` / `all_funding_accounts_mut()` live in the pinned +//! `key-wallet` fork, so the invariant cannot be documented at their +//! definition. Instead, [`guardrail::every_union_iteration_is_privacy_reviewed`] +//! scans this crate's own sources and fails if any use of those iterators is +//! not preceded by an explicit `PRIVACY-DOMAIN-OK:` review marker, and +//! [`guardrail::no_spend_entry_point_unions_by_default`] asserts behaviorally +//! that the send path does not cross domains. See the `guardrail` module docs +//! for why the pair is sufficient. + +use key_wallet::ManagedAccountType; + +/// Whether a funds account can be *signed for* by the local mnemonic, and may +/// therefore fund a spend at all. +/// +/// Only `DashpayExternalAccount`s are watch-only: they hold a **contact's** +/// receiving addresses (we keep the contact's xpub to build payments *to* them +/// and to watch that side), so no private key of ours derives their UTXOs and +/// signing them would fail. Every other funds account +/// (BIP44 / BIP32 / CoinJoin / DashPay-receiving) comes from our own seed. +/// +/// This is a **signability** filter, not a privacy filter — it is orthogonal to +/// the module-level funding-domain invariant, and passing it does NOT make an +/// account eligible to be unioned with another. A watch-only account must be +/// refused even when a caller names its derivation path explicitly. +pub(crate) fn is_signable_funding_account(managed_type: &ManagedAccountType) -> bool { + !matches!( + managed_type, + ManagedAccountType::DashpayExternalAccount { .. } + ) +} + +#[cfg(test)] +mod guardrail { + //! Automated enforcement of the funding-domain invariant. + //! + //! Two complementary tests, because either alone has a blind spot: + //! + //! * [`no_spend_entry_point_unions_by_default`] is **behavioral**. It + //! proves, over a wallet whose balance is split across two domains, that + //! the general send entry point cannot fund a transaction neither domain + //! covers alone. This catches a semantic regression in an *existing* + //! entry point even if it is written without ever naming + //! `all_funding_accounts` (e.g. by iterating the account maps directly). + //! Its blind spot: it only knows about the entry points listed in it, so + //! a *newly added* spend path is invisible to it. + //! + //! On this branch (the isolated `send-raw-tx` feature line) the only + //! funding-domain-sensitive spend entry point is + //! [`CoreWallet::build_signed_payment`]: the asset-lock builder here is + //! still the pre-#4184 per-`account_index` model and does not accept a + //! `funding_path`, so it is out of scope for this behavioral test. + //! + //! * [`every_union_iteration_is_privacy_reviewed`] is **static**, and + //! covers exactly that blind spot. `all_funding_accounts()` / + //! `all_funding_accounts_mut()` are the only wallet-wide funds-account + //! iterators key-wallet exposes, so a new unparameterized union spend + //! path essentially has to call one of them. This test fails unless each + //! such call is preceded by an explicit `PRIVACY-DOMAIN-OK:` marker + //! comment, which forces the author to state why the call does not + //! union — and makes the marker show up in the review diff, which is how + //! the #4247 regression should have been caught. + //! + //! Scope is this crate's `src/` on purpose: coin selection happens only + //! here. The FFI / JNI layers above merely forward a `funding_path` and + //! cannot select coins themselves. + + use std::collections::HashSet; + use std::path::{Path, PathBuf}; + use std::sync::Arc; + + use dashcore::{Address as DashAddress, Network, OutPoint}; + + use crate::test_support::{split_funded_wallet_manager, AlwaysRejectedBroadcaster}; + use crate::wallet::core::balance::WalletBalance; + use crate::wallet::core::CoreWallet; + use crate::PlatformWalletError; + + // -- static guard -------------------------------------------------------- + + /// The wallet-wide funds-account iterators. Any use of these in a spend + /// path is a potential cross-domain union. + const UNION_ITERATORS: [&str; 2] = ["all_funding_accounts(", "all_funding_accounts_mut("]; + + /// The marker a call site must carry to certify it was reviewed against the + /// funding-domain invariant. + const REVIEW_MARKER: &str = "PRIVACY-DOMAIN-OK"; + + /// How many lines above a call site the marker may sit. Wide enough for the + /// explanatory comment block a legitimate use needs, narrow enough that one + /// marker cannot silently cover an unrelated call added later. + const MARKER_LOOKBACK_LINES: usize = 15; + + /// Collect every `.rs` file under `dir`, recursively. + fn rust_sources(dir: &Path, out: &mut Vec) { + let entries = std::fs::read_dir(dir) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", dir.display())); + for entry in entries { + let path = entry.expect("readable dir entry").path(); + if path.is_dir() { + rust_sources(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } + } + + /// Every use of key-wallet's wallet-wide funds-account iterators must be + /// explicitly privacy-reviewed. + /// + /// Fails with the offending `file:line` and the invariant restated, so an + /// author who reintroduces an unparameterized `all_funding_accounts()` + /// spend path (the dashpay/platform#4247 regression) is told exactly what + /// rule they tripped and where the contract lives. + /// + /// Comment lines are ignored (prose may name the iterators freely), as is + /// this file — it names them in the constants above. + #[test] + fn every_union_iteration_is_privacy_reviewed() { + let src = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = Vec::new(); + rust_sources(&src, &mut files); + assert!( + !files.is_empty(), + "found no Rust sources under {} — the guardrail would silently pass", + src.display() + ); + + let this_file = Path::new(file!()) + .file_name() + .expect("this file has a name") + .to_owned(); + + let mut unreviewed = Vec::new(); + for file in &files { + if file.file_name() == Some(this_file.as_os_str()) { + continue; + } + let text = std::fs::read_to_string(file) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", file.display())); + let lines: Vec<&str> = text.lines().collect(); + for (i, line) in lines.iter().enumerate() { + // Prose is free to discuss the iterators. + if line.trim_start().starts_with("//") { + continue; + } + if !UNION_ITERATORS.iter().any(|needle| line.contains(needle)) { + continue; + } + let from = i.saturating_sub(MARKER_LOOKBACK_LINES); + let reviewed = lines[from..=i].iter().any(|l| l.contains(REVIEW_MARKER)); + if !reviewed { + unreviewed.push(format!( + " {}:{}: {}", + file.strip_prefix(&src).unwrap_or(file).display(), + i + 1, + line.trim() + )); + } + } + } + + assert!( + unreviewed.is_empty(), + "FUNDING-DOMAIN INVARIANT: unreviewed use of key-wallet's wallet-wide \ + funds-account iterator(s):\n{}\n\n\ + A single L1 transaction must draw its inputs from EXACTLY ONE funds \ + account. Unioning ordinary BIP44/BIP32, CoinJoin, and DashPay-receiving \ + coins into one transaction irreversibly links those privacy domains on \ + chain (dashpay/platform#4073, blocked and re-scoped by #4184; regressed \ + once already in #4247).\n\n\ + If your call site does NOT select coins across accounts (e.g. it is \ + looking one account up by derivation path, or reading balances), add a \ + comment containing `{REVIEW_MARKER}:` within {MARKER_LOOKBACK_LINES} \ + lines above it saying why. If it DOES select across accounts, it is the \ + bug — take a `funding_path: Option` instead and fund \ + from the one named account, defaulting to unmixed BIP44. See \ + `wallet::funding_privacy`.", + unreviewed.join("\n"), + ); + } + + // -- behavioral guard ---------------------------------------------------- + + /// Duffs on BIP44 account 0 in the split fixture. + const BIP44_DUFFS: u64 = 9_000_000; + /// Duffs on DIP-9 CoinJoin account 0 in the split fixture. + const COINJOIN_DUFFS: u64 = 9_000_000; + /// More than either domain holds, less than their sum — fundable ONLY by a + /// cross-domain union. The send entry point must refuse it. + const CROSS_DOMAIN_ONLY: u64 = 15_000_000; + + /// The general L1 send entry point may not fund a transaction that requires + /// coins from more than one funding account. + /// + /// The fixture splits the balance evenly across two privacy domains (BIP44 + /// and DIP-9 CoinJoin) and asks the default (unmixed BIP44) send path for an + /// amount that neither domain covers alone but their union does. A + /// union-funding path succeeds here; a compliant single-domain path fails. + /// Failing is the CORRECT outcome: selection is confined to the named + /// account, and a shortfall surfaces as a typed insufficient-funds error + /// rather than silently reaching into a second domain. + #[tokio::test] + async fn no_spend_entry_point_unions_by_default() { + let (wm, wallet_id, signer) = + split_funded_wallet_manager(BIP44_DUFFS, COINJOIN_DUFFS).await; + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let core = CoreWallet::new( + sdk, + wm, + wallet_id, + Arc::new(AlwaysRejectedBroadcaster), + Arc::new(WalletBalance::new()), + ); + let payment = core + .build_signed_payment( + vec![(DashAddress::dummy(Network::Testnet, 7), CROSS_DOMAIN_ONLY)], + None, + &signer, + None, + ) + .await; + assert!( + matches!( + payment, + Err(PlatformWalletError::PaymentInsufficientFunds { .. }) + ), + "build_signed_payment must not union BIP44 with CoinJoin, got {payment:?}" + ); + } + + /// The flip side of the invariant: naming a domain explicitly confines + /// selection to it and to nothing else. Asks for an amount BIP44 alone + /// could not cover, from a CoinJoin account that can — and asserts no BIP44 + /// input rides along. + #[tokio::test] + async fn an_explicit_domain_selects_strictly_within_itself() { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + + // 0.09 DASH BIP44, 0.2 DASH CoinJoin; take 0.15 DASH from CoinJoin. + let (wm, wallet_id, signer) = split_funded_wallet_manager(9_000_000, 20_000_000).await; + + let (bip44_ops, coinjoin_ops, coinjoin_path) = { + let guard = wm.read().await; + let (_, info) = guard + .get_wallet_and_info(&wallet_id) + .expect("wallet present"); + let network = info.core_wallet.network(); + let bip44: HashSet = info + .core_wallet + .accounts + .standard_bip44_accounts + .get(&0) + .map(|a| a.utxos.keys().copied().collect()) + .unwrap_or_default(); + let coinjoin_acc = info + .core_wallet + .accounts + .coinjoin_accounts + .get(&0) + .expect("coinjoin account 0 present"); + let coinjoin: HashSet = coinjoin_acc.utxos.keys().copied().collect(); + let path = coinjoin_acc + .managed_account_type() + .to_account_type() + .derivation_path(network) + .expect("coinjoin account-level path"); + (bip44, coinjoin, path) + }; + + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let core = CoreWallet::new( + sdk, + wm, + wallet_id, + Arc::new(AlwaysRejectedBroadcaster), + Arc::new(WalletBalance::new()), + ); + let payment = core + .build_signed_payment( + vec![(DashAddress::dummy(Network::Testnet, 7), CROSS_DOMAIN_ONLY)], + None, + &signer, + Some(coinjoin_path), + ) + .await + .expect("the named CoinJoin account covers 0.15 DASH"); + + let spent: HashSet = payment + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + // `all` is vacuously true on an empty set, so without this the + // guardrail would pass while proving nothing. The sibling + // `explicit_coinjoin_path_selects_only_coinjoin` in `wallet::core::send` + // already asserts it. + assert!(!spent.is_empty(), "the payment must have selected inputs"); + assert!( + spent.iter().all(|op| coinjoin_ops.contains(op)), + "every input must come from the named CoinJoin account, spent {spent:?}" + ); + assert!( + !spent.iter().any(|op| bip44_ops.contains(op)), + "an explicitly-named domain must not pull BIP44 inputs, spent {spent:?}" + ); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/mod.rs b/packages/rs-platform-wallet/src/wallet/mod.rs index 1963422be7..fe4890bcec 100644 --- a/packages/rs-platform-wallet/src/wallet/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/mod.rs @@ -2,6 +2,7 @@ pub mod apply; pub mod asset_lock; pub mod core; pub mod core_address_key; +pub mod funding_privacy; pub mod identity; pub mod persister; pub mod platform_addresses; @@ -11,6 +12,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 +27,4 @@ pub use platform_wallet::{ PlatformWallet, PlatformWalletInfo, WalletId, WalletStateReadGuard, WalletStateWriteGuard, }; pub use provider_key_at_index::{ProviderDerivedKey, ProviderKeyKind}; +pub use signed_payment_registry::{ReservationToken, SignedPaymentError, SignedPaymentRegistry}; diff --git a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs new file mode 100644 index 0000000000..faabfd5e06 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -0,0 +1,1907 @@ +//! 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 +//! (`dashpay/platform#4196`) uses). Two +//! wallets sharing one multi-wallet `PlatformWalletManager`, or a re-created +//! wallet under the same id whose in-memory `ReservationSet` no longer holds +//! the inputs, are both told apart: broadcasting through either is a +//! [`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`]). 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; +// key-wallet's UTXO-reservation token, distinct from this registry's own +// `ReservationToken` (the u64 payment handle below). Aliased so the two never +// blur: the funding token identifies the reserved *inputs* for an owner-guarded +// release, the payment handle identifies the *registered payment*. +use key_wallet::ReservationToken as FundingReservationToken; + +use crate::broadcaster::TransactionBroadcaster; +use crate::wallet::core::{CoreWallet, FundingAccountRef}; +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; + +/// Maximum age, in `last_processed_height` blocks, of a registered token before +/// its broadcast or release is refused. +/// +/// Kept strictly below key-wallet's `RESERVATION_TTL_BLOCKS` (24, ~1h at the +/// mainnet block target): a `build_signed` / `finalize_transaction` reservation +/// is stamped at the wallet's `last_processed_height` (via `set_current_height`) +/// and swept by a later `reserve`/`reserved` call — itself stamped with the same +/// `last_processed_height` clock — once it is `RESERVATION_TTL_BLOCKS` old, +/// silently returning the outpoint to the selectable pool where an unrelated +/// build can re-select and re-reserve it. +/// `ReservationSet::release` removes an outpoint unconditionally, with no +/// ownership/generation check, so acting on a token whose reservation was +/// already swept could free (or broadcast against) a newer, unrelated +/// reservation. Refusing at this lower bound guarantees the guard always trips +/// **before** the underlying reservation could have been swept, leaving a margin +/// for `last_processed_height` to lag a few blocks behind the true tip. +const RESERVATION_MAX_AGE_BLOCKS: u32 = 20; + +/// Whether a token registered at `registered_height` is too old to act on at +/// `current_height` (see [`RESERVATION_MAX_AGE_BLOCKS`]). Unknown heights (the +/// wallet was gone at register or is gone now) disable the guard — the +/// wallet-mismatch / account-lookup paths already reject those cases. +fn reservation_expired(registered_height: Option, current_height: Option) -> bool { + match (registered_height, current_height) { + (Some(registered), Some(current)) => { + current.saturating_sub(registered) >= RESERVATION_MAX_AGE_BLOCKS + } + _ => false, + } +} + +/// Failure of a deferred broadcast/release token operation. +#[derive(Debug, thiserror::Error)] +pub enum SignedPaymentError { + /// 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`], 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 the + /// build took and which a rejected broadcast or an explicit release must + /// reconcile. + /// + /// A [`FundingAccountRef`], not a bare `StandardAccountType`, so every + /// funding domain retains a releasable handle: + /// + /// * [`FundingAccountRef::Standard`] covers BIP44/BIP32 **and** CoinJoin — + /// `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. + /// * [`FundingAccountRef::Path`] covers accounts key-wallet's + /// [`AccountTypePreference`] cannot name at all — above all a **DashPay + /// receiving-funds** account, whose reservation would otherwise be + /// unreleasable, and whose release keyed on BIP44 instead would free an + /// unrelated account's inputs. + funding: FundingAccountRef, + /// Wallet `last_processed_height` captured at registration — the exact clock + /// `build_signed` / `finalize_transaction` stamps the funding reservation + /// with. Compared against the wallet's current `last_processed_height` to + /// refuse a broadcast/release once the reservation could plausibly have been + /// swept by key-wallet's TTL (see [`RESERVATION_MAX_AGE_BLOCKS`]). `None` when + /// the wallet was not resolvable at registration, which disables the age + /// guard for this entry. + registered_height: Option, + /// The key-wallet [`FundingReservationToken`] stamped onto the funding + /// inputs when `finalize_transaction` reserved them + /// (`SignedCoreTransaction::reservation_token`), or `None` if the build + /// reserved nothing. A deferred payment can sit here across many blocks, so + /// key-wallet's TTL may sweep its reservation and a concurrent build + /// re-reserve the same inputs under a new token before this entry is + /// broadcast or released. Presenting this token to the owner-guarded release + /// frees only inputs still owned by this build, never the other build's + /// (`dashpay/platform#4185`). + funding_reservation_token: Option, +} + +/// Registry of signed-but-unsent payments keyed by [`ReservationToken`]. +/// +/// 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`]. + /// + /// `funding_reservation_token` MUST be the key-wallet token the build + /// stamped onto the reserved inputs (`SignedCoreTransaction::reservation_token`) + /// so a later broadcast-reject or release frees only inputs this build still + /// owns; `None` disables the owner guard (never the case for a funded + /// finalize, which always reserves). + pub async fn register( + &self, + core: CoreWallet, + tx: Transaction, + account_type: AccountTypePreference, + account_index: u32, + registered_height: Option, + funding_reservation_token: Option, + ) -> ReservationToken { + self.register_funded_by( + core, + tx, + FundingAccountRef::standard(account_type, account_index), + registered_height, + funding_reservation_token, + ) + .await + } + + /// [`register`](Self::register) for a payment funded from the single funds + /// account named by its **account-level derivation path** — the shape + /// [`CoreWallet::finalize_signed_payment_from_funding_path`] produces. + /// + /// This is the only registration form that can hold a **DashPay + /// receiving-funds** payment: key-wallet's [`AccountTypePreference`] has no + /// variant for that account, so registering such a payment through + /// [`register`](Self::register) would have to lie about its funding account + /// and a later release would then free BIP44's inputs instead of the + /// receival account's. + /// + /// `funding_path` MUST be the path the build actually selected from + /// (`FinalizedCorePayment::funding`), not the caller's requested path: + /// `None` requests resolve to the unmixed BIP44 account's path, and the + /// release must name the resolved account. + /// + /// [`CoreWallet::finalize_signed_payment_from_funding_path`]: + /// crate::CoreWallet::finalize_signed_payment_from_funding_path + pub async fn register_funded_by( + &self, + core: CoreWallet, + tx: Transaction, + funding: FundingAccountRef, + registered_height: Option, + funding_reservation_token: Option, + ) -> ReservationToken { + let token = self.next_token.fetch_add(1, Ordering::SeqCst); + self.lock().insert( + token, + RegisteredPayment { + core, + tx, + funding, + registered_height, + funding_reservation_token, + }, + ); + 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.funding, + &entry.tx, + entry.funding_reservation_token, + ) + .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_reservation_for(&entry.funding, &entry.tx, entry.funding_reservation_token) + .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, RESERVATION_MAX_AGE_BLOCKS}; + use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; + use crate::test_support::{ + funded_wallet_manager, AlwaysMaybeSentBroadcaster, AlwaysRejectedBroadcaster, WalletSigner, + }; + use crate::wallet::core::CoreWallet; + + /// The [`AccountTypePreference`] a `build_signed_tx` funding account maps to + /// — the registry now retains the full account handle (CoinJoin included), + /// so the tests register with the preference rather than the narrower + /// `StandardAccountType`. + fn preference(account_type: StandardAccountType) -> AccountTypePreference { + match account_type { + StandardAccountType::BIP44Account => AccountTypePreference::BIP44, + StandardAccountType::BIP32Account => AccountTypePreference::BIP32, + } + } + use crate::PlatformWalletError; + + /// Broadcaster that records the exact bytes handed to it and succeeds, + /// 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_reserved` reserves the inputs, leaves the reservation held + /// for the later broadcast/release, and returns the key-wallet + /// [`ReservationToken`](key_wallet::ReservationToken) stamped onto them so + /// the test can register it for an owner-guarded release. + async fn build_signed_tx( + core: &CoreWallet, + account_type: StandardAccountType, + account_index: u32, + outputs: &[(DashAddress, u64)], + signer: &S, + ) -> Result<(Transaction, Option), PlatformWalletError> { + 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, reservation_token) = builder + .build_signed_reserved(signer, |addr| { + managed_account.address_derivation_path(&addr) + }) + .await + .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; + Ok((tx, reservation_token)) + } + + /// 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, reservation_token) = 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, + reservation_token, + ) + .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, reservation_token) = + build_signed_tx(&core, account_type, 0, &outputs, &signer) + .await + .expect("build should succeed"); + let token = registry + .register( + core.clone(), + tx, + preference(account_type), + 0, + core.last_processed_height().await, + reservation_token, + ) + .await; + + // 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()), + finalized.reservation_token(), + ) + .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, reservation_token) = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register( + core.clone(), + tx, + AccountTypePreference::BIP44, + 0, + core.last_processed_height().await, + reservation_token, + ) + .await; + + 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, reservation_token) = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register( + core.clone(), + tx, + AccountTypePreference::BIP44, + 0, + core.last_processed_height().await, + reservation_token, + ) + .await; + + 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, reservation_token) = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register( + core.clone(), + tx, + AccountTypePreference::BIP44, + 0, + core.last_processed_height().await, + reservation_token, + ) + .await; + + 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, reservation_token) = 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, + reservation_token, + ) + .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, reservation_token) = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register( + core.clone(), + tx, + AccountTypePreference::BIP44, + 0, + core.last_processed_height().await, + reservation_token, + ) + .await; + + 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, reservation_token) = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register( + core.clone(), + tx, + AccountTypePreference::BIP44, + 0, + core.last_processed_height().await, + reservation_token, + ) + .await; + + 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, reservation_token) = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let registry = Arc::new(SignedPaymentRegistry::new()); + + let mut handles = Vec::new(); + for _ in 0..16 { + let registry = Arc::clone(®istry); + let core = core.clone(); + let tx = tx.clone(); + handles.push(tokio::spawn(async move { + let height = core.last_processed_height().await; + registry + .register( + core, + tx, + AccountTypePreference::BIP44, + 0, + height, + reservation_token, + ) + .await + })); + } + let mut tokens = Vec::new(); + for handle in handles { + tokens.push(handle.await.expect("task panicked")); + } + let unique: std::collections::HashSet<_> = tokens.iter().copied().collect(); + assert_eq!(unique.len(), tokens.len(), "all tokens must be distinct"); + assert_eq!(registry.outstanding(), 16); + } + + /// 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, reservation_token) = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register( + core.clone(), + tx, + AccountTypePreference::BIP44, + 0, + core.last_processed_height().await, + reservation_token, + ) + .await; + + // 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, reservation_token) = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register( + core.clone(), + tx, + AccountTypePreference::BIP44, + 0, + core.last_processed_height().await, + reservation_token, + ) + .await; + + 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, reservation_token) = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register( + core.clone(), + tx, + AccountTypePreference::BIP44, + 0, + core.last_processed_height().await, + reservation_token, + ) + .await; + + // 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, reservation_token_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, + reservation_token_a, + ) + .await; + let (tx_b, reservation_token_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, + reservation_token_b, + ) + .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, reservation_token) = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let _token = registry + .register( + core.clone(), + tx, + AccountTypePreference::BIP44, + 0, + core.last_processed_height().await, + reservation_token, + ) + .await; + + // Reservation held: an immediate rebuild fails at input selection. + let blocked = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await; + assert!( + matches!(blocked, Err(PlatformWalletError::TransactionBuild(_))), + "rebuild must fail while the reservation is held, got {blocked:?}" + ); + + // Final-alias destroy path: release (not drop) the generation's tokens. + let released = registry.release_entries_for_wallet(&core).await; + assert_eq!(released, 1, "the generation's one token is reconciled"); + assert_eq!(registry.outstanding(), 0); + + // The released input is spendable again — the rebuild now succeeds. + let rebuilt = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await; + assert!( + rebuilt.is_ok(), + "release_entries_for_wallet must free the reservation, got {rebuilt:?}" + ); + } + + /// 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, reservation_token) = 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, + reservation_token, + ) + .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, reservation_token) = 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), + reservation_token, + ) + .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. An unconditional release-by-outpoint would instead free the + /// new generation's reservation, and the rebuild below would succeed; the + /// generation guard makes it still fail. + #[tokio::test] + async fn recreation_between_validation_and_cleanup_cannot_release_new_generation() { + let broadcaster = Arc::new(RecordingBroadcaster::new()); + let (core, signer, outputs) = + funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; + let registry = SignedPaymentRegistry::new(); + + let (tx, reservation_token) = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register( + core.clone(), + tx, + AccountTypePreference::BIP44, + 0, + core.last_processed_height().await, + reservation_token, + ) + .await; + + // Reservation held: 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:?}" + ); + } + + /// A funding builder over the fixture's outputs, selecting largest-first + /// like the production send path. + fn payment_builder(outputs: &[(DashAddress, u64)]) -> TransactionBuilder { + let mut builder = + TransactionBuilder::new().set_selection_strategy(SelectionStrategy::LargestFirst); + for (addr, amount) in outputs { + builder = builder.add_output(addr, *amount); + } + builder + } + + /// Unconditionally release `tx`'s input reservation on the BIP44 account, + /// modelling key-wallet's TTL sweep returning the outpoint to the selectable + /// pool — WITHOUT touching the registry entry, which still holds the token. + async fn force_release_reservation( + core: &CoreWallet, + tx: &Transaction, + ) { + let wm = core.wallet_manager.read().await; + let (_, info) = wm + .get_wallet_and_info(&core.wallet_id()) + .expect("wallet present in manager"); + info.core_wallet + .accounts + .standard_bip44_accounts + .get(&0) + .expect("bip44 managed account") + .release_reservation(tx); + } + + /// Owner-guarded release regression (`dashpay/platform#4185`): a rejected + /// deferred broadcast must free ONLY the inputs its own build still owns. If + /// key-wallet's TTL swept this build's reservation and a concurrent build + /// re-reserved the same outpoint under a new token, the rejection's release + /// must leave that other build's reservation intact — freeing it would let + /// coin selection hand the outpoint to a third build and double-spend it. + /// The registry threads the build's key-wallet `ReservationToken` to the + /// reject path, so the release is owner-guarded rather than by-outpoint. + #[tokio::test] + async fn rejected_broadcast_releases_only_its_own_reservation_not_one_retaken_after_a_sweep() { + let broadcaster = Arc::new(AlwaysRejectedBroadcaster); + let (core, signer, outputs) = + funded_core_wallet(StandardAccountType::BIP44Account, broadcaster).await; + let registry = SignedPaymentRegistry::new(); + + // Build 1 reserves the sole funding UTXO under token T1 and registers it + // for deferred submission. + let finalized = core + .finalize_transaction( + payment_builder(&outputs), + AccountTypePreference::BIP44, + 0, + &signer, + ) + .await + .expect("first finalize should succeed"); + let token = registry + .register( + core.clone(), + finalized.transaction().clone(), + AccountTypePreference::BIP44, + 0, + Some(finalized.reservation_height()), + finalized.reservation_token(), + ) + .await; + + // Model key-wallet's TTL sweep: the outpoint returns to the selectable + // pool, but the registry still holds T1. + force_release_reservation(&core, finalized.transaction()).await; + + // A concurrent build re-selects and re-reserves that same outpoint under + // a NEW token T2. Held alive so its reservation persists to the end. + let retaken = core + .finalize_transaction( + payment_builder(&outputs), + AccountTypePreference::BIP44, + 0, + &signer, + ) + .await + .expect("re-reserving finalize should succeed after the sweep"); + + // Build 1's deferred broadcast is definitively rejected. Its release is + // owner-guarded by T1, so it must NOT free T2's reservation. + let sent = registry.broadcast(token, &core).await; + assert!( + matches!( + sent, + Err(SignedPaymentError::Broadcast( + PlatformWalletError::TransactionBroadcast(_) + )) + ), + "a rejected deferred broadcast must surface the rejection, got {sent:?}" + ); + + // T2 still owns the outpoint: a third build finds no free UTXO. Under the + // pre-fix unconditional release, build 1's rejection would have freed it + // and this build would succeed — double-spending T2's outpoint. + let third = core + .finalize_transaction( + payment_builder(&outputs), + AccountTypePreference::BIP44, + 0, + &signer, + ) + .await; + assert!( + matches!( + third, + Err(PlatformWalletError::CoreInsufficientFunds { .. }) + ), + "the re-taken reservation must survive build 1's rejected broadcast, got {third:?}" + ); + + // Keep T2's build (and thus its reservation) alive until the assertions run. + drop(retaken); + } +} diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index f8dc82f050..dd8f2d5bfb 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -258,6 +258,40 @@ fn read_cstring_opt( } } +/// Strict variant of [`read_cstring_opt`] for parameters where a JNI read +/// failure must be surfaced rather than silently degraded. JVM null (or an +/// empty string) is still `Ok(None)` (the natural "unset" default), but a +/// genuine `get_string` error THROWS and returns `Err(())` instead of falling +/// back to `None`. Used for money-source parameters such as `fundingPath`, +/// where degrading to the default account would spend the wrong coins. +pub(crate) fn read_cstring_opt_strict( + env: &mut JNIEnv, + s: &JString, + field: &str, +) -> Result, ()> { + if s.is_null() { + return Ok(None); + } + let owned: String = match env.get_string(s) { + Ok(v) => v.into(), + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, &format!("{field} string was invalid")); + return Err(()); + } + }; + if owned.is_empty() { + return Ok(None); + } + match std::ffi::CString::new(owned) { + Ok(c) => Ok(Some(c)), + Err(_) => { + throw_sdk_exception(env, 1, &format!("{field} contained an interior NUL")); + Err(()) + } + } +} + /// The default Orchard payment address for `account` on the wallet's bound /// shielded sub-wallet — bridges `platform_wallet_manager_shielded_default_address`. /// Returns the 43 raw bytes (11-byte diversifier + 32-byte pk_d) as a diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 8e0f3e676b..541bf26acc 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1013,6 +1013,256 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c }) } +/// `core_wallet_build_signed_payment` — build + sign a standard L1 payment +/// funded from ONE of the wallet's signable funds accounts, and return the +/// result WITHOUT broadcasting. +/// +/// `core_handle` is the transient core-wallet `Handle` from +/// [platformWalletGetCore]. `outputs_blob` is the recipients, encoded +/// big-endian as `u32 count` then per row `u32 addrLen, addr utf8, u64 amount` +/// (`ManagedPlatformWallet.encodePaymentOutputs`). `fee_per_kb` is duffs/kB, or +/// 0 for the default. `core_signer_handle` is the manager's +/// `MnemonicResolverHandle`. `funding_path` is an optional UTF-8 BIP32 +/// derivation-path string (dashpay/platform#4184) naming the SINGLE funds +/// account whose UTXOs fund the payment: null (the default) funds from the +/// unmixed BIP44 account; an explicit account-level path (e.g. the DIP-9 +/// CoinJoin account path) funds strictly from that one account, with no union +/// across accounts and no consent gate. +/// +/// Returns a `byte[]` packed big-endian as `u64 fee, u64 change,` then the +/// consensus-serialized signed transaction bytes (`fee` and `change` in duffs), +/// or null after throwing. The FFI-owned tx bytes are freed here before +/// returning; Kotlin decodes the packed array via +/// `ManagedPlatformWallet.decodeSignedPayment`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreWalletBuildSignedPayment( + mut env: JNIEnv, + _class: JClass, + core_handle: jlong, + outputs_blob: JByteArray, + fee_per_kb: jlong, + core_signer_handle: jlong, + funding_path: JString, +) -> jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + if core_handle == 0 { + throw_sdk_exception(env, 1, "core handle is 0"); + return ptr::null_mut(); + } + if core_signer_handle == 0 { + throw_sdk_exception(env, 1, "coreSignerHandle is 0"); + return ptr::null_mut(); + } + if fee_per_kb < 0 { + throw_sdk_exception(env, 1, "feePerKb must be non-negative"); + return ptr::null_mut(); + } + let blob = match env.convert_byte_array(&outputs_blob) { + Ok(b) => b, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, "outputs byte[] was invalid"); + return ptr::null_mut(); + } + }; + // Optional BIP32 derivation-path string naming the single funds account + // (null = the unmixed BIP44 account). Passed to the FFI as UTF-8 bytes + // (without the trailing NUL) + length. Uses the STRICT reader: a genuine + // read error must throw, not silently degrade this money-source param to + // the default BIP44 account (which would spend the wrong coins). + let funding_path = + match crate::funding::read_cstring_opt_strict(env, &funding_path, "fundingPath") { + Ok(v) => v, + Err(()) => return ptr::null_mut(), + }; + let (funding_path_ptr, funding_path_len) = + funding_path.as_ref().map_or((ptr::null(), 0usize), |c| { + let b = c.as_bytes(); + (b.as_ptr(), b.len()) + }); + + let mut out_tx_bytes: *mut u8 = ptr::null_mut(); + let mut out_tx_len: usize = 0; + let mut out_fee: u64 = 0; + let mut out_change: u64 = 0; + let result = unsafe { + platform_wallet_ffi::core_wallet_build_signed_payment( + core_handle as Handle, + blob.as_ptr(), + blob.len(), + fee_per_kb as u64, + core_signer_handle as *mut rs_sdk_ffi::MnemonicResolverHandle, + funding_path_ptr, + funding_path_len, + &mut out_tx_bytes, + &mut out_tx_len, + &mut out_fee, + &mut out_change, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + + // Copy the FFI-owned tx bytes out, then free them, then pack the + // metadata-prefixed result for Kotlin. `fee` and `change` are written + // big-endian ahead of the raw tx bytes. + let tx_bytes: &[u8] = if out_tx_bytes.is_null() || out_tx_len == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(out_tx_bytes, out_tx_len) } + }; + let mut packed = Vec::with_capacity(16 + tx_bytes.len()); + packed.extend_from_slice(&out_fee.to_be_bytes()); + packed.extend_from_slice(&out_change.to_be_bytes()); + packed.extend_from_slice(tx_bytes); + unsafe { + platform_wallet_ffi::core_wallet_free_payment_bytes(out_tx_bytes, out_tx_len); + } + + env.byte_array_from_slice(&packed) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + +/// `core_wallet_build_signed_payment_with_token` — build + sign a standard L1 +/// payment funded from ONE of the wallet's signable funds accounts (named by +/// derivation path, so a **DashPay receiving-funds** account is reachable) AND +/// register it for deferred submission, returning a reservation token. +/// +/// The bridge between [coreWalletBuildSignedPayment] (path-selected but +/// tokenless and never broadcast) and [coreWalletFinalizeSignedPayment] +/// (tokened + broadcastable but limited to BIP44/BIP32/CoinJoin). Parameters are +/// exactly [coreWalletBuildSignedPayment]'s. +/// +/// On success the funding UTXOs are RESERVED and owned by the returned token: +/// consume it with [coreWalletBroadcastSignedPayment] or +/// [coreWalletReleaseSignedPayment]. +/// +/// Returns a big-endian BLOB: `u64 token, u64 feeDuffs, u64 changeDuffs, +/// u32 txidLen, txid utf8, u32 txBytesLen, txBytes` (null after throwing). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreWalletBuildSignedPaymentWithToken( + mut env: JNIEnv, + _class: JClass, + core_handle: jlong, + outputs_blob: JByteArray, + fee_per_kb: jlong, + core_signer_handle: jlong, + funding_path: JString, +) -> jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + if core_handle == 0 { + throw_sdk_exception(env, 1, "core handle is 0"); + return ptr::null_mut(); + } + if core_signer_handle == 0 { + throw_sdk_exception(env, 1, "coreSignerHandle is 0"); + return ptr::null_mut(); + } + if fee_per_kb < 0 { + throw_sdk_exception(env, 1, "feePerKb must be non-negative"); + return ptr::null_mut(); + } + let blob = match env.convert_byte_array(&outputs_blob) { + Ok(b) => b, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, "outputs byte[] was invalid"); + return ptr::null_mut(); + } + }; + // STRICT reader, as in [coreWalletBuildSignedPayment]: a genuine read + // error must throw, never silently degrade this money-source parameter + // to the default BIP44 account (which would spend the wrong coins). + let funding_path = + match crate::funding::read_cstring_opt_strict(env, &funding_path, "fundingPath") { + Ok(v) => v, + Err(()) => return ptr::null_mut(), + }; + let (funding_path_ptr, funding_path_len) = + funding_path.as_ref().map_or((ptr::null(), 0usize), |c| { + let b = c.as_bytes(); + (b.as_ptr(), b.len()) + }); + + let mut token: u64 = 0; + let mut out_txid: *mut c_char = ptr::null_mut(); + let mut out_tx_bytes: *mut u8 = ptr::null_mut(); + let mut out_tx_len: usize = 0; + let mut out_fee: u64 = 0; + let mut out_change: u64 = 0; + let result = unsafe { + platform_wallet_ffi::core_wallet_build_signed_payment_with_token( + core_handle as Handle, + blob.as_ptr(), + blob.len(), + fee_per_kb as u64, + core_signer_handle as *mut rs_sdk_ffi::MnemonicResolverHandle, + funding_path_ptr, + funding_path_len, + &mut token as *mut u64, + &mut out_txid as *mut *mut c_char, + &mut out_tx_bytes as *mut *mut u8, + &mut out_tx_len as *mut usize, + &mut out_fee as *mut u64, + &mut out_change as *mut u64, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + + // Past this point the registration has committed and is holding the + // funding reservation, so EVERY failure path below must release the + // token — otherwise Kotlin never receives it and the reservation is + // stranded until key-wallet's TTL backstop. + if out_txid.is_null() { + unsafe { + platform_wallet_ffi::core_wallet_free_payment_bytes(out_tx_bytes, out_tx_len); + platform_wallet_ffi::core_wallet_signed_payment_release(token); + }; + throw_sdk_exception(env, 1, "build-with-token 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) }; + + let tx_bytes: &[u8] = if out_tx_bytes.is_null() || out_tx_len == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(out_tx_bytes, out_tx_len) } + }; + + let txid_bytes = txid.into_bytes(); + let mut packed = Vec::with_capacity(8 + 8 + 8 + 4 + txid_bytes.len() + 4 + tx_bytes.len()); + packed.extend_from_slice(&token.to_be_bytes()); + packed.extend_from_slice(&out_fee.to_be_bytes()); + packed.extend_from_slice(&out_change.to_be_bytes()); + packed.extend_from_slice(&(txid_bytes.len() as u32).to_be_bytes()); + packed.extend_from_slice(&txid_bytes); + packed.extend_from_slice(&(tx_bytes.len() as u32).to_be_bytes()); + packed.extend_from_slice(tx_bytes); + unsafe { + platform_wallet_ffi::core_wallet_free_payment_bytes(out_tx_bytes, out_tx_len); + } + + match env.byte_array_from_slice(&packed) { + Ok(array) => array.into_raw(), + Err(_) => { + // Kotlin will never see the token; release it here. + unsafe { platform_wallet_ffi::core_wallet_signed_payment_release(token) }; + ptr::null_mut() + } + } + }) +} + /// `platform_wallet_get_core` — resolve the transient core-wallet `Handle` /// (as `jlong`) from a `PlatformWallet` handle, for [coreWalletBroadcastTransaction]. /// Free with [coreWalletDestroy]. Returns 0 after throwing. @@ -1232,6 +1482,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` (34, aged +/// out), `ErrorReservationTokenConsumed` (35, unknown / already broadcast / +/// already released), or `ErrorReservationWalletMismatch` (36, different wallet +/// generation). `coreHandle` must resolve to the wallet the token was minted +/// against. Returns the txid as a lowercase hex string. +#[no_mangle] +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 3834ca71b4..6f9b3e35f0 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -69,6 +69,33 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// Core definitively rejected the transaction. Its reserved inputs were /// released and a corrected transaction may be submitted again. case errorTransactionBroadcastRejected = 26 + // Codes 27-33 are claimed outside this trio and must not be reused here: + // 27 errorShutdownIncomplete (dashpay/platform#4268, merged), 29 + // errorAssetLockInsufficientFunds (#4184), 31 errorSigningKeyUnavailable + // (#4183/#4259), 32 errorTransactionBuild, 33 errorTransactionSigning; 28 + // and 30 are free. The deferred-token trio therefore occupies the + // contiguous block 34-36. These raw values MUST match + // `PlatformWalletFFIResultCode` in + // packages/rs-platform-wallet-ffi/src/error.rs — there is no compile-time + // check across the ABI. See ERROR_CODE_REGISTRY.md (#4261). + /// A deferred (BIP70/BIP270) reservation token has outlived its funding + /// reservation's lifetime: key-wallet's TTL may already have swept and + /// re-selected the inputs, so acting on it could touch a newer, unrelated + /// reservation. The call did NOT touch the network. NOT retryable in place — + /// rebuild the payment. + case errorStaleReservationToken = 34 + /// A deferred reservation token is unknown, already broadcast, or already + /// released — the guard that turns a double-broadcast (or a broadcast after + /// release) into a typed error instead of a second send. The call did NOT + /// touch the network. NOT retryable: rebuild the payment. (Release is + /// idempotent and never surfaces this.) + case errorReservationTokenConsumed = 35 + /// A deferred reservation token was minted against a different wallet + /// *generation* than the one broadcasting it (e.g. a wallet re-created under + /// the same id); its reservation lives in that other generation's reservation + /// set. The call did NOT touch the network and did NOT consume the rightful + /// owner's token. NOT retryable through this handle: rebuild the payment. + case errorReservationWalletMismatch = 36 case notFound = 98 case errorUnknown = 99 @@ -128,6 +155,12 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorAssetLockFundingMismatch case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_TRANSACTION_BROADCAST_REJECTED: self = .errorTransactionBroadcastRejected + 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: @@ -250,6 +283,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) @@ -272,6 +320,8 @@ public enum PlatformWalletError: LocalizedError { .transactionBroadcastUnconfirmed(let m), .transactionBroadcastRejected(let m), .addressNonceMismatch(let m), + .staleReservationToken(let m), .reservationTokenConsumed(let m), + .reservationWalletMismatch(let m), .notFound(let m), .unknown(let m): return m } @@ -313,6 +363,12 @@ public enum PlatformWalletError: LocalizedError { self = .transactionBroadcastRejected(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) }