From 0cb063b20075691dfe2cb3fc342200c9b0cf85ac Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 6 Aug 2026 11:54:24 -0700 Subject: [PATCH 1/8] feat(kotlin-sdk): expose the drain strategy and the amount it delivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A max swap deposit is a drain: spend every UTXO in the funding account and pay the destination (total inputs - fee), with the memo as a zero-value OP_RETURN beside it. key-wallet can build that, but nothing above it could ask for one, and nothing could report what it would pay. - buildSignedPayment gains `selectionStrategy` (null = the builder's default), applied after the outputs so a drain's "exactly one value carrier" check runs against the finished output set, before anything is reserved. - SignedCoreTransaction gains `deliverableAmountDuffs`: the value of the sole non-OP_RETURN output, parsed from the signed bytes already present (no ABI change, no extra native call). Under a drain the ENGINE computes that figure, so this is the only way a caller can learn what the transaction will pay — a swap must quote from it and then broadcast THIS transaction, so quote and payment cannot disagree. - The positive-amount rule now yields to a drain in both layers that enforced it. `buildSignedPayment` knows whether the caller is draining and keeps the rule for every other build; the JNI boundary does not know, so it no longer duplicates a check it cannot qualify and refuses only negatives (a negative jlong would bit-cast to a huge u64). Rejecting 0 made "send my whole balance" inexpressible and forced callers to invent a placeholder the engine then discarded. Tests: 288 pass in :sdk, including six covering the parse — the vault output beside a memo, order independence, multiple inputs with realistic scriptSigs, and refusals for two spendable outputs, OP_RETURN-only, and malformed bytes. Verified on-device (testnet, emulator): a max Maya deposit measures 27442985 duffs with a real 432-duff fee for an 80-byte memo, identical whether the caller passes 0 or a placeholder. Co-Authored-By: Claude Opus 5 --- .../dashsdk/wallet/ManagedPlatformWallet.kt | 119 +++++++++++++++++- .../wallet/SignedCoreTransactionTest.kt | 109 ++++++++++++++++ .../rs-unified-sdk-jni/src/wallet_manager.rs | 14 ++- 3 files changed, 238 insertions(+), 4 deletions(-) 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..71e3d927d00 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 @@ -233,6 +233,25 @@ class ManagedPlatformWallet internal constructor( */ override fun close() = cleanable.clean() + /** + * Value in duffs of the single non-OP_RETURN output — what the + * recipient actually receives. + * + * Needed for a DRAIN + * ([CoreTransactionBuilder.SelectionStrategy.ALL]): there the ENGINE + * computes the deliverable amount (`total inputs − fee`, no change), + * so the caller never supplied it and has no other way to learn it. + * A swap deposit must quote from this exact figure and then broadcast + * THIS transaction, so the quote and the payment cannot disagree. + * + * Derived from [rawTxBytes] (already present — no extra native call). + * Throws [IllegalStateException] if the bytes are malformed or hold + * anything other than exactly one non-OP_RETURN output; under a drain + * the engine guarantees exactly one, and a plain multi-recipient + * payment has no single "deliverable" amount to report. + */ + val deliverableAmountDuffs: Long by lazy { parseSoleDeliverableValue(rawTxBytes) } + override fun equals(other: Any?): Boolean = other is SignedCoreTransaction && txidHex == other.txidHex && @@ -260,6 +279,76 @@ class ManagedPlatformWallet internal constructor( } internal companion object { + private const val OP_RETURN: Byte = 0x6a + + /** + * Value of the sole non-OP_RETURN output in a consensus-serialized + * Dash transaction. Walks the outputs rather than trusting an + * index: [buildSignedPayment]'s `preserveOutputOrder` is optional, + * so the value carrier is not always VOUT0. + * + * Layout: `i32 version | varint vinCount | vin* | varint voutCount + * | vout* | u32 locktime [| special payload]`, all little-endian. + * A `vin` is `32B txid | u32 vout | varint scriptLen | script | + * u32 sequence`; a `vout` is `i64 value | varint scriptLen | + * script`. Special-transaction payloads follow the locktime and are + * never read here. Dash has no segwit marker/flag. + */ + private fun parseSoleDeliverableValue(txBytes: ByteArray): Long { + val buf = java.nio.ByteBuffer.wrap(txBytes) + .order(java.nio.ByteOrder.LITTLE_ENDIAN) + try { + buf.int // version (+ 16-bit type for special transactions) + repeat(readVarInt(buf).toIntExact("input count")) { + buf.position(buf.position() + 36) // txid + prev vout + // Read the length prefix BEFORE computing the new + // position: readVarInt advances the buffer, and an + // inline call would be evaluated after position() is + // sampled, losing the prefix's own bytes. + val scriptSigLen = readVarInt(buf).toIntExact("scriptSig length") + buf.position(buf.position() + scriptSigLen) + buf.position(buf.position() + 4) // sequence + } + var value: Long? = null + repeat(readVarInt(buf).toIntExact("output count")) { + val outValue = buf.long + val script = ByteArray(readVarInt(buf).toIntExact("scriptPubKey length")) + buf.get(script) + if (script.isEmpty() || script[0] != OP_RETURN) { + check(value == null) { + "transaction has more than one non-OP_RETURN output; " + + "deliverableAmountDuffs is defined only for a single-" + + "destination payment (a drain always builds one)" + } + value = outValue + } + } + return checkNotNull(value) { + "transaction has no non-OP_RETURN output to deliver to" + } + } catch (e: java.nio.BufferUnderflowException) { + throw IllegalStateException("malformed signed transaction bytes", e) + } catch (e: IllegalArgumentException) { + // ByteBuffer.position rejects an out-of-range index — a + // length prefix pointing past the end of the buffer. + throw IllegalStateException("malformed signed transaction bytes", e) + } + } + + /** Bitcoin-style compact size: `<0xfd` inline, else 2/4/8 LE bytes. */ + private fun readVarInt(buf: java.nio.ByteBuffer): Long = + when (val first = buf.get().toInt() and 0xff) { + 0xfd -> (buf.short.toInt() and 0xffff).toLong() + 0xfe -> buf.int.toLong() and 0xffffffffL + 0xff -> buf.long + else -> first.toLong() + } + + private fun Long.toIntExact(what: String): Int { + check(this in 0..Int.MAX_VALUE.toLong()) { "implausible $what: $this" } + return toInt() + } + /** * Decode the big-endian native BLOB the atomic * finalize-and-register FFI returns: `u64 token, u64 feeDuffs, @@ -346,6 +435,18 @@ 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. */ suspend fun buildSignedPayment( recipients: List>, @@ -356,6 +457,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 +471,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 +509,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..258b0019180 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 @@ -73,4 +73,113 @@ class SignedCoreTransactionTest { assertEquals(1, runs.get()) } + + // --- deliverableAmountDuffs ------------------------------------------- + // + // A DRAIN (SelectionStrategy.ALL) has the ENGINE compute the deliverable + // amount (total inputs − fee, no change), so the caller never supplied it. + // A swap deposit must quote from this exact figure and then broadcast this + // same transaction, so the two cannot disagree. These pin the parse against + // hand-built consensus bytes. + + /** Little-endian compact size, matching the parser. */ + private fun varInt(value: Long): ByteArray = when { + value < 0xfd -> byteArrayOf(value.toByte()) + value <= 0xffff -> ByteBuffer.allocate(3).order(java.nio.ByteOrder.LITTLE_ENDIAN) + .put(0xfd.toByte()).putShort(value.toShort()).array() + else -> ByteBuffer.allocate(5).order(java.nio.ByteOrder.LITTLE_ENDIAN) + .put(0xfe.toByte()).putInt(value.toInt()).array() + } + + /** One input (32B txid + vout + empty scriptSig + sequence) and [outputs]. */ + private fun tx(outputs: List>, inputs: Int = 1): ByteArray { + val out = java.io.ByteArrayOutputStream() + out.write(ByteBuffer.allocate(4).order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(2).array()) + out.write(varInt(inputs.toLong())) + repeat(inputs) { + out.write(ByteArray(32) { 0x11 }) + out.write(ByteBuffer.allocate(4).order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(0).array()) + out.write(varInt(0)) + out.write(ByteBuffer.allocate(4).order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(-1).array()) + } + out.write(varInt(outputs.size.toLong())) + for ((value, script) in outputs) { + out.write(ByteBuffer.allocate(8).order(java.nio.ByteOrder.LITTLE_ENDIAN).putLong(value).array()) + out.write(varInt(script.size.toLong())) + out.write(script) + } + out.write(ByteBuffer.allocate(4).order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(0).array()) + return out.toByteArray() + } + + private fun p2pkh(): ByteArray = byteArrayOf(0x76, 0xa9.toByte(), 0x14) + ByteArray(20) + + byteArrayOf(0x88.toByte(), 0xac.toByte()) + + private fun opReturn(payload: ByteArray): ByteArray = + byteArrayOf(0x6a, payload.size.toByte()) + payload + + private fun signedWith(txBytes: ByteArray) = + ManagedPlatformWallet.SignedCoreTransaction.fromRegisterBlob( + registerBlob(token = 1L, fee = 226L, txid = "aa", txBytes = txBytes) + ) + + @Test + fun deliverableAmountReadsTheVaultOutputBesideAMemo() { + // The MAYACHAIN drain shape: vault = VOUT0, zero-value memo = VOUT1. + val signed = signedWith( + tx(listOf(1_790_000L to p2pkh(), 0L to opReturn("=:MAYA.CACAO:addr".toByteArray()))) + ) + assertEquals(1_790_000L, signed.deliverableAmountDuffs) + } + + @Test + fun deliverableAmountIgnoresOutputOrder() { + // preserveOutputOrder is optional, so the value carrier is not always + // VOUT0 — the parser must walk the outputs, not index into them. + val signed = signedWith( + tx(listOf(0L to opReturn(byteArrayOf(1, 2, 3)), 42_000L to p2pkh())) + ) + assertEquals(42_000L, signed.deliverableAmountDuffs) + } + + @Test + fun deliverableAmountSurvivesMultipleInputsAndLongScripts() { + // A drain selects EVERY spendable UTXO, so many inputs is the norm; the + // input walk must skip variable-length scriptSigs correctly. + val out = java.io.ByteArrayOutputStream() + out.write(ByteBuffer.allocate(4).order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(2).array()) + out.write(varInt(3)) + repeat(3) { + out.write(ByteArray(32) { 0x22 }) + out.write(ByteBuffer.allocate(4).order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(1).array()) + val scriptSig = ByteArray(107) { 0x33 } // realistic P2PKH signature script + out.write(varInt(scriptSig.size.toLong())) + out.write(scriptSig) + out.write(ByteBuffer.allocate(4).order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(-1).array()) + } + out.write(varInt(1)) + out.write(ByteBuffer.allocate(8).order(java.nio.ByteOrder.LITTLE_ENDIAN).putLong(999_777L).array()) + out.write(varInt(p2pkh().size.toLong())) + out.write(p2pkh()) + out.write(ByteBuffer.allocate(4).order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(0).array()) + + assertEquals(999_777L, signedWith(out.toByteArray()).deliverableAmountDuffs) + } + + @Test(expected = IllegalStateException::class) + fun deliverableAmountRefusesTwoSpendableOutputs() { + // No single "deliverable" amount exists for a multi-recipient payment, + // and a drain never builds one — refuse rather than pick arbitrarily. + signedWith(tx(listOf(10L to p2pkh(), 20L to p2pkh()))).deliverableAmountDuffs + } + + @Test(expected = IllegalStateException::class) + fun deliverableAmountRefusesAnOpReturnOnlyTransaction() { + signedWith(tx(listOf(0L to opReturn(byteArrayOf(9))))).deliverableAmountDuffs + } + + @Test(expected = IllegalStateException::class) + fun deliverableAmountRefusesMalformedBytes() { + signedWith(byteArrayOf(1, 2, 3)).deliverableAmountDuffs + } } diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 1cc5801db3e..1af7b64e493 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 { From 714c65e3efa5830505e5677be46d76d3631376a8 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 6 Aug 2026 13:50:26 -0700 Subject: [PATCH 2/8] fix(sdk): report the deliverable amount from the registered transaction Review blocker: the drain's deliverable amount was parsed in Kotlin from `rawTxBytes`. Those bytes are a mutable copy the host owns, while a broadcast sends the REGISTERED transaction the reservation token refers to -- so the quote could report a value the payment does not pay. It also put consensus parsing in Kotlin, against the Rust-first rule in packages/kotlin-sdk/CLAUDE.md, with hand-rolled varint reads that had to defend against malformed lengths. Compute it in Rust instead, from the finalized transaction, before `register` consumes it: the sole non-OP_RETURN output's value, or 0 when there is no single such output (multi-recipient or OP_RETURN-only) -- which hosts read as "not applicable", not "pays nothing". - platform-wallet-ffi: new `out_deliverable_duffs` out param on `core_wallet_signed_payment_finalize`, null-checked and sentinel-zeroed with the others before any fallible step. - rs-unified-sdk-jni: carry it in the registration blob after the fee. - kotlin-sdk: `deliverableAmountDuffs` becomes a constructor property fed from the blob; delete the Kotlin parser (parseSoleDeliverableValue, readVarInt, toIntExact, the OP_RETURN constant). - tests: replace the six parser tests with three blob tests, including one proving that mutating rawTxBytes cannot change the reported amount. Swift does not call this entry point, so no Swift surface changes. Co-Authored-By: Claude Opus 5 --- .../dashsdk/wallet/ManagedPlatformWallet.kt | 115 ++++------------ .../wallet/SignedCoreTransactionTest.kt | 130 +++++------------- .../src/core_wallet/transaction_builder.rs | 32 +++++ .../rs-unified-sdk-jni/src/wallet_manager.rs | 11 +- 4 files changed, 100 insertions(+), 188 deletions(-) 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 71e3d927d00..2a3f90e59a7 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,24 +253,6 @@ class ManagedPlatformWallet internal constructor( */ override fun close() = cleanable.clean() - /** - * Value in duffs of the single non-OP_RETURN output — what the - * recipient actually receives. - * - * Needed for a DRAIN - * ([CoreTransactionBuilder.SelectionStrategy.ALL]): there the ENGINE - * computes the deliverable amount (`total inputs − fee`, no change), - * so the caller never supplied it and has no other way to learn it. - * A swap deposit must quote from this exact figure and then broadcast - * THIS transaction, so the quote and the payment cannot disagree. - * - * Derived from [rawTxBytes] (already present — no extra native call). - * Throws [IllegalStateException] if the bytes are malformed or hold - * anything other than exactly one non-OP_RETURN output; under a drain - * the engine guarantees exactly one, and a plain multi-recipient - * payment has no single "deliverable" amount to report. - */ - val deliverableAmountDuffs: Long by lazy { parseSoleDeliverableValue(rawTxBytes) } override fun equals(other: Any?): Boolean = other is SignedCoreTransaction && @@ -279,85 +281,19 @@ class ManagedPlatformWallet internal constructor( } internal companion object { - private const val OP_RETURN: Byte = 0x6a - - /** - * Value of the sole non-OP_RETURN output in a consensus-serialized - * Dash transaction. Walks the outputs rather than trusting an - * index: [buildSignedPayment]'s `preserveOutputOrder` is optional, - * so the value carrier is not always VOUT0. - * - * Layout: `i32 version | varint vinCount | vin* | varint voutCount - * | vout* | u32 locktime [| special payload]`, all little-endian. - * A `vin` is `32B txid | u32 vout | varint scriptLen | script | - * u32 sequence`; a `vout` is `i64 value | varint scriptLen | - * script`. Special-transaction payloads follow the locktime and are - * never read here. Dash has no segwit marker/flag. - */ - private fun parseSoleDeliverableValue(txBytes: ByteArray): Long { - val buf = java.nio.ByteBuffer.wrap(txBytes) - .order(java.nio.ByteOrder.LITTLE_ENDIAN) - try { - buf.int // version (+ 16-bit type for special transactions) - repeat(readVarInt(buf).toIntExact("input count")) { - buf.position(buf.position() + 36) // txid + prev vout - // Read the length prefix BEFORE computing the new - // position: readVarInt advances the buffer, and an - // inline call would be evaluated after position() is - // sampled, losing the prefix's own bytes. - val scriptSigLen = readVarInt(buf).toIntExact("scriptSig length") - buf.position(buf.position() + scriptSigLen) - buf.position(buf.position() + 4) // sequence - } - var value: Long? = null - repeat(readVarInt(buf).toIntExact("output count")) { - val outValue = buf.long - val script = ByteArray(readVarInt(buf).toIntExact("scriptPubKey length")) - buf.get(script) - if (script.isEmpty() || script[0] != OP_RETURN) { - check(value == null) { - "transaction has more than one non-OP_RETURN output; " + - "deliverableAmountDuffs is defined only for a single-" + - "destination payment (a drain always builds one)" - } - value = outValue - } - } - return checkNotNull(value) { - "transaction has no non-OP_RETURN output to deliver to" - } - } catch (e: java.nio.BufferUnderflowException) { - throw IllegalStateException("malformed signed transaction bytes", e) - } catch (e: IllegalArgumentException) { - // ByteBuffer.position rejects an out-of-range index — a - // length prefix pointing past the end of the buffer. - throw IllegalStateException("malformed signed transaction bytes", e) - } - } - - /** Bitcoin-style compact size: `<0xfd` inline, else 2/4/8 LE bytes. */ - private fun readVarInt(buf: java.nio.ByteBuffer): Long = - when (val first = buf.get().toInt() and 0xff) { - 0xfd -> (buf.short.toInt() and 0xffff).toLong() - 0xfe -> buf.int.toLong() and 0xffffffffL - 0xff -> buf.long - else -> first.toLong() - } - - private fun Long.toIntExact(what: String): Int { - check(this in 0..Int.MAX_VALUE.toLong()) { "implausible $what: $this" } - return toInt() - } - /** * 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) @@ -369,6 +305,7 @@ class ManagedPlatformWallet internal constructor( rawTxBytes = rawTxBytes, feeDuffs = feeDuffs, reservationToken = token, + deliverableAmountDuffs = deliverableDuffs, ) } } 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 258b0019180..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) @@ -76,110 +83,39 @@ class SignedCoreTransactionTest { // --- deliverableAmountDuffs ------------------------------------------- // - // A DRAIN (SelectionStrategy.ALL) has the ENGINE compute the deliverable - // amount (total inputs − fee, no change), so the caller never supplied it. - // A swap deposit must quote from this exact figure and then broadcast this - // same transaction, so the two cannot disagree. These pin the parse against - // hand-built consensus bytes. - - /** Little-endian compact size, matching the parser. */ - private fun varInt(value: Long): ByteArray = when { - value < 0xfd -> byteArrayOf(value.toByte()) - value <= 0xffff -> ByteBuffer.allocate(3).order(java.nio.ByteOrder.LITTLE_ENDIAN) - .put(0xfd.toByte()).putShort(value.toShort()).array() - else -> ByteBuffer.allocate(5).order(java.nio.ByteOrder.LITTLE_ENDIAN) - .put(0xfe.toByte()).putInt(value.toInt()).array() - } - - /** One input (32B txid + vout + empty scriptSig + sequence) and [outputs]. */ - private fun tx(outputs: List>, inputs: Int = 1): ByteArray { - val out = java.io.ByteArrayOutputStream() - out.write(ByteBuffer.allocate(4).order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(2).array()) - out.write(varInt(inputs.toLong())) - repeat(inputs) { - out.write(ByteArray(32) { 0x11 }) - out.write(ByteBuffer.allocate(4).order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(0).array()) - out.write(varInt(0)) - out.write(ByteBuffer.allocate(4).order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(-1).array()) - } - out.write(varInt(outputs.size.toLong())) - for ((value, script) in outputs) { - out.write(ByteBuffer.allocate(8).order(java.nio.ByteOrder.LITTLE_ENDIAN).putLong(value).array()) - out.write(varInt(script.size.toLong())) - out.write(script) - } - out.write(ByteBuffer.allocate(4).order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(0).array()) - return out.toByteArray() - } - - private fun p2pkh(): ByteArray = byteArrayOf(0x76, 0xa9.toByte(), 0x14) + ByteArray(20) + - byteArrayOf(0x88.toByte(), 0xac.toByte()) - - private fun opReturn(payload: ByteArray): ByteArray = - byteArrayOf(0x6a, payload.size.toByte()) + payload - - private fun signedWith(txBytes: ByteArray) = - ManagedPlatformWallet.SignedCoreTransaction.fromRegisterBlob( - registerBlob(token = 1L, fee = 226L, txid = "aa", txBytes = txBytes) - ) + // 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 deliverableAmountReadsTheVaultOutputBesideAMemo() { - // The MAYACHAIN drain shape: vault = VOUT0, zero-value memo = VOUT1. - val signed = signedWith( - tx(listOf(1_790_000L to p2pkh(), 0L to opReturn("=:MAYA.CACAO:addr".toByteArray()))) + fun deliverableAmountComesFromTheBlobNotTheBytes() { + val signed = ManagedPlatformWallet.SignedCoreTransaction.fromRegisterBlob( + registerBlob(token = 7L, fee = 432L, txid = "ab", txBytes = byteArrayOf(9, 9, 9), + deliverable = 27_442_985L) ) - assertEquals(1_790_000L, signed.deliverableAmountDuffs) + assertEquals(27_442_985L, signed.deliverableAmountDuffs) } @Test - fun deliverableAmountIgnoresOutputOrder() { - // preserveOutputOrder is optional, so the value carrier is not always - // VOUT0 — the parser must walk the outputs, not index into them. - val signed = signedWith( - tx(listOf(0L to opReturn(byteArrayOf(1, 2, 3)), 42_000L to p2pkh())) + 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) ) - assertEquals(42_000L, signed.deliverableAmountDuffs) + signed.rawTxBytes.fill(0xFF.toByte()) + assertEquals(500_000L, signed.deliverableAmountDuffs) } @Test - fun deliverableAmountSurvivesMultipleInputsAndLongScripts() { - // A drain selects EVERY spendable UTXO, so many inputs is the norm; the - // input walk must skip variable-length scriptSigs correctly. - val out = java.io.ByteArrayOutputStream() - out.write(ByteBuffer.allocate(4).order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(2).array()) - out.write(varInt(3)) - repeat(3) { - out.write(ByteArray(32) { 0x22 }) - out.write(ByteBuffer.allocate(4).order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(1).array()) - val scriptSig = ByteArray(107) { 0x33 } // realistic P2PKH signature script - out.write(varInt(scriptSig.size.toLong())) - out.write(scriptSig) - out.write(ByteBuffer.allocate(4).order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(-1).array()) - } - out.write(varInt(1)) - out.write(ByteBuffer.allocate(8).order(java.nio.ByteOrder.LITTLE_ENDIAN).putLong(999_777L).array()) - out.write(varInt(p2pkh().size.toLong())) - out.write(p2pkh()) - out.write(ByteBuffer.allocate(4).order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(0).array()) - - assertEquals(999_777L, signedWith(out.toByteArray()).deliverableAmountDuffs) - } - - @Test(expected = IllegalStateException::class) - fun deliverableAmountRefusesTwoSpendableOutputs() { - // No single "deliverable" amount exists for a multi-recipient payment, - // and a drain never builds one — refuse rather than pick arbitrarily. - signedWith(tx(listOf(10L to p2pkh(), 20L to p2pkh()))).deliverableAmountDuffs - } - - @Test(expected = IllegalStateException::class) - fun deliverableAmountRefusesAnOpReturnOnlyTransaction() { - signedWith(tx(listOf(0L to opReturn(byteArrayOf(9))))).deliverableAmountDuffs - } - - @Test(expected = IllegalStateException::class) - fun deliverableAmountRefusesMalformedBytes() { - signedWith(byteArrayOf(1, 2, 3)).deliverableAmountDuffs + 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 2cefb0888a0..845bbef39df 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 @@ -215,6 +215,14 @@ 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". +/// /// # Safety /// `builder` must be a valid, non-destroyed pointer; `wallet` a valid /// platform-wallet handle; `core_signer_handle` a valid resolver handle; every @@ -234,6 +242,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 +252,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 +267,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 +350,27 @@ 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 = { + let mut carriers = finalized + .transaction() + .output + .iter() + .filter(|out| !out.script_pubkey.is_op_return()); + match (carriers.next(), carriers.next()) { + (Some(only), None) => only.value, + _ => 0, + } + }; + unsafe { *out_deliverable_duffs = deliverable_duffs }; + let serialized = dashcore::consensus::serialize(finalized.transaction()); let len = serialized.len(); diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 1af7b64e493..e29f289b284 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1389,7 +1389,11 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c /// 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( @@ -1442,6 +1446,7 @@ 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( builder as *mut platform_wallet_ffi::FFITransactionBuilder, @@ -1455,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) { @@ -1488,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()); From 1889679537d3a34a4313496b1761c49ee5d531dd Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 7 Aug 2026 08:37:19 -0700 Subject: [PATCH 3/8] test(platform-wallet-ffi): cover the deliverable-amount classification Review note on #4324: this filter-and-match is the authoritative calculation behind the amount a host quotes, and no Rust test exercised it. The Kotlin tests feed the registration blob a caller-chosen scalar, so they prove the value survives the wire but cannot catch a regression in OP_RETURN classification, output ordering, or the zero-for-ambiguous result. Extract it as `sole_deliverable_value(&[TxOut]) -> u64` and test the cases that matter: a lone destination; a destination beside a data carrier in BOTH orders (Maya puts its memo at VOUT1, and nothing here may depend on that); two spendable outputs, alone and beside a carrier; an OP_RETURN-only build and an empty output set; and a value-bearing OP_RETURN, which reports 0 because an asset lock's burn is not a payee a host would quote. 258 platform-wallet-ffi tests pass. Co-Authored-By: Claude Opus 5 --- .../src/core_wallet/transaction_builder.rs | 112 ++++++++++++++++-- 1 file changed, 100 insertions(+), 12 deletions(-) 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 845bbef39df..deb4451c252 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; @@ -215,6 +215,24 @@ 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`. /// +/// 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, + } +} + /// 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 @@ -358,17 +376,7 @@ pub unsafe extern "C" fn core_wallet_signed_payment_finalize( // 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 = { - let mut carriers = finalized - .transaction() - .output - .iter() - .filter(|out| !out.script_pubkey.is_op_return()); - match (carriers.next(), carriers.next()) { - (Some(only), None) => only.value, - _ => 0, - } - }; + let deliverable_duffs = sole_deliverable_value(&finalized.transaction().output); unsafe { *out_deliverable_duffs = deliverable_duffs }; let serialized = dashcore::consensus::serialize(finalized.transaction()); @@ -852,3 +860,83 @@ 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; + use dashcore::blockdata::script::ScriptBuf; + use dashcore::TxOut; + + /// 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); + } +} From 8245402e266149082e8f4c4d05770ebece8afa0e Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 8 Aug 2026 08:53:52 -0700 Subject: [PATCH 4/8] fix(platform-wallet-ffi): keep the 11-argument finalize symbol intact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding `out_deliverable_duffs` to `core_wallet_signed_payment_finalize` in place changed an exported C symbol that crosses a binary boundary. The Swift SDK consumes `DashSDKFFI.xcframework` as a `binaryTarget`, so a host's compiled Swift and this library ship separately and can meet at different versions. An eleven-argument caller never passes the twelfth pointer, so the callee would read whatever occupied that argument slot, treat it as an address, and write eight bytes through it — silent corruption, not a diagnosable failure. The checked-in Swift caller also passes eleven, so the regenerated header broke its compile. Move the twelve-argument form to `core_wallet_signed_payment_finalize_with_deliverable` and keep `core_wallet_signed_payment_finalize` at its original signature, forwarding to the new symbol with a local it discards. cbindgen regenerates the old declaration with exactly eleven parameters, so `CoreTransactionBuilder.swift` compiles unchanged and already-built binaries keep linking. The JNI caller, which needs the amount, moves to the new symbol. Also reattaches the finalize doc comment, which had been left on the private `sole_deliverable_value` helper when it was introduced, leaving the exported function documented with a dangling "Also writes ...". Co-Authored-By: Claude Opus 5 --- .../dashsdk/ffi/WalletManagerNative.kt | 6 +- .../src/core_wallet/transaction_builder.rs | 97 +++++++++++++++---- .../rs-unified-sdk-jni/src/wallet_manager.rs | 8 +- 3 files changed, 85 insertions(+), 26 deletions(-) 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 40bdef869c3..fd48feeccd3 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,9 +285,9 @@ 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 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 deb4451c252..432b9873ae8 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 @@ -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,24 +233,6 @@ 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`. /// -/// 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, - } -} - /// 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 @@ -241,6 +241,10 @@ fn sole_deliverable_value(outputs: &[TxOut]) -> u64 { /// 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 @@ -248,7 +252,7 @@ fn sole_deliverable_value(outputs: &[TxOut]) -> u64 { /// `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, @@ -425,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, diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index e29f289b284..bba0b0e7be3 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -1380,9 +1380,9 @@ 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, @@ -1448,7 +1448,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_c 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, From c0885b5bf10792f3039179adfff2da3fa80d70e6 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 8 Aug 2026 17:43:50 -0700 Subject: [PATCH 5/8] docs(kotlin-sdk): document deliverableDuffs in the finalize BLOB layout The JNI writes `deliverable` third, right after `fee`, and `fromRegisterBlob` reads it there, but this native-method KDoc still described the pre-drain layout. Decoding by the documented contract would read `txidLen` from inside an eight-byte integer and every later field at the wrong offset. Documentation only; the encoder and decoder already agreed. Co-Authored-By: Claude Opus 5 --- .../org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 fd48feeccd3..b0039d9f689 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 @@ -294,7 +294,13 @@ internal object WalletManagerNative { * `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, From e4efd8093151e10656247d579ff24fe8ee41d80a Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Mon, 10 Aug 2026 19:10:31 -0700 Subject: [PATCH 6/8] docs(kotlin-sdk): a drain's scope is the account type, which defaults to pooled `SelectionStrategy.ALL` was documented as "drains the account" -- written when a drain could only mean one account. Since #4329 the send APIs default `accountType` to `ALL_SPENDABLE`, so `ALL` on a call that does not name an account type drains BIP44 AND BIP32 AND every DashPay contact-receiving account in one transaction, change returning to BIP44. That is the intended shape rather than a hazard: "send everything" means everything the wallet can sign for, and achieving it before the pooled selector meant sweeping accounts together on-chain first. Document it on the strategy enum and again on `buildSignedPayment`, where the two parameters meet and where the scope is inherited rather than typed, so a reader can see what a defaulted drain reaches. Naming a single account type is for drains genuinely scoped to one family -- a CoinJoin sweep that must not leave its privacy domain. A test pins the sweep scope per selector, so anything later added to SEND_FUNDING_SOURCES -- which would enlarge every defaulted drain -- fails there first. Co-Authored-By: Claude Opus 5 --- .../dashsdk/wallet/CoreTransactionBuilder.kt | 26 +++++++++++++- .../dashsdk/wallet/ManagedPlatformWallet.kt | 11 ++++++ .../src/core_wallet/transaction_builder.rs | 36 ++++++++++++++++++- 3 files changed, 71 insertions(+), 2 deletions(-) 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 2a3f90e59a7..d09438c439d 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 @@ -384,6 +384,17 @@ class ManagedPlatformWallet internal constructor( * [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 + * [CoreTransactionBuilder.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, + * change returning to BIP44. 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 a single [CoreTransactionBuilder.AccountType] only for a + * drain genuinely scoped to one family, such as a CoinJoin sweep that + * must not leave its privacy domain. */ suspend fun buildSignedPayment( recipients: List>, 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 432b9873ae8..c5b48f79784 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 @@ -923,9 +923,43 @@ pub unsafe extern "C" fn core_wallet_transaction_free(tx: *mut FFICoreTransactio #[cfg(test)] mod tests { - use super::sole_deliverable_value; + 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 { From 6d22bbbb50914f3c3a978086cea5a3f5097453f2 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 13 Aug 2026 09:32:38 -0700 Subject: [PATCH 7/8] docs(kotlin-sdk): correct the drain-scope note on buildSignedPayment Two errors in the paragraph added by the previous commit, both caught in review. It linked to `CoreTransactionBuilder.AccountType` and offered a CoinJoin sweep as the single-family example. But `buildSignedPayment` takes `ManagedPlatformWallet.AccountType`, whose only variants are BIP44, BIP32 and ALL_SPENDABLE -- there is no CoinJoin variant, so the advice named a scope the method cannot express. It now references the method's own enum and offers BIP44 / BIP32, noting that a CoinJoin sweep goes through the dedicated send-all path. It also said change returns to BIP44. That is true of pooled funding in general and false of a drain, which is the one case the paragraph is about: `SelectionStrategy.ALL` leaves no change at all. Saying otherwise contradicts the reason a drain needs documenting -- no change means no wallet-owned output. Co-Authored-By: Claude Opus 5 --- .../dashsdk/wallet/ManagedPlatformWallet.kt | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) 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 d09438c439d..ec420dfe118 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 @@ -386,15 +386,18 @@ class ManagedPlatformWallet internal constructor( * what a swap quote must be taken from. * * **A drain's scope is [accountType], which defaults to - * [CoreTransactionBuilder.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, - * change returning to BIP44. 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 a single [CoreTransactionBuilder.AccountType] only for a - * drain genuinely scoped to one family, such as a CoinJoin sweep that - * must not leave its privacy domain. + * [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>, From 7a1ab3c0608a029a4dd66d6cceac2b9c694959a5 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 13 Aug 2026 10:00:46 -0700 Subject: [PATCH 8/8] chore: bump rust-dashcore to 0f948590 (drain may carry a zero-value OP_RETURN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the workspace pin from 173ffac0 to the dev tip, two commits ahead: - 0f948590 — rust-dashcore#928, merged 2026-08-13: SelectionStrategy::All accepts a zero-value OP_RETURN data carrier beside the destination, classifying outputs as value-carrier vs data-carrier instead of destructuring for exactly one output. This is the engine half of the memo-bearing drain this PR's Kotlin surface exposes; at the previous pin the FFI rejected that shape with "SelectionStrategy::All requires exactly one output", which was this PR's remaining blocking finding. - 0fdad664 — rust-dashcore#955, a dash-spv resume fix riding along (resume on the invariant start_download asserts). Verified against the new engine: platform-wallet 672 and platform-wallet-ffi 280 tests pass; #928's own drain-with-memo tests (test_drain_allows_a_zero_value_op_return_beside_the_destination and siblings) now run at the pinned rev. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 46 +++++++++++++++++++++++----------------------- Cargo.toml | 16 ++++++++-------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6775bdd0c17..c84b89d662a 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 5238bf2a982..3c85cfdd3dc 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"