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 d0c4e9f2f53..090bca5a5a1 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 @@ -302,16 +302,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 65971485ab6..a740d779857 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 dc2593dd30d..9cee58497f9 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, so a CoinJoin + * account cannot be drained through this API. */ 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 337e3431cf4..7877d379b7d 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 f91675e8287..7d48af10f6d 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; @@ -199,6 +199,26 @@ 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. @@ -221,6 +241,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 @@ -228,7 +260,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, @@ -240,6 +272,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); @@ -249,6 +282,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 @@ -263,6 +297,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. @@ -353,6 +388,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(); @@ -399,6 +445,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, @@ -864,3 +965,122 @@ 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-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index a3a21337db2..bf7646b4abd 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -726,6 +726,7 @@ mod tests { use dashcore::{Address as DashAddress, Network}; use key_wallet::account::account_type::StandardAccountType; use key_wallet::signer::{Signer, SignerMethod}; + 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::DerivationPath; @@ -916,6 +917,73 @@ mod tests { ) } + /// A DRAIN THAT CARRIES A MEMO, through the production finalize path. + /// + /// The MAYAChain deposit shape: `SelectionStrategy::All` with the vault + /// destination plus a zero-value OP_RETURN memo. key-wallet used to accept + /// exactly one output under `All` ("requires exactly one output"), so the + /// memo made every whole-balance swap unbuildable — a caller had to guess + /// the fee and send an explicit amount instead. The engine now counts + /// value carriers and lets zero-value data outputs ride along; this pins + /// that the rust-dashcore revision THIS workspace builds against carries + /// that behaviour, since a pin regression leaves every other test green. + /// + /// The amount given for the destination is 0: under `All` the engine sets + /// it, and 0 is what the hosts pass. The assertions are the ones a host + /// relies on: the build succeeds, the memo survives at value 0, and the + /// sole spendable output — the deliverable amount the FFI reports — + /// receives the entire balance minus the fee, with no change. + #[tokio::test] + async fn memo_bearing_drain_delivers_the_whole_balance_minus_fee() { + let (core, signer) = core( + StandardAccountType::BIP44Account, + Arc::new(AlwaysOkBroadcaster), + ) + .await; + let destination = DashAddress::dummy(Network::Testnet, 7); + let memo = b"=:MAYA.CACAO:maya1abc"; + let builder = TransactionBuilder::new() + .set_selection_strategy(SelectionStrategy::All) + .add_output(&destination, 0) + .add_op_return(memo) + .expect("memo is within the OP_RETURN limit"); + + let finalized = core + .finalize_transaction(builder, &[AccountTypePreference::BIP44], 0, &signer) + .await + .expect("a drain may carry a zero-value OP_RETURN memo beside its destination"); + + let tx = finalized.transaction(); + let (memos, carriers): (Vec<_>, Vec<_>) = tx + .output + .iter() + .partition(|out| out.script_pubkey.is_op_return()); + assert_eq!( + carriers.len(), + 1, + "exactly one spendable output: no change under a drain" + ); + assert_eq!(memos.len(), 1, "the memo is on-chain"); + assert_eq!( + memos[0].value, 0, + "a data carrier claims none of the drained balance" + ); + assert!( + memos[0].script_pubkey.as_bytes().ends_with(memo), + "the OP_RETURN carries the caller's memo bytes" + ); + assert_eq!(carriers[0].script_pubkey, destination.script_pubkey()); + assert!( + finalized.fee() > 0, + "the memo bytes are priced into a real fee" + ); + assert_eq!( + carriers[0].value, + 10_000_000 - finalized.fee(), + "the destination receives the fixture's whole balance minus the fee" + ); + } + /// `reservation_only` end to end IN THIS WORKSPACE. key-wallet covers /// `add_funding_reservation_only` on its own side, but only this proves /// the flag survives the crossing: it travels an FFI struct field, a diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index c1ac4b1d871..3a0b2be5ffe 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -733,8 +733,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 { @@ -1423,16 +1433,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( @@ -1485,8 +1499,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, @@ -1498,6 +1513,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) { @@ -1531,9 +1547,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());