Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,20 @@ sealed class DashSdkError(
cause,
)

/**
* `ErrorStaleReservationToken` (native code 26). A deferred
* (BIP70/BIP270) [broadcastSigned][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.broadcastSigned]
* was given a reservation token that is unknown, already broadcast,
* already released, or was minted against a re-created wallet instance.
* The call did NOT touch the network — there is no double-broadcast —
* but the token can never succeed, so this is NOT retryable: rebuild the
* payment with
* [buildSignedPayment][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.buildSignedPayment].
* (Release is idempotent and never raises this.)
*/
class StaleReservationToken(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

/**
* Any other `PlatformWalletFFIResultCode` without a dedicated type.
* Carries the platform-wallet [nativeCode] (already de-offset) and
Expand Down Expand Up @@ -245,6 +259,7 @@ sealed class DashSdkError(
23 -> PlatformWallet.AssetLockNotTracked(message, cause) // ErrorAssetLockNotTracked
24 -> PlatformWallet.AssetLockAlreadyConsumed(message, cause) // ErrorAssetLockAlreadyConsumed
25 -> PlatformWallet.AssetLockFundingMismatch(message, cause) // ErrorAssetLockFundingMismatch
26 -> PlatformWallet.StaleReservationToken(message, cause) // ErrorStaleReservationToken
else -> PlatformWallet.Generic(code, message, cause)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,65 @@ internal object WalletManagerNative {
*/
external fun coreTransactionFree(tx: Long)

/**
* `core_wallet_signed_payment_register` — register a built+signed
* transaction (from [coreTxBuilderBuildSigned]) for deferred
* (BIP70/BIP270) submission, holding its UTXO reservation. Does NOT consume
* the transaction — free it separately with [coreTransactionFree].
* [accountType]/[accountIndex] identify the funding account (0 BIP44,
* 1 BIP32, 2 CoinJoin).
*
* Returns a big-endian BLOB decoded into a `SignedCoreTransaction`:
* `u64 token, u64 feeDuffs, u32 txidLen, txid utf8, u32 txBytesLen, txBytes`.
* The raw tx bytes come back in this same call — no second native round trip.
*/
external fun coreWalletRegisterSignedPayment(
coreHandle: Long,
tx: Long,
accountType: Int,
accountIndex: Int,
): ByteArray

/**
* `core_wallet_signed_payment_finalize` — atomically fund, reserve, sign,
* AND register a builder for deferred (BIP70/BIP270) submission in one
* native call. The concurrency-safe replacement for
* [coreTxBuilderSetFunding] + [coreTxBuilderBuildSigned] +
* [coreWalletRegisterSignedPayment]: selection and reservation commit as a
* single unit under the wallet-manager lock, closing the double-selection
* window. CONSUMES [builder]. [accountType]/[accountIndex] identify the
* funding account (0 BIP44, 1 BIP32, 2 CoinJoin); [coreSignerHandle] is a
* `MnemonicResolverHandle`.
*
* Returns the same big-endian BLOB [coreWalletRegisterSignedPayment]
* returns, decoded into a `SignedCoreTransaction`:
* `u64 token, u64 feeDuffs, u32 txidLen, txid utf8, u32 txBytesLen, txBytes`.
*/
external fun coreWalletFinalizeSignedPayment(
builder: Long,
walletHandle: Long,
accountType: Int,
accountIndex: Int,
coreSignerHandle: Long,
): ByteArray

/**
* `core_wallet_signed_payment_broadcast` — broadcast the payment behind
* [token], reconciling its reservation on failure and consuming the token.
* A repeated/stale/wrong-wallet token throws
* `ErrorStaleReservationToken` (never a double-broadcast). [coreHandle] must
* resolve to the wallet the token was minted against. Returns the txid as a
* lowercase hex string.
*/
external fun coreWalletBroadcastSignedPayment(coreHandle: Long, token: Long): String

/**
* `core_wallet_signed_payment_release` — release the funding reservation
* behind [token] and drop it. Idempotent: releasing an unknown /
* already-consumed token is a silent no-op.
*/
external fun coreWalletReleaseSignedPayment(token: Long)

/**
* Enumerate the wallet's Platform-payment addresses with cached credit
* balances, as a big-endian blob: `u32 rowCount` then per row
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,38 @@ class CoreTransactionBuilder internal constructor(network: Network) : AutoClosea
return FinalizedCoreTransaction(transaction, fee)
}

/**
* Consume this configured builder and, in ONE atomic native operation,
* select + reserve + sign the inputs and register the built transaction for
* deferred (BIP70/BIP270) submission. The concurrency-safe replacement for
* the deprecated [setFunding] + [buildSigned] + register split: selection
* and reservation commit as a single unit under the wallet-manager lock, so
* concurrent deferred builds cannot double-select an input. Returns the
* decoded [ManagedPlatformWallet.SignedCoreTransaction].
*/
internal fun finalizeSignedPayment(
wallet: ManagedPlatformWallet,
accountType: AccountType,
accountIndex: Int,
coreSignerHandle: Long,
): ManagedPlatformWallet.SignedCoreTransaction {
require(accountIndex >= 0) { "accountIndex must be non-negative" }
require(coreSignerHandle != 0L) { "coreSignerHandle must be non-zero" }
// Validate every borrowed dependency before transferring builder
// ownership. Once getAndSet(0) runs, JNI consumes the native builder.
val walletHandle = wallet.handle
val builderPtr = handleRef.getAndSet(0)
check(builderPtr != 0L) { "CoreTransactionBuilder has been consumed or closed" }
val blob = WalletManagerNative.coreWalletFinalizeSignedPayment(
builderPtr,
walletHandle,
accountType.ffiValue,
accountIndex,
coreSignerHandle,
)
return ManagedPlatformWallet.SignedCoreTransaction.fromRegisterBlob(blob)
}

override fun close() {
cleanable.clean()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,33 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable {
)
}

/**
* Register a built+signed [tx] for deferred (BIP70/BIP270) submission,
* holding its UTXO reservation, and return the resulting
* [ManagedPlatformWallet.SignedCoreTransaction]. Does NOT consume [tx] — the
* caller still closes it. Decodes the single register BLOB
* (`token, feeDuffs, txid, rawTxBytes`) — one native round trip.
*/
internal fun registerSignedPayment(
tx: CoreTransaction,
): ManagedPlatformWallet.SignedCoreTransaction {
val blob = WalletManagerNative.coreWalletRegisterSignedPayment(
handle,
tx.handle,
tx.accountType.ffiValue,
tx.accountIndex,
)
return ManagedPlatformWallet.SignedCoreTransaction.fromRegisterBlob(blob)
}
Comment on lines +65 to +75

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.

🟡 Suggestion: registerSignedPayment stitches two native round-trips in Kotlin, contrary to this repo's kotlin-sdk rule

registerSignedPayment calls coreTransactionGetBytes(tx.handle) and then coreWalletRegisterSignedPayment(...), stitching the two results into one SignedCoreTransaction with hand-rolled ByteBuffer decoding. packages/kotlin-sdk/CLAUDE.md states explicitly: "No JNI functions that stitch together existing Rust calls — add the composite to Rust instead." Confirmed core_wallet_transaction_get_bytes/coreTransactionGetBytes has exactly one Kotlin call site — this one — so there's no other caller relying on it staying separate.

core_wallet_signed_payment_register (rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs:84) already has (*tx).bytes() in scope (it uses it to deserialize the transaction at line 100); it could copy those bytes into an additional out-parameter and return everything in one native round trip, removing coreTransactionGetBytes from this path entirely.

source: ['claude']

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.

Resolved in a428f54registerSignedPayment stitches two native round-trips in Kotlin, contrary to this repo's kotlin-sdk rule no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.


/**
* Broadcast the deferred payment behind [token] and return its txid. A
* stale / already-broadcast / wrong-wallet token surfaces as
* [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.StaleReservationToken].
*/
internal fun broadcastSignedPayment(token: Long): String =
WalletManagerNative.coreWalletBroadcastSignedPayment(handle, token)

override fun close() {
cleanable.clean()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,167 @@ class ManagedPlatformWallet internal constructor(
}
}

/**
* A built, signed Core transaction whose funding UTXOs are reserved,
* awaiting a deferred [broadcastSigned] or [releaseReservation] — the
* split-out result of [buildSignedPayment] for BIP70/BIP270 (CTX/DashSpend)
* flows that must sign now, POST the raw bytes to a merchant server, and
* broadcast only on the server's ack.
*
* @property txidHex the transaction id (lowercase hex) the broadcast will
* return — computed from the signed bytes Rust-side so it matches exactly.
* @property rawTxBytes the consensus-serialized signed transaction, to hand
* to the merchant server.
* @property feeDuffs the fee the build charged, in duffs.
* @property reservationToken the opaque token for [broadcastSigned] /
* [releaseReservation]. Valid only for this wallet instance and only until
* consumed by one of those calls.
*/
class SignedCoreTransaction internal constructor(
val txidHex: String,
val rawTxBytes: ByteArray,
val feeDuffs: Long,
val reservationToken: Long,
) {
override fun equals(other: Any?): Boolean =
other is SignedCoreTransaction &&
txidHex == other.txidHex &&
rawTxBytes.contentEquals(other.rawTxBytes) &&
feeDuffs == other.feeDuffs &&
reservationToken == other.reservationToken

override fun hashCode(): Int {
var result = txidHex.hashCode()
result = 31 * result + rawTxBytes.contentHashCode()
result = 31 * result + feeDuffs.hashCode()
result = 31 * result + reservationToken.hashCode()
return result
}

override fun toString(): String =
"SignedCoreTransaction(txidHex=$txidHex, feeDuffs=$feeDuffs, " +
"reservationToken=$reservationToken, rawTxBytes=${rawTxBytes.size} bytes)"

internal companion object {
/**
* Decode the big-endian native BLOB the deferred build/register FFI
* returns: `u64 token, u64 feeDuffs, u32 txidLen, txid utf8,
* u32 txBytesLen, txBytes`. Shared by the atomic
* finalize-and-register path and the deprecated register path.
*/
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 txidLen = buffer.int
val txidBytes = ByteArray(txidLen)
buffer.get(txidBytes)
val txBytesLen = buffer.int
val rawTxBytes = ByteArray(txBytesLen)
buffer.get(rawTxBytes)
return SignedCoreTransaction(
txidHex = String(txidBytes, Charsets.UTF_8),
rawTxBytes = rawTxBytes,
feeDuffs = feeDuffs,
reservationToken = token,
)
}
}
}

