diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/addresses.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/addresses.rs index ccd16e52798..01d8953ad14 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/addresses.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/addresses.rs @@ -1,9 +1,11 @@ //! FFI bindings for CoreWallet address derivation. +use super::transaction_builder::CoreAccountTypeFFI; use crate::error::*; use crate::handle::*; use crate::runtime::runtime; use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; +use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use std::ffi::CString; use std::os::raw::c_char; @@ -51,6 +53,30 @@ pub unsafe extern "C" fn core_wallet_next_change_address( PlatformWalletFFIResult::ok() } +/// Widen the gap limit for an account, generating the addresses the wider +/// limit now requires. +/// +/// # Safety +/// `handle` must be a valid core-wallet handle. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_set_gap_limit( + handle: Handle, + account_type: CoreAccountTypeFFI, + account_index: u32, + gap_limit: u32, +) -> PlatformWalletFFIResult { + let source: AccountTypePreference = account_type.into(); + + let option = CORE_WALLET_STORAGE.with_item(handle, |wallet| { + runtime().block_on(wallet.set_gap_limit(source, account_index, gap_limit)) + }); + + let result = unwrap_option_or_return!(option); + unwrap_result_or_return!(result); + + PlatformWalletFFIResult::ok() +} + /// Free an address string returned by `core_wallet_next_receive_address` /// or `core_wallet_next_change_address`. #[no_mangle] diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs index 24d54bb0776..81ba11b77b7 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -1,136 +1,60 @@ -//! FFI bindings for CoreWallet transaction building and broadcasting. +//! FFI bindings for CoreWallet transaction broadcasting. +use super::transaction_builder::{CoreAccountTypeFFI, FFICoreTransaction}; use crate::error::*; use crate::handle::*; use crate::runtime::runtime; use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; -use rs_sdk_ffi::MnemonicResolverCoreSigner; -use rs_sdk_ffi::MnemonicResolverHandle; use std::os::raw::c_char; -use std::str::FromStr; -/// Broadcast a signed transaction to the network. +/// Broadcast a transaction built by `core_wallet_tx_builder_build_signed`. +/// +/// `account_type`/`account_index` identify the funding account handed to +/// `core_wallet_tx_builder_set_funding` when the transaction was built: on a +/// definitive broadcast rejection its UTXO reservation is released so an +/// immediate retry can reselect the inputs; an ambiguous failure keeps it. +/// `CoinJoin` funding has no standard-account reservation to reconcile and is +/// broadcast plainly. +/// +/// # Safety +/// `handle` must be a valid core-wallet handle; `tx` must be a valid, +/// non-null pointer to an `FFICoreTransaction`; `out_txid` must be writable. #[no_mangle] pub unsafe extern "C" fn core_wallet_broadcast_transaction( handle: Handle, - tx_bytes: *const u8, - tx_bytes_len: usize, + tx: *const FFICoreTransaction, + account_type: CoreAccountTypeFFI, + account_index: u32, out_txid: *mut *mut c_char, ) -> PlatformWalletFFIResult { - check_ptr!(tx_bytes); + check_ptr!(tx); check_ptr!(out_txid); - let bytes = std::slice::from_raw_parts(tx_bytes, tx_bytes_len); let tx: dashcore::Transaction = - unwrap_result_or_return!(dashcore::consensus::deserialize(bytes)); + unwrap_result_or_return!(dashcore::consensus::deserialize((*tx).bytes())); let option = CORE_WALLET_STORAGE.with_item(handle, |wallet| { - runtime().block_on(wallet.broadcast_transaction(&tx)) + runtime().block_on(async { + match account_type.as_standard_account_type() { + Some(account_type) => { + wallet + .broadcast_transaction_releasing_reservation( + account_type, + account_index, + &tx, + ) + .await + } + None => wallet.broadcast_transaction(&tx).await, + } + }) }); + let result = unwrap_option_or_return!(option); + let txid = unwrap_result_or_return!(result); let c_str = unwrap_result_or_return!(std::ffi::CString::new(txid.to_string())); *out_txid = c_str.into_raw(); - PlatformWalletFFIResult::ok() -} - -/// Build, sign, and broadcast a payment to the given addresses via -/// an external mnemonic-resolver-backed signer. -/// -/// The Swift caller supplies a [`MnemonicResolverHandle`] — the same -/// vtable shape used by -/// [`crate::dash_sdk_sign_with_mnemonic_resolver_and_path`] — which -/// the FFI wraps in a [`MnemonicResolverCoreSigner`] for the -/// lifetime of this call. Private keys never cross the FFI as raw -/// bytes; every signature is produced inside the signer's atomic -/// derive-and-sign step. -/// -/// # Safety -/// - `core_signer_handle` must be a valid, non-destroyed -/// `*mut MnemonicResolverHandle`. Ownership is retained by the -/// caller — this function does NOT destroy it. -#[no_mangle] -#[allow(clippy::too_many_arguments)] -pub unsafe extern "C" fn core_wallet_send_to_addresses( - handle: Handle, - account_type: u32, - account_index: u32, - addresses: *const *const c_char, - amounts: *const u64, - count: usize, - core_signer_handle: *mut MnemonicResolverHandle, - out_tx_bytes: *mut *mut u8, - out_tx_len: *mut usize, -) -> PlatformWalletFFIResult { - check_ptr!(core_signer_handle); - if count > 0 { - check_ptr!(addresses); - check_ptr!(amounts); - } - 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); - - 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])); - } - - use key_wallet::account::account_type::StandardAccountType; - let std_account_type = match account_type { - 0 => StandardAccountType::BIP44Account, - 1 => StandardAccountType::BIP32Account, - _ => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!("Unknown account type: {account_type}"), - ); - } - }; - let signer_addr = core_signer_handle as usize; - - let option = CORE_WALLET_STORAGE.with_item(handle, |wallet| { - let wallet_id = wallet.wallet_id(); - let network = wallet.network(); - // SAFETY: the resolver handle is pinned alive for the duration - // of this FFI call (see fn-level safety doc). The - // `MnemonicResolverCoreSigner` lives on this stack frame and - // is dropped before the function returns. - let signer = unsafe { - MnemonicResolverCoreSigner::new( - signer_addr as *mut MnemonicResolverHandle, - wallet_id, - network, - ) - }; - runtime().block_on(wallet.send_to_addresses( - std_account_type, - account_index, - outputs, - &signer, - )) - }); - let result = unwrap_option_or_return!(option); - let tx = unwrap_result_or_return!(result); - let serialized = dashcore::consensus::serialize(&tx); - let len = serialized.len(); - let boxed = serialized.into_boxed_slice(); - *out_tx_bytes = Box::into_raw(boxed) as *mut u8; - *out_tx_len = len; PlatformWalletFFIResult::ok() } - -/// Free transaction bytes returned by `core_wallet_send_to_addresses`. -#[no_mangle] -pub unsafe extern "C" fn core_wallet_free_tx_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)); - } -} diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs index c48f9f772a4..8e12ebc1783 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs @@ -4,8 +4,10 @@ mod addresses; mod broadcast; +mod transaction_builder; mod wallet; pub use addresses::*; pub use broadcast::*; +pub use transaction_builder::*; pub use wallet::*; diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs new file mode 100644 index 00000000000..cd4a35aa942 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs @@ -0,0 +1,612 @@ +use crate::core_wallet_types::OutPointFFI; +use crate::error::*; +use crate::handle::{Handle, PLATFORM_WALLET_STORAGE}; +use crate::runtime::runtime; +use crate::types::{FFINetwork, Network}; +use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; +use dashcore::blockdata::transaction::special_transaction::TransactionPayload; +use dashcore::hashes::Hash; +use dashcore::{Address as DashAddress, OutPoint, Transaction, Txid}; +use key_wallet::account::account_type::StandardAccountType; +use key_wallet::account::ManagedAccountCollection; +use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::managed_account::ManagedCoreFundsAccount; +use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy; +use key_wallet::wallet::managed_wallet_info::fee::FeeRate; +use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder; +use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle}; +use std::os::raw::{c_char, c_void}; +use std::str::FromStr; + +/// Opaque, C-compatible transaction builder. `inner` is a heap-boxed +/// key-wallet `TransactionBuilder`; `network` is the wallet network output +/// and change addresses are validated against. +/// +/// NOT thread-safe: a single builder must be used from one thread at a time. +/// The setters mutate `*inner` in place (`take_builder`/`store_builder`) +/// without synchronization. +#[repr(C)] +pub struct FFITransactionBuilder { + inner: *mut c_void, + network: FFINetwork, +} + +/// Broadcast it with `core_wallet_broadcast_transaction`, then release it +/// with `core_wallet_transaction_free`. +#[repr(C)] +pub struct FFICoreTransaction { + tx_bytes: *mut u8, + tx_len: usize, + fee: u64, +} + +impl FFICoreTransaction { + pub(crate) fn bytes(&self) -> &[u8] { + if self.tx_bytes.is_null() || self.tx_len == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(self.tx_bytes, self.tx_len) } + } + } +} + +#[repr(C)] +pub enum CoreAccountTypeFFI { + BIP44, + BIP32, + CoinJoin, +} + +impl From for AccountTypePreference { + fn from(value: CoreAccountTypeFFI) -> Self { + match value { + CoreAccountTypeFFI::BIP44 => AccountTypePreference::BIP44, + CoreAccountTypeFFI::BIP32 => AccountTypePreference::BIP32, + CoreAccountTypeFFI::CoinJoin => AccountTypePreference::CoinJoin, + } + } +} + +impl CoreAccountTypeFFI { + /// The `StandardAccountType` this maps to, or `None` for `CoinJoin`. + /// + /// A CoinJoin-funded build DOES end up with reserved UTXOs — `build_signed` + /// (via `assemble_unsigned`) reserves the selected inputs regardless of + /// account type, since `set_funding` attaches the shared `ReservationSet` + /// for every variant. But `reservations.rs`'s release-on-rejection is + /// defined only over `StandardAccountType` (BIP44/BIP32). Returning `None` + /// here routes CoinJoin through the plain broadcast, so a rejected CoinJoin + /// tx keeps its reservation until the TTL backstop. That is intentional: the + /// only CoinJoin funding path is a sweep — a single sender spending each + /// UTXO exactly once, with no concurrent build or retry to race — so there + /// is nothing to reconcile in practice. + pub(crate) fn as_standard_account_type(&self) -> Option { + match self { + CoreAccountTypeFFI::BIP44 => Some(StandardAccountType::BIP44Account), + CoreAccountTypeFFI::BIP32 => Some(StandardAccountType::BIP32Account), + CoreAccountTypeFFI::CoinJoin => None, + } + } +} + +#[repr(C)] +pub enum CoreSelectionStrategyFFI { + SmallestFirst, + LargestFirst, + BranchAndBound, + OptimalConsolidation, + Random, + All, +} + +impl From for SelectionStrategy { + fn from(value: CoreSelectionStrategyFFI) -> Self { + match value { + CoreSelectionStrategyFFI::SmallestFirst => SelectionStrategy::SmallestFirst, + CoreSelectionStrategyFFI::LargestFirst => SelectionStrategy::LargestFirst, + CoreSelectionStrategyFFI::BranchAndBound => SelectionStrategy::BranchAndBound, + CoreSelectionStrategyFFI::OptimalConsolidation => { + SelectionStrategy::OptimalConsolidation + } + CoreSelectionStrategyFFI::Random => SelectionStrategy::Random, + CoreSelectionStrategyFFI::All => SelectionStrategy::All, + } + } +} + +fn managed_account( + accounts: &ManagedAccountCollection, + source: AccountTypePreference, + account_index: u32, +) -> Option<&ManagedCoreFundsAccount> { + match source { + AccountTypePreference::BIP44 => accounts.standard_bip44_accounts.get(&account_index), + AccountTypePreference::BIP32 => accounts.standard_bip32_accounts.get(&account_index), + AccountTypePreference::CoinJoin => accounts.coinjoin_accounts.get(&account_index), + } +} + +fn managed_account_mut( + accounts: &mut ManagedAccountCollection, + source: AccountTypePreference, + account_index: u32, +) -> Option<&mut ManagedCoreFundsAccount> { + match source { + AccountTypePreference::BIP44 => accounts.standard_bip44_accounts.get_mut(&account_index), + AccountTypePreference::BIP32 => accounts.standard_bip32_accounts.get_mut(&account_index), + AccountTypePreference::CoinJoin => accounts.coinjoin_accounts.get_mut(&account_index), + } +} + +impl FFITransactionBuilder { + /// The inner builder taken out by value, leaving an empty one in its + /// place. Pair with [`FFITransactionBuilder::store_builder`] to apply a + /// fluent (`self -> Self`) method. + /// + /// # Safety + /// `self.inner` must point at a live `TransactionBuilder` + unsafe fn take_builder(&self) -> TransactionBuilder { + std::mem::take(&mut *(self.inner as *mut TransactionBuilder)) + } + + /// Store `builder` back into the inner slot. + /// + /// # Safety + /// `self.inner` must point at a live `TransactionBuilder` + unsafe fn store_builder(&self, builder: TransactionBuilder) { + *(self.inner as *mut TransactionBuilder) = builder; + } +} + +/// Create a new transaction builder for `network`. Free with +/// `core_wallet_tx_builder_destroy` (or `core_wallet_tx_builder_build_signed`, +/// which consumes it). +/// +/// # Safety +/// The returned pointer is owned by the caller. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_tx_builder_new( + network: FFINetwork, +) -> *mut FFITransactionBuilder { + let inner = Box::into_raw(Box::new(TransactionBuilder::new())) as *mut c_void; + Box::into_raw(Box::new(FFITransactionBuilder { inner, network })) +} + +/// # Safety +/// `builder` must be a valid, non-destroyed pointer; `address` a valid NUL-terminated C string. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_tx_builder_add_output( + builder: *mut FFITransactionBuilder, + address: *const c_char, + amount: u64, +) -> PlatformWalletFFIResult { + check_ptr!(builder); + check_ptr!(address); + + let addr_str = unwrap_result_or_return!(std::ffi::CStr::from_ptr(address).to_str()); + let network: Network = (*builder).network.into(); + let parsed = unwrap_result_or_return!(DashAddress::from_str(addr_str)); + let address = match parsed.require_network(network) { + Ok(a) => a, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("output address network mismatch: {e}"), + ); + } + }; + + let b = (*builder).take_builder(); + let b = b.add_output(&address, amount); + (*builder).store_builder(b); + + PlatformWalletFFIResult::ok() +} + +/// # Safety +/// `builder` must be a valid, non-destroyed pointer; `address` a valid NUL-terminated C string. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_tx_builder_set_change_address( + builder: *mut FFITransactionBuilder, + address: *const c_char, +) -> PlatformWalletFFIResult { + check_ptr!(builder); + check_ptr!(address); + + let addr_str = unwrap_result_or_return!(std::ffi::CStr::from_ptr(address).to_str()); + let network: Network = (*builder).network.into(); + let parsed = unwrap_result_or_return!(DashAddress::from_str(addr_str)); + let address = match parsed.require_network(network) { + Ok(a) => a, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("change address network mismatch: {e}"), + ); + } + }; + + let b = (*builder).take_builder(); + let b = b.set_change_address(address); + (*builder).store_builder(b); + + PlatformWalletFFIResult::ok() +} + +/// # Safety +/// `builder` must be a valid, non-destroyed pointer. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_tx_builder_set_fee_rate( + builder: *mut FFITransactionBuilder, + sat_per_kb: u64, +) -> PlatformWalletFFIResult { + check_ptr!(builder); + + let b = (*builder).take_builder(); + let b = b.set_fee_rate(FeeRate::new(sat_per_kb)); + (*builder).store_builder(b); + + PlatformWalletFFIResult::ok() +} + +/// # Safety +/// `builder` must be a valid, non-destroyed pointer. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_tx_builder_set_selection_strategy( + builder: *mut FFITransactionBuilder, + strategy: CoreSelectionStrategyFFI, +) -> PlatformWalletFFIResult { + check_ptr!(builder); + + let b = (*builder).take_builder(); + let b = b.set_selection_strategy(strategy.into()); + (*builder).store_builder(b); + + PlatformWalletFFIResult::ok() +} + +/// Set the block height coin selection treats as the chain tip (used for +/// coinbase maturity and locktime). +/// +/// This value is advisory: `core_wallet_tx_builder_set_funding` and +/// `core_wallet_tx_builder_build_signed` both override it with the wallet's +/// last processed height when they run, so the wallet height always wins for +/// the funded/signed build. Use this only when building without a wallet. +/// +/// # Safety +/// `builder` must be a valid, non-destroyed pointer. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_tx_builder_set_current_height( + builder: *mut FFITransactionBuilder, + height: u32, +) -> PlatformWalletFFIResult { + check_ptr!(builder); + + let b = (*builder).take_builder(); + let b = b.set_current_height(height); + (*builder).store_builder(b); + + PlatformWalletFFIResult::ok() +} + +/// `payload_bytes` is a bincode-encoded `TransactionPayload`. +/// +/// # Safety +/// `builder` must be a valid, non-destroyed pointer; `payload_bytes` a readable buffer of +/// `payload_len` bytes. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_tx_builder_set_special_payload( + builder: *mut FFITransactionBuilder, + payload_bytes: *const u8, + payload_len: usize, +) -> PlatformWalletFFIResult { + check_ptr!(builder); + check_ptr!(payload_bytes); + + let bytes = std::slice::from_raw_parts(payload_bytes, payload_len); + let payload: TransactionPayload = + match bincode::decode_from_slice(bytes, bincode::config::standard()) { + Ok((p, consumed)) => { + if consumed != payload_len { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorDeserialization, + format!( + "trailing bytes after payload: decoded {consumed} of {payload_len} bytes" + ), + ); + } + p + } + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorDeserialization, + format!("invalid special payload: {e}"), + ); + } + }; + + let b = (*builder).take_builder(); + let b = b.set_special_payload(payload); + (*builder).store_builder(b); + + PlatformWalletFFIResult::ok() +} + +/// Fund the builder from the wallet account, setting inputs and change. +/// +/// # Concurrency limitation (known, intentionally not fixed here) +/// key-wallet's `set_funding` filters out UTXOs already recorded in the +/// account's shared `ReservationSet`, but the reservation for *this* build is +/// only taken at build time (`assemble_unsigned` inside `build_signed`). +/// Because the FFI splits `set_funding` and `build_signed` across the C ABI — +/// the wallet lock cannot be held across the boundary — two concurrent builds +/// on the SAME account can both pass `set_funding` before either reserves and +/// select the same UTXO, producing a double-spend at broadcast. Single-threaded +/// callers (the SDK's send flow) are unaffected; concurrent same-account sends +/// must serialize at the call site. +/// +/// # Safety +/// `builder` must be a valid, non-destroyed pointer; `wallet` a valid +/// platform-wallet handle. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_tx_builder_set_funding( + builder: *mut FFITransactionBuilder, + wallet: Handle, + account_type: CoreAccountTypeFFI, + account_index: u32, +) -> PlatformWalletFFIResult { + check_ptr!(builder); + + let wallet = unwrap_option_or_return!(PLATFORM_WALLET_STORAGE.with_item(wallet, |w| w.clone())); + + // Reject a builder created for a different network than the wallet. + let builder_network: Network = (*builder).network.into(); + if builder_network != wallet.network() { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "builder network does not match wallet network".to_string(), + ); + } + + let wallet_id = wallet.wallet_id(); + let source: AccountTypePreference = account_type.into(); + + let result = runtime().block_on(async { + let mut wm = wallet.wallet_manager().write().await; + let (w, info) = wm + .get_wallet_and_info_mut(&wallet_id) + .ok_or_else(|| "wallet not found".to_string())?; + + let account = match source { + AccountTypePreference::BIP44 => w.get_bip44_account(account_index), + AccountTypePreference::BIP32 => w.get_bip32_account(account_index), + AccountTypePreference::CoinJoin => w.get_coinjoin_account(account_index), + } + .ok_or_else(|| format!("wallet account {source:?} #{account_index} not found"))?; + + let height = info.core_wallet.last_processed_height(); + + let managed = managed_account_mut(&mut info.core_wallet.accounts, source, account_index) + .ok_or_else(|| format!("managed account {source:?} #{account_index} not found"))?; + + // Resolution succeeded — only now consume the builder so a lookup + // failure above can never leave it emptied. + let taken = (*builder).take_builder(); + let funded = taken + .set_current_height(height) + .set_funding(managed, account); + (*builder).store_builder(funded); + Ok::<_, String>(()) + }); + + match result { + Ok(()) => PlatformWalletFFIResult::ok(), + Err(e) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("set_funding failed: {e}"), + ), + } +} + +/// Add a caller-chosen subset of the account's UTXOs as inputs. `outpoints` +/// are selected from the account's own UTXO set (the same ones +/// `platform_wallet_account_utxos` returns). An outpoint not owned by the +/// account is an error +/// +/// # Safety +/// `builder` must be a valid, non-destroyed pointer; `wallet` a valid platform-wallet handle; +/// `outpoints` a readable array of `outpoints_len` elements. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_tx_builder_add_inputs_from_outpoints( + builder: *mut FFITransactionBuilder, + wallet: Handle, + account_type: CoreAccountTypeFFI, + account_index: u32, + outpoints: *const OutPointFFI, + outpoints_len: usize, +) -> PlatformWalletFFIResult { + check_ptr!(builder); + + let wallet = unwrap_option_or_return!(PLATFORM_WALLET_STORAGE.with_item(wallet, |w| w.clone())); + + // Reject a builder created for a different network than the wallet, matching + // `set_funding` / `build_signed` so all three wallet-aware entry points fail + // fast instead of mutating a foreign-network builder. + let builder_network: Network = (*builder).network.into(); + if builder_network != wallet.network() { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "builder network does not match wallet network".to_string(), + ); + } + + let wallet_id = wallet.wallet_id(); + let source: AccountTypePreference = account_type.into(); + + let requested: Vec = if outpoints_len == 0 { + Vec::new() + } else { + check_ptr!(outpoints); + std::slice::from_raw_parts(outpoints, outpoints_len) + .iter() + .map(|op| OutPoint { + txid: Txid::from_byte_array(op.txid), + vout: op.vout, + }) + .collect() + }; + + let result = runtime().block_on(async { + let wm = wallet.wallet_manager().read().await; + let info = wm + .get_wallet_info(&wallet_id) + .ok_or_else(|| "wallet not found".to_string())?; + + let managed = managed_account(&info.core_wallet.accounts, source, account_index) + .ok_or_else(|| format!("managed account {source:?} #{account_index} not found"))?; + + let mut selected = Vec::with_capacity(requested.len()); + for op in &requested { + let utxo = managed + .utxos + .get(op) + .cloned() + .ok_or_else(|| format!("outpoint {}:{} not in account", op.txid, op.vout))?; + selected.push(utxo); + } + + // Validation succeeded — only now consume the builder. + let taken = (*builder).take_builder(); + (*builder).store_builder(taken.add_inputs(selected)); + Ok::<_, String>(()) + }); + + match result { + Ok(()) => PlatformWalletFFIResult::ok(), + Err(e) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("add_inputs_from_outpoints failed: {e}"), + ), + } +} + +/// Build and sign, resolving signing paths from the wallet account. Returns +/// consensus-serialized signed bytes and the fee. +/// +/// This function also frees the builder +/// +/// # Safety +/// `builder` must be a valid, non-destroyed pointer; `wallet` a valid platform-wallet handle; +/// `core_signer_handle` a valid, non-destroyed resolver handle; `out_tx` a +/// writable pointer the caller later frees with `core_wallet_transaction_free`. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn core_wallet_tx_builder_build_signed( + builder: *mut FFITransactionBuilder, + wallet: Handle, + account_type: CoreAccountTypeFFI, + account_index: u32, + core_signer_handle: *mut MnemonicResolverHandle, + out_tx: *mut FFICoreTransaction, +) -> PlatformWalletFFIResult { + check_ptr!(builder); + // `build` consumes the builder: reclaim both heap boxes up front so they + // are freed on every return path below + let ffi = Box::from_raw(builder); + let inner = *Box::from_raw(ffi.inner as *mut TransactionBuilder); + + check_ptr!(core_signer_handle); + check_ptr!(out_tx); + + let wallet = unwrap_option_or_return!(PLATFORM_WALLET_STORAGE.with_item(wallet, |w| w.clone())); + + // Backstop network check: reject a builder built for a different network + // than the wallet, even when `set_funding` already validated it. + let builder_network: Network = ffi.network.into(); + if builder_network != wallet.network() { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "builder network does not match wallet network".to_string(), + ); + } + + let wallet_id = wallet.wallet_id(); + let source: AccountTypePreference = account_type.into(); + let signer = MnemonicResolverCoreSigner::new(core_signer_handle, wallet_id, wallet.network()); + + let build = runtime().block_on(async { + let wm = wallet.wallet_manager().read().await; + let info = wm + .get_wallet_info(&wallet_id) + .ok_or_else(|| "wallet not found".to_string())?; + + let height = info.core_wallet.last_processed_height(); + + let managed = managed_account(&info.core_wallet.accounts, source, account_index) + .ok_or_else(|| format!("managed account {source:?} #{account_index} not found"))?; + + inner + .set_current_height(height) + .build_signed(&signer, |addr| managed.address_derivation_path(&addr)) + .await + .map_err(|e| e.to_string()) + }); + + let (tx, fee): (Transaction, u64) = match build { + Ok(v) => v, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("transaction build failed: {e}"), + ); + } + }; + + let serialized = dashcore::consensus::serialize(&tx); + let len = serialized.len(); + + *out_tx = FFICoreTransaction { + tx_bytes: Box::into_raw(serialized.into_boxed_slice()) as *mut u8, + tx_len: len, + fee, + }; + + PlatformWalletFFIResult::ok() +} + +/// Destroy a transaction builder created by `core_wallet_tx_builder_new`. +/// +/// # Safety +/// `builder` must not have already been destroyed or built (or null). +#[no_mangle] +pub unsafe extern "C" fn core_wallet_tx_builder_destroy(builder: *mut FFITransactionBuilder) { + if builder.is_null() { + return; + } + + let b = Box::from_raw(builder); + let _ = Box::from_raw(b.inner as *mut TransactionBuilder); +} + +/// Free a transaction returned by `core_wallet_tx_builder_build_signed`. +/// Idempotent: the fields are nulled, so a second call is a no-op. +/// +/// # Safety +/// `tx` must be a valid pointer to an `FFICoreTransaction` from +/// `core_wallet_tx_builder_build_signed` (or null). +#[no_mangle] +pub unsafe extern "C" fn core_wallet_transaction_free(tx: *mut FFICoreTransaction) { + if tx.is_null() { + return; + } + + let tx = &mut *tx; + if !tx.tx_bytes.is_null() && tx.tx_len > 0 { + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(tx.tx_bytes, tx.tx_len)); + } + + tx.tx_bytes = std::ptr::null_mut(); + tx.tx_len = 0; +} diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index a15ce6db8a3..d8b9b7e8c2f 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -1,7 +1,5 @@ -use dashcore::{Address as DashAddress, Transaction}; +use dashcore::Transaction; use key_wallet::account::account_type::StandardAccountType; -use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; -use key_wallet::signer::Signer; use crate::broadcaster::TransactionBroadcaster; use crate::wallet::reservations::broadcast_releasing_on_rejection; @@ -10,14 +8,21 @@ use crate::{CoreWallet, PlatformWalletError}; impl CoreWallet { /// Broadcast a signed transaction to the network. /// - /// Build the transaction using key-wallet's - /// [`TransactionBuilder`](key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder), - /// then pass the result here for broadcasting. + /// Transactions can be built and signed with key-wallet's + /// [`TransactionBuilder`](key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder) + /// before being passed here; this method only broadcasts the + /// caller-supplied signed transaction. /// /// Delegates to the injected [`TransactionBroadcaster`] which may use /// SPV (P2P) or DAPI (gRPC) depending on how the wallet was constructed. /// /// Returns the transaction ID on success. + /// + /// This plain form does **not** reconcile the funding account's UTXO + /// reservation on failure. Prefer + /// [`broadcast_transaction_releasing_reservation`](Self::broadcast_transaction_releasing_reservation) + /// for the build-then-broadcast send path, where a `build_signed` + /// reserved the selected inputs and a failed broadcast must release them. pub async fn broadcast_transaction( &self, transaction: &Transaction, @@ -28,134 +33,35 @@ impl CoreWallet { .map_err(Into::into) } - /// Build, sign, and broadcast a payment to the given addresses. - /// - /// Uses key-wallet's - /// [`TransactionBuilder`](key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder) - /// for UTXO selection, fee estimation, and signing. Change is sent to - /// the next internal address of the specified account. + /// Broadcast a signed transaction, reconciling the funding account's UTXO + /// reservation on failure. /// - /// Signing is delegated to the caller-supplied - /// [`Signer`](key_wallet::signer::Signer) via the - /// `impl TransactionSigner for S` blanket in - /// `key-wallet`'s `transaction_builder.rs`. For Swift wallets this - /// is typically a - /// [`MnemonicResolverCoreSigner`](crate::wallet::asset_lock::build) - /// from `platform-wallet-ffi`, backed by the Keychain-resolver - /// vtable so private keys never cross the FFI boundary. + /// `build_signed` reserves the selected inputs in the funding account's + /// `ReservationSet` and leaves the reservation held on success (expecting + /// this broadcast). On a definitive rejection the reservation is released + /// so an immediate retry can reselect those inputs; on an ambiguous + /// failure it is kept. See + /// [`broadcast_releasing_on_rejection`](crate::wallet::reservations::broadcast_releasing_on_rejection) + /// for the full rationale. /// - /// **Note (smell):** the body of this method is a near-duplicate of - /// `ManagedWalletInfo::build_and_sign_transaction` in `key-wallet` - /// (`wallet/managed_wallet_info/transaction_building.rs`). - /// It's reimplemented here because the upstream helper is BIP-44-only, - /// parametrizing upstream on `AccountTypePreference` so it picks - /// `standard_bip{32,44}_accounts` would be a trivial change - pub async fn send_to_addresses( + /// `account_type`/`account_index` identify the funding account handed to + /// `set_funding` when the transaction was built. + pub async fn broadcast_transaction_releasing_reservation( &self, account_type: StandardAccountType, account_index: u32, - outputs: Vec<(DashAddress, u64)>, - signer: &S, - ) -> Result { - use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy; - use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder; - use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; - - if outputs.is_empty() { - return Err(PlatformWalletError::TransactionBuild( - "No outputs specified".to_string(), - )); - } - - let tx = { - let mut wm = self.wallet_manager.write().await; - let (wallet, info) = wm.get_wallet_and_info_mut(&self.wallet_id).ok_or_else(|| { - crate::error::PlatformWalletError::WalletNotFound( - "Wallet not found in wallet manager".to_string(), - ) - })?; - - let current_height = info.core_wallet.synced_height(); - - let (managed_account, account) = match account_type { - StandardAccountType::BIP44Account => ( - info.core_wallet - .accounts - .standard_bip44_accounts - .get_mut(&account_index) - .ok_or_else(|| { - PlatformWalletError::TransactionBuild(format!( - "{:?} managed account {} not found", - account_type, account_index - )) - })?, - wallet - .accounts - .standard_bip44_accounts - .get(&account_index) - .ok_or_else(|| { - PlatformWalletError::TransactionBuild(format!( - "{:?} account {} not found in wallet", - account_type, account_index - )) - })?, - ), - StandardAccountType::BIP32Account => ( - info.core_wallet - .accounts - .standard_bip32_accounts - .get_mut(&account_index) - .ok_or_else(|| { - PlatformWalletError::TransactionBuild(format!( - "{:?} managed account {} not found", - account_type, account_index - )) - })?, - wallet - .accounts - .standard_bip32_accounts - .get(&account_index) - .ok_or_else(|| { - PlatformWalletError::TransactionBuild(format!( - "{:?} account {} not found in wallet", - account_type, account_index - )) - })?, - ), - }; - - // The blanket `impl TransactionSigner for S` in - // `key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs:482` - // makes the signer drop-in for the previously `Wallet`-backed - // path; the funds-derived `address_derivation_path` lookup is - // unchanged. - let mut builder = TransactionBuilder::new() - .set_current_height(current_height) - .set_selection_strategy(SelectionStrategy::LargestFirst) - .set_funding(managed_account, account); - for (addr, amount) in &outputs { - builder = builder.add_output(addr, *amount); - } - - let (tx, _fee) = builder - .build_signed(signer, |addr| { - managed_account.address_derivation_path(&addr) - }) - .await - .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; - tx - }; - + transaction: &Transaction, + ) -> Result { broadcast_releasing_on_rejection( self.broadcaster.as_ref(), &self.wallet_manager, &self.wallet_id, account_type, account_index, - &tx, + transaction, ) - .await?; - Ok(tx) + .await + .map_err(Into::into) } } @@ -163,8 +69,13 @@ impl CoreWallet { mod tests { use std::sync::Arc; - use dashcore::{Address as DashAddress, Network}; + use dashcore::{Address as DashAddress, Network, Transaction}; use key_wallet::account::account_type::StandardAccountType; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::signer::Signer; + use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy; + use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder; + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use crate::broadcaster::TransactionBroadcaster; use crate::test_support::{ @@ -191,12 +102,74 @@ mod tests { (core, signer, outputs) } - /// A pre-send broadcast failure must release the UTXO reservation taken while - /// building the transaction, so an immediate retry can reselect those inputs - /// instead of failing with spurious insufficient funds until the TTL backstop. - /// Covers both funds-account arms of the release path. + /// Build and sign a payment the way the split send path does: `build_signed` + /// reserves the selected inputs in the funding account's `ReservationSet`, + /// leaving the reservation held for the subsequent broadcast. Mirrors the + /// FFI `core_wallet_tx_builder_*` sequence. + async fn build_signed_tx( + core: &CoreWallet, + account_type: StandardAccountType, + account_index: u32, + outputs: &[(DashAddress, u64)], + signer: &S, + ) -> Result { + let mut wm = core.wallet_manager.write().await; + let (wallet, info) = wm + .get_wallet_and_info_mut(&core.wallet_id()) + .expect("wallet present in manager"); + + let current_height = info.core_wallet.synced_height(); + + let (managed_account, account) = match account_type { + StandardAccountType::BIP44Account => ( + info.core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&account_index) + .expect("bip44 managed account"), + wallet + .accounts + .standard_bip44_accounts + .get(&account_index) + .expect("bip44 account"), + ), + StandardAccountType::BIP32Account => ( + info.core_wallet + .accounts + .standard_bip32_accounts + .get_mut(&account_index) + .expect("bip32 managed account"), + wallet + .accounts + .standard_bip32_accounts + .get(&account_index) + .expect("bip32 account"), + ), + }; + + let mut builder = TransactionBuilder::new() + .set_current_height(current_height) + .set_selection_strategy(SelectionStrategy::LargestFirst) + .set_funding(managed_account, account); + for (addr, amount) in outputs { + builder = builder.add_output(addr, *amount); + } + + let (tx, _fee) = builder + .build_signed(signer, |addr| { + managed_account.address_derivation_path(&addr) + }) + .await + .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; + Ok(tx) + } + + /// A pre-send broadcast rejection must release the UTXO reservation taken + /// while building the transaction, so an immediate retry can reselect those + /// inputs instead of failing with spurious insufficient funds until the TTL + /// backstop. Covers both funds-account arms of the release path. #[tokio::test] - async fn send_to_addresses_releases_reservation_on_broadcast_failure() { + async fn broadcast_releases_reservation_on_rejection() { for account_type in [ StandardAccountType::BIP44Account, StandardAccountType::BIP32Account, @@ -204,25 +177,37 @@ mod tests { let broadcaster = Arc::new(RejectFirstBroadcaster::new()); let (core, signer, outputs) = funded_core_wallet(account_type, broadcaster).await; - // First attempt: build + sign succeed, broadcast fails. + // First attempt: build + sign reserve the input, broadcast is rejected. + let tx = build_signed_tx(&core, account_type, 0, &outputs, &signer) + .await + .expect("first build should succeed"); let first = core - .send_to_addresses(account_type, 0, outputs.clone(), &signer) + .broadcast_transaction_releasing_reservation(account_type, 0, &tx) .await; assert!( matches!(first, Err(PlatformWalletError::TransactionBroadcast(_))), - "first send should surface the broadcast failure for {account_type:?}, got {first:?}" + "first broadcast should surface the rejection for {account_type:?}, got {first:?}" ); - // Immediate retry: only succeeds if the failed broadcast released the - // reservation. With the leak, coin selection sees no spendable UTXO and - // this fails with a build error instead. + // Immediate retry: the build only succeeds if the failed broadcast + // released the reservation. With the leak, coin selection sees no + // spendable UTXO and the build fails. + let retry_tx = build_signed_tx(&core, account_type, 0, &outputs, &signer).await; + assert!( + retry_tx.is_ok(), + "retry build after a released reservation should succeed for \ + {account_type:?}, got {retry_tx:?}" + ); let second = core - .send_to_addresses(account_type, 0, outputs, &signer) + .broadcast_transaction_releasing_reservation( + account_type, + 0, + &retry_tx.expect("retry tx"), + ) .await; assert!( second.is_ok(), - "retry after a failed broadcast should succeed once the reservation \ - is released for {account_type:?}, got {second:?}" + "retry broadcast should succeed for {account_type:?}, got {second:?}" ); } } @@ -232,7 +217,7 @@ mod tests { /// double-spend. The reservation is kept, so an immediate retry fails at the /// build stage (no spendable UTXO) rather than reaching broadcast again. #[tokio::test] - async fn send_to_addresses_keeps_reservation_on_ambiguous_broadcast_failure() { + async fn broadcast_keeps_reservation_on_ambiguous_failure() { for account_type in [ StandardAccountType::BIP44Account, StandardAccountType::BIP32Account, @@ -240,26 +225,28 @@ mod tests { let broadcaster = Arc::new(AlwaysMaybeSentBroadcaster); let (core, signer, outputs) = funded_core_wallet(account_type, broadcaster).await; + let tx = build_signed_tx(&core, account_type, 0, &outputs, &signer) + .await + .expect("first build should succeed"); let first = core - .send_to_addresses(account_type, 0, outputs.clone(), &signer) + .broadcast_transaction_releasing_reservation(account_type, 0, &tx) .await; assert!( matches!( first, Err(PlatformWalletError::TransactionBroadcastUnconfirmed(_)) ), - "first send should surface the ambiguous failure for {account_type:?}, got {first:?}" + "first broadcast should surface the ambiguous failure for \ + {account_type:?}, got {first:?}" ); // Reservation kept: the retry cannot reselect the reserved input and // fails while building, never reaching the broadcaster again. - let second = core - .send_to_addresses(account_type, 0, outputs, &signer) - .await; + let second = build_signed_tx(&core, account_type, 0, &outputs, &signer).await; assert!( matches!(second, Err(PlatformWalletError::TransactionBuild(_))), - "retry after an ambiguous failure must fail at build with the reservation \ - kept for {account_type:?}, got {second:?}" + "retry build must fail with the reservation kept for \ + {account_type:?}, got {second:?}" ); } } diff --git a/packages/rs-platform-wallet/src/wallet/core/wallet.rs b/packages/rs-platform-wallet/src/wallet/core/wallet.rs index 8e83fd6b947..8cc0488ade9 100644 --- a/packages/rs-platform-wallet/src/wallet/core/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/core/wallet.rs @@ -7,6 +7,9 @@ use super::balance::WalletBalance; use dashcore::Address as DashAddress; use tokio::sync::RwLock; +use key_wallet::managed_account::address_pool::KeySource; +use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use key_wallet_manager::WalletManager; use crate::broadcaster::TransactionBroadcaster; @@ -63,6 +66,58 @@ impl CoreWallet { self.wallet_id } + pub async fn set_gap_limit( + &self, + account_type: AccountTypePreference, + account_index: u32, + gap_limit: u32, + ) -> Result<(), PlatformWalletError> { + let mut wm = self.wallet_manager.write().await; + let (wallet, info) = wm.get_wallet_and_info_mut(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound("Wallet not found in wallet manager".to_string()) + })?; + + let xpub = match account_type { + AccountTypePreference::BIP44 => wallet.get_bip44_account(account_index), + AccountTypePreference::BIP32 => wallet.get_bip32_account(account_index), + AccountTypePreference::CoinJoin => wallet.get_coinjoin_account(account_index), + } + .map(|a| a.account_xpub) + .ok_or_else(|| { + PlatformWalletError::WalletNotFound(format!( + "wallet account {account_type:?} #{account_index} not found" + )) + })?; + + let account = match account_type { + AccountTypePreference::BIP44 => info + .core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&account_index), + AccountTypePreference::BIP32 => info + .core_wallet + .accounts + .standard_bip32_accounts + .get_mut(&account_index), + AccountTypePreference::CoinJoin => info + .core_wallet + .accounts + .coinjoin_accounts + .get_mut(&account_index), + } + .ok_or_else(|| { + PlatformWalletError::WalletNotFound(format!( + "managed account {account_type:?} #{account_index} not found" + )) + })?; + + account + .set_gap_limit(gap_limit, &KeySource::Public(xpub)) + .map(|_| ()) + .map_err(|e| PlatformWalletError::AddressOperation(e.to_string())) + } + /// Get the next unused BIP-44 external (receive) address for a specific account. pub async fn next_receive_address_for_account( &self, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift new file mode 100644 index 00000000000..ea08eb858bb --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift @@ -0,0 +1,225 @@ +import Foundation +import DashSDKFFI + +/// A built, signed core transaction. Broadcast it via +/// `ManagedCoreWallet.broadcastTransaction`; its bytes are freed when this +/// object is released. +public final class CoreTransaction { + var ffi: FFICoreTransaction + + /// The account that funded this transaction, captured at build time. + /// `broadcastTransaction` forwards it so a failed broadcast can release + /// the UTXO reservation `buildSigned` took on that account. + let accountType: CoreTransactionBuilder.AccountType + let accountIndex: UInt32 + + init( + ffi: FFICoreTransaction, + accountType: CoreTransactionBuilder.AccountType, + accountIndex: UInt32 + ) { + self.ffi = ffi + self.accountType = accountType + self.accountIndex = accountIndex + } + + deinit { withUnsafeMutablePointer(to: &ffi) { core_wallet_transaction_free($0) } } + + /// Network fee in duffs. + public var fee: UInt64 { ffi.fee } + + /// Consensus-serialized signed transaction bytes (copied out). + public var data: Data { + guard let p = ffi.tx_bytes, ffi.tx_len > 0 else { return Data() } + return Data(bytes: p, count: Int(ffi.tx_len)) + } +} + +/// key-wallet transaction builder over FFI. Build step by step, `buildSigned`, +/// then broadcast separately via `ManagedCoreWallet.broadcastTransaction`. +public final class CoreTransactionBuilder { + public enum AccountType { + case bip44 + case bip32 + case coinJoin + + var ffi: CoreAccountTypeFFI { + switch self { + case .bip44: return CORE_ACCOUNT_TYPE_FFI_BIP44 + case .bip32: return CORE_ACCOUNT_TYPE_FFI_BIP32 + case .coinJoin: return CORE_ACCOUNT_TYPE_FFI_COIN_JOIN + } + } + } + + /// Mirrors key-wallet's `SelectionStrategy`. `all` drains the account. + public enum SelectionStrategy { + case smallestFirst + case largestFirst + case branchAndBound + case optimalConsolidation + case random + case all + + var ffi: CoreSelectionStrategyFFI { + switch self { + case .smallestFirst: return CORE_SELECTION_STRATEGY_FFI_SMALLEST_FIRST + case .largestFirst: return CORE_SELECTION_STRATEGY_FFI_LARGEST_FIRST + case .branchAndBound: return CORE_SELECTION_STRATEGY_FFI_BRANCH_AND_BOUND + case .optimalConsolidation: return CORE_SELECTION_STRATEGY_FFI_OPTIMAL_CONSOLIDATION + case .random: return CORE_SELECTION_STRATEGY_FFI_RANDOM + case .all: return CORE_SELECTION_STRATEGY_FFI_ALL + } + } + } + + private let handle: UnsafeMutablePointer + /// Set once `buildSigned` has consumed the builder, so `deinit` skips the + /// Rust-side destroy. + private var consumed = false + + /// `network` is the wallet network; output and change addresses are + /// validated against it. + public init(network: Network) throws { + guard let handle = core_wallet_tx_builder_new(network.ffiValue) else { + throw PlatformWalletError.nullPointer("core_wallet_tx_builder_new returned NULL") + } + self.handle = handle + } + + deinit { + if !consumed { + core_wallet_tx_builder_destroy(handle) + } + } + + /// Fund from the account's UTXOs and set its change address. + @discardableResult + public func setFunding( + wallet: ManagedPlatformWallet, + accountType: AccountType, + accountIndex: UInt32 + ) throws -> CoreTransactionBuilder { + try core_wallet_tx_builder_set_funding( + handle, wallet.handle, accountType.ffi, accountIndex + ).check() + return self + } + + /// Add a chosen subset of the account's UTXOs (as returned by + /// `PlatformWalletManager.accountUtxos`) as inputs. Each must belong to + /// the account. + @discardableResult + public func addInputs( + wallet: ManagedPlatformWallet, + accountType: AccountType, + accountIndex: UInt32, + utxos: [PlatformWalletManager.AccountUtxo] + ) throws -> CoreTransactionBuilder { + var ffiOutpoints = [OutPointFFI]() + ffiOutpoints.reserveCapacity(utxos.count) + for utxo in utxos { + guard utxo.outpointTxid.count == 32 else { + throw PlatformWalletError.unknown( + "outpoint txid must be 32 bytes, got \(utxo.outpointTxid.count)" + ) + } + var entry = OutPointFFI() + withUnsafeMutableBytes(of: &entry.txid) { dst in + _ = utxo.outpointTxid.copyBytes(to: dst.bindMemory(to: UInt8.self)) + } + entry.vout = utxo.outpointVout + ffiOutpoints.append(entry) + } + + try ffiOutpoints.withUnsafeBufferPointer { buf in + try core_wallet_tx_builder_add_inputs_from_outpoints( + handle, wallet.handle, accountType.ffi, accountIndex, + buf.baseAddress, UInt(buf.count) + ).check() + } + return self + } + + @discardableResult + public func addOutput(address: String, amountDuffs: UInt64) throws -> CoreTransactionBuilder { + let c = strdup(address) + defer { free(c) } + try core_wallet_tx_builder_add_output(handle, c, amountDuffs).check() + return self + } + + @discardableResult + public func setChangeAddress(_ address: String) throws -> CoreTransactionBuilder { + let c = strdup(address) + defer { free(c) } + try core_wallet_tx_builder_set_change_address(handle, c).check() + return self + } + + @discardableResult + public func setFeeRate(satPerKb: UInt64) throws -> CoreTransactionBuilder { + try core_wallet_tx_builder_set_fee_rate(handle, satPerKb).check() + return self + } + + @discardableResult + public func setSelectionStrategy(_ strategy: SelectionStrategy) throws -> CoreTransactionBuilder { + try core_wallet_tx_builder_set_selection_strategy(handle, strategy.ffi).check() + return self + } + + @discardableResult + public func setCurrentHeight(_ height: UInt32) throws -> CoreTransactionBuilder { + try core_wallet_tx_builder_set_current_height(handle, height).check() + return self + } + + /// `bincodeBytes` is a bincode-encoded `TransactionPayload`. + @discardableResult + public func setSpecialPayload(_ bincodeBytes: Data) throws -> CoreTransactionBuilder { + try bincodeBytes.withUnsafeBytes { buf in + try core_wallet_tx_builder_set_special_payload( + handle, + buf.baseAddress?.assumingMemoryBound(to: UInt8.self), + UInt(bincodeBytes.count) + ).check() + } + return self + } + + /// Build and sign against the account; returns the signed transaction + /// without broadcasting. Consumes the builder — it is freed on the Rust + /// side and this instance must not be reused afterwards. + public func buildSigned( + wallet: ManagedPlatformWallet, + accountType: AccountType, + accountIndex: UInt32 + ) throws -> CoreTransaction { + guard !consumed else { + throw PlatformWalletError.unknown("CoreTransactionBuilder already consumed") + } + var out = FFICoreTransaction(tx_bytes: nil, tx_len: 0, fee: 0) + + let resolver = MnemonicResolver() + let result = withExtendedLifetime(resolver) { + core_wallet_tx_builder_build_signed( + handle, + wallet.handle, + accountType.ffi, + accountIndex, + resolver.handle, + &out + ) + } + // The FFI frees the builder on every path, so mark consumed before the check. + consumed = true + try result.check() + + guard out.tx_bytes != nil, out.tx_len > 0 else { + throw PlatformWalletError.unknown("FFI returned success but tx buffer was empty") + } + + return CoreTransaction(ffi: out, accountType: accountType, accountIndex: accountIndex) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift index 4e45600e01b..3f5793025c7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift @@ -86,93 +86,30 @@ public class ManagedCoreWallet { return String(cString: ptr) } - // MARK: - Transactions - - /// Account type for transaction building. - public enum AccountType: UInt32 { - case bip44 = 0 - case bip32 = 1 + /// Widen the gap limit for an account, generating the addresses the wider + /// limit now requires. + public func setGapLimit( + accountType: CoreTransactionBuilder.AccountType, + accountIndex: UInt32, + gapLimit: UInt32 + ) throws { + try core_wallet_set_gap_limit(handle, accountType.ffi, accountIndex, gapLimit).check() } - /// Build, sign, and broadcast a payment to the given addresses. - /// - /// Returns the serialized signed transaction. - public func sendToAddresses( - accountType: AccountType = .bip44, - accountIndex: UInt32 = 0, - recipients: [(address: String, amountDuffs: UInt64)] - ) throws -> Data { - var txBytesPtr: UnsafeMutablePointer? = nil - var txLen: UInt = 0 - - // Own the C-string storage explicitly. The naive shape - // `recipients.map { ($0.address as NSString).utf8String }` - // yields pointers into a bridged `NSString` temporary whose - // lifetime ends with the `.map` closure — Rust would then - // `CStr::from_ptr` against freed (or autorelease-pool- - // recycled) memory. `strdup` malloc's a fresh NUL-terminated - // copy we own through the FFI call; `defer` frees them after - // Rust returns. - let cStringStorage: [UnsafeMutablePointer?] = recipients.map { - strdup($0.address) - } - defer { - for ptr in cStringStorage { - if let p = ptr { free(p) } - } - } - // Promote the owned buffers to immutable `UnsafePointer?` - // for the FFI's `*const *const c_char` signature. No - // `assumingMemoryBound` re-interpretation needed — the bytes - // are already correctly typed. - let cStringPointers: [UnsafePointer?] = cStringStorage.map { ptr in - ptr.map { UnsafePointer($0) } - } - let amounts = recipients.map { $0.amountDuffs } - - // Resolver-backed signer owns mnemonic access for the lifetime - // of this call. Each Core ECDSA signature happens atomically - // inside the resolver vtable (mnemonic fetched, key derived, - // digest signed, buffers zeroed) — no priv key leaves Swift. - let resolver = MnemonicResolver() - - try cStringPointers.withUnsafeBufferPointer { addrBuf in - try amounts.withUnsafeBufferPointer { amountBuf in - try withExtendedLifetime(resolver) { - try core_wallet_send_to_addresses( - handle, - accountType.rawValue, - accountIndex, - addrBuf.baseAddress, - amountBuf.baseAddress, - UInt(recipients.count), - resolver.handle, - &txBytesPtr, - &txLen - ).check() - } - } - } - - guard let ptr = txBytesPtr, txLen > 0 else { - throw PlatformWalletError.unknown("FFI returned success but tx buffer was empty") - } - defer { core_wallet_free_tx_bytes(ptr, txLen) } - - return Data(bytes: ptr, count: Int(txLen)) - } + // MARK: - Transactions - /// Broadcast a raw signed transaction. + /// Broadcast a transaction built by `CoreTransactionBuilder.buildSigned`. + /// + /// The funding account captured at build time is forwarded so that a + /// definitive broadcast rejection releases the UTXO reservation + /// `buildSigned` took, letting an immediate retry reselect those inputs. /// /// Returns the transaction ID as a hex string. - public func broadcastTransaction(_ txData: Data) throws -> String { + public func broadcastTransaction(_ tx: CoreTransaction) throws -> String { var txidPtr: UnsafeMutablePointer? = nil - try txData.withUnsafeBytes { txBuf in + try withUnsafePointer(to: tx.ffi) { txPtr in try core_wallet_broadcast_transaction( - handle, - txBuf.baseAddress?.assumingMemoryBound(to: UInt8.self), - UInt(txData.count), - &txidPtr + handle, txPtr, tx.accountType.ffi, tx.accountIndex, &txidPtr ).check() } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index ae5d4ecee9f..b4894ef145f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -476,7 +476,7 @@ class SendViewModel: ObservableObject { shieldedService: ShieldedService, platformState: AppState, wallet: PersistentWallet, - coreWallet: ManagedCoreWallet?, + platformWallet: ManagedPlatformWallet?, platformAddressWallet: ManagedPlatformAddressWallet?, signer: KeychainSigner?, senderAccountIndex: UInt32, @@ -492,23 +492,45 @@ class SendViewModel: ObservableObject { do { switch flow { case .coreToCore: - guard let core = coreWallet else { - error = "Core wallet not available" + guard let platformWallet else { + error = "Wallet not available" return } - // Build the ordered output list (primary + any extra - // rows). `coreRecipients` is nil unless every row is a - // valid on-network Core address with a > 0 duffs amount — - // the same condition `canSend` gates on, re-checked here - // so a stale enabled-Send tap can't slip an invalid batch - // through. Coin selection + multi-output tx building are - // entirely Rust-side; we only marshal the parallel - // address/amount arrays into `sendToAddresses`. + // `coreRecipients` is nil unless every row is a valid + // on-network Core address with a > 0 duffs amount — the + // same condition `canSend` gates on, re-checked here so a + // stale enabled-Send tap can't slip an invalid batch + // through. guard let recipients = coreRecipients else { error = "Invalid recipient or amount" return } - let _ = try core.sendToAddresses(recipients: recipients) + // Coin selection, funding, and signing are Rust-side; we + // marshal the outputs into the builder, fund + sign from + // the sender account, then broadcast the signed tx. The + // signed tx carries its funding account, so a failed + // broadcast releases its UTXO reservation for retry. The + // builder validates addresses against `self.network`, which + // the Rust side re-checks against the wallet's own network. + let builder = try CoreTransactionBuilder(network: network) + for recipient in recipients { + try builder.addOutput( + address: recipient.address, + amountDuffs: recipient.amountDuffs + ) + } + try builder.setFunding( + wallet: platformWallet, + accountType: .bip44, + accountIndex: senderAccountIndex + ) + let signedTx = try builder.buildSigned( + wallet: platformWallet, + accountType: .bip44, + accountIndex: senderAccountIndex + ) + // Broadcast lives on the core wallet; grab it locally. + let _ = try platformWallet.coreWallet().broadcastTransaction(signedTx) successMessage = recipients.count > 1 ? "Payment sent to \(recipients.count) recipients" : "Payment sent" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index df158c6c0e1..9f27c215072 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -247,7 +247,6 @@ struct SendTransactionView: View { // and this view's `wallet` may not // be the one that was last created. let managed = walletManager.wallet(for: wallet.walletId) - let coreWallet = try? managed?.coreWallet() let platformAddressWallet = try? managed?.platformAddressWallet() // Pick the account that will FUND a platform → // platform transfer. The Rust Auto selector @@ -295,7 +294,7 @@ struct SendTransactionView: View { shieldedService: shieldedService, platformState: platformState, wallet: wallet, - coreWallet: coreWallet, + platformWallet: managed, platformAddressWallet: platformAddressWallet, signer: signer, senderAccountIndex: senderAccountIndex,