Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
* 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 &&
Expand Down Expand Up @@ -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)
Expand All @@ -280,6 +305,7 @@ class ManagedPlatformWallet internal constructor(
rawTxBytes = rawTxBytes,
feeDuffs = feeDuffs,
reservationToken = token,
deliverableAmountDuffs = deliverableDuffs,
)
}
}
Expand Down Expand Up @@ -346,6 +372,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<Pair<String, Long>>,
Expand All @@ -356,6 +394,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
Expand All @@ -369,8 +408,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)"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
val builderAccountType = when (accountType) {
AccountType.BIP44 -> CoreTransactionBuilder.AccountType.BIP44
Expand Down Expand Up @@ -400,6 +446,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) }
Comment on lines 446 to +454

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Memo-bearing drains are rejected by the pinned engine

This builds a destination output plus a zero-value OP_RETURN and then selects ALL, but Cargo.toml and Cargo.lock still pin rust-dashcore to dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29. At that exact revision, key-wallet's assemble_unsigned uses let [out] = tx_outputs.as_mut_slice() and returns SelectionStrategy::All requires exactly one output (the destination) whenever the memo creates a second output. rust-dashcore PR #928 changes this block to count exactly one non-OP_RETURN value carrier while allowing zero-value OP_RETURN outputs, but this PR changes only the Kotlin/JNI files and does not update the dependency. Therefore the documented MAYACHAIN drain path always fails; pin a revision containing #928 or include equivalent engine support before exposing this behavior.

source: ['codex']

builder.finalizeSignedPayment(
this@ManagedPlatformWallet,
builderAccountType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
Loading
Loading