/**
* Build and sign a Core payment to [recipients] WITHOUT broadcasting,
* reserving the funding UTXOs and returning a [SignedCoreTransaction] whose
* [SignedCoreTransaction.reservationToken] later drives [broadcastSigned]
* (server acked) or [releaseReservation] (abandoned / server nacked).
*
* The BIP70/BIP270 counterpart to [sendToAddresses]: those protocols sign,
* POST the raw bytes to a merchant server, and broadcast only on ack, which
* a single build-sign-broadcast call cannot express. The `new → addOutput* →
* setFunding → buildSigned` build runs under the same per-wallet teardown
* gate ([gate]) as [sendToAddresses]; [buildSigned] atomically reserves the
* selected UTXOs in the Rust reservation layer (which closes the
* setFunding/buildSigned selection race), so once this returns the
* reservation holds the inputs and [broadcastSigned] / [releaseReservation]
* operate on the token later.
*
* Process-death note: the reservation is in-memory. An app crash between
* this call and [broadcastSigned] drops the reservation on restart (the
* UTXOs become spendable again) — the same property dashj has.
*
* @param network the wallet network — see [sendToAddresses].
* @param coreSignerHandle the manager's `MnemonicResolverHandle` — see
* [sendToAddresses]. No private key crosses the boundary.
*/
suspend fun buildSignedPayment(
recipients: List<Pair<String, Long>>,
network: org.dashfoundation.dashsdk.Network,
coreSignerHandle: Long,
accountType: AccountType = AccountType.BIP44,
accountIndex: Int = 0,
): SignedCoreTransaction = gate.op {
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"
}
val builderAccountType = when (accountType) {
AccountType.BIP44 -> CoreTransactionBuilder.AccountType.BIP44
AccountType.BIP32 -> CoreTransactionBuilder.AccountType.BIP32
}
mapNativeErrors {
// One atomic native operation: select + reserve + sign + register.
// `finalizeSignedPayment` consumes the builder on every path, so
// `use` only needs to destroy it on the pre-finalize failure paths
// (adding outputs). Selection and reservation commit as a single unit
// under the wallet-manager lock, so a concurrent deferred build — or a
// deferred build racing an immediate send — can no longer double-
// select the same input, restoring the atomicity the removed Kotlin
// per-wallet send mutex used to provide.
CoreTransactionBuilder(network).use { builder ->
for ((address, amount) in recipients) {
builder.addOutput(address, amount)
}
builder.finalizeSignedPayment(
this@ManagedPlatformWallet,
builderAccountType,
accountIndex,
coreSignerHandle,
)
}
}
}

