Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,24 @@ sealed class DashSdkError(
class CoreInsufficientFunds(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

/**
* `ErrorTransactionBuild` (native code 32). A Core transaction could
* not be assembled from the request. The REQUEST is at fault, so a
* verbatim retry fails identically — the caller must change it.
*
* It is what every non-shortfall `buildSignedPayment` rejection
* surfaces as: a `fundingPath` matching no spendable funds account or
* naming a watch-only one (the two failure modes the single-account
* send design rests on, dashpay/platform#4184), a request breaching a
* monetary bound (MAX_MONEY total, max fee rate, a below-dust
* recipient, an over-100 kB recipient list), or a malformed recipients
* blob. Before this code existed they all arrived as [Generic] with
* native code 99 and could only be told apart by string-matching the
* message. The specific cause is still in [message].
*/
class TransactionBuild(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

class AssetLockNotTracked(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

Expand Down Expand Up @@ -245,6 +263,7 @@ sealed class DashSdkError(
23 -> PlatformWallet.AssetLockNotTracked(message, cause) // ErrorAssetLockNotTracked
24 -> PlatformWallet.AssetLockAlreadyConsumed(message, cause) // ErrorAssetLockAlreadyConsumed
25 -> PlatformWallet.AssetLockFundingMismatch(message, cause) // ErrorAssetLockFundingMismatch
32 -> PlatformWallet.TransactionBuild(message, cause) // ErrorTransactionBuild
else -> PlatformWallet.Generic(code, message, cause)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,63 @@ internal object WalletManagerNative {
*/
external fun platformWalletGetCore(walletHandle: Long): Long

/**
* `core_wallet_build_signed_payment` — build + sign a standard L1 payment
* funded from ONE of the wallet's signable funds accounts, WITHOUT
* broadcasting.
*
* [coreHandle] is a core-wallet handle from [platformWalletGetCore].
* [outputsBlob] encodes the recipients big-endian as `u32 count` then per
* row `u32 addrLen, addr utf8, u64 amount`. [feePerKb] is duffs/kB (0 =
* default). [coreSignerHandle] is the manager's `MnemonicResolverHandle`.
* [fundingPath] is an optional UTF-8 BIP32 derivation-path string
* (dashpay/platform#4184) naming the single funds account whose UTXOs fund
* the payment: null (the default) funds from the unmixed BIP44 account; an
* explicit account-level path (e.g. the DIP-9 CoinJoin account path) funds
* strictly from that one account, with no union across accounts and no
* consent gate.
*
* Returns a `byte[]` packed big-endian as `u64 fee, u64 change,` then the
* consensus-serialized signed transaction bytes (0-length / null after
* throwing). Does NOT broadcast and does NOT persist a debit.
*/
external fun coreWalletBuildSignedPayment(
coreHandle: Long,
outputsBlob: ByteArray,
feePerKb: Long,
coreSignerHandle: Long,
fundingPath: String?,
): ByteArray

/**
* `core_wallet_release_payment_reservation` — release the UTXO reservation
* a [coreWalletBuildSignedPayment] call took, for a build that will NOT be
* broadcast.
*
* `build_signed_payment` leaves its selected inputs reserved on success,
* expecting a broadcast to follow. A caller that abandons the build must
* call this or the coins stay unselectable until key-wallet's 24-block TTL
* backstop reclaims them — and that backstop never fires before the first
* sync completes (`ReservationSet::sweep` early-returns at height 0), so an
* abandoned build on a freshly restored wallet can otherwise strand the
* whole balance for the life of the process (dashpay/platform#4247 review).
* This call consults no height.
*
* [coreHandle] is a core-wallet handle from [platformWalletGetCore].
* [txBytes] is the consensus-serialized signed transaction exactly as
* [coreWalletBuildSignedPayment] returned it — the transaction is the
* ownership signal, so only that build's own inputs are released.
* [fundingPath] must be the SAME optional path the build was given (null =
* the unmixed BIP44 account).
*
* Idempotent, and a silent no-op after a successful broadcast.
*/
external fun coreWalletReleasePaymentReservation(
coreHandle: Long,
txBytes: ByteArray,
fundingPath: String?,
)

/**
* `core_wallet_broadcast_transaction` — broadcast a transaction built by
* [coreTxBuilderBuildSigned]. [accountType]/[accountIndex] identify the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,37 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable {
)
}

/**
* Build + sign a standard L1 payment funded from ONE of the wallet's
* signable funds accounts, WITHOUT broadcasting. Returns the packed native
* result (`u64 fee, u64 change,` then the signed tx bytes, big-endian) —
* decoded by [ManagedPlatformWallet.buildSignedPayment]. See that method
* for the full contract; drive this through it (it serializes concurrent
* builds), not directly.
*/
internal fun buildSignedPayment(
outputsBlob: ByteArray,
feePerKb: Long,
coreSignerHandle: Long,
fundingPath: String?,
): ByteArray =
WalletManagerNative.coreWalletBuildSignedPayment(
handle,
outputsBlob,
feePerKb,
coreSignerHandle,
fundingPath,
)

/**
* Release the UTXO reservation a [buildSignedPayment] call took, for a
* build that will not be broadcast. See
* [ManagedPlatformWallet.releasePaymentReservation] for the full contract;
* drive this through it, not directly.
*/
internal fun releasePaymentReservation(txBytes: ByteArray, fundingPath: String?) =
WalletManagerNative.coreWalletReleasePaymentReservation(handle, txBytes, fundingPath)

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

/**
* A built-and-signed Core L1 payment that was NOT broadcast — the output of
* [buildSignedPayment]. [txBytes] is the consensus-serialized signed
* transaction the caller commits/broadcasts itself (dashj during the
* dashj→SDK transition; the SDK's own broadcast afterwards). [fee] and
* [change] are duffs.
*/
data class SignedCorePayment(
val txBytes: ByteArray,
val fee: Long,
val change: Long,
) {
override fun equals(other: Any?): Boolean =
other is SignedCorePayment &&
txBytes.contentEquals(other.txBytes) &&
fee == other.fee &&
change == other.change

override fun hashCode(): Int =
(31 * txBytes.contentHashCode() + fee.hashCode()) * 31 + change.hashCode()
}

/**
* Build and sign a Core L1 payment to [recipients], funding it from a
* **single** funds account, and return the signed raw transaction bytes
* plus the fee and change — **WITHOUT broadcasting**.
*
* This is the transition-era "give me signed bytes" primitive: the Android
* wallet hands [SignedCorePayment.txBytes] to dashj for commit + broadcast
* (keeping dashj's `maybeCommitTx` bookkeeping — CrowdNode, memos,
* confidence listeners), while the SDK owns coin selection and signing. It
* does not broadcast and does not persist a debit; the selected inputs are
* only reserved in memory (released when the spend is later observed by sync
* or by the reservation-TTL backstop).
*
* **Funding-domain isolation (dashpay/platform#4184).** Coin selection never
* spans accounts. [fundingPath] names the one funds account to draw from;
* `null` (the default) draws from the unmixed BIP44 account. Passing an
* explicit account-level path — e.g. the DIP-9 CoinJoin account path — spends
* previously-mixed coins deliberately, and only those. Unioning ordinary,
* CoinJoin, and DashPay-receiving coins into one transaction would
* irreversibly link those privacy domains on chain, so it is never done
* implicitly: if the named account cannot cover the payment this throws
* [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.CoreInsufficientFunds]
* rather than reaching into another account — whose `available` figure is
* that ONE account's balance, so the actionable response is to pick a
* different [fundingPath], not to retry the same one.
*
* Runs through the manager's [TeardownGate] like every other native op.
* A concurrent build cannot select the same UTXO because the underlying
* `build_signed_payment` holds the wallet-manager write lock across coin
* selection and signing (the same native serialization [sendToAddresses]
* relies on).
*
* @param recipients `(address, amountDuffs)` pairs; must be non-empty and
* every amount positive.
* @param coreSignerHandle the manager's `MnemonicResolverHandle`
* (`PlatformWalletManager.mnemonicResolverHandle`); no private key crosses
* the boundary.
* @param feePerKb fee rate in duffs/kB, or 0 for the SDK default.
* @param fundingPath optional UTF-8 BIP32 derivation-path string naming the
* single funds account to fund from; `null` = the unmixed BIP44 account.
*/
suspend fun buildSignedPayment(
recipients: List<Pair<String, Long>>,
coreSignerHandle: Long,
feePerKb: Long = 0,
fundingPath: String? = null,
): SignedCorePayment = gate.op {
require(recipients.isNotEmpty()) { "recipients must not be empty" }
require(recipients.all { it.second > 0 }) { "every recipient amount must be positive" }
require(feePerKb >= 0) { "feePerKb must be non-negative, got $feePerKb" }

val outputsBlob = encodePaymentOutputs(recipients)
mapNativeErrors {
coreWallet().use { core ->
decodeSignedPayment(
core.buildSignedPayment(outputsBlob, feePerKb, coreSignerHandle, fundingPath),
)
}
}
}

/**
* Release the UTXO reservation a [buildSignedPayment] call took, for a
* build this caller has decided **not** to broadcast.
*
* [buildSignedPayment] deliberately leaves its selected inputs reserved on
* success, because the expected next step is a broadcast. If the build is
* abandoned instead — the user backed out of the confirmation screen, an
* upstream check failed, the send screen is being torn down — this must be
* called or those coins stay unselectable.
*
* **Why it matters (dashpay/platform#4247 review).** Without it the inputs
* are stranded until key-wallet's TTL backstop reclaims them 24 blocks
* (~1 hour) later. Worse, that backstop does not merely run late but does
* not run at all before the first sync completes: the sweep early-returns
* while the wallet has no processed height, so a reservation taken at
* height 0 is never reclaimed for the life of the process. A single
* abandoned build on a freshly restored wallet could otherwise strand the
* entire balance. This call consults no height, so it is the one release
* path that works pre-sync.
*
* **Releases only this build's own inputs.** The transaction is the
* ownership signal: a reserved outpoint is skipped by every other build's
* coin selection, so no concurrent build can hold a reservation on any
* input of [txBytes].
*
* **Idempotent, and safe after a broadcast.** Calling it twice, or on a
* transaction that was in fact broadcast, is a silent no-op rather than an
* error, and it cannot resurrect a spent coin — coin selection reads the
* UTXO set, from which sync removes the spend independently of any
* reservation. That makes it safe in an unconditional `finally` without
* tracking whether the broadcast succeeded.
*
* @param txBytes [SignedCorePayment.txBytes] from the build being
* abandoned.
* @param fundingPath the **same** [fundingPath] the build was given, so the
* release lands on the account holding the reservation; `null` means the
* unmixed BIP44 account, exactly as it does for the build. A path naming
* a different account is harmless but frees nothing.
*/
suspend fun releasePaymentReservation(
txBytes: ByteArray,
fundingPath: String? = null,
): Unit = gate.op {
require(txBytes.isNotEmpty()) { "txBytes must not be empty" }
mapNativeErrors {
coreWallet().use { core -> core.releasePaymentReservation(txBytes, fundingPath) }
}
}

/**
* The wallet's Platform-payment addresses that currently hold credits,
* each as a [FundingInput] whose `credits` is the full cached balance —
Expand Down Expand Up @@ -617,6 +749,41 @@ class ManagedPlatformWallet internal constructor(
return out.toByteArray()
}

/**
* Encode [recipients] to the payment-outputs blob
* `core_wallet_build_signed_payment` reads: `u32 count` then per row
* `u32 addrLen, addr utf8 bytes, u64 amount` (all big-endian, matching
* `DataOutputStream`'s wire order and the Rust `from_be_bytes` decoder).
*/
private fun encodePaymentOutputs(recipients: List<Pair<String, Long>>): ByteArray {
val out = java.io.ByteArrayOutputStream()
val dos = java.io.DataOutputStream(out)
dos.writeInt(recipients.size)
for ((address, amount) in recipients) {
val addrBytes = address.toByteArray(Charsets.UTF_8)
dos.writeInt(addrBytes.size)
dos.write(addrBytes)
dos.writeLong(amount)
}
return out.toByteArray()
}

/**
* Decode the packed [SignedCorePayment] the native build returns:
* `u64 fee, u64 change,` then the signed transaction bytes (big-endian).
*/
private fun decodeSignedPayment(packed: ByteArray): SignedCorePayment {
require(packed.size >= 16) {
"signed-payment result too short (${packed.size} bytes, need >= 16)"
}
val buffer = java.nio.ByteBuffer.wrap(packed) // big-endian by default
val fee = buffer.long
val change = buffer.long
val txBytes = ByteArray(buffer.remaining())
buffer.get(txBytes)
return SignedCorePayment(txBytes = txBytes, fee = fee, change = change)
}

/**
* Encode [recipients] to the funding-recipients blob the FFI reads:
* `u32 rowCount` then per row `u8 addressType, u8[20] hash,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,31 @@ class DashSdkErrorTest {
assertTrue(broadcastUnconfirmed.message!!.contains("do NOT retry"))
}

/**
* `ErrorTransactionBuild` (32) must reach callers as its own type. Every
* non-shortfall `buildSignedPayment` rejection maps to it — including the
* two failure modes the single-account send design rests on, an unmatched
* `fundingPath` and a watch-only one. They previously arrived as
* [DashSdkError.PlatformWallet.Generic] with code 99, distinguishable only
* by string-matching the message (dashpay/platform#4247 review).
*/
@Test
fun transactionBuildFailuresGetTheirOwnType() {
val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET
val message = "no spendable funds account matches funding derivation path m/44'/5'/7'"

val mapped = DashSdkError.fromNative(DashSDKException(offset + 32, message))
assertTrue(
"code 32 must not fall through to Generic",
mapped is DashSdkError.PlatformWallet.TransactionBuild,
)
assertEquals(message, mapped.message)
assertFalse(
"the request itself is at fault, so a verbatim retry cannot help",
mapped.isRetryable,
)
}

@Test
fun unmappedPlatformWalletCodesFallBackToGeneric() {
val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET
Expand Down
17 changes: 15 additions & 2 deletions packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,14 +236,27 @@ mod outcome_tests {
);
}

/// An error raised BEFORE the transaction reached the network must not
/// report a txid — the caller would otherwise track a transaction that was
/// never broadcast. The code itself is whatever the blanket `From` impl
/// maps the variant to; `TransactionBuild` gained a dedicated
/// `ErrorTransactionBuild` code (dashpay/platform#4247 review) instead of
/// flattening to `ErrorUnknown`, and the txid contract is unchanged by
/// that.
#[test]
fn operational_error_does_not_carry_a_txid() {
let outcome = classify_broadcast_result(
Err(PlatformWalletError::TransactionBuild("invalid".to_string())),
txid(4),
);
assert_eq!(outcome.0, None);
assert_eq!(outcome.1.code, PlatformWalletFFIResultCode::ErrorUnknown);
assert_eq!(
outcome.0, None,
"a pre-broadcast failure must report no txid"
);
assert_eq!(
outcome.1.code,
PlatformWalletFFIResultCode::ErrorTransactionBuild
);
}
}

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;
mod send;
mod transaction_builder;
mod wallet;

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