diff --git a/Cargo.lock b/Cargo.lock index 6775bdd0c1..c84b89d662 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.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0f9485909142e2cab165ef96c7f8ad245d9bec3c#0f9485909142e2cab165ef96c7f8ad245d9bec3c" 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=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0f9485909142e2cab165ef96c7f8ad245d9bec3c#0f9485909142e2cab165ef96c7f8ad245d9bec3c" 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=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0f9485909142e2cab165ef96c7f8ad245d9bec3c#0f9485909142e2cab165ef96c7f8ad245d9bec3c" 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=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0f9485909142e2cab165ef96c7f8ad245d9bec3c#0f9485909142e2cab165ef96c7f8ad245d9bec3c" 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=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0f9485909142e2cab165ef96c7f8ad245d9bec3c#0f9485909142e2cab165ef96c7f8ad245d9bec3c" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0f9485909142e2cab165ef96c7f8ad245d9bec3c#0f9485909142e2cab165ef96c7f8ad245d9bec3c" 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=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0f9485909142e2cab165ef96c7f8ad245d9bec3c#0f9485909142e2cab165ef96c7f8ad245d9bec3c" 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=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0f9485909142e2cab165ef96c7f8ad245d9bec3c#0f9485909142e2cab165ef96c7f8ad245d9bec3c" dependencies = [ "bincode", "dashcore-private", @@ -2475,7 +2475,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2536,7 +2536,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2905,7 +2905,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0f9485909142e2cab165ef96c7f8ad245d9bec3c#0f9485909142e2cab165ef96c7f8ad245d9bec3c" [[package]] name = "glob" @@ -3840,7 +3840,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4096,7 +4096,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0f9485909142e2cab165ef96c7f8ad245d9bec3c#0f9485909142e2cab165ef96c7f8ad245d9bec3c" dependencies = [ "aes", "async-trait", @@ -4125,7 +4125,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0f9485909142e2cab165ef96c7f8ad245d9bec3c#0f9485909142e2cab165ef96c7f8ad245d9bec3c" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4141,7 +4141,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb#173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0f9485909142e2cab165ef96c7f8ad245d9bec3c#0f9485909142e2cab165ef96c7f8ad245d9bec3c" dependencies = [ "async-trait", "bincode", @@ -4652,7 +4652,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5752,7 +5752,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6560,7 +6560,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6573,7 +6573,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6632,7 +6632,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -7492,7 +7492,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8941,7 +8941,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5238bf2a98..3c85cfdd3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,14 +52,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "0f9485909142e2cab165ef96c7f8ad245d9bec3c" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "0f9485909142e2cab165ef96c7f8ad245d9bec3c" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "0f9485909142e2cab165ef96c7f8ad245d9bec3c" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "0f9485909142e2cab165ef96c7f8ad245d9bec3c" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "0f9485909142e2cab165ef96c7f8ad245d9bec3c" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "0f9485909142e2cab165ef96c7f8ad245d9bec3c" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "0f9485909142e2cab165ef96c7f8ad245d9bec3c" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "0f9485909142e2cab165ef96c7f8ad245d9bec3c" } tokio-metrics = "0.5" 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 40bdef869c..b0039d9f68 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 @@ -285,16 +285,22 @@ internal object WalletManagerNative { external fun coreWalletDestroy(coreHandle: 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 + * `core_wallet_signed_payment_finalize_with_deliverable` — 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`. + * `u64 token, u64 feeDuffs, u64 deliverableDuffs, u32 txidLen, txid utf8, + * u32 txBytesLen, txBytes`. + * + * `deliverableDuffs` sits between `feeDuffs` and `txidLen`, so every field + * after it shifts by eight bytes against the pre-drain layout. It is the + * value of the transaction's sole non-OP_RETURN output, or 0 when there is + * no single such output. */ external fun coreWalletFinalizeSignedPayment( builder: Long, 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 65971485ab..a740d77985 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 @@ -48,7 +48,31 @@ class CoreTransactionBuilder internal constructor(network: Network) : AutoClosea /** * Coin-selection strategy — mirror of key-wallet's `SelectionStrategy` - * (`CoreSelectionStrategyFFI`). [ALL] drains the account. + * (`CoreSelectionStrategyFFI`). + * + * [ALL] drains — it selects every spendable UTXO the chosen funding + * source offers, sets the single destination output to + * `total inputs − fee`, and leaves no change. **Its scope is whatever + * [AccountType] names, not "the wallet's main account":** + * + * - [AccountType.BIP44] / [AccountType.BIP32] / [AccountType.COIN_JOIN] + * drain that one account family; + * - [AccountType.ALL_SPENDABLE] — **the default** — drains BIP44 **and** + * BIP32 **and** every DashPay contact-receiving account, in one + * transaction. + * + * So `selectionStrategy = ALL` on a call that does not name an + * [AccountType] sweeps the wallet's whole spendable balance, contact + * receiving accounts included. That is the intended shape of a drain: a + * host asking to send everything means everything it can sign for, and + * before the pooled selector existed a host had to sweep accounts + * together on-chain first to achieve it. Name a single [AccountType] only + * when the drain is genuinely scoped to one family — a CoinJoin sweep, + * say, which must stay in its own privacy domain. + * + * Read what a drain actually pays from + * [SignedCoreTransaction.deliverableAmountDuffs]; the engine computes it, + * and the caller's requested amount is discarded. */ enum class SelectionStrategy(val ffiValue: Int) { SMALLEST_FIRST(0), 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 dc2593dd30..ec420dfe11 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 @@ -217,6 +217,26 @@ class ManagedPlatformWallet internal constructor( val rawTxBytes: ByteArray, val feeDuffs: Long, val reservationToken: Long, + /** + * Value in duffs of the sole non-OP_RETURN output of the REGISTERED + * transaction — the one [broadcastSigned] will send. + * + * Computed Rust-side during finalization and carried in the + * registration result, NOT re-derived here from [rawTxBytes]: those + * bytes are a mutable copy the host owns, while the broadcast uses the + * registered transaction referenced by [reservationToken]. Deriving it + * here could report a value the broadcast does not pay. + * + * Needed for a DRAIN ([CoreTransactionBuilder.SelectionStrategy.ALL]), + * where the ENGINE sets this output to `total inputs − fee` and the + * caller therefore never supplied it. A swap must quote from this and + * then broadcast THIS payment, so quote and payment cannot disagree. + * + * 0 when the payment has no single destination (multi-recipient, or an + * OP_RETURN-only build) — read that as "not applicable", not "pays + * nothing". + */ + val deliverableAmountDuffs: Long = 0, ) : AutoCloseable { // GC backstop: releases the token if it was neither broadcast nor @@ -233,6 +253,7 @@ class ManagedPlatformWallet internal constructor( */ override fun close() = cleanable.clean() + override fun equals(other: Any?): Boolean = other is SignedCoreTransaction && txidHex == other.txidHex && @@ -263,12 +284,16 @@ class ManagedPlatformWallet internal constructor( /** * Decode the big-endian native BLOB the atomic * finalize-and-register FFI returns: `u64 token, u64 feeDuffs, - * u32 txidLen, txid utf8, u32 txBytesLen, txBytes`. + * u64 deliverableDuffs, u32 txidLen, txid utf8, u32 txBytesLen, + * txBytes`. `deliverableDuffs` is computed from the REGISTERED + * transaction Rust-side (see + * [SignedCoreTransaction.deliverableAmountDuffs]). */ internal fun fromRegisterBlob(blob: ByteArray): SignedCoreTransaction { val buffer = java.nio.ByteBuffer.wrap(blob) // big-endian by default val token = buffer.long val feeDuffs = buffer.long + val deliverableDuffs = buffer.long val txidLen = buffer.int val txidBytes = ByteArray(txidLen) buffer.get(txidBytes) @@ -280,6 +305,7 @@ class ManagedPlatformWallet internal constructor( rawTxBytes = rawTxBytes, feeDuffs = feeDuffs, reservationToken = token, + deliverableAmountDuffs = deliverableDuffs, ) } } @@ -346,6 +372,32 @@ class ManagedPlatformWallet internal constructor( * output indices, as MAYAChain does. * @param changeToFirstInput route change back to the first selected * input's address (VIN0) instead of a fresh change address. + * @param selectionStrategy coin-selection strategy, or null to leave the + * builder's default. Pass + * [CoreTransactionBuilder.SelectionStrategy.ALL] to DRAIN the funding + * account: every spendable UTXO is selected, there is no change, and the + * engine sets the single value-carrying output to `total inputs − fee` + * — so the `amount` given in [recipients] is IGNORED (pass 0). A + * zero-value [opReturnData] carrier may accompany the destination (the + * MAYACHAIN "swap my whole balance" case); its bytes are priced into + * the fee. Read what the drain will actually pay from + * [SignedCoreTransaction.deliverableAmountDuffs] BEFORE broadcasting — + * that is the only way to learn the engine-computed amount, and it is + * what a swap quote must be taken from. + * + * **A drain's scope is [accountType], which defaults to + * [AccountType.ALL_SPENDABLE].** Combined with `ALL`, a call that does + * not name an account type sweeps BIP44, BIP32 AND every DashPay + * contact-receiving account into one transaction — and, being a drain, + * leaves no change: every selected input becomes the destination output + * plus fee. That is the intended shape: "send everything" means + * everything the wallet can sign for, which before the pooled selector + * required sweeping accounts together on-chain first. + * + * Name [AccountType.BIP44] or [AccountType.BIP32] to confine the drain + * to one family. Those are the only single-family scopes this method + * can express — its [AccountType] has no CoinJoin variant, and a + * CoinJoin sweep goes through the dedicated send-all path instead. */ suspend fun buildSignedPayment( recipients: List>, @@ -356,6 +408,7 @@ class ManagedPlatformWallet internal constructor( opReturnData: ByteArray? = null, preserveOutputOrder: Boolean = false, changeToFirstInput: Boolean = false, + selectionStrategy: CoreTransactionBuilder.SelectionStrategy? = null, ): 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 @@ -369,8 +422,15 @@ class ManagedPlatformWallet internal constructor( ) { 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" + // A DRAIN has the engine set the destination output to + // (total inputs − fee), so the caller's amount is ignored and 0 is the + // honest value to pass. Requiring a positive one here would make + // "send my whole balance" inexpressible through this API — the caller + // would have to invent a placeholder the engine then discards. + val draining = selectionStrategy == CoreTransactionBuilder.SelectionStrategy.ALL + require(draining || recipients.all { it.second > 0 }) { + "every recipient amount must be positive (except under " + + "SelectionStrategy.ALL, where the engine computes it)" } val builderAccountType = when (accountType) { AccountType.BIP44 -> CoreTransactionBuilder.AccountType.BIP44 @@ -400,6 +460,12 @@ class ManagedPlatformWallet internal constructor( if (changeToFirstInput) { builder.changeToFirstInput() } + // Set LAST so it applies to the fully-composed output set: a + // drain (SelectionStrategy.ALL) requires exactly one + // value-carrying output, and the engine rejects the build here + // — before anything is reserved — if the OP_RETURN above + // carries a value or a second spendable output was added. + selectionStrategy?.let { builder.setSelectionStrategy(it) } builder.finalizeSignedPayment( this@ManagedPlatformWallet, builderAccountType, diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/SignedCoreTransactionTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/SignedCoreTransactionTest.kt index 337e3431cf..7877d379b7 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/SignedCoreTransactionTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/SignedCoreTransactionTest.kt @@ -21,11 +21,18 @@ import java.util.concurrent.atomic.AtomicInteger */ class SignedCoreTransactionTest { - private fun registerBlob(token: Long, fee: Long, txid: String, txBytes: ByteArray): ByteArray { + private fun registerBlob( + token: Long, + fee: Long, + txid: String, + txBytes: ByteArray, + deliverable: Long = 0, + ): ByteArray { val txidBytes = txid.toByteArray(Charsets.UTF_8) - val buf = ByteBuffer.allocate(8 + 8 + 4 + txidBytes.size + 4 + txBytes.size) + val buf = ByteBuffer.allocate(8 + 8 + 8 + 4 + txidBytes.size + 4 + txBytes.size) buf.putLong(token) buf.putLong(fee) + buf.putLong(deliverable) buf.putInt(txidBytes.size) buf.put(txidBytes) buf.putInt(txBytes.size) @@ -73,4 +80,42 @@ class SignedCoreTransactionTest { assertEquals(1, runs.get()) } + + // --- deliverableAmountDuffs ------------------------------------------- + // + // Carried in the registration blob, computed Rust-side from the REGISTERED + // transaction. It must NOT be re-derived from rawTxBytes: those are a + // mutable copy the host owns, while the broadcast sends the registered + // transaction referenced by the token. + + @Test + fun deliverableAmountComesFromTheBlobNotTheBytes() { + val signed = ManagedPlatformWallet.SignedCoreTransaction.fromRegisterBlob( + registerBlob(token = 7L, fee = 432L, txid = "ab", txBytes = byteArrayOf(9, 9, 9), + deliverable = 27_442_985L) + ) + assertEquals(27_442_985L, signed.deliverableAmountDuffs) + } + + @Test + fun mutatingRawBytesCannotChangeTheDeliverableAmount() { + // The guarantee the drain quote rests on: what was quoted is what the + // registered transaction pays, whatever happens to the host's copy. + val signed = ManagedPlatformWallet.SignedCoreTransaction.fromRegisterBlob( + registerBlob(token = 1L, fee = 1L, txid = "cd", txBytes = byteArrayOf(1, 2, 3, 4), + deliverable = 500_000L) + ) + signed.rawTxBytes.fill(0xFF.toByte()) + assertEquals(500_000L, signed.deliverableAmountDuffs) + } + + @Test + fun deliverableAmountIsZeroWhenTheEngineReportsNoSingleDestination() { + // Multi-recipient or OP_RETURN-only builds have no single deliverable + // output; Rust reports 0 and the host reads that as "not applicable". + val signed = ManagedPlatformWallet.SignedCoreTransaction.fromRegisterBlob( + registerBlob(token = 2L, fee = 10L, txid = "ef", txBytes = ByteArray(0)) + ) + assertEquals(0L, signed.deliverableAmountDuffs) + } } 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 2cefb0888a..c5b48f7978 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 @@ -6,7 +6,7 @@ use crate::types::{FFINetwork, Network}; use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; use dashcore::blockdata::transaction::special_transaction::TransactionPayload; use dashcore::hashes::Hash; -use dashcore::{Address as DashAddress, OutPoint, Txid}; +use dashcore::{Address as DashAddress, OutPoint, TxOut, Txid}; use key_wallet::account::ManagedAccountCollection; use key_wallet::managed_account::ManagedCoreFundsAccount; use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy; @@ -193,6 +193,24 @@ pub unsafe extern "C" fn core_wallet_tx_builder_finalize( PlatformWalletFFIResult::ok() } +/// Value of the sole non-OP_RETURN output: what a broadcast of this +/// transaction actually pays out. +/// +/// Returns 0 when there is no single such output. A multi-recipient build has +/// no one deliverable amount, and an OP_RETURN-only build pays no one — hosts +/// read the 0 as "not applicable" rather than "pays nothing", so the two cases +/// need not be told apart here. +/// +/// Output ORDER is deliberately irrelevant: a MAYAChain deposit carries its +/// memo at VOUT1, while other layouts put the data carrier first. +fn sole_deliverable_value(outputs: &[TxOut]) -> u64 { + let mut carriers = outputs.iter().filter(|out| !out.script_pubkey.is_op_return()); + match (carriers.next(), carriers.next()) { + (Some(only), None) => only.value, + _ => 0, + } +} + /// 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. @@ -215,6 +233,18 @@ pub unsafe extern "C" fn core_wallet_tx_builder_finalize( /// `core_wallet_transaction_free`). `out_bytes_ptr`/`out_bytes_len` borrow /// `out_tx`'s buffer — copy them out before freeing `out_tx`. /// +/// Also writes `out_deliverable_duffs`: the value of the sole non-OP_RETURN +/// output of the REGISTERED transaction — what a later broadcast actually +/// pays out. Hosts need it for a drain (`SelectionStrategy::All`), where the +/// engine, not the caller, sets that output to `total inputs - fee`; reading it +/// from the registered transaction here keeps a quote and its payment from +/// disagreeing. Writes 0 when there is no single such output (multi-recipient, +/// or an OP_RETURN-only build) — "not applicable", not "pays nothing". +/// +/// This is the CURRENT entry point. `core_wallet_signed_payment_finalize` is +/// the pre-existing eleven-argument symbol, kept so already-compiled callers +/// keep linking; it forwards here and discards the amount. +/// /// # Safety /// `builder` must be a valid, non-destroyed pointer; `wallet` a valid /// platform-wallet handle; `core_signer_handle` a valid resolver handle; every @@ -222,7 +252,7 @@ pub unsafe extern "C" fn core_wallet_tx_builder_finalize( /// `FFICoreTransaction` (typically zeroed). #[no_mangle] #[allow(clippy::too_many_arguments)] -pub unsafe extern "C" fn core_wallet_signed_payment_finalize( +pub unsafe extern "C" fn core_wallet_signed_payment_finalize_with_deliverable( builder: *mut FFITransactionBuilder, wallet: Handle, account_type: CoreAccountTypeFFI, @@ -234,6 +264,7 @@ pub unsafe extern "C" fn core_wallet_signed_payment_finalize( out_tx: *mut FFICoreTransaction, out_bytes_ptr: *mut *const u8, out_bytes_len: *mut usize, + out_deliverable_duffs: *mut u64, ) -> PlatformWalletFFIResult { check_ptr!(builder); check_ptr!(core_signer_handle); @@ -243,6 +274,7 @@ pub unsafe extern "C" fn core_wallet_signed_payment_finalize( check_ptr!(out_tx); check_ptr!(out_bytes_ptr); check_ptr!(out_bytes_len); + check_ptr!(out_deliverable_duffs); // Publish sentinels into EVERY output before any fallible step (wallet // resolution, network validation, signing, registration), so an error // return never leaves caller-supplied garbage in an out param that a host @@ -257,6 +289,7 @@ pub unsafe extern "C" fn core_wallet_signed_payment_finalize( }; *out_bytes_ptr = std::ptr::null(); *out_bytes_len = 0; + *out_deliverable_duffs = 0; // `finalize_transaction` consumes the builder: reclaim both heap boxes up // front so they are freed on every return path below. @@ -339,6 +372,17 @@ pub unsafe extern "C" fn core_wallet_signed_payment_finalize( } }; + // The deliverable amount, taken from the transaction that will actually be + // broadcast — not re-derived by the host from a copy of the bytes. Under a + // drain the ENGINE sets this output (total inputs - fee), so the caller + // never supplied it and has no other authoritative source; a host that + // re-parsed its own byte array could quote a value the broadcast does not + // pay. Defined only for a single-destination payment: exactly one output + // that is not an OP_RETURN data carrier. Anything else reports 0, which the + // host reads as "not applicable" rather than "pays nothing". + let deliverable_duffs = sole_deliverable_value(&finalized.transaction().output); + unsafe { *out_deliverable_duffs = deliverable_duffs }; + let serialized = dashcore::consensus::serialize(finalized.transaction()); let len = serialized.len(); @@ -385,6 +429,61 @@ pub unsafe extern "C" fn core_wallet_signed_payment_finalize( PlatformWalletFFIResult::ok() } +/// The pre-existing ELEVEN-argument finalize, preserved byte-for-byte in its +/// C signature. Forwards to +/// [`core_wallet_signed_payment_finalize_with_deliverable`] and discards the +/// deliverable amount; behaviour is otherwise identical. +/// +/// Kept because this symbol is exported across a BINARY boundary: the Swift SDK +/// consumes `DashSDKFFI.xcframework` as a `binaryTarget`, so a host's compiled +/// Swift and this library are built and shipped separately and can meet at +/// different versions. Adding the twelfth out-parameter to this symbol in place +/// would make the callee write eight bytes through a pointer an eleven-argument +/// caller never passed — reading whatever occupied that argument slot and +/// treating it as an address. That corrupts silently rather than failing, so the +/// old shape stays, and callers that want the amount move to the new symbol. +/// +/// Do not "simplify" this away by deleting it and updating the in-tree callers: +/// the callers that matter here are already-compiled binaries, which no +/// source-tree edit can reach. +/// +/// # Safety +/// Identical to [`core_wallet_signed_payment_finalize_with_deliverable`], minus +/// `out_deliverable_duffs` (supplied internally). +#[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 { + // A real local, never null: the callee null-checks every out-pointer and + // would reject the call outright. + let mut discarded_deliverable_duffs: u64 = 0; + core_wallet_signed_payment_finalize_with_deliverable( + builder, + wallet, + account_type, + account_index, + core_signer_handle, + out_token, + out_fee, + out_txid, + out_tx, + out_bytes_ptr, + out_bytes_len, + &mut discarded_deliverable_duffs, + ) +} + #[repr(C)] pub enum CoreSelectionStrategyFFI { SmallestFirst, @@ -820,3 +919,117 @@ pub unsafe extern "C" fn core_wallet_transaction_free(tx: *mut FFICoreTransactio tx.tx_bytes = std::ptr::null_mut(); tx.tx_len = 0; } + + +#[cfg(test)] +mod tests { + use super::{sole_deliverable_value, CoreAccountTypeFFI}; + use dashcore::blockdata::script::ScriptBuf; + use dashcore::TxOut; + use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; + + /// What a DRAIN spans, per selector. `SelectionStrategy::All` takes every + /// UTXO each named source offers, so this list IS the sweep scope — the + /// claim the Kotlin `SelectionStrategy.ALL` doc makes to callers. + /// + /// The pooled selector is the DEFAULT for a send, so a caller who asks for + /// a drain without naming an account type sweeps all three families at + /// once, contact-receiving funds included. Pinned here so that widening + /// cannot happen silently: anything added to `SEND_FUNDING_SOURCES` + /// enlarges every defaulted drain, and this test is where that shows up. + #[test] + fn a_drains_scope_is_whatever_the_selector_names() { + assert_eq!( + CoreAccountTypeFFI::BIP44.funding_sources(), + &[AccountTypePreference::BIP44], + "a single-family selector drains exactly one account" + ); + assert_eq!( + CoreAccountTypeFFI::CoinJoin.funding_sources(), + &[AccountTypePreference::CoinJoin], + "CoinJoin stays its own privacy domain, never pooled" + ); + assert_eq!( + CoreAccountTypeFFI::AllSpendable.funding_sources(), + &[ + AccountTypePreference::BIP44, + AccountTypePreference::BIP32, + AccountTypePreference::AllDashpayReceivingFunds, + ], + "the DEFAULT selector drains BIP44 + BIP32 + every DashPay \ + receiving account; BIP44 must stay first, as it supplies change" + ); + } + + /// A spendable output. The script only has to NOT be an OP_RETURN. + fn destination(value: u64) -> TxOut { + TxOut { + value, + script_pubkey: ScriptBuf::from(vec![0x76, 0xa9, 0x14]), + } + } + + fn op_return(payload: &[u8]) -> TxOut { + let data = dashcore::script::PushBytesBuf::try_from(payload.to_vec()) + .expect("test payload is within push limits"); + TxOut { + value: 0, + script_pubkey: ScriptBuf::new_op_return(&data), + } + } + + #[test] + fn a_lone_destination_is_the_deliverable_amount() { + assert_eq!(sole_deliverable_value(&[destination(27_442_985)]), 27_442_985); + } + + /// The MAYAChain shape: vault output plus a zero-value memo. The memo must + /// not be mistaken for a second recipient, in EITHER order — Maya puts the + /// memo at VOUT1, but nothing in the calculation may depend on that. + #[test] + fn a_data_carrier_beside_the_destination_is_ignored_in_both_orders() { + let memo = op_return(b"=:MAYA.CACAO:maya1abc"); + assert_eq!( + sole_deliverable_value(&[destination(27_442_985), memo.clone()]), + 27_442_985, + "memo after the destination (the Maya layout)" + ); + assert_eq!( + sole_deliverable_value(&[memo, destination(27_442_985)]), + 27_442_985, + "memo before the destination" + ); + } + + /// Two recipients have no single deliverable amount. Reporting either one + /// would let a host quote a number the payment does not pay. + #[test] + fn two_spendable_outputs_report_zero() { + assert_eq!(sole_deliverable_value(&[destination(1_000), destination(2_000)]), 0); + } + + #[test] + fn two_spendable_outputs_report_zero_even_beside_a_data_carrier() { + assert_eq!( + sole_deliverable_value(&[destination(1_000), op_return(b"x"), destination(2_000)]), + 0 + ); + } + + /// An OP_RETURN-only build pays no one; so does an empty output set. + #[test] + fn a_transaction_with_no_spendable_output_reports_zero() { + assert_eq!(sole_deliverable_value(&[op_return(b"data only")]), 0); + assert_eq!(sole_deliverable_value(&[]), 0); + } + + /// An asset lock's single output IS an OP_RETURN, so it reports 0 rather + /// than its burn value. That is the intended reading: the credits go to an + /// identity, not to a payee a host would quote. + #[test] + fn an_op_return_carrying_value_still_reports_zero() { + let mut burn = op_return(b"credits"); + burn.value = 500_000; + assert_eq!(sole_deliverable_value(&[burn]), 0); + } +} diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 1cc5801db3..bba0b0e7be 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -680,8 +680,18 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c throw_sdk_exception(env, 1, "builder handle is 0"); return; } - if amount <= 0 { - throw_sdk_exception(env, 1, "amount must be positive"); + // Negative only. A ZERO output is legitimate for a drain + // (SelectionStrategy::All): the engine overwrites the destination + // output with (total inputs - fee), so the caller supplies no amount. + // Rejecting it here made "send my whole balance" inexpressible and + // forced callers to invent a placeholder the engine then discarded. + // The positive-amount rule still holds for every other build — it is + // enforced one layer up in `ManagedPlatformWallet.buildSignedPayment`, + // which knows whether the caller is draining; this boundary does not, + // so it must not duplicate a check it cannot qualify. A negative + // jlong would bit-cast to a huge u64, so that stays refused here. + if amount < 0 { + throw_sdk_exception(env, 1, "amount must not be negative"); return; } let Some(address_c) = read_cstring_required(env, &address, "address") else { @@ -1370,16 +1380,20 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c // 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 +/// `core_wallet_signed_payment_finalize_with_deliverable` — 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`. +/// `u64 token, u64 feeDuffs, u64 deliverableDuffs, u32 txidLen, txid utf8, +/// u32 txBytesLen, txBytes`. `deliverableDuffs` is the value of the sole +/// non-OP_RETURN output of the REGISTERED transaction (0 when the payment has +/// no single destination) — computed Rust-side so the host never re-derives it +/// from its own copy of the bytes. #[no_mangle] #[allow(clippy::too_many_arguments)] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_coreWalletFinalizeSignedPayment( @@ -1432,8 +1446,9 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c 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 mut deliverable: u64 = 0; let result = unsafe { - platform_wallet_ffi::core_wallet_signed_payment_finalize( + platform_wallet_ffi::core_wallet_signed_payment_finalize_with_deliverable( builder as *mut platform_wallet_ffi::FFITransactionBuilder, wallet_handle as Handle, account_type, @@ -1445,6 +1460,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c out_tx, &mut out_bytes_ptr as *mut *const u8, &mut out_bytes_len as *mut usize, + &mut deliverable as *mut u64, ) }; if take_pwffi_error(env, result) { @@ -1478,9 +1494,10 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c // 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()); + let mut blob = Vec::with_capacity(8 + 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(&deliverable.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());