Skip to content
Open
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 @@ -211,6 +211,27 @@ internal object WalletManagerNative {
*/
external fun platformWalletGetCore(walletHandle: Long): Long

/**
* `core_wallet_build_signed_payment` — build + sign a standard L1 payment
* funded from the UNION of the wallet's signable funds accounts (watch-only
* DashPay external accounts excluded), 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`.
*
* 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,
): ByteArray

/**
* `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,26 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable {
)
}

/**
* Build + sign a standard L1 payment funded from the UNION 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,
): ByteArray =
WalletManagerNative.coreWalletBuildSignedPayment(
handle,
outputsBlob,
feePerKb,
coreSignerHandle,
)

override fun close() {
cleanable.clean()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,77 @@ 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 the
* UNION of this wallet's signable funds accounts (BIP44 + BIP32 + CoinJoin
* + DashPay receiving; watch-only DashPay external accounts excluded), 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). Coin selection auto-spans every
* signable account, so — unlike [sendToAddresses] — the caller does not
* pick a funding account.
*
* 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.
*/
suspend fun buildSignedPayment(
recipients: List<Pair<String, Long>>,
coreSignerHandle: Long,
feePerKb: Long = 0,
): 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),
)
}
}
}

/**
* 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 +688,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
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::*;
169 changes: 169 additions & 0 deletions packages/rs-platform-wallet-ffi/src/core_wallet/send.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
//! FFI binding for the union-funding "build a signed payment" primitive.
//!
//! Unlike the step-by-step `core_wallet_tx_builder_*` builder (which funds from
//! a single caller-chosen account), this is a one-shot call that funds a
//! standard L1 payment from the UNION of every signable funds account
//! (BIP44 + BIP32 + CoinJoin + DashPay receiving; watch-only DashPay external
//! accounts are excluded) and returns the **signed serialized transaction
//! bytes** plus the computed fee and change amount. It does NOT broadcast and
//! does NOT persist a debit — the caller commits/broadcasts the returned bytes
//! itself (dashj during the Android transition; a later SDK-broadcast mode
//! afterwards). See `platform_wallet::wallet::core::send` for the semantics.

use crate::error::*;
use crate::handle::{Handle, CORE_WALLET_STORAGE};
use crate::runtime::runtime;
use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return};
use dashcore::Address as DashAddress;
use platform_wallet::PlatformWalletError;
use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle};
use std::str::FromStr;

/// Decode the recipients blob the caller passes to
/// [`core_wallet_build_signed_payment`]. Layout (big-endian):
///
/// ```text
/// u32 count
/// count × ( u32 address_len, address_len bytes (UTF-8), u64 amount_duffs )
/// ```
///
/// Each address is parsed and checked against `network`; a malformed blob or a
/// wrong-network / unparseable address is a decode error.
fn decode_payment_outputs(
blob: &[u8],
network: dashcore::Network,
) -> Result<Vec<(DashAddress, u64)>, PlatformWalletError> {
let err = |m: String| PlatformWalletError::TransactionBuild(m);
let mut cursor = 0usize;
let read_u32 = |buf: &[u8], at: &mut usize| -> Result<u32, PlatformWalletError> {
let end = *at + 4;
if end > buf.len() {
return Err(PlatformWalletError::TransactionBuild(
"truncated recipients blob (u32)".to_string(),
));
}
let v = u32::from_be_bytes([buf[*at], buf[*at + 1], buf[*at + 2], buf[*at + 3]]);
*at = end;
Ok(v)
};
let read_u64 = |buf: &[u8], at: &mut usize| -> Result<u64, PlatformWalletError> {
let end = *at + 8;
if end > buf.len() {
return Err(PlatformWalletError::TransactionBuild(
"truncated recipients blob (u64)".to_string(),
));
}
let mut b = [0u8; 8];
b.copy_from_slice(&buf[*at..end]);
*at = end;
Ok(u64::from_be_bytes(b))
};

let count = read_u32(blob, &mut cursor)? as usize;
let mut outputs = Vec::with_capacity(count);
Comment on lines +62 to +63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Untrusted count drives an unbounded upfront allocation.

count is read directly from the blob header with no relation to blob.len() before being used in Vec::with_capacity(count). A malformed/corrupted blob (or a future encoding bug) with a large count triggers a large allocation attempt before any row is validated, which can abort the process via OOM instead of returning a clean TransactionBuild decode error.

🛡️ Proposed fix
     let count = read_u32(blob, &mut cursor)? as usize;
-    let mut outputs = Vec::with_capacity(count);
+    // Don't trust `count` for pre-allocation; rows accumulate incrementally
+    // and each is bounds-checked below.
+    let mut outputs = Vec::new();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let count = read_u32(blob, &mut cursor)? as usize;
let mut outputs = Vec::with_capacity(count);
let count = read_u32(blob, &mut cursor)? as usize;
let mut outputs = Vec::new();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet-ffi/src/core_wallet/send.rs` around lines 62 -
63, Validate the decoded count in the send transaction decoding flow before the
Vec::with_capacity(count) allocation. Use the remaining blob length and the
minimum encoded size of one output to reject impossible or excessively large
counts with the existing TransactionBuild decode error, then allocate only after
validation; preserve normal decoding for valid counts.

for _ in 0..count {
let addr_len = read_u32(blob, &mut cursor)? as usize;
let end = cursor + addr_len;
Comment on lines +62 to +66

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: Untrusted recipient count can abort the process through an enormous allocation

The decoder passes the caller-controlled u32 count directly to Vec::with_capacity before checking whether the blob contains that many rows. A four-byte blob declaring u32::MAX outputs can therefore request hundreds of gigabytes and enter Rust's process-aborting allocation-failure path instead of returning the documented decode error. On 32-bit targets, capacity or cursor + addr_len overflow can also panic inside the nested extern "C" function, where the JNI guard cannot safely recover it. Bound the count using the remaining blob length and the standard-transaction output limit, use try_reserve_exact, and use checked cursor arithmetic.

source: ['codex']

if end > blob.len() {
return Err(err("truncated recipients blob (address)".to_string()));
}
let addr_str = std::str::from_utf8(&blob[cursor..end])
.map_err(|e| err(format!("recipient address is not valid UTF-8: {e}")))?;
cursor = end;
let amount = read_u64(blob, &mut cursor)?;

let parsed = DashAddress::from_str(addr_str)
.map_err(|e| err(format!("invalid recipient address {addr_str:?}: {e}")))?;
let address = parsed
.require_network(network)
.map_err(|e| err(format!("recipient address {addr_str:?} network mismatch: {e}")))?;
outputs.push((address, amount));
}
Ok(outputs)
}

/// Build and sign a standard L1 payment from the wallet's signable funds
/// accounts (union coin selection) and return the signed bytes + fee + change.
///
/// * `handle` — a core-wallet handle (`platform_wallet_get_core`).
/// * `outputs_blob`/`outputs_blob_len` — the recipients, encoded as documented
/// on [`decode_payment_outputs`].
/// * `fee_per_kb` — fee rate in duffs/kB, or `0` for the default (1000).
/// * `core_signer_handle` — the caller's `MnemonicResolverHandle`; ownership is
/// retained by the caller (this function does NOT destroy it).
/// * `out_tx_bytes`/`out_tx_len` — receive the consensus-serialized signed
/// transaction. Free with [`core_wallet_free_payment_bytes`].
/// * `out_fee` — receives the fee paid, in duffs.
/// * `out_change` — receives the change returned to the wallet, in duffs (0 if
/// the build produced no change output).
///
/// # Safety
/// All pointers must be valid; `outputs_blob` must be readable for
/// `outputs_blob_len` bytes; the out-pointers must be writable.
#[no_mangle]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn core_wallet_build_signed_payment(
handle: Handle,
outputs_blob: *const u8,
outputs_blob_len: usize,
fee_per_kb: u64,
core_signer_handle: *mut MnemonicResolverHandle,
out_tx_bytes: *mut *mut u8,
out_tx_len: *mut usize,
out_fee: *mut u64,
out_change: *mut u64,
) -> PlatformWalletFFIResult {
check_ptr!(outputs_blob);
check_ptr!(core_signer_handle);
check_ptr!(out_tx_bytes);
check_ptr!(out_tx_len);
check_ptr!(out_fee);
check_ptr!(out_change);

let blob = std::slice::from_raw_parts(outputs_blob, outputs_blob_len);
let signer_addr = core_signer_handle as usize;
let fee = if fee_per_kb == 0 {
None
} else {
Some(fee_per_kb)
};

let option = CORE_WALLET_STORAGE.with_item(handle, |wallet| {
let network = wallet.network();
let outputs = decode_payment_outputs(blob, network)?;
let wallet_id = wallet.wallet_id();
// SAFETY: `signer_addr` came from `core_signer_handle`, which the caller
// pinned alive for this call; the `MnemonicResolverCoreSigner` lives
// only on this stack frame and is dropped before returning.
let signer = MnemonicResolverCoreSigner::new(
signer_addr as *mut MnemonicResolverHandle,
wallet_id,
network,
);
runtime().block_on(wallet.build_signed_payment(outputs, fee, &signer))
});

let result = unwrap_option_or_return!(option);
let payment = unwrap_result_or_return!(result);

let serialized = dashcore::consensus::serialize(&payment.transaction);
let len = serialized.len();
*out_tx_bytes = Box::into_raw(serialized.into_boxed_slice()) as *mut u8;
*out_tx_len = len;
*out_fee = payment.fee;
*out_change = payment.change_amount;

PlatformWalletFFIResult::ok()
}

/// Free the signed-payment bytes returned by [`core_wallet_build_signed_payment`].
///
/// # Safety
/// `bytes`/`len` must be the exact pair written to `out_tx_bytes`/`out_tx_len`
/// by [`core_wallet_build_signed_payment`] (or null / 0).
#[no_mangle]
pub unsafe extern "C" fn core_wallet_free_payment_bytes(bytes: *mut u8, len: usize) {
if !bytes.is_null() && len > 0 {
let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(bytes, len));
}
}
13 changes: 13 additions & 0 deletions packages/rs-platform-wallet/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ pub enum PlatformWalletError {
#[error("Asset lock transaction failed: {0}")]
AssetLockTransaction(String),

/// A general Core L1 payment build (`CoreWallet::build_signed_payment`)
/// could not cover the requested outputs plus fee from the union of the
/// wallet's *signable* funds accounts (BIP44 + BIP32 + CoinJoin + DashPay
/// receiving; watch-only DashPay external accounts are excluded). `available`
/// is the total selectable value across those accounts, `required` the
/// outputs-plus-fee target — carried as exact duff amounts (instead of being
/// flattened into a string) so callers can render a precise shortfall.
#[error(
"payment coin selection is short: available {available} duffs, \
required {required} duffs"
)]
PaymentInsufficientFunds { available: u64, required: u64 },

#[error("Transaction broadcast failed: {0}")]
TransactionBroadcast(String),

Expand Down
1 change: 1 addition & 0 deletions packages/rs-platform-wallet/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ pub use spv::SpvRuntime;
pub use wallet::asset_lock::manager::AssetLockManager;
pub use wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock};
pub use wallet::asset_lock::AssetLockFunding;
pub use wallet::core::SignedCorePayment;
pub use wallet::core::WalletBalance;
pub use wallet::core::{CoreWallet, SignedCoreTransaction};
// DashPay types + crypto helpers re-exported through the identity
Expand Down
2 changes: 2 additions & 0 deletions packages/rs-platform-wallet/src/wallet/core/mod.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
pub mod balance;
pub mod balance_handler;
mod broadcast;
mod send;
mod transaction;
pub mod wallet;

pub use balance::WalletBalance;
pub use balance_handler::BalanceUpdateHandler;
pub use send::SignedCorePayment;
pub use transaction::SignedCoreTransaction;
pub use wallet::CoreWallet;
Loading
Loading