diff --git a/Cargo.lock b/Cargo.lock index 9ea5c27163..0bdbd03339 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1229,7 +1229,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" dependencies = [ "bincode", "bincode_derive", @@ -1673,7 +1673,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" dependencies = [ "dash-network", ] @@ -1750,7 +1750,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" dependencies = [ "async-trait", "chrono", @@ -1779,7 +1779,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" dependencies = [ "anyhow", "base64-compat", @@ -1805,12 +1805,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" dependencies = [ "dashcore-rpc-json", "hex", @@ -1823,7 +1823,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" dependencies = [ "bincode", "dashcore", @@ -1838,7 +1838,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" dependencies = [ "bincode", "dashcore-private", @@ -2474,7 +2474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2535,7 +2535,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2904,7 +2904,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" [[package]] name = "glob" @@ -3588,7 +3588,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -3839,7 +3839,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4095,7 +4095,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" dependencies = [ "aes", "async-trait", @@ -4124,7 +4124,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4140,7 +4140,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/dashpay/rust-dashcore?rev=8f78baa6b7979b9bea56501ad75b5a7b7150a711#8f78baa6b7979b9bea56501ad75b5a7b7150a711" dependencies = [ "async-trait", "bincode", @@ -5709,7 +5709,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls", - "socket2 0.6.4", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -5747,9 +5747,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.5.10", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6556,7 +6556,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6569,7 +6569,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6628,7 +6628,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7488,7 +7488,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8937,7 +8937,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 86e5432b7e..3c7a1ad076 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,14 +52,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "8f78baa6b7979b9bea56501ad75b5a7b7150a711" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "8f78baa6b7979b9bea56501ad75b5a7b7150a711" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "8f78baa6b7979b9bea56501ad75b5a7b7150a711" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "8f78baa6b7979b9bea56501ad75b5a7b7150a711" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "8f78baa6b7979b9bea56501ad75b5a7b7150a711" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "8f78baa6b7979b9bea56501ad75b5a7b7150a711" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "8f78baa6b7979b9bea56501ad75b5a7b7150a711" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "8f78baa6b7979b9bea56501ad75b5a7b7150a711" } tokio-metrics = "0.5" diff --git a/packages/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 9c57d763b1..c616b3117e 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -89,6 +89,24 @@ sealed class DashSdkError( class CoreInsufficientFunds(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorTransactionBuild` (native code 32). A Core transaction could + * not be assembled from the request. The REQUEST is at fault, so a + * verbatim retry fails identically — the caller must change it. + * + * It is what every non-shortfall `buildSignedPayment` rejection + * surfaces as: a `fundingPath` matching no spendable funds account or + * naming a watch-only one (the two failure modes the single-account + * send design rests on, dashpay/platform#4184), a request breaching a + * monetary bound (MAX_MONEY total, max fee rate, a below-dust + * recipient, an over-100 kB recipient list), or a malformed recipients + * blob. Before this code existed they all arrived as [Generic] with + * native code 99 and could only be told apart by string-matching the + * message. The specific cause is still in [message]. + */ + class TransactionBuild(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + class AssetLockNotTracked(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) @@ -239,6 +257,67 @@ sealed class DashSdkError( class NotFound(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorTransactionBroadcastRejected` (native code 26). Core + * DEFINITIVELY rejected the core transaction: it is not on the network + * and will not get there. The build's UTXO reservation was released and, + * on the deferred (BIP70/BIP270) path, the token was consumed at the + * same time — so the inputs are spendable again and the token is gone. + * + * The definitive counterpart to [TransactionBroadcastUnconfirmed] (20), + * whose outcome is AMBIGUOUS and which therefore keeps its inputs + * reserved. Because the reservation and token are already gone, this is + * NOT retryable in place: address the rejection reason carried in the + * message, then rebuild with + * [buildSignedPayment][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.buildSignedPayment] + * (deferred) or re-issue the send. + */ + class TransactionBroadcastRejected(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + + /** + * `ErrorStaleReservationToken` (native code 34). A deferred + * (BIP70/BIP270) [broadcastSigned][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.broadcastSigned] + * token has outlived its funding reservation's lifetime: key-wallet's + * TTL may already have swept and re-selected the inputs, so acting on it + * 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 @@ -335,6 +414,20 @@ sealed class DashSdkError( // RAW code via translateManagedIdentityNotFoundToZero (#4051) // before this mapping ever runs. BREAKING for Kotlin hosts that // caught DashSdkError.NotFound from platform-wallet operations. + // + // 98 is also what the wallet-was-REMOVED case returns on BOTH + // deferred-send paths: + // * deferred (BIP70/BIP270) TOKEN path — a signed-payment broadcast + // whose wallet is no longer registered in the manager, or a + // signed-payment finalize whose wallet was removed while it was + // being signed; + // * finalized-transaction HANDLE (V2) path — a tx-builder finalize + // whose wallet was removed or re-created during signing (no handle + // is published), or a V2 broadcast whose generation is gone. + // Every one reconciles the build's UTXO reservation before returning. + // Nothing was broadcast, and unlike ReservationWalletMismatch (36) + // no other live generation holds the payment either — so it is not + // retryable. See dashpay/platform#4185. PLATFORM_WALLET_NOT_FOUND_CODE -> PlatformWallet.NotFound(message, cause) 16 -> PlatformWallet.ShieldedBroadcastFailed(message, cause) // ErrorShieldedBroadcastFailed @@ -345,6 +438,17 @@ sealed class DashSdkError( 23 -> PlatformWallet.AssetLockNotTracked(message, cause) // ErrorAssetLockNotTracked 24 -> PlatformWallet.AssetLockAlreadyConsumed(message, cause) // ErrorAssetLockAlreadyConsumed 25 -> PlatformWallet.AssetLockFundingMismatch(message, cause) // ErrorAssetLockFundingMismatch + 26 -> PlatformWallet.TransactionBroadcastRejected(message, cause) // ErrorTransactionBroadcastRejected + 32 -> PlatformWallet.TransactionBuild(message, cause) // ErrorTransactionBuild + // The deferred-token trio sits at the contiguous block 34-36 because + // 27-33 are claimed elsewhere: 27 ErrorShutdownIncomplete + // (dashpay/platform#4268, merged), 29 ErrorAssetLockInsufficientFunds + // (#4184), 31 ErrorSigningKeyUnavailable (#4183/#4259), 32 + // ErrorTransactionBuild (#4247/#4256), 33 ErrorTransactionSigning + // (#4256). See packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md. + 34 -> PlatformWallet.StaleReservationToken(message, cause) // ErrorStaleReservationToken + 35 -> PlatformWallet.ReservationTokenConsumed(message, cause) // ErrorReservationTokenConsumed + 36 -> PlatformWallet.ReservationWalletMismatch(message, cause) // ErrorReservationWalletMismatch // ErrorSigningKeyUnavailable — the STRUCTURED signer // discriminator (dashpay/platform#4060 finding 7): the typed // completion code rides the whole Rust round-trip, no message diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt index 205fe225ba..83d5e1c7d0 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt @@ -214,6 +214,76 @@ 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, u64 reservationHandle,` then the + * consensus-serialized signed transaction bytes (0-length / null after + * throwing). Does NOT broadcast and does NOT persist a debit. + * + * `reservationHandle` is the opaque stand-in for the key-wallet reservation + * token this build stamped on its inputs (0 = it reserved nothing); it must + * be passed back to [coreWalletReleasePaymentReservation] to abandon the + * build. The token itself cannot cross the ABI — it has no public + * constructor, by design, so that it cannot be forged. + */ + external fun coreWalletBuildSignedPayment( + coreHandle: Long, + outputsBlob: ByteArray, + feePerKb: Long, + coreSignerHandle: Long, + fundingPath: String?, + ): ByteArray + + /** + * `core_wallet_release_payment_reservation` — release the UTXO reservation + * a [coreWalletBuildSignedPayment] call took, for a build that will NOT be + * broadcast. + * + * `build_signed_payment` leaves its selected inputs reserved on success, + * expecting a broadcast to follow. A caller that abandons the build must + * call this or the coins stay unselectable until key-wallet's 24-block TTL + * backstop reclaims them — and that backstop never fires before the first + * sync completes (`ReservationSet::sweep` early-returns at height 0), so an + * abandoned build on a freshly restored wallet can otherwise strand the + * whole balance for the life of the process (dashpay/platform#4247 review). + * This call consults no height. + * + * [coreHandle] is a core-wallet handle from [platformWalletGetCore]. + * [txBytes] is the consensus-serialized signed transaction exactly as + * [coreWalletBuildSignedPayment] returned it. [fundingPath] must be the SAME + * optional path the build was given (null = the unmixed BIP44 account). + * [reservationHandle] must be the SAME handle that build returned: it is the + * ownership proof that stops this release from freeing a concurrent build's + * inputs after key-wallet's TTL swept this build's reservation and that other + * build re-reserved the same outpoint (dashpay/platform#4247 review). An + * unrecognised non-zero handle throws rather than releasing unguarded. + * + * FOR ABANDONED BUILDS ONLY — never after a successful broadcast. Repeated + * releases of the same abandoned build are idempotent; that is the only sense + * in which this is safe to call twice. + */ + external fun coreWalletReleasePaymentReservation( + coreHandle: Long, + txBytes: ByteArray, + fundingPath: String?, + reservationHandle: Long, + ) + /** * `core_wallet_broadcast_transaction` — broadcast a transaction built by * [coreTxBuilderBuildSigned]. [accountType]/[accountIndex] identify the @@ -249,6 +319,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..a5c8c5fd7b 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,57 @@ 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, + ) + // Native finalization has ALREADY inserted the payment and committed its + // reservation by the time this blob returns; the token only gains its + // owning NativeCleaner once fromRegisterBlob finishes constructing the + // SignedCoreTransaction. So if construction throws (allocation failure, a + // malformed blob from an ABI mismatch, or Cleaner-registration failure) + // the native token would be registered with no JVM owner able to release + // it, leaking the reservation until key-wallet's TTL. Parse the token + // first (its 8 big-endian bytes lead the blob) and release it defensively + // if ownership construction fails, mirroring the owner-guarded release on + // the rest of the deferred path (dashpay/platform#4185). + var token: Long? = null + return try { + token = java.nio.ByteBuffer.wrap(blob).long + ManagedPlatformWallet.SignedCoreTransaction.fromRegisterBlob(blob) + } catch (error: Throwable) { + token?.let { value -> + runCatching { WalletManagerNative.coreWalletReleaseSignedPayment(value) } + } + throw error + } + } + override fun close() { 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..677cd1d496 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,61 @@ 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, u64 reservationHandle,` then the signed tx + * bytes, big-endian) — decoded by + * [ManagedPlatformWallet.buildSignedPayment]. See that method for the full + * contract; drive this through it (it serializes concurrent builds), not + * directly. + */ + internal fun buildSignedPayment( + outputsBlob: ByteArray, + feePerKb: Long, + coreSignerHandle: Long, + fundingPath: String?, + ): ByteArray = + WalletManagerNative.coreWalletBuildSignedPayment( + handle, + outputsBlob, + feePerKb, + coreSignerHandle, + fundingPath, + ) + + /** + * Release the UTXO reservation a [buildSignedPayment] call took, for a + * build that will not be broadcast. [reservationHandle] must be the handle + * that build returned — it is what makes the release owner-guarded. See + * [ManagedPlatformWallet.releasePaymentReservation] for the full contract + * (including why this must NOT be called after a broadcast); drive this + * through it, not directly. + */ + internal fun releasePaymentReservation( + txBytes: ByteArray, + fundingPath: String?, + reservationHandle: Long, + ) = WalletManagerNative.coreWalletReleasePaymentReservation( + handle, + txBytes, + fundingPath, + reservationHandle, + ) + override fun close() { cleanable.clean() } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt index 27c846cd3b..ee0a5f5b6f 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt @@ -173,6 +173,532 @@ class ManagedPlatformWallet internal constructor( } } + /** + * A built, signed Core transaction whose funding UTXOs are reserved, + * awaiting a deferred [broadcastSigned] or [releaseReservation] — the + * split-out result of [buildSignedPayment] for BIP70/BIP270 (CTX/DashSpend) + * flows that must sign now, POST the raw bytes to a merchant server, and + * broadcast only on the server's ack. + * + * **Owns the reservation token.** The blocking native registration mints the + * token before this object exists, so if the object were then discarded — + * the caller drops it, or a coroutine cancellation is observed after + * [buildSignedPayment]'s native call returned — the token (and its funding + * reservation) would be orphaned until key-wallet's TTL. This type is + * therefore [AutoCloseable] with a [NativeCleaner] GC backstop: [close], or + * GC if you never call it, releases the token exactly once. Release is + * idempotent native-side and tokens are process-unique (never reused), so + * releasing a token already consumed by [broadcastSigned] / + * [releaseReservation] — or releasing twice — is a harmless no-op. A caller + * that broadcasts or releases can still `use`/close this object; a caller + * that abandons it is covered by GC. + * + * @property txidHex the transaction id (lowercase hex) the broadcast will + * return — computed from the signed bytes Rust-side so it matches exactly. + * @property rawTxBytes the consensus-serialized signed transaction, to hand + * to the merchant server. + * @property feeDuffs the fee the build charged, in duffs. + * @property reservationToken the opaque token for [broadcastSigned] / + * [releaseReservation]. Valid only for this wallet instance and only until + * consumed by one of those calls (or released by [close] / GC). + */ + class SignedCoreTransaction internal constructor( + val txidHex: String, + val rawTxBytes: ByteArray, + val feeDuffs: Long, + val reservationToken: Long, + ) : AutoCloseable { + + // GC backstop: releases the token if it was neither broadcast nor + // released. The action must not reference this object (it would never + // become phantom-reachable), so it captures the token by value. + private val cleanable = NativeCleaner.register(this, TokenRelease(reservationToken)) + + /** + * Release the funding reservation if this payment was neither broadcast + * nor released, and drop the token. Idempotent — safe to call after a + * [broadcastSigned] / [releaseReservation] (native no-op) and safe to + * call twice. The [NativeCleaner] backstop runs the same release on GC + * if you never call [close]. + */ + override fun close() = cleanable.clean() + + override fun equals(other: Any?): Boolean = + other is SignedCoreTransaction && + txidHex == other.txidHex && + rawTxBytes.contentEquals(other.rawTxBytes) && + feeDuffs == other.feeDuffs && + reservationToken == other.reservationToken + + override fun hashCode(): Int { + var result = txidHex.hashCode() + result = 31 * result + rawTxBytes.contentHashCode() + result = 31 * result + feeDuffs.hashCode() + result = 31 * result + reservationToken.hashCode() + return result + } + + override fun toString(): String = + "SignedCoreTransaction(txidHex=$txidHex, feeDuffs=$feeDuffs, " + + "reservationToken=$reservationToken, rawTxBytes=${rawTxBytes.size} bytes)" + + /** Releases the reservation token exactly once, on [close] or GC. */ + private class TokenRelease(private val token: Long) : Runnable { + override fun run() { + WalletManagerNative.coreWalletReleaseSignedPayment(token) + } + } + + internal companion object { + /** + * Decode the big-endian native BLOB the atomic + * finalize-and-register FFI returns: `u64 token, u64 feeDuffs, + * u32 txidLen, txid utf8, u32 txBytesLen, txBytes`. + */ + internal fun fromRegisterBlob(blob: ByteArray): SignedCoreTransaction { + val buffer = java.nio.ByteBuffer.wrap(blob) // big-endian by default + // The blob is produced by our own JNI packer, not an attacker, so + // a malformed layout is a bug rather than a reachable hostile + // input. Validate the two length prefixes anyway: a negative + // length (a u32 high bit read as a signed Int) would make + // `ByteArray(len)` throw NegativeArraySizeException, and a length + // past the buffer would throw BufferUnderflowException — both + // opaque. `require` turns either into a precise, greppable message + // naming the field and the offending value. + fun readLengthPrefixedBytes(field: String): ByteArray { + val len = buffer.int + require(len in 0..buffer.remaining()) { + "register blob: $field length $len out of range " + + "0..${buffer.remaining()} (blob is ${blob.size} bytes)" + } + val bytes = ByteArray(len) + buffer.get(bytes) + return bytes + } + val token = buffer.long + val feeDuffs = buffer.long + val txidBytes = readLengthPrefixedBytes("txid") + val rawTxBytes = readLengthPrefixedBytes("txBytes") + return SignedCoreTransaction( + txidHex = String(txidBytes, Charsets.UTF_8), + rawTxBytes = rawTxBytes, + feeDuffs = feeDuffs, + reservationToken = token, + ) + } + } + } + + /** + * Build and sign a Core payment to [recipients] WITHOUT broadcasting, + * reserving the funding UTXOs and returning a [SignedCoreTransaction] whose + * [SignedCoreTransaction.reservationToken] later drives [broadcastSigned] + * (server acked) or [releaseReservation] (abandoned / server nacked). + * + * The BIP70/BIP270 counterpart to [sendToAddresses]: those protocols sign, + * POST the raw bytes to a merchant server, and broadcast only on ack, which + * a single build-sign-broadcast call cannot express. The + * `new → addOutput* → finalizeSignedPayment` build runs under the same + * per-wallet teardown gate ([gate]) as [sendToAddresses]. The single atomic + * finalize does select + reserve + sign + register under the wallet-manager + * lock (closing the funding/signing selection race the old setFunding + + * buildSigned split had), so once this returns the reservation holds the + * inputs and [broadcastSigned] / [releaseReservation] operate on the token + * later. + * + * The returned [SignedCoreTransaction] OWNS the token: it is [AutoCloseable] + * with a GC/[NativeCleaner] backstop, so a token that is neither broadcast + * nor released is never orphaned. If a cancellation discards the result + * *after* the blocking native registration already minted the token, this + * call closes it deterministically on the way out (the gate's + * cancellation-cleanup handoff) rather than leaving the reservation to the + * GC backstop or the reservation TTL. Otherwise the backstop releases on GC, + * or the caller releases via an explicit [SignedCoreTransaction.close]; + * consuming the token via [broadcastSigned] / [releaseReservation] makes + * that release a native no-op. + * + * Process-death note: the reservation is in-memory. An app crash between + * this call and [broadcastSigned] drops the reservation on restart (the + * 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.opWithCleanupOnCancellation( + // Native finalization mints the token and transfers reservation ownership + // to it before the blocking JNI call returns, so the token already exists + // by the time `withContext` dispatches back to the caller. That handoff is + // a prompt-cancellation point: if the caller was cancelled while JNI ran, + // the completed SignedCoreTransaction is discarded before anyone can hold + // it, leaving only the GC/NativeCleaner backstop — the reservation would + // then sit until an unpredictable GC cycle or the reservation TTL. + // Closing the discarded result releases the token deterministically. + cleanup = { payment: SignedCoreTransaction -> payment.close() }, + ) { + require(accountIndex >= 0) { "accountIndex must be non-negative, got $accountIndex" } + require(recipients.isNotEmpty()) { "recipients must not be empty" } + require(recipients.all { it.second > 0 }) { + "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, + /** + * Opaque handle for the key-wallet reservation token this build stamped + * on its inputs (0 = it reserved nothing). + * + * Pass it to [releasePaymentReservation] together with [txBytes] when + * abandoning the build. It is the ownership proof that stops the release + * from freeing a *different* build's reservation on the same outpoint — + * possible whenever key-wallet's TTL swept this build's reservation and + * another build re-took it (dashpay/platform#4247 review). The token + * itself never crosses the native boundary: it has no public constructor + * precisely so it cannot be forged. + * + * Not a resource: there is nothing to close, and holding a stale handle + * is harmless — it simply stops matching. + */ + val reservationHandle: Long, + ) { + override fun equals(other: Any?): Boolean = + other is SignedCorePayment && + txBytes.contentEquals(other.txBytes) && + fee == other.fee && + change == other.change && + reservationHandle == other.reservationHandle + + override fun hashCode(): Int = + ((31 * txBytes.contentHashCode() + fee.hashCode()) * 31 + change.hashCode()) * 31 + + reservationHandle.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). + * + * **Cancellation.** The blocking native build cannot observe cancellation + * once it has started, and it reserves the funding UTXOs before returning. + * If the caller is cancelled while it runs, `withContext` applies prompt + * cancellation and discards the completed [SignedCorePayment] — including + * the [SignedCorePayment.txBytes] and [SignedCorePayment.reservationHandle] + * that are the only way to release that reservation. It would then sit until + * key-wallet's TTL backstop, or forever at height 0 where the sweep never + * runs. This call therefore releases a discarded result on the way out, so a + * cancelled send strands nothing (dashpay/platform#4247 review). The cleanup + * is best-effort and never masks the cancellation. + * + * @param recipients `(address, amountDuffs)` pairs; must be non-empty and + * every amount positive. + * @param coreSignerHandle the manager's `MnemonicResolverHandle` + * (`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.opWithCleanupOnCancellation( + // The native build reserves the funding UTXOs before it returns, so by + // the time `withContext` dispatches back to the caller the reservation + // already exists. That handoff is a prompt-cancellation point: a caller + // cancelled while JNI ran never receives the payment, and with it never + // receives the tx bytes + reservation handle that are the ONLY way to + // release. Releasing the discarded payment here is the deterministic + // alternative to waiting out a TTL that, at height 0, never fires. + // Mirrors the token-owning `buildSignedPayment` overload above. + cleanup = { payment: SignedCorePayment -> + releaseDiscardedPayment(payment, fundingPath) + }, + ) { + require(recipients.isNotEmpty()) { "recipients must not be empty" } + require(recipients.all { it.second > 0 }) { "every recipient amount must be positive" } + require(feePerKb >= 0) { "feePerKb must be non-negative, got $feePerKb" } + + val outputsBlob = encodePaymentOutputs(recipients) + mapNativeErrors { + coreWallet().use { core -> + decodeSignedPayment( + core.buildSignedPayment(outputsBlob, feePerKb, coreSignerHandle, fundingPath), + ) + } + } + } + + /** + * Best-effort, synchronous release of a [buildSignedPayment] result that + * coroutine cancellation discarded before the caller could take ownership. + * + * Blocking rather than suspending on purpose: it runs from + * [opWithCleanupOnCancellation]'s `finally`, where the coroutine is already + * cancelled and any suspension would be refused. The underlying native call + * is a fast in-memory map operation, so blocking that thread is not a real + * cost. + * + * Never throws. A failure here would replace the caller's + * `CancellationException` with an unrelated error thrown out of a `finally`, + * and the reservation is recoverable by other means (TTL, or an explicit + * release once sync has a height) whereas a corrupted cancellation signal is + * not. `Throwable` rather than `Exception` because the native boundary can + * surface `UnsatisfiedLinkError` and friends. + */ + private fun releaseDiscardedPayment(payment: SignedCorePayment, fundingPath: String?) { + try { + coreWallet().use { core -> + core.releasePaymentReservation( + payment.txBytes, + fundingPath, + payment.reservationHandle, + ) + } + } catch (t: Throwable) { + android.util.Log.w( + "ManagedPlatformWallet", + "failed to release the reservation of a cancelled buildSignedPayment; " + + "its inputs stay reserved until the TTL backstop", + t, + ) + } + } + + /** + * Broadcast the deferred payment behind [token] (from [buildSignedPayment]) + * and return its broadcast txid — the "merchant server acked" arm. Consumes + * 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) + } + } + + /** + * Release the UTXO reservation a [buildSignedPayment] call took, for a + * build this caller has decided **not** to broadcast. + * + * [buildSignedPayment] deliberately leaves its selected inputs reserved on + * success, because the expected next step is a broadcast. If the build is + * abandoned instead — the user backed out of the confirmation screen, an + * upstream check failed, the send screen is being torn down — this must be + * called or those coins stay unselectable. + * + * **Why it matters (dashpay/platform#4247 review).** Without it the inputs + * are stranded until key-wallet's TTL backstop reclaims them 24 blocks + * (~1 hour) later. Worse, that backstop does not merely run late but does + * not run at all before the first sync completes: the sweep early-returns + * while the wallet has no processed height, so a reservation taken at + * height 0 is never reclaimed for the life of the process. A single + * abandoned build on a freshly restored wallet could otherwise strand the + * entire balance. This call consults no height, so it is the one release + * path that works pre-sync. + * + * **Releases only inputs this build still owns.** The guard is + * [reservationHandle], not the transaction. An earlier version of this + * contract claimed the transaction alone sufficed, because "a reserved + * outpoint is skipped by every other build's coin selection". That was + * wrong: key-wallet's TTL sweep reclaims a reservation after 24 blocks, and + * from that moment a concurrent build can legitimately re-reserve the very + * same outpoint. A release by outpoint would then free the OTHER build's + * inputs and let coin selection hand them to a second transaction + * (dashpay/platform#4247 review). + * + * **For abandoned builds ONLY — never call this after a broadcast.** The + * previous contract advertised it as safe in an unconditional `finally`, + * reasoning that sync removes a broadcast spend from the UTXO set anyway. + * The gap is the window *before* sync observes it: this SDK does not + * broadcast (dashj does), so between a successful broadcast and sync + * processing that spend the inputs are still in the UTXO set, and the + * reservation is the only thing keeping a second build off them. Releasing + * there invites a conflicting transaction. Branch on "did I broadcast it?" — + * something the caller always knows — not on a `finally`. + * + * **Idempotent** across repeated releases of the *same abandoned build*: + * the second call finds the token owns nothing and does nothing. + * + * @param txBytes [SignedCorePayment.txBytes] from the build being + * abandoned. + * @param fundingPath the **same** [fundingPath] the build was given, so the + * release lands on the account holding the reservation; `null` means the + * unmixed BIP44 account, exactly as it does for the build. A path naming + * a different account is harmless but frees nothing. + * @param reservationHandle the **same** + * [SignedCorePayment.reservationHandle] that build returned. An + * unrecognised non-zero handle throws rather than falling back to an + * unguarded release. + */ + suspend fun releasePaymentReservation( + txBytes: ByteArray, + fundingPath: String? = null, + reservationHandle: Long, + ): Unit = gate.op { + require(txBytes.isNotEmpty()) { "txBytes must not be empty" } + mapNativeErrors { + coreWallet().use { core -> + core.releasePaymentReservation(txBytes, fundingPath, reservationHandle) + } + } + } + + /** + * Release the reservation [payment] holds — the object form of + * [releasePaymentReservation], and the one to prefer: it pairs the + * transaction bytes with their own reservation handle, so the two can never + * be mismatched between two in-flight builds. + * + * Same contract: for a build being **abandoned**, never one that was + * broadcast. + * + * @param fundingPath the **same** path [buildSignedPayment] was given. + */ + suspend fun releasePaymentReservation( + payment: SignedCorePayment, + fundingPath: String? = null, + ): Unit = releasePaymentReservation( + txBytes = payment.txBytes, + fundingPath = fundingPath, + reservationHandle = payment.reservationHandle, + ) + /** * The wallet's Platform-payment addresses that currently hold credits, * each as a [FundingInput] whose `credits` is the full cached balance — @@ -625,6 +1151,48 @@ 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, u64 reservationHandle,` then the signed transaction + * bytes (big-endian). + */ + private fun decodeSignedPayment(packed: ByteArray): SignedCorePayment { + require(packed.size >= 24) { + "signed-payment result too short (${packed.size} bytes, need >= 24)" + } + val buffer = java.nio.ByteBuffer.wrap(packed) // big-endian by default + val fee = buffer.long + val change = buffer.long + val reservationHandle = buffer.long + val txBytes = ByteArray(buffer.remaining()) + buffer.get(txBytes) + return SignedCorePayment( + txBytes = txBytes, + fee = fee, + change = change, + reservationHandle = reservationHandle, + ) + } + /** * 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 8879712206..1f8f41137b 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -115,6 +115,74 @@ class DashSdkErrorTest { ) // The message must warn against retrying (distinct from the anchor case). assertTrue(broadcastUnconfirmed.message!!.contains("do NOT retry")) + + // Definitive broadcast rejection (26) must reach callers as its own type, + // NOT as Generic: it is the definitive counterpart to the ambiguous + // TransactionBroadcastUnconfirmed (20), and on the deferred path the + // reservation was released and the token consumed — so it is not + // retryable in place, it must be rebuilt. + val rejected = DashSdkError.fromNative(DashSDKException(offset + 26, "bad-txns-inputs-spent")) + assertTrue( + "code 26 must not fall through to Generic", + rejected is DashSdkError.PlatformWallet.TransactionBroadcastRejected, + ) + assertFalse( + "TransactionBroadcastRejected must NOT be retryable in place (rebuild the payment)", + rejected.isRetryable, + ) + assertEquals("bad-txns-inputs-spent", rejected.message) + + // Deferred build/broadcast: the three sibling reservation-token failures + // map to three distinct typed errors, none retryable. + val agedOut = DashSdkError.fromNative(DashSDKException(offset + 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, + ) } @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..337e3431cf --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/SignedCoreTransactionTest.kt @@ -0,0 +1,76 @@ +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 + + // Disarm the cleaner backstop before `signed` becomes unreachable: this + // is a pure-JVM test with no cdylib loaded, so the registered native + // release must never fire from the cleaner thread. (NativeCleaner already + // contains the resulting UnsatisfiedLinkError in `runCatching`, so this is + // determinism/hygiene rather than a crash fix.) + signed.close() + } + + @Test + 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..85713eb8e8 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -27,6 +27,11 @@ fn classify_broadcast_result( /// Success and `MaybeSent` both permanently consume the handle. A definitive /// rejection also consumes it after releasing the reservation. This prevents /// accidental rebroadcast through the same ownership token. +/// +/// A handle whose wallet generation is no longer registered in the manager +/// (removed, or re-created under the same id) is refused with `NotFound` (98) +/// **before** the network is touched; the handle is consumed and its reservation +/// reconciled. This mirrors the deferred-token path's `WalletRemoved` → 98. #[no_mangle] pub unsafe extern "C" fn core_wallet_broadcast_signed_transaction_v2( handle: Handle, @@ -47,14 +52,46 @@ 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(); + + // Hold this generation's lifecycle gate across BOTH the liveness check and + // the send. The `is_same_generation` check above compares two HANDLES, so it + // passes for a removed generation — both sides name the same removed wallet — + // and nothing further down re-checks: `broadcast_finalized_transaction` goes + // straight to the broadcaster with no manager lookup. Without this, two + // retained handles push a deleted wallet's transaction onto the network, + // where it can conflict with inputs a re-created generation has since + // selected (`dashpay/platform#4185`). + // + // The gate makes this atomic rather than check-then-act: a teardown takes the + // exclusive side, so it cannot interleave between the check and the send. + // Scoped per generation, so this send — up to the broadcaster's timeout — + // blocks only THIS wallet's teardown, never an unrelated wallet's. + let (_lifecycle, wallet_is_live) = runtime().block_on(async { + let gate = wallet.generation_payment_guard().await; + let live = wallet.is_current_generation().await; + (gate, live) + }); + if !wallet_is_live { + runtime().block_on(finalized.wallet.abandon_transaction(&finalized.transaction)); + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + "wallet is no longer registered in the manager (removed or re-created); the \ + transaction was NOT broadcast and its reservation was reconciled" + .to_string(), + ); + } + let result = runtime().block_on( finalized .wallet @@ -90,7 +127,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 +136,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 +274,27 @@ mod outcome_tests { ); } + /// An error raised BEFORE the transaction reached the network must not + /// report a txid — the caller would otherwise track a transaction that was + /// never broadcast. The code itself is whatever the blanket `From` impl + /// maps the variant to; `TransactionBuild` gained a dedicated + /// `ErrorTransactionBuild` code (dashpay/platform#4247 review) instead of + /// flattening to `ErrorUnknown`, and the txid contract is unchanged by + /// that. #[test] fn operational_error_does_not_carry_a_txid() { let outcome = classify_broadcast_result( Err(PlatformWalletError::TransactionBuild("invalid".to_string())), txid(4), ); - assert_eq!(outcome.0, None); - assert_eq!(outcome.1.code, PlatformWalletFFIResultCode::ErrorUnknown); + assert_eq!( + outcome.0, None, + "a pre-broadcast failure must report no txid" + ); + assert_eq!( + outcome.1.code, + PlatformWalletFFIResultCode::ErrorTransactionBuild + ); } } diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs index 8e12ebc178..4ca697bb41 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,15 @@ mod addresses; mod broadcast; +pub(crate) mod payment_reservation; +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/payment_reservation.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/payment_reservation.rs new file mode 100644 index 0000000000..277a5fd9da --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/payment_reservation.rs @@ -0,0 +1,307 @@ +//! ABI-side handle table for the raw-payment path's key-wallet reservation +//! tokens. +//! +//! # Why a table and not just the token +//! +//! [`core_wallet_release_payment_reservation`](super::core_wallet_release_payment_reservation) +//! must be *owner-guarded*: it may only free inputs the abandoned build still +//! owns, or it can free a reservation key-wallet's TTL swept and a concurrent +//! build re-took — a double-spend window (dashpay/platform#4247 review). +//! The proof of ownership is key-wallet's `ReservationToken`, and that type +//! **cannot cross the C ABI**: it is deliberately opaque, with a private counter +//! and no public constructor, precisely so a caller cannot forge one and release +//! another build's inputs. Serializing it to a `u64` and rebuilding it on the way +//! back in is therefore not available — and would defeat the point if it were. +//! +//! So the token stays in Rust and an opaque, table-allocated +//! [`PaymentReservationHandle`] crosses the boundary in its place. This is the +//! same indirection `SignedPaymentRegistry` uses for the deferred-broadcast path +//! (its `ReservationToken` payment handle is a `u64` over the ABI while the +//! key-wallet funding token it guards stays Rust-side); the two token spaces are +//! deliberately separate types so a handle from one can never be presented to the +//! other. +//! +//! Forgery-resistance is preserved by construction: an unknown handle resolves to +//! nothing, so a fabricated `u64` releases nothing rather than acting on some +//! other build's token. +//! +//! # Lifetime and bounding +//! +//! Entries are **not** consumed by a release. Two reasons: the release stays +//! idempotent (a second release re-resolves the same token, which by then owns +//! nothing — a no-op inside key-wallet rather than an "unknown handle" error), +//! and the happy path never releases at all. That second point is what forces a +//! bound: this primitive does not broadcast — dashj does — so the SDK is never +//! told that a build went out on the wire, and a build → broadcast payment simply +//! leaves its entry behind. +//! +//! The table is therefore a fixed-capacity FIFO ring of [`MAX_ENTRIES`]: minting +//! entry N evicts entry N − [`MAX_ENTRIES`]. Reaching eviction takes +//! [`MAX_ENTRIES`] builds that were never released, i.e. builds that were +//! broadcast — whose reservations are backed by genuinely spent coins and whose +//! handles are of no further use. A build that is going to be abandoned is +//! abandoned within seconds of being made, so eviction cannot realistically +//! overtake one. +//! +//! An evicted or unknown handle is **refused**, never silently downgraded to the +//! unguarded release. Downgrading is exactly the behavior this change removes. +//! +//! In-memory only, like every reservation in this stack: a crash drops the table +//! and the underlying `ReservationSet` together, so nothing leaks across a +//! restart. + +use std::collections::{HashMap, VecDeque}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, MutexGuard}; + +use key_wallet::ReservationToken as FundingReservationToken; +use once_cell::sync::Lazy; + +/// Capacity of the FIFO ring. Sized so that reaching eviction requires thousands +/// of un-released builds in one process lifetime — several orders of magnitude +/// past any real send volume between app launches — while costing a fixed few +/// tens of kilobytes. +const MAX_ENTRIES: usize = 4096; + +/// Opaque, process-unique handle standing in for a key-wallet +/// [`FundingReservationToken`] across the C ABI. +/// +/// `0` is reserved and never minted: it is the "this build reserved nothing" +/// sentinel, matching the FFI's null-handle convention. A distinct newtype rather +/// than a bare `u64` so it can never be confused with the deferred path's +/// `platform_wallet::ReservationToken` payment handle, which is a different token +/// space entirely. +#[repr(transparent)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct PaymentReservationHandle(u64); + +impl PaymentReservationHandle { + /// The sentinel meaning "the build took no reservation". + pub(crate) const NONE: Self = Self(0); + + /// The raw wire value handed across the ABI. + pub(crate) const fn as_u64(self) -> u64 { + self.0 + } +} + +impl From for PaymentReservationHandle { + fn from(value: u64) -> Self { + Self(value) + } +} + +/// A handle the table does not (or no longer) know: forged, already evicted, or +/// minted by a previous process. Releasing against it is refused. +#[derive(Debug)] +pub(crate) struct UnknownReservationHandle(pub(crate) PaymentReservationHandle); + +/// The interior state: the live tokens plus the insertion order that drives +/// eviction. +struct Entries { + tokens: HashMap, + /// Handles in mint order. The front is the oldest and is evicted first. + order: VecDeque, +} + +/// Bounded FIFO table of the reservation tokens currently addressable from the +/// host. +/// +/// Generic over the stored token purely so the bounding/eviction/forgery logic +/// can be unit-tested: a real [`FundingReservationToken`] has no public +/// constructor (by design), so a test cannot fabricate one. The production +/// instantiation is [`PAYMENT_RESERVATIONS`]. +pub(crate) struct PaymentReservationTable { + next_handle: AtomicU64, + entries: Mutex>, +} + +impl PaymentReservationTable { + /// A fresh, empty table. Not `const` — `HashMap::new` is not a const fn — so + /// the process-global below is a `Lazy` rather than a plain `static`, exactly + /// like `SIGNED_PAYMENT_REGISTRY`. + pub(crate) fn new() -> Self { + Self { + // Start at 1 so `PaymentReservationHandle::NONE` is never minted. + next_handle: AtomicU64::new(1), + entries: Mutex::new(Entries { + tokens: HashMap::new(), + order: VecDeque::new(), + }), + } + } + + /// Lock the table, recovering from a poisoned mutex rather than panicking. + /// This is one process-global, so propagating a poison would permanently + /// disable the abandon path for every wallet; the guarded state is a map plus + /// a queue with no invariant a partial write could break. Mirrors + /// `SignedPaymentRegistry::lock` and key-wallet's `ReservationSet::lock`. + fn lock(&self) -> MutexGuard<'_, Entries> { + self.entries + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// Record `token` and return the handle that addresses it, evicting the + /// oldest entry if the table is full. `None` mints nothing and yields + /// [`PaymentReservationHandle::NONE`]. + pub(crate) fn stash(&self, token: Option) -> PaymentReservationHandle { + let Some(token) = token else { + return PaymentReservationHandle::NONE; + }; + let handle = PaymentReservationHandle(self.next_handle.fetch_add(1, Ordering::SeqCst)); + + let mut entries = self.lock(); + entries.tokens.insert(handle, token); + entries.order.push_back(handle); + while entries.order.len() > MAX_ENTRIES { + if let Some(oldest) = entries.order.pop_front() { + entries.tokens.remove(&oldest); + } + } + handle + } + + /// Resolve `handle` to the token it addresses, WITHOUT consuming it — see the + /// module docs on why the release must stay idempotent. + /// + /// [`PaymentReservationHandle::NONE`] resolves to `Ok(None)`: the build took + /// no reservation, so there is nothing to guard and nothing to free. Any + /// other unrecognised handle is an error rather than a silent `None`, because + /// a `None` here would downgrade the caller to the unguarded release this + /// change exists to remove. + pub(crate) fn resolve( + &self, + handle: PaymentReservationHandle, + ) -> Result, UnknownReservationHandle> { + if handle == PaymentReservationHandle::NONE { + return Ok(None); + } + self.lock() + .tokens + .get(&handle) + .copied() + .map(Some) + .ok_or(UnknownReservationHandle(handle)) + } + + /// Number of live entries. Test-only introspection. + #[cfg(test)] + fn len(&self) -> usize { + self.lock().tokens.len() + } +} + +/// Process-global table backing `core_wallet_build_signed_payment` / +/// `core_wallet_release_payment_reservation`. +pub(crate) static PAYMENT_RESERVATIONS: Lazy> = + Lazy::new(PaymentReservationTable::new); + +#[cfg(test)] +mod tests { + use super::*; + + /// A fresh table per test — the process-global one is shared with every other + /// test in the binary and cannot support exact-count assertions. `u64` stands + /// in for the real token, whose constructor is private by design. + fn table() -> PaymentReservationTable { + PaymentReservationTable::new() + } + + #[test] + fn the_none_sentinel_round_trips_without_minting() { + let t = table(); + let handle = t.stash(None); + assert_eq!(handle, PaymentReservationHandle::NONE); + assert_eq!(handle.as_u64(), 0); + assert_eq!(t.len(), 0, "the sentinel must not occupy an entry"); + assert!( + matches!(t.resolve(handle), Ok(None)), + "the sentinel resolves to 'no token', not an error" + ); + } + + /// A fabricated handle must resolve to an error, never to some other build's + /// token and never to a silent `None` — a `None` would downgrade the caller + /// to the unguarded release that reopens the double-spend window. + #[test] + fn an_unknown_handle_is_refused() { + let t = table(); + t.stash(Some(11)); + for forged in [2u64, 99, u64::MAX] { + assert!( + matches!( + t.resolve(PaymentReservationHandle::from(forged)), + Err(UnknownReservationHandle(_)) + ), + "handle {forged} was never minted and must be refused" + ); + } + } + + /// Handles are unique and never collide with the `NONE` sentinel, so a stale + /// handle can always be told apart from a live one. + #[test] + fn minted_handles_are_unique_and_never_zero() { + let t = table(); + let mut seen = std::collections::HashSet::new(); + for i in 0..1_000u64 { + let h = t.stash(Some(i)); + assert_ne!(h, PaymentReservationHandle::NONE); + assert!(seen.insert(h), "handle {h:?} was minted twice"); + } + } + + /// The ring is bounded: past `MAX_ENTRIES` the oldest handles are evicted and + /// then refused, while every newer one stays resolvable. + #[test] + fn the_table_is_bounded_and_evicts_oldest_first() { + let t = table(); + let first: Vec<_> = (0..MAX_ENTRIES as u64).map(|i| t.stash(Some(i))).collect(); + assert_eq!(t.len(), MAX_ENTRIES); + assert!( + t.resolve(first[0]).is_ok(), + "nothing is evicted while the table is merely full" + ); + + // One more mint evicts exactly one — the oldest. + let newest = t.stash(Some(9_999)); + assert_eq!(t.len(), MAX_ENTRIES, "capacity must stay fixed"); + assert!( + matches!(t.resolve(first[0]), Err(UnknownReservationHandle(_))), + "the oldest handle must have been evicted" + ); + assert!( + t.resolve(first[1]).is_ok() && t.resolve(newest).is_ok(), + "every other handle must still resolve" + ); + } + + /// Resolving does NOT consume, which is what keeps the release idempotent. + #[test] + fn resolving_does_not_consume_the_handle() { + let t = table(); + let handle = t.stash(Some(42)); + for attempt in 0..5 { + assert!( + matches!(t.resolve(handle), Ok(Some(42))), + "resolve attempt {attempt} must still find the token" + ); + } + assert_eq!(t.len(), 1); + } + + /// Each stashed token is addressed by its own handle — no aliasing, which is + /// what stops one build's release from presenting another build's token. + #[test] + fn handles_address_their_own_token() { + let t = table(); + let handles: Vec<_> = (0..16u64).map(|i| t.stash(Some(i * 7))).collect(); + for (i, handle) in handles.iter().enumerate() { + assert!( + matches!(t.resolve(*handle), Ok(Some(v)) if v == i as u64 * 7), + "handle {i} resolved to the wrong token" + ); + } + } +} diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs new file mode 100644 index 0000000000..5023dde514 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/send.rs @@ -0,0 +1,540 @@ +//! 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; an 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::core_wallet::payment_reservation::{ + PaymentReservationHandle, UnknownReservationHandle, PAYMENT_RESERVATIONS, +}; +use crate::error::*; +use crate::handle::{Handle, CORE_WALLET_STORAGE}; +use crate::runtime::runtime; +use crate::utils::parse_optional_derivation_path; +use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; +use dashcore::Address as DashAddress; +use platform_wallet::PlatformWalletError; +use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle}; +use std::str::FromStr; + +/// Smallest number of bytes one encoded output row can occupy: `u32 addr_len` +/// (4) + at least one address byte + `u64 amount` (8). Used to reject an +/// impossible `count` before any allocation. +const MIN_ENCODED_OUTPUT_LEN: usize = 4 + 1 + 8; + +/// Decode the recipients blob the caller passes to +/// [`core_wallet_build_signed_payment`]. Layout (big-endian): +/// +/// ```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). +/// * `out_reservation_handle` — receives the opaque handle standing in for the +/// key-wallet reservation token this build stamped on its inputs, or `0` if it +/// reserved nothing. Pass it back to +/// [`core_wallet_release_payment_reservation`] to abandon the build; it is what +/// makes that release owner-guarded. Not a pointer and nothing to free — see +/// [`payment_reservation`](crate::core_wallet::payment_reservation) for why the +/// token itself cannot cross this boundary. +/// +/// # Safety +/// All pointers must be valid; `outputs_blob` must be readable for +/// `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, + out_reservation_handle: *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); + check_ptr!(out_reservation_handle); + + 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; + // Park the key-wallet token behind an opaque handle: it is not + // ABI-representable (private counter, no public constructor — deliberately, + // so it cannot be forged), so the host carries the handle instead. + *out_reservation_handle = PAYMENT_RESERVATIONS + .stash(payment.reservation_token) + .as_u64(); + + PlatformWalletFFIResult::ok() +} + +/// 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)); + } +} + +/// Release the UTXO reservation a [`core_wallet_build_signed_payment`] call +/// took, for a build the caller has decided NOT to broadcast. +/// +/// `build_signed_payment` leaves its selected inputs reserved on success, +/// because the expected next step is a broadcast. A caller that abandons the +/// build instead — the user backed out, an upstream check failed, the app is +/// tearing down — must call this, or those coins stay unselectable until +/// key-wallet's 24-block TTL backstop reclaims them. Before the first sync +/// completes that backstop never fires at all (`ReservationSet::sweep` +/// early-returns at height 0), so without this call a single abandoned build +/// on a freshly restored wallet can strand the whole balance for the life of +/// the process (dashpay/platform#4247 review). This call consults no height. +/// +/// Releases ONLY inputs this build still owns. The guard is +/// `reservation_handle`, not the transaction: between the build and this call +/// key-wallet's TTL can sweep the reservation and a concurrent build can +/// re-reserve the same outpoint, at which point releasing by outpoint alone would +/// free the OTHER build's inputs and open a double-spend window +/// (dashpay/platform#4247 review). +/// +/// FOR ABANDONED BUILDS ONLY — do NOT call this after a successful broadcast. +/// This primitive does not broadcast, so between the caller's broadcast and sync +/// processing that spend the inputs are still in the UTXO set and the reservation +/// is the only thing keeping a second build off them. Releasing there invites a +/// conflicting transaction. Repeated releases of the same abandoned build are +/// idempotent; that is the only sense in which this is safe to call twice. +/// +/// * `handle` — a core-wallet handle (`platform_wallet_get_core`). +/// * `tx_bytes`/`tx_bytes_len` — the consensus-serialized signed transaction +/// exactly as `core_wallet_build_signed_payment` returned it. +/// * `funding_path_ptr`/`funding_path_len` — the SAME optional funding path the +/// build was given, so the release lands on the account holding the +/// reservation. `null` / `0` means the unmixed BIP44 account, as it does for +/// the build. A path naming a different account is harmless but frees +/// nothing. +/// * `reservation_handle` — the SAME value +/// `core_wallet_build_signed_payment` wrote to `out_reservation_handle`. `0` +/// means "that build reserved nothing" and releases nothing. Any other +/// unrecognised value is refused with `ErrorInvalidParameter` rather than +/// downgraded to an unguarded release. +/// +/// # Safety +/// `tx_bytes` must be readable for `tx_bytes_len` bytes; `funding_path_ptr`, +/// when non-null, must point to `funding_path_len` readable bytes for the +/// duration of the call. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_release_payment_reservation( + handle: Handle, + tx_bytes: *const u8, + tx_bytes_len: usize, + funding_path_ptr: *const u8, + funding_path_len: usize, + reservation_handle: u64, +) -> PlatformWalletFFIResult { + check_ptr!(tx_bytes); + + let funding_path = match parse_optional_derivation_path(funding_path_ptr, funding_path_len) { + Ok(p) => p, + Err(result) => return result, + }; + + let reservation_token = + match PAYMENT_RESERVATIONS.resolve(PaymentReservationHandle::from(reservation_handle)) { + Ok(token) => token, + Err(UnknownReservationHandle(unknown)) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!( + "reservation handle {} is unknown: it was never issued by \ + core_wallet_build_signed_payment, belongs to a previous process, or \ + has aged out of the handle table. Refusing rather than releasing \ + unguarded, which could free a concurrent build's inputs", + unknown.as_u64() + ), + ); + } + }; + + let raw = std::slice::from_raw_parts(tx_bytes, tx_bytes_len); + let transaction: dashcore::Transaction = + match dashcore::consensus::deserialize(raw).map_err(|e| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("tx_bytes is not a consensus-serialized transaction: {e}"), + ) + }) { + Ok(tx) => tx, + Err(result) => return result, + }; + + let option = CORE_WALLET_STORAGE.with_item(handle, |wallet| { + runtime().block_on(wallet.release_payment_reservation( + &transaction, + funding_path.clone(), + reservation_token, + )) + }); + + let result = unwrap_option_or_return!(option); + unwrap_result_or_return!(result); + + PlatformWalletFFIResult::ok() +} + +/// Decoder hardening tests. +/// +/// `decode_payment_outputs` parses a caller-controlled blob inside an +/// `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..528c5c28e4 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs @@ -0,0 +1,151 @@ +//! 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); + +/// Serializes tests that reason about the process-global registry's *contents*. +/// +/// [`SIGNED_PAYMENT_REGISTRY`] is one static shared by every test in the binary, +/// and the harness runs tests in parallel threads by default. Any test that +/// captures an `outstanding()` baseline and then asserts a delta against it is +/// therefore racing every other test that mints or consumes a token — the +/// baseline can be captured while a sibling's token is outstanding and compared +/// after that sibling consumed it. +/// +/// Tests take this around their whole body. Poisoning is recovered rather than +/// propagated (mirroring `SignedPaymentRegistry`'s own lock): a panic in one +/// test should fail that test, not cascade into every sibling. +#[cfg(test)] +pub(crate) static REGISTRY_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Take [`REGISTRY_TEST_LOCK`], recovering from poisoning. +#[cfg(test)] +pub(crate) fn registry_test_guard() -> std::sync::MutexGuard<'static, ()> { + REGISTRY_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// Broadcast the payment behind `token` (built earlier via +/// [`core_wallet_signed_payment_finalize`](super::transaction_builder::core_wallet_signed_payment_finalize)), +/// reconciling its UTXO reservation on +/// 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); + // Define the out-param before any fallible work. `*out_txid` is written only + // in the Ok arm below, so all five error returns (unknown handle, broadcast + // failure, interior-NUL txid) would otherwise leave the caller's pointer at + // whatever it allocated. Null-initializing means a caller that frees + // `out_txid` after an error frees a null, not an indeterminate pointer. + *out_txid = std::ptr::null_mut(); + + let core = unwrap_option_or_return!(CORE_WALLET_STORAGE.with_item(core_handle, |w| w.clone())); + + let result = + runtime().block_on(SIGNED_PAYMENT_REGISTRY.broadcast(ReservationToken::from(token), &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(), + ), + // The wallet was REMOVED from the manager, so there is no live + // generation to broadcast through. Reported as the existing `NotFound` + // (98) rather than a new code: it is exactly the "the thing you named + // does not exist" case 98 already means, and both hosts already map it. + // Distinct from `ErrorReservationWalletMismatch` (36), where a DIFFERENT + // live generation answers to the same id. Did NOT touch the network and + // is NOT retryable — the wallet is gone. + Err(e @ SignedPaymentError::WalletRemoved(_)) => { + PlatformWalletFFIResult::err(PlatformWalletFFIResultCode::NotFound, e.to_string()) + } + // Preserve the typed underlying wallet error (keeps the ambiguous + // "may already be on the network" retry semantics intact). + Err(SignedPaymentError::Broadcast(e)) => PlatformWalletFFIResult::from(e), + } +} + +/// 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(ReservationToken::from(token))); + PlatformWalletFFIResult::ok() +} diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs index 8b79efcf2a..4a77e74454 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, } @@ -90,6 +94,11 @@ impl From for AccountTypePreference { /// On success `out_transaction_handle` receives an opaque V2 handle. Consume /// it with `core_wallet_broadcast_signed_transaction_v2` or /// `core_wallet_abandon_signed_transaction_v2`. +/// +/// If the host removes (or re-creates) this wallet while the external signer is +/// running, no handle is published: the build's reservation is reconciled and +/// this returns `NotFound` (98), the same code the deferred-token sibling +/// `core_wallet_signed_payment_finalize` uses for that case. #[no_mangle] #[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn core_wallet_tx_builder_finalize( @@ -126,6 +135,43 @@ pub unsafe extern "C" fn core_wallet_tx_builder_finalize( &signer, )); let finalized = unwrap_result_or_return!(finalized); + + // Publishing the V2 handle is gated exactly like the deferred-token sibling + // below (`core_wallet_signed_payment_finalize`). `finalize_transaction` drops + // the wallet-manager write lock before awaiting the (external, possibly slow) + // signer, so the host can have removed this wallet while we were signing — + // and that removal's V2-handle sweep has then ALREADY run. Inserting now + // would publish a live handle for a removed generation that no later sweep + // catches, and `core_wallet_broadcast_signed_transaction_v2` would happily + // push it to the network: its `is_same_generation` check compares two + // handles, and a removed generation matches itself (`dashpay/platform#4185`). + // + // Hold THIS generation's lifecycle gate across BOTH the liveness check and + // the insert, so a teardown cannot interleave between them. Acquired AFTER + // the signer await, never around it: holding it across an open signing prompt + // would stall this wallet's teardown for as long as the user takes, and the + // check makes that unnecessary. + let (_lifecycle, wallet_is_live) = runtime().block_on(async { + let gate = wallet.core().generation_payment_guard().await; + let live = wallet.core().is_current_generation().await; + (gate, live) + }); + if !wallet_is_live { + // No handle was published, so nothing would ever release this build's + // reservation. Reconcile it here: the release is generation-bound, so on + // a genuine removal it is a logged no-op (the `ReservationSet` died with + // the generation), and on a re-create it correctly declines to touch the + // new generation's inputs. + runtime().block_on(wallet.core().abandon_transaction(&finalized)); + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + "wallet is no longer registered in the manager (removed or re-created while the \ + transaction was being signed); no transaction handle was published and its \ + reservation was reconciled" + .to_string(), + ); + } + *out_transaction_handle = CORE_SIGNED_TRANSACTION_V2_STORAGE.insert(FFICoreSignedTransactionV2 { wallet: wallet.core().clone(), @@ -134,6 +180,201 @@ 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); + // Zero-initialize EVERY out-param up front. The success block at the bottom + // writes all six, but the many error returns in between (build failure, + // signing failure, txid encoding) previously left five of them untouched, + // so a caller that does not check the result code would read whatever it had + // allocated — a dangling `out_tx.tx_bytes`/`out_bytes_ptr` in particular is a + // use-after-free hazard. Defined defaults make every error path leave a null + // transaction, a null txid, and zero fee/length instead. + *out_token = 0; + *out_fee = 0; + *out_txid = std::ptr::null_mut(); + *out_tx = FFICoreTransaction { + tx_bytes: std::ptr::null_mut(), + tx_len: 0, + fee: 0, + }; + *out_bytes_ptr = std::ptr::null(); + *out_bytes_len = 0; + + // `finalize_transaction` consumes the builder: reclaim both heap boxes up + // front so they are freed on every return path below. + 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); + + // `finalize_transaction` drops the wallet-manager write lock before awaiting + // the (external, possibly slow) signer, so the host can have removed this + // wallet while we were signing — and that removal's registry sweep has then + // ALREADY run. Registering now would insert a live token for a removed + // generation, which no later sweep would catch, defeating the teardown + // invariant that dropping tokens makes stale handles inert + // (`dashpay/platform#4185`). + // + // Take THIS wallet generation's lifecycle gate (shared — concurrent payments + // are unaffected) and hold it across BOTH the liveness check and the + // synchronous `register`, so a teardown cannot interleave between them. + // Deliberately acquired AFTER the signer await rather than around it: holding + // it across an open signing prompt would stall this wallet's teardown for as + // long as the user takes, and the check below makes that unnecessary. + let (_lifecycle, wallet_is_live) = runtime().block_on(async { + let gate = wallet.core().generation_payment_guard().await; + let live = wallet.core().is_current_generation().await; + (gate, live) + }); + if !wallet_is_live { + // Nothing was registered, so no token would ever release this build's + // reservation. Reconcile it here: the release is generation-bound, so on + // a genuine removal it is a logged no-op (the `ReservationSet` died with + // the generation), and on a re-create it correctly declines to touch the + // new generation's inputs. + runtime().block_on(wallet.core().abandon_transaction(&finalized)); + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + "wallet is no longer registered in the manager (removed or re-created while the \ + payment was being signed); the payment was not registered and its reservation was \ + reconciled" + .to_string(), + ); + } + + let txid = finalized.transaction().txid(); + let fee = finalized.fee(); + + // 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` CONSUMES the `SignedCoreTransaction` + // ownership object (deriving its transaction, funding account, reservation + // height, and owner-guard token internally) and binds the token to the wallet + // whose `ReservationSet` holds the inputs. Because the object is consumed + // exactly once, this finalize can yield at most one token — no second token + // can ever name the same reservation (`dashpay/platform#4185`, blocker 1). + // + // `register` is SYNCHRONOUS: its reservation-owning insert runs inline with + // no future that could be dropped before its first poll and silently strand + // the consumed reservation (`dashpay/platform#4185`). It also validates that + // this wallet is the exact generation `finalize` bound the payment to; that + // always holds here (we register through the very wallet that finalized), but + // on the impossible mismatch it hands the finalized payment back so we + // release its reservation (owner-guarded) rather than leaking it. + let token = match crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY + .register(wallet.core().clone(), finalized) + { + Ok(token) => token, + Err(err) => { + runtime().block_on(wallet.core().abandon_transaction(&err.signed)); + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorReservationWalletMismatch, + "deferred payment was finalized against a different wallet generation".to_string(), + ); + } + }; + + *out_tx = FFICoreTransaction { + tx_bytes: Box::into_raw(serialized.into_boxed_slice()) as *mut u8, + tx_len: len, + fee, + }; + *out_token = token.as_u64(); + *out_fee = fee; + *out_txid = c_txid.into_raw(); + // Borrowed view into the just-written `out_tx` buffer; the caller copies the + // 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/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index 20e5e82ac2..c3edadfa51 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -491,6 +491,17 @@ fn account_index_of(at: &key_wallet::account::AccountType) -> u32 { /// account. The pool counts are meaningful for both funds and keys /// variants; the explorer surfaces them as the headline number on /// keys-only rows where balance reads zero by construction. +/// +/// `derivation_path` is a heap-owned, NUL-terminated UTF-8 C string +/// carrying the account-level derivation path (e.g. the DIP-15 +/// `m/9'/coinType'/15'/{index}'/0x/0x` for a +/// DashPay receiving-funds account). It is `null` for account types that +/// have no derivable account-level path. Freed by +/// [`platform_wallet_manager_free_account_balances`]. Appended as the +/// LAST field so the layout change is purely additive — existing readers +/// of the earlier fields are byte-compatible, but any consumer that +/// indexes the array by struct stride (iOS/Android bindings) must be +/// regenerated against this definition before use. #[repr(C)] pub struct AccountBalanceEntryFFI { pub type_tag: crate::wallet_restore_types::AccountTypeTagFFI, @@ -506,6 +517,8 @@ pub struct AccountBalanceEntryFFI { pub locked: u64, pub keys_used: u32, pub keys_total: u32, + /// Heap-owned NUL-terminated UTF-8 derivation-path string, or `null`. + pub derivation_path: *const c_char, } // --------------------------------------------------------------------------- diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 6676678cb6..148e0320e0 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -215,13 +215,6 @@ pub enum PlatformWalletFFIResultCode { /// join instead of erroring. Swift mirror: /// `PlatformWalletResultCode.errorShutdownIncomplete`. ErrorShutdownIncomplete = 27, - // Codes 28-30 are NOT claimed here. 28 and 30 are reserved (vacated by the - // deferred-payment reservation-token trio on dashpay/platform#4185/#4256 - // when it moved to 34-36) and 29 belongs to ErrorAssetLockInsufficientFunds - // on the asset-lock funding branch (dashpay/platform#4184). Allocating any - // of them here too would merge without a textual conflict and silently - // misclassify across hosts. See - // packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md. /// A state transition could not be signed because the signer has no /// usable private key for the requested public key — the stored blob is /// missing, stranded, or written under a different Keystore/Keychain @@ -235,8 +228,104 @@ pub enum PlatformWalletFFIResultCode { /// wallet-operation failure. Not retryable as-is — the key must be /// (re-)derived first. ErrorSigningKeyUnavailable = 31, + /// Maps `PlatformWalletError::TransactionBuild`. A Core transaction could + /// not be assembled from the request — the request itself is at fault, and + /// the host must change it rather than retry it verbatim. It is the code + /// every `build_signed_payment` rejection lands on: + /// + /// * the named `funding_path` matches no spendable funds account, or names + /// a watch-only one whose coins the local mnemonic cannot sign — the two + /// failure modes the single-account design rests on + /// (dashpay/platform#4184); + /// * the request violates a monetary bound (`MAX_MONEY` output total, + /// `MAX_FEE_PER_KB` fee rate, a below-dust recipient output, or an + /// over-`MAX_STANDARD_TX_SIZE` recipient list); + /// * the recipients blob failed to decode. + /// + /// These previously flattened to `ErrorUnknown` (99), reaching Kotlin as + /// `DashSdkError.PlatformWallet.Generic` and leaving the host to + /// string-match the message to tell a bad funding path from a bad amount + /// (dashpay/platform#4247 review). The specific cause still travels in the + /// result `message` via the typed `Display`. + /// + /// Numbering: this code is **32**. The authoritative in-file allocation map + /// is the comment block immediately below this variant (and + /// `ERROR_CODE_REGISTRY.md`, dashpay/platform#4261); do not restate positions + /// here, they drift. In short: 27–33 are claimed across the merged ABI and + /// the sibling v4.1 stack (27 `ErrorShutdownIncomplete`, 29 + /// `ErrorAssetLockInsufficientFunds`, 31 `ErrorSigningKeyUnavailable`, 33 + /// `ErrorTransactionSigning`), 28 and 30 are vacated-but-reserved, and the + /// deferred-token trio `ErrorStaleReservationToken` / + /// `ErrorReservationTokenConsumed` / `ErrorReservationWalletMismatch` sits at + /// **34–36**, not in the 27–31 window an earlier revision of this comment + /// placed it in. + ErrorTransactionBuild = 32, + + // Codes 27-33 are claimed outside this PR and MUST NOT be reused here. + // The deferred-token trio below therefore occupies the contiguous block + // 34-36. Current owners (see ERROR_CODE_REGISTRY.md, dashpay/platform#4261): + // + // 27 ErrorShutdownIncomplete MERGED on v4.2-dev (dashpay/platform#4268) + // 28 (free — vacated by this PR) + // 29 ErrorAssetLockInsufficientFunds dashpay/platform#4184 + // 30 (free — vacated by this PR) + // 31 ErrorSigningKeyUnavailable dashpay/platform#4183, #4259 + // 32 ErrorTransactionBuild dashpay/platform#4247, #4256 + // 33 ErrorTransactionSigning dashpay/platform#4256 + // + // This trio previously sat at 26-28, then 27/28/30. It moved to 34-36 after + // #4268 merged `ErrorShutdownIncomplete = 27` into the v4.2-dev ABI; the + // contiguous block above every current claim ends the renumbering churn. + /// Maps `SignedPaymentError::StaleReservationToken` from the deferred + /// build → broadcast/release core-send lifecycle (`core_wallet_signed_payment_*`): + /// the token has outlived the registry's `RESERVATION_MAX_AGE_BLOCKS` bound + /// 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, - NotFound = 98, // Used exclusively for all the Option that are retuned as errors + /// 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, + + /// The named thing does not exist. + /// + /// Originally (and still mostly) the code for every `Option` returned as an + /// error — a handle that resolves to nothing, a lookup that came back empty. + /// + /// The deferred build → broadcast/release lifecycle also reports its + /// wallet-was-REMOVED case here rather than minting a fourth + /// deferred-token code, because it *is* that same "does not exist" case: + /// `core_wallet_signed_payment_broadcast` maps + /// `SignedPaymentError::WalletRemoved` (the token's wallet is no longer + /// registered in the manager), and `core_wallet_signed_payment_finalize` + /// refuses to register a payment whose wallet was removed while it was being + /// signed — reconciling that build's reservation before returning. Neither + /// touched the network. Contrast [`Self::ErrorReservationWalletMismatch`] + /// (36), where a DIFFERENT live generation answers to the same wallet id; + /// here there is no live generation at all, so there is nothing to retry + /// against (`dashpay/platform#4185`). + NotFound = 98, ErrorUnknown = 99, } @@ -387,9 +476,31 @@ 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 } + // Every `build_signed_payment` rejection that is not a shortfall + // arrives here: an unmatched or watch-only `funding_path`, a + // monetary-bound violation (MAX_MONEY / MAX_FEE_PER_KB / dust / + // MAX_STANDARD_TX_SIZE), or a recipients-blob decode failure. + // Without this arm they all flattened to `ErrorUnknown` (99), so + // the two failure modes the single-account design rests on — + // "that path names no spendable account" and "that path is + // watch-only" — were distinguishable only by string-matching the + // message (dashpay/platform#4247 review). + PlatformWalletError::TransactionBuild(..) => { + PlatformWalletFFIResultCode::ErrorTransactionBuild + } PlatformWalletError::AssetLockNotTracked(..) => { PlatformWalletFFIResultCode::ErrorAssetLockNotTracked } @@ -739,6 +850,73 @@ 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–33 are claimed across the merged ABI and the stack; the deferred-token + /// trio sits at 34–36 — see the variant's doc comment and + /// `ERROR_CODE_REGISTRY.md`). + #[test] + fn transaction_build_code_is_thirty_two() { + assert_eq!( + PlatformWalletFFIResultCode::ErrorTransactionBuild as i32, + 32 + ); + } + #[test] fn asset_lock_recovery_failures_map_to_stable_codes() { use dashcore::OutPoint; diff --git a/packages/rs-platform-wallet-ffi/src/handle.rs b/packages/rs-platform-wallet-ffi/src/handle.rs index f343e4ccc9..2a5692b2a5 100644 --- a/packages/rs-platform-wallet-ffi/src/handle.rs +++ b/packages/rs-platform-wallet-ffi/src/handle.rs @@ -78,6 +78,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 c3aba1491b..23daa81d5c 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -506,6 +506,61 @@ pub unsafe extern "C" fn platform_wallet_manager_destroy( PlatformWalletFFIResult::ok() } +/// Remove one wallet from the manager, tearing down its generation's deferred +/// state in the same linearization step. +/// +/// Generic over the persister so tests can drive the exact production sequence +/// with the in-crate test fixture (the FFI handle storage is pinned to +/// [`FFIPersister`](crate::persistence::FFIPersister)). The ordering here is the +/// invariant under test — see the `remove_wallet_lifecycle_tests` module. +pub(crate) async fn remove_wallet_and_tear_down_generation< + P: platform_wallet::changeset::PlatformWalletPersistence + 'static, +>( + manager: &platform_wallet::PlatformWalletManager

, + wallet_id: &[u8; 32], +) -> Result<(), platform_wallet::PlatformWalletError> { + // The removal and the sweep below are ONE linearization point, taken under + // the REMOVED GENERATION'S OWN lifecycle gate. `remove_wallet_with_teardown` + // owns that gate, so the ordering cannot be got wrong here — or by any other + // caller, including a direct Rust embedder that never goes through this FFI. + // + // What the single step buys (`dashpay/platform#4185`): the removal's own + // `.await`s (shielded-coordinator and identity-sync unregistration) used to + // sit in a gap between the removal and the sweep, and a concurrent + // `core_wallet_signed_payment_broadcast` on a retained handle slipped through + // it — its entry was still registered, `is_same_generation` passes (a removed + // generation matches itself), the age guard is skipped (`last_processed_height` + // is `None` once the wallet is gone, which the guard maps to "not expired"), + // and it reached the broadcaster, pushing a removed wallet's payment onto the + // network. + // + // Acquiring the gate waits for this generation's payment operations that have + // entered their liveness-check/publish section. It does NOT wait for one still + // awaiting an external signer: those take the gate only after the signature + // returns (see `core_wallet_signed_payment_finalize` and + // `core_wallet_tx_builder_finalize`), deliberately, so an open signing prompt + // cannot stall teardown. Such a late finalizer instead observes the removed + // generation at its own liveness check and abandons rather than publishing. + manager + .remove_wallet_with_teardown(wallet_id, |removed| { + // The wallet and its accounts' `ReservationSet`s are now gone from + // the manager, so the deferred-payment reservations cease to exist — + // there is nothing to reconcile. DROP (do not release) this + // generation's registry tokens and its finalized-tx V2 handles. This + // is the teardown half of the single generation policy both deferred + // paths share: it makes any stale handle to the removed generation + // inert, so a later destroy/release of a lingering handle can never + // release-by-outpoint against a re-created generation's inputs. + let core = removed.core(); + crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY + .remove_entries_for_wallet(core); + crate::handle::CORE_SIGNED_TRANSACTION_V2_STORAGE + .remove_matching(|tx| tx.wallet.is_same_generation(core)); + }) + .await?; + Ok(()) +} + /// Remove one wallet from the manager. Idempotent on missing wallets. #[no_mangle] pub unsafe extern "C" fn platform_wallet_manager_remove_wallet( @@ -516,11 +571,14 @@ pub unsafe extern "C" fn platform_wallet_manager_remove_wallet( let wallet_id_value = *wallet_id; let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { - runtime().block_on(manager.remove_wallet(&wallet_id_value)) + runtime().block_on(remove_wallet_and_tear_down_generation( + manager, + &wallet_id_value, + )) }); let result = unwrap_option_or_return!(option); match result { - Ok(_) => PlatformWalletFFIResult::ok(), + Ok(()) => PlatformWalletFFIResult::ok(), // Idempotency: a wallet that's already gone is the success // state callers want. Everything else is a real failure. Err(platform_wallet::PlatformWalletError::WalletNotFound(_)) => { @@ -806,3 +864,571 @@ mod tests { assert_eq!(result.code, PlatformWalletFFIResultCode::Success); } } + +/// Wallet-generation teardown vs. the deferred-payment registry +/// (`dashpay/platform#4185`). +/// +/// The invariant every test here defends is one sentence: **no deferred-payment +/// token for a wallet that is not currently registered in the manager is ever +/// actionable.** Removal and the registry sweep used to be two independent steps +/// with the removal's own `.await`s in the gap, and `register` could land after +/// the sweep, so the invariant held only by timing. +/// +/// These drive [`remove_wallet_and_tear_down_generation`] — the exact sequence +/// `platform_wallet_manager_remove_wallet` runs — rather than the `extern "C"` +/// wrapper, because the FFI handle storage is pinned to `FFIPersister` while the +/// wallet fixture uses the in-crate test persister. The wrapper adds only handle +/// resolution and error-code mapping on top. +#[cfg(test)] +mod remove_wallet_lifecycle_tests { + use super::*; + use crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY; + use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; + use platform_wallet::test_support::test_platform_wallet_manager; + use platform_wallet::{ + CoreWallet, ReservationToken, SignedCoreTransaction, SignedPaymentError, + }; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::Duration; + + fn dummy_tx() -> dashcore::Transaction { + dashcore::Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + } + } + + /// Mint a token against `core`. The dummy tx reserved nothing (height 0, no + /// funding token), so these tests exercise the lifecycle guards rather than + /// the age or owner guard. + fn register_token( + core: &CoreWallet, + ) -> ReservationToken { + SIGNED_PAYMENT_REGISTRY + .register( + core.clone(), + SignedCoreTransaction::new_for_test( + dummy_tx(), + 0, + AccountTypePreference::BIP44, + 0, + 0, + None, + core.test_generation_marker(), + ), + ) + .expect("register binds to the finalizing generation") + } + + /// A token is dead iff broadcasting it reports it as unknown/consumed. Token- + /// scoped on purpose: the registry is a process-global shared with every + /// other test in the binary, so `outstanding()` deltas are not reliable under + /// the default parallel test harness. + async fn assert_token_is_gone( + token: ReservationToken, + core: &CoreWallet, + ) { + match SIGNED_PAYMENT_REGISTRY.broadcast(token, core).await { + Err(SignedPaymentError::StaleToken(t)) if t == token => {} + other => panic!("token {token} should have been swept, got {other:?}"), + } + } + + /// Requirement: a broadcast must FAIL CLEANLY when the wallet is no longer in + /// the manager, rather than silently proceeding to the network. + /// + /// The setup reproduces the in-flight-finalizer resurrection directly: + /// register AFTER teardown has already swept, which is exactly what + /// `core_wallet_signed_payment_finalize` used to do when the host removed the + /// wallet during the signer await. Before the fix this token was fully + /// actionable — `is_same_generation` passes (a removed generation matches + /// itself), `last_processed_height` is `None` so the age guard is skipped, + /// and `broadcast_payment_releasing_reservation` has no wallet-existence gate + /// — so the payment went to the broadcaster. + #[test] + fn broadcasting_a_token_for_a_removed_wallet_is_refused_before_the_network() { + // Shares the process-global registry with `wallet::destroy_tests`, + // which asserts on `outstanding()` counts — serialize against it. + let _registry = crate::core_wallet::signed_payment::registry_test_guard(); + + runtime().block_on(async { + let (manager, wallet_id) = test_platform_wallet_manager().await; + let wallet = manager + .get_wallet(&wallet_id) + .await + .expect("wallet present"); + let core = wallet.core().clone(); + + remove_wallet_and_tear_down_generation(&manager, &wallet_id) + .await + .expect("remove succeeds"); + assert!( + !core.is_current_generation().await, + "the retained handle must observe its generation as gone" + ); + + let token = register_token(&core); + + match SIGNED_PAYMENT_REGISTRY.broadcast(token, &core).await { + Err(SignedPaymentError::WalletRemoved(t)) if t == token => {} + other => panic!( + "a token whose wallet was removed must be refused without a send, got {other:?}" + ), + } + + // Refusing still CONSUMES the token: the generation is gone, so there + // is nothing to reconcile and nothing to retry. + assert_token_is_gone(token, &core).await; + }); + } + + /// Requirement: removal and the registry sweep are linearized with respect to + /// broadcast. Run repeatedly to shake the interleaving. + /// + /// Both orderings are legal, so the assertion cannot simply be "the broadcast + /// is refused": + /// + /// * teardown first → the entry is swept and the broadcast is refused + /// (`StaleToken`), or the wallet is gone and it is refused + /// (`WalletRemoved`); + /// * broadcast first → it holds the shared gate, the wallet is genuinely + /// still live, the payment legitimately goes to the broadcaster, and the + /// teardown waits. + /// + /// What must be impossible is the combination the pre-fix gap allowed: + /// reaching the broadcaster even though teardown had ALREADY completed. That + /// is what the completion-order tickets pin down. Because the gate serializes + /// the two, a broadcast that reached the broadcaster must have been holding + /// the gate, so the teardown cannot have finished before it — i.e. the + /// sender's ticket must precede the remover's. Without the gate the remover + /// could finish first and the send still go out, which is exactly the + /// `dashpay/platform#4185` finding. + #[test] + fn a_broadcast_never_reaches_the_broadcaster_after_teardown_completed() { + // Shares the process-global registry with `wallet::destroy_tests`, + // which asserts on `outstanding()` counts — serialize against it. + let _registry = crate::core_wallet::signed_payment::registry_test_guard(); + + for iteration in 0..25 { + runtime().block_on(async { + let (manager, wallet_id) = test_platform_wallet_manager().await; + let wallet = manager + .get_wallet(&wallet_id) + .await + .expect("wallet present"); + let core = wallet.core().clone(); + let token = register_token(&core); + + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + // Monotonic tickets stamped the instant each operation returns, + // giving a total order over the two completions. + let ticket = Arc::new(AtomicUsize::new(0)); + + let remover = { + let barrier = Arc::clone(&barrier); + let manager = Arc::clone(&manager); + let ticket = Arc::clone(&ticket); + tokio::spawn(async move { + barrier.wait().await; + let outcome = + remove_wallet_and_tear_down_generation(&manager, &wallet_id).await; + (outcome, ticket.fetch_add(1, Ordering::SeqCst)) + }) + }; + let sender = { + let barrier = Arc::clone(&barrier); + let core = core.clone(); + let ticket = Arc::clone(&ticket); + tokio::spawn(async move { + barrier.wait().await; + let outcome = SIGNED_PAYMENT_REGISTRY.broadcast(token, &core).await; + (outcome, ticket.fetch_add(1, Ordering::SeqCst)) + }) + }; + + let (removed, remover_ticket) = remover.await.expect("remover task"); + removed.expect("remove succeeds"); + let (sent, sender_ticket) = sender.await.expect("sender task"); + + // `Ok` is unreachable in-test (the fixture's SPV client is not + // started, so the broadcaster errors), but it is the same class + // of outcome: the payment was handed to the network layer. + let reached_broadcaster = + matches!(sent, Ok(_) | Err(SignedPaymentError::Broadcast(_))); + if reached_broadcaster { + assert!( + sender_ticket < remover_ticket, + "iteration {iteration}: a payment reached the broadcaster even though \ + wallet teardown had already completed — removal is not linearized with \ + broadcast (got {sent:?})" + ); + } else { + // The only other legal outcomes are the two clean refusals. + assert!( + matches!( + sent, + Err(SignedPaymentError::StaleToken(_)) + | Err(SignedPaymentError::WalletRemoved(_)) + ), + "iteration {iteration}: unexpected outcome {sent:?}" + ); + } + + // Whichever way it went, nothing survives teardown. + assert!(!core.is_current_generation().await); + assert_token_is_gone(token, &core).await; + }); + } + } + + /// Requirement: teardown WAITS for an in-flight finalizer, so a late + /// `register` cannot resurrect a token for a removed generation. + /// + /// Deterministic. The held shared guard stands in for + /// `core_wallet_signed_payment_finalize` sitting between its liveness check + /// and its synchronous `register`. Before the fix nothing connected those two + /// operations: the teardown ran to completion — sweep included — while the + /// finalizer was signing, and the token it then inserted was permanently + /// outside any sweep. + #[test] + fn teardown_waits_for_an_in_flight_finalizer_and_then_sweeps_its_token() { + // Shares the process-global registry with `wallet::destroy_tests`, + // which asserts on `outstanding()` counts — serialize against it. + let _registry = crate::core_wallet::signed_payment::registry_test_guard(); + + runtime().block_on(async { + let (manager, wallet_id) = test_platform_wallet_manager().await; + let wallet = manager + .get_wallet(&wallet_id) + .await + .expect("wallet present"); + let core = wallet.core().clone(); + + // The finalizer enters THIS generation's gate (as the FFI does after + // signing). + let in_flight = core.generation_payment_guard().await; + + let teardown = { + let manager = Arc::clone(&manager); + tokio::spawn(async move { + remove_wallet_and_tear_down_generation(&manager, &wallet_id).await + }) + }; + + // Teardown must block on the exclusive side of the gate. + tokio::time::sleep(Duration::from_millis(200)).await; + assert!( + !teardown.is_finished(), + "teardown must wait for the in-flight finalizer to leave the gate" + ); + + // Because teardown is still waiting, the finalizer's liveness check + // sees a live wallet and its register is legitimate. + assert!( + core.is_current_generation().await, + "the wallet must still be live while a finalizer holds the gate" + ); + let token = register_token(&core); + + drop(in_flight); + teardown + .await + .expect("teardown task") + .expect("remove succeeds"); + + // The teardown that was waiting sweeps the token the finalizer + // inserted — the invariant the gate exists to restore. + assert_token_is_gone(token, &core).await; + }); + } + + // --------------------------------------------------------------------- + // V2 finalized-transaction-handle path (`dashpay/platform#4185` review). + // + // The registry-token path above was gated first; the V2 path + // (`core_wallet_tx_builder_finalize` → `CORE_SIGNED_TRANSACTION_V2_STORAGE` + // → `core_wallet_broadcast_signed_transaction_v2`) reaches the SAME + // broadcaster through a retained handle and was left ungated. Its + // `is_same_generation` check compares two HANDLES, and a removed generation + // matches itself, so two retained handles pushed a deleted wallet's + // transaction onto the network. + // --------------------------------------------------------------------- + + /// Publish a V2 finalized-transaction handle for `core`'s generation, the + /// way `core_wallet_tx_builder_finalize` does. + fn publish_v2_handle( + core: &CoreWallet, + ) -> Handle { + crate::handle::CORE_SIGNED_TRANSACTION_V2_STORAGE.insert( + crate::core_wallet::FFICoreSignedTransactionV2 { + wallet: core.clone(), + transaction: SignedCoreTransaction::new_for_test( + dummy_tx(), + 0, + AccountTypePreference::BIP44, + 0, + 0, + None, + core.test_generation_marker(), + ), + }, + ) + } + + /// Requirement: a V2 handle whose wallet generation was removed must be + /// refused BEFORE the network, exactly as the registry-token path is. + /// + /// Deterministic. The setup reproduces the late-finalizer publication + /// directly: publish the handle AFTER teardown has already swept, which is + /// what an ungated `core_wallet_tx_builder_finalize` does when the host + /// removes the wallet during the signer await. + /// + /// Before the fix this handle was fully actionable — the caller handle and + /// the embedded originating handle name the same removed generation, so + /// `is_same_generation` passes, and `broadcast_finalized_transaction` goes + /// straight to the broadcaster with no manager lookup at all — so the + /// transaction went to the network. + #[test] + fn broadcasting_a_v2_handle_for_a_removed_wallet_is_refused_before_the_network() { + let _registry = crate::core_wallet::signed_payment::registry_test_guard(); + + // The extern "C" entry points call `runtime().block_on` themselves, so + // they must be invoked from OUTSIDE a runtime context — do the async + // setup first, then call across the boundary. + let core = runtime().block_on(async { + let (manager, wallet_id) = test_platform_wallet_manager().await; + let wallet = manager + .get_wallet(&wallet_id) + .await + .expect("wallet present"); + let core = wallet.core().clone(); + + remove_wallet_and_tear_down_generation(&manager, &wallet_id) + .await + .expect("remove succeeds"); + assert!( + !core.is_current_generation().await, + "the retained handle must observe its generation as gone" + ); + core + }); + + let transaction_handle = publish_v2_handle(&core); + let core_handle = crate::handle::CORE_WALLET_STORAGE.insert(core.clone()); + + let mut out_txid: *mut std::os::raw::c_char = std::ptr::null_mut(); + let result = unsafe { + crate::core_wallet::core_wallet_broadcast_signed_transaction_v2( + core_handle, + transaction_handle, + &mut out_txid, + ) + }; + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::NotFound, + "a V2 handle whose wallet was removed must be refused without a send" + ); + assert!( + out_txid.is_null(), + "no txid may be produced for a refused broadcast" + ); + + // Refusing still CONSUMES the handle: the generation is gone, so there is + // nothing to reconcile and nothing to retry. + assert!( + crate::handle::CORE_SIGNED_TRANSACTION_V2_STORAGE + .remove(transaction_handle) + .is_none(), + "the refused V2 handle must have been consumed" + ); + } + + /// Requirement: a teardown WAITS for an in-flight V2 operation on that + /// generation, then sweeps its handle — so a V2 handle can never be published + /// into an already-swept storage and outlive its wallet. + /// + /// Deterministic. The held shared guard stands in for + /// `core_wallet_tx_builder_finalize` sitting between its liveness check and + /// its insert, or `core_wallet_broadcast_signed_transaction_v2` sitting + /// between its liveness check and the send. Before the fix the V2 path took no + /// gate at all: the teardown ran to completion — sweep included — while the + /// finalizer was signing, and the handle it then published was permanently + /// outside any sweep and fully broadcastable. + #[test] + fn teardown_waits_for_an_in_flight_v2_operation_and_then_sweeps_its_handle() { + let _registry = crate::core_wallet::signed_payment::registry_test_guard(); + + let (core, transaction_handle) = runtime().block_on(async { + let (manager, wallet_id) = test_platform_wallet_manager().await; + let wallet = manager + .get_wallet(&wallet_id) + .await + .expect("wallet present"); + let core = wallet.core().clone(); + + // The V2 operation enters this generation's gate (as the FFI does + // after signing). + let in_flight = core.generation_payment_guard().await; + + let teardown = { + let manager = Arc::clone(&manager); + tokio::spawn(async move { + remove_wallet_and_tear_down_generation(&manager, &wallet_id).await + }) + }; + + // Teardown must block on the exclusive side of the gate. + tokio::time::sleep(Duration::from_millis(200)).await; + assert!( + !teardown.is_finished(), + "teardown must wait for the in-flight V2 operation to leave the gate" + ); + + // Because teardown is still waiting, the operation's liveness check + // sees a live wallet and its publish is legitimate. + assert!( + core.is_current_generation().await, + "the wallet must still be live while a V2 operation holds the gate" + ); + let transaction_handle = publish_v2_handle(&core); + + drop(in_flight); + teardown + .await + .expect("teardown task") + .expect("remove succeeds"); + + assert!(!core.is_current_generation().await); + (core, transaction_handle) + }); + + // The teardown that was waiting swept the handle the V2 operation + // published — the invariant the gate exists to restore. + assert!( + crate::handle::CORE_SIGNED_TRANSACTION_V2_STORAGE + .remove(transaction_handle) + .is_none(), + "the in-flight V2 operation's handle must have been swept by teardown" + ); + drop(core); + } + + /// Requirement: the lifecycle gate belongs to shared wallet-generation state, + /// so removal driven through the PUBLIC Rust API is excluded too — not just + /// removal driven through this crate's FFI wrapper. + /// + /// Deterministic. The held shared guard stands in for any payment operation + /// sitting between its liveness check and the action that check authorizes (a + /// register, or a send). `PlatformWalletManager` is public and + /// `SignedPaymentRegistry` is re-exported from `platform-wallet`, so a direct + /// Rust embedder reaches `remove_wallet` without ever touching + /// [`remove_wallet_and_tear_down_generation`]. While the gate lived on the + /// FFI's process-global registry singleton, that path took the write side + /// nowhere at all: removal ran straight through, and the payment then acted on + /// a generation the manager had already dropped. + #[test] + fn public_remove_wallet_waits_for_an_in_flight_payment_on_that_generation() { + let _registry = crate::core_wallet::signed_payment::registry_test_guard(); + + runtime().block_on(async { + let (manager, wallet_id) = test_platform_wallet_manager().await; + let wallet = manager + .get_wallet(&wallet_id) + .await + .expect("wallet present"); + let core = wallet.core().clone(); + + // A payment operation on this generation is in flight. + let in_flight = core.generation_payment_guard().await; + + let remover = { + let manager = Arc::clone(&manager); + tokio::spawn(async move { manager.remove_wallet(&wallet_id).await.map(|_| ()) }) + }; + + tokio::time::sleep(Duration::from_millis(200)).await; + assert!( + !remover.is_finished(), + "PlatformWalletManager::remove_wallet must wait for an in-flight payment on the \ + generation it is removing — the public removal path takes no lifecycle exclusion" + ); + + // Because the removal is still waiting, the in-flight payment's + // liveness check sees a live wallet and its action is legitimate. + assert!( + core.is_current_generation().await, + "the wallet must still be live while a payment holds its generation gate" + ); + + drop(in_flight); + remover + .await + .expect("remover task") + .expect("remove succeeds"); + assert!(!core.is_current_generation().await); + }); + } + + /// Requirement: the gate is scoped to ONE generation, so a slow payment on one + /// wallet cannot stall an unrelated wallet's teardown. + /// + /// Deterministic, and the reason the gate could not stay on the FFI's + /// process-global `SIGNED_PAYMENT_REGISTRY` singleton. A deferred broadcast + /// holds the shared side across an SPV send — up to the broadcaster's timeout + /// — and tokio's `RwLock` is write-preferring, so on one global lock that send + /// blocked teardown, and every payment operation queued behind the waiting + /// writer, for every unrelated wallet in the process. + #[test] + fn an_in_flight_payment_does_not_block_an_unrelated_wallets_teardown() { + let _registry = crate::core_wallet::signed_payment::registry_test_guard(); + + runtime().block_on(async { + let (manager_a, wallet_id_a) = test_platform_wallet_manager().await; + let (manager_b, wallet_id_b) = test_platform_wallet_manager().await; + + let core_a = manager_a + .get_wallet(&wallet_id_a) + .await + .expect("wallet A present") + .core() + .clone(); + let core_b = manager_b + .get_wallet(&wallet_id_b) + .await + .expect("wallet B present") + .core() + .clone(); + assert!( + !core_a.is_same_generation(&core_b), + "the fixture must produce two distinct generations" + ); + + // Wallet A has a payment in flight, holding A's gate. + let in_flight_a = core_a.generation_payment_guard().await; + + // Wallet B's teardown must not care. + let teardown_b = tokio::time::timeout( + Duration::from_secs(5), + remove_wallet_and_tear_down_generation(&manager_b, &wallet_id_b), + ) + .await + .expect( + "an unrelated wallet's teardown must not wait on wallet A's in-flight payment — \ + the lifecycle gate is not scoped to the wallet generation", + ); + teardown_b.expect("remove B succeeds"); + + // A is untouched and still live; B is gone. + assert!(core_a.is_current_generation().await); + assert!(!core_b.is_current_generation().await); + + drop(in_flight_a); + }); + } +} diff --git a/packages/rs-platform-wallet-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..8ce14575a1 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -167,6 +167,16 @@ pub unsafe extern "C" fn platform_wallet_manager_get_account_balances( locked: row.balance.locked(), keys_used: row.keys_used, keys_total: row.keys_total, + // Account-level derivation-path string → heap-owned C + // string, or null when the account type has no path. + // `CString::new` only fails on an interior NUL, which a + // derivation path never contains; `.ok()` degrades that + // impossible case to null rather than panicking. + derivation_path: row + .derivation_path + .and_then(|s| std::ffi::CString::new(s).ok()) + .map(|c| c.into_raw() as *const std::os::raw::c_char) + .unwrap_or(std::ptr::null()), } }) .collect(); @@ -191,6 +201,16 @@ pub unsafe extern "C" fn platform_wallet_manager_free_account_balances( count: usize, ) { if !entries.is_null() && count > 0 { + // Reclaim each entry's heap-owned derivation-path C string before + // dropping the array backing store (mirrors the `into_raw` in the + // producer above). + let slice = std::slice::from_raw_parts_mut(entries, count); + for e in slice.iter_mut() { + if !e.derivation_path.is_null() { + let _ = std::ffi::CString::from_raw(e.derivation_path as *mut std::os::raw::c_char); + e.derivation_path = std::ptr::null(); + } + } let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(entries, count)); } } @@ -390,6 +410,133 @@ 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); + // Destroying a wrapper alias must NOT touch the deferred-payment registry. + // + // `platform_wallet_manager_get_wallet` hands out an independent handle for + // each alias of a wallet *generation*, but none of those wrappers OWN the + // logical wallet — the manager still owns it and can hand out another alias, + // `platform_wallet_get_core` yields independently-owned core handles, and + // each registry entry pins its own `CoreWallet` (keeping the reservation + // live). A registered deferred-payment token is owned by the payment flow + // that minted it, NOT by any wrapper handle, so closing or garbage-collecting + // the last wrapper must leave the token intact: a later merchant ack has to + // remain broadcastable through a retained core handle or a re-acquired alias. + // + // Token cleanup therefore follows the payment owner (an explicit + // broadcast/release) or actual wallet-generation removal + // (`platform_wallet_manager_remove_wallet`, which drops the entries because + // the reservation ceases to exist with the generation) — never a transient + // wrapper-alias count. Dropping this handle just releases its `Arc`s; the + // registry entry's own `CoreWallet` clone keeps the generation alive as long + // as a token references it. (`dashpay/platform#4185`, blocker 2.) + let _ = PLATFORM_WALLET_STORAGE.remove(handle); PlatformWalletFFIResult::ok() } + +#[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; + use platform_wallet::SignedCoreTransaction; + + fn dummy_tx() -> dashcore::Transaction { + dashcore::Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + } + } + + /// Destroying wrapper alias handles must NEVER invalidate a deferred-payment + /// token — not even when the FINAL alias is destroyed. A wrapper handle does + /// not own the logical wallet or the registered payment; the token is owned + /// by the payment flow that minted it and stays live and actionable until its + /// owner broadcasts/releases it or the generation is actually removed + /// (`platform_wallet_manager_remove_wallet`). Regression for + /// `dashpay/platform#4185` blocker 2: the old final-alias sweep consumed + /// independently-owned payments. + #[test] + fn destroying_wrapper_aliases_never_sweeps_tokens() { + // Asserts `outstanding()` DELTAS against a captured baseline, so it must + // not run while a sibling test mints or consumes tokens in the same + // process-global registry. + let _registry = crate::core_wallet::signed_payment::registry_test_guard(); + + // Async setup only. `platform_wallet_destroy` and the final `release` + // each do their own `runtime().block_on(...)`, exactly as the JNI / + // NativeCleaner threads do (never from inside a tokio runtime). Calling + // them from within an outer `block_on` would nest runtimes and abort, so + // they run on the plain test thread below. + let (manager, handle_a, handle_b, token, baseline) = runtime().block_on(async { + let (manager, wallet_id) = test_platform_wallet_manager().await; + + // Two independent handles for the SAME logical wallet, exactly as two + // `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). The + // dummy tx reserved nothing (reservation height 0, no funding token) — + // this test exercises destroy/ownership, not the age or owner guard. + let baseline = SIGNED_PAYMENT_REGISTRY.outstanding(); + let token = SIGNED_PAYMENT_REGISTRY + .register( + core.clone(), + SignedCoreTransaction::new_for_test( + dummy_tx(), + 0, + AccountTypePreference::BIP44, + 0, + 0, + None, + // Bind the finalized payment to this exact wallet + // generation so `register` accepts it (it now validates + // the wallet against the finalizing generation). + core.test_generation_marker(), + ), + ) + .expect("register with the same generation"); + assert_eq!(SIGNED_PAYMENT_REGISTRY.outstanding(), baseline + 1); + (manager, handle_a, handle_b, token, 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 → the token STILL survives: a wrapper alias + // does not own the payment, so its destruction must not consume the token. + let result = unsafe { platform_wallet_destroy(handle_b) }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!( + SIGNED_PAYMENT_REGISTRY.outstanding(), + baseline + 1, + "destroying the final wrapper alias must NOT sweep an independently-owned token" + ); + + // The token is still fully live: its owner can release it even after both + // wrappers are gone (the registry entry pinned its own `CoreWallet`). + runtime().block_on(SIGNED_PAYMENT_REGISTRY.release(token)); + assert_eq!( + SIGNED_PAYMENT_REGISTRY.outstanding(), + baseline, + "the payment owner can still release the surviving token" + ); + + // 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 b3abb04410..d5d7263dfc 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), diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index e91c5ccee0..c1cb927544 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -56,8 +56,12 @@ pub use spv::SpvRuntime; pub use wallet::asset_lock::manager::AssetLockManager; pub use wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; pub use wallet::asset_lock::AssetLockFunding; +pub use wallet::core::SignedCorePayment; pub use wallet::core::WalletBalance; pub use wallet::core::{CoreWallet, SignedCoreTransaction}; +pub use wallet::signed_payment_registry::{ + RegisterWrongGeneration, 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/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 7dfc444c83..5ef4bc5a34 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -75,12 +75,23 @@ pub struct PlatformAddressSyncConfigSnapshot { /// rather than a positional tuple so adding the next field /// (`pool_count`, `last_used_height`, …) doesn't ripple through every /// destructuring site. -#[derive(Debug, Clone, Copy)] +/// +/// `derivation_path` is the account-level derivation path STRING (e.g. +/// the DIP-15 `m/9'/coinType'/15'/{index}'/0x/0x` for a DashPay receiving-funds account), produced by the SAME +/// `AccountType::derivation_path(network)` call that +/// [`CoreWallet::build_signed_payment`](crate::wallet::core::CoreWallet::build_signed_payment)'s +/// funds-account selector compares against — so a caller can round-trip +/// this exact string back in as `funding_path` to spend from this single +/// account with no divergence risk. `None` for account types that have +/// no derivable account-level path. +#[derive(Debug, Clone)] pub struct AccountBalanceRow { pub account_type: AccountType, pub balance: WalletCoreBalance, pub keys_used: u32, pub keys_total: u32, + pub derivation_path: Option, } /// Snapshot of [`IdentitySyncManager`] tunables / queue depth, returned @@ -383,6 +394,11 @@ impl PlatformWalletManager

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

{ let pool_total = pool.addresses.len() as u32; (used + pool_used, total + pool_total) }); + let account_type = account.managed_account_type().to_account_type(); + // The single source of truth: the identical call the + // spend-selector uses. `Ok(path)` for account types with + // a derivable account-level path (Standard/BIP44, + // CoinJoin, DashPay receiving-funds, …); `Err` (→ `None`) + // for the ones that have none. + let derivation_path = account_type + .derivation_path(network) + .ok() + .map(|p| p.to_string()); AccountBalanceRow { - account_type: account.managed_account_type().to_account_type(), + account_type, balance, keys_used, keys_total, + derivation_path, } }) .collect() diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index c746fb802b..65f410f339 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use crate::changeset::{ClientStartState, ClientWalletStartState, PlatformWalletPersistence}; use crate::error::PlatformWalletError; -use crate::wallet::core::WalletBalance; +use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; use crate::wallet::PlatformWallet; @@ -77,7 +77,7 @@ impl PlatformWalletManager

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

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

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

{ Arc::clone(&self.sdk), wallet_id, Arc::clone(&self.wallet_manager), - balance, + generation, Arc::clone(&self.lock_notify), Arc::clone(&persister_dyn), broadcaster, diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 938e0e790e..23086a8e84 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -16,7 +16,7 @@ use crate::changeset::{ PlatformWalletPersistence, ProviderKeyAccountEntry, WalletMetadataEntry, }; use crate::error::PlatformWalletError; -use crate::wallet::core::WalletBalance; +use crate::wallet::core::WalletGeneration; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; use crate::wallet::PlatformWallet; @@ -51,6 +51,33 @@ fn parse_mnemonic_any_language(phrase: &str) -> Result { Err("phrase does not match any supported BIP-39 wordlist") } +/// Test-only rendezvous fired inside [`PlatformWalletManager::remove_wallet_with_teardown`], +/// between the inner-manager removal and the public-map removal. +/// +/// That window is exactly where a concurrent same-id `register_wallet` can +/// publish a NEW generation into both maps — the id is free in the inner +/// manager from the moment the removal above completes, and nothing gates +/// registration. Reproducing it deterministically from outside is not possible: +/// the window is bounded by two *different* locks, and the only lock a test +/// could hold to park the remover inside it (`self.wallets`) is the same lock +/// the registration must acquire to publish, so parking the remover would also +/// block the registration — and `tokio`'s `RwLock` hands the writer queue out +/// in FIFO order, which puts the remover first. A rendezvous is therefore the +/// only way to pin this ordering without a sleep or a completion-order race. +/// +/// Compiled under `cfg(test)` only: neither this static nor its call site +/// exists in a production build, and it is not part of any public API. +#[cfg(test)] +pub(crate) type RemoveWalletMidpointHook = Box< + dyn Fn(&WalletId) -> std::pin::Pin + Send>> + + Send + + Sync, +>; + +#[cfg(test)] +pub(crate) static REMOVE_WALLET_MIDPOINT_HOOK: std::sync::Mutex> = + std::sync::Mutex::new(None); + impl PlatformWalletManager

{ /// Create a PlatformWallet from a BIP39 mnemonic phrase. /// @@ -183,7 +210,7 @@ impl PlatformWalletManager

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

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

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

{ } /// Remove a wallet from the manager. + /// + /// Runs under the removed generation's lifecycle gate — see + /// [`remove_wallet_with_teardown`](Self::remove_wallet_with_teardown), of + /// which this is the no-extra-teardown case. pub async fn remove_wallet( &self, wallet_id: &WalletId, ) -> Result, PlatformWalletError> { + self.remove_wallet_with_teardown(wallet_id, |_| {}).await + } + + /// Remove a wallet from the manager and run `tear_down` on the removed + /// wallet — both under that generation's exclusive lifecycle gate, as one + /// linearization point. + /// + /// # Why the gate lives here rather than in the caller + /// + /// Removing the generation and tearing down the deferred state that names it + /// (the [`SignedPaymentRegistry`](crate::SignedPaymentRegistry) tokens and + /// the FFI's finalized-transaction handles) must be indivisible. If they are + /// two steps, a retained handle can broadcast in the gap: the removal's own + /// `.await`s (shielded-coordinator and identity-sync unregistration) sit + /// inside it, `CoreWallet::is_same_generation` passes for a removed + /// generation (a removed generation matches itself), and the reservation age + /// guard is disabled once `last_processed_height` returns `None`. So a + /// payment for a wallet the host already deleted reaches the network + /// (`dashpay/platform#4185`). + /// + /// Taking the gate *inside* this method rather than leaving it to the caller + /// is deliberate: `PlatformWalletManager` is public and `SignedPaymentRegistry` + /// is re-exported, so a direct Rust embedder that never goes through the FFI + /// would otherwise remove wallets with no exclusion at all, and could + /// interleave between a payment operation's liveness check and its register + /// or network action. `tear_down` is the hook that lets the FFI layer sweep + /// its own process-global handle storages inside the same critical section + /// without the gate ever being optional. + /// + /// `tear_down` is synchronous by design — it runs while the gate is held, and + /// every sweep it needs (`remove_entries_for_wallet`, + /// `HandleStorage::remove_matching`) is a synchronous map retain. + /// + /// ## Lock ordering + /// + /// The generation gate is always taken BEFORE the manager locks. The lookup + /// that finds the gate takes `wallets` briefly and **drops it before** + /// awaiting the gate, so no manager lock is ever held across a gate + /// acquisition; payment operations likewise take the gate and only then await + /// the manager. The order is total, so the two cannot deadlock. + /// + /// ## Removal is by generation identity, not by key + /// + /// The gate excludes *payment operations on this generation*. It does not + /// exclude a fresh **registration** under the same `wallet_id`: + /// [`register_wallet`](Self::register_wallet) mints its own + /// [`WalletGeneration`] and takes no gate at all, by design — a create must + /// never queue behind an unrelated wallet's teardown. + /// + /// So once this method has removed generation G1 from the inner + /// `wallet_manager`, the id is free and a concurrent registration can publish + /// a *different* generation G2 into both maps before this method reaches its + /// own `self.wallets` removal — the two removals are separately locked, with + /// no happens-before edge between them and the registration. Removing by key + /// there would take G2 out of the public map (leaving it registered in the + /// inner manager, invisible and unremovable) and hand G2 to `tear_down`, + /// which would sweep G2's registry tokens and V2 handles while holding only + /// G1's gate — i.e. with G2's payment operations *not* excluded, which is the + /// exact property this gate exists to provide. + /// + /// The `Arc` validated under the gate is therefore retained, + /// and the public-map entry is removed only while it still names that same + /// generation. Both maps, the returned handle and the `tear_down` argument + /// are then all that one generation (`dashpay/platform#4185`). The one + /// remaining id-keyed step is the shielded coordinator detach below, which + /// has no generation concept at all; a generation that has just been + /// registered has not run `bind_shielded` yet, so it holds no coordinator + /// entry to detach. + /// + /// The inner-manager removal needs no such check: G1 can only leave + /// `wallet_manager` through this method (which requires G1's gate, held here) + /// or through a registration/load rollback for an insert that could not have + /// happened while G1 occupied the id — so while the gate is held and before + /// the removal below, the inner entry is still G1 by construction. + pub async fn remove_wallet_with_teardown( + &self, + wallet_id: &WalletId, + tear_down: F, + ) -> Result, PlatformWalletError> + where + F: FnOnce(&Arc), + { + // Find the generation registered under `wallet_id` and take ITS gate. + // Re-validated after acquisition because the wallet could have been + // removed and re-created under the same id while we waited: in that case + // we hold the OLD generation's gate, which excludes nothing relevant to + // the new one, so retry against the generation that is actually current. + // + // The validated handle is carried out of the loop: it is both what this + // call returns and tears down, and the identity every mutation below is + // matched against. + let (removed, _teardown) = loop { + let candidate = { + let wallets = self.wallets.read().await; + match wallets.get(wallet_id) { + None => { + return Err(PlatformWalletError::WalletNotFound(hex::encode(wallet_id))) + } + Some(wallet) => Arc::clone(wallet), + } + }; + let guard = candidate.generation().teardown_guard().await; + let still_current = { + let wallets = self.wallets.read().await; + wallets + .get(wallet_id) + .is_some_and(|wallet| Arc::ptr_eq(wallet.generation(), candidate.generation())) + }; + if still_current { + break (candidate, guard); + } + // Drop this generation's guard and re-resolve. + drop(guard); + }; + let generation = Arc::clone(removed.generation()); + let owned_identity_ids: Vec = { let mut wm = self.wallet_manager.write().await; let ids = match wm.get_wallet_info(wallet_id) { @@ -598,12 +745,41 @@ impl PlatformWalletManager

{ ids }; - let removed = { + // Test-only rendezvous: the window a concurrent same-id registration can + // publish a new generation into. See `REMOVE_WALLET_MIDPOINT_HOOK`. + #[cfg(test)] + { + let pending = REMOVE_WALLET_MIDPOINT_HOOK + .lock() + .expect("remove-wallet midpoint hook mutex") + .as_ref() + .map(|hook| hook(wallet_id)); + if let Some(rendezvous) = pending { + rendezvous.await; + } + } + + // Remove the public-map entry only while it still names the generation + // validated under the gate. A concurrent same-id registration could have + // published a NEW generation here in the window since the inner removal + // above freed the id (see the "Removal is by generation identity" note on + // this method); removing by key would evict that live wallet and hand it + // to `tear_down` under the wrong gate. + { let mut wallets = self.wallets.write().await; - wallets - .remove(wallet_id) - .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(wallet_id)))? - }; + let entry_is_ours = wallets + .get(wallet_id) + .is_some_and(|wallet| Arc::ptr_eq(wallet.generation(), &generation)); + if entry_is_ours { + wallets.remove(wallet_id); + } else { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + "remove_wallet: a new generation was registered under this id while the \ + previous one was being removed; leaving the new registration in place" + ); + } + } // Detach the wallet's shielded state from the network // coordinator. After the Phase-2b refactor the coordinator @@ -640,6 +816,12 @@ impl PlatformWalletManager

{ .await; } + // Still under the generation's teardown gate: any deferred state naming + // this generation is dropped in the same critical section as the removal + // itself, so no payment operation can observe the wallet as live and then + // act on it after this returns. + tear_down(&removed); + Ok(removed) } } @@ -858,3 +1040,192 @@ mod register_wallet_duplicate_tests { ); } } + +/// Removal versus a same-id re-registration that lands *during* the removal +/// (`dashpay/platform#4185` review). +/// +/// The invariant: `remove_wallet_with_teardown` removes, returns and tears down +/// exactly the wallet generation it validated under that generation's lifecycle +/// gate — never a different generation that appeared under the same +/// `wallet_id` while the removal was in progress. +#[cfg(test)] +mod remove_versus_recreate_tests { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; + + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::Network; + + use super::REMOVE_WALLET_MIDPOINT_HOOK; + use crate::test_support::test_platform_wallet_manager; + use crate::wallet::core::WalletGeneration; + use crate::wallet::PlatformWallet; + + /// The mnemonic `test_platform_wallet_manager` builds its wallet from, so + /// re-registering from the same seed collides on the same network-scoped + /// `wallet_id` — which is the whole point of the scenario. + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + + /// Clears [`REMOVE_WALLET_MIDPOINT_HOOK`] on drop, including on panic, so a + /// failing assertion can never leave the hook armed for another test in the + /// same binary. + struct MidpointHookGuard; + + impl Drop for MidpointHookGuard { + fn drop(&mut self) { + if let Ok(mut slot) = REMOVE_WALLET_MIDPOINT_HOOK.lock() { + *slot = None; + } + } + } + + /// Requirement: a wallet generation registered while a removal is in flight + /// must survive that removal — in BOTH maps — and the removal must return + /// and tear down the generation it actually validated. + /// + /// Deterministic by construction: the re-registration runs from a rendezvous + /// fired inside the removal, in the exact window between the inner-manager + /// removal and the public-map removal, so there is no completion order to + /// race and no sleep. The registration itself is the real + /// `create_wallet_from_seed_bytes` → `register_wallet` path, publishing into + /// the inner `WalletManager` and then `self.wallets` in the production + /// order. + /// + /// Why that window is reachable in production: the removal frees the id in + /// the inner manager and only then acquires `self.wallets` — two separately + /// locked stages with no happens-before edge to a concurrent registration, + /// which takes no lifecycle gate at all (it mints its own generation). A + /// remover descheduled in that gap resumes into a map that already names the + /// new generation. + /// + /// Before the fix the removal took the public-map entry by KEY: it evicted + /// the freshly registered generation — leaving it registered in the inner + /// manager but invisible and unremovable through `self.wallets` — returned + /// it to the caller, and handed it to `tear_down`, which sweeps that + /// generation's registry tokens and V2 finalized-transaction handles while + /// holding only the OLD generation's gate. The new generation's in-flight + /// payment operations were therefore not excluded, which is the one property + /// the gate exists to provide. + #[tokio::test] + async fn removal_leaves_a_generation_registered_during_it_intact() { + let (manager, wallet_id) = test_platform_wallet_manager().await; + let original = manager + .get_wallet(&wallet_id) + .await + .expect("fixture wallet is registered"); + + // Filled by the rendezvous with the generation the re-registration + // publishes, so the assertions can name it rather than infer it. + let recreated: Arc>>> = Arc::new(Mutex::new(None)); + + let _hook_guard = MidpointHookGuard; + { + let manager_for_hook = Arc::clone(&manager); + let recreated_slot = Arc::clone(&recreated); + // One-shot: the re-registration must not recurse into a later + // removal, and no other test in this binary may see the hook. + let fired = AtomicBool::new(false); + *REMOVE_WALLET_MIDPOINT_HOOK + .lock() + .expect("midpoint hook mutex") = Some(Box::new(move |id| { + let already_fired = fired.swap(true, Ordering::SeqCst); + let manager = Arc::clone(&manager_for_hook); + let recreated_slot = Arc::clone(&recreated_slot); + let id = *id; + Box::pin(async move { + if already_fired { + return; + } + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + .expect("valid test mnemonic"); + let seed_bytes = mnemonic.to_seed(""); + // The real registration path: inner `WalletManager` first, + // then `self.wallets`. `Some(0)` skips the SPV-tip lookup. + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed_bytes, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect( + "the id is free in the inner manager at this point, so a same-seed \ + re-registration must succeed", + ); + assert_eq!(wallet.wallet_id(), id, "the fixture seeds must collide"); + *recreated_slot.lock().expect("recreated slot") = Some(wallet); + }) + })); + } + + // Capture what teardown was actually handed. + let torn_down: Arc>>> = Arc::new(Mutex::new(None)); + let torn_down_slot = Arc::clone(&torn_down); + + let removed = manager + .remove_wallet_with_teardown(&wallet_id, move |wallet| { + *torn_down_slot.lock().expect("torn-down slot") = + Some(Arc::clone(wallet.generation())); + }) + .await + .expect("removal of the validated generation succeeds"); + + let recreated = recreated + .lock() + .expect("recreated slot") + .clone() + .expect("the rendezvous must have re-registered the wallet"); + assert!( + !Arc::ptr_eq(original.generation(), recreated.generation()), + "the fixture must produce two distinct generations under one wallet id" + ); + + // 1. The removal returns the generation it validated under the gate. + assert!( + Arc::ptr_eq(removed.generation(), original.generation()), + "remove_wallet_with_teardown returned a generation it never validated — it took the \ + public-map entry by key and got the generation registered during the removal" + ); + + // 2. …and tears down that same generation. Sweeping the other one here + // would run without holding ITS gate, so its in-flight payment + // operations would not be excluded. + let torn_down = torn_down + .lock() + .expect("torn-down slot") + .clone() + .expect("tear_down must have run"); + assert!( + Arc::ptr_eq(&torn_down, original.generation()), + "tear_down was handed a generation whose lifecycle gate this removal does not hold" + ); + + // 3. The generation registered during the removal is still published. + let still_registered = manager + .get_wallet(&wallet_id) + .await + .expect("a wallet registered during a removal must remain in the public map"); + assert!( + Arc::ptr_eq(still_registered.generation(), recreated.generation()), + "the public map must still name the generation the registration published" + ); + + // 4. …and both maps agree about it: `is_current_generation` compares the + // handle against the inner `WalletManager`, so this fails if the + // removal evicted it from one map only. + assert!( + recreated.core().is_current_generation().await, + "the re-registered generation must be live in both the inner manager and the public \ + map — evicting it from one leaves an invisible, unremovable wallet" + ); + + // 5. The removed generation is gone. + assert!( + !original.core().is_current_generation().await, + "the validated generation must be gone from the inner manager" + ); + } +} diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 8fd146dc77..02cd24401c 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}; @@ -27,7 +32,7 @@ use tokio::sync::RwLock; #[cfg(test)] use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; -use crate::wallet::core::WalletBalance; +use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; @@ -178,7 +183,7 @@ pub(crate) async fn funded_wallet_manager( ) -> ( Arc>>, WalletId, - Arc, + Arc, WalletSigner, ) { funded_wallet_manager_with_outputs(account_type, &[10_000_000]).await @@ -193,7 +198,7 @@ pub(crate) async fn funded_wallet_manager_with_outputs( ) -> ( Arc>>, WalletId, - Arc, + Arc, WalletSigner, ) { let mut ctx = TestWalletContext::new_random(); @@ -243,10 +248,81 @@ pub(crate) async fn funded_wallet_manager_with_outputs( wallet: ctx.wallet.clone(), }; - let balance = Arc::new(WalletBalance::new()); + let generation = Arc::new(WalletGeneration::new()); + let info = PlatformWalletInfo { + core_wallet: ctx.managed_wallet, + generation: Arc::clone(&generation), + 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, generation, 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 generation = Arc::new(WalletGeneration::new()); let info = PlatformWalletInfo { core_wallet: ctx.managed_wallet, - balance: Arc::clone(&balance), + generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), }; @@ -254,7 +330,101 @@ pub(crate) async fn funded_wallet_manager_with_outputs( let mut wm = WalletManager::::new(Network::Testnet); let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); - (Arc::new(RwLock::new(wm)), wallet_id, balance, signer) + (Arc::new(RwLock::new(wm)), wallet_id, generation, signer) +} + +/// 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, + Arc, + 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(), + }; + + // Returned, not discarded: a `CoreWallet` built over a *different* + // `WalletGeneration` than the one registered here is a foreign generation, + // and every generation-bound path (above all the owner-guarded reservation + // release) correctly no-ops against it. Handing the real handle back is what + // keeps callers from silently testing a no-op. + let generation = Arc::new(WalletGeneration::new()); + let info = PlatformWalletInfo { + core_wallet: ctx.managed_wallet, + generation: Arc::clone(&generation), + 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, generation, signer) } /// Funded SPV-backed Core wallet for downstream FFI lifecycle tests. The SPV @@ -265,7 +435,7 @@ pub async fn funded_spv_core_wallet( crate::CoreWallet, WalletSigner, ) { - let (manager, wallet_id, balance, signer) = funded_wallet_manager(account_type).await; + let (manager, wallet_id, generation, signer) = funded_wallet_manager(account_type).await; let spv = Arc::new(crate::spv::SpvRuntime::new( Arc::clone(&manager), Arc::new(crate::events::PlatformEventManager::new(Vec::new())), @@ -273,7 +443,83 @@ pub async fn funded_spv_core_wallet( let broadcaster = Arc::new(crate::broadcaster::SpvBroadcaster::new(spv)); let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); ( - crate::CoreWallet::new(sdk, manager, wallet_id, broadcaster, balance), + crate::CoreWallet::new(sdk, manager, wallet_id, broadcaster, generation), signer, ) } + +/// 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` regression asserting that destroying wrapper +/// aliases never sweeps an independently-owned deferred-payment token. +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/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index bbe87893d6..993ccff413 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -359,7 +359,7 @@ impl PlatformWalletInfo { // Mirror the recomputed balance into the lock-free Arc that the // UI reads. let core_balance = &self.core_wallet.balance; - self.balance.set( + self.generation.set( core_balance.confirmed(), core_balance.unconfirmed(), core_balance.immature(), @@ -389,7 +389,7 @@ mod tests { ReceivedContactRequestKey, SentContactRequestKey, TokenBalanceChangeSet, }; use crate::wallet::asset_lock::tracked::AssetLockStatus; - use crate::wallet::core::WalletBalance; + use crate::wallet::core::WalletGeneration; use crate::wallet::identity::state::managed_identity::ManagedIdentity; use crate::wallet::identity::IdentityManager; use crate::wallet::identity::{ContactRequest, EstablishedContact}; @@ -410,7 +410,7 @@ mod tests { fn empty_info(wallet: &Wallet) -> PlatformWalletInfo { PlatformWalletInfo { core_wallet: ManagedWalletInfo::from_wallet(wallet, 0), - balance: std::sync::Arc::new(WalletBalance::new()), + generation: std::sync::Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index 536f669a86..6083f29822 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -467,7 +467,7 @@ mod tests { use crate::test_support::{funded_wallet_manager, AlwaysRejectedBroadcaster}; use crate::wallet::asset_lock::manager::AssetLockManager; use crate::wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; - use crate::wallet::core::WalletBalance; + use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::PlatformWalletInfo; @@ -740,7 +740,7 @@ mod tests { let restored_wallet = Wallet::new_external_signable(Network::Testnet, wallet_id, accounts); let mut restored_info = PlatformWalletInfo { core_wallet: ManagedWalletInfo::from_wallet(&restored_wallet, 0), - balance: Arc::new(WalletBalance::new()), + generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), }; diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 0f3d7fd1f0..0176d661d3 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::wallet::managed_wallet_info::transaction_building::AccountTypePreference; +use key_wallet::ReservationToken; 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,61 @@ impl CoreWallet { .await .map_err(Into::into) } + + /// Broadcast a raw signed `transaction` for the deferred-payment + /// [`SignedPaymentRegistry`](crate::SignedPaymentRegistry), reconciling the + /// funding reservation on failure. + /// + /// Same policy as + /// [`broadcast_finalized_transaction`](Self::broadcast_finalized_transaction): + /// a definitive [`BroadcastError::Rejected`] releases the reservation for an + /// immediate rebuild; an ambiguous `MaybeSent` keeps it. Unlike the + /// `StandardAccountType`-typed + /// [`broadcast_transaction_releasing_reservation`](Self::broadcast_transaction_releasing_reservation) + /// used by the immediate send path, this takes an [`AccountTypePreference`] + /// so it ALSO reconciles a CoinJoin-funded deferred payment — one whose + /// `build_signed`/`finalize` reserved the selected inputs but which has no + /// `StandardAccountType`, and which previously kept its reservation held + /// until the TTL backstop. + /// + /// The release delegates to + /// [`release_transaction_reservation`](Self::release_transaction_reservation), + /// so it acts only on the wallet *generation* this handle names (a wallet + /// re-created under the same id between build and broadcast cannot have its + /// reservation freed by this token) AND — via `token` — only on inputs this + /// build still owns. The deferred registry can hold the reservation across a + /// long build→broadcast gap, so a TTL sweep re-reserving the same inputs + /// under a new token is a real risk; the owner guard closes the + /// `dashpay/platform#4185` release/re-reserve race. + /// + /// `account_type`/`account_index` identify the funding account handed to the + /// builder when the transaction was finalized; `token` is the + /// [`ReservationToken`] that build stamped + /// (`SignedCoreTransaction::reservation_token`), `None` only when the build + /// reserved nothing. + pub(crate) async fn broadcast_payment_releasing_reservation( + &self, + account_type: AccountTypePreference, + account_index: u32, + transaction: &Transaction, + token: Option, + ) -> Result { + match self.broadcaster.broadcast(transaction).await { + Ok(txid) => Ok(txid), + Err(error) => { + if matches!(error, BroadcastError::Rejected { .. }) { + self.release_transaction_reservation( + account_type, + account_index, + transaction, + token, + ) + .await; + } + Err(error.into()) + } + } + } } #[cfg(test)] diff --git a/packages/rs-platform-wallet/src/wallet/core/generation.rs b/packages/rs-platform-wallet/src/wallet/core/generation.rs new file mode 100644 index 0000000000..5d70488443 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/core/generation.rs @@ -0,0 +1,138 @@ +//! Per-wallet-*generation* shared state: the identity marker every handle to +//! one generation shares, and that generation's lifecycle gate. + +use std::ops::Deref; +use std::sync::Arc; + +use tokio::sync::{OwnedRwLockWriteGuard, RwLock, RwLockReadGuard}; + +use super::balance::WalletBalance; + +/// The state one wallet *generation* shares across every handle that names it. +/// +/// A "generation" is one live in-memory instance of a logical wallet. Removing a +/// wallet and re-creating it under the same `wallet_id` produces a *different* +/// generation: same id, same shared multi-wallet `WalletManager` `Arc`, fresh +/// `WalletGeneration`. `PlatformWalletManager` builds exactly one of these per +/// registration and clones the `Arc` into `PlatformWalletInfo`, `PlatformWallet` +/// and `CoreWallet`, so `Arc::ptr_eq` on it is the canonical generation identity +/// (see [`CoreWallet::is_same_generation`](super::CoreWallet::is_same_generation)). +/// +/// # Why the balance and the lifecycle gate live in the *same* object +/// +/// They are one indivisible fact — "which generation is this?" — and splitting +/// them into two `Arc`s threaded separately through ~15 construction sites would +/// let a future site clone the identity marker but mint a *fresh* gate. Two +/// handles would then compare as the same generation while excluding each other +/// through different locks: teardown would take one gate, an in-flight payment +/// would hold the other, and the exclusion would silently vanish with nothing to +/// fail. Keeping them in one `Arc` makes that divergence unrepresentable — +/// same generation is the same gate, by construction (`dashpay/platform#4185`). +/// +/// [`Deref`] to [`WalletBalance`] keeps every existing lock-free balance read +/// (`generation.confirmed()`, `info.balance.locked()`, …) working unchanged. +#[derive(Debug)] +pub struct WalletGeneration { + /// Lock-free balance for UI reads. Updated from `ManagedWalletInfo` after + /// each SPV block/mempool processing and RPC refresh. + balance: WalletBalance, + /// This generation's lifecycle gate — held across whole *operations* rather + /// than around individual state mutations. + /// + /// Shared side ([`payment_guard`](Self::payment_guard)): any operation that + /// will publish an ownership handle for this generation, or push one of its + /// transactions to the network, after observing that the generation is still + /// live. Exclusive side ([`teardown_guard`](Self::teardown_guard)): removing + /// the generation from the manager and sweeping its deferred state. + /// + /// This is deliberately **per generation** rather than one process-global + /// lock. A deferred broadcast holds the shared side across an SPV send + /// (seconds, up to the broadcaster's timeout); with a single global lock that + /// send would block teardown — and, because tokio's `RwLock` is + /// write-preferring, every subsequent payment operation — for *every + /// unrelated wallet* in the process. Scoped here, one wallet's slow send + /// only ever excludes that same wallet's teardown, which is exactly the pair + /// that must not interleave. + /// + /// Held in its own `Arc` so [`teardown_guard`](Self::teardown_guard) can hand + /// back an *owned* guard: the remover resolves which generation is current in + /// a retry loop, and the guard must outlive the loop iteration that produced + /// the `Arc` it came from. + lifecycle: Arc>, +} + +impl Default for WalletGeneration { + fn default() -> Self { + Self::new() + } +} + +impl WalletGeneration { + /// A fresh generation: zeroed balance, uncontended gate. + pub fn new() -> Self { + Self { + balance: WalletBalance::new(), + lifecycle: Arc::new(RwLock::new(())), + } + } + + /// This generation's lock-free balance. + pub fn balance(&self) -> &WalletBalance { + &self.balance + } + + /// Enter this generation's lifecycle gate as a *payment* operation. + /// + /// Shared: any number of payment operations on this generation (and every + /// operation on every *other* generation) run concurrently. What it excludes + /// is this generation's own teardown ([`teardown_guard`](Self::teardown_guard)), + /// which is what makes a liveness observation + /// ([`CoreWallet::is_current_generation`](super::CoreWallet::is_current_generation)) + /// safe to act on: held across both the check and the action it gates, a + /// removal cannot interleave between them. + /// + /// Callers must hold it across the check *and* the publication/network step, + /// and must not already hold it (the `RwLock` is not reentrant, and because + /// tokio's is write-preferring a queued teardown would deadlock the + /// re-entry). + /// + /// # Lock ordering + /// + /// Always taken BEFORE the wallet-manager `RwLock`, never while holding it. + /// Teardown takes it and then awaits the manager write lock; payment + /// operations take it and then await the manager read lock. The order is + /// total, so the two locks cannot deadlock. + pub async fn payment_guard(&self) -> RwLockReadGuard<'_, ()> { + self.lifecycle.read().await + } + + /// Enter this generation's lifecycle gate as a *teardown*. + /// + /// Exclusive against every payment operation on this generation. Removal + /// holds it across BOTH the manager removal and the deferred-state sweep, so + /// the two are one linearization point rather than two steps with a window + /// between them that a retained handle could broadcast through + /// (`dashpay/platform#4185`). + /// + /// Acquiring it waits for payment operations that have already entered their + /// liveness-check/publish section. It does **not** wait for an operation + /// still awaiting an external signer: those acquire the gate only *after* + /// the signature returns, precisely so an open signing prompt cannot stall + /// teardown. Such a late finalizer then observes the removed generation at + /// its liveness check and abandons instead of publishing. + /// + /// Returns an *owned* guard so it can outlive the `Arc` + /// binding it was taken from — the remover resolves the current generation in + /// a retry loop and must carry the guard out of the iteration that found it. + pub async fn teardown_guard(&self) -> OwnedRwLockWriteGuard<()> { + Arc::clone(&self.lifecycle).write_owned().await + } +} + +impl Deref for WalletGeneration { + type Target = WalletBalance; + + fn deref(&self) -> &WalletBalance { + &self.balance + } +} diff --git a/packages/rs-platform-wallet/src/wallet/core/mod.rs b/packages/rs-platform-wallet/src/wallet/core/mod.rs index 5481362ae8..1e15136cc2 100644 --- a/packages/rs-platform-wallet/src/wallet/core/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/core/mod.rs @@ -1,10 +1,14 @@ pub mod balance; pub mod balance_handler; mod broadcast; +pub mod generation; +mod send; mod transaction; pub mod wallet; pub use balance::WalletBalance; pub use balance_handler::BalanceUpdateHandler; +pub use generation::WalletGeneration; +pub use send::SignedCorePayment; pub use transaction::SignedCoreTransaction; pub use wallet::CoreWallet; diff --git a/packages/rs-platform-wallet/src/wallet/core/send.rs b/packages/rs-platform-wallet/src/wallet/core/send.rs new file mode 100644 index 0000000000..8df235bfa9 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/core/send.rs @@ -0,0 +1,2173 @@ +//! 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_reserved` perform on the **selected** +//! funding account: the selected inputs are marked *reserved* so a concurrent +//! SDK build does not re-select the same coins. Because selection is confined to +//! one account, every selected input is reserved in the ledger that all funding +//! paths consult for that account — there are no unreserved "secondary-account" +//! inputs (dashpay/platform#4247 review finding, now structurally impossible). +//! +//! 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 +//! [`CoreWallet::release_payment_reservation`] for an abandoned build. No +//! balance is debited until the transaction actually confirms — exactly what +//! the transition flow needs, since dashj owns commit/broadcast. +//! +//! ## Abandoning a build +//! +//! A caller that builds and then decides **not** to broadcast MUST call +//! [`CoreWallet::release_payment_reservation`] with the transaction it was +//! handed *and* the [`SignedCorePayment::reservation_token`] that came with it. +//! Without it the selected inputs stay reserved until the TTL backstop fires 24 +//! blocks later — and, critically, **forever** while the wallet has no processed +//! height: key-wallet's `ReservationSet::sweep` early-returns at height 0, so a +//! build made before the first sync completes can strand the whole balance for +//! the life of the process (dashpay/platform#4247 review). The explicit release +//! is height-independent and closes that hole. +//! +//! The release is **owner-guarded** by that token, and it is for abandoned +//! builds ONLY — never for one that was broadcast. Both restrictions are +//! load-bearing; see [`CoreWallet::release_payment_reservation`] for the two +//! concrete hazards they close (dashpay/platform#4247 review). +//! +//! [`ManagedCoreFundsAccount::release_reservation_if_owner`]: +//! key_wallet::managed_account::ManagedCoreFundsAccount::release_reservation_if_owner + +use std::collections::HashMap; +use std::sync::Arc; + +use dashcore::{Address as DashAddress, OutPoint, Transaction}; +use key_wallet::bip32::DerivationPath; +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; +// key-wallet's per-build UTXO-reservation token. Aliased exactly as +// `signed_payment_registry` aliases it, so it never blurs with that module's own +// `ReservationToken` (an opaque *payment handle*): this one identifies the +// reserved **inputs**, and is the proof of ownership an owner-guarded release +// presents. +use key_wallet::ReservationToken as FundingReservationToken; + +use crate::broadcaster::TransactionBroadcaster; +use crate::error::PlatformWalletError; +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, 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, + /// The key-wallet [`FundingReservationToken`] stamped onto the inputs this + /// build reserved (`None` only if the funding account carried no reservation + /// set, which the send path never produces). + /// + /// This is the **proof of ownership** the abandon path must present to + /// [`CoreWallet::release_payment_reservation`]. Carrying it is not + /// bookkeeping: between this build and its release, key-wallet's TTL sweep + /// can reclaim the reservation and a concurrent build can re-reserve the very + /// same outpoint under a *new* token. A release by outpoint alone would then + /// free that other build's inputs and re-open the double-spend window the + /// reservation exists to close, so the token is what makes the release a + /// no-op for any input that has since changed hands + /// (dashpay/platform#4247 review; the mechanism is documented on + /// key-wallet's `ReservationSet::release_if_owner`). + pub reservation_token: Option, +} + +impl CoreWallet { + /// 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 { + 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 = bip44_account_path(info, network)?; + let funding_path = funding_path.unwrap_or_else(|| bip44_path.clone()); + let funds_from_change_account = funding_path == bip44_path; + + // 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#4247 and + // #4256 review). + // + // Verified not to narrow any real path: `all_accounts()` does enumerate + // CoinJoin and DashPay receiving-funds accounts, so every send test — + // including the explicit-CoinJoin one — passes with the fallback + // removed. It was dead code on every exercised path. + let funding_wallet_acc = wallet + .all_accounts() + .into_iter() + .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`: the latter drops the + // `ReservationToken` it was handed, and this build must keep it. The + // token is both the abandon path's proof of ownership + // (`SignedCorePayment::reservation_token`) and what lets the + // size-rejection path below free exactly its own inputs. key-wallet + // already releases owner-guarded if the signer itself fails, so the only + // post-reservation failure left to handle here is that size check. + let (transaction, _estimated_fee, reservation_token) = builder + .build_signed_reserved(signer, move |addr| path_map.get(&addr).cloned()) + .await + .map_err(|e| map_send_builder_error(e, selectable_value, outputs_total))?; + + // 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); + + // Belt-and-braces: the pre-build check bounded only the output side, + // because the input count is not knowable until coin selection has run. + // Measure the transaction we actually built and refuse to hand back + // bytes that cannot relay. In practice this fires only for a request + // whose recipient list already passed the output-side bound but whose + // funding account then contributed enough small inputs to push the + // whole transaction over the limit. + let signed_size = transaction.size(); + if signed_size > MAX_STANDARD_TX_SIZE { + // RELEASE BEFORE RETURNING (dashpay/platform#4247 review). + // + // The inputs are already reserved by the time this check runs, and + // this is the one error path that can reach it. Returning bare would + // strand them twice over: the caller never receives the transaction, + // so it has nothing to hand `release_payment_reservation`, and the + // TTL backstop is not a fallback — `ReservationSet::sweep` + // early-returns at height 0, so on a freshly restored wallet the + // coins would sit unselectable for the life of the process. Since + // the failure is "too many small inputs", the stranded amount is + // typically the whole account. + // + // Owner-guarded even though nothing can have interleaved (the + // manager write lock has been held continuously since selection, and + // both the TTL sweep and any re-reservation need it): the guard costs + // nothing and keeps this path correct by construction rather than by + // an argument about the current locking, which is exactly the + // reasoning that failed review elsewhere in this module. A failure to + // resolve the account is logged, not propagated — it must not mask + // the size error the caller actually needs to see. + match release_in_funding_account( + info, + network, + &funding_path, + &transaction, + reservation_token, + ) { + Ok(true) => {} + Ok(false) => tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + %funding_path, + "oversized-payment cleanup could not find the funding account to \ + release into; its inputs stay reserved until the TTL backstop" + ), + Err(e) => tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + %funding_path, + "oversized-payment cleanup failed to release the reservation: {e}" + ), + } + + return Err(PlatformWalletError::TransactionBuild(format!( + "the signed transaction is {signed_size} bytes, over the \ + {MAX_STANDARD_TX_SIZE}-byte standard transaction limit; it would not relay. \ + Send a smaller amount (fewer inputs) or fewer recipients" + ))); + } + + Ok(SignedCorePayment { + transaction, + fee, + change_amount, + reservation_token, + }) + } + + /// Release the UTXO reservation that a previous [`build_signed_payment`] + /// took, for a build the caller has decided **not** to broadcast. + /// + /// [`build_signed_payment`] deliberately leaves its selected inputs + /// reserved on success, because the expected next step is a broadcast. A + /// caller that abandons the build instead — the user backed out of the + /// confirmation screen, an upstream check failed, the app is tearing + /// down — must say so, or those coins stay unselectable. + /// + /// ## Why this is needed (dashpay/platform#4247 review) + /// + /// Without an explicit release the inputs are stranded until key-wallet's + /// TTL backstop reclaims them `RESERVATION_TTL_BLOCKS` (24) blocks later, + /// roughly an hour. Worse, that backstop is not merely slow but *absent* + /// before the first sync completes: `ReservationSet::sweep` early-returns + /// when the current height is 0, so a reservation taken at height 0 is + /// never reclaimed for the life of the process. A single abandoned build + /// on a freshly restored wallet could therefore strand the entire balance + /// indefinitely. This method takes no height and consults none, so it is + /// the one release path that works pre-sync. + /// + /// ## What is released — only inputs this build still owns + /// + /// The release is **owner-guarded** by `reservation_token`: key-wallet frees + /// an outpoint only while that token is still its recorded owner. + /// + /// An earlier revision of this contract argued the transaction alone was a + /// sufficient ownership signal — "a reserved outpoint is skipped by every + /// subsequent coin selection, so no concurrent build can hold a reservation + /// on any input of `transaction`". **That reasoning was wrong**, and the + /// unguarded release it justified was a real double-spend window + /// (dashpay/platform#4247 review). It overlooks the TTL sweep: key-wallet + /// reclaims a reservation `RESERVATION_TTL_BLOCKS` after it was stamped, at + /// which point the outpoint *is* selectable again and a concurrent build can + /// legitimately re-reserve it under a new token. A late release by outpoint + /// then frees that other build's inputs and lets coin selection hand them to + /// a second transaction. The sweep happens inside key-wallet, invisibly, so + /// this layer cannot detect it — which is precisely why key-wallet's own + /// docs state the platform layer "cannot make this safe on its own" and + /// expose [`release_reservation_if_owner`] for it. With the token the stale + /// release is simply a no-op. + /// + /// [`release_reservation_if_owner`]: + /// key_wallet::managed_account::ManagedCoreFundsAccount::release_reservation_if_owner + /// + /// The release is additionally bound to this handle's own wallet + /// *generation*: a wallet removed and re-created under the same id has a + /// fresh `ReservationSet`, and releasing into it could free the NEW + /// generation's reservation. A generation mismatch is therefore a no-op — + /// the original generation's reservation ceased to exist with it. (Same + /// guard, same reasoning as + /// [`release_transaction_reservation`](CoreWallet::release_transaction_reservation).) + /// + /// ## Idempotent — but for ABANDONED builds only, never after a broadcast + /// + /// Calling this twice is a silent no-op: the second call resolves the + /// account fine and the token no longer owns anything. + /// + /// It is **not** safe to wire into an unconditional cleanup path (a + /// `finally`, a teardown hook) that runs regardless of whether the caller + /// broadcast. A previous revision of this contract advertised exactly that, + /// on the grounds that a broadcast spend "is removed from the UTXO set by + /// sync independently of any reservation". The gap is the interval *before* + /// sync observes it: this primitive does not broadcast, so between the + /// caller's successful external broadcast and sync processing that spend + /// back into the wallet, the inputs are still in the UTXO set and the + /// reservation is the only thing keeping a second build off them. Releasing + /// in that window re-opens them for selection and invites a conflicting + /// transaction the network will reject as a double-spend + /// (dashpay/platform#4247 review). + /// + /// The rule for callers is therefore: release a build you decided **not** to + /// broadcast; never release one you did. Once sync has processed the spend + /// the release is harmless again — the inputs have left the UTXO set — but + /// nothing tells the caller when that moment arrives, so "did I broadcast + /// it?" is the condition to branch on, and it is one the caller always knows. + /// + /// ## Parameters + /// + /// * `transaction` — the transaction [`build_signed_payment`] returned + /// (`SignedCorePayment::transaction`), or the same bytes deserialized. + /// * `funding_path` — the **same** `funding_path` the build was given, so + /// the release lands on the account that holds the reservation. `None` + /// means the unmixed BIP44 account, exactly as it does for the build. + /// Passing a path that names a different account is harmless: that + /// account's ledger holds none of these outpoints, so nothing is + /// released. + /// * `reservation_token` — the **same** + /// [`SignedCorePayment::reservation_token`] the build returned. Passing + /// `None` falls back to the unguarded by-outpoint release and is reserved + /// for a build that genuinely reserved nothing; do not pass `None` merely + /// because the token was inconvenient to carry — that reinstates the race + /// above. + /// + /// [`build_signed_payment`]: CoreWallet::build_signed_payment + pub async fn release_payment_reservation( + &self, + transaction: &Transaction, + funding_path: Option, + reservation_token: Option, + ) -> Result<(), PlatformWalletError> { + // `release_reservation_if_owner` takes `&self` and no manager entry is + // mutated, so a read lock suffices — abandoning a build must not + // serialize against concurrent sends (same reasoning as the + // rejected-broadcast cleanup in `crate::wallet::reservations`). The read + // lock also makes the generation check below atomic against a + // recreation, which needs the write lock. + let wm = self.wallet_manager.read().await; + let (_, info) = wm + .get_wallet_and_info(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + + // The wallet registered under this id is the same generation as `self` + // iff their per-generation `Arc`s are pointer-equal — `wallet_id` alone + // survives a remove-then-recreate, so it cannot tell them apart. + if !Arc::ptr_eq(&info.generation, self.generation()) { + tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + "skipping payment reservation release: the wallet was re-created under the \ + same id, so this build's reservation no longer exists and releasing would \ + act on the new generation's ledger" + ); + return Ok(()); + } + + let network = info.core_wallet.network(); + let funding_path = match funding_path { + Some(path) => path, + None => bip44_account_path(info, network)?, + }; + + if release_in_funding_account(info, network, &funding_path, transaction, reservation_token)? + { + return Ok(()); + } + + // An unresolvable path is a caller error worth reporting, and is NOT + // the idempotent case: repeat releases resolve the account fine and + // no-op inside it. Watch-only accounts are not filtered out here the + // way the build filters them — releasing is not a spend, and a + // watch-only account can never have been funded a build to abandon. + Err(PlatformWalletError::TransactionBuild(format!( + "no funds account matches funding derivation path {funding_path}; \ + the build to abandon must be released against the account that funded it" + ))) + } +} + +/// Release `transaction`'s input reservation inside the ONE funds account whose +/// account-level path is `funding_path`, owner-guarded by `token`. +/// +/// Returns `true` when an account matched and was asked to release, `false` when +/// `funding_path` names no funds account in this wallet — the caller decides +/// whether that is an error (the explicit abandon path) or a warning (the +/// oversized-build cleanup, which must not mask the size error). +/// +/// Shared by both release sites so the guard can never be applied on one and +/// forgotten on the other. `Some(token)` is the normal case and is what closes +/// the sweep/re-reserve race (dashpay/platform#4247 review); `None` falls back to +/// the unconditional by-outpoint release and is only correct for a build that +/// reserved nothing, in which case there is nothing to free anyway. +/// +/// PRIVACY-DOMAIN-OK: iterates funds accounts only to LOOK ONE UP by derivation +/// path, exactly as the build does. Nothing is accumulated across accounts and +/// only the named account's ledger is touched. +fn release_in_funding_account( + info: &crate::wallet::platform_wallet::PlatformWalletInfo, + network: dashcore::Network, + funding_path: &DerivationPath, + transaction: &Transaction, + token: Option, +) -> Result { + for account in info.core_wallet.accounts.all_funding_accounts() { + let account_path = account + .managed_account_type() + .to_account_type() + .derivation_path(network) + .map_err(|e| { + PlatformWalletError::TransactionBuild(format!( + "failed to derive account-level path for a funds account: {e}" + )) + })?; + if account_path != *funding_path { + continue; + } + match token { + Some(token) => account.release_reservation_if_owner(transaction, token), + None => account.release_reservation(transaction), + } + return Ok(true); + } + Ok(false) +} + +/// Account-level derivation path of the unmixed BIP44 account at +/// [`BIP44_ACCOUNT_INDEX`] — the default funding source and the change sink. +/// +/// Shared by the build and the release paths so both resolve `funding_path: +/// None` to the same account; a release that disagreed with its build would +/// silently fail to free anything. +fn bip44_account_path( + info: &crate::wallet::platform_wallet::PlatformWalletInfo, + network: dashcore::Network, +) -> Result { + info.core_wallet + .accounts + .standard_bip44_accounts + .get(&BIP44_ACCOUNT_INDEX) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "BIP44 account {BIP44_ACCOUNT_INDEX} not found for payment funding" + )) + })? + .managed_account_type() + .to_account_type() + .derivation_path(network) + .map_err(|e| { + PlatformWalletError::TransactionBuild(format!( + "failed to derive the unmixed BIP44 account-level path: {e}" + )) + }) +} + +/// Map a key-wallet [`BuilderError`] to a [`PlatformWalletError`], promoting the +/// 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. +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, + } + } + 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 crate::test_support::{ + funded_wallet_manager, split_funded_wallet_manager, AlwaysRejectedBroadcaster, + }; + use crate::wallet::core::{CoreWallet, WalletGeneration}; + use crate::wallet::platform_wallet::WalletId; + use crate::PlatformWalletError; + + use super::SignedCorePayment; + + /// A `CoreWallet` over a manager fixture. The send path never broadcasts, + /// so the broadcaster is irrelevant. + /// + /// `generation` MUST be the handle the fixture registered, never a fresh + /// `WalletGeneration::new()`: `release_payment_reservation` is + /// generation-bound and silently no-ops against a foreign generation, so a + /// fabricated handle turns every release assertion vacuous. Both fixtures + /// return their real handle for exactly this reason + /// (dashpay/platform#4247 review). + fn core_wallet( + wallet_manager: Arc< + tokio::sync::RwLock< + key_wallet_manager::WalletManager< + crate::wallet::platform_wallet::PlatformWalletInfo, + >, + >, + >, + wallet_id: WalletId, + generation: 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), + generation, + ) + } + + 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, generation, signer) = + split_funded_wallet_manager(9_000_000, 9_000_000).await; + let core = core_wallet(wm, wallet_id, generation); + + let result = core + .build_signed_payment(vec![(recipient(7), 15_000_000)], None, &signer, None) + .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, generation, signer) = + split_funded_wallet_manager(20_000_000, 9_000_000).await; + let (bip44_ops, coinjoin_ops, _) = + split_account_outpoints_and_coinjoin_path(&wm, &wallet_id).await; + + let core = core_wallet(wm, wallet_id, generation); + 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, generation, signer) = + split_funded_wallet_manager(9_000_000, 20_000_000).await; + let (bip44_ops, coinjoin_ops, coinjoin_path) = + split_account_outpoints_and_coinjoin_path(&wm, &wallet_id).await; + + let core = core_wallet(wm, wallet_id, generation); + 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); + } + + /// 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, generation, signer) = + split_funded_wallet_manager(9_000_000, 9_000_000).await; + let (_, _, coinjoin_path) = + split_account_outpoints_and_coinjoin_path(&wm, &wallet_id).await; + let core = core_wallet(wm, wallet_id, generation); + + 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, generation, 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, generation); + + // Ask for 0.5 DASH: covered only if the 1.0-DASH watch-only UTXO were + // spendable. It is on a different domain from the default BIP44 funding + // 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); + } + + /// 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" + ); + } + + // ------------------------------------------------------------------ + // Abandoning a build — `release_payment_reservation` + // + // `funded_wallet_manager` puts the WHOLE balance on a single UTXO, so + // "the reservation was released" and "the reservation was not released" + // are cleanly distinguishable: while that one input is reserved the next + // build has nothing to select and fails, and the moment it is released + // the next build succeeds. Every test below turns on that signal. + // ------------------------------------------------------------------ + + type TestWalletManager = Arc< + tokio::sync::RwLock< + key_wallet_manager::WalletManager, + >, + >; + + /// Force the wallet's `last_processed_height`. Lets a test reproduce the + /// pre-sync state (height 0) in which key-wallet's TTL sweep early-returns + /// and therefore never reclaims anything. + async fn set_last_processed_height(wm: &TestWalletManager, wallet_id: &WalletId, height: u32) { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let mut guard = wm.write().await; + let (_, info) = guard + .get_wallet_and_info_mut(wallet_id) + .expect("wallet present"); + info.core_wallet.update_last_processed_height(height); + } + + /// The BIP44 account-0 outpoints currently in the wallet's UTXO set. + async fn bip44_outpoints(wm: &TestWalletManager, wallet_id: &WalletId) -> HashSet { + let guard = wm.read().await; + let (_, info) = guard + .get_wallet_and_info(wallet_id) + .expect("wallet present"); + info.core_wallet + .accounts + .standard_bip44_accounts + .get(&0) + .map(|a| a.utxos.keys().copied().collect()) + .unwrap_or_default() + } + + /// Process `tx` back into the wallet as a chain-locked spend — what sync + /// does after a real broadcast confirms, removing the spent input from the + /// UTXO set. + async fn process_spend( + wm: &TestWalletManager, + wallet_id: &WalletId, + tx: &dashcore::Transaction, + ) { + use dashcore::BlockHash; + use key_wallet::transaction_checking::{ + BlockInfo, TransactionContext, WalletTransactionChecker, + }; + + let mut guard = wm.write().await; + let (wallet, info) = guard + .get_wallet_mut_and_info_mut(wallet_id) + .expect("wallet present"); + info.core_wallet + .check_core_transaction( + tx, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 2, + BlockHash::all_zeros(), + 1_700_000_100, + )), + wallet, + true, + true, + ) + .await; + } + + /// The core contract: a build reserves its inputs (proved by the second + /// build failing), and abandoning it makes exactly those inputs selectable + /// again immediately — no TTL wait. + #[tokio::test] + async fn abandoning_a_build_makes_its_inputs_selectable_again() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(Arc::clone(&wm), wallet_id, balance); + + let payment = core + .build_signed_payment(vec![(recipient(4), 1_000_000)], None, &signer, None) + .await + .expect("the funded account covers the payment"); + let reserved: HashSet = payment + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + assert!(!reserved.is_empty(), "the build must have selected inputs"); + + // Precondition: the reservation is real and it is what blocks a + // second build. Without this the test could pass vacuously. + assert!( + matches!( + core.build_signed_payment(vec![(recipient(4), 1_000_000)], None, &signer, None) + .await, + Err(PlatformWalletError::PaymentInsufficientFunds { .. }) + ), + "the first build's reservation must block a second build" + ); + + core.release_payment_reservation(&payment.transaction, None, payment.reservation_token) + .await + .expect("abandoning a build must succeed"); + + let after = core + .build_signed_payment(vec![(recipient(4), 1_000_000)], None, &signer, None) + .await + .expect("the abandoned build's inputs must be selectable again"); + let reselected: HashSet = after + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + assert_eq!( + reselected, reserved, + "the rebuild must reselect exactly the released inputs" + ); + assert_all_inputs_signed(&after); + } + + /// Releasing twice is a no-op, not an error: the second call resolves the + /// funding account fine and the token no longer owns anything. This is what + /// lets a caller release without tracking whether it already ran. + /// + /// Note the scope: idempotent across *repeated releases of the same + /// abandoned build*. It does NOT license releasing a build that was + /// broadcast — see `releasing_a_broadcast_build_before_sync_reopens_its_inputs`. + #[tokio::test] + async fn abandoning_twice_is_a_no_op() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(Arc::clone(&wm), wallet_id, balance); + + let payment = core + .build_signed_payment(vec![(recipient(5), 1_000_000)], None, &signer, None) + .await + .expect("the funded account covers the payment"); + + for attempt in 0..3 { + core.release_payment_reservation(&payment.transaction, None, payment.reservation_token) + .await + .unwrap_or_else(|e| panic!("release attempt {attempt} must be a no-op, got {e:?}")); + } + + // Still exactly one release's worth of effect: the coins are free. + core.build_signed_payment(vec![(recipient(5), 1_000_000)], None, &signer, None) + .await + .expect("repeated releases must leave the inputs selectable"); + } + + /// Releasing **after sync has processed** a broadcast spend is a no-op, and + /// cannot resurrect the spent coin: coin selection reads the UTXO set, from + /// which sync has already removed the spend, so the released reservation has + /// nothing to expose. + /// + /// This pins the *post-sync* half only. It deliberately no longer claims the + /// release is safe in an unconditional `finally`: the dangerous window is + /// BEFORE sync observes the broadcast, which + /// `releasing_a_broadcast_build_before_sync_reopens_its_inputs` covers + /// (dashpay/platform#4247 review). + #[tokio::test] + async fn abandoning_after_broadcast_is_a_no_op() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(Arc::clone(&wm), wallet_id, balance); + + // Send the entire balance so the confirmed spend leaves no change to + // fund a follow-up build — any later success could only come from a + // resurrected input. + let payment = core + .build_signed_payment(vec![(recipient(6), 9_900_000)], None, &signer, None) + .await + .expect("the funded account covers the payment"); + let spent: HashSet = payment + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + + // Stand in for the caller broadcasting and sync observing it. + process_spend(&wm, &wallet_id, &payment.transaction).await; + + core.release_payment_reservation(&payment.transaction, None, payment.reservation_token) + .await + .expect("releasing after a broadcast must be a silent no-op, not an error"); + + let live = bip44_outpoints(&wm, &wallet_id).await; + assert!( + spent.iter().all(|op| !live.contains(op)), + "the release must not resurrect the spent inputs {spent:?} into the UTXO set {live:?}" + ); + } + + /// The height-0 case shumkov flagged: before the first sync completes the + /// wallet's processed height is 0, and key-wallet's `ReservationSet::sweep` + /// early-returns at height 0 — so the TTL backstop never fires and an + /// abandoned build strands the balance for the life of the process. This + /// pins both halves: the TTL genuinely cannot recover it, and the explicit + /// release can. + #[tokio::test] + async fn abandoning_releases_at_height_zero_where_the_ttl_never_fires() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + // Pre-sync: no processed height yet. The funding UTXO is non-coinbase, + // so it stays spendable at height 0 — only the sweep is disabled. + set_last_processed_height(&wm, &wallet_id, 0).await; + let core = core_wallet(Arc::clone(&wm), wallet_id, balance); + + let payment = core + .build_signed_payment(vec![(recipient(8), 1_000_000)], None, &signer, None) + .await + .expect("a pre-sync wallet can still build from a confirmed UTXO"); + assert!( + !payment.transaction.input.is_empty(), + "the build must have selected — and so reserved — inputs at height 0" + ); + + // The TTL backstop is inert here: even far beyond RESERVATION_TTL_BLOCKS + // worth of build attempts, the height-0 reservation is never swept, so + // the coins stay stranded. This is the bug, reproduced. + for _ in 0..30 { + assert!( + matches!( + core.build_signed_payment(vec![(recipient(8), 1_000_000)], None, &signer, None) + .await, + Err(PlatformWalletError::PaymentInsufficientFunds { .. }) + ), + "at height 0 the TTL sweep must never reclaim the reservation" + ); + } + + // The explicit release consults no height, so it works where the TTL + // cannot. + core.release_payment_reservation(&payment.transaction, None, payment.reservation_token) + .await + .expect("the release must not depend on a processed height"); + + core.build_signed_payment(vec![(recipient(8), 1_000_000)], None, &signer, None) + .await + .expect("releasing at height 0 must free the stranded inputs"); + } + + /// A release aimed at the wrong account frees nothing — the reservation + /// lives in the funding account's own ledger. Guards the "releases ONLY + /// its own build's inputs" property against a path-confusion regression. + #[tokio::test] + async fn releasing_against_another_account_frees_nothing() { + let (wm, wallet_id, generation, signer) = + split_funded_wallet_manager(9_000_000, 20_000_000).await; + let (_, _, coinjoin_path) = + split_account_outpoints_and_coinjoin_path(&wm, &wallet_id).await; + let core = core_wallet(Arc::clone(&wm), wallet_id, generation); + + // Fund from CoinJoin, then try to release against the BIP44 default. + let payment = core + .build_signed_payment( + vec![(recipient(7), 15_000_000)], + None, + &signer, + Some(coinjoin_path.clone()), + ) + .await + .expect("the named CoinJoin account covers 0.15 DASH"); + + core.release_payment_reservation(&payment.transaction, None, payment.reservation_token) + .await + .expect("a mismatched release resolves the account and simply frees nothing"); + assert!( + matches!( + core.build_signed_payment( + vec![(recipient(7), 15_000_000)], + None, + &signer, + Some(coinjoin_path.clone()), + ) + .await, + Err(PlatformWalletError::PaymentInsufficientFunds { .. }) + ), + "releasing against BIP44 must not free the CoinJoin account's reservation" + ); + + // The correctly-aimed release does free it. + core.release_payment_reservation( + &payment.transaction, + Some(coinjoin_path.clone()), + payment.reservation_token, + ) + .await + .expect("releasing against the funding account must succeed"); + core.build_signed_payment( + vec![(recipient(7), 15_000_000)], + None, + &signer, + Some(coinjoin_path), + ) + .await + .expect("the CoinJoin inputs must be selectable again"); + } + + /// An unresolvable funding path is reported rather than silently treated + /// as "nothing to release" — a caller passing a bad path would otherwise + /// believe it had cleaned up. + #[tokio::test] + async fn releasing_with_an_unknown_funding_path_is_rejected() { + use std::str::FromStr; + + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(Arc::clone(&wm), wallet_id, balance); + + let payment = core + .build_signed_payment(vec![(recipient(3), 1_000_000)], None, &signer, None) + .await + .expect("the funded account covers the payment"); + + let bogus = DerivationPath::from_str("m/44'/5'/77'").expect("valid path"); + match core + .release_payment_reservation( + &payment.transaction, + Some(bogus), + payment.reservation_token, + ) + .await + { + Err(PlatformWalletError::TransactionBuild(m)) => assert!( + m.contains("no funds account matches"), + "the rejection must name the unresolvable path, got {m:?}" + ), + other => panic!("an unknown funding path must be refused, got {other:?}"), + } + } + + /// FINDING 1 (dashpay/platform#4247 review): the post-signing size check + /// runs *after* `build_signed_reserved` has already reserved the selected + /// inputs, so returning bare stranded them — the caller never receives the + /// transaction and so has nothing to hand the release, and the TTL backstop + /// is no fallback (it never fires at height 0 at all). + /// + /// Reached exactly as the review described: a recipient list that passes the + /// output-side bound, funded by an account whose many small UTXOs then push + /// the signed transaction over the standard limit. + /// + /// The assertion is the one that matters — after the rejection every input + /// must be selectable again, proved by an ordinary payment succeeding and + /// reselecting them. + #[tokio::test] + async fn an_oversized_signed_transaction_strands_no_reservation() { + use crate::test_support::funded_wallet_manager_with_outputs; + + // Eight 0.005-DASH UTXOs: enough small inputs that funding the recipient + // list below needs most of them, and none of them alone can. + let (wm, wallet_id, balance, signer) = + funded_wallet_manager_with_outputs(StandardAccountType::BIP44Account, &[500_000; 8]) + .await; + let all_inputs = bip44_outpoints(&wm, &wallet_id).await; + assert_eq!(all_inputs.len(), 8, "the fixture must provide 8 UTXOs"); + + let core = core_wallet(Arc::clone(&wm), wallet_id, balance); + + // The largest recipient count the OUTPUT-side pre-check still admits: + // one below the smallest count that leaves no room for a single input. + // The build therefore gets past every pre-flight bound, selects, signs, + // and only then measures over the limit. + let admitted = + (super::MAX_STANDARD_TX_SIZE - super::TX_INPUT_SIZE) / super::TX_OUTPUT_SIZE - 1; + let outputs: Vec<_> = (0..admitted) + .map(|i| (recipient((i % 250) as u8), 1_000u64)) + .collect(); + + match core + .build_signed_payment(outputs, None, &signer, None) + .await + { + Err(PlatformWalletError::TransactionBuild(m)) => assert!( + m.contains("the signed transaction is") && m.contains("would not relay"), + "this must be the POST-signing size rejection (the path that holds a \ + reservation), not a pre-flight bound, got {m:?}" + ), + other => panic!( + "{admitted} recipients over 8 small inputs must exceed the standard size \ + limit after signing, got {other:?}" + ), + } + + // THE REGRESSION: before the fix the rejected build kept its inputs + // reserved, so this ordinary payment failed with PaymentInsufficientFunds + // even though the wallet plainly held 0.04 DASH. + let after = core + .build_signed_payment(vec![(recipient(11), 3_000_000)], None, &signer, None) + .await + .expect("a rejected oversized build must leave every input selectable"); + let reselected: HashSet = after + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + assert!( + !reselected.is_empty() && reselected.is_subset(&all_inputs), + "the follow-up build must reselect the released inputs, got {reselected:?}" + ); + } + + /// FINDING 2 (dashpay/platform#4247 review): the release must be + /// owner-guarded, because a build's reservation can be swept by key-wallet's + /// TTL and the same outpoint legitimately re-reserved by a *different* build + /// before the first build gets around to releasing. + /// + /// This reproduces that window end to end — reserve, age past + /// `RESERVATION_TTL_BLOCKS`, let a second build re-take the outpoint — and + /// pins both directions: + /// + /// * releasing build A **with its token** leaves build B's reservation + /// intact (the fix); + /// * releasing the very same transaction **without** a token frees it (the + /// old behavior), so the guard is demonstrably what closes the window + /// rather than something else in the path. + /// + /// Freeing B's inputs is a double-spend window: coin selection would hand + /// them straight to a third transaction while B is still in flight. + #[tokio::test] + async fn an_owner_guarded_release_cannot_free_a_re_reserved_input() { + const TTL_BLOCKS: u32 = 24; + + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + set_last_processed_height(&wm, &wallet_id, 10).await; + let core = core_wallet(Arc::clone(&wm), wallet_id, balance); + + // Build A reserves the wallet's single UTXO at height 10. + let build_a = core + .build_signed_payment(vec![(recipient(21), 1_000_000)], None, &signer, None) + .await + .expect("the funded account covers the payment"); + assert!( + build_a.reservation_token.is_some(), + "a funded build must stamp a reservation token" + ); + + // Age past the TTL. The next build's `reserved()` call sweeps A's entry, + // so A's outpoint becomes selectable again — inside key-wallet, with no + // signal to this layer. + set_last_processed_height(&wm, &wallet_id, 10 + TTL_BLOCKS).await; + + // Build B re-reserves that very outpoint under a NEW token. + let build_b = core + .build_signed_payment(vec![(recipient(22), 1_000_000)], None, &signer, None) + .await + .expect("the swept reservation must let a second build select the same coin"); + let b_inputs: HashSet = build_b + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + let a_inputs: HashSet = build_a + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + assert_eq!( + a_inputs, b_inputs, + "the test is only meaningful if B re-reserved exactly A's inputs" + ); + assert_ne!( + build_a.reservation_token, build_b.reservation_token, + "the re-reservation must mint a fresh token — that difference IS the guard" + ); + + // A is abandoned late, WITH its token. B's reservation must survive. + core.release_payment_reservation(&build_a.transaction, None, build_a.reservation_token) + .await + .expect("a stale owner-guarded release is a no-op, not an error"); + + assert!( + matches!( + core.build_signed_payment(vec![(recipient(23), 1_000_000)], None, &signer, None) + .await, + Err(PlatformWalletError::PaymentInsufficientFunds { .. }) + ), + "releasing A must not free the inputs B is still holding — doing so would let \ + coin selection double-spend B's in-flight transaction" + ); + + // Control: the unguarded release (what this path did before the fix) + // DOES free B's reservation, which is the bug. + core.release_payment_reservation(&build_a.transaction, None, None) + .await + .expect("the unguarded release resolves the account fine"); + core.build_signed_payment(vec![(recipient(24), 1_000_000)], None, &signer, None) + .await + .expect( + "the unguarded release frees B's inputs — the double-spend window the token \ + closes", + ); + } + + /// FINDING 2, second clause (dashpay/platform#4247 review): the contract + /// used to advertise the release as "safe after a broadcast" and therefore + /// safe to wire into an unconditional `finally`. It is not. + /// + /// This primitive does not broadcast, so between the caller's successful + /// external broadcast and sync processing that spend back into the wallet, + /// the inputs are STILL in the UTXO set and the reservation is the only + /// thing keeping a second build off them. This pins the consequence of + /// releasing in that window: the next build reselects the same inputs and + /// produces a conflicting transaction. + /// + /// It is a documentation-contract test — the behavior it shows is inherent + /// to releasing, which is exactly why the fix is that callers must not + /// release a build they broadcast. + #[tokio::test] + async fn releasing_a_broadcast_build_before_sync_reopens_its_inputs() { + let (wm, wallet_id, balance, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let core = core_wallet(Arc::clone(&wm), wallet_id, balance); + + let broadcast = core + .build_signed_payment(vec![(recipient(31), 1_000_000)], None, &signer, None) + .await + .expect("the funded account covers the payment"); + + // The caller hands these bytes to dashj, which broadcasts them + // successfully. Sync has NOT yet processed the spend, so `process_spend` + // is deliberately NOT called here — that is the whole window. + + core.release_payment_reservation(&broadcast.transaction, None, broadcast.reservation_token) + .await + .expect( + "the release itself succeeds — nothing at this layer knows about the broadcast", + ); + + let conflicting = core + .build_signed_payment(vec![(recipient(32), 1_000_000)], None, &signer, None) + .await + .expect("releasing re-opened the still-unspent inputs to selection"); + + let broadcast_inputs: HashSet = broadcast + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + let conflicting_inputs: HashSet = conflicting + .transaction + .input + .iter() + .map(|i| i.previous_output) + .collect(); + assert_eq!( + broadcast_inputs, conflicting_inputs, + "the second build spends the already-broadcast inputs" + ); + assert_ne!( + broadcast.transaction.txid(), + conflicting.transaction.txid(), + "two distinct transactions now spend the same coins — a double-spend the network \ + will reject, which is why releasing after a broadcast is NOT safe and the \ + 'unconditional cleanup path' guidance was removed" + ); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 81b2e8a824..32603873da 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,9 +17,9 @@ 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 super::{CoreWallet, WalletGeneration}; use crate::broadcaster::TransactionBroadcaster; use crate::PlatformWalletError; @@ -59,6 +60,42 @@ 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, + /// The per-generation balance `Arc` of the wallet this payment was + /// **finalized against** — captured from the originating `CoreWallet` inside + /// `finalize_transaction`. It is the same unforgeable generation-identity + /// marker [`CoreWallet::is_same_generation`] compares (a fresh `Arc` per + /// wallet generation; two aliases of one generation share it, a + /// remove-then-recreate under the same id gets a new one). + /// + /// The deferred-payment registry validates the wallet it is asked to bind + /// this payment to against **this** marker before it mints a token + /// ([`SignedPaymentRegistry::register`](crate::SignedPaymentRegistry::register)), + /// so a caller cannot finalize through wallet A and then register/broadcast + /// through an unrelated wallet B — the registry would otherwise treat B as + /// the owner, submit A's transaction through B's broadcaster, and run B's + /// cleanup while A's real reservation leaked until its TTL + /// (`dashpay/platform#4185`). + origin_generation: Arc, } impl SignedCoreTransaction { @@ -77,6 +114,97 @@ 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 + } + + /// The per-generation balance `Arc` of the wallet this payment was finalized + /// against — the unforgeable generation-identity marker the deferred-payment + /// registry pointer-compares before binding the payment to a wallet (see + /// [`origin_generation`](Self::origin_generation) field docs). Borrowed, not + /// consumed, so the check can run before + /// [`into_registered_parts`](Self::into_registered_parts) takes ownership. + pub(crate) fn origin_generation(&self) -> &Arc { + &self.origin_generation + } + + /// Consume this finalized transaction into the owned parts the deferred + /// [`SignedPaymentRegistry`](crate::SignedPaymentRegistry) stores. + /// + /// Consuming (rather than cloning) is what enforces unique reservation + /// ownership: `SignedCoreTransaction` is deliberately not `Clone`, so a + /// finalize yields exactly one ownership object and the registry can be + /// handed it exactly once — a caller cannot mint two live tokens that name + /// the same held reservation (`dashpay/platform#4185`). The transaction, + /// funding account, and reservation height are derived here, not supplied + /// independently by the caller. + pub(crate) fn into_registered_parts(self) -> RegisteredPaymentParts { + RegisteredPaymentParts { + transaction: self.transaction, + funding_account_type: self.funding_account_type, + funding_account_index: self.funding_account_index, + reservation_height: self.reservation_height, + reservation_token: self.reservation_token, + } + } +} + +/// The owned facts the deferred-payment registry takes over when it registers a +/// finalized transaction. Produced only by +/// [`SignedCoreTransaction::into_registered_parts`], which consumes the +/// non-`Clone` ownership object exactly once. +pub(crate) struct RegisteredPaymentParts { + pub(crate) transaction: Transaction, + pub(crate) funding_account_type: AccountTypePreference, + pub(crate) funding_account_index: u32, + pub(crate) reservation_height: u32, + pub(crate) reservation_token: Option, +} + +#[cfg(any(test, feature = "test-utils"))] +impl SignedCoreTransaction { + /// Build a `SignedCoreTransaction` directly, for tests that need a finalized + /// ownership object without running the full funding + signing pipeline + /// (e.g. the registry and FFI destroy/lifecycle tests). + /// + /// `origin_generation` is the per-generation balance `Arc` the payment is to + /// be treated as finalized against — a test that registers it must hand the + /// registry the SAME generation + /// ([`CoreWallet::test_generation_marker`](crate::CoreWallet::test_generation_marker)), + /// exactly as the production path binds a token to the finalizing wallet. + pub fn new_for_test( + transaction: Transaction, + fee: u64, + funding_account_type: AccountTypePreference, + funding_account_index: u32, + reservation_height: u32, + reservation_token: Option, + origin_generation: Arc, + ) -> Self { + Self { + transaction, + fee, + funding_account_type, + funding_account_index, + reservation_height, + reservation_token, + origin_generation, + } + } } fn account( @@ -125,7 +253,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 +275,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 +334,7 @@ impl CoreWallet { } }; - (unsigned, fee, selected, paths) + (unsigned, fee, selected, paths, height, reservation_token) }; let signed = match signer @@ -212,8 +345,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 +366,12 @@ impl CoreWallet { fee, funding_account_type: account_type, funding_account_index: account_index, + reservation_height: height, + reservation_token, + // Capture the finalizing wallet's generation identity so the + // deferred registry can refuse to bind this payment to any other + // wallet (`dashpay/platform#4185`). + origin_generation: Arc::clone(self.generation()), }) } @@ -232,29 +381,84 @@ impl CoreWallet { transaction.funding_account_type, transaction.funding_account_index, &transaction.transaction, + transaction.reservation_token, ) .await; } + /// Release the funding reservation `transaction` holds, bound to this + /// handle's own wallet *generation*. + /// + /// `token` is the [`ReservationToken`] the build stamped onto the inputs + /// (`SignedCoreTransaction::reservation_token`). When present the release is + /// *owner-guarded* — it frees only inputs still owned by that token, so a + /// reservation key-wallet's TTL swept and a concurrent build re-took is left + /// untouched (`dashpay/platform#4185`). When `None` (the build reserved + /// nothing) it falls back to the unconditional by-outpoint release; that + /// path is never reached for a funded finalize, which always reserves. pub(crate) async fn release_transaction_reservation( &self, account_type: AccountTypePreference, account_index: u32, transaction: &Transaction, + token: Option, ) { + // Validate the generation AND mutate the `ReservationSet` under one + // manager-lock hold. `ReservationSet::release` removes an outpoint + // 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: wallet not found" + ); + return; + }; + if !Arc::ptr_eq(&info.generation, self.generation()) { + // The wallet under this id is a different (re-created) generation: + // releasing by outpoint could free ITS reservation. Leave it — the + // original generation's reservation ceased to exist with it. tracing::warn!( wallet_id = %hex::encode(self.wallet_id), ?account_type, account_index, - "could not release finalized Core transaction reservation" + "skipping reservation release: wallet was re-created under the same id \ + (different generation) since the token was minted" ); + return; + } + match managed_account(&info.core_wallet.accounts, account_type, account_index) { + // Owner-guarded when the build stamped a token: even within this + // generation, a TTL sweep between build and release could have + // re-reserved the same outpoints under a new token, and an + // unconditional release would free that newer reservation. With the + // token key-wallet frees only inputs this build still owns. `None` + // (no reservation taken) falls back to the unconditional release. + Some(managed) => match token { + Some(token) => managed.release_reservation_if_owner(transaction, token), + None => managed.release_reservation(transaction), + }, + None => tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + ?account_type, + account_index, + "could not release finalized Core transaction reservation: account not found" + ), } } } diff --git a/packages/rs-platform-wallet/src/wallet/core/wallet.rs b/packages/rs-platform-wallet/src/wallet/core/wallet.rs index 8cc0488ade..fbf2c7684e 100644 --- a/packages/rs-platform-wallet/src/wallet/core/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/core/wallet.rs @@ -3,13 +3,15 @@ use std::sync::Arc; use super::balance::WalletBalance; +use super::generation::WalletGeneration; use dashcore::Address as DashAddress; -use tokio::sync::RwLock; +use tokio::sync::{RwLock, RwLockReadGuard}; use key_wallet::managed_account::address_pool::KeySource; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; 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; @@ -32,8 +34,10 @@ pub struct CoreWallet { /// Injected broadcaster — delegates to SPV or DAPI depending on how /// the wallet was constructed by `PlatformWalletManager`. pub(crate) broadcaster: Arc, - /// Lock-free balance for UI reads. - balance: Arc, + /// This handle's wallet *generation*: the lock-free balance the UI reads and + /// that generation's lifecycle gate, in the one `Arc` every handle to the + /// generation shares. + generation: Arc, } impl CoreWallet { @@ -42,20 +46,20 @@ impl CoreWallet { wallet_manager: Arc>>, wallet_id: WalletId, broadcaster: Arc, - balance: Arc, + generation: Arc, ) -> Self { Self { sdk, wallet_manager, wallet_id, broadcaster, - balance, + generation, } } /// Lock-free balance snapshot for UI reads. pub fn balance(&self) -> &WalletBalance { - &self.balance + self.generation.balance() } /// Wallet id this `CoreWallet` operates on. Exposed so FFI @@ -66,6 +70,75 @@ 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`](WalletGeneration); a wallet removed and + /// re-created under the same `wallet_id` gets a fresh one. `Arc::ptr_eq` on + /// it therefore distinguishes generations that `wallet_id` — and the shared + /// multi-wallet `WalletManager` `Arc` — alone cannot (both are equal across a + /// remove-then-recreate). While either handle is held that `Arc` cannot be + /// freed, so its address can never be reused for a different generation, + /// which makes the pointer comparison sound (the same soundness argument the + /// registry already relies on for `Arc::ptr_eq` on the manager). + /// + /// This is the single generation identity shared by BOTH deferred-payment + /// paths — the registry-token path + /// ([`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.generation, &other.generation) + } + + /// This handle's [`WalletGeneration`] `Arc` — the generation-identity marker + /// (see [`is_same_generation`](Self::is_same_generation)). The manager stores + /// the same `Arc` in `PlatformWalletInfo.generation`, so a + /// reservation-cleanup path can, **under the manager lock**, compare this + /// against the wallet currently registered under `wallet_id` and act only if + /// they are the same generation — binding a validate-then-mutate to one lock + /// hold and refusing to touch a generation re-created under the same id. + pub(crate) fn generation(&self) -> &Arc { + &self.generation + } + + /// Enter THIS generation's lifecycle gate as a payment operation — see + /// [`WalletGeneration::payment_guard`]. + /// + /// Every path that publishes an ownership handle for this generation (a + /// registry token, a V2 finalized-transaction handle) or pushes one of its + /// transactions to the network must hold this across both its + /// [`is_current_generation`](Self::is_current_generation) check and the + /// action that check authorizes. Without it the check is a bare + /// point-in-time observation and a teardown can complete in the gap + /// (`dashpay/platform#4185`). + /// + /// Scoped to this generation, so holding it across a slow SPV send blocks + /// only this wallet's teardown — never an unrelated wallet's. + pub async fn generation_payment_guard(&self) -> RwLockReadGuard<'_, ()> { + self.generation.payment_guard().await + } + + /// This handle's per-generation identity marker, cloned — for tests (and + /// downstream FFI-crate tests via `test-utils`) that build a finalized + /// [`SignedCoreTransaction`](crate::SignedCoreTransaction) with + /// [`new_for_test`](crate::SignedCoreTransaction::new_for_test) and must + /// stamp it with the SAME generation they then register it against, exactly + /// as the production `finalize_transaction` path binds a token to the + /// finalizing wallet. + #[cfg(any(test, feature = "test-utils"))] + pub fn test_generation_marker(&self) -> Arc { + Arc::clone(&self.generation) + } + pub async fn set_gap_limit( &self, account_type: AccountTypePreference, @@ -286,6 +359,53 @@ 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()) + } + + /// Whether the generation this handle names is STILL the one registered + /// under its `wallet_id` in the manager. + /// + /// [`is_same_generation`](Self::is_same_generation) compares two *handles* + /// and therefore cannot see either way a generation stops being current: + /// + /// * **Removed** (`platform_wallet_manager_remove_wallet`). A retained + /// handle keeps `wallet_id`, the shared manager `Arc`, and its own + /// [`WalletGeneration`] `Arc` alive, so two handles to the removed + /// generation still compare equal to each other. Only a lookup against the + /// manager can tell that nothing is registered under the id any more. + /// * **Re-created** under the same id. `wallet_id` and the manager `Arc` are + /// preserved; only the `WalletGeneration` `Arc` is fresh. + /// + /// Both cases mean the same thing to a deferred payment: the accounts — + /// and therefore the `ReservationSet` holding its funding inputs — that this + /// handle names are no longer the wallet's live state, so acting on them + /// would spend against state the manager no longer owns. Callers that must + /// be atomic against a concurrent teardown take + /// [`generation_payment_guard`](Self::generation_payment_guard) around the + /// check and the action it gates; on its own this is a point-in-time + /// observation (`dashpay/platform#4185`). + pub async fn is_current_generation(&self) -> bool { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .is_some_and(|info| Arc::ptr_eq(&info.generation, self.generation())) + } } impl std::fmt::Debug for CoreWallet { @@ -306,7 +426,72 @@ impl Clone for CoreWallet { wallet_manager: Arc::clone(&self.wallet_manager), wallet_id: self.wallet_id, broadcaster: Arc::clone(&self.broadcaster), - balance: Arc::clone(&self.balance), + generation: Arc::clone(&self.generation), } } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use key_wallet::account::account_type::StandardAccountType; + + use super::WalletGeneration; + use crate::test_support::{funded_wallet_manager, AlwaysOkBroadcaster}; + use crate::wallet::core::CoreWallet; + + /// The single generation identity both deferred-payment paths share: + /// aliases of one generation share the per-generation `WalletGeneration` `Arc` (same + /// generation), while a wallet re-created under the same `wallet_id` and the + /// same multi-wallet `WalletManager` `Arc` but a fresh generation `Arc` is a + /// DIFFERENT generation. Neither `wallet_id` nor the manager `Arc` alone can + /// tell them apart — the generation `Arc` is what distinguishes them, closing + /// the gap where an old handle could act through the old generation while a + /// new generation selected the same inputs. + #[tokio::test] + async fn is_same_generation_distinguishes_recreation_from_aliases() { + let (manager, wallet_id, generation, _signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let broadcaster = Arc::new(AlwaysOkBroadcaster); + + let generation_a = CoreWallet::new( + Arc::clone(&sdk), + Arc::clone(&manager), + wallet_id, + Arc::clone(&broadcaster), + Arc::clone(&generation), + ); + + // A clone is an alias of the SAME generation (shares the generation Arc). + let alias = generation_a.clone(); + assert!( + generation_a.is_same_generation(&alias), + "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 `WalletGeneration` Arc. + let generation_b = CoreWallet::new( + sdk, + Arc::clone(&manager), + wallet_id, + broadcaster, + Arc::new(WalletGeneration::new()), + ); + assert!( + !generation_a.is_same_generation(&generation_b), + "a re-created generation must NOT match, despite equal wallet_id + manager" + ); + // Sanity: it is ONLY the generation Arc that differs — wallet_id and the + // manager Arc are identical, so those checks alone could not tell the + // two generations apart. + assert_eq!(generation_a.wallet_id(), generation_b.wallet_id()); + 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..64c0631e59 --- /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::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, generation, signer) = + split_funded_wallet_manager(BIP44_DUFFS, COINJOIN_DUFFS).await; + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let core = CoreWallet::new( + sdk, + wm, + wallet_id, + Arc::new(AlwaysRejectedBroadcaster), + generation, + ); + 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, generation, signer) = + split_funded_wallet_manager(9_000_000, 20_000_000).await; + + let (bip44_ops, coinjoin_ops, coinjoin_path) = { + let guard = wm.read().await; + 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), + generation, + ); + 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/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index fc96fd6ead..5df388e7ba 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -3338,7 +3338,7 @@ mod sweep_tests { use super::*; use crate::broadcaster::SpvBroadcaster; use crate::changeset::{ContactChangeSet, PlatformWalletChangeSet, SentContactRequestKey}; - use crate::wallet::core::WalletBalance; + use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; use crate::wallet::persister::{NoPlatformPersistence, WalletPersister}; use crate::wallet::platform_wallet::PlatformWalletInfo; @@ -3362,7 +3362,7 @@ mod sweep_tests { fn empty_info(wallet: &Wallet) -> PlatformWalletInfo { PlatformWalletInfo { core_wallet: ManagedWalletInfo::from_wallet(wallet, 0), - balance: Arc::new(WalletBalance::new()), + generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), } diff --git a/packages/rs-platform-wallet/src/wallet/mod.rs b/packages/rs-platform-wallet/src/wallet/mod.rs index 1963422be7..3143a4ea39 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,6 @@ pub use platform_wallet::{ PlatformWallet, PlatformWalletInfo, WalletId, WalletStateReadGuard, WalletStateWriteGuard, }; pub use provider_key_at_index::{ProviderDerivedKey, ProviderKeyKind}; +pub use signed_payment_registry::{ + RegisterWrongGeneration, ReservationToken, SignedPaymentError, SignedPaymentRegistry, +}; diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index cfb310ea59..bf28a640c2 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -12,7 +12,7 @@ use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; use super::asset_lock::manager::AssetLockManager; use super::asset_lock::tracked::TrackedAssetLock; -use super::core::{CoreWallet, WalletBalance}; +use super::core::{CoreWallet, WalletBalance, WalletGeneration}; use super::identity::{IdentityManager, IdentityWallet}; use super::persister::WalletPersister; use super::platform_addresses::PlatformAddressWallet; @@ -40,14 +40,28 @@ pub type WalletId = [u8; 32]; /// Lives inside `WalletManager.wallet_infos`. The `Wallet` /// key material is in `WalletManager.wallets` — NOT inside this struct. /// -/// `WalletBalance` is stored as `Arc` for lock-free UI reads. +/// The per-generation state (lock-free balance + lifecycle gate) is stored as +/// `Arc`; `Arc::ptr_eq` on it is this wallet's generation identity. pub struct PlatformWalletInfo { /// Core wallet metadata, accounts, UTXOs, balances. /// Delegates `WalletInfoInterface` methods. pub core_wallet: ManagedWalletInfo, - /// Lock-free balance for UI reads. Updated from `ManagedWalletInfo` after - /// each SPV block/mempool processing and RPC refresh. - pub balance: Arc, + /// This wallet generation's shared state: the lock-free balance for UI reads + /// (updated from `ManagedWalletInfo` after each SPV block/mempool processing + /// and RPC refresh) and the generation's lifecycle gate. + /// + /// Deliberately `pub(crate)`, not `pub`: this `Arc` *is* the generation + /// identity that `Arc::ptr_eq` compares, and `PlatformWalletInfo` is + /// reachable mutably from outside the crate through + /// [`PlatformWallet::state_mut`] / [`PlatformWallet::state_mut_blocking`]. + /// A public field would let safe downstream code drop a fresh `Arc` in here + /// while `PlatformWallet` and `CoreWallet` keep the original, splitting the + /// identity: `is_current_generation()` would then reject the still-live + /// wallet, generation-bound reservation cleanup would become a no-op, and + /// teardown would exclude through a different lifecycle gate than the + /// payment operations it has to fence. Read it through + /// [`PlatformWallet::generation`]; it is assigned only at construction. + pub(crate) generation: Arc, pub identity_manager: IdentityManager, pub tracked_asset_locks: BTreeMap, } @@ -79,8 +93,8 @@ pub struct PlatformWallet { pub(crate) asset_locks: Arc>, /// Per-wallet persistence handle. persister: WalletPersister, - /// Lock-free balance for UI reads, cloned from `PlatformWalletInfo.balance`. - pub(crate) balance: Arc, + /// This generation's shared state, cloned from `PlatformWalletInfo.generation`. + pub(crate) generation: Arc, /// Per-account Orchard keysets, populated by [`bind_shielded`]. /// `None` until bind has run; remains `None` for `WatchOnly` /// / `ExternalSignable` wallets that have never had a @@ -185,8 +199,14 @@ impl PlatformWallet { } /// Get the lock-free balance for UI reads. - pub fn balance(&self) -> &Arc { - &self.balance + pub fn balance(&self) -> &WalletBalance { + self.generation.balance() + } + + /// This wallet's [`WalletGeneration`] `Arc` — its generation identity and + /// lifecycle gate. See [`CoreWallet::is_same_generation`]. + pub fn generation(&self) -> &Arc { + &self.generation } /// Get a reference to the per-wallet persistence handle. @@ -411,7 +431,7 @@ impl PlatformWallet { sdk: Arc, wallet_id: WalletId, wallet_manager: Arc>>, - balance: Arc, + generation: Arc, lock_notify: Arc, persister: Arc, broadcaster: Arc, @@ -426,7 +446,7 @@ impl PlatformWallet { Arc::clone(&wallet_manager), wallet_id, Arc::clone(&broadcaster), - Arc::clone(&balance), + Arc::clone(&generation), ); // Asset-lock broadcaster is pinned to `SpvBroadcaster`; the @@ -475,7 +495,7 @@ impl PlatformWallet { platform, asset_locks, persister: wallet_persister, - balance, + generation, #[cfg(feature = "shielded")] shielded_keys: Arc::new(RwLock::new(None)), #[cfg(feature = "shielded")] @@ -1604,7 +1624,7 @@ impl Clone for PlatformWallet { platform: self.platform.clone(), asset_locks: self.asset_locks.clone(), persister: self.persister.clone(), - balance: self.balance.clone(), + generation: self.generation.clone(), #[cfg(feature = "shielded")] shielded_keys: self.shielded_keys.clone(), #[cfg(feature = "shielded")] diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs index 9feb25bb04..62b1cef00e 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -37,7 +37,7 @@ impl WalletInfoInterface for PlatformWalletInfo { let inner = ManagedWalletInfo::from_wallet(wallet, birth_height); Self { core_wallet: inner, - balance: std::sync::Arc::new(super::core::WalletBalance::new()), + generation: std::sync::Arc::new(super::core::WalletGeneration::new()), identity_manager: super::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), } @@ -49,7 +49,7 @@ impl WalletInfoInterface for PlatformWalletInfo { let inner = ManagedWalletInfo::from_wallet_with_name(wallet, name, birth_height); Self { core_wallet: inner, - balance: std::sync::Arc::new(super::core::WalletBalance::new()), + generation: std::sync::Arc::new(super::core::WalletGeneration::new()), identity_manager: super::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), } diff --git a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs new file mode 100644 index 0000000000..fa638fcea9 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -0,0 +1,1921 @@ +//! 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::{Arc, 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, SignedCoreTransaction}; +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. +/// +/// A distinct newtype rather than a bare `u64` alias so a payment handle can +/// never be silently confused with any other numeric identifier (the funding +/// [`FundingReservationToken`], an account index, a raw height). It crosses the +/// C ABI as a `u64` — [`from`](ReservationToken::from) / [`as_u64`](ReservationToken::as_u64) +/// are the only conversions, applied at the FFI boundary. +#[repr(transparent)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ReservationToken(u64); + +impl ReservationToken { + /// The raw wire value handed back across the FFI boundary to the host. + pub const fn as_u64(self) -> u64 { + self.0 + } +} + +impl From for ReservationToken { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl From for u64 { + fn from(token: ReservationToken) -> Self { + token.0 + } +} + +impl std::fmt::Display for ReservationToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Maximum age, in `last_processed_height` blocks, of a registered token before +/// its broadcast or release is refused. +/// +/// Kept strictly below key-wallet's `RESERVATION_TTL_BLOCKS` (24, ~1h at the +/// mainnet block target): a `build_signed` / `finalize_transaction` reservation +/// is stamped at the wallet's `last_processed_height` (via `set_current_height`) +/// and swept by a later `reserve`/`reserved` call — itself stamped with the same +/// `last_processed_height` clock — once it is `RESERVATION_TTL_BLOCKS` old, +/// silently returning the outpoint to the selectable pool where an unrelated +/// build can re-select and re-reserve it. +/// `ReservationSet::release` removes an outpoint unconditionally, with no +/// ownership/generation check, so acting on a token whose reservation was +/// already swept could free (or broadcast against) a newer, unrelated +/// reservation. Refusing at this lower bound guarantees the guard always trips +/// **before** the underlying reservation could have been swept, leaving a margin +/// for `last_processed_height` to lag a few blocks behind the true tip. +const RESERVATION_MAX_AGE_BLOCKS: u32 = 20; + +/// Whether a token stamped at `registered_height` is too old to act on at +/// `current_height` (see [`RESERVATION_MAX_AGE_BLOCKS`]). The registration +/// height is mandatory — it is derived from the finalized +/// [`SignedCoreTransaction::reservation_height`](crate::SignedCoreTransaction) +/// the registry consumed. +/// +/// An unknown *current* height means the wallet is gone from the manager, which +/// disables the guard (`None` → not expired). That is safe only because every +/// caller establishes liveness first and so never reaches here with a removed +/// wallet: [`broadcast`](SignedPaymentRegistry::broadcast) refuses with +/// [`SignedPaymentError::WalletRemoved`] before sampling the height, and +/// [`reconcile_removed_entry`](SignedPaymentRegistry::reconcile_removed_entry)'s +/// release is itself generation-bound and no-ops on a missing wallet. The +/// earlier claim that "the wallet-mismatch / account-lookup paths already reject +/// those cases" was wrong for the broadcast path — `is_same_generation` compares +/// handles (a removed generation matches itself) and the broadcast path performs +/// no account lookup at all (`dashpay/platform#4185`). +fn reservation_expired(registered_height: u32, current_height: Option) -> bool { + match current_height { + Some(current) => current.saturating_sub(registered_height) >= RESERVATION_MAX_AGE_BLOCKS, + None => false, + } +} + +/// Failure of a deferred broadcast/release token operation. +#[derive(Debug, thiserror::Error)] +pub enum SignedPaymentError { + /// 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 wallet the token was minted against is no longer registered in the + /// manager — it was removed (`platform_wallet_manager_remove_wallet`), so + /// its accounts and their `ReservationSet`s ceased to exist along with it. + /// + /// Distinct from [`WalletMismatch`](Self::WalletMismatch), which means a + /// *different* live generation answers to the same id. Here there is no live + /// generation at all, so there is nothing to broadcast against and nothing + /// to reconcile: the token is dropped WITHOUT releasing (a release by + /// outpoint would have no `ReservationSet` to act on, and the reservation + /// died with the generation). + /// + /// Refusing here is what stops a retained handle from pushing a removed + /// wallet's payment onto the network after the host believed the wallet was + /// gone (`dashpay/platform#4185`). The network was NOT touched. + #[error("reservation token {0} belongs to a wallet that is no longer in the manager")] + WalletRemoved(ReservationToken), + + /// The token has outlived [`RESERVATION_MAX_AGE_BLOCKS`], so its underlying + /// UTXO reservation may already have been swept by key-wallet's TTL and + /// re-selected by an unrelated build. Acting on it (broadcast or release) + /// 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), +} + +/// The wallet handed to [`SignedPaymentRegistry::register`] is **not** the +/// generation the payment was finalized against, so registering it would bind +/// the reservation to the wrong wallet. Registration is refused up front rather +/// than minting a token that later broadcasts through — and runs cleanup +/// against — a wallet whose `ReservationSet` never held the inputs +/// (`dashpay/platform#4185`). +/// +/// The rejected [`SignedCoreTransaction`] is returned so its held funding +/// reservation is **never stranded**: the caller still owns it and can release +/// it through the correct wallet ([`CoreWallet::abandon_transaction`]) or drop +/// it. This mirrors the owner-guarded discipline of the rest of the deferred +/// path — an ownership object is never dropped on a failure path without the +/// caller getting a chance to reconcile its reservation. +#[derive(Debug)] +pub struct RegisterWrongGeneration { + /// The finalized payment `register` refused to bind, handed back intact. + pub signed: SignedCoreTransaction, +} + +impl std::fmt::Display for RegisterWrongGeneration { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("registration wallet is not the generation the payment was finalized against") + } +} + +impl std::error::Error for RegisterWrongGeneration {} + +/// A built, signed transaction whose funding UTXOs are reserved, awaiting a +/// deferred broadcast or an explicit release. +struct RegisteredPayment { + /// The wallet instance the payment was built against — captured so the + /// broadcast/release act on the exact `ReservationSet` that holds the + /// inputs, and so a re-created wallet can be detected via `Arc::ptr_eq`. + core: CoreWallet, + /// The signed transaction to broadcast. + tx: Transaction, + /// The releasable funding-account handle — the account whose reservation + /// `finalize` took and which a rejected broadcast or an explicit release + /// must reconcile. An [`AccountTypePreference`] (not the narrower + /// `StandardAccountType`) so CoinJoin-funded deferred payments retain a + /// releasable handle too: `finalize` reserves the selected inputs for EVERY + /// account variant, so a CoinJoin token must be able to release them + /// immediately on rejection/abandon rather than stranding them until the + /// key-wallet TTL backstop. + account_type: AccountTypePreference, + account_index: u32, + /// Wallet `last_processed_height` captured inside the funding critical + /// section — the exact clock `finalize_transaction` stamps the funding + /// reservation with (`SignedCoreTransaction::reservation_height`). Compared + /// against the wallet's current `last_processed_height` to refuse a + /// broadcast/release once the reservation could plausibly have been swept by + /// key-wallet's TTL (see [`RESERVATION_MAX_AGE_BLOCKS`]). Mandatory: it is + /// derived from the consumed ownership object, never sampled independently. + registered_height: u32, + /// The key-wallet [`FundingReservationToken`] stamped onto the funding + /// inputs when `finalize_transaction` reserved them + /// (`SignedCoreTransaction::reservation_token`), or `None` if the build + /// 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 finalized [`SignedCoreTransaction`] (whose funding + /// UTXOs `finalize` already reserved) and return an opaque token for a later + /// [`broadcast`](Self::broadcast) or [`release`](Self::release). + /// + /// `signed` is **consumed**, which is what enforces unique reservation + /// ownership: `SignedCoreTransaction` is not `Clone`, so a single finalize + /// can be registered at most once — there is no way to mint two live tokens + /// that name the same held reservation (`dashpay/platform#4185`). The built + /// transaction, the funding account, the mandatory reservation height + /// (`SignedCoreTransaction::reservation_height` — captured inside the + /// funding critical section before the potentially-slow external signer ran, + /// so the age guard measures the reservation's true age rather than a + /// post-signing sample), and the owner-guard token + /// (`SignedCoreTransaction::reservation_token`) are all derived from that + /// object here rather than supplied independently by the caller. + /// + /// `core` is the wallet the token is bound to for its later broadcast / + /// release. It **must** be the same wallet *generation* the payment was + /// finalized against — validated here against the unforgeable + /// `origin_generation` marker `SignedCoreTransaction` captured at finalize. + /// Binding to any other wallet is refused with [`RegisterWrongGeneration`] + /// (the rejected `signed` handed back so its reservation is not stranded): + /// otherwise safe public code could finalize through wallet A and + /// `register(core_b, signed_from_a)`, after which broadcasting through B + /// would pass the generation check and submit A's transaction through B's + /// broadcaster while cleanup ran against B and A's real reservation leaked + /// until its TTL. Deriving/validating the core from the consumed object + /// (rather than trusting a separate argument) upholds the documented + /// guarantee that a token is bound to the generation whose `ReservationSet` + /// owns the inputs. + /// + /// Synchronous **by design**: the body performs the reservation-owning + /// insertion with no `.await`, so there is no future that could be dropped + /// before its first poll and silently drop the consumed `signed` — and its + /// held reservation — without inserting it. An `async fn` here would only + /// move `signed` into a future whose body runs on the first poll; dropping + /// that future before polling would leak the reservation to key-wallet's TTL + /// (`dashpay/platform#4185`). Callers invoke it directly. + /// + /// # Liveness is the caller's obligation + /// + /// The generation check here is `signed`-relative: it proves `core` is the + /// wallet that *finalized* the payment. It says nothing about whether that + /// wallet is still registered in the manager, and being synchronous it + /// cannot ask (the manager lock is `async`). `finalize_transaction` drops + /// the manager write lock before awaiting the signer, so a teardown can run + /// to completion — sweep included — while a finalize is mid-signature; the + /// `register` that follows would then insert a live token for a removed + /// generation, defeating the documented teardown invariant that dropping + /// tokens makes stale handles inert. + /// + /// Callers must therefore hold + /// [`CoreWallet::generation_payment_guard`] — the finalizing generation's own + /// lifecycle gate — across `CoreWallet::is_current_generation` and this call, + /// and abandon the payment (releasing its reservation) when the wallet is + /// gone. The FFI's `core_wallet_signed_payment_finalize` is the production + /// caller and does exactly that. + /// + /// The gate is acquired **after** the external signer returns, not around it: + /// holding a generation's gate across an open signing prompt would stall that + /// wallet's teardown for as long as the user takes, and the liveness check + /// makes it unnecessary. A finalizer whose wallet was torn down mid-signature + /// therefore observes the missing generation at its check and abandons + /// instead of registering. + pub fn register( + &self, + core: CoreWallet, + signed: SignedCoreTransaction, + ) -> Result { + // Bind the payment to the EXACT generation it was finalized against. + // `core.generation()` and `signed.origin_generation()` are the same kind + // of per-generation balance `Arc` `is_same_generation` pointer-compares; + // a mismatch means `core` is a different (switched / stale / unrelated) + // wallet than the one whose `ReservationSet` holds the inputs. Refuse + // BEFORE consuming `signed`, and hand it back so the caller can reconcile + // its reservation. + if !Arc::ptr_eq(core.generation(), signed.origin_generation()) { + return Err(RegisterWrongGeneration { signed }); + } + let parts = signed.into_registered_parts(); + let token = ReservationToken(self.next_token.fetch_add(1, Ordering::SeqCst)); + self.lock().insert( + token, + RegisteredPayment { + core, + tx: parts.transaction, + account_type: parts.funding_account_type, + account_index: parts.funding_account_index, + registered_height: parts.reservation_height, + funding_reservation_token: parts.reservation_token, + }, + ); + Ok(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. + // + // Hold `current`'s OWN generation lifecycle gate for the whole operation. + // That generation's teardown needs the exclusive side, so it cannot + // interleave between the liveness check below and the send: either the + // wallet is gone before we enter (our entry was already swept → + // `StaleToken`), or it stays live until we leave. Shared, so concurrent + // payments — on this generation and on every other — are unaffected, and + // scoped per generation, so holding it across the network send below + // blocks only THIS wallet's teardown rather than every wallet's + // (`dashpay/platform#4185`). + // + // Taking `current`'s gate rather than the entry's is sound because the + // only path that proceeds past the check below is one where + // `entry.core.is_same_generation(current)` held — i.e. they are the same + // generation and therefore the same gate. A mismatched caller returns + // without touching the entry or the network. + let _lifecycle = current.generation_payment_guard().await; + + let entry = { + let mut entries = self.lock(); + 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 wallet is no longer registered in the manager. + // + // `is_same_generation` above compares two HANDLES, so it passes for a + // removed generation: both sides are the same removed wallet. Nothing + // further down re-checks — `broadcast_payment_releasing_reservation` + // goes straight to the broadcaster with no manager lookup, and the age + // guard below is *disabled* for a removed wallet + // (`last_processed_height` is `None`). So without this check a retained + // handle broadcasts a removed wallet's payment onto the network, and the + // teardown sweep cannot stop it: the sweep and the removal are one + // linearization point, but a broadcast that entered the gate first is + // outside it (`dashpay/platform#4185`). + // + // The entry is already removed, so we drop it WITHOUT releasing — the + // reservation ceased to exist with the generation, and a release by + // outpoint has no live `ReservationSet` to act on. Held under the + // lifecycle gate, so this is not a check-then-act: the wallet cannot be + // removed between here and the send below. + if !current.is_current_generation().await { + return Err(SignedPaymentError::WalletRemoved(token)); + } + + // Refuse a token whose reservation could already have been swept and + // re-selected by an unrelated build. The entry is already removed, so we + // simply drop it — deliberately WITHOUT releasing, since a release by + // outpoint here could free a newer build's reservation. The stale + // reservation is reclaimed by key-wallet's own TTL sweep. + if reservation_expired( + entry.registered_height, + current.last_processed_height().await, + ) { + return Err(SignedPaymentError::StaleReservationToken(token)); + } + + // One releasing-broadcast path for every funding variant, CoinJoin + // included: a definitive rejection releases the reservation for an + // immediate rebuild, an ambiguous outcome keeps it, and the release is + // bound to the token's own wallet generation. + let txid = entry + .core + .broadcast_payment_releasing_reservation( + entry.account_type, + entry.account_index, + &entry.tx, + 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_transaction_reservation( + entry.account_type, + entry.account_index, + &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) { + // Same per-generation lifecycle gate as `broadcast`: the reconciliation + // below reads the manager to bind its release to a live generation, so + // that generation's teardown must not interleave between taking the entry + // and acting on it. + // + // No wallet handle is threaded in, so the gate has to come from the entry + // itself. PEEK the entry's generation without consuming it, drop the map + // lock (a `std::sync::Mutex` — it must never be held across an `.await`), + // take that generation's gate, and only then consume. Both ways the peek + // can go stale are already the correct outcome: if a teardown swept the + // entry, or a concurrent release/broadcast consumed it, the `remove` + // below returns `None` and this is the documented idempotent no-op. + let generation = { + let entries = self.lock(); + match entries.get(&token) { + // Unknown / already consumed — idempotent no-op. + None => return, + Some(entry) => Arc::clone(entry.core.generation()), + } + }; + let _lifecycle = generation.payment_guard().await; + + let entry = { self.lock().remove(&token) }; + let Some(entry) = entry else { + // Swept or consumed while we were acquiring the gate — no-op. + return; + }; + Self::reconcile_removed_entry(entry).await; + } + + /// 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. + /// + /// # Must be called under the removed generation's [`WalletGeneration::teardown_guard`] + /// + /// Dropping the tokens is only half of teardown; the other half is the + /// manager removal itself, and the two are one atomic step only if the + /// caller holds that generation's exclusive lifecycle gate across BOTH. + /// Sweeping without it leaves two windows a payment operation slips through — + /// a broadcast between the removal and this sweep still finds its entry, and + /// an in-flight finalizer registers a fresh token *after* this sweep has run + /// (`dashpay/platform#4185`). This function cannot take the gate itself: it + /// is synchronous, and the removal it must be atomic with is `async`. + /// + /// [`PlatformWalletManager::remove_wallet_with_teardown`](crate::PlatformWalletManager::remove_wallet_with_teardown) + /// is the supported way to satisfy this: it holds the gate across the removal + /// and runs the sweep as its teardown hook, so the ordering cannot be got + /// wrong by a caller — including a direct Rust embedder that never goes + /// through the FFI. + pub fn remove_entries_for_wallet(&self, wallet: &CoreWallet) -> usize { + let mut entries = self.lock(); + let before = entries.len(); + 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` lifecycle tests) 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::{ + RegisterWrongGeneration, ReservationToken, SignedPaymentError, SignedPaymentRegistry, + RESERVATION_MAX_AGE_BLOCKS, + }; + use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; + use crate::test_support::{ + funded_wallet_manager, AlwaysMaybeSentBroadcaster, AlwaysRejectedBroadcaster, WalletSigner, + }; + use crate::wallet::core::{CoreWallet, SignedCoreTransaction}; + + /// 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 and leaves the reservation + /// held for the later broadcast/release. Returns a finalized + /// [`SignedCoreTransaction`] — the same non-`Clone` ownership object the + /// production `finalize_transaction` path yields — so the test hands it to + /// [`SignedPaymentRegistry::register`] exactly once (it captures the funding + /// account, the reservation height, and the key-wallet owner-guard token). + async fn build_signed_tx( + core: &CoreWallet, + account_type: StandardAccountType, + account_index: u32, + outputs: &[(DashAddress, u64)], + signer: &S, + ) -> Result { + 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(SignedCoreTransaction::new_for_test( + tx, + fee, + preference(account_type), + account_index, + current_height, + reservation_token, + // Stamp the finalizing generation so registering through this same + // `core` passes the registry's generation binding, exactly as the + // production finalize path does. + core.generation().clone(), + )) + } + + /// 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 signed = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let expected_bytes = dashcore::consensus::serialize(signed.transaction()); + let expected_txid = signed.transaction().txid(); + + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); + assert_eq!(registry.outstanding(), 1); + + // Broadcast through a *clone* of the same wallet instance — the + // 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 signed = build_signed_tx(&core, account_type, 0, &outputs, &signer) + .await + .expect("build should succeed"); + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); + + // With the reservation held, an immediate rebuild finds no + // spendable UTXO and fails. + 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) + .expect("test registers with the finalizing generation"); + + // 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 signed = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); + + 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 signed = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); + + registry.release(token).await; + // Second release: no panic, no error, still consumed. + 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 signed = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); + + registry.release(token).await; + let sent = registry.broadcast(token, &core).await; + 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 unknown = ReservationToken::from(9999); + let sent = registry.broadcast(unknown, &core).await; + assert!(matches!(sent, Err(SignedPaymentError::StaleToken(t)) if t == unknown)); + // Releasing an unknown token is a no-op, not a panic. + registry.release(unknown).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 signed = build_signed_tx( + &core_a, + StandardAccountType::BIP44Account, + 0, + &outputs_a, + &signer_a, + ) + .await + .expect("build should succeed"); + let token = registry + .register(core_a.clone(), signed) + .expect("test registers with the finalizing generation"); + + 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" + ); + } + + /// Regression for `dashpay/platform#4185` blocker: registration must bind the + /// token to the SAME wallet generation the payment was finalized against, not + /// to a separately-supplied wallet. Registering a payment finalized through + /// wallet A through an unrelated wallet B is refused up front with + /// [`RegisterWrongGeneration`], no token is minted (so B can never broadcast + /// A's transaction through B's broadcaster or run cleanup against B), and the + /// rejected `SignedCoreTransaction` is handed back so A's reservation is not + /// stranded — releasing it through A frees the input for an immediate rebuild. + #[tokio::test] + async fn register_rejects_a_different_wallet_generation() { + let (core_a, signer_a, outputs_a) = funded_core_wallet( + StandardAccountType::BIP44Account, + Arc::new(CountingBroadcaster::new()), + ) + .await; + // A separate wallet-manager instance stands in for an unrelated / re-created + // generation: same account shape, different generation-identity `Arc`. + let (core_b, _signer_b, _outputs_b) = funded_core_wallet( + StandardAccountType::BIP44Account, + Arc::new(CountingBroadcaster::new()), + ) + .await; + let registry = SignedPaymentRegistry::new(); + + let signed = build_signed_tx( + &core_a, + StandardAccountType::BIP44Account, + 0, + &outputs_a, + &signer_a, + ) + .await + .expect("build should succeed"); + + // Registering A's finalized payment through wallet B is refused, and no + // token is minted. + let baseline = registry.outstanding(); + let rejected = registry.register(core_b.clone(), signed); + let RegisterWrongGeneration { signed } = match rejected { + Err(err) => err, + Ok(_) => panic!("registering through a different generation must be refused"), + }; + assert_eq!( + registry.outstanding(), + baseline, + "a rejected registration must not mint a token" + ); + + // Registering through the correct generation (an alias of A) is accepted: + // the guard binds to generation identity, not wallet-manager pointer. + let token = registry + .register(core_a.clone(), signed) + .expect("registering through the finalizing generation must be accepted"); + assert_eq!(registry.outstanding(), baseline + 1); + + // The reservation is A's and is reachable: releasing the token frees the + // input, so an immediate rebuild on A succeeds — nothing was stranded. + registry.release(token).await; + assert_eq!(registry.outstanding(), baseline); + let rebuilt = build_signed_tx( + &core_a, + StandardAccountType::BIP44Account, + 0, + &outputs_a, + &signer_a, + ) + .await; + assert!( + rebuilt.is_ok(), + "the reservation must be reachable after a rejected mis-binding, got {rebuilt:?}" + ); + } + + /// An ambiguous ("may already be on the network") broadcast failure keeps + /// the reservation and surfaces the typed unconfirmed error; the token is + /// still consumed so it cannot be retried into a double-spend. + #[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 signed = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); + + 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 signed = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); + + 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" + ); + } + + // NOTE: the former `concurrent_registers_yield_distinct_tokens` test + // registered sixteen clones of ONE reserved transaction to probe the token + // allocator. That is exactly the duplicate-capability pattern unique + // ownership now forbids: `register` consumes a non-`Clone` + // `SignedCoreTransaction`, so a single reservation can be registered at most + // once (`dashpay/platform#4185`). Token distinctness is guaranteed by + // construction (the `AtomicU64` allocator), and concurrent consumption is + // covered by `concurrent_broadcasts_serialize_to_one_send`. + + /// Force the wallet's `last_processed_height` forward, simulating chain + /// progress between build/register and a later broadcast/release — the window + /// 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 signed = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); + + // Advance past the age bound but stay below key-wallet's 24-block TTL, so + // the reservation is provably still held (only our guard has tripped). + 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 signed = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); + + advance_processed_height(&core, registered_height + RESERVATION_MAX_AGE_BLOCKS + 2).await; + + 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 signed = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); + + // A sibling handle over the SAME manager Arc but a different wallet_id — + // `Arc::ptr_eq` on `wallet_manager` is true, so only the wallet_id check + // 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 signed_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(), signed_a) + .expect("test registers with the finalizing generation"); + let signed_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(), signed_b) + .expect("test registers with the finalizing generation"); + 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:?}" + ); + } + + // NOTE: the former `release_entries_for_wallet_frees_the_reservation` test + // is removed with the `release_entries_for_wallet` method it exercised. + // Destroying wrapper aliases no longer releases deferred-payment tokens: a + // wrapper handle does not own the payment, so its destruction must leave the + // token live and broadcastable (`dashpay/platform#4185`, blocker 2). Token + // reservations are reconciled by the payment owner (explicit + // broadcast/release) or dropped at actual generation teardown + // (`remove_entries_for_wallet`). + + /// Regression for the wrong-wallet-broadcast token theft: a mismatched + /// caller must return `WalletMismatch` WITHOUT consuming the entry, so the + /// 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 signed = build_signed_tx( + &core_a, + StandardAccountType::BIP44Account, + 0, + &outputs_a, + &signer_a, + ) + .await + .expect("build should succeed"); + let token = registry + .register(core_a.clone(), signed) + .expect("test registers with the finalizing generation"); + + // Wrong wallet: mismatch, and the token MUST survive for its owner. + let mismatched = registry.broadcast(token, &core_b).await; + 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 signed = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + // The finalized object carries the reservation's OWN stamp height, + // captured at build time — not a value the caller samples at register. + assert_eq!(signed.reservation_height(), reservation_height); + + // Slow signer: the wallet advanced to just under the age bound while + // signing. A fresh sample here would read `reservation_height + + // MAX_AGE - 1`. + advance_processed_height(&core, reservation_height + RESERVATION_MAX_AGE_BLOCKS - 1).await; + + // Register: the age baseline is the reservation height the consumed + // object carries, not the advanced `last_processed_height` sampled now. + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); + + // One block past the reservation height (still below the 24-block TTL) + // trips the guard because the baseline is `reservation_height`. + 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.generation = Arc::new(crate::wallet::core::WalletGeneration::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 signed = build_signed_tx( + &core, + StandardAccountType::BIP44Account, + 0, + &outputs, + &signer, + ) + .await + .expect("build should succeed"); + let token = registry + .register(core.clone(), signed) + .expect("test registers with the finalizing generation"); + + // Reservation held: a rebuild fails at input selection. + let blocked = build_signed_tx( + &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"); + // Capture the built tx before `register` consumes the ownership object; + // the sweep below needs it to release the outpoint by hand. + let finalized_tx = finalized.transaction().clone(); + let token = registry + .register(core.clone(), finalized) + .expect("test registers with the finalizing generation"); + + // Model key-wallet's TTL sweep: the outpoint returns to the selectable + // pool, but the registry still holds T1. + force_release_reservation(&core, &finalized_tx).await; + + // A concurrent build re-selects and re-reserves that same outpoint under + // a NEW token T2. Held alive so its reservation persists to the end. + 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/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index 725532e7ca..30922cf2cb 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -356,12 +356,20 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_walletM if !entries.is_null() && count > 0 { let items = unsafe { std::slice::from_raw_parts(entries, count) }; for e in items { + // Account-level derivation-path string (the value a caller + // hands back to `build_signed_payment` as `funding_path` + // to spend this single account). Null C-string → JSON + // `null`; a present path is JSON-escaped. + let derivation_path_json = unsafe { opt_cstr(e.derivation_path) } + .map(|s| json_string(&s)) + .unwrap_or_else(|| "null".to_string()); rows.push(format!( "{{\"typeTag\":{},\"standardTag\":{},\"index\":{},\ \"registrationIndex\":{},\"keyClass\":{},\ \"userIdentityId\":{},\"friendIdentityId\":{},\ \"confirmed\":{},\"unconfirmed\":{},\"immature\":{},\ - \"locked\":{},\"keysUsed\":{},\"keysTotal\":{}}}", + \"locked\":{},\"keysUsed\":{},\"keysTotal\":{},\ + \"derivationPath\":{}}}", e.type_tag as u8, e.standard_tag as u8, e.index, @@ -375,6 +383,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_walletM e.locked, e.keys_used, e.keys_total, + derivation_path_json, )); } } 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 78c25061fa..9103283977 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1006,6 +1006,248 @@ 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, u64 reservationHandle,` then the consensus-serialized +/// signed transaction bytes (`fee` and `change` in duffs), or null after +/// throwing. The FFI-owned tx bytes are freed here before returning; Kotlin +/// decodes the packed array via `ManagedPlatformWallet.decodeSignedPayment`. +/// +/// `reservationHandle` is the opaque stand-in for the key-wallet reservation +/// token this build stamped on its inputs (0 = it reserved nothing). It must be +/// handed back to [coreWalletReleasePaymentReservation] to abandon the build: +/// that is what makes the release owner-guarded, and the token itself cannot +/// cross the ABI (it has no public constructor, by design). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreWalletBuildSignedPayment( + mut env: JNIEnv, + _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 mut out_reservation_handle: 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, + &mut out_reservation_handle, + ) + }; + 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`, `change` and the + // reservation handle are written big-endian ahead of the raw tx bytes. + let tx_bytes: &[u8] = if out_tx_bytes.is_null() || out_tx_len == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(out_tx_bytes, out_tx_len) } + }; + let mut packed = Vec::with_capacity(24 + tx_bytes.len()); + packed.extend_from_slice(&out_fee.to_be_bytes()); + packed.extend_from_slice(&out_change.to_be_bytes()); + packed.extend_from_slice(&out_reservation_handle.to_be_bytes()); + packed.extend_from_slice(tx_bytes); + unsafe { + platform_wallet_ffi::core_wallet_free_payment_bytes(out_tx_bytes, out_tx_len); + } + + match env.byte_array_from_slice(&packed) { + Ok(a) => a.into_raw(), + Err(_) => { + // The native build already RESERVED its selected inputs and + // handed back the reservation handle inside `packed`, which + // Kotlin will now never receive. Left alone, those coins sit + // unselectable until key-wallet's 24-block TTL backstop — and + // that backstop never fires before the first sync completes + // (`ReservationSet::sweep` early-returns at height 0), so an + // alloc failure here can strand a freshly restored wallet's + // whole balance. Release the reservation before returning null, + // exactly as the sibling send-payment export does in its Err + // arm. `packed` is `fee|change|handle` (24 BE bytes) then the + // raw tx, so the tx bytes are `packed[24..]`; the funding path + // and handle are still in scope. + let _ = env.exception_clear(); + let tx = &packed[24..]; + let _ = unsafe { + platform_wallet_ffi::core_wallet_release_payment_reservation( + core_handle as Handle, + tx.as_ptr(), + tx.len(), + funding_path_ptr, + funding_path_len, + out_reservation_handle, + ) + }; + ptr::null_mut() + } + } + }) +} + +/// `core_wallet_release_payment_reservation` — release the UTXO reservation a +/// [coreWalletBuildSignedPayment] call took, for a build that will NOT be +/// broadcast. +/// +/// `build_signed_payment` leaves its selected inputs reserved on success, +/// expecting a broadcast to follow. A caller that abandons the build instead +/// must call this or the coins stay unselectable until key-wallet's 24-block +/// TTL backstop reclaims them — and that backstop never fires before the first +/// sync completes (`ReservationSet::sweep` early-returns at height 0), so an +/// abandoned build on a freshly restored wallet can otherwise strand the whole +/// balance for the life of the process (dashpay/platform#4247 review). This +/// call consults no height. +/// +/// `core_handle` is the transient core-wallet `Handle` from +/// [platformWalletGetCore]. `tx_bytes` is the consensus-serialized signed +/// transaction exactly as [coreWalletBuildSignedPayment] returned it. +/// `funding_path` must be the SAME optional path the build was given (null = the +/// unmixed BIP44 account). `reservation_handle` must be the SAME handle that +/// build returned: it is the ownership proof that keeps this release from +/// freeing a concurrent build's inputs after key-wallet's TTL swept and that +/// build re-reserved the same outpoint (dashpay/platform#4247 review). An +/// unrecognised non-zero handle is refused rather than released unguarded. +/// +/// FOR ABANDONED BUILDS ONLY — never call this after a successful broadcast. +/// Until sync processes the spend the inputs are still in the UTXO set, and the +/// reservation is the only thing keeping a second build off them. Repeated +/// releases of the same abandoned build are idempotent; that is the only sense in +/// which it is safe to call twice. Throws on an invalid handle, undecodable +/// transaction bytes, an unknown reservation handle, or an unresolvable funding +/// path. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreWalletReleasePaymentReservation( + mut env: JNIEnv, + _class: JClass, + core_handle: jlong, + tx_bytes: JByteArray, + funding_path: JString, + reservation_handle: jlong, +) { + guard(&mut env, (), |env| { + if core_handle == 0 { + throw_sdk_exception(env, 1, "core handle is 0"); + return; + } + // Handles are minted from a monotonic counter starting at 1, so a + // negative `jlong` can only be a caller error. Reject it here rather than + // letting `as u64` wrap it into a huge value that the table would refuse + // with a much less obvious message. + if reservation_handle < 0 { + throw_sdk_exception(env, 1, "reservationHandle must be non-negative"); + return; + } + let raw = match env.convert_byte_array(&tx_bytes) { + Ok(b) => b, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, "txBytes byte[] was invalid"); + return; + } + }; + if raw.is_empty() { + throw_sdk_exception(env, 1, "txBytes must not be empty"); + return; + } + // STRICT reader, matching the build: a genuine read error must throw + // rather than silently degrade to the default BIP44 account, which + // would release against the wrong ledger and leave the real + // reservation stranded — the exact failure this export exists to fix. + let funding_path = + match crate::funding::read_cstring_opt_strict(env, &funding_path, "fundingPath") { + Ok(v) => v, + Err(()) => return, + }; + let (funding_path_ptr, funding_path_len) = + funding_path.as_ref().map_or((ptr::null(), 0usize), |c| { + let b = c.as_bytes(); + (b.as_ptr(), b.len()) + }); + + let result = unsafe { + platform_wallet_ffi::core_wallet_release_payment_reservation( + core_handle as Handle, + raw.as_ptr(), + raw.len(), + funding_path_ptr, + funding_path_len, + reservation_handle as u64, + ) + }; + take_pwffi_error(env, result); + }) +} + /// `platform_wallet_get_core` — resolve the transient core-wallet `Handle` /// (as `jlong`) from a `PlatformWallet` handle, for [coreWalletBroadcastTransaction]. /// Free with [coreWalletDestroy]. Returns 0 after throwing. @@ -1225,6 +1467,207 @@ 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() { + // finalize returned Ok, so it already minted and registered the + // reservation token — but with no txid there is nothing to hand back + // to Kotlin, and the token would otherwise sit holding its inputs + // until the TTL sweep (which never fires pre-first-sync). Release the + // token as well as freeing the transaction before throwing, matching + // the sibling register-blob path's Err arm below. + unsafe { platform_wallet_ffi::core_wallet_signed_payment_release(token) }; + unsafe { platform_wallet_ffi::core_wallet_transaction_free(out_tx) }; + throw_sdk_exception(env, 1, "finalize returned a NULL txid"); + return ptr::null_mut(); + } + + // 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 a194918ad5..f2766f9238 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -76,15 +76,63 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// (Not returned by `destroy`: Rust owns the callback contexts, so a /// straggling worker is memory-safe and merely logged there.) case errorShutdownIncomplete = 27 - // Raw values 28-30 are NOT claimed here: 28 and 30 are reserved (vacated by - // the deferred-payment reservation-token trio on dashpay/platform#4185 / - // #4256 when it moved to 34-36) and 29 belongs to the asset-lock funding - // shortfall on dashpay/platform#4184. /// A state transition could not be signed because the signer has no /// usable private key for the requested public key — restored from the /// structured signer completion code (dashpay/platform#4060 finding 7). /// Route to key repair; not retryable as-is. case errorSigningKeyUnavailable = 31 + // Codes 27-33 are claimed outside this PR and must not be reused here: + // 27 errorShutdownIncomplete (dashpay/platform#4268, merged), 29 + // errorAssetLockInsufficientFunds (#4184), 31 errorSigningKeyUnavailable + // (#4183/#4259); 28 and 30 are vacated-but-reserved. The deferred-token + // trio therefore occupies the contiguous block 34-36. These raw values + // MUST match `PlatformWalletFFIResultCode` in + // packages/rs-platform-wallet-ffi/src/error.rs — there is no compile-time + // check across the ABI. See ERROR_CODE_REGISTRY.md (#4261). + /// A Core transaction could not be assembled from the request: a + /// `fundingPath` matching no spendable funds account or naming a + /// watch-only one, a request breaching a monetary bound (MAX_MONEY total, + /// max fee rate, a below-dust recipient, an over-100 kB recipient list), or + /// a malformed recipients blob. The REQUEST is at fault, so a verbatim + /// retry fails identically — the caller must change it. Nothing was + /// reserved or broadcast. + case errorTransactionBuild = 32 + /// A deferred (BIP70/BIP270) reservation token has outlived its funding + /// reservation's lifetime: key-wallet's TTL may already have swept and + /// re-selected the inputs, so acting on it could touch a newer, unrelated + /// 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 + /// The named thing does not exist. Besides the handle/lookup failures this + /// has always covered, BOTH deferred-send paths report the + /// wallet-was-REMOVED case here. + /// + /// Deferred (BIP70/BIP270) *token* path: a signed-payment broadcast refuses + /// a token whose wallet is no longer registered in the manager, and a + /// signed-payment finalize refuses to register a payment whose wallet was + /// removed while it was being signed. + /// + /// Finalized-transaction *handle* (V2) path: `finalizeAtomic` publishes no + /// handle when the wallet was removed or re-created during signing, and + /// `broadcastTransactionWithOutcome(_: FinalizedCoreTransaction)` refuses a + /// handle whose generation is gone. + /// + /// Every one of these reconciles the build's UTXO reservation before + /// returning. Distinct from `errorReservationWalletMismatch` (36), where a + /// *different* live generation answers to the same id. The call did NOT touch + /// the network and is NOT retryable — the wallet is gone. case notFound = 98 case errorUnknown = 99 @@ -148,6 +196,14 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorShutdownIncomplete case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SIGNING_KEY_UNAVAILABLE: self = .errorSigningKeyUnavailable + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_TRANSACTION_BUILD: + self = .errorTransactionBuild + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_STALE_RESERVATION_TOKEN: + self = .errorStaleReservationToken + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_RESERVATION_TOKEN_CONSUMED: + 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: @@ -263,6 +319,11 @@ public enum PlatformWalletError: LocalizedError { /// Core definitively rejected the transaction and its input reservation /// was released. Unlike `transactionBroadcastUnconfirmed`, retry is safe. case transactionBroadcastRejected(String) + /// A Core transaction could not be assembled from the request — an + /// unresolvable or watch-only funding path, a breached monetary bound, or a + /// malformed recipients blob. The REQUEST is at fault: a verbatim retry + /// fails identically. Nothing was reserved or broadcast. + case transactionBuild(String) /// Definitively-failed address-nonce race (shield, or identity /// top-up-from-addresses): Platform rejected the transition because the /// submitted address nonce raced its expected value. The transition did @@ -280,6 +341,28 @@ public enum PlatformWalletError: LocalizedError { /// (dashpay/platform#4060 finding 7); route to key repair. Kotlin /// parity: `DashSdkError.PlatformWallet.SigningKeyUnavailable`. case signingKeyUnavailable(String) + /// A deferred (BIP70/BIP270) reservation token has outlived its funding + /// reservation's lifetime — key-wallet's TTL may already have swept and + /// re-selected the inputs. Nothing was broadcast. NOT retryable in place; + /// rebuild the payment. Sibling of `reservationTokenConsumed` and + /// `reservationWalletMismatch`, which this code used to conflate. + case staleReservationToken(String) + /// A deferred reservation token is unknown, already broadcast, or already + /// released — the double-broadcast guard. Nothing was broadcast. NOT + /// retryable; rebuild the payment. + case reservationTokenConsumed(String) + /// A deferred reservation token was minted against a different wallet + /// generation than the one broadcasting it (e.g. a wallet re-created under + /// the same id). Nothing was broadcast and the rightful owner's token was + /// not consumed. NOT retryable through this handle; rebuild the payment. + case reservationWalletMismatch(String) + /// The named thing does not exist. For the deferred payment calls this is + /// the wallet-was-REMOVED case: the token's wallet (or the wallet a payment + /// was just signed against) is no longer registered in the manager, so there + /// is no live generation to act through. Nothing was broadcast; the + /// finalize path reconciles the build's reservation before returning. NOT + /// retryable — unlike `reservationWalletMismatch`, no other generation holds + /// this payment either. case notFound(String) case unknown(String) @@ -301,9 +384,12 @@ public enum PlatformWalletError: LocalizedError { .shieldedNoRecordedAnchor(let m), .transactionBroadcastUnconfirmed(let m), .transactionBroadcastRejected(let m), + .transactionBuild(let m), .addressNonceMismatch(let m), .shutdownIncomplete(let m), .signingKeyUnavailable(let m), + .staleReservationToken(let m), .reservationTokenConsumed(let m), + .reservationWalletMismatch(let m), .notFound(let m), .unknown(let m): return m } @@ -343,12 +429,20 @@ public enum PlatformWalletError: LocalizedError { self = .transactionBroadcastUnconfirmed(detail) case .errorTransactionBroadcastRejected: self = .transactionBroadcastRejected(detail) + case .errorTransactionBuild: + self = .transactionBuild(detail) case .errorAddressNonceMismatch: self = .addressNonceMismatch(detail) case .errorShutdownIncomplete: self = .shutdownIncomplete(detail) case .errorSigningKeyUnavailable: self = .signingKeyUnavailable(detail) + case .errorStaleReservationToken: + self = .staleReservationToken(detail) + case .errorReservationTokenConsumed: + self = .reservationTokenConsumed(detail) + case .errorReservationWalletMismatch: + self = .reservationWalletMismatch(detail) case .notFound: self = .notFound(detail) case .errorUnknown: self = .unknown(detail) }