diff --git a/key-wallet-ffi/include/key_wallet_ffi.h b/key-wallet-ffi/include/key_wallet_ffi.h index 2b70891dd..f30471df4 100644 --- a/key-wallet-ffi/include/key_wallet_ffi.h +++ b/key-wallet-ffi/include/key_wallet_ffi.h @@ -3525,7 +3525,7 @@ bool mnemonic_to_seed(const char *mnemonic, */ bool wallet_build_and_sign_transaction(const FFIWalletManager *manager, - const FFIWallet *wallet, + const uint8_t *wallet_id, uint32_t account_index, const FFITxOutput *outputs, size_t outputs_count, diff --git a/key-wallet-ffi/src/transaction.rs b/key-wallet-ffi/src/transaction.rs index 531d4ced8..6f577bd4b 100644 --- a/key-wallet-ffi/src/transaction.rs +++ b/key-wallet-ffi/src/transaction.rs @@ -2,20 +2,18 @@ use std::ffi::{CStr, CString}; use std::os::raw::c_char; -use std::ptr; -use std::slice; +use std::{ptr, slice}; use dashcore::{ consensus, hashes::Hash, sighash::SighashCache, EcdsaSighashType, Network, OutPoint, Script, ScriptBuf, Transaction, TxIn, TxOut, Txid, }; -use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; -use key_wallet_manager::FeeRate; +use key_wallet::transaction::{FeeRate, TransactionBuilder}; use secp256k1::{Message, Secp256k1, SecretKey}; use crate::error::{FFIError, FFIErrorCode}; use crate::types::{FFINetwork, FFITransactionContext, FFIWallet}; -use crate::FFIWalletManager; +use crate::{wallet, FFIWalletManager}; // MARK: - Transaction Types @@ -81,7 +79,7 @@ pub struct FFITxOutput { #[no_mangle] pub unsafe extern "C" fn wallet_build_and_sign_transaction( manager: *const FFIWalletManager, - wallet: *const FFIWallet, + wallet_id: *const u8, account_index: u32, outputs: *const FFITxOutput, outputs_count: usize, @@ -93,7 +91,7 @@ pub unsafe extern "C" fn wallet_build_and_sign_transaction( ) -> bool { // Validate inputs if manager.is_null() - || wallet.is_null() + || wallet_id.is_null() || outputs.is_null() || tx_bytes_out.is_null() || tx_len_out.is_null() @@ -103,67 +101,16 @@ pub unsafe extern "C" fn wallet_build_and_sign_transaction( return false; } - if outputs_count == 0 { - FFIError::set_error( - error, - FFIErrorCode::InvalidInput, - "At least one output required".to_string(), - ); - return false; - } - unsafe { - use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy; - use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder; - let manager_ref = &*manager; - let wallet_ref = &*wallet; - let network_rust = wallet_ref.inner().network; + let wallet_id = slice::from_raw_parts(wallet_id, 32).try_into().unwrap(); let outputs_slice = slice::from_raw_parts(outputs, outputs_count); manager_ref.runtime.block_on(async { let mut manager = manager_ref.manager.write().await; - let managed_wallet = manager.get_wallet_info_mut(&wallet_ref.inner().wallet_id); - - let Some(managed_wallet) = managed_wallet else { - FFIError::set_error( - error, - FFIErrorCode::InvalidInput, - "Could not obtain ManagedWalletInfo for the provided wallet".to_string(), - ); - return false; - }; - - // Get the managed account - let managed_account = - match managed_wallet.accounts.standard_bip44_accounts.get_mut(&account_index) { - Some(account) => account, - None => { - FFIError::set_error( - error, - FFIErrorCode::WalletError, - format!("Account {} not found", account_index), - ); - return false; - } - }; - - let wallet_account = - match wallet_ref.inner().accounts.standard_bip44_accounts.get(&account_index) { - Some(account) => account, - None => { - FFIError::set_error( - error, - FFIErrorCode::WalletError, - format!("Wallet account {} not found", account_index), - ); - return false; - } - }; - // Convert FFI outputs to Rust outputs - let mut tx_builder = TransactionBuilder::new(); + let mut tx_builder = TransactionBuilder::new(&mut manager, wallet_id, account_index); for output in outputs_slice { if output.address.is_null() { @@ -193,7 +140,7 @@ pub unsafe extern "C" fn wallet_build_and_sign_transaction( let address = match dashcore::Address::from_str(address_str) { Ok(addr) => { // Verify network matches - let addr_network = addr.require_network(network_rust).map_err(|e| { + let addr_network = addr.require_network(wallet.network).map_err(|e| { FFIError::set_error( error, FFIErrorCode::InvalidAddress, @@ -229,93 +176,10 @@ pub unsafe extern "C" fn wallet_build_and_sign_transaction( }; } - // Get change address (next internal address) - let xpub = wallet_account.extended_public_key(); - let change_address = match managed_account.next_change_address(Some(&xpub), true) { - Ok(addr) => addr, - Err(e) => { - FFIError::set_error( - error, - FFIErrorCode::WalletError, - format!("Failed to get change address: {}", e), - ); - return false; - } - }; - - tx_builder = tx_builder - .set_change_address(change_address) - .set_fee_rate(FeeRate::new(fee_per_kb)); - - // Get available UTXOs (collect owned UTXOs, not references) - let utxos: Vec = managed_account.utxos.values().cloned().collect(); - - // Get the wallet's root extended private key for signing - use key_wallet::wallet::WalletType; - - let root_xpriv = match &wallet_ref.inner().wallet_type { - WalletType::Mnemonic { - root_extended_private_key, - .. - } => root_extended_private_key, - WalletType::Seed { - root_extended_private_key, - .. - } => root_extended_private_key, - WalletType::ExtendedPrivKey(root_extended_private_key) => root_extended_private_key, - _ => { - FFIError::set_error( - error, - FFIErrorCode::WalletError, - "Cannot sign with watch-only wallet".to_string(), - ); - return false; - } - }; + let fee_rate = FeeRate::new(fee_per_kb); - // Build a map of address -> derivation path for all addresses in the account - use std::collections::HashMap; - let mut address_to_path: HashMap = - HashMap::new(); - - // Collect from all address pools (receive, change, etc.) - for pool in managed_account.account_type.address_pools() { - for addr_info in pool.addresses.values() { - address_to_path.insert(addr_info.address.clone(), addr_info.path.clone()); - } - } - - // Select inputs and build transaction - let mut tx_builder_with_inputs = match tx_builder.select_inputs( - &utxos, - SelectionStrategy::BranchAndBound, - managed_wallet.synced_height(), - |utxo| { - // Look up the derivation path for this UTXO's address - let path = address_to_path.get(&utxo.address)?; - - // Convert root key to ExtendedPrivKey and derive the child key - let root_ext_priv = root_xpriv.to_extended_priv_key(network_rust); - let secp = secp256k1::Secp256k1::new(); - let derived_xpriv = root_ext_priv.derive_priv(&secp, path).ok()?; - - Some(derived_xpriv.private_key) - }, - ) { + let transaction = match tx_builder.set_fee_rate(fee_rate).build() { Ok(builder) => builder, - Err(e) => { - FFIError::set_error( - error, - FFIErrorCode::WalletError, - format!("Coin selection failed: {}", e), - ); - return false; - } - }; - - // Build and sign the transaction - let transaction = match tx_builder_with_inputs.build() { - Ok(tx) => tx, Err(e) => { FFIError::set_error( error, @@ -326,19 +190,6 @@ pub unsafe extern "C" fn wallet_build_and_sign_transaction( } }; - // This is tricky, the transaction creation + fee calculation need a little - // bit of love to avoid this kind of logic. - // - // First, we need to know that TransactionBuilder may add an extra output for change - // to the final transaction but not to itself, with that knowledge, we can compare the - // number of outputs in the transaction with the number of outputs in the TransactionBuilder - // to then call the appropriate fee calculation method - *fee_out = if transaction.output.len() > tx_builder_with_inputs.outputs().len() { - tx_builder_with_inputs.calculate_fee_with_extra_output() - } else { - tx_builder_with_inputs.calculate_fee() - }; - // Serialize the transaction let serialized = consensus::serialize(&transaction); let size = serialized.len(); @@ -349,6 +200,8 @@ pub unsafe extern "C" fn wallet_build_and_sign_transaction( *tx_bytes_out = tx_bytes; *tx_len_out = size; + *fee_out = fee_rate.calculate_fee(size); + FFIError::set_success(error); true }) diff --git a/key-wallet-manager/examples/wallet_creation.rs b/key-wallet-manager/examples/wallet_creation.rs index b609234b1..e43358ebb 100644 --- a/key-wallet-manager/examples/wallet_creation.rs +++ b/key-wallet-manager/examples/wallet_creation.rs @@ -7,11 +7,10 @@ use key_wallet::account::StandardAccountType; use key_wallet::wallet::initialization::WalletAccountCreationOptions; -use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::{AccountType, Network}; use key_wallet_manager::wallet_interface::WalletInterface; -use key_wallet_manager::wallet_manager::WalletManager; +use key_wallet_manager::wallet_manager::{AccountTypePreference, WalletManager}; fn main() { println!("=== Wallet Creation Example ===\n"); diff --git a/key-wallet-manager/src/lib.rs b/key-wallet-manager/src/lib.rs index 2f13f19aa..98513ae62 100644 --- a/key-wallet-manager/src/lib.rs +++ b/key-wallet-manager/src/lib.rs @@ -40,10 +40,5 @@ pub use dashcore::{OutPoint, TxIn, TxOut}; // Export our high-level types pub use events::WalletEvent; -pub use key_wallet::wallet::managed_wallet_info::coin_selection::{ - CoinSelector, SelectionResult, SelectionStrategy, -}; -pub use key_wallet::wallet::managed_wallet_info::fee::FeeRate; -pub use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder; pub use wallet_interface::BlockProcessingResult; pub use wallet_manager::{WalletError, WalletManager}; diff --git a/key-wallet-manager/src/wallet_manager/mod.rs b/key-wallet-manager/src/wallet_manager/mod.rs index 7117a9350..a8a021493 100644 --- a/key-wallet-manager/src/wallet_manager/mod.rs +++ b/key-wallet-manager/src/wallet_manager/mod.rs @@ -6,7 +6,6 @@ mod matching; mod process_block; -mod transaction_building; pub use crate::wallet_manager::matching::{check_compact_filters_for_addresses, FilterMatchKey}; use alloc::collections::BTreeMap; @@ -16,7 +15,6 @@ use dashcore::blockdata::transaction::Transaction; use dashcore::prelude::CoreBlockHeight; use key_wallet::account::AccountCollection; use key_wallet::transaction_checking::TransactionContext; -use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::managed_wallet_info::{ManagedWalletInfo, TransactionRecord}; use key_wallet::wallet::WalletType; @@ -41,6 +39,19 @@ pub type WalletId = [u8; 32]; /// Unique identifier for an account within a wallet pub type AccountId = u32; +/// Account type preference for transaction building +#[derive(Debug, Clone, Copy)] +pub enum AccountTypePreference { + /// Use BIP44 account only + BIP44, + /// Use BIP32 account only + BIP32, + /// Prefer BIP44, fallback to BIP32 + PreferBIP44, + /// Prefer BIP32, fallback to BIP44 + PreferBIP32, +} + /// The actual account type that was used for address generation #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AccountTypeUsed { diff --git a/key-wallet-manager/src/wallet_manager/transaction_building.rs b/key-wallet-manager/src/wallet_manager/transaction_building.rs deleted file mode 100644 index baf23d8cd..000000000 --- a/key-wallet-manager/src/wallet_manager/transaction_building.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! Transaction building functionality for the wallet manager - -use super::{WalletError, WalletId, WalletManager}; -use dashcore::Transaction; -use key_wallet::wallet::managed_wallet_info::fee::FeeRate; -use key_wallet::wallet::managed_wallet_info::transaction_building::{ - AccountTypePreference, TransactionError, -}; -use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; -use key_wallet::Address; - -impl WalletManager { - /// Creates an unsigned transaction from a specific wallet and account - /// - /// This method delegates to the ManagedWalletInfo's create_payment_transaction method - /// If account_type_pref is None, defaults to BIP44 - #[allow(clippy::too_many_arguments)] - pub fn create_unsigned_payment_transaction( - &mut self, - wallet_id: &WalletId, - account_index: u32, - account_type_pref: Option, - recipients: Vec<(Address, u64)>, - fee_rate: FeeRate, - current_block_height: u32, - ) -> Result { - // Get the wallet - let wallet = self.wallets.get(wallet_id).ok_or(WalletError::WalletNotFound(*wallet_id))?; - - // Get the managed wallet info - let managed_info = - self.wallet_infos.get_mut(wallet_id).ok_or(WalletError::WalletNotFound(*wallet_id))?; - - // Delegate to the managed wallet info's method - managed_info - .create_unsigned_payment_transaction( - wallet, - account_index, - account_type_pref, - recipients, - fee_rate, - current_block_height, - ) - .map_err(|e| match e { - TransactionError::NoAccount => WalletError::AccountNotFound(account_index), - TransactionError::InsufficientFunds => WalletError::InsufficientFunds, - TransactionError::ChangeAddressGeneration(msg) => { - WalletError::AddressGeneration(msg) - } - TransactionError::BuildFailed(msg) => WalletError::TransactionBuild(msg), - TransactionError::CoinSelection(err) => { - WalletError::TransactionBuild(format!("Coin selection failed: {}", err)) - } - }) - } -} diff --git a/key-wallet-manager/tests/integration_test.rs b/key-wallet-manager/tests/integration_test.rs index 3c1700405..d14845722 100644 --- a/key-wallet-manager/tests/integration_test.rs +++ b/key-wallet-manager/tests/integration_test.rs @@ -4,12 +4,11 @@ //! works correctly with the low-level key-wallet primitives. use key_wallet::wallet::initialization::WalletAccountCreationOptions; -use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::{mnemonic::Language, Mnemonic, Network}; use key_wallet_manager::wallet_interface::WalletInterface; -use key_wallet_manager::wallet_manager::{WalletError, WalletManager}; +use key_wallet_manager::wallet_manager::{AccountTypePreference, WalletError, WalletManager}; #[test] fn test_wallet_manager_creation() { diff --git a/key-wallet/Cargo.toml b/key-wallet/Cargo.toml index 606b40223..09f46c1c8 100644 --- a/key-wallet/Cargo.toml +++ b/key-wallet/Cargo.toml @@ -22,6 +22,7 @@ test-utils = ["dashcore/test-utils"] internals = { path = "../internals", package = "dashcore-private" } dashcore_hashes = { path = "../hashes", default-features = false } dashcore = { path="../dash" } +key-wallet-manager = { path = "../key-wallet-manager", features = ["std"] } secp256k1 = { version = "0.30.0", default-features = false, features = ["hashes", "recovery"] } bip39 = { version = "2.2.0", default-features = false, features = ["chinese-simplified", "chinese-traditional", "czech", "french", "italian", "japanese", "korean", "portuguese", "spanish", "zeroize"] } serde = { version = "1.0", default-features = false, features = ["derive"], optional = true } diff --git a/key-wallet/src/lib.rs b/key-wallet/src/lib.rs index 38b4e3cd8..aaf8a57d3 100644 --- a/key-wallet/src/lib.rs +++ b/key-wallet/src/lib.rs @@ -46,6 +46,7 @@ pub mod managed_account; pub mod mnemonic; pub mod psbt; pub mod seed; +pub mod transaction; pub mod transaction_checking; pub(crate) mod utils; pub mod utxo; diff --git a/key-wallet/src/transaction/builder.rs b/key-wallet/src/transaction/builder.rs new file mode 100644 index 000000000..7bb7493a9 --- /dev/null +++ b/key-wallet/src/transaction/builder.rs @@ -0,0 +1,371 @@ +//! Transaction building with dashcore types +//! +//! This module provides high-level transaction building functionality +//! using types from the dashcore crate. + +use alloc::vec::Vec; +use core::fmt; +use dashcore::consensus::Encodable; +use dashcore::prelude::CoreBlockHeight; +use std::collections::HashMap; + +use dashcore::blockdata::script::{Builder, PushBytes}; +use dashcore::blockdata::transaction::special_transaction::TransactionPayload; +use dashcore::blockdata::transaction::Transaction; +use dashcore::sighash::{EcdsaSighashType, SighashCache}; +use dashcore::Address; +use dashcore::{TxIn, TxOut}; +use dashcore_hashes::Hash; +use secp256k1::{Message, Secp256k1}; + +use crate::account::{ManagedAccountTrait, ManagedCoreAccount}; +use crate::transaction::coin_selection::{SelectionError, UtxoSelector, UtxoSelectorStrategy}; +use crate::transaction::fee::FeeRate; +use crate::wallet::WalletType; +use crate::{Account, DerivationPath, ManagedAccountType, Utxo}; +use key_wallet_manager::wallet_manager::WalletManager; + +/// Errors that can occur during transaction building +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TransactionBuildingError { + NoOutputs, + ZeroValueOutputs, + AccountError(String), + InvalidAmount(String), + SigningFailed(String), + CoinSelection(SelectionError), +} + +impl fmt::Display for TransactionBuildingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoOutputs => write!(f, "No outputs provided"), + Self::ZeroValueOutputs => write!(f, "Sero value outputs"), + Self::AccountError(msg) => write!(f, "Account error: {}", msg), + Self::InvalidAmount(msg) => write!(f, "Invalid amount: {}", msg), + Self::SigningFailed(msg) => write!(f, "Signing failed: {}", msg), + Self::CoinSelection(err) => write!(f, "Coin selection error: {}", err), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for TransactionBuildingError {} + +/// Transaction builder for creating Dash transactions +/// +/// This builder implements BIP-69 (Lexicographical Indexing of Transaction Inputs and Outputs) +/// to ensure deterministic ordering and improve privacy by preventing information leakage +/// through predictable input/output ordering patterns. +pub struct TransactionBuilder<'a> { + // Sender account needed information + synced_height: CoreBlockHeight, + wallet_type: WalletType, + change_address: Address, + available_utxos: Vec<&'a Utxo>, + managed_account_type: ManagedAccountType, + // TODO: Remove this stuff but need to fix bad crate architecture + managed_account: &'a mut ManagedCoreAccount, + account: &'a Account, + + // Variables that define the desired transaction + fee_rate: FeeRate, + selection_strategy: UtxoSelectorStrategy, + + // Transaction fields + version: u16, + lock_time: u32, + outputs: Vec, + special_transaction_payload: Option, +} + +impl<'a> TransactionBuilder<'a> { + /// Create a new transaction builder + pub fn new( + wallet_manager: &'a WalletManager, + wallet_id: &'a WalletId, + account_index: u32, + ) -> Self { + Self { + synced_height: todo!(), + wallet_type: todo!(), + change_address: todo!(), + available_utxos: todo!(), + managed_account_type: todo!(), + managed_account: todo!(), + account: todo!(), + + fee_rate: FeeRate::normal(), + selection_strategy: UtxoSelectorStrategy::OptimalConsolidation, + + version: 2, + lock_time: 0, + outputs: Vec::new(), + special_transaction_payload: None, + } + } + + /// Add an output to a specific address + /// + /// Note: Outputs will be sorted according to BIP-69 when the transaction is built: + /// - First by amount (ascending) + /// - Then by scriptPubKey (lexicographically) + pub fn add_output( + mut self, + address: &Address, + amount: u64, + ) -> Result { + if amount == 0 { + return Err(TransactionBuildingError::InvalidAmount( + "Output amount cannot be zero".into(), + )); + } + + let script_pubkey = address.script_pubkey(); + self.outputs.push(TxOut { + value: amount, + script_pubkey, + }); + Ok(self) + } + + /// Set the fee rate + pub fn set_fee_rate(mut self, fee_rate: FeeRate) -> Self { + self.fee_rate = fee_rate; + self + } + + /// Set the lock time + pub fn set_lock_time(mut self, lock_time: u32) -> Self { + self.lock_time = lock_time; + self + } + + /// Set the transaction version + pub fn set_version(mut self, version: u16) -> Self { + self.version = version; + self + } + + /// Set the special transaction payload + pub fn set_special_payload(mut self, payload: TransactionPayload) -> Self { + self.special_transaction_payload = Some(payload); + self + } + + /// Build the transaction + pub fn build(self) -> Result { + if self.outputs.is_empty() { + return Err(TransactionBuildingError::NoOutputs); + } + + let total_output: u64 = self.outputs.iter().map(|out| out.value).sum(); + + if total_output == 0 && self.special_transaction_payload.is_none() { + return Err(TransactionBuildingError::ZeroValueOutputs); + } + + let mut tx = Transaction { + version: self.version.clone(), + lock_time: self.lock_time.clone(), + input: Vec::new(), + output: self.outputs.clone(), + special_transaction_payload: self.special_transaction_payload.clone(), + }; + + // the coin selection logic is hard to follow so read carefully + // First we add a dummy output for the change, + // we are about to build the transaction by approximation + + { + let account_xpub = &self.account.account_xpub; + let change_addr = self + .managed_account + .next_change_address(Some(account_xpub), true) + .map_err(|e| TransactionBuildingError::AccountError(e.to_string()))?; + let change_script = change_addr.script_pubkey(); + tx.output.push(TxOut { + value: 0, + script_pubkey: change_script, + }); + } + + // we calculate the current fee for the current state of the transaction (no inputs yet) + let mut current_fee = { + let mut buff = Vec::new(); + tx.consensus_encode(&mut buff).expect("The writer cannot fail"); + self.fee_rate.calculate_fee(buff.len()) + }; + + // Lets iterate until we build a transaction that has enough inputs to pay the outputs + fee + loop { + // Represents the min required value of the change output to be added to the transaction + const DUST: u64 = 1000; + + // Select utxo that can afford the output sum + fee + DUST + // By adding DUST we ensure the change always reaches the min required amount + let selection = UtxoSelector::new(self.selection_strategy) + .select( + total_output + current_fee + DUST, + self.managed_account.utxos().values(), + self.synced_height, + ) + .map_err(TransactionBuildingError::CoinSelection)?; + + let total_input: u64 = selection.iter().map(|utxo| utxo.value()).sum(); + + self.add_signed_inputs(&mut tx, &selection)?; + + // Here we recalculate the fee with the new inputs inside the transaction + current_fee = { + let mut buff = Vec::new(); + tx.consensus_encode(&mut buff).expect("The writer cannot fail"); + self.fee_rate.calculate_fee(buff.len()) + }; + + // Check the inputs obtained are enough, if not, execute the loop again, but this time + // with the current_fee updated with the new min number of inputs. This ensures next + // iteration selects the same inputs + new ones to pay for the new fee + if total_input >= total_output + current_fee + DUST { + // We added the change output as the last one, just update it with the correct amount + let change_amount = total_input - total_output - current_fee; + let change_output = tx + .output + .last_mut() + .expect("Transaction is expect to have more than one output"); + change_output.value = change_amount; + break; + } + } + + let tx_outputs = &mut tx.output; + let tx_inputs = &mut tx.input; + + // BIP-69: Sort outputs by amount first, then by scriptPubKey lexicographically + tx_outputs.sort_by(|a, b| match a.value.cmp(&b.value) { + std::cmp::Ordering::Equal => a.script_pubkey.as_bytes().cmp(b.script_pubkey.as_bytes()), + other => other, + }); + + // BIP-69: Sort inputs by transaction hash (reversed) and then by output index + tx_inputs.sort_by(|a, b| { + let tx_hash_a = a.previous_output.txid.to_byte_array(); + let tx_hash_b = b.previous_output.txid.to_byte_array(); + + match tx_hash_a.cmp(&tx_hash_b) { + std::cmp::Ordering::Equal => a.previous_output.vout.cmp(&b.previous_output.vout), + other => other, + } + }); + + Ok(tx) + } + + fn add_signed_inputs( + &self, + tx: &mut Transaction, + utxos: &Vec<&Utxo>, + ) -> Result<(), TransactionBuildingError> { + let root_xpriv = match &self.wallet_type { + WalletType::Mnemonic { + root_extended_private_key, + .. + } => root_extended_private_key, + WalletType::Seed { + root_extended_private_key, + .. + } => root_extended_private_key, + WalletType::ExtendedPrivKey(root_extended_private_key) => root_extended_private_key, + _ => { + return Err(TransactionBuildingError::SigningFailed( + "Cannot sign with watch-only wallet".to_string(), + )); + } + }; + + // Build a map of address -> derivation path for all addresses in the account + let mut address_to_path: HashMap = HashMap::new(); + + // Collect from all address pools (receive, change, etc.) + for pool in self.managed_account.account_type.address_pools() { + for addr_info in pool.addresses.values() { + address_to_path.insert(addr_info.address.clone(), addr_info.path.clone()); + } + } + + let secp = Secp256k1::new(); + + // Collect all signatures first, then apply them + let mut inputs = Vec::new(); + + let cache = SighashCache::new(&*tx); + + for (index, &utxo) in utxos.iter().enumerate() { + let key = { + // Look up the derivation path for this UTXO's address + let path = address_to_path.get(&utxo.address).ok_or( + TransactionBuildingError::SigningFailed(format!( + "Derivation path not found for address {}", + utxo.address, + )), + )?; + + // Convert root key to ExtendedPrivKey and derive the child key + let root_ext_priv = root_xpriv.to_extended_priv_key(self.account.network); + let secp = secp256k1::Secp256k1::new(); + let derived_xpriv = root_ext_priv + .derive_priv(&secp, path) + .map_err(|e| TransactionBuildingError::SigningFailed(e.to_string()))?; + + derived_xpriv.private_key + }; + + // Get the script pubkey from the UTXO + let script_pubkey = &utxo.txout.script_pubkey; + + // Create signature hash for P2PKH + let sighash = cache + .legacy_signature_hash(index, script_pubkey, EcdsaSighashType::All.to_u32()) + .map_err(|e| { + TransactionBuildingError::SigningFailed(format!( + "Failed to compute sighash: {}", + e + )) + })?; + + // Sign the hash + let message = Message::from_digest(*sighash.as_byte_array()); + let signature = secp.sign_ecdsa(&message, &key); + + // Create script signature (P2PKH) + let mut sig_bytes = signature.serialize_der().to_vec(); + sig_bytes.push(EcdsaSighashType::All.to_u32() as u8); + + let pubkey = secp256k1::PublicKey::from_secret_key(&secp, &key); + + let script_sig = Builder::new() + .push_slice(<&PushBytes>::try_from(sig_bytes.as_slice()).map_err(|_| { + TransactionBuildingError::SigningFailed("Invalid signature length".into()) + })?) + .push_slice(pubkey.serialize()) + .into_script(); + + // Map the UTXO to TxIn with the new scrip_sig + // Dash doesn't use RBF, so we use the standard sequence number + let sequence = 0xffffffff; + + let txin = TxIn { + previous_output: utxo.outpoint, + script_sig, + sequence, + witness: dashcore::blockdata::witness::Witness::new(), + }; + + inputs.push(txin); + } + + tx.input = inputs; + + Ok(()) + } +} diff --git a/key-wallet/src/transaction/coin_selection.rs b/key-wallet/src/transaction/coin_selection.rs new file mode 100644 index 000000000..45a37e8f8 --- /dev/null +++ b/key-wallet/src/transaction/coin_selection.rs @@ -0,0 +1,419 @@ +//! Coin selection algorithms for transaction building +//! +//! This module provides various strategies for selecting UTXOs +//! when building transactions. + +use crate::Utxo; +use alloc::vec::Vec; +use core::cmp::Reverse; +use rand::seq::SliceRandom; +use rand::thread_rng; + +/// Errors that can occur during coin selection +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SelectionError { + /// Insufficient funds + InsufficientFunds { + available: u64, + required: u64, + }, +} + +impl core::fmt::Display for SelectionError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::InsufficientFunds { + available, + required, + } => { + write!(f, "Insufficient funds: available {}, required {}", available, required) + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for SelectionError {} + +#[derive(Clone, Copy)] +pub enum UtxoSelectorStrategy { + /// Select smallest UTXOs first (minimize UTXO set) + SmallestFirst, + /// Select largest UTXOs first (minimize fees) + LargestFirst, + /// Select smallest UTXOs first until count, then largest (This minimizes UTXO set without + /// creating massive transactions) + SmallestFirstTill(u16), + /// Branch and bound optimization - exhaustively searches for the optimal combination of UTXOs + /// that minimizes waste (excess value that would go to fees or change). Uses a depth-first + /// search with pruning to find exact matches or near-exact matches efficiently. + /// + /// Best for: Regular transactions where minimizing fees is the priority. This strategy + /// works well when you have many UTXOs of varying sizes and want to find the most + /// efficient combination. It prioritizes larger UTXOs first to minimize the number + /// of inputs needed. + BranchAndBound, + /// Optimal consolidation - tries to find exact match or minimal change while consolidating UTXOs + /// + /// Best for: Wallets with many small UTXOs that need consolidation. This strategy + /// prioritizes using smaller UTXOs first to reduce wallet fragmentation over time. + /// It searches for exact matches (no change output needed) using smaller denominations, + /// which helps clean up dust and small UTXOs while making payments. If no exact match + /// exists, it tries to minimize change while still preferring smaller inputs. + OptimalConsolidation, + /// Random selection for privacy + Random, +} + +impl UtxoSelectorStrategy { + fn apply<'a>( + &self, + target: u64, + mut utxos: Vec<&'a Utxo>, + ) -> Result, SelectionError> { + match self { + UtxoSelectorStrategy::SmallestFirst => { + utxos.sort_by_key(|&u| u.value()); + let iter = utxos.into_iter(); + + select_utxo_from_iterator(iter, target) + } + UtxoSelectorStrategy::LargestFirst => { + utxos.sort_by_key(|&u| Reverse(u.value())); + let iter = utxos.into_iter(); + + select_utxo_from_iterator(iter, target) + } + UtxoSelectorStrategy::SmallestFirstTill(v) => { + utxos.sort_by_key(|&u| u.value()); + let (utxos, _) = utxos.split_at(*v as usize); + let iter = utxos.to_vec().into_iter(); + + select_utxo_from_iterator(iter, target) + } + UtxoSelectorStrategy::BranchAndBound => branch_and_bound_impl(utxos, target), + UtxoSelectorStrategy::OptimalConsolidation => optimal_consolidation_impl(utxos, target), + UtxoSelectorStrategy::Random => { + utxos.shuffle(&mut thread_rng()); + let iter = utxos.into_iter(); + + select_utxo_from_iterator(iter, target) + } + } + } +} + +pub struct UtxoSelector { + strategy: UtxoSelectorStrategy, +} + +impl UtxoSelector { + pub fn new(strategy: UtxoSelectorStrategy) -> Self { + Self { + strategy, + } + } + + pub fn select<'a, I>( + &self, + target: u64, + utxos: I, + current_height: u32, + ) -> Result, SelectionError> + where + I: IntoIterator, + { + let utxos: Vec<&Utxo> = + utxos.into_iter().filter(|&u| u.is_spendable(current_height)).collect(); + + self.strategy.apply(target, utxos) + } +} + +fn select_utxo_from_iterator<'a>( + iter: impl Iterator, + target: u64, +) -> Result, SelectionError> { + let mut current_value = 0; + let mut utxos = vec![]; + + for utxo in iter { + if current_value >= target { + break; + } + + current_value += utxo.value(); + utxos.push(utxo); + } + + if current_value < target { + return Err(SelectionError::InsufficientFunds { + available: current_value, + required: target, + }); + } + + Ok(utxos) +} + +/// Optimal consolidation strategy implementation +/// Tries to find combinations that either: +/// 1. Match exactly (no change needed) +/// 2. Create minimal change while using smaller UTXOs +/// +/// This algorithm: +/// - Sorts UTXOs by value ascending (smallest first) +/// - Prioritizes exact matches using smaller denominations +/// - Falls back to minimal change if no exact match exists +/// - Helps reduce UTXO set size over time +/// +/// Trade-offs vs BranchAndBound: +/// - Pros: Reduces wallet fragmentation by consuming small UTXOs +/// - Pros: More likely to find exact matches with smaller denominations +/// - Pros: Better for long-term wallet health and UTXO management +/// - Cons: May result in higher fees due to more inputs +/// - Cons: Transactions may be larger due to using more UTXOs +/// +/// When to use this over BranchAndBound: +/// - When wallet has accumulated many small UTXOs (dust) +/// - During low-fee periods when consolidation is cheaper +/// - For wallets that receive many small payments +/// - When exact change is preferred to minimize privacy leaks +fn optimal_consolidation_impl<'a>( + mut utxos: Vec<&'a Utxo>, + target: u64, +) -> Result, SelectionError> { + // First, try to find an exact match using smaller UTXOs + // Sort by value ascending to prioritize using smaller UTXOs + utxos.sort_by_key(|&u| u.value()); + + // Try combinations of up to 10 UTXOs for exact match + + // Try to find exact match with smaller UTXOs first + for max_inputs in 1..=10.min(utxos.len()) { + for num_inputs in 1..=max_inputs.min(utxos.len()) { + // Try combinations of this size + if let Some(combo) = + find_combination_recursive(&utxos, target, num_inputs, 0, Vec::new(), 0) + { + return Ok(combo); + } + } + } + + // If no exact match, try to minimize change while consolidating small UTXOs + // Use a combination of smallest UTXOs that slightly exceeds the target + let mut best_selection: Option> = None; + let mut best_change = u64::MAX; + + for i in 1..=utxos.len().min(10) { + let mut current = Vec::new(); + let mut current_total = 0u64; + + for &utxo in &utxos[..i] { + current.push(utxo); + current_total += utxo.value(); + } + + if current_total >= target { + let change = current_total - target; + if change < best_change { + best_selection = Some(current); + best_change = change; + } + } + } + + return if let Some(selected) = best_selection { + Ok(selected) + } else { + select_utxo_from_iterator(utxos.into_iter(), target) + }; + + /// Recursive helper to find exact combination + fn find_combination_recursive<'a>( + utxos: &[&'a Utxo], + target: u64, + remaining_picks: usize, + index: usize, + current: Vec<&'a Utxo>, + current_total: u64, + ) -> Option> { + if remaining_picks == 0 { + return None; + } + + // Check if we've found an exact match + if current_total == target { + return Some(current); + } + + // Prune if we've exceeded the target + if current_total > target { + return None; + } + + for i in index..=utxos.len().saturating_sub(remaining_picks) { + let mut new_current = current.clone(); + new_current.push(utxos[i]); + let new_total = current_total + utxos[i].value(); + + if let Some(result) = find_combination_recursive( + utxos, + target, + remaining_picks - 1, + i + 1, + new_current, + new_total, + ) { + return Some(result); + } + } + + None + } +} + +/// Branch and bound coin selection with custom sizes (finds exact match if possible) +/// +/// This algorithm: +/// - Sorts UTXOs by value descending (largest first) +/// - Recursively explores combinations looking for exact matches +/// - Prunes branches that exceed the target by too much +/// - Falls back to simple accumulation if no exact match found +/// +/// Trade-offs vs OptimalConsolidation: +/// - Pros: Minimizes transaction fees by using fewer, larger UTXOs +/// - Pros: Faster to find solutions due to aggressive pruning +/// - Cons: May leave small UTXOs unconsolidated, leading to wallet fragmentation +/// - Cons: Less likely to find exact matches with larger denominations +fn branch_and_bound_impl<'a>( + mut utxos: Vec<&'a Utxo>, + target: u64, +) -> Result, SelectionError> { + utxos.sort_by_key(|&u| Reverse(u.value())); + + // Try to find an exact match first + + // Use a simple recursive approach with memoization + let result = find_exact_match(&utxos, target, 0, Vec::new(), 0); + + if let Some(selected) = result { + return Ok(selected); + } + + // Fall back to accumulation if no exact match found + // For fallback, assume change output is needed + return select_utxo_from_iterator(utxos.into_iter(), target); + + fn find_exact_match<'a>( + utxos: &[&'a Utxo], + target: u64, + index: usize, + mut current: Vec<&'a Utxo>, + current_total: u64, + ) -> Option> { + // Check if we've found an exact match + if current_total == target { + return Some(current); + } + + // Prune if we've exceeded the target + if current_total > target { + return None; + } + + // Try remaining UTXOs + for i in index..utxos.len() { + let new_total = current_total + utxos[i].value(); + + // Skip if this would exceed our target by too much + if new_total > target { + continue; + } + + current.push(utxos[i]); + + if let Some(result) = find_exact_match(utxos, target, i + 1, current.clone(), new_total) + { + return Some(result); + } + + current.pop(); + } + + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_smallest_first_selection() { + let utxos = vec![ + Utxo::dummy(0, 10000, 100, false, true), + Utxo::dummy(0, 20000, 100, false, true), + Utxo::dummy(0, 30000, 100, false, true), + Utxo::dummy(0, 40000, 100, false, true), + ]; + + let selector = UtxoSelector::new(UtxoSelectorStrategy::SmallestFirst); + let selected = selector.select(25000, &utxos, 200).unwrap(); + + // The algorithm should select the smallest UTXOs first: 10k + 20k = 30k which covers 25k target + assert_eq!(selected.len(), 2); // Should select 10k + 20k + } + + #[test] + fn test_largest_first_selection() { + let utxos = vec![ + Utxo::dummy(0, 10000, 100, false, true), + Utxo::dummy(0, 20000, 100, false, true), + Utxo::dummy(0, 30000, 100, false, true), + Utxo::dummy(0, 40000, 100, false, true), + ]; + + let selector = UtxoSelector::new(UtxoSelectorStrategy::LargestFirst); + let selected = selector.select(25000, &utxos, 200).unwrap(); + + assert_eq!(selected.len(), 1); // Should select just 40k + } + + #[test] + fn test_insufficient_funds() { + let utxos = + vec![Utxo::dummy(0, 10000, 100, false, true), Utxo::dummy(0, 20000, 100, false, true)]; + + let selector = UtxoSelector::new(UtxoSelectorStrategy::LargestFirst); + let result = selector.select(50000, &utxos, 200); + + assert!(matches!(result, Err(SelectionError::InsufficientFunds { .. }))); + } + + #[test] + fn test_optimal_consolidation_strategy() { + // Test that OptimalConsolidation strategy works correctly + let utxos = vec![ + Utxo::dummy(0, 100, 100, false, true), + Utxo::dummy(0, 200, 100, false, true), + Utxo::dummy(0, 300, 100, false, true), + Utxo::dummy(0, 500, 100, false, true), + Utxo::dummy(0, 1000, 100, false, true), + Utxo::dummy(0, 2000, 100, false, true), + ]; + + let selector = UtxoSelector::new(UtxoSelectorStrategy::OptimalConsolidation); + let selected = selector.select(1500, &utxos, 200).unwrap(); + + // OptimalConsolidation should work and produce a valid selection + assert!(!selected.is_empty()); + + // The strategy should prefer smaller UTXOs, so it should include + // some of the smaller values + let selected_values: Vec = selected.iter().map(|&u| u.value()).collect(); + let has_small_utxos = selected_values.iter().any(|&v| v <= 500); + assert!(has_small_utxos, "Should include at least one small UTXO for consolidation"); + } +} diff --git a/key-wallet/src/wallet/managed_wallet_info/fee.rs b/key-wallet/src/transaction/fee.rs similarity index 71% rename from key-wallet/src/wallet/managed_wallet_info/fee.rs rename to key-wallet/src/transaction/fee.rs index c5d3cbfaa..ab5a149b8 100644 --- a/key-wallet/src/wallet/managed_wallet_info/fee.rs +++ b/key-wallet/src/transaction/fee.rs @@ -92,36 +92,6 @@ impl FeeRate { } } -/// Calculate the size of a transaction -pub fn estimate_tx_size(num_inputs: usize, num_outputs: usize, has_change: bool) -> usize { - // Base size: version (2) + type (2) + locktime (4) + varint counts - let mut size = 10; - - // Inputs (P2PKH assumed: ~148 bytes each) - size += num_inputs * 148; - - // Outputs (P2PKH assumed: ~34 bytes each) - size += num_outputs * 34; - - // Change output if needed - if has_change { - size += 34; - } - - size -} - -/// Calculate the virtual size of a transaction (for fee calculation) -pub fn estimate_tx_vsize( - num_inputs: usize, - num_outputs: usize, - has_change: bool, - _has_witness: bool, // For future SegWit support -) -> usize { - // For non-SegWit transactions, vsize equals size - estimate_tx_size(num_inputs, num_outputs, has_change) -} - #[cfg(test)] mod tests { use super::*; @@ -144,15 +114,4 @@ mod tests { assert_eq!(rate.as_sat_per_kb(), 5000); assert_eq!(rate.calculate_fee(1000), 5000); } - - #[test] - fn test_tx_size_estimation() { - // 1 input, 1 output, no change - let size = estimate_tx_size(1, 1, false); - assert!(size > 180 && size < 200); - - // 2 inputs, 2 outputs, with change - let size = estimate_tx_size(2, 2, true); - assert!(size > 400 && size < 450); - } } diff --git a/key-wallet/src/transaction/mod.rs b/key-wallet/src/transaction/mod.rs new file mode 100644 index 000000000..fe1f1afe7 --- /dev/null +++ b/key-wallet/src/transaction/mod.rs @@ -0,0 +1,7 @@ +mod builder; +mod coin_selection; +mod fee; + +pub use builder::TransactionBuilder; +pub use coin_selection::UtxoSelectorStrategy; +pub use fee::FeeRate; diff --git a/key-wallet/src/wallet/managed_wallet_info/coin_selection.rs b/key-wallet/src/wallet/managed_wallet_info/coin_selection.rs deleted file mode 100644 index 52fb37aee..000000000 --- a/key-wallet/src/wallet/managed_wallet_info/coin_selection.rs +++ /dev/null @@ -1,767 +0,0 @@ -//! Coin selection algorithms for transaction building -//! -//! This module provides various strategies for selecting UTXOs -//! when building transactions. - -use crate::wallet::managed_wallet_info::fee::FeeRate; -use crate::Utxo; -use alloc::vec::Vec; -use core::cmp::Reverse; - -/// UTXO selection strategy -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SelectionStrategy { - /// Select smallest UTXOs first (minimize UTXO set) - SmallestFirst, - /// Select largest UTXOs first (minimize fees) - LargestFirst, - /// Select smallest UTXOs first until count, then largest (This minimizes UTXO set without - /// creating massive transactions) - SmallestFirstTill(u16), - /// Branch and bound optimization - exhaustively searches for the optimal combination of UTXOs - /// that minimizes waste (excess value that would go to fees or change). Uses a depth-first - /// search with pruning to find exact matches or near-exact matches efficiently. - /// - /// Best for: Regular transactions where minimizing fees is the priority. This strategy - /// works well when you have many UTXOs of varying sizes and want to find the most - /// efficient combination. It prioritizes larger UTXOs first to minimize the number - /// of inputs needed. - BranchAndBound, - /// Optimal consolidation - tries to find exact match or minimal change while consolidating UTXOs - /// - /// Best for: Wallets with many small UTXOs that need consolidation. This strategy - /// prioritizes using smaller UTXOs first to reduce wallet fragmentation over time. - /// It searches for exact matches (no change output needed) using smaller denominations, - /// which helps clean up dust and small UTXOs while making payments. If no exact match - /// exists, it tries to minimize change while still preferring smaller inputs. - OptimalConsolidation, - /// Random selection for privacy - Random, -} - -/// Result of UTXO selection -#[derive(Debug, Clone)] -pub struct SelectionResult { - /// Selected UTXOs - pub selected: Vec, - /// Total value of selected UTXOs - pub total_value: u64, - /// Target amount (excluding fees) - pub target_amount: u64, - /// Change amount (if any) - pub change_amount: u64, - /// Estimated transaction size in bytes - pub estimated_size: usize, - /// Estimated fee - pub estimated_fee: u64, - /// Whether an exact match was found (no change needed) - pub exact_match: bool, -} - -/// Coin selector for choosing UTXOs -/// -/// # Strategy Selection Guide -/// -/// ## For Fee Optimization: -/// - **BranchAndBound**: Best when fees are high and you want to minimize transaction cost -/// - **LargestFirst**: Simple strategy that also minimizes fees but may not find optimal solutions -/// -/// ## For UTXO Management: -/// - **OptimalConsolidation**: Best for wallets with many small UTXOs that need cleaning up -/// - **SmallestFirst**: Aggressively consolidates but may create expensive transactions -/// - **SmallestFirstTill(n)**: Balanced approach - consolidates up to n small UTXOs then switches to large -/// -/// ## Special Cases: -/// - **Random**: For privacy-conscious users (currently not fully implemented) -/// -/// ## Recommended Defaults: -/// - Normal payments: **BranchAndBound** (minimizes fees) -/// - Wallet maintenance: **OptimalConsolidation** (during low fee periods) -/// - High-frequency receivers: **SmallestFirstTill(10)** (balanced approach) -pub struct CoinSelector { - strategy: SelectionStrategy, - min_confirmations: u32, - include_unconfirmed: bool, - dust_threshold: u64, -} - -impl CoinSelector { - /// Create a new coin selector - pub fn new(strategy: SelectionStrategy) -> Self { - Self { - strategy, - min_confirmations: 1, - include_unconfirmed: false, - dust_threshold: 546, // Standard dust threshold - } - } - - /// Set minimum confirmations required - pub fn with_min_confirmations(mut self, confirmations: u32) -> Self { - self.min_confirmations = confirmations; - self - } - - /// Include unconfirmed UTXOs - pub fn include_unconfirmed(mut self) -> Self { - self.include_unconfirmed = true; - self - } - - /// Set dust threshold - pub fn with_dust_threshold(mut self, threshold: u64) -> Self { - self.dust_threshold = threshold; - self - } - - /// Select UTXOs for a target amount with default transaction size assumptions - pub fn select_coins<'a, I>( - &self, - utxos: I, - target_amount: u64, - fee_rate: FeeRate, - current_height: u32, - ) -> Result - where - I: IntoIterator, - { - // Default base size assumes 2 outputs (target + change) - let default_base_size = 10 + (34 * 2); - let input_size = 148; - self.select_coins_with_size( - utxos, - target_amount, - fee_rate, - current_height, - default_base_size, - input_size, - ) - } - - /// Select UTXOs for a target amount with custom transaction size parameters - pub fn select_coins_with_size<'a, I>( - &self, - utxos: I, - target_amount: u64, - fee_rate: FeeRate, - current_height: u32, - base_size: usize, - input_size: usize, - ) -> Result - where - I: IntoIterator, - { - // For strategies that need sorting, we must collect - // For others, we can work with iterators directly - match self.strategy { - SelectionStrategy::SmallestFirst - | SelectionStrategy::LargestFirst - | SelectionStrategy::SmallestFirstTill(_) - | SelectionStrategy::BranchAndBound - | SelectionStrategy::OptimalConsolidation => { - // These strategies need all UTXOs to sort/analyze - let mut available: Vec<&'a Utxo> = utxos - .into_iter() - .filter(|u| { - u.is_spendable(current_height) - && (self.include_unconfirmed || u.is_confirmed || u.is_instantlocked) - && (current_height.saturating_sub(u.height) >= self.min_confirmations - || u.height == 0) - }) - .collect(); - - if available.is_empty() { - return Err(SelectionError::NoUtxosAvailable); - } - - // Check if we have enough funds - let total_available: u64 = available.iter().map(|u| u.value()).sum(); - if total_available < target_amount { - return Err(SelectionError::InsufficientFunds { - available: total_available, - required: target_amount, - }); - } - - match self.strategy { - SelectionStrategy::SmallestFirst => { - available.sort_by_key(|u| u.value()); - self.accumulate_coins_with_size( - available, - target_amount, - fee_rate, - base_size, - input_size, - ) - } - SelectionStrategy::LargestFirst => { - available.sort_by_key(|u| Reverse(u.value())); - self.accumulate_coins_with_size( - available, - target_amount, - fee_rate, - base_size, - input_size, - ) - } - SelectionStrategy::SmallestFirstTill(threshold) => { - // Sort by value ascending (smallest first) - available.sort_by_key(|u| u.value()); - - // Take the first 'threshold' smallest, then sort the rest by largest - let threshold = threshold as usize; - if available.len() <= threshold { - // If we have fewer UTXOs than threshold, just use smallest first - self.accumulate_coins_with_size( - available, - target_amount, - fee_rate, - base_size, - input_size, - ) - } else { - // Split at threshold - let (smallest, rest) = available.split_at(threshold); - - // Sort the rest by largest first - let mut rest_vec = rest.to_vec(); - rest_vec.sort_by_key(|u| Reverse(u.value())); - - // Chain smallest first, then largest of the rest - let combined = smallest.iter().copied().chain(rest_vec); - self.accumulate_coins_with_size( - combined, - target_amount, - fee_rate, - base_size, - input_size, - ) - } - } - SelectionStrategy::BranchAndBound => { - // Sort by value descending for better pruning in branch and bound - available.sort_by_key(|u| Reverse(u.value())); - self.branch_and_bound_with_size( - available, - target_amount, - fee_rate, - base_size, - input_size, - ) - } - SelectionStrategy::OptimalConsolidation => self - .optimal_consolidation_with_size( - &available, - target_amount, - fee_rate, - base_size, - input_size, - ), - _ => unreachable!(), - } - } - SelectionStrategy::Random => { - // Random can work with iterators directly - let filtered = utxos.into_iter().filter(|u| { - u.is_spendable(current_height) - && (self.include_unconfirmed || u.is_confirmed || u.is_instantlocked) - && (current_height.saturating_sub(u.height) >= self.min_confirmations - || u.height == 0) - }); - - // For Random (currently just uses accumulate as-is) - // TODO: Implement proper random selection for privacy - self.accumulate_coins_with_size( - filtered, - target_amount, - fee_rate, - base_size, - input_size, - ) - } - } - } - - /// Simple accumulation strategy with custom transaction size parameters - fn accumulate_coins_with_size<'a, I>( - &self, - utxos: I, - target_amount: u64, - fee_rate: FeeRate, - base_size: usize, - input_size: usize, - ) -> Result - where - I: IntoIterator, - { - let mut selected = Vec::new(); - let mut total_value = 0u64; - - for utxo in utxos { - total_value += utxo.value(); - selected.push(utxo.clone()); - - // Calculate size with current inputs - let estimated_size = base_size + (input_size * selected.len()); - let estimated_fee = fee_rate.calculate_fee(estimated_size); - let required_amount = target_amount + estimated_fee; - - if total_value >= required_amount { - let change_amount = total_value - required_amount; - - // Check if change is dust - let (final_change, exact_match) = if change_amount < self.dust_threshold { - // Add dust to fee - (0, change_amount == 0) - } else { - (change_amount, false) - }; - - return Ok(SelectionResult { - selected, - total_value, - target_amount, - change_amount: final_change, - estimated_size, - estimated_fee: if final_change == 0 { - total_value - target_amount - } else { - estimated_fee - }, - exact_match, - }); - } - } - - Err(SelectionError::InsufficientFunds { - available: total_value, - required: target_amount, - }) - } - - /// Branch and bound coin selection with custom sizes (finds exact match if possible) - /// - /// This algorithm: - /// - Sorts UTXOs by value descending (largest first) - /// - Recursively explores combinations looking for exact matches - /// - Prunes branches that exceed the target by too much - /// - Falls back to simple accumulation if no exact match found - /// - /// Trade-offs vs OptimalConsolidation: - /// - Pros: Minimizes transaction fees by using fewer, larger UTXOs - /// - Pros: Faster to find solutions due to aggressive pruning - /// - Cons: May leave small UTXOs unconsolidated, leading to wallet fragmentation - /// - Cons: Less likely to find exact matches with larger denominations - fn branch_and_bound_with_size<'a, I>( - &self, - utxos: I, - target_amount: u64, - fee_rate: FeeRate, - base_size: usize, - input_size: usize, - ) -> Result - where - I: IntoIterator, - { - // Collect the UTXOs - they should already be in the right order if needed - let sorted_refs: Vec<&'a Utxo> = utxos.into_iter().collect(); - - // Try to find an exact match first - - // Use a simple recursive approach with memoization - let result = self.find_exact_match( - &sorted_refs, - target_amount, - fee_rate, - base_size, - input_size, - 0, - Vec::new(), - 0, - ); - - if let Some((selected, total)) = result { - let estimated_size = base_size + (input_size * selected.len()); - let estimated_fee = fee_rate.calculate_fee(estimated_size); - - return Ok(SelectionResult { - selected, - total_value: total, - target_amount, - change_amount: 0, - estimated_size, - estimated_fee, - exact_match: true, - }); - } - - // Fall back to accumulation if no exact match found - // For fallback, assume change output is needed - let base_size_with_change = base_size + 34; - self.accumulate_coins_with_size( - sorted_refs, - target_amount, - fee_rate, - base_size_with_change, - input_size, - ) - } - - /// Optimal consolidation strategy with custom sizes - /// Tries to find combinations that either: - /// 1. Match exactly (no change needed) - /// 2. Create minimal change while using smaller UTXOs - /// - /// This algorithm: - /// - Sorts UTXOs by value ascending (smallest first) - /// - Prioritizes exact matches using smaller denominations - /// - Falls back to minimal change if no exact match exists - /// - Helps reduce UTXO set size over time - /// - /// Trade-offs vs BranchAndBound: - /// - Pros: Reduces wallet fragmentation by consuming small UTXOs - /// - Pros: More likely to find exact matches with smaller denominations - /// - Pros: Better for long-term wallet health and UTXO management - /// - Cons: May result in higher fees due to more inputs - /// - Cons: Transactions may be larger due to using more UTXOs - /// - /// When to use this over BranchAndBound: - /// - When wallet has accumulated many small UTXOs (dust) - /// - During low-fee periods when consolidation is cheaper - /// - For wallets that receive many small payments - /// - When exact change is preferred to minimize privacy leaks - fn optimal_consolidation_with_size<'a>( - &self, - utxos: &[&'a Utxo], - target_amount: u64, - fee_rate: FeeRate, - base_size: usize, - input_size: usize, - ) -> Result { - // First, try to find an exact match using smaller UTXOs - // Sort by value ascending to prioritize using smaller UTXOs - let mut sorted_asc: Vec<&'a Utxo> = utxos.to_vec(); - sorted_asc.sort_by_key(|u| u.value()); - - // Try combinations of up to 10 UTXOs for exact match - - // Try to find exact match with smaller UTXOs first - for max_inputs in 1..=10.min(sorted_asc.len()) { - if let Some(combination) = self.find_exact_combination( - &sorted_asc, // Check all UTXOs - target_amount, - fee_rate, - base_size, - input_size, - max_inputs, - ) { - let estimated_size = base_size + (input_size * combination.len()); - let estimated_fee = fee_rate.calculate_fee(estimated_size); - - return Ok(SelectionResult { - selected: combination.clone(), - total_value: combination.iter().map(|u| u.value()).sum(), - target_amount, - change_amount: 0, - estimated_size, - estimated_fee, - exact_match: true, - }); - } - } - - // If no exact match, try to minimize change while consolidating small UTXOs - // Use a combination of smallest UTXOs that slightly exceeds the target - let base_size_with_change = base_size + 34; // Add change output to base size - let mut best_selection: Option> = None; - let mut best_change = u64::MAX; - - for i in 1..=sorted_asc.len().min(10) { - let mut current = Vec::new(); - let mut current_total = 0u64; - - for utxo in &sorted_asc[..i] { - current.push((*utxo).clone()); - current_total += utxo.value(); - } - - let estimated_size = base_size_with_change + (input_size * current.len()); - let estimated_fee = fee_rate.calculate_fee(estimated_size); - let required = target_amount + estimated_fee; - - if current_total >= required { - let change = current_total - required; - if change < best_change && change >= self.dust_threshold { - best_selection = Some(current); - best_change = change; - } - } - } - - if let Some(selected) = best_selection { - let estimated_size = base_size_with_change + (input_size * selected.len()); - let estimated_fee = fee_rate.calculate_fee(estimated_size); - let total_value: u64 = selected.iter().map(|u| u.value()).sum(); - - return Ok(SelectionResult { - selected, - total_value, - target_amount, - change_amount: best_change, - estimated_size, - estimated_fee, - exact_match: false, - }); - } - - // Fall back to accumulate if we couldn't find a good solution - // For fallback, assume change output is needed - let base_size_with_change = base_size + 34; - self.accumulate_coins_with_size( - sorted_asc, - target_amount, - fee_rate, - base_size_with_change, - input_size, - ) - } - - /// Find exact combination of UTXOs - fn find_exact_combination( - &self, - utxos: &[&Utxo], - target: u64, - fee_rate: FeeRate, - base_size: usize, - input_size: usize, - max_inputs: usize, - ) -> Option> { - // Simple subset sum solver for exact matches - // This is a simplified version - could be optimized with dynamic programming - - for num_inputs in 1..=max_inputs.min(utxos.len()) { - let estimated_size = base_size + (input_size * num_inputs); - let estimated_fee = fee_rate.calculate_fee(estimated_size); - let required = target + estimated_fee; - - // Try combinations of this size - if let Some(combo) = - Self::find_combination_recursive(utxos, required, num_inputs, 0, Vec::new(), 0) - { - return Some(combo); - } - } - - None - } - - /// Recursive helper to find exact combination - fn find_combination_recursive( - utxos: &[&Utxo], - target: u64, - remaining_picks: usize, - start_index: usize, - current: Vec, - current_sum: u64, - ) -> Option> { - if remaining_picks == 0 { - return if current_sum == target { - Some(current) - } else { - None - }; - } - - if start_index >= utxos.len() || current_sum > target { - return None; - } - - for i in start_index..=utxos.len().saturating_sub(remaining_picks) { - let mut new_current = current.clone(); - new_current.push(utxos[i].clone()); - let new_sum = current_sum + utxos[i].value(); - - if let Some(result) = Self::find_combination_recursive( - utxos, - target, - remaining_picks - 1, - i + 1, - new_current, - new_sum, - ) { - return Some(result); - } - } - - None - } - - /// Recursive helper for finding exact match - #[allow(clippy::too_many_arguments)] - fn find_exact_match( - &self, - utxos: &[&Utxo], - target: u64, - fee_rate: FeeRate, - base_size: usize, - input_size: usize, - index: usize, - mut current: Vec, - current_total: u64, - ) -> Option<(Vec, u64)> { - // Calculate required amount including fee - let estimated_size = base_size + (input_size * (current.len() + 1)); - let estimated_fee = fee_rate.calculate_fee(estimated_size); - let required = target + estimated_fee; - - // Check if we've found an exact match - if current_total == required { - return Some((current, current_total)); - } - - // Prune if we've exceeded the target - if current_total > required + self.dust_threshold { - return None; - } - - // Try remaining UTXOs - for i in index..utxos.len() { - let new_total = current_total + utxos[i].value(); - - // Skip if this would exceed our target by too much - if new_total > required + self.dust_threshold * 10 { - continue; - } - - current.push(utxos[i].clone()); - - if let Some(result) = self.find_exact_match( - utxos, - target, - fee_rate, - base_size, - input_size, - i + 1, - current.clone(), - new_total, - ) { - return Some(result); - } - - current.pop(); - } - - None - } -} - -/// Errors that can occur during coin selection -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SelectionError { - /// No UTXOs available for selection - NoUtxosAvailable, - /// Insufficient funds - InsufficientFunds { - available: u64, - required: u64, - }, - /// Selection failed - SelectionFailed(String), -} - -impl core::fmt::Display for SelectionError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - Self::NoUtxosAvailable => write!(f, "No UTXOs available for selection"), - Self::InsufficientFunds { - available, - required, - } => { - write!(f, "Insufficient funds: available {}, required {}", available, required) - } - Self::SelectionFailed(msg) => write!(f, "Selection failed: {}", msg), - } - } -} - -#[cfg(feature = "std")] -impl std::error::Error for SelectionError {} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_smallest_first_selection() { - let utxos = vec![ - Utxo::dummy(0, 10000, 100, false, true), - Utxo::dummy(0, 20000, 100, false, true), - Utxo::dummy(0, 30000, 100, false, true), - Utxo::dummy(0, 40000, 100, false, true), - ]; - - let selector = CoinSelector::new(SelectionStrategy::SmallestFirst); - let result = selector.select_coins(&utxos, 25000, FeeRate::new(1000), 200).unwrap(); - - // The algorithm should select the smallest UTXOs first: 10k + 20k = 30k which covers 25k target - assert_eq!(result.selected.len(), 2); // Should select 10k + 20k - assert_eq!(result.total_value, 30000); - assert!(result.change_amount > 0); - } - - #[test] - fn test_largest_first_selection() { - let utxos = vec![ - Utxo::dummy(0, 10000, 100, false, true), - Utxo::dummy(0, 20000, 100, false, true), - Utxo::dummy(0, 30000, 100, false, true), - Utxo::dummy(0, 40000, 100, false, true), - ]; - - let selector = CoinSelector::new(SelectionStrategy::LargestFirst); - let result = selector.select_coins(&utxos, 25000, FeeRate::new(1000), 200).unwrap(); - - assert_eq!(result.selected.len(), 1); // Should select just 40k - assert_eq!(result.total_value, 40000); - assert!(result.change_amount > 0); - } - - #[test] - fn test_insufficient_funds() { - let utxos = - vec![Utxo::dummy(0, 10000, 100, false, true), Utxo::dummy(0, 20000, 100, false, true)]; - - let selector = CoinSelector::new(SelectionStrategy::LargestFirst); - let result = selector.select_coins(&utxos, 50000, FeeRate::new(1000), 200); - - assert!(matches!(result, Err(SelectionError::InsufficientFunds { .. }))); - } - - #[test] - fn test_optimal_consolidation_strategy() { - // Test that OptimalConsolidation strategy works correctly - let utxos = vec![ - Utxo::dummy(0, 100, 100, false, true), - Utxo::dummy(0, 200, 100, false, true), - Utxo::dummy(0, 300, 100, false, true), - Utxo::dummy(0, 500, 100, false, true), - Utxo::dummy(0, 1000, 100, false, true), - Utxo::dummy(0, 2000, 100, false, true), - ]; - - let selector = CoinSelector::new(SelectionStrategy::OptimalConsolidation); - let fee_rate = FeeRate::new(100); // Simpler fee rate - let result = selector.select_coins(&utxos, 1500, fee_rate, 200).unwrap(); - - // OptimalConsolidation should work and produce a valid selection - assert!(!result.selected.is_empty()); - assert!(result.total_value >= 1500 + result.estimated_fee); - assert_eq!(result.target_amount, 1500); - - // The strategy should prefer smaller UTXOs, so it should include - // some of the smaller values - let selected_values: Vec = result.selected.iter().map(|u| u.value()).collect(); - let has_small_utxos = selected_values.iter().any(|&v| v <= 500); - assert!(has_small_utxos, "Should include at least one small UTXO for consolidation"); - } -} diff --git a/key-wallet/src/wallet/managed_wallet_info/mod.rs b/key-wallet/src/wallet/managed_wallet_info/mod.rs index 7c49fb021..db5360c6b 100644 --- a/key-wallet/src/wallet/managed_wallet_info/mod.rs +++ b/key-wallet/src/wallet/managed_wallet_info/mod.rs @@ -3,13 +3,9 @@ //! This module contains the mutable metadata and information about a wallet //! that is managed separately from the core wallet structure. -pub mod coin_selection; -pub mod fee; pub mod helpers; pub mod managed_account_operations; pub mod managed_accounts; -pub mod transaction_builder; -pub mod transaction_building; pub mod wallet_info_interface; pub use managed_account_operations::ManagedAccountOperations; diff --git a/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs b/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs deleted file mode 100644 index c8cca6b37..000000000 --- a/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs +++ /dev/null @@ -1,1244 +0,0 @@ -//! Transaction building with dashcore types -//! -//! This module provides high-level transaction building functionality -//! using types from the dashcore crate. - -use alloc::vec::Vec; -use core::fmt; - -use dashcore::blockdata::script::{Builder, PushBytes, ScriptBuf}; -use dashcore::blockdata::transaction::special_transaction::{ - asset_lock::AssetLockPayload, - coinbase::CoinbasePayload, - provider_registration::{ProviderMasternodeType, ProviderRegistrationPayload}, - provider_update_registrar::ProviderUpdateRegistrarPayload, - provider_update_revocation::ProviderUpdateRevocationPayload, - provider_update_service::ProviderUpdateServicePayload, - TransactionPayload, -}; -use dashcore::blockdata::transaction::Transaction; -use dashcore::bls_sig_utils::{BLSPublicKey, BLSSignature}; -use dashcore::hash_types::{InputsHash, MerkleRootMasternodeList, MerkleRootQuorums, PubkeyHash}; -use dashcore::sighash::{EcdsaSighashType, SighashCache}; -use dashcore::Address; -use dashcore::{OutPoint, TxIn, TxOut, Txid}; -use dashcore_hashes::Hash; -use secp256k1::{Message, Secp256k1, SecretKey}; -use std::net::SocketAddr; - -use crate::wallet::managed_wallet_info::coin_selection::{CoinSelector, SelectionStrategy}; -use crate::wallet::managed_wallet_info::fee::FeeRate; -use crate::Utxo; - -/// Calculate varint size for a given number -fn varint_size(n: usize) -> usize { - match n { - 0..=0xFC => 1, - 0xFD..=0xFFFF => 3, - 0x10000..=0xFFFFFFFF => 5, - _ => 9, - } -} - -/// Transaction builder for creating Dash transactions -/// -/// This builder implements BIP-69 (Lexicographical Indexing of Transaction Inputs and Outputs) -/// to ensure deterministic ordering and improve privacy by preventing information leakage -/// through predictable input/output ordering patterns. -pub struct TransactionBuilder { - /// Selected UTXOs with their private keys - inputs: Vec<(Utxo, Option)>, - /// Outputs to create - outputs: Vec, - /// Change address - change_address: Option
, - /// Fee rate (satoshis per kilobyte) - fee_rate: FeeRate, - /// Lock time - lock_time: u32, - /// Transaction version - version: u16, - /// Special transaction payload for Dash-specific transactions - special_payload: Option, -} - -impl Default for TransactionBuilder { - fn default() -> Self { - Self::new() - } -} - -impl TransactionBuilder { - /// Create a new transaction builder - pub fn new() -> Self { - Self { - inputs: Vec::new(), - outputs: Vec::new(), - change_address: None, - fee_rate: FeeRate::normal(), - lock_time: 0, - version: 2, // Default to version 2 for Dash - special_payload: None, - } - } - - pub fn outputs(&self) -> &Vec { - &self.outputs - } - - /// Add a UTXO input with optional private key for signing - pub fn add_input(mut self, utxo: Utxo, key: Option) -> Self { - self.inputs.push((utxo, key)); - self - } - - /// Add multiple inputs - pub fn add_inputs(mut self, inputs: Vec<(Utxo, Option)>) -> Self { - self.inputs.extend(inputs); - self - } - - /// Select inputs automatically using coin selection - /// - /// This method requires outputs to be added first so it knows how much to select. - /// For special transactions without regular outputs, add the required inputs manually. - pub fn select_inputs( - mut self, - available_utxos: &[Utxo], - strategy: SelectionStrategy, - current_height: u32, - keys: impl Fn(&Utxo) -> Option, - ) -> Result { - // Calculate target amount from outputs - let target_amount = self.total_output_value(); - - if target_amount == 0 && self.special_payload.is_none() { - return Err(BuilderError::NoOutputs); - } - - // Calculate the base transaction size including existing outputs and special payload - let base_size = self.calculate_base_size(); - let input_size = 148; // Size per P2PKH input - - let fee_rate = self.fee_rate; - - // Use the CoinSelector with the proper size context - let selector = CoinSelector::new(strategy); - let selection = selector - .select_coins_with_size( - available_utxos, - target_amount, - fee_rate, - current_height, - base_size, - input_size, - ) - .map_err(BuilderError::CoinSelection)?; - - // Add selected UTXOs with their keys - for utxo in selection.selected { - let key = keys(&utxo); - self.inputs.push((utxo, key)); - } - - Ok(self) - } - - /// Add an output to a specific address - /// - /// Note: Outputs will be sorted according to BIP-69 when the transaction is built: - /// - First by amount (ascending) - /// - Then by scriptPubKey (lexicographically) - pub fn add_output(mut self, address: &Address, amount: u64) -> Result { - if amount == 0 { - return Err(BuilderError::InvalidAmount("Output amount cannot be zero".into())); - } - - let script_pubkey = address.script_pubkey(); - self.outputs.push(TxOut { - value: amount, - script_pubkey, - }); - Ok(self) - } - - /// Add a data output (OP_RETURN) - /// - /// Note: Outputs will be sorted according to BIP-69 when the transaction is built: - /// - First by amount (ascending) - data outputs have 0 value - /// - Then by scriptPubKey (lexicographically) - pub fn add_data_output(mut self, data: Vec) -> Result { - if data.len() > 80 { - return Err(BuilderError::InvalidData("Data output too large (max 80 bytes)".into())); - } - - let script = Builder::new() - .push_opcode(dashcore::blockdata::opcodes::all::OP_RETURN) - .push_slice( - <&PushBytes>::try_from(data.as_slice()) - .map_err(|_| BuilderError::InvalidData("Invalid data length".into()))?, - ) - .into_script(); - - self.outputs.push(TxOut { - value: 0, - script_pubkey: script, - }); - Ok(self) - } - - /// Set the change address - pub fn set_change_address(mut self, address: Address) -> Self { - self.change_address = Some(address); - self - } - - /// Set the fee rate - pub fn set_fee_rate(mut self, fee_rate: FeeRate) -> Self { - self.fee_rate = fee_rate; - self - } - - /// Set the lock time - pub fn set_lock_time(mut self, lock_time: u32) -> Self { - self.lock_time = lock_time; - self - } - - /// Set the transaction version - pub fn set_version(mut self, version: u16) -> Self { - self.version = version; - self - } - - /// Set the special transaction payload - pub fn set_special_payload(mut self, payload: TransactionPayload) -> Self { - self.special_payload = Some(payload); - self - } - - /// Get the total value of all outputs added so far - pub fn total_output_value(&self) -> u64 { - self.outputs.iter().map(|out| out.value).sum() - } - - /// Calculate the base transaction size excluding inputs - /// Based on dashsync/DashSync/shared/Models/Transactions/Base/DSTransaction.m - fn calculate_base_size(&self) -> usize { - // Base: version (2) + type (2) + locktime (4) = 8 bytes - let mut size = 8; - - // Add varint for input count (will be added later, typically 1 byte) - size += 1; - - // Add varint for output count - size += varint_size( - self.outputs.len() - + if self.change_address.is_some() { - 1 - } else { - 0 - }, - ); - - // Add outputs size (TX_OUTPUT_SIZE = 34 bytes per P2PKH output) - size += self.outputs.len() * 34; - - // Add change output if we have a change address - if self.change_address.is_some() { - size += 34; // TX_OUTPUT_SIZE - } - - // Add special payload size if present - // Based on dashsync payload size calculations - if let Some(ref payload) = self.special_payload { - let payload_size = match payload { - TransactionPayload::CoinbasePayloadType(p) => { - // version (2) + height (4) + merkleRootMasternodeList (32) + merkleRootQuorums (32) - let mut size = 2 + 4 + 32 + 32; - // Optional fields for newer versions - if p.best_cl_height.is_some() { - size += 4; // best_cl_height - size += 96; // best_cl_signature (BLS) - } - if p.asset_locked_amount.is_some() { - size += 8; // asset_locked_amount - } - size - } - TransactionPayload::ProviderRegistrationPayloadType(p) => { - // Base payload + signature - // version (2) + type (2) + mode (2) + collateralHash (32) + collateralIndex (4) - // + ipAddress (16) + port (2) + KeyIDOwner (20) + KeyIDOperator (20) + KeyIDVoting (20) - // + operatorReward (2) + scriptPayoutSize + scriptPayout + inputsHash (32) - // + payloadSigSize (1-9) + payloadSig (up to 75) - let script_size = p.script_payout.len(); - let base = 2 - + 2 - + 2 - + 32 - + 4 - + 16 - + 2 - + 20 - + 20 - + 20 - + 2 - + varint_size(script_size) - + script_size - + 32; - base + varint_size(75) + 75 // MAX_ECDSA_SIGNATURE_SIZE = 75 - } - TransactionPayload::ProviderUpdateServicePayloadType(p) => { - // version (2) + optionally mn_type (2) + proTxHash (32) + ipAddress (16) + port (2) - // + scriptPayoutSize + scriptPayout + inputsHash (32) + payloadSig (96 for BLS) - let script_size = p.script_payout.len(); - let mut size = - 2 + 32 + 16 + 2 + varint_size(script_size) + script_size + 32 + 96; - if p.mn_type.is_some() { - size += 2; // mn_type for BasicBLS version - } - // Platform fields for Evo masternodes - if p.platform_node_id.is_some() { - size += 20; // platform_node_id - size += 2; // platform_p2p_port - size += 2; // platform_http_port - } - size - } - TransactionPayload::ProviderUpdateRegistrarPayloadType(p) => { - // version (2) + proTxHash (32) + mode (2) + PubKeyOperator (48) + KeyIDVoting (20) - // + scriptPayoutSize + scriptPayout + inputsHash (32) + payloadSig (up to 75) - let script_size = p.script_payout.len(); - 2 + 32 + 2 + 48 + 20 + varint_size(script_size) + script_size + 32 + 75 - } - TransactionPayload::ProviderUpdateRevocationPayloadType(_) => { - // version (2) + proTxHash (32) + reason (2) + inputsHash (32) + payloadSig (96 for BLS) - 2 + 32 + 2 + 32 + 96 - } - TransactionPayload::AssetLockPayloadType(p) => { - // version (1) + creditOutputsCount + creditOutputs - 1 + varint_size(p.credit_outputs.len()) + p.credit_outputs.len() * 34 - } - TransactionPayload::AssetUnlockPayloadType(_p) => { - // version (1) + index (8) + fee (4) + requestHeight (4) + quorumHash (32) + quorumSig (96) - 1 + 8 + 4 + 4 + 32 + 96 - } - _ => 100, // Default estimate for unknown types - }; - - // Add varint for payload length - size += varint_size(payload_size) + payload_size; - } - - size - } - - /// Calculates the transaction fee for the current number of outputs and inputs - pub fn calculate_fee(&self) -> u64 { - let fee_rate = self.fee_rate; - let estimated_size = self.estimate_transaction_size(self.inputs.len(), self.outputs.len()); - fee_rate.calculate_fee(estimated_size) - } - - /// Calculates the transaction fee adding an extra output - /// - /// This is useful when you need to calculate the transaction fee to be - /// able to calculate the change amount to later add it as a new output. - /// Basically we are calculating the fee with that extra change output before - /// adding it - pub fn calculate_fee_with_extra_output(&self) -> u64 { - let fee_rate = self.fee_rate; - let estimated_size = - self.estimate_transaction_size(self.inputs.len(), self.outputs.len() + 1); - fee_rate.calculate_fee(estimated_size) - } - - /// Build the transaction - pub fn build(&mut self) -> Result { - if self.inputs.is_empty() { - return Err(BuilderError::NoInputs); - } - - if self.outputs.is_empty() { - return Err(BuilderError::NoOutputs); - } - - // Calculate total input value - let total_input: u64 = self.inputs.iter().map(|(utxo, _)| utxo.value()).sum(); - - // Calculate total output value - let total_output: u64 = self.outputs.iter().map(|out| out.value).sum(); - - if total_input < total_output { - return Err(BuilderError::InsufficientFunds { - available: total_input, - required: total_output, - }); - } - - // BIP-69: Sort inputs by transaction hash (reversed) and then by output index - // We need to maintain the association between UTXOs and their keys - let mut sorted_inputs = self.inputs.clone(); - sorted_inputs.sort_by(|a, b| { - // First compare by transaction hash (reversed byte order) - let tx_hash_a = a.0.outpoint.txid.to_byte_array(); - let tx_hash_b = b.0.outpoint.txid.to_byte_array(); - - match tx_hash_a.cmp(&tx_hash_b) { - std::cmp::Ordering::Equal => { - // If transaction hashes match, compare by output index - a.0.outpoint.vout.cmp(&b.0.outpoint.vout) - } - other => other, - } - }); - - // Create transaction inputs from sorted inputs - // Dash doesn't use RBF, so we use the standard sequence number - let sequence = 0xffffffff; - - let tx_inputs: Vec = sorted_inputs - .iter() - .map(|(utxo, _)| TxIn { - previous_output: utxo.outpoint, - script_sig: ScriptBuf::new(), - sequence, - witness: dashcore::blockdata::witness::Witness::new(), - }) - .collect(); - - let mut tx_outputs = self.outputs.clone(); - - let fee = self.calculate_fee_with_extra_output(); - - let change_amount = total_input.saturating_sub(total_output).saturating_sub(fee); - - // Add change output if needed - if change_amount > 546 { - // Above dust threshold - if let Some(change_addr) = &self.change_address { - let change_script = change_addr.script_pubkey(); - tx_outputs.push(TxOut { - value: change_amount, - script_pubkey: change_script, - }); - } else { - return Err(BuilderError::NoChangeAddress); - } - } - - // BIP-69: Sort outputs by amount first, then by scriptPubKey lexicographically - tx_outputs.sort_by(|a, b| { - match a.value.cmp(&b.value) { - std::cmp::Ordering::Equal => { - // If amounts match, compare scriptPubKeys lexicographically - a.script_pubkey.as_bytes().cmp(b.script_pubkey.as_bytes()) - } - other => other, - } - }); - - // Create unsigned transaction with optional special payload - // Update sorted_inputs to maintain the key association after sorting - let mut transaction = Transaction { - version: self.version, - lock_time: self.lock_time, - input: tx_inputs, - output: tx_outputs, - special_transaction_payload: self.special_payload.clone(), - }; - - // Sign inputs if keys are provided - if sorted_inputs.iter().any(|(_, key)| key.is_some()) { - transaction = self.sign_transaction_with_sorted_inputs(transaction, sorted_inputs)?; - } - - Ok(transaction) - } - - /// Build a Provider Registration Transaction (ProRegTx) - /// - /// Used to register a new masternode on the network - /// - /// Note: This method intentionally takes many parameters rather than a single - /// payload object to make the API more explicit and allow callers to construct - /// transactions without needing to build intermediate payload types. - #[allow(clippy::too_many_arguments)] - pub fn build_provider_registration( - self, - masternode_type: ProviderMasternodeType, - masternode_mode: u16, - collateral_outpoint: OutPoint, - service_address: SocketAddr, - owner_key_hash: PubkeyHash, - operator_public_key: BLSPublicKey, - voting_key_hash: PubkeyHash, - operator_reward: u16, - script_payout: ScriptBuf, - inputs_hash: InputsHash, - signature: Vec, - platform_node_id: Option, - platform_p2p_port: Option, - platform_http_port: Option, - ) -> Result { - let payload = ProviderRegistrationPayload { - version: 2, - masternode_type, - masternode_mode, - collateral_outpoint, - service_address, - owner_key_hash, - operator_public_key, - voting_key_hash, - operator_reward, - script_payout, - inputs_hash, - signature, - platform_node_id, - platform_p2p_port, - platform_http_port, - }; - - self.set_special_payload(TransactionPayload::ProviderRegistrationPayloadType(payload)) - .build() - } - - /// Build a Provider Update Service Transaction (ProUpServTx) - /// - /// Used to update the service details of an existing masternode - /// - /// Note: This method intentionally takes many parameters rather than a single - /// payload object to make the API more explicit and allow callers to construct - /// transactions without needing to build intermediate payload types. - #[allow(clippy::too_many_arguments)] - pub fn build_provider_update_service( - self, - mn_type: Option, - pro_tx_hash: Txid, - ip_address: u128, - port: u16, - script_payout: ScriptBuf, - inputs_hash: InputsHash, - platform_node_id: Option<[u8; 20]>, - platform_p2p_port: Option, - platform_http_port: Option, - payload_sig: BLSSignature, - ) -> Result { - let payload = ProviderUpdateServicePayload { - version: 2, - mn_type, - pro_tx_hash, - ip_address, - port, - script_payout, - inputs_hash, - platform_node_id, - platform_p2p_port, - platform_http_port, - payload_sig, - }; - self.set_special_payload(TransactionPayload::ProviderUpdateServicePayloadType(payload)) - .build() - } - - /// Build a Provider Update Registrar Transaction (ProUpRegTx) - /// - /// Used to update the registrar details of an existing masternode - /// - /// Note: This method intentionally takes many parameters rather than a single - /// payload object to make the API more explicit and allow callers to construct - /// transactions without needing to build intermediate payload types. - #[allow(clippy::too_many_arguments)] - pub fn build_provider_update_registrar( - self, - pro_tx_hash: Txid, - provider_mode: u16, - operator_public_key: BLSPublicKey, - voting_key_hash: PubkeyHash, - script_payout: ScriptBuf, - inputs_hash: InputsHash, - payload_sig: Vec, - ) -> Result { - let payload = ProviderUpdateRegistrarPayload { - version: 2, - pro_tx_hash, - provider_mode, - operator_public_key, - voting_key_hash, - script_payout, - inputs_hash, - payload_sig, - }; - self.set_special_payload(TransactionPayload::ProviderUpdateRegistrarPayloadType(payload)) - .build() - } - - /// Build a Provider Update Revocation Transaction (ProUpRevTx) - /// - /// Used to revoke an existing masternode - pub fn build_provider_update_revocation( - self, - pro_tx_hash: Txid, - reason: u16, - inputs_hash: InputsHash, - payload_sig: BLSSignature, - ) -> Result { - let payload = ProviderUpdateRevocationPayload { - version: 2, - pro_tx_hash, - reason, - inputs_hash, - payload_sig, - }; - self.set_special_payload(TransactionPayload::ProviderUpdateRevocationPayloadType(payload)) - .build() - } - - /// Build a Coinbase Transaction - /// - /// Used for block rewards and includes additional coinbase-specific data - pub fn build_coinbase( - self, - height: u32, - merkle_root_masternode_list: MerkleRootMasternodeList, - merkle_root_quorums: MerkleRootQuorums, - best_cl_height: Option, - best_cl_signature: Option, - asset_locked_amount: Option, - ) -> Result { - let payload = CoinbasePayload { - version: 3, // Current coinbase version - height, - merkle_root_masternode_list, - merkle_root_quorums, - best_cl_height, - best_cl_signature, - asset_locked_amount, - }; - self.set_special_payload(TransactionPayload::CoinbasePayloadType(payload)).build() - } - - /// Build an Asset Lock Transaction - /// - /// Used to lock Dash for use in Platform (creates Platform credits) - pub fn build_asset_lock(self, credit_outputs: Vec) -> Result { - let payload = AssetLockPayload { - version: 0, - credit_outputs, - }; - self.set_special_payload(TransactionPayload::AssetLockPayloadType(payload)).build() - } - - /// Estimate transaction size in bytes - fn estimate_transaction_size(&self, input_count: usize, output_count: usize) -> usize { - // Base: version (2) + type (2) + locktime (4) = 8 bytes - let mut size = 8; - - // Add varints for input/output counts - size += varint_size(input_count); - size += varint_size(output_count); - - // Add inputs (TX_INPUT_SIZE = 148 bytes per P2PKH input) - size += input_count * 148; - - // Add outputs (TX_OUTPUT_SIZE = 34 bytes per P2PKH output) - size += output_count * 34; - - // Add special payload size if present (same logic as calculate_base_size) - if let Some(ref payload) = self.special_payload { - let payload_size = match payload { - TransactionPayload::CoinbasePayloadType(p) => { - let mut size = 2 + 4 + 32 + 32; - if p.best_cl_height.is_some() { - size += 4 + 96; - } - if p.asset_locked_amount.is_some() { - size += 8; - } - size - } - TransactionPayload::ProviderRegistrationPayloadType(p) => { - let script_size = p.script_payout.len(); - let base = 2 - + 2 - + 2 - + 32 - + 4 - + 16 - + 2 - + 20 - + 20 - + 20 - + 2 - + varint_size(script_size) - + script_size - + 32; - base + varint_size(75) + 75 - } - TransactionPayload::ProviderUpdateServicePayloadType(p) => { - let script_size = p.script_payout.len(); - let mut size = - 2 + 32 + 16 + 2 + varint_size(script_size) + script_size + 32 + 96; - if p.mn_type.is_some() { - size += 2; - } - if p.platform_node_id.is_some() { - size += 20 + 2 + 2; - } - size - } - TransactionPayload::ProviderUpdateRegistrarPayloadType(p) => { - let script_size = p.script_payout.len(); - 2 + 32 + 2 + 48 + 20 + varint_size(script_size) + script_size + 32 + 75 - } - TransactionPayload::ProviderUpdateRevocationPayloadType(_) => 2 + 32 + 2 + 32 + 96, - TransactionPayload::AssetLockPayloadType(p) => { - 1 + varint_size(p.credit_outputs.len()) + p.credit_outputs.len() * 34 - } - TransactionPayload::AssetUnlockPayloadType(_) => 1 + 8 + 4 + 4 + 32 + 96, - _ => 100, - }; - - size += varint_size(payload_size) + payload_size; - } - - size - } - - /// Sign the transaction with sorted inputs (for BIP-69 compliance) - fn sign_transaction_with_sorted_inputs( - &self, - mut tx: Transaction, - sorted_inputs: Vec<(Utxo, Option)>, - ) -> Result { - let secp = Secp256k1::new(); - - // Collect all signatures first, then apply them - let mut signatures = Vec::new(); - { - let cache = SighashCache::new(&tx); - - for (index, (utxo, key_opt)) in sorted_inputs.iter().enumerate() { - if let Some(key) = key_opt { - // Get the script pubkey from the UTXO - let script_pubkey = &utxo.txout.script_pubkey; - - // Create signature hash for P2PKH - let sighash = cache - .legacy_signature_hash(index, script_pubkey, EcdsaSighashType::All.to_u32()) - .map_err(|e| { - BuilderError::SigningFailed(format!("Failed to compute sighash: {}", e)) - })?; - - // Sign the hash - let message = Message::from_digest(*sighash.as_byte_array()); - let signature = secp.sign_ecdsa(&message, key); - - // Create script signature (P2PKH) - let mut sig_bytes = signature.serialize_der().to_vec(); - sig_bytes.push(EcdsaSighashType::All.to_u32() as u8); - - let pubkey = secp256k1::PublicKey::from_secret_key(&secp, key); - - let script_sig = Builder::new() - .push_slice(<&PushBytes>::try_from(sig_bytes.as_slice()).map_err(|_| { - BuilderError::SigningFailed("Invalid signature length".into()) - })?) - .push_slice(pubkey.serialize()) - .into_script(); - - signatures.push((index, script_sig)); - } else { - signatures.push((index, ScriptBuf::new())); - } - } - } // cache goes out of scope here - - // Apply signatures - for (index, script_sig) in signatures { - tx.input[index].script_sig = script_sig; - } - - Ok(tx) - } - - /// Sign the transaction (legacy method for backward compatibility) - pub fn sign_transaction(&self, tx: Transaction) -> Result { - // For backward compatibility, we sort the inputs according to BIP-69 before signing - let mut sorted_inputs = self.inputs.clone(); - sorted_inputs.sort_by(|a, b| { - let tx_hash_a = a.0.outpoint.txid.to_byte_array(); - let tx_hash_b = b.0.outpoint.txid.to_byte_array(); - - match tx_hash_a.cmp(&tx_hash_b) { - std::cmp::Ordering::Equal => a.0.outpoint.vout.cmp(&b.0.outpoint.vout), - other => other, - } - }); - - self.sign_transaction_with_sorted_inputs(tx, sorted_inputs) - } -} - -/// Errors that can occur during transaction building -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum BuilderError { - /// No inputs provided - NoInputs, - /// No outputs provided - NoOutputs, - /// No change address provided - NoChangeAddress, - /// Insufficient funds - InsufficientFunds { - available: u64, - required: u64, - }, - /// Invalid amount - InvalidAmount(String), - /// Invalid data - InvalidData(String), - /// Signing failed - SigningFailed(String), - /// Coin selection error - CoinSelection(crate::wallet::managed_wallet_info::coin_selection::SelectionError), -} - -impl fmt::Display for BuilderError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::NoInputs => write!(f, "No inputs provided"), - Self::NoOutputs => write!(f, "No outputs provided"), - Self::NoChangeAddress => write!(f, "No change address provided"), - Self::InsufficientFunds { - available, - required, - } => { - write!(f, "Insufficient funds: available {}, required {}", available, required) - } - Self::InvalidAmount(msg) => write!(f, "Invalid amount: {}", msg), - Self::InvalidData(msg) => write!(f, "Invalid data: {}", msg), - Self::SigningFailed(msg) => write!(f, "Signing failed: {}", msg), - Self::CoinSelection(err) => write!(f, "Coin selection error: {}", err), - } - } -} - -#[cfg(feature = "std")] -impl std::error::Error for BuilderError {} - -#[cfg(test)] -mod tests { - use super::*; - use crate::Network; - use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; - use dashcore_hashes::{sha256d, Hash}; - use hex; - - #[test] - fn test_transaction_builder_basic() { - let utxo = Utxo::dummy(0, 100000, 100, false, true); - let destination = Address::dummy(Network::Testnet, 0); - let change = Address::dummy(Network::Testnet, 0); - - let tx = TransactionBuilder::new() - .add_input(utxo, None) - .add_output(&destination, 50000) - .unwrap() - .set_change_address(change) - .build(); - - assert!(tx.is_ok()); - let transaction = tx.unwrap(); - assert_eq!(transaction.input.len(), 1); - assert_eq!(transaction.output.len(), 2); // Output + change - } - - #[test] - fn test_insufficient_funds() { - let utxo = Utxo::dummy(0, 10000, 100, false, true); - let destination = Address::dummy(Network::Testnet, 0); - - let result = TransactionBuilder::new() - .add_input(utxo, None) - .add_output(&destination, 50000) - .unwrap() - .build(); - - assert!(matches!(result, Err(BuilderError::InsufficientFunds { .. }))); - } - - #[test] - fn test_asset_lock_transaction() { - // Test based on DSTransactionTests.m testAssetLockTx1 - use dashcore::consensus::Decodable; - let hex_data = hex::decode("0300080001eecf4e8f1ffd3a3a4e5033d618231fd05e5f08c1a727aac420f9a26db9bf39eb010000006a473044022026f169570532332f857cb64a0b7d9c0837d6f031633e1d6c395d7c03b799460302207eba4c4575a66803cecf50b61ff5f2efc2bd4e61dff00d9d4847aa3d8b1a5e550121036cd0b73d304bacc80fa747d254fbc5f0bf944dd8c8b925cd161bb499b790d08d0000000002317dd0be030000002321022ca85dba11c4e5a6da3a00e73a08765319a5d66c2f6434b288494337b0c9ed2dac6df29c3b00000000026a000000000046010200e1f505000000001976a9147c75beb097957cc09537b615dde9ea6807719cdf88ac6d11a735000000001976a9147c75beb097957cc09537b615dde9ea6807719cdf88ac").unwrap(); - - let mut cursor = std::io::Cursor::new(hex_data); - let tx = Transaction::consensus_decode(&mut cursor).unwrap(); - - assert_eq!(tx.version, 3); - assert_eq!(tx.lock_time, 0); - assert_eq!(tx.input.len(), 1); - assert_eq!(tx.output.len(), 2); - - // Verify it's an asset lock transaction - if let Some(TransactionPayload::AssetLockPayloadType(payload)) = - &tx.special_transaction_payload - { - assert_eq!(payload.version, 1); - assert_eq!(payload.credit_outputs.len(), 2); - assert_eq!(payload.credit_outputs[0].value, 100000000); - assert_eq!(payload.credit_outputs[1].value, 900141421); - } else { - panic!("Expected AssetLockPayload"); - } - } - - #[test] - fn test_coinbase_transaction() { - // Test based on DSTransactionTests.m testCoinbaseTransaction - use dashcore::consensus::Decodable; - let hex_data = hex::decode("03000500010000000000000000000000000000000000000000000000000000000000000000ffffffff0502f6050105ffffffff0200c11a3d050000002321038df098a36af5f1b7271e32ad52947f64c1ad70c16a8a1a987105eaab5daa7ad2ac00c11a3d050000001976a914bfb885c89c83cd44992a8ade29b610e6ddf00c5788ac00000000260100f6050000aaaec8d6a8535a01bd844817dea1faed66f6c397b1dcaec5fe8c5af025023c35").unwrap(); - - let mut cursor = std::io::Cursor::new(hex_data); - let tx = Transaction::consensus_decode(&mut cursor).unwrap(); - - assert_eq!(tx.version, 3); - assert_eq!(tx.lock_time, 0); - // Check if it's a coinbase transaction by checking if first input has null previous_output - assert_eq!( - tx.input[0].previous_output.txid, - Txid::from_raw_hash(sha256d::Hash::from_slice(&[0u8; 32]).unwrap()) - ); - assert_eq!(tx.input[0].previous_output.vout, 0xffffffff); - assert_eq!(tx.output.len(), 2); - - // Verify txid matches expected - let expected_txid = "5b4e5e99e967e01e27627621df00c44525507a31201ceb7b96c6e1a452e82bef"; - assert_eq!(tx.txid().to_string(), expected_txid); - } - - #[test] - fn test_transaction_size_estimation() { - // Test that transaction size estimation is accurate - let utxos = vec![ - Utxo::dummy(0, 100000, 100, false, true), - Utxo::dummy(0, 200000, 100, false, true), - ]; - - let recipient_address = Address::dummy(Network::Testnet, 0); - let change_address = Address::dummy(Network::Testnet, 0); - - let builder = TransactionBuilder::new() - .set_fee_rate(FeeRate::normal()) - .set_change_address(change_address.clone()) - .add_output(&recipient_address, 150000) - .unwrap() - .add_inputs(utxos.into_iter().map(|u| (u, None)).collect()); - - // Test calculate_base_size - let base_size = builder.calculate_base_size(); - // Base (8) + input varint (1) + output varint (1) + 1 output (34) + 1 change (34) = 78 bytes - assert!( - base_size > 70 && base_size < 85, - "Base size should be around 78 bytes, got {}", - base_size - ); - - // Test estimate_transaction_size - let estimated_size = builder.estimate_transaction_size(2, 2); - // Base (8) + varints (2) + 2 inputs (296) + 2 outputs (68) = ~374 bytes - assert!( - estimated_size > 370 && estimated_size < 380, - "Estimated size should be around 374 bytes, got {}", - estimated_size - ); - } - - #[test] - fn test_fee_calculation() { - // Test that fees are calculated correctly - let utxos = vec![Utxo::dummy(0, 1000000, 100, false, true)]; - - let recipient_address = Address::dummy(Network::Testnet, 0); - let change_address = Address::dummy(Network::Testnet, 0); - - let tx = TransactionBuilder::new() - .set_fee_rate(FeeRate::normal()) // 1 duff per byte - .set_change_address(change_address.clone()) - .add_inputs(utxos.into_iter().map(|u| (u, None)).collect()) - .add_output(&recipient_address, 500000) - .unwrap() - .build() - .unwrap(); - - // Total input: 1000000 - // Output to recipient: 500000 - // Change output should be approximately: 1000000 - 500000 - fee - // Fee should be roughly 226 duffs for a 1-input, 2-output transaction - let total_output: u64 = tx.output.iter().map(|o| o.value).sum(); - let fee = 1000000 - total_output; - - assert!(fee > 200 && fee < 300, "Fee should be around 226 duffs, got {}", fee); - } - - #[test] - fn test_exact_change_no_change_output() { - // Test when the exact amount is used (no change output needed) - let utxos = vec![Utxo::dummy(0, 150226, 100, false, true)]; // Exact amount for output + fee - - let recipient_address = Address::dummy(Network::Testnet, 0); - let change_address = Address::dummy(Network::Testnet, 0); - - let tx = TransactionBuilder::new() - .set_fee_rate(FeeRate::normal()) - .set_change_address(change_address.clone()) - .add_inputs(utxos.into_iter().map(|u| (u, None)).collect()) - .add_output(&recipient_address, 150000) - .unwrap() - .build() - .unwrap(); - - // Should only have 1 output (no change) because change is below dust threshold - assert_eq!(tx.output.len(), 1); - assert_eq!(tx.output[0].value, 150000); - } - - #[test] - fn test_special_payload_size_calculations() { - // Test that special payload sizes are calculated correctly - let utxo = Utxo::dummy(0, 100000, 100, false, true); - let destination = Address::dummy(Network::Testnet, 0); - let change = Address::dummy(Network::Testnet, 0); - - // Test with AssetLock payload - let credit_outputs = vec![ - TxOut { - value: 100000000, - script_pubkey: ScriptBuf::new(), - }, - TxOut { - value: 895000941, - script_pubkey: ScriptBuf::new(), - }, - ]; - - let asset_lock_payload = AssetLockPayload { - version: 1, - credit_outputs: credit_outputs.clone(), - }; - - let builder = TransactionBuilder::new() - .add_input(utxo.clone(), None) - .add_output(&destination, 50000) - .unwrap() - .set_change_address(change.clone()) - .set_special_payload(TransactionPayload::AssetLockPayloadType(asset_lock_payload)); - - let base_size = builder.calculate_base_size(); - // Should include special payload size - assert!(base_size > 100, "Base size with AssetLock payload should be larger"); - - // Test with CoinbasePayload - use dashcore::blockdata::transaction::special_transaction::coinbase::CoinbasePayload; - use dashcore::hash_types::{MerkleRootMasternodeList, MerkleRootQuorums}; - - let coinbase_payload = CoinbasePayload { - version: 3, - height: 1526, - merkle_root_masternode_list: MerkleRootMasternodeList::from_raw_hash( - sha256d::Hash::from_slice(&[0xaa; 32]).unwrap(), - ), - merkle_root_quorums: MerkleRootQuorums::from_raw_hash( - sha256d::Hash::from_slice(&[0xbb; 32]).unwrap(), - ), - best_cl_height: Some(1500), - best_cl_signature: Some(dashcore::bls_sig_utils::BLSSignature::from([0; 96])), - asset_locked_amount: Some(1000000), - }; - - let builder2 = TransactionBuilder::new() - .add_input(utxo, None) - .add_output(&destination, 50000) - .unwrap() - .set_change_address(change) - .set_special_payload(TransactionPayload::CoinbasePayloadType(coinbase_payload)); - - let base_size2 = builder2.calculate_base_size(); - // Coinbase payload: 2 + 4 + 32 + 32 + 4 + 96 + 8 = 178 bytes + varint - assert!(base_size2 > 180, "Base size with Coinbase payload should be larger"); - } - - #[test] - fn test_bip69_output_ordering() { - // Test that outputs are sorted according to BIP-69 - let utxo = Utxo::dummy(0, 1000000, 100, false, true); - let address1 = Address::dummy(Network::Testnet, 0); - let address2 = Address::p2pkh( - &dashcore::PublicKey::from_slice(&[ - 0x02, 0x60, 0x86, 0x3a, 0xd6, 0x4a, 0x87, 0xae, 0x8a, 0x2f, 0xe8, 0x3c, 0x1a, 0xf1, - 0xa8, 0x40, 0x3c, 0xb5, 0x3f, 0x53, 0xe4, 0x86, 0xd8, 0x51, 0x1d, 0xad, 0x8a, 0x04, - 0x88, 0x7e, 0x5b, 0x23, 0x52, - ]) - .unwrap(), - Network::Testnet, - ); - let change_address = Address::dummy(Network::Testnet, 0); - - let tx = TransactionBuilder::new() - .set_fee_rate(FeeRate::normal()) - .set_change_address(change_address) - .add_input(utxo, None) - // Add outputs in non-sorted order - .add_output(&address1, 300000) - .unwrap() // Higher amount - .add_output(&address2, 100000) - .unwrap() // Lower amount - .add_output(&address1, 200000) - .unwrap() // Middle amount - .build() - .unwrap(); - - // Verify outputs are sorted by amount (ascending) - assert!(tx.output[0].value <= tx.output[1].value); - assert!(tx.output[1].value <= tx.output[2].value); - - // The lowest value should be 100000 - assert_eq!(tx.output[0].value, 100000); - } - - #[test] - fn test_bip69_input_ordering() { - // Test that inputs are sorted according to BIP-69 - let utxo1 = Utxo::new( - OutPoint { - txid: Txid::from_raw_hash(sha256d::Hash::from_slice(&[2u8; 32]).unwrap()), - vout: 1, - }, - TxOut { - value: 100000, - script_pubkey: ScriptBuf::new(), - }, - Address::dummy(Network::Testnet, 0), - 100, - false, - ); - - let utxo2 = Utxo::new( - OutPoint { - txid: Txid::from_raw_hash(sha256d::Hash::from_slice(&[1u8; 32]).unwrap()), - vout: 2, - }, - TxOut { - value: 200000, - script_pubkey: ScriptBuf::new(), - }, - Address::dummy(Network::Testnet, 0), - 100, - false, - ); - - let utxo3 = Utxo::new( - OutPoint { - txid: Txid::from_raw_hash(sha256d::Hash::from_slice(&[1u8; 32]).unwrap()), - vout: 0, - }, - TxOut { - value: 300000, - script_pubkey: ScriptBuf::new(), - }, - Address::dummy(Network::Testnet, 0), - 100, - false, - ); - - let destination = Address::dummy(Network::Testnet, 0); - let change = Address::dummy(Network::Testnet, 0); - - let tx = TransactionBuilder::new() - .set_fee_rate(FeeRate::normal()) - .set_change_address(change) - // Add inputs in non-sorted order - .add_input(utxo1.clone(), None) - .add_input(utxo2.clone(), None) - .add_input(utxo3.clone(), None) - .add_output(&destination, 500000) - .unwrap() - .build() - .unwrap(); - - // Verify inputs are sorted by txid first, then by vout - // Expected order: [1u8; 32]:0, [1u8; 32]:2, [2u8; 32]:1 - assert_eq!( - tx.input[0].previous_output.txid, - Txid::from_raw_hash(sha256d::Hash::from_slice(&[1u8; 32]).unwrap()) - ); - assert_eq!(tx.input[0].previous_output.vout, 0); - - assert_eq!( - tx.input[1].previous_output.txid, - Txid::from_raw_hash(sha256d::Hash::from_slice(&[1u8; 32]).unwrap()) - ); - assert_eq!(tx.input[1].previous_output.vout, 2); - - assert_eq!( - tx.input[2].previous_output.txid, - Txid::from_raw_hash(sha256d::Hash::from_slice(&[2u8; 32]).unwrap()) - ); - assert_eq!(tx.input[2].previous_output.vout, 1); - } - - #[test] - fn test_coin_selection_with_special_payload() { - // Test that coin selection considers special payload size - let utxos = vec![ - Utxo::dummy(0, 50000, 100, false, true), - Utxo::dummy(0, 60000, 100, false, true), - Utxo::dummy(0, 70000, 100, false, true), - ]; - - let recipient_address = Address::dummy(Network::Testnet, 0); - let change_address = Address::dummy(Network::Testnet, 0); - - // Create a large special payload that affects fee calculation - let credit_outputs = vec![ - TxOut { - value: 10000, - script_pubkey: ScriptBuf::new(), - }, - TxOut { - value: 20000, - script_pubkey: ScriptBuf::new(), - }, - TxOut { - value: 30000, - script_pubkey: ScriptBuf::new(), - }, - ]; - - let asset_lock_payload = AssetLockPayload { - version: 1, - credit_outputs, - }; - - let result = TransactionBuilder::new() - .set_fee_rate(FeeRate::normal()) - .set_change_address(change_address) - .set_special_payload(TransactionPayload::AssetLockPayloadType(asset_lock_payload)) - .add_output(&recipient_address, 50000) - .unwrap() - .select_inputs(&utxos, SelectionStrategy::SmallestFirst, 200, |_| None); - - assert!(result.is_ok()); - let mut builder = result.unwrap(); - let tx = builder.build().unwrap(); - - // Should have selected enough inputs to cover output + fees for larger transaction - assert!( - tx.input.len() >= 2, - "Should select multiple inputs to cover fees for special payload" - ); - } -} diff --git a/key-wallet/src/wallet/managed_wallet_info/transaction_building.rs b/key-wallet/src/wallet/managed_wallet_info/transaction_building.rs deleted file mode 100644 index 95d7f7fe8..000000000 --- a/key-wallet/src/wallet/managed_wallet_info/transaction_building.rs +++ /dev/null @@ -1,416 +0,0 @@ -//! Transaction building functionality for managed wallets - -use super::coin_selection::{SelectionError, SelectionStrategy}; -use super::transaction_builder::{BuilderError, TransactionBuilder}; -use super::ManagedWalletInfo; -use crate::wallet::managed_wallet_info::fee::FeeRate; -use crate::{Address, Network, Wallet}; -use alloc::vec::Vec; -use dashcore::Transaction; - -/// Account type preference for transaction building -#[derive(Debug, Clone, Copy)] -pub enum AccountTypePreference { - /// Use BIP44 account only - BIP44, - /// Use BIP32 account only - BIP32, - /// Prefer BIP44, fallback to BIP32 - PreferBIP44, - /// Prefer BIP32, fallback to BIP44 - PreferBIP32, -} - -/// Transaction creation error -#[derive(Debug)] -pub enum TransactionError { - /// No account found for the specified type - NoAccount, - /// Insufficient funds - InsufficientFunds, - /// Failed to generate change address - ChangeAddressGeneration(String), - /// Transaction building failed - BuildFailed(String), - /// Coin selection failed - CoinSelection(SelectionError), -} - -impl ManagedWalletInfo { - /// Create an unsigned payment transaction - #[allow(clippy::too_many_arguments)] - pub(crate) fn create_unsigned_payment_transaction_internal( - &mut self, - wallet: &Wallet, - _network: Network, - account_index: u32, - account_type_pref: Option, - recipients: Vec<(Address, u64)>, - fee_rate: FeeRate, - current_block_height: u32, - ) -> Result { - // Validate network consistency - if wallet.network != self.network { - return Err(TransactionError::BuildFailed(format!( - "Network mismatch: wallet network {:?} does not match managed wallet info network {:?}", - wallet.network, - self.network - ))); - } - - // Get the wallet's account collection - let wallet_collection = &wallet.accounts; - - // Use BIP44 as default if no preference specified - let pref = account_type_pref.unwrap_or(AccountTypePreference::BIP44); - - // Get the immutable account from wallet for address generation - let wallet_account = match pref { - AccountTypePreference::BIP44 => wallet_collection - .standard_bip44_accounts - .get(&account_index) - .ok_or(TransactionError::NoAccount)?, - AccountTypePreference::BIP32 => wallet_collection - .standard_bip32_accounts - .get(&account_index) - .ok_or(TransactionError::NoAccount)?, - AccountTypePreference::PreferBIP44 => wallet_collection - .standard_bip44_accounts - .get(&account_index) - .or_else(|| wallet_collection.standard_bip32_accounts.get(&account_index)) - .ok_or(TransactionError::NoAccount)?, - AccountTypePreference::PreferBIP32 => wallet_collection - .standard_bip32_accounts - .get(&account_index) - .or_else(|| wallet_collection.standard_bip44_accounts.get(&account_index)) - .ok_or(TransactionError::NoAccount)?, - }; - - // Get the mutable managed account for UTXO access - let managed_account = match pref { - AccountTypePreference::BIP44 => self - .accounts - .standard_bip44_accounts - .get_mut(&account_index) - .ok_or(TransactionError::NoAccount)?, - AccountTypePreference::BIP32 => self - .accounts - .standard_bip32_accounts - .get_mut(&account_index) - .ok_or(TransactionError::NoAccount)?, - AccountTypePreference::PreferBIP44 => self - .accounts - .standard_bip44_accounts - .get_mut(&account_index) - .or_else(|| self.accounts.standard_bip32_accounts.get_mut(&account_index)) - .ok_or(TransactionError::NoAccount)?, - AccountTypePreference::PreferBIP32 => self - .accounts - .standard_bip32_accounts - .get_mut(&account_index) - .or_else(|| self.accounts.standard_bip44_accounts.get_mut(&account_index)) - .ok_or(TransactionError::NoAccount)?, - }; - - // Generate change address using the wallet account - let change_address = managed_account - .next_change_address(Some(&wallet_account.account_xpub), true) - .map_err(|e| { - TransactionError::ChangeAddressGeneration(format!( - "Failed to generate change address: {}", - e - )) - })?; - - if managed_account.utxos.is_empty() { - return Err(TransactionError::InsufficientFunds); - } - - // Get all UTXOs from the managed account as a vector - let all_utxos: Vec<_> = managed_account.utxos.values().cloned().collect(); - - // Use TransactionBuilder to create the transaction - let mut builder = TransactionBuilder::new() - .set_fee_rate(fee_rate) - .set_change_address(change_address.clone()); - - // Add outputs for recipients first - for (address, amount) in recipients { - builder = builder - .add_output(&address, amount) - .map_err(|e| TransactionError::BuildFailed(e.to_string()))?; - } - - // Select inputs using OptimalConsolidation strategy - // The target amount is calculated from the outputs already added - // Note: We don't have private keys here since this is for unsigned transactions - builder = builder - .select_inputs( - &all_utxos, - SelectionStrategy::OptimalConsolidation, - current_block_height, - |_| None, // No private keys for unsigned transaction - ) - .map_err(|e| match e { - BuilderError::CoinSelection(err) => TransactionError::CoinSelection(err), - _ => TransactionError::BuildFailed(e.to_string()), - })?; - - // Build the unsigned transaction - let transaction = - builder.build().map_err(|e| TransactionError::BuildFailed(e.to_string()))?; - - // Mark the change address as used in the managed account - managed_account.mark_address_used(&change_address); - - // Lock the UTXOs that were selected for this transaction - for input in &transaction.input { - if let Some(stored_utxo) = managed_account.utxos.get_mut(&input.previous_output) { - stored_utxo.is_locked = true; // Lock the UTXO while transaction is pending - } - } - - Ok(transaction) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::wallet::managed_wallet_info::transaction_builder::TransactionBuilder; - use crate::Utxo; - use dashcore::blockdata::transaction::special_transaction::TransactionPayload; - use dashcore::{Address, Network, Transaction, Txid}; - use dashcore_hashes::{sha256d, Hash}; - use std::str::FromStr; - - #[test] - fn test_basic_transaction_creation() { - // Test creating a basic transaction with inputs and outputs - let utxos = vec![ - Utxo::dummy(0, 100000, 100, false, true), - Utxo::dummy(0, 200000, 100, false, true), - Utxo::dummy(0, 300000, 100, false, true), - ]; - - let recipient_address = Address::from_str("yTb47qEBpNmgXvYYsHEN4nh8yJwa5iC4Cs") - .unwrap() - .require_network(Network::Testnet) - .unwrap(); - let change_address = Address::from_str("yXfXh3jFYHHxnJZVsXnPcktCENqPaAhcX1") - .unwrap() - .require_network(Network::Testnet) - .unwrap(); - - let mut builder = TransactionBuilder::new() - .set_fee_rate(FeeRate::normal()) - .set_change_address(change_address.clone()); - - // Add output - builder = builder.add_output(&recipient_address, 150000).unwrap(); - - // Select inputs - builder = builder - .select_inputs( - &utxos, - SelectionStrategy::SmallestFirst, - 200, - |_| None, // No private keys for unsigned - ) - .unwrap(); - - let tx = builder.build().unwrap(); - - assert!(!tx.input.is_empty()); - assert_eq!(tx.output.len(), 2); // recipient + change - - // With BIP-69 sorting, outputs are sorted by amount - // Find the output with value 150000 (the recipient output) - let recipient_output = tx.output.iter().find(|o| o.value == 150000); - assert!(recipient_output.is_some(), "Should have recipient output of 150000"); - - // The other output should be the change - let change_output = tx.output.iter().find(|o| o.value != 150000); - assert!(change_output.is_some(), "Should have change output"); - } - - #[test] - fn test_asset_lock_transaction() { - // Test based on DSTransactionTests.m testAssetLockTx1 - use dashcore::consensus::Decodable; - use hex; - - let hex_data = hex::decode("0300080001eecf4e8f1ffd3a3a4e5033d618231fd05e5f08c1a727aac420f9a26db9bf39eb010000006a473044022026f169570532332f857cb64a0b7d9c0837d6f031633e1d6c395d7c03b799460302207eba4c4575a66803cecf50b61ff5f2efc2bd4e61dff00d9d4847aa3d8b1a5e550121036cd0b73d304bacc80fa747d254fbc5f0bf944dd8c8b925cd161bb499b790d08d0000000002317dd0be030000002321022ca85dba11c4e5a6da3a00e73a08765319a5d66c2f6434b288494337b0c9ed2dac6df29c3b00000000026a000000000046010200e1f505000000001976a9147c75beb097957cc09537b615dde9ea6807719cdf88ac6d11a735000000001976a9147c75beb097957cc09537b615dde9ea6807719cdf88ac").unwrap(); - - let mut cursor = std::io::Cursor::new(hex_data); - let tx = Transaction::consensus_decode(&mut cursor).unwrap(); - - assert_eq!(tx.version, 3); - assert_eq!(tx.lock_time, 0); - assert_eq!(tx.input.len(), 1); - assert_eq!(tx.output.len(), 2); - - // Verify it's an asset lock transaction - if let Some(TransactionPayload::AssetLockPayloadType(payload)) = - &tx.special_transaction_payload - { - assert_eq!(payload.version, 1); - assert_eq!(payload.credit_outputs.len(), 2); - assert_eq!(payload.credit_outputs[0].value, 100000000); - assert_eq!(payload.credit_outputs[1].value, 900141421); - } else { - panic!("Expected AssetLockPayload"); - } - } - - #[test] - fn test_coinbase_transaction() { - // Test based on DSTransactionTests.m testCoinbaseTransaction - use dashcore::consensus::Decodable; - use hex; - - let hex_data = hex::decode("03000500010000000000000000000000000000000000000000000000000000000000000000ffffffff0502f6050105ffffffff0200c11a3d050000002321038df098a36af5f1b7271e32ad52947f64c1ad70c16a8a1a987105eaab5daa7ad2ac00c11a3d050000001976a914bfb885c89c83cd44992a8ade29b610e6ddf00c5788ac00000000260100f6050000aaaec8d6a8535a01bd844817dea1faed66f6c397b1dcaec5fe8c5af025023c35").unwrap(); - - let mut cursor = std::io::Cursor::new(hex_data); - let tx = Transaction::consensus_decode(&mut cursor).unwrap(); - - assert_eq!(tx.version, 3); - assert_eq!(tx.lock_time, 0); - // Check if it's a coinbase transaction by checking if first input has null previous_output - assert_eq!( - tx.input[0].previous_output.txid, - Txid::from_raw_hash(sha256d::Hash::from_slice(&[0u8; 32]).unwrap()) - ); - assert_eq!(tx.input[0].previous_output.vout, 0xffffffff); - assert_eq!(tx.output.len(), 2); - - // Verify txid matches expected - let expected_txid = "5b4e5e99e967e01e27627621df00c44525507a31201ceb7b96c6e1a452e82bef"; - assert_eq!(tx.txid().to_string(), expected_txid); - } - - #[test] - fn test_transaction_size_estimation() { - // Test that transaction size estimation is accurate - let utxos = vec![ - Utxo::dummy(0, 100000, 100, false, true), - Utxo::dummy(0, 200000, 100, false, true), - ]; - - let recipient_address = Address::from_str("yTb47qEBpNmgXvYYsHEN4nh8yJwa5iC4Cs") - .unwrap() - .require_network(Network::Testnet) - .unwrap(); - let change_address = Address::from_str("yXfXh3jFYHHxnJZVsXnPcktCENqPaAhcX1") - .unwrap() - .require_network(Network::Testnet) - .unwrap(); - - let mut builder = TransactionBuilder::new() - .set_fee_rate(FeeRate::normal()) - .set_change_address(change_address.clone()) - .add_output(&recipient_address, 150000) - .unwrap() - .select_inputs(&utxos, SelectionStrategy::SmallestFirst, 200, |_| None) - .unwrap(); - - let tx = builder.build().unwrap(); - let serialized = dashcore::consensus::encode::serialize(&tx); - - // Size should be close to our estimation - // Base (8) + varints (2) + 2 inputs (296) + 2 outputs (68) = ~374 bytes - // But inputs have empty script_sig since they're unsigned, so smaller - assert!( - serialized.len() > 150 && serialized.len() < 250, - "Actual size: {}", - serialized.len() - ); - } - - #[test] - fn test_fee_calculation() { - // Test that fees are calculated correctly - let utxos = vec![Utxo::dummy(0, 1000000, 100, false, true)]; - - let recipient_address = Address::from_str("yTb47qEBpNmgXvYYsHEN4nh8yJwa5iC4Cs") - .unwrap() - .require_network(Network::Testnet) - .unwrap(); - let change_address = Address::from_str("yXfXh3jFYHHxnJZVsXnPcktCENqPaAhcX1") - .unwrap() - .require_network(Network::Testnet) - .unwrap(); - - let mut builder = TransactionBuilder::new() - .set_fee_rate(FeeRate::normal()) // 1 duff per byte - .set_change_address(change_address.clone()) - .add_output(&recipient_address, 500000) - .unwrap() - .select_inputs(&utxos, SelectionStrategy::SmallestFirst, 200, |_| None) - .unwrap(); - - let tx = builder.build().unwrap(); - - // Total input: 1000000 - // Output to recipient: 500000 - // Change output should be approximately: 1000000 - 500000 - fee - // Fee should be roughly 226 duffs for a 1-input, 2-output transaction - let total_output: u64 = tx.output.iter().map(|o| o.value).sum(); - let fee = 1000000 - total_output; - - assert!(fee > 200 && fee < 300, "Fee should be around 226 duffs, got {}", fee); - } - - #[test] - fn test_insufficient_funds() { - // Test that insufficient funds returns an error - let utxos = vec![Utxo::dummy(0, 100000, 100, false, true)]; - - let recipient_address = Address::from_str("yTb47qEBpNmgXvYYsHEN4nh8yJwa5iC4Cs") - .unwrap() - .require_network(Network::Testnet) - .unwrap(); - let change_address = Address::from_str("yXfXh3jFYHHxnJZVsXnPcktCENqPaAhcX1") - .unwrap() - .require_network(Network::Testnet) - .unwrap(); - - let result = TransactionBuilder::new() - .set_fee_rate(FeeRate::normal()) - .set_change_address(change_address.clone()) - .add_output(&recipient_address, 1000000) // More than available - .unwrap() - .select_inputs(&utxos, SelectionStrategy::SmallestFirst, 200, |_| None); - - assert!(result.is_err()); - } - - #[test] - fn test_exact_change_no_change_output() { - // Test when the exact amount is used (no change output needed) - let utxos = vec![Utxo::dummy(0, 150226, 100, false, true)]; // Exact amount for output + fee - - let recipient_address = Address::from_str("yTb47qEBpNmgXvYYsHEN4nh8yJwa5iC4Cs") - .unwrap() - .require_network(Network::Testnet) - .unwrap(); - let change_address = Address::from_str("yXfXh3jFYHHxnJZVsXnPcktCENqPaAhcX1") - .unwrap() - .require_network(Network::Testnet) - .unwrap(); - - let mut builder = TransactionBuilder::new() - .set_fee_rate(FeeRate::normal()) - .set_change_address(change_address.clone()) - .add_output(&recipient_address, 150000) - .unwrap() - .select_inputs(&utxos, SelectionStrategy::SmallestFirst, 200, |_| None) - .unwrap(); - - let tx = builder.build().unwrap(); - - // Should only have 1 output (no change) - assert_eq!(tx.output.len(), 1); - assert_eq!(tx.output[0].value, 150000); - } -} diff --git a/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs b/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs index f247df458..a55a1f8f7 100644 --- a/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs +++ b/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs @@ -6,17 +6,13 @@ use super::managed_account_operations::ManagedAccountOperations; use crate::account::ManagedAccountTrait; use crate::managed_account::managed_account_collection::ManagedAccountCollection; use crate::transaction_checking::WalletTransactionChecker; -use crate::wallet::managed_wallet_info::fee::FeeRate; -use crate::wallet::managed_wallet_info::transaction_building::{ - AccountTypePreference, TransactionError, -}; use crate::wallet::managed_wallet_info::TransactionRecord; use crate::wallet::ManagedWalletInfo; use crate::{Network, Utxo, Wallet, WalletCoreBalance}; use alloc::collections::BTreeSet; use alloc::vec::Vec; use dashcore::prelude::CoreBlockHeight; -use dashcore::{Address as DashAddress, Address, Transaction, Txid}; +use dashcore::{Address as DashAddress, Transaction, Txid}; /// Trait that wallet info types must implement to work with WalletManager pub trait WalletInfoInterface: Sized + WalletTransactionChecker + ManagedAccountOperations { @@ -88,18 +84,6 @@ pub trait WalletInfoInterface: Sized + WalletTransactionChecker + ManagedAccount /// Get immature transactions fn immature_transactions(&self) -> Vec; - /// Create an unsigned payment transaction - #[allow(clippy::too_many_arguments)] - fn create_unsigned_payment_transaction( - &mut self, - wallet: &Wallet, - account_index: u32, - account_type_pref: Option, - recipients: Vec<(Address, u64)>, - fee_rate: FeeRate, - current_block_height: u32, - ) -> Result; - /// Return the last fully processed height of the wallet. fn synced_height(&self) -> CoreBlockHeight; @@ -239,26 +223,6 @@ impl WalletInfoInterface for ManagedWalletInfo { transactions } - fn create_unsigned_payment_transaction( - &mut self, - wallet: &Wallet, - account_index: u32, - account_type_pref: Option, - recipients: Vec<(Address, u64)>, - fee_rate: FeeRate, - current_block_height: u32, - ) -> Result { - self.create_unsigned_payment_transaction_internal( - wallet, - self.network, - account_index, - account_type_pref, - recipients, - fee_rate, - current_block_height, - ) - } - fn update_synced_height(&mut self, current_height: u32) { self.metadata.synced_height = current_height; // Update cached balance diff --git a/key-wallet/tests/test_optimal_consolidation.rs b/key-wallet/tests/test_optimal_consolidation.rs deleted file mode 100644 index fab6a467f..000000000 --- a/key-wallet/tests/test_optimal_consolidation.rs +++ /dev/null @@ -1,36 +0,0 @@ -use key_wallet::Utxo; - -// Test for OptimalConsolidation coin selection strategy -#[test] -fn test_optimal_consolidation_strategy() { - use key_wallet::wallet::managed_wallet_info::coin_selection::*; - use key_wallet::wallet::managed_wallet_info::fee::FeeRate; - - // Test that OptimalConsolidation strategy works correctly - let utxos = vec![ - Utxo::dummy(0, 100, 100, false, true), - Utxo::dummy(0, 200, 100, false, true), - Utxo::dummy(0, 300, 100, false, true), - Utxo::dummy(0, 500, 100, false, true), - Utxo::dummy(0, 1000, 100, false, true), - Utxo::dummy(0, 2000, 100, false, true), - ]; - - let selector = CoinSelector::new(SelectionStrategy::OptimalConsolidation); - let fee_rate = FeeRate::new(100); // Simpler fee rate - let result = selector.select_coins(&utxos, 1500, fee_rate, 200).unwrap(); - - // OptimalConsolidation should work and produce a valid selection - assert!(!result.selected.is_empty()); - assert!(result.total_value >= 1500 + result.estimated_fee); - assert_eq!(result.target_amount, 1500); - - // The strategy should prefer smaller UTXOs, so it should include - // some of the smaller values - let selected_values: Vec = result.selected.iter().map(|u| u.value()).collect(); - let has_small_utxos = selected_values.iter().any(|&v| v <= 500); - assert!(has_small_utxos, "Should include at least one small UTXO for consolidation"); - - println!("Selected {} UTXOs with total value {}", result.selected.len(), result.total_value); - println!("Selected values: {:?}", selected_values); -}