Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -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
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
* `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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
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,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<Pair<String, Long>>,
Expand All @@ -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
Expand All @@ -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)"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
val builderAccountType = when (accountType) {
AccountType.BIP44 -> CoreTransactionBuilder.AccountType.BIP44
Expand Down Expand Up @@ -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) }
Comment thread
shumkov marked this conversation as resolved.
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