diff --git a/rust/crates/cove-types/src/fees.rs b/rust/crates/cove-types/src/fees.rs index 9d30a287d..0c9ee2165 100644 --- a/rust/crates/cove-types/src/fees.rs +++ b/rust/crates/cove-types/src/fees.rs @@ -242,16 +242,10 @@ impl FeeRateOptionsWithTotalFee { /// Used when we have fee rates but haven't built PSBTs to calculate actual fees yet #[must_use] pub fn without_totals(base: FeeRateOptions) -> Self { - let from = |opt: FeeRateOption| FeeRateOptionWithTotalFee { - fee_speed: opt.fee_speed, - fee_rate: opt.fee_rate, - total_fee: None, - }; - Self { - fast: from(base.fast), - medium: from(base.medium), - slow: from(base.slow), + fast: FeeRateOptionWithTotalFee::without_total(base.fast), + medium: FeeRateOptionWithTotalFee::without_total(base.medium), + slow: FeeRateOptionWithTotalFee::without_total(base.slow), custom: None, } } @@ -265,6 +259,12 @@ pub struct FeeRateOptionWithTotalFee { } impl FeeRateOptionWithTotalFee { + #[must_use] + pub fn without_total(option: FeeRateOption) -> Self { + Self { fee_speed: option.fee_speed, fee_rate: option.fee_rate, total_fee: None } + } + + #[must_use] pub fn new(option: FeeRateOption, total_fee: impl Into) -> Self { Self { fee_speed: option.fee_speed, @@ -520,6 +520,22 @@ mod tests { assert_eq!(option_with_total.sats_per_vbyte_string(), "3.20 sats/vbyte"); } + #[test] + fn fee_rate_options_without_totals_use_no_total_constructor() { + let base = FeeRateOptions { + fast: FeeRateOption::new(FeeSpeed::Fast, 3.2), + medium: FeeRateOption::new(FeeSpeed::Medium, 2.1), + slow: FeeRateOption::new(FeeSpeed::Slow, 1.0), + }; + + let options = FeeRateOptionsWithTotalFee::without_totals(base); + + assert_eq!(options.fast, FeeRateOptionWithTotalFee::without_total(base.fast)); + assert_eq!(options.medium, FeeRateOptionWithTotalFee::without_total(base.medium)); + assert_eq!(options.slow, FeeRateOptionWithTotalFee::without_total(base.slow)); + assert_eq!(options.custom, None); + } + #[test] fn grouped_integer_formatting_matches_platform_number_format() { assert_eq!(0_u32.thousands_int(), "0"); diff --git a/rust/src/manager/send_flow_manager.rs b/rust/src/manager/send_flow_manager.rs index 2058c00b3..9c0a1a87f 100644 --- a/rust/src/manager/send_flow_manager.rs +++ b/rust/src/manager/send_flow_manager.rs @@ -270,46 +270,92 @@ impl RustSendFlowManager { return false; } - let (spendable_balance, unavailable_balance_alert, is_max_selected) = { + let ( + spendable_balance, + unavailable_balance_alert, + is_max_selected, + total_fee_sats, + fee_consumes_coin_control_balance, + ) = { let state = self.state.lock(); + + let spendable_balance = + validation::spendable_balance_limit(state.unlocked_spendable_sats, &state.mode); + + let spendable_balance_for_validation = + validation::spendable_balance_for_validation(spendable_balance); + + let total_fee_sats = match state.fee_selection.as_ref() { + Some(selection) => selection.selected.total_fee.map(|fee| fee.as_sats()), + None => Some(0), + }; + + let fee_consumes_coin_control_balance = state.mode.is_coin_control() + && spendable_balance_for_validation > 0 + && total_fee_sats.is_some_and(|fee| fee >= spendable_balance_for_validation); + ( - validation::spendable_balance_for_validation(state.unlocked_spendable_sats), + spendable_balance_for_validation, validation::unavailable_spendable_balance_alert( state.unlocked_spendable_sats, state.lock_state_load_failed, + &state.mode, ), state.max_selected.is_some(), + total_fee_sats, + fee_consumes_coin_control_balance, ) }; - if spendable_balance < amount { - if let Some(alert) = unavailable_balance_alert { - let msg = Message::SetAlert(alert); + match validation::spendable_balance_check(amount, total_fee_sats, spendable_balance) { + validation::SpendableBalanceCheck::WithinBalance => true, + validation::SpendableBalanceCheck::ExceedsBalance => { + // fee-only coin-control failures are handled by finalization so users get the fee-specific alert + if fee_consumes_coin_control_balance { + return true; + } + + if let Some(alert) = unavailable_balance_alert { + let msg = Message::SetAlert(alert); + + if display_alert { + sender.queue(msg); + } else { + debug!("validate_amount_failed: {msg:?}"); + } + + return false; + } + + if is_max_selected { + let me = self.clone(); + cove_tokio::task::spawn(async move { me.select_max_send_report_error().await }); + return false; + } + + let msg = Message::SetAlert(SendFlowError::InsufficientFunds.into()); if display_alert { sender.queue(msg); } else { debug!("validate_amount_failed: {msg:?}"); } - return false; + false } - if is_max_selected { - let me = self.clone(); - cove_tokio::task::spawn(async move { me.select_max_send_report_error().await }); - return false; - } + validation::SpendableBalanceCheck::MissingFeeTotal => { + self.schedule_fee_rate_update(); - let msg = Message::SetAlert(SendFlowError::InsufficientFunds.into()); - if display_alert { - sender.queue(msg); - } else { - debug!("validate_amount_failed: {msg:?}"); + let msg = Message::SetAlert(validation::missing_fee_total_alert()); + if display_alert { + sender.queue(msg); + } else { + debug!("validate_amount_failed: {msg:?}"); + } + + false } - return false; } - - true } fn pending_send_warning(&self) -> Option { @@ -696,12 +742,12 @@ impl RustSendFlowManager { #[cfg(test)] mod tests { - use std::{sync::Arc, time::Duration}; + use std::sync::Arc; - use cove_types::fees::{ - FeeRateOption, FeeRateOptionWithTotalFee, FeeRateOptionsWithTotalFee, FeeSpeed, + use cove_types::{ + fees::{FeeRateOption, FeeRateOptionWithTotalFee, FeeRateOptionsWithTotalFee, FeeSpeed}, + utxo::{UtxoList, ffi_preview::preview_new_utxo_list}, }; - use cove_types::utxo::{UtxoList, ffi_preview::preview_new_utxo_list}; use crate::{ manager::{deferred_sender::SingleOrMany, wallet_manager::RustWalletManager}, @@ -758,11 +804,8 @@ mod tests { fn set_selected_fee_without_total(manager: &super::RustSendFlowManager) { let base_options = super::FeeRateOptions::_ffi_preview_new(); - let selected = FeeRateOptionWithTotalFee { - fee_speed: FeeSpeed::Custom { duration_mins: 10 }, - fee_rate: FeeRateOption::new(FeeSpeed::Custom { duration_mins: 10 }, 1.0).fee_rate, - total_fee: None, - }; + let fee_option = FeeRateOption::new(FeeSpeed::Custom { duration_mins: 10 }, 1.0); + let selected = FeeRateOptionWithTotalFee::without_total(fee_option); let options = FeeRateOptionsWithTotalFee::without_totals(base_options); let mut state = manager.state.lock(); @@ -1228,35 +1271,32 @@ mod tests { } #[test] - fn finalize_refreshes_missing_total_fee_before_warning_detection() { + fn finalize_blocks_when_selected_fee_total_is_missing() { let manager = manager_for_validation(); set_valid_amount_and_address(&manager, 1_000); set_selected_fee_without_total(&manager); manager.finalize_and_go_to_next_screen(); - assert!(manager.reconciler.receiver().try_recv().is_err()); + assert!(matches!( + next_reconcile_message(&manager), + super::Message::SetAlert(super::SendFlowAlertState::General { title, .. }) + if title == "Fee Estimate Still Loading" + )); } #[test] - fn finalize_reports_dust_when_missing_total_fee_refresh_cannot_build_fee_totals() { + fn finalize_reports_missing_fee_before_dust_when_fee_total_is_unavailable() { let manager = manager_for_validation(); set_valid_amount_and_address(&manager, 1); set_selected_fee_without_total(&manager); manager.finalize_and_go_to_next_screen(); - let message = manager - .reconciler - .receiver() - .recv_timeout(Duration::from_secs(2)) - .expect("dust alert is reconciled"); - assert!(matches!( - message, - SingleOrMany::Single(super::Message::SetAlert(super::SendFlowAlertState::Error( - super::SendFlowError::SendBelowDustLimit - ))) + next_reconcile_message(&manager), + super::Message::SetAlert(super::SendFlowAlertState::General { title, .. }) + if title == "Fee Estimate Still Loading" )); } @@ -1282,4 +1322,134 @@ mod tests { super::SendFlowAlertState::General { title, .. } if title.contains("Locked") )); } + + #[test] + fn validate_amount_blocks_send_when_amount_plus_fee_exceeds_balance() { + let _guard = crate::test_support::global_state_test_lock().blocking_lock(); + let manager = manager_for_validation(); + { + let mut state = manager.state.lock(); + state.amount_sats = Some(5_000); + state.unlocked_spendable_sats = Some(5_001); + } + set_selected_fee_total(&manager, 156); + + assert!(!manager.validate_amount(true)); + + let super::Message::SetAlert(alert) = next_reconcile_message(&manager) else { + panic!("expected a single insufficient funds alert"); + }; + + assert!(matches!( + alert, + super::SendFlowAlertState::Error(super::SendFlowError::InsufficientFunds) + )); + } + + #[test] + fn validate_amount_blocks_send_when_selected_fee_total_is_missing() { + let _guard = crate::test_support::global_state_test_lock().blocking_lock(); + let manager = manager_for_validation(); + { + let mut state = manager.state.lock(); + state.amount_sats = Some(5_000); + state.unlocked_spendable_sats = Some(5_001); + state.address = Some(Arc::new(Address::preview_new())); + } + set_selected_fee_without_total(&manager); + + assert!(!manager.validate_amount(true)); + + let super::Message::SetAlert(alert) = next_reconcile_message(&manager) else { + panic!("expected a single missing fee alert"); + }; + + assert!(matches!( + alert, + super::SendFlowAlertState::General { title, .. } + if title == "Fee Estimate Still Loading" + )); + } + + #[test] + fn validate_amount_blocks_coin_control_send_when_amount_plus_fee_exceeds_selected_total() { + let _guard = crate::test_support::global_state_test_lock().blocking_lock(); + let manager = manager_for_validation(); + set_coin_control_mode_with_total(&manager, 10_000); + { + let mut state = manager.state.lock(); + state.amount_sats = Some(9_900); + state.unlocked_spendable_sats = Some(50_000); + } + set_selected_fee_total(&manager, 156); + + assert!(!manager.validate_amount(true)); + + let super::Message::SetAlert(alert) = next_reconcile_message(&manager) else { + panic!("expected a single insufficient funds alert"); + }; + + assert!(matches!( + alert, + super::SendFlowAlertState::Error(super::SendFlowError::InsufficientFunds) + )); + } + + #[test] + fn validate_amount_allows_total_equal_to_spendable_balance() { + let _guard = crate::test_support::global_state_test_lock().blocking_lock(); + let manager = manager_for_validation(); + { + let mut state = manager.state.lock(); + state.amount_sats = Some(5_000); + state.unlocked_spendable_sats = Some(5_156); + } + set_selected_fee_total(&manager, 156); + + assert!(manager.validate_amount(true)); + assert!(manager.reconciler.receiver().try_recv().is_err()); + } + + #[test] + fn amount_exceeds_balance_includes_selected_fee_total() { + let _guard = crate::test_support::global_state_test_lock().blocking_lock(); + let manager = manager_for_validation(); + { + let mut state = manager.state.lock(); + state.amount_sats = Some(5_000); + state.unlocked_spendable_sats = Some(5_001); + } + set_selected_fee_total(&manager, 156); + + assert!(manager.amount_exceeds_balance()); + } + + #[test] + fn amount_exceeds_balance_uses_coin_control_selected_total() { + let _guard = crate::test_support::global_state_test_lock().blocking_lock(); + let manager = manager_for_validation(); + set_coin_control_mode_with_total(&manager, 10_000); + { + let mut state = manager.state.lock(); + state.amount_sats = Some(9_900); + state.unlocked_spendable_sats = Some(50_000); + } + set_selected_fee_total(&manager, 156); + + assert!(manager.amount_exceeds_balance()); + } + + #[test] + fn amount_exceeds_balance_treats_missing_fee_as_exceeding_at_spendable_limit() { + let _guard = crate::test_support::global_state_test_lock().blocking_lock(); + let manager = manager_for_validation(); + { + let mut state = manager.state.lock(); + state.amount_sats = Some(5_000); + state.unlocked_spendable_sats = Some(5_000); + } + set_selected_fee_without_total(&manager); + + assert!(manager.amount_exceeds_balance()); + } } diff --git a/rust/src/manager/send_flow_manager/finalize.rs b/rust/src/manager/send_flow_manager/finalize.rs index 8fc482516..493b1efcf 100644 --- a/rust/src/manager/send_flow_manager/finalize.rs +++ b/rust/src/manager/send_flow_manager/finalize.rs @@ -313,11 +313,8 @@ mod tests { } fn selected_fee_without_total() -> FeeRateOptionWithTotalFee { - FeeRateOptionWithTotalFee { - fee_speed: FeeSpeed::Custom { duration_mins: 10 }, - fee_rate: FeeRateOption::new(FeeSpeed::Custom { duration_mins: 10 }, 1.0).fee_rate, - total_fee: None, - } + let fee_option = FeeRateOption::new(FeeSpeed::Custom { duration_mins: 10 }, 1.0); + FeeRateOptionWithTotalFee::without_total(fee_option) } fn finalize_snapshot( @@ -387,11 +384,8 @@ mod tests { let snapshot = finalize_snapshot(address.clone(), selected_fee_rate.clone()); set_finalize_snapshot_state(&manager, address, selected_fee_rate); - let selected = Arc::new(FeeRateOptionWithTotalFee { - fee_speed: FeeSpeed::Custom { duration_mins: 20 }, - fee_rate: FeeRateOption::new(FeeSpeed::Custom { duration_mins: 20 }, 2.0).fee_rate, - total_fee: None, - }); + let fee_option = FeeRateOption::new(FeeSpeed::Custom { duration_mins: 20 }, 2.0); + let selected = Arc::new(FeeRateOptionWithTotalFee::without_total(fee_option)); let options = FeeRateOptionsWithTotalFee::without_totals(FeeRateOptions::_ffi_preview_new()); manager.state.lock().fee_selection = Some(FeeSelection::new(Arc::new(options), selected)); diff --git a/rust/src/manager/send_flow_manager/read_api.rs b/rust/src/manager/send_flow_manager/read_api.rs index e8cc61d69..67922c95a 100644 --- a/rust/src/manager/send_flow_manager/read_api.rs +++ b/rust/src/manager/send_flow_manager/read_api.rs @@ -90,10 +90,17 @@ impl RustSendFlowManager { #[uniffi::method] pub fn amount_exceeds_balance(&self) -> bool { let state = self.state.lock(); + let total_fee_sats = state + .fee_selection + .as_ref() + .and_then(|selection| selection.selected.total_fee.map(|fee| fee.as_sats())); + let spendable_balance = + validation::spendable_balance_limit(state.unlocked_spendable_sats, &state.mode); validation::amount_exceeds_spendable_balance( state.amount_sats, - state.unlocked_spendable_sats, + total_fee_sats, + spendable_balance, ) } diff --git a/rust/src/manager/send_flow_manager/validation.rs b/rust/src/manager/send_flow_manager/validation.rs index 93ea09940..63d9fa18c 100644 --- a/rust/src/manager/send_flow_manager/validation.rs +++ b/rust/src/manager/send_flow_manager/validation.rs @@ -1,7 +1,15 @@ -use super::SendFlowAlertState; +use super::{EnterMode, SendFlowAlertState}; + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) enum SpendableBalanceCheck { + WithinBalance, + ExceedsBalance, + MissingFeeTotal, +} pub(crate) fn amount_exceeds_spendable_balance( amount: Option, + total_fee_sats: Option, spendable_balance: Option, ) -> bool { let amount = amount.unwrap_or(0); @@ -13,18 +21,61 @@ pub(crate) fn amount_exceeds_spendable_balance( return false; }; - amount > spendable + match spendable_balance_check(amount, total_fee_sats, spendable) { + SpendableBalanceCheck::WithinBalance => false, + SpendableBalanceCheck::ExceedsBalance => true, + SpendableBalanceCheck::MissingFeeTotal => amount >= spendable, + } +} + +pub(crate) fn spendable_balance_check( + amount: u64, + total_fee_sats: Option, + spendable_balance: u64, +) -> SpendableBalanceCheck { + if amount > spendable_balance { + return SpendableBalanceCheck::ExceedsBalance; + } + + let Some(total_fee_sats) = total_fee_sats else { + return SpendableBalanceCheck::MissingFeeTotal; + }; + + if total_spend_sats(amount, total_fee_sats) > spendable_balance { + return SpendableBalanceCheck::ExceedsBalance; + } + + SpendableBalanceCheck::WithinBalance } -pub(crate) fn spendable_balance_for_validation(unlocked_spendable_sats: Option) -> u64 { +pub(crate) fn total_spend_sats(amount: u64, total_fee_sats: u64) -> u64 { + amount.saturating_add(total_fee_sats) +} + +pub(crate) fn spendable_balance_limit( + unlocked_spendable_sats: Option, + mode: &EnterMode, +) -> Option { + match mode { + EnterMode::SetAmount => unlocked_spendable_sats, + EnterMode::CoinControl(coin_control) => Some(coin_control.max_send().as_sats()), + } +} + +pub(crate) fn spendable_balance_for_validation(spendable_balance: Option) -> u64 { // fail closed so amount validation cannot overspend locked or unknown UTXOs - unlocked_spendable_sats.unwrap_or(0) + spendable_balance.unwrap_or(0) } pub(crate) fn unavailable_spendable_balance_alert( unlocked_spendable_sats: Option, lock_state_load_failed: bool, + mode: &EnterMode, ) -> Option { + if mode.is_coin_control() { + return None; + } + if lock_state_load_failed { return Some(SendFlowAlertState::General { title: "Unable to Read Locked Coins".to_string(), @@ -43,26 +94,72 @@ pub(crate) fn unavailable_spendable_balance_alert( None } +pub(crate) fn missing_fee_total_alert() -> SendFlowAlertState { + SendFlowAlertState::General { + title: "Fee Estimate Still Loading".to_string(), + message: + "Cove is still calculating the network fee for this send. Please try again shortly." + .to_string(), + } +} + #[cfg(test)] mod tests { #[test] fn amount_exceeds_spendable_balance_uses_unlocked_balance() { - assert!(super::amount_exceeds_spendable_balance(Some(6_000), Some(5_000))); - assert!(!super::amount_exceeds_spendable_balance(Some(5_000), Some(5_000))); - assert!(!super::amount_exceeds_spendable_balance(Some(0), Some(0))); - assert!(!super::amount_exceeds_spendable_balance(Some(1), None)); - assert!(!super::amount_exceeds_spendable_balance(Some(0), None)); + assert!(super::amount_exceeds_spendable_balance(Some(6_000), Some(1), Some(5_000))); + assert!(!super::amount_exceeds_spendable_balance(Some(5_000), Some(0), Some(5_000))); + assert!(!super::amount_exceeds_spendable_balance(Some(0), None, Some(0))); + assert!(!super::amount_exceeds_spendable_balance(Some(1), None, None)); + assert!(!super::amount_exceeds_spendable_balance(Some(0), None, None)); + } + + #[test] + fn amount_exceeds_spendable_balance_includes_fee_when_available() { + assert!(super::amount_exceeds_spendable_balance(Some(5_000), Some(156), Some(5_001))); + assert!(!super::amount_exceeds_spendable_balance(Some(5_000), Some(1), Some(5_001))); + } + + #[test] + fn amount_exceeds_spendable_balance_treats_missing_fee_as_exceeding_at_limit() { + assert!(super::amount_exceeds_spendable_balance(Some(5_000), None, Some(5_000))); + assert!(!super::amount_exceeds_spendable_balance(Some(4_999), None, Some(5_000))); + } + + #[test] + fn zero_amount_does_not_exceed_spendable_balance_even_with_fee() { + assert!(!super::amount_exceeds_spendable_balance(Some(0), Some(1), Some(0))); + } + + #[test] + fn spendable_balance_check_tracks_missing_fee_total() { + assert_eq!( + super::spendable_balance_check(5_000, None, 5_001), + super::SpendableBalanceCheck::MissingFeeTotal + ); + assert_eq!( + super::spendable_balance_check(5_002, None, 5_001), + super::SpendableBalanceCheck::ExceedsBalance + ); + assert_eq!( + super::spendable_balance_check(5_000, Some(1), 5_001), + super::SpendableBalanceCheck::WithinBalance + ); } #[test] fn validation_spendable_balance_uses_zero_when_unlocked_balance_is_unknown() { - assert_eq!(super::spendable_balance_for_validation(Some(5_000)), 5_000); - assert_eq!(super::spendable_balance_for_validation(None), 0); + let spendable = super::spendable_balance_limit(Some(5_000), &super::EnterMode::SetAmount); + assert_eq!(super::spendable_balance_for_validation(spendable), 5_000); + + let spendable = super::spendable_balance_limit(None, &super::EnterMode::SetAmount); + assert_eq!(super::spendable_balance_for_validation(spendable), 0); } #[test] fn unavailable_balance_alert_distinguishes_lock_state_failures() { - let alert = super::unavailable_spendable_balance_alert(None, true); + let alert = + super::unavailable_spendable_balance_alert(None, true, &super::EnterMode::SetAmount); assert!(matches!( alert, @@ -72,12 +169,20 @@ mod tests { #[test] fn unavailable_balance_alert_distinguishes_loading_state() { - let alert = super::unavailable_spendable_balance_alert(None, false); + let alert = + super::unavailable_spendable_balance_alert(None, false, &super::EnterMode::SetAmount); assert!(matches!( alert, Some(super::SendFlowAlertState::General { title, .. }) if title.contains("Loading") )); - assert_eq!(super::unavailable_spendable_balance_alert(Some(5_000), false), None); + assert_eq!( + super::unavailable_spendable_balance_alert( + Some(5_000), + false, + &super::EnterMode::SetAmount + ), + None + ); } } diff --git a/rust/src/manager/wallet_manager/actor.rs b/rust/src/manager/wallet_manager/actor.rs index ab5a6695f..3719e73ab 100644 --- a/rust/src/manager/wallet_manager/actor.rs +++ b/rust/src/manager/wallet_manager/actor.rs @@ -1160,7 +1160,10 @@ mod tests { }; use cove_bdk_progressive_scan::ScanUpdate; use cove_device::keychain::{Keychain, KeychainAccess, KeychainError}; - use cove_types::network::Network as CoveNetwork; + use cove_types::{ + fees::{FeeRateOption, FeeRateOptions, FeeSpeed}, + network::Network as CoveNetwork, + }; use parking_lot::RwLock; use std::{ collections::{BTreeMap, HashMap, HashSet}, @@ -1701,6 +1704,14 @@ mod tests { bitcoin::FeeRate::from_sat_per_vb(100).expect("fee rate") } + fn one_sat_fee_options() -> FeeRateOptions { + FeeRateOptions { + fast: FeeRateOption::new(FeeSpeed::Fast, 1.0), + medium: FeeRateOption::new(FeeSpeed::Medium, 1.0), + slow: FeeRateOption::new(FeeSpeed::Slow, 1.0), + } + } + #[test] fn progressive_scan_update_response_preserves_last_active_indices() { let scan_update = ScanUpdate { @@ -1988,6 +1999,26 @@ mod tests { assert!(spent_outpoints.contains(&fixture.unlocked)); } + #[tokio::test(flavor = "current_thread")] + async fn actor_fee_options_include_fee_when_amount_exceeds_available_with_fee() { + let _guard = crate::test_support::global_state_test_lock().lock().await; + crate::test_support::ensure_tokio_runtime(); + let fixture = locked_actor_fixture(); + let mut actor = fixture.actor; + + let result = actor + .fee_rate_options_with_total_fee( + one_sat_fee_options(), + Amount::from_sat(79_950), + Address::preview_new(), + ) + .await; + let options = actor_value(result).await.expect("fee totals are estimated"); + let medium_fee = options.medium.total_fee.expect("medium fee total exists"); + + assert!(medium_fee.as_sats() > 50); + } + #[tokio::test(flavor = "current_thread")] async fn actor_drain_tx_excludes_db_locked_outpoints_from_psbt_inputs() { let _guard = crate::test_support::global_state_test_lock().lock().await; diff --git a/rust/src/manager/wallet_manager/actor/transactions.rs b/rust/src/manager/wallet_manager/actor/transactions.rs index e77de0f39..a83d24a8f 100644 --- a/rust/src/manager/wallet_manager/actor/transactions.rs +++ b/rust/src/manager/wallet_manager/actor/transactions.rs @@ -16,7 +16,7 @@ use bitcoin::{ use cove_bdk::coin_selection::CoveDefaultCoinSelection; use cove_types::{ confirm::{AddressAndAmount, ConfirmDetails, ExtraItem, InputOutputDetails, SplitOutput}, - fees::{FeeRateOptionWithTotalFee, FeeRateOptions, FeeRateOptionsWithTotalFee}, + fees::{FeeRateOption, FeeRateOptionWithTotalFee, FeeRateOptions, FeeRateOptionsWithTotalFee}, utxo::{UtxoList, UtxoType}, }; use cove_util::result_ext::ResultExt as _; @@ -51,6 +51,46 @@ impl BroadcastTransactionError { } impl WalletActor { + fn fee_from_insufficient_funds_needed(amount: Amount, needed: Amount) -> Result { + needed.checked_sub(amount).ok_or_else(|| { + WalletManagerError::FeesError(format!( + "coin selection needed amount below send amount: needed_sats={}, amount_sats={}", + needed.to_sat(), + amount.to_sat() + )) + }) + } + + fn fee_option_with_total_fee( + &mut self, + option: FeeRateOption, + amount: Amount, + address: Address, + ) -> Result { + let coin_selection = CoveDefaultCoinSelection::new(self.seed); + let locked_outpoints = self.locked_output_outpoints()?; + let mut tx_builder = self.wallet.bdk.build_tx().coin_selection(coin_selection); + + exclude_locked_outpoints(&mut tx_builder, locked_outpoints); + tx_builder.ordering(TxOrdering::Untouched); + tx_builder.add_recipient(address.script_pubkey(), amount); + tx_builder.fee_rate(*option.fee_rate); + + let psbt = tx_builder.finish(); + let total_fee = match psbt { + Ok(psbt) => { + self.wallet.unreserve_tx_change_addresses(&psbt.unsigned_tx); + psbt.fee().map_err(WalletManagerFeesError::from)? + } + Err(CreateTxError::CoinSelection(insufficient_funds)) => { + Self::fee_from_insufficient_funds_needed(amount, insufficient_funds.needed)? + } + Err(error) => return Err(error.into()), + }; + + Ok(FeeRateOptionWithTotalFee::new(option, total_fee)) + } + // Build a transaction but don't advance the change address index #[into_actor_result] pub async fn build_ephemeral_drain_tx( @@ -201,30 +241,14 @@ impl WalletActor { ) -> Result { self.ensure_ledger_ready_for_spend()?; - let fast_fee_rate = fee_rate_options.fast.fee_rate; - let medium_fee_rate = fee_rate_options.medium.fee_rate; - let slow_fee_rate = fee_rate_options.slow.fee_rate; - - let fast_psbt = self.do_build_ephemeral_tx(amount, address.clone(), fast_fee_rate).await?; - - let medium_psbt = - self.do_build_ephemeral_tx(amount, address.clone(), medium_fee_rate).await?; - - let slow_psbt = self.do_build_ephemeral_tx(amount, address.clone(), slow_fee_rate).await?; - let options = FeeRateOptionsWithTotalFee { - fast: FeeRateOptionWithTotalFee::new( - fee_rate_options.fast, - fast_psbt.fee().map_err(WalletManagerFeesError::from)?, - ), - medium: FeeRateOptionWithTotalFee::new( + fast: self.fee_option_with_total_fee(fee_rate_options.fast, amount, address.clone())?, + medium: self.fee_option_with_total_fee( fee_rate_options.medium, - medium_psbt.fee().map_err(WalletManagerFeesError::from)?, - ), - slow: FeeRateOptionWithTotalFee::new( - fee_rate_options.slow, - slow_psbt.fee().map_err(WalletManagerFeesError::from)?, - ), + amount, + address.clone(), + )?, + slow: self.fee_option_with_total_fee(fee_rate_options.slow, amount, address.clone())?, custom: None, }; @@ -933,3 +957,36 @@ async fn broadcast_transaction_with_connection( Ok(()) } + +#[cfg(test)] +mod tests { + use bitcoin::Amount; + + use super::{WalletActor, WalletManagerError}; + + #[test] + fn insufficient_funds_needed_amount_derives_fee() { + let fee = WalletActor::fee_from_insufficient_funds_needed( + Amount::from_sat(5_000), + Amount::from_sat(5_156), + ) + .expect("fee is derived"); + + assert_eq!(fee, Amount::from_sat(156)); + } + + #[test] + fn insufficient_funds_needed_below_amount_returns_error() { + let error = WalletActor::fee_from_insufficient_funds_needed( + Amount::from_sat(5_000), + Amount::from_sat(4_999), + ) + .expect_err("invalid needed amount is rejected"); + + assert!(matches!( + error, + WalletManagerError::FeesError(message) + if message.contains("needed amount below send amount") + )); + } +}