Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
fe6d7c1
fix(rs-sdk): case-insensitive .dash suffix in resolve_dpns_name
lklimek May 5, 2026
26f13d9
fix(rs-platform-wallet): prevent UTXO double-spend race in send_to_ad…
lklimek May 5, 2026
0d17a63
Merge branch 'v3.1-dev' into fix/dpns-case-and-utxo-race-v3.1-dev
lklimek May 5, 2026
1bd306a
fix: improve platform wallet UTXO checks and DPNS parsing (#3595)
thepastaclaw May 6, 2026
4616cba
Merge branch 'v3.1-dev' into fix/dpns-case-and-utxo-race-v3.1-dev
lklimek May 6, 2026
23d8943
fix(rs-platform-wallet): defer change-address advance until after rev…
lklimek May 6, 2026
a3a5d96
fix(rs-platform-wallet): typed ConcurrentSpendConflict variant for re…
lklimek May 6, 2026
41c9493
fix(rs-sdk): skip DPNS contract fetch when label is empty (CMT-001)
lklimek May 6, 2026
2c7e22a
docs(rs-platform-wallet): rewrite revalidation comment to match build…
lklimek May 6, 2026
97d1532
fix(rs-platform-wallet): structured event for post-broadcast !is_rele…
lklimek May 6, 2026
79843e3
test(rs-platform-wallet): broadcast ordering + rollback contract (CMT…
lklimek May 6, 2026
391768c
docs(rs-platform-wallet): tighten and deduplicate inline comments on …
lklimek May 6, 2026
43e3f9d
fix(rs-platform-wallet): defer change-address commit past broadcast (…
lklimek May 6, 2026
5d4a61b
test(rs-platform-wallet): rename broadcast pass-through tests to matc…
lklimek May 6, 2026
cc2104f
Merge remote-tracking branch 'origin/v3.1-dev' into fix/dpns-case-and…
lklimek May 8, 2026
6aa4f42
Merge branch 'v3.1-dev' into fix/dpns-case-and-utxo-race-v3.1-dev
lklimek May 8, 2026
4dd55d2
fix: close same-UTXO concurrent-selection race in send_to_addresses (…
lklimek May 8, 2026
349b95b
feat(rs-platform-wallet): attach outpoint context to ConcurrentSpendC…
lklimek May 8, 2026
6239fda
chore(rs-platform-wallet): drop CMT-NNN review tombstones from broadc…
lklimek May 8, 2026
4d204cd
fix(rs-platform-wallet): structured tracing fields on wallet-missing …
lklimek May 8, 2026
543a8dc
Merge remote-tracking branch 'origin/v3.1-dev' into fix/dpns-case-and…
lklimek May 12, 2026
0bacd25
chore: improve error type
lklimek May 12, 2026
0188fa9
chore: improve docs
lklimek May 12, 2026
9902cbd
chore: fix build
lklimek May 12, 2026
371e2c3
fix(rs-platform-wallet): restore defensive post-build UTXO revalidation
lklimek May 13, 2026
ff56c56
chore(rs-platform-wallet-ffi): use Result::is_err in group_info tests
lklimek May 13, 2026
5466501
chore: fmt
lklimek May 13, 2026
e4cf6b3
Merge remote-tracking branch 'origin/v3.1-dev' into fix/dpns-case-and…
lklimek May 14, 2026
8d4cfe8
Merge remote-tracking branch 'origin/v3.1-dev' into fix/dpns-case-and…
lklimek May 21, 2026
25bb359
fix(rs-platform-wallet-ffi): typed FFI codes for retryable errors + s…
lklimek May 21, 2026
c29b4ba
fix(platform-wallet): close UTXO race in DashPay + harden change-inde…
lklimek May 21, 2026
b8043f9
Merge branch 'v3.1-dev' into fix/dpns-case-and-utxo-race-v3.1-dev
lklimek May 25, 2026
8de859a
fix(rs-platform-wallet): typed BuilderError → PlatformWalletError map…
lklimek May 25, 2026
4d017b8
docs(rs-platform-wallet): correct leak_until_sync docstring to match …
lklimek May 25, 2026
cd2ade5
refactor(rs-platform-wallet): factor shared send-flow helper for Core…
lklimek May 25, 2026
701971d
Merge remote-tracking branch 'origin/v3.1-dev' into fix/dpns-case-and…
lklimek May 26, 2026
96c1d4b
fix(rs-platform-wallet-ffi): replace `0x1 as *mut` with `ptr::danglin…
lklimek May 26, 2026
87d24e3
fix(rs-platform-wallet-ffi): collapse NoSpendableInputs onto ErrorNoS…
lklimek May 26, 2026
b9a3be7
fix(platform-wallet): reserve receive address via AddressReservations…
lklimek May 26, 2026
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
125 changes: 115 additions & 10 deletions packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,32 @@ pub unsafe extern "C" fn core_wallet_send_to_addresses(
check_ptr!(out_tx_bytes);
check_ptr!(out_tx_len);

let mut outputs = Vec::with_capacity(count);
let addr_ptrs = std::slice::from_raw_parts(addresses, count);
let amount_slice = std::slice::from_raw_parts(amounts, count);
// `std::slice::from_raw_parts` requires a non-null, properly
// aligned pointer even for `len == 0`. Swift's empty
// `Array.withUnsafeBufferPointer.baseAddress` returns `nil`, so
// the `count == 0` path is allowed to ship null `addresses` /
// `amounts` — guard against constructing the slice in that case.
let outputs: Vec<(dashcore::Address, u64)> = if count == 0 {
Vec::new()
} else {
let addr_ptrs = std::slice::from_raw_parts(addresses, count);
let amount_slice = std::slice::from_raw_parts(amounts, count);

for i in 0..count {
let c_str = unwrap_result_or_return!(std::ffi::CStr::from_ptr(addr_ptrs[i]).to_str());

let addr = unwrap_result_or_return!(dashcore::Address::from_str(c_str)).assume_checked();

outputs.push((addr, amount_slice[i]));
}
let mut outputs = Vec::with_capacity(count);
for i in 0..count {
if addr_ptrs[i].is_null() {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorNullPointer,
format!("null address pointer at index {i}"),
);
}
let c_str = unwrap_result_or_return!(std::ffi::CStr::from_ptr(addr_ptrs[i]).to_str());
let addr =
unwrap_result_or_return!(dashcore::Address::from_str(c_str)).assume_checked();
outputs.push((addr, amount_slice[i]));
}
outputs
};

use key_wallet::account::account_type::StandardAccountType;
let std_account_type = match account_type {
Expand Down Expand Up @@ -134,3 +149,93 @@ pub unsafe extern "C" fn core_wallet_free_tx_bytes(bytes: *mut u8, len: usize) {
let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(bytes, len));
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::handle::NULL_HANDLE;

/// `count == 0` MUST NOT touch `addresses` / `amounts` — Swift's
/// empty `Array.withUnsafeBufferPointer.baseAddress` gives `nil`,
/// and `slice::from_raw_parts` is UB on a null pointer regardless
/// of length. Pass `NULL_HANDLE` so the storage lookup short-
/// circuits to `NotFound` before any wallet code runs — we only
/// care that input marshalling did not blow up.
#[test]
fn send_to_addresses_zero_count_null_pointers_is_safe() {
// Use a non-null but fake signer pointer; the closure that
// would dereference it is never entered because `NULL_HANDLE`
// makes `with_item` return `None`.
let fake_signer = std::ptr::dangling_mut::<MnemonicResolverHandle>();
let mut out_tx: *mut u8 = std::ptr::null_mut();
let mut out_len: usize = 0;

let result = unsafe {
core_wallet_send_to_addresses(
NULL_HANDLE,
0, // BIP44Account
0,
std::ptr::null(), // null addresses — allowed because count == 0
std::ptr::null(), // null amounts — allowed because count == 0
0,
fake_signer,
&mut out_tx,
&mut out_len,
)
};
assert_eq!(result.code, PlatformWalletFFIResultCode::NotFound);
}

/// Non-null `addresses` array with `count == 0` is also fine.
#[test]
fn send_to_addresses_zero_count_nonnull_pointers_is_safe() {
let dummy_addr: *const c_char = std::ptr::null();
let dummy_amount: u64 = 0;
let addrs: [*const c_char; 1] = [dummy_addr];
let amts: [u64; 1] = [dummy_amount];
let fake_signer = std::ptr::dangling_mut::<MnemonicResolverHandle>();
let mut out_tx: *mut u8 = std::ptr::null_mut();
let mut out_len: usize = 0;

let result = unsafe {
core_wallet_send_to_addresses(
NULL_HANDLE,
0,
0,
addrs.as_ptr(),
amts.as_ptr(),
0, // count = 0 → array contents ignored
fake_signer,
&mut out_tx,
&mut out_len,
)
};
assert_eq!(result.code, PlatformWalletFFIResultCode::NotFound);
}

/// A null entry inside the address array must surface as
/// `ErrorNullPointer`, not UB.
#[test]
fn send_to_addresses_null_element_is_rejected() {
let addrs: [*const c_char; 2] = [std::ptr::null(), std::ptr::null()];
let amts: [u64; 2] = [1, 2];
let fake_signer = std::ptr::dangling_mut::<MnemonicResolverHandle>();
let mut out_tx: *mut u8 = std::ptr::null_mut();
let mut out_len: usize = 0;

let result = unsafe {
core_wallet_send_to_addresses(
NULL_HANDLE,
0,
0,
addrs.as_ptr(),
amts.as_ptr(),
2,
fake_signer,
&mut out_tx,
&mut out_len,
)
};
assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer);
}
}
80 changes: 61 additions & 19 deletions packages/rs-platform-wallet-ffi/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,16 +80,27 @@ pub enum PlatformWalletFFIResultCode {
/// no in-tree producer today. Holding the slot here keeps language-mirror
/// enums (Swift, Kotlin) numerically aligned with the eventual producer.
ErrorArithmeticOverflow = 13,
/// Auto-select had no candidate inputs. Covers all three "can't-select-inputs"
/// wallet variants: `NoSpendableInputs` (account has nothing spendable),
/// `OnlyOutputAddressesFunded` (every funded address is also a destination),
/// and `OnlyDustInputs` (every funded address is below `min_input_amount`).
/// The typed Display rendering survives via the result message so callers
/// can distinguish the underlying cause. Caller must rotate to a fresh
/// Auto-select had no candidate inputs. Umbrella for every
/// "can't-pick-UTXOs" wallet variant: `NoSpendableInputs` (account
/// has nothing spendable, including the race-loser path),
/// `OnlyOutputAddressesFunded` (every funded address is also a
/// destination), and `OnlyDustInputs` (every funded address is
/// below `min_input_amount`). The typed Display rendering survives
/// via the result message so callers can distinguish the underlying
/// cause. Caller must wait for sync / new UTXOs, rotate to a fresh
/// receive address, consolidate sub-min balances, or fall back to
/// `InputSelection::Explicit`.
ErrorNoSelectableInputs = 14,

/// Transaction builder selected an outpoint that another in-flight
/// build had already reserved — retryable. The originating
/// [`PlatformWalletError::ConcurrentSpendConflict`] carries a
/// `Vec<OutPoint>` of the colliding inputs; that payload is
/// stringified into the `message` field for now (TODO: propagate
/// the structured outpoint list across the FFI when a generic
/// payload sidecar exists).
ErrorConcurrentSpendConflict = 31,

NotFound = 98, // Used exclusively for all the Option that are retuned as errors
ErrorUnknown = 99,
}
Expand Down Expand Up @@ -169,17 +180,27 @@ impl<T> From<Option<T>> for PlatformWalletFFIResult {

impl From<PlatformWalletError> for PlatformWalletFFIResult {
fn from(error: PlatformWalletError) -> Self {
// Map the typed wallet error variants explicitly so they
// don't flatten to ErrorUnknown at the FFI boundary. The
// catch-all ErrorUnknown remains for variants the FFI hasn't
// assigned a dedicated code yet — those still carry the
// typed Display rendering as the message.
// Map the typed wallet error variants explicitly so they don't
// flatten to ErrorUnknown at the FFI boundary. The catch-all
// ErrorUnknown remains for variants the FFI hasn't assigned a
// dedicated code yet — those still carry the typed Display
// rendering as the message.
//
// All three "can't-select-inputs" wallet variants
// (`NoSpendableInputs`, `OnlyOutputAddressesFunded`,
// `OnlyDustInputs`) collapse onto the umbrella
// `ErrorNoSelectableInputs` code; the typed Display rendering
// in the message lets callers distinguish the underlying cause
// (including the race-loser breadcrumb on `NoSpendableInputs`).
let code = match &error {
PlatformWalletError::NoSpendableInputs { .. }
| PlatformWalletError::OnlyOutputAddressesFunded { .. }
| PlatformWalletError::OnlyDustInputs { .. } => {
PlatformWalletFFIResultCode::ErrorNoSelectableInputs
}
PlatformWalletError::ConcurrentSpendConflict { .. } => {
PlatformWalletFFIResultCode::ErrorConcurrentSpendConflict
}
_ => PlatformWalletFFIResultCode::ErrorUnknown,
};
PlatformWalletFFIResult::err(code, error.to_string())
Expand Down Expand Up @@ -403,14 +424,13 @@ mod tests {
assert!(!r.message.is_null());
}

/// The three "can't-select-inputs" wallet variants (`NoSpendableInputs`,
/// `OnlyOutputAddressesFunded`, `OnlyDustInputs`) all map to the dedicated
/// `ErrorNoSelectableInputs` FFI code rather than flattening to
/// `ErrorUnknown`, and the typed Display rendering survives across the
/// boundary so callers can distinguish the underlying cause from the
/// message string.
/// All three "can't-select-inputs" wallet variants collapse onto the
/// umbrella `ErrorNoSelectableInputs` (14) FFI code, and the typed
/// Display rendering survives across the boundary so callers can
/// distinguish the underlying cause (including the race-loser
/// breadcrumb on `NoSpendableInputs`) from the message string.
#[test]
fn no_selectable_inputs_maps_to_dedicated_code() {
fn no_selectable_inputs_maps_to_umbrella_code() {
use dpp::address_funds::PlatformAddress;
use key_wallet::account::StandardAccountType;

Expand All @@ -437,7 +457,7 @@ mod tests {
assert_eq!(
result.code,
PlatformWalletFFIResultCode::ErrorNoSelectableInputs,
"variant should map to ErrorNoSelectableInputs (rendered: {rendered})"
"variant must collapse onto the umbrella code (rendered: {rendered})"
);
assert!(!result.message.is_null());
let msg = unsafe { std::ffi::CStr::from_ptr(result.message) }
Expand All @@ -450,6 +470,28 @@ mod tests {
}
}

/// `ConcurrentSpendConflict` keeps its dedicated FFI code (31) —
/// race-loser breadcrumb on the typed `NoSpendableInputs` Display
/// is not the same thing as the builder picking an already-reserved
/// outpoint, and downstream callers (retry logic) must be able to
/// tell them apart by code.
#[test]
fn concurrent_spend_conflict_keeps_dedicated_code() {
let err = PlatformWalletError::ConcurrentSpendConflict {
selected: Vec::new(),
};
let rendered = err.to_string();
let result: PlatformWalletFFIResult = err.into();
assert_eq!(
result.code,
PlatformWalletFFIResultCode::ErrorConcurrentSpendConflict,
);
let msg = unsafe { std::ffi::CStr::from_ptr(result.message) }
.to_string_lossy()
.into_owned();
assert_eq!(msg, rendered);
}

/// Other wallet-error variants without a dedicated FFI arm still
/// fall through to `ErrorUnknown` while carrying the typed
/// Display rendering as the message. Pin this so the catch-all
Expand Down
7 changes: 7 additions & 0 deletions packages/rs-platform-wallet/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use dashcore::OutPoint;
use dpp::address_funds::PlatformAddress;
use dpp::fee::Credits;
use dpp::identifier::Identifier;
Expand Down Expand Up @@ -63,6 +64,12 @@ pub enum PlatformWalletError {
#[error("Transaction building failed: {0}")]
TransactionBuild(String),

#[error(
"Transaction builder selected an unavailable UTXO (concurrent spend); retry. \
Selected outpoints: {selected:?}"
)]
ConcurrentSpendConflict { selected: Vec<OutPoint> },

#[error("no spendable inputs available on {account_type} account {account_index}: {context}")]
NoSpendableInputs {
account_type: StandardAccountType,
Expand Down
Loading
Loading