/**
* Broadcast the deferred payment behind [token] (from [buildSignedPayment])
* and return its broadcast txid — the "merchant server acked" arm. Consumes
* the token: a second [broadcastSigned] with the same token, or one for a
* re-created wallet, throws
* [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.StaleReservationToken]
* rather than double-broadcasting. Operates on the token directly (the
* inputs are already reserved).
*/
suspend fun broadcastSigned(token: Long): String = withContext(Dispatchers.IO) {
mapNativeErrors {
coreWallet().use { core -> core.broadcastSignedPayment(token) }
}
}

/**
* Release the funding reservation behind [token] (from [buildSignedPayment])
* — the "payment abandoned / merchant server nacked" arm — returning the
* reserved UTXOs to spendable. Idempotent: releasing an unknown /
* already-broadcast / already-released token is a silent no-op, so it is
* always safe to call defensively.
*/
suspend fun releaseReservation(token: Long) {
withContext(Dispatchers.IO) {
mapNativeErrors {
WalletManagerNative.coreWalletReleaseSignedPayment(token)
}
}
}

/**
* The wallet's Platform-payment addresses that currently hold credits,
* each as a [FundingInput] whose `credits` is the full cached balance —
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,16 @@ class DashSdkErrorTest {
)
// The message must warn against retrying (distinct from the anchor case).
assertTrue(broadcastUnconfirmed.message!!.contains("do NOT retry"))

// Deferred build/broadcast: a stale/consumed/wrong-wallet reservation
// token → typed StaleReservationToken, not retryable.
val staleToken = DashSdkError.fromNative(DashSDKException(offset + 26, "stale token 7"))
assertTrue(staleToken is DashSdkError.PlatformWallet.StaleReservationToken)
assertFalse(
"StaleReservationToken must NOT be retryable (rebuild the payment)",
staleToken.isRetryable,
)
assertEquals("stale token 7", staleToken.message)
}

@Test
Expand Down
2 changes: 2 additions & 0 deletions packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@

mod addresses;
mod broadcast;
pub(crate) mod signed_payment;
mod transaction_builder;
mod wallet;

pub use addresses::*;
pub use broadcast::*;
pub use signed_payment::*;
pub use transaction_builder::*;
pub use wallet::*;
Loading
Loading