From 2b862e64877d84ad04c5277835c1099a285ed07c Mon Sep 17 00:00:00 2001 From: Tee8z Date: Thu, 20 Nov 2025 13:58:30 -0500 Subject: [PATCH] feat: add the ability to split utxos --- .gitignore | 2 + src/components/mod.rs | 2 + src/components/split_modal.rs | 409 +++++++++++++++++++++++++++ src/components/toast.rs | 17 ++ src/components/transactions_table.rs | 60 +++- src/ui.rs | 82 +++++- src/wallet.rs | 171 ++++++++++- 7 files changed, 729 insertions(+), 14 deletions(-) create mode 100644 src/components/split_modal.rs diff --git a/.gitignore b/.gitignore index eb5a316..78e6a92 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ target +data/ +config.toml diff --git a/src/components/mod.rs b/src/components/mod.rs index 1fcc0e0..4203bb8 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,10 +1,12 @@ mod send_modal; +mod split_modal; mod toast; mod transactions_table; mod utxos_table; mod wallet_info; pub use send_modal::*; +pub use split_modal::*; pub use toast::*; pub use transactions_table::*; pub use utxos_table::*; diff --git a/src/components/split_modal.rs b/src/components/split_modal.rs new file mode 100644 index 0000000..e2b932d --- /dev/null +++ b/src/components/split_modal.rs @@ -0,0 +1,409 @@ +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + layout::{Constraint, Layout, Margin, Rect}, + style::{Color, Modifier, Style}, + widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph}, + Frame, +}; +use tui_textarea::TextArea; + +#[derive(Debug, Clone, thiserror::Error)] +pub enum SplitModalErr { + #[error("No split count provided")] + EmptyCount, + #[error("Invalid split count: {0}")] + InvalidCount(String), + #[error("Split count must be greater than 1")] + CountTooSmall, + #[error("Split count too large (max 100)")] + CountTooLarge, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SplitMode { + EqualSplit, + CustomMix, +} + +pub struct SplitModal { + split_count_input: TextArea<'static>, + small_count_input: TextArea<'static>, + medium_count_input: TextArea<'static>, + large_count_input: TextArea<'static>, + active_input: usize, + show_modal: bool, + mode: SplitMode, + mode_list_state: ListState, + use_change_addresses: bool, +} + +impl Default for SplitModal { + fn default() -> Self { + let mut split_count_input = TextArea::default(); + let mut small_count_input = TextArea::default(); + let mut medium_count_input = TextArea::default(); + let mut large_count_input = TextArea::default(); + + // Configure inputs + split_count_input.set_placeholder_text("Number of outputs (2-100)"); + split_count_input.set_cursor_line_style(Style::default()); + + small_count_input.set_placeholder_text("Small UTXOs (0.001 BTC each)"); + small_count_input.set_cursor_line_style(Style::default()); + + medium_count_input.set_placeholder_text("Medium UTXOs (0.01 BTC each)"); + medium_count_input.set_cursor_line_style(Style::default()); + + large_count_input.set_placeholder_text("Large UTXOs (0.1 BTC each)"); + large_count_input.set_cursor_line_style(Style::default()); + + let mut mode_list_state = ListState::default(); + mode_list_state.select(Some(0)); + + Self { + split_count_input, + small_count_input, + medium_count_input, + large_count_input, + active_input: 0, + show_modal: false, + mode: SplitMode::EqualSplit, + mode_list_state, + use_change_addresses: true, + } + } +} + +impl SplitModal { + pub fn visible(&self) -> bool { + self.show_modal + } + + pub fn toggle(&mut self) { + self.show_modal = !self.show_modal; + if self.show_modal { + self.reset(); + } + } + + pub fn reset(&mut self) { + self.split_count_input = TextArea::default(); + self.small_count_input = TextArea::default(); + self.medium_count_input = TextArea::default(); + self.large_count_input = TextArea::default(); + + self.split_count_input + .set_placeholder_text("Number of outputs (2-100)"); + self.small_count_input + .set_placeholder_text("Small UTXOs (0.001 BTC each)"); + self.medium_count_input + .set_placeholder_text("Medium UTXOs (0.01 BTC each)"); + self.large_count_input + .set_placeholder_text("Large UTXOs (0.1 BTC each)"); + + self.active_input = 0; + self.mode = SplitMode::EqualSplit; + self.mode_list_state.select(Some(0)); + } + + pub fn handle_input(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Esc => { + self.show_modal = false; + return true; + } + KeyCode::Tab => { + match self.mode { + SplitMode::EqualSplit => { + // Tab between mode selection and split count input + self.active_input = (self.active_input + 1) % 2; + } + SplitMode::CustomMix => { + // Tab between mode selection and three count inputs + self.active_input = (self.active_input + 1) % 4; + } + } + return true; + } + KeyCode::Up | KeyCode::Down => { + if self.active_input == 0 { + // Handle mode selection + let selected = self.mode_list_state.selected().unwrap_or(0); + let new_selected = match key.code { + KeyCode::Up => { + if selected > 0 { + selected - 1 + } else { + 1 + } + } + KeyCode::Down => { + if selected < 1 { + selected + 1 + } else { + 0 + } + } + _ => selected, + }; + self.mode_list_state.select(Some(new_selected)); + self.mode = if new_selected == 0 { + SplitMode::EqualSplit + } else { + SplitMode::CustomMix + }; + return true; + } + } + KeyCode::Char(' ') => { + if self.active_input == 0 { + self.use_change_addresses = !self.use_change_addresses; + return true; + } + } + _ => {} + } + + // Handle text input based on active input and mode + if self.active_input > 0 { + match self.mode { + SplitMode::EqualSplit => { + if self.active_input == 1 { + self.split_count_input.input(key); + } + } + SplitMode::CustomMix => match self.active_input { + 1 => { + self.small_count_input.input(key); + } + 2 => { + self.medium_count_input.input(key); + } + 3 => { + self.large_count_input.input(key); + } + _ => {} + }, + } + } + + false + } + + pub fn get_equal_split_count(&self) -> Result { + let text = self.split_count_input.lines()[0].trim(); + if text.is_empty() { + return Err(SplitModalErr::EmptyCount); + } + + let count = text + .parse::() + .map_err(|_| SplitModalErr::InvalidCount(text.to_string()))?; + + if count < 2 { + return Err(SplitModalErr::CountTooSmall); + } + + if count > 100 { + return Err(SplitModalErr::CountTooLarge); + } + + Ok(count) + } + + pub fn get_custom_mix(&self) -> Result<(usize, usize, usize), SplitModalErr> { + let small_text = self.small_count_input.lines()[0].trim(); + let medium_text = self.medium_count_input.lines()[0].trim(); + let large_text = self.large_count_input.lines()[0].trim(); + + let small_count = if small_text.is_empty() { + 0 + } else { + small_text + .parse::() + .map_err(|_| SplitModalErr::InvalidCount(small_text.to_string()))? + }; + + let medium_count = if medium_text.is_empty() { + 0 + } else { + medium_text + .parse::() + .map_err(|_| SplitModalErr::InvalidCount(medium_text.to_string()))? + }; + + let large_count = if large_text.is_empty() { + 0 + } else { + large_text + .parse::() + .map_err(|_| SplitModalErr::InvalidCount(large_text.to_string()))? + }; + + if small_count + medium_count + large_count == 0 { + return Err(SplitModalErr::EmptyCount); + } + + Ok((small_count, medium_count, large_count)) + } + + pub fn get_mode(&self) -> &SplitMode { + &self.mode + } + + pub fn use_change_addresses(&self) -> bool { + self.use_change_addresses + } + + pub fn render(&mut self, f: &mut Frame) { + if !self.show_modal { + return; + } + + let size = f.size(); + let modal_area = Rect { + x: size.width / 8, + y: size.height / 8, + width: (size.width * 3) / 4, + height: (size.height * 3) / 4, + }; + + // Clear the background + f.render_widget(Clear, modal_area); + + // Main modal block + let modal_block = Block::default() + .title("Split UTXOs") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(Style::default().bg(Color::Black)); + + f.render_widget(modal_block, modal_area); + + let inner_area = modal_area.inner(&Margin { + vertical: 1, + horizontal: 2, + }); + + let chunks = Layout::vertical([ + Constraint::Length(4), // Mode selection + Constraint::Length(1), // Address type selection + Constraint::Min(5), // Input fields + Constraint::Length(3), // Help text + ]) + .split(inner_area); + + // Mode selection + let mode_items = vec![ + ListItem::new("Equal Split - Split largest UTXO into equal parts"), + ListItem::new("Custom Mix - Create small, medium, and large UTXOs"), + ]; + + let mode_list = List::new(mode_items) + .block( + Block::default() + .title("Split Mode") + .borders(Borders::ALL) + .border_style(if self.active_input == 0 { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Gray) + }), + ) + .style(Style::default().fg(Color::White)) + .highlight_style( + Style::default() + .bg(Color::Yellow) + .fg(Color::Black) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">> "); + + f.render_stateful_widget(mode_list, chunks[0], &mut self.mode_list_state); + + // Address type selection + let address_type_text = format!( + "[{}] Use change addresses (internal) instead of external addresses", + if self.use_change_addresses { "x" } else { " " } + ); + let address_type_paragraph = + Paragraph::new(address_type_text).style(if self.active_input == 0 { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Gray) + }); + f.render_widget(address_type_paragraph, chunks[1]); + + // Input fields based on mode + match self.mode { + SplitMode::EqualSplit => { + let input_area = chunks[2]; + self.split_count_input.set_block( + Block::default() + .title("Number of Equal Outputs") + .borders(Borders::ALL) + .border_style(if self.active_input == 1 { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Gray) + }), + ); + let split_count_widget = self.split_count_input.clone(); + f.render_widget(split_count_widget.widget(), input_area); + } + SplitMode::CustomMix => { + let input_chunks = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + ]) + .split(chunks[2]); + + self.small_count_input.set_block( + Block::default() + .title("Small UTXOs (0.001 BTC each)") + .borders(Borders::ALL) + .border_style(if self.active_input == 1 { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Gray) + }), + ); + let small_widget = self.small_count_input.clone(); + f.render_widget(small_widget.widget(), input_chunks[0]); + + self.medium_count_input.set_block( + Block::default() + .title("Medium UTXOs (0.01 BTC each)") + .borders(Borders::ALL) + .border_style(if self.active_input == 2 { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Gray) + }), + ); + let medium_widget = self.medium_count_input.clone(); + f.render_widget(medium_widget.widget(), input_chunks[1]); + + self.large_count_input.set_block( + Block::default() + .title("Large UTXOs (0.1 BTC each)") + .borders(Borders::ALL) + .border_style(if self.active_input == 3 { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Gray) + }), + ); + let large_widget = self.large_count_input.clone(); + f.render_widget(large_widget.widget(), input_chunks[2]); + } + } + + let help_text = "↑↓: Navigate modes | Tab: Switch fields | Space: Toggle address type | Enter: Split | Esc: Cancel"; + let help_paragraph = Paragraph::new(help_text) + .block(Block::default().title("Help").borders(Borders::ALL)) + .style(Style::default().fg(Color::Cyan)); + f.render_widget(help_paragraph, chunks[3]); + } +} diff --git a/src/components/toast.rs b/src/components/toast.rs index a83a948..2c3ef79 100644 --- a/src/components/toast.rs +++ b/src/components/toast.rs @@ -34,6 +34,19 @@ impl Toast { } } + pub fn new_with_duration( + message: impl Into, + level: ToastLevel, + duration: Duration, + ) -> Self { + Self { + message: message.into(), + level, + timestamp: Instant::now(), + duration, + } + } + pub fn info(message: impl Into) -> Self { Self::new(message, ToastLevel::Info) } @@ -42,6 +55,10 @@ impl Toast { Self::new(message, ToastLevel::Success) } + pub fn success_long(message: impl Into) -> Self { + Self::new_with_duration(message, ToastLevel::Success, Duration::from_secs(10)) + } + pub fn warning(message: impl Into) -> Self { Self::new(message, ToastLevel::Warning) } diff --git a/src/components/transactions_table.rs b/src/components/transactions_table.rs index f2defbf..4c8a549 100644 --- a/src/components/transactions_table.rs +++ b/src/components/transactions_table.rs @@ -1,3 +1,4 @@ +use arboard::Clipboard; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ layout::{Constraint, Layout, Rect}, @@ -7,6 +8,7 @@ use ratatui::{ Frame, }; +use crate::components::Toast; use crate::utils::{format_signed_amount, short_tx_id}; use crate::wallet::AppWallet; @@ -14,6 +16,7 @@ use crate::wallet::AppWallet; pub struct TransactionsTable { wallet: AppWallet, state: TableState, + toast_callback: Option Toast>, } impl TransactionsTable { @@ -21,9 +24,14 @@ impl TransactionsTable { Self { wallet, state: TableState::default().with_selected(0), + toast_callback: None, } } + pub fn set_toast_callback(&mut self, callback: fn(&str) -> Toast) { + self.toast_callback = Some(callback); + } + pub fn render(&mut self, f: &mut Frame, area: Rect, active: bool) { // Draw the transactions section let [tx_table] = Layout::vertical([ @@ -31,16 +39,23 @@ impl TransactionsTable { ]) .areas(area); - // Create table rows + // Create table rows with highlighting for recent transactions let rows: Vec = self .wallet .get_transactions() .iter() .map(|tx| { - Row::new(vec![ + let mut row = Row::new(vec![ Cell::from(short_tx_id(&tx.id)), Cell::from(Text::from(format_signed_amount(&tx.net_amount())).right_aligned()), - ]) + ]); + + // Highlight recently created transactions + if self.wallet.is_transaction_recent(&tx.id) { + row = row.style(Style::default().bg(ratatui::style::Color::Green)); + } + + row }) .collect(); @@ -72,21 +87,52 @@ impl TransactionsTable { f.render_stateful_widget(table, tx_table, &mut self.state); } - pub fn handle_input(&mut self, input: KeyEvent) -> bool { + pub fn handle_input(&mut self, input: KeyEvent) -> (bool, Option) { let count = self.wallet.get_transactions().len(); + let mut toast = None; match input.code { KeyCode::Down => { self.state .select(Some((self.state.selected().unwrap() + 1) % count)); - true + (true, None) } KeyCode::Up => { self.state .select(Some((self.state.selected().unwrap() + count - 1) % count)); - true + (true, None) + } + KeyCode::Char('c') => { + // Copy selected transaction ID to clipboard + if let Some(selected) = self.state.selected() { + let transactions = self.wallet.get_transactions(); + if let Some(tx) = transactions.get(selected) { + match Clipboard::new() { + Ok(mut clipboard) => { + if let Err(e) = clipboard.set_text(tx.id.to_string()) { + toast = Some(Toast::error(format!( + "Failed to copy transaction ID to clipboard: {}", + e + ))); + } else { + toast = Some(Toast::success_long(format!( + "Transaction ID copied to clipboard: {}", + tx.id + ))); + } + } + Err(e) => { + toast = Some(Toast::error(format!( + "Failed to access clipboard: {}", + e + ))); + } + } + } + } + (true, toast) } - _ => false, + _ => (false, None), } } } diff --git a/src/ui.rs b/src/ui.rs index 5a348e0..0b34715 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -34,6 +34,7 @@ pub struct App { wallet: AppWallet, toast: Option, send_modal: SendModal, + split_modal: SplitModal, transactions_table: TransactionsTable, wallet_info: WalletInfo, utxos_table: UtxosTable, @@ -47,6 +48,7 @@ impl App { wallet: wallet.clone(), toast: None, send_modal: SendModal::default(), + split_modal: SplitModal::default(), transactions_table: TransactionsTable::new(wallet.clone()), wallet_info: WalletInfo::new(&config.name, wallet.clone()), utxos_table: UtxosTable::new(wallet), @@ -86,6 +88,8 @@ impl App { Span::raw(" Receive "), Span::styled("s:", Style::default().bold()), Span::raw(" Send "), + Span::styled("u:", Style::default().bold()), + Span::raw(" Split UTXOs "), Span::styled("Esc:", Style::default().bold()), Span::raw(" Cancel"), ]); @@ -123,6 +127,12 @@ impl App { return; } + // Draw the split modal if it's visible + self.split_modal.render(f); + if self.split_modal.visible() { + return; + } + // Split the inner area into left (wallet info) and right (transactions) sections with a line in between let inner_area = main_block.inner(content_area); let [left, _center, right] = Layout::horizontal([ @@ -195,7 +205,10 @@ impl App { match self.send_modal.get_form_data() { Ok((address, amount)) => match self.wallet.send(&address, amount).await { Ok(txid) => { - self.show_toast(Toast::success(format!("Transaction {} broadcasted", txid))); + self.show_toast(Toast::success_long(format!( + "Transaction {} broadcasted", + txid + ))); self.send_modal.toggle(); match Clipboard::new() { Ok(mut clipboard) => { @@ -205,7 +218,7 @@ impl App { e ))); } else { - self.show_toast(Toast::success(format!( + self.show_toast(Toast::success_long(format!( "Transaction ID copied to clipboard: {}", txid ))); @@ -241,6 +254,13 @@ impl App { }; } + if self.split_modal.visible() { + return match key.code { + KeyCode::Enter => self.handle_split_submit().await, + _ => self.split_modal.handle_input(key), + }; + } + match key.code { KeyCode::Down | KeyCode::Up | KeyCode::Char('o') => match self.selected_component { SelectableComponent::TransactionsTable => { @@ -258,10 +278,68 @@ impl App { self.send_modal.toggle(); return false; // Event handled, stop further processing } + KeyCode::Char('u') => { + self.split_modal.toggle(); + return false; // Event handled, stop further processing + } _ => {} } true } + + async fn handle_split_submit(&mut self) -> bool { + use crate::components::{SplitMode, Toast}; + + let result = match self.split_modal.get_mode() { + SplitMode::EqualSplit => { + let count = match self.split_modal.get_equal_split_count() { + Ok(count) => count, + Err(e) => { + self.show_toast(Toast::error(format!("Error: {}", e))); + return false; + } + }; + + self.wallet + .split_largest_utxo_equally(count, self.split_modal.use_change_addresses()) + .await + } + SplitMode::CustomMix => { + let (small, medium, large) = match self.split_modal.get_custom_mix() { + Ok(counts) => counts, + Err(e) => { + self.show_toast(Toast::error(format!("Error: {}", e))); + return false; + } + }; + + self.wallet + .create_utxo_mix( + small, + medium, + large, + self.split_modal.use_change_addresses(), + ) + .await + } + }; + + match result { + Ok(txid) => { + self.show_toast(Toast::success_long(format!( + "UTXO split successful! TXID: {}", + txid + ))); + self.split_modal.toggle(); + } + Err(e) => { + self.show_toast(Toast::error(format!("Split failed: {:#}", e))); + } + } + + true + } + /// Handle all input events async fn handle_events(&mut self) -> Result<()> { if !event::poll(std::time::Duration::from_millis(100))? { diff --git a/src/wallet.rs b/src/wallet.rs index a262665..2be355a 100644 --- a/src/wallet.rs +++ b/src/wallet.rs @@ -1,6 +1,7 @@ +use std::collections::HashSet; use std::io::ErrorKind; use std::sync::{Arc, Mutex, RwLock}; -use std::time::SystemTime; +use std::time::{Duration, SystemTime}; use std::{path::Path, str::FromStr}; use anyhow::{Context, Result}; @@ -29,6 +30,8 @@ pub struct AppWallet { esplora: esplora_client::AsyncClient, block_height: Arc>, sync_status: Arc>, + recent_transactions: Arc>>, + recent_tx_timestamp: Arc>, } impl AppWallet { @@ -41,10 +44,10 @@ impl AppWallet { let mut conn = tokio::task::spawn_blocking(move || Connection::open(db_path)).await??; let (mut wallet, created) = match fs::read_to_string(&key_path).await { - Ok(contents) if contents.starts_with("tprv") => { - load_wallet_with_pvt(&mut conn, &contents)? + Ok(contents) if contents.trim().starts_with("tprv") => { + load_wallet_with_pvt(&mut conn, contents.trim())? } - Ok(contents) => load_wallet_with_pub(&mut conn, &contents)?, + Ok(contents) => load_wallet_with_pub(&mut conn, contents.trim())?, Err(e) if e.kind() == ErrorKind::NotFound => { (create_wallet(&mut conn, &key_path).await?, true) } @@ -66,6 +69,8 @@ impl AppWallet { is_syncing: false, last_sync: None, })), + recent_transactions: Arc::new(RwLock::new(HashSet::new())), + recent_tx_timestamp: Arc::new(RwLock::new(SystemTime::now())), }; let app_wallet_clone = app_wallet.clone(); @@ -145,6 +150,8 @@ impl AppWallet { pub fn get_transactions(&self) -> Vec { let wallet = self.wallet.read().unwrap(); wallet + // Sort by chain position in descending order (most recent first) + // Higher chain_position = more recent transaction .transactions_sort_by(|tx1, tx2| tx2.chain_position.cmp(&tx1.chain_position)) .iter() .map(|tx| Transaction::from_wallet_transaction(tx, &wallet)) @@ -185,13 +192,167 @@ impl AppWallet { self.esplora.broadcast(&tx).await?; + let txid = tx.compute_txid(); + + { + let mut wallet = self.wallet.write().unwrap(); + let mut conn = self.conn.lock().unwrap(); + wallet.persist(&mut conn)?; + } + + // Mark this transaction as recently created + self.mark_transaction_as_recent(txid); + + Ok(txid) + } + + pub async fn split_utxos( + &self, + amounts: Vec, + use_change_addresses: bool, + ) -> Result { + let tx = { + let mut wallet = self.wallet.write().unwrap(); + + let keychain_kind = if use_change_addresses { + KeychainKind::Internal + } else { + KeychainKind::External + }; + + let mut addresses = Vec::new(); + for _ in &amounts { + let address_info = wallet.reveal_next_address(keychain_kind); + addresses.push(address_info.address); + } + + let mut builder = wallet.build_tx(); + + // Add each amount as a separate output to addresses controlled by this wallet + for (amount, address) in amounts.iter().zip(addresses.iter()) { + builder.add_recipient(address.script_pubkey(), Amount::from_sat(*amount)); + } + + // TODO(@tee8z): make fee rate configurable + builder.fee_rate(FeeRate::from_sat_per_vb(2).unwrap()); + + let mut psbt = builder.finish()?; + + if !wallet.sign(&mut psbt, SignOptions::default())? { + return Err(anyhow::anyhow!("Failed to sign splitting transaction")); + } + + psbt.extract_tx()? + }; + + // Broadcast the transaction + self.esplora.broadcast(&tx).await?; + + let txid = tx.compute_txid(); + + // Persist wallet state { let mut wallet = self.wallet.write().unwrap(); let mut conn = self.conn.lock().unwrap(); wallet.persist(&mut conn)?; } - Ok(tx.compute_txid()) + // Mark this transaction as recently created + self.mark_transaction_as_recent(txid); + + Ok(txid) + } + + pub async fn split_largest_utxo_equally( + &self, + num_outputs: usize, + use_change_addresses: bool, + ) -> Result { + if num_outputs == 0 { + return Err(anyhow::anyhow!("Number of outputs must be greater than 0")); + } + + // Find the largest UTXO + let largest_utxo = { + let wallet = self.wallet.read().unwrap(); + wallet + .list_unspent() + .max_by_key(|utxo| utxo.txout.value.to_sat()) + .ok_or_else(|| anyhow::anyhow!("No UTXOs available to split"))? + }; + + // Calculate amount per output (leaving some for fees) + let total_amount = largest_utxo.txout.value.to_sat(); + let estimated_fee = 1000; // Conservative estimate for fees in satoshis + + if total_amount <= estimated_fee { + return Err(anyhow::anyhow!( + "UTXO too small to split after accounting for fees" + )); + } + + let amount_per_output = (total_amount - estimated_fee) / num_outputs as u64; + + if amount_per_output == 0 { + return Err(anyhow::anyhow!( + "Amount per output would be 0 after splitting" + )); + } + + // Create vector of equal amounts + let amounts = vec![amount_per_output; num_outputs]; + + self.split_utxos(amounts, use_change_addresses).await + } + + pub async fn create_utxo_mix( + &self, + small_count: usize, + medium_count: usize, + large_count: usize, + use_change_addresses: bool, + ) -> Result { + let mut amounts = Vec::new(); + + // Small UTXOs: 100,000 sats (0.001 BTC) + amounts.extend(vec![100_000; small_count]); + + // Medium UTXOs: 1,000,000 sats (0.01 BTC) + amounts.extend(vec![1_000_000; medium_count]); + + // Large UTXOs: 10,000,000 sats (0.1 BTC) + amounts.extend(vec![10_000_000; large_count]); + + if amounts.is_empty() { + return Err(anyhow::anyhow!("At least one output must be specified")); + } + + self.split_utxos(amounts, use_change_addresses).await + } + + fn mark_transaction_as_recent(&self, txid: bitcoin::Txid) { + let mut recent_transactions = self.recent_transactions.write().unwrap(); + let mut recent_tx_timestamp = self.recent_tx_timestamp.write().unwrap(); + + recent_transactions.clear(); // Only keep the most recent transaction highlighted + recent_transactions.insert(txid); + *recent_tx_timestamp = SystemTime::now(); + } + + pub fn is_transaction_recent(&self, txid: &bitcoin::Txid) -> bool { + let recent_transactions = self.recent_transactions.read().unwrap(); + let recent_tx_timestamp = self.recent_tx_timestamp.read().unwrap(); + + // Highlight for 30 seconds + if recent_tx_timestamp + .elapsed() + .unwrap_or(Duration::from_secs(0)) + > Duration::from_secs(30) + { + return false; + } + + recent_transactions.contains(txid) } }