diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 89b740d2..8f9a3a06 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -55,7 +55,7 @@ pub use utils::IsDust; use address_validator::AddressValidator; use signer::{Signer, SignerId, SignerOrdering, SignersContainer}; -use tx_builder::TxBuilder; +use tx_builder::{FeePolicy, TxBuilder}; use utils::{After, Older}; use crate::blockchain::{Blockchain, BlockchainMarker, OfflineBlockchain, Progress}; @@ -299,7 +299,7 @@ where output: vec![], }; - let fee_rate = builder.fee_rate.unwrap_or_default(); + let fee_rate = get_fee_rate(&builder.fee_policy); if builder.send_all && builder.recipients.len() != 1 { return Err(Error::SendAllMultipleOutputs); } @@ -393,6 +393,14 @@ where }; let mut fee_amount = fee_amount.ceil() as u64; + + if builder.has_absolute_fee() { + fee_amount = match builder.fee_policy.as_ref().unwrap() { + FeePolicy::FeeAmount(amount) => *amount, + _ => fee_amount, + } + }; + let change_val = (selected_amount - outgoing).saturating_sub(fee_amount); if !builder.send_all && !change_val.is_dust() { let mut change_output = change_output.unwrap(); @@ -485,9 +493,9 @@ where // the new tx must "pay for its bandwidth" let vbytes = tx.get_weight() as f32 / 4.0; let required_feerate = FeeRate::from_sat_per_vb(details.fees as f32 / vbytes + 1.0); - let new_feerate = builder.fee_rate.unwrap_or_default(); + let new_feerate = get_fee_rate(&builder.fee_policy); - if new_feerate < required_feerate { + if new_feerate < required_feerate && !builder.has_absolute_fee() { return Err(Error::FeeRateTooLow { required: required_feerate, }); @@ -623,6 +631,15 @@ where let amount_needed = tx.output.iter().fold(0, |acc, out| acc + out.value); let initial_fee = tx.get_weight() as f32 / 4.0 * new_feerate.as_sat_vb(); + let initial_fee = if builder.has_absolute_fee() { + match builder.fee_policy.as_ref().unwrap() { + FeePolicy::FeeAmount(amount) => *amount as f32, + _ => initial_fee, + } + } else { + initial_fee + }; + let coin_selection::CoinSelectionResult { txin, selected_amount, @@ -652,6 +669,12 @@ where details.sent = selected_amount; let mut fee_amount = fee_amount.ceil() as u64; + if builder.has_absolute_fee() { + fee_amount = match builder.fee_policy.as_ref().unwrap() { + FeePolicy::FeeAmount(amount) => *amount, + _ => fee_amount, + } + }; let removed_output_fee_cost = (serialize(&removed_updatable_output).len() as f32 * new_feerate.as_sat_vb()) .ceil() as u64; @@ -659,14 +682,23 @@ where let change_val = selected_amount - amount_needed - fee_amount; let change_val_after_add = change_val.saturating_sub(removed_output_fee_cost); if !builder.send_all && !change_val_after_add.is_dust() { - removed_updatable_output.value = change_val_after_add; - fee_amount += removed_output_fee_cost; - details.received += change_val_after_add; + if builder.has_absolute_fee() { + removed_updatable_output.value = change_val_after_add + removed_output_fee_cost; + details.received += change_val_after_add + removed_output_fee_cost; + } else { + removed_updatable_output.value = change_val_after_add; + fee_amount += removed_output_fee_cost; + details.received += change_val_after_add; + } tx.output.push(removed_updatable_output); } else if builder.send_all && !change_val_after_add.is_dust() { - removed_updatable_output.value = change_val_after_add; - fee_amount += removed_output_fee_cost; + if builder.has_absolute_fee() { + removed_updatable_output.value = change_val_after_add + removed_output_fee_cost; + } else { + removed_updatable_output.value = change_val_after_add; + fee_amount += removed_output_fee_cost; + } // send_all to our address if self.is_mine(&removed_updatable_output.script_pubkey)? { @@ -1210,6 +1242,17 @@ where } } +/// get the fee rate if specified or a default +fn get_fee_rate(fee_policy: &Option) -> FeeRate { + if fee_policy.is_none() { + return FeeRate::default(); + } + match fee_policy.as_ref().unwrap() { + FeePolicy::FeeRate(fr) => *fr, + _ => FeeRate::default(), + } +} + #[cfg(test)] mod test { use std::str::FromStr; @@ -1660,6 +1703,21 @@ mod test { assert_fee_rate!(psbt.extract_tx(), details.fees, FeeRate::from_sat_per_vb(5.0), @add_signature); } + #[test] + fn test_create_tx_absolute_fee() { + let (wallet, _, _) = get_funded_wallet(get_test_wpkh()); + let addr = wallet.get_new_address().unwrap(); + let (psbt, details) = wallet + .create_tx( + TxBuilder::with_recipients(vec![(addr.script_pubkey(), 0)]) + .fee_absolute(100) + .send_all(), + ) + .unwrap(); + + assert_eq!(details.fees, 100); + } + #[test] fn test_create_tx_add_change() { use super::tx_builder::TxOrdering; @@ -2049,6 +2107,71 @@ mod test { assert_fee_rate!(psbt.extract_tx(), details.fees, FeeRate::from_sat_per_vb(2.5), @add_signature); } + #[test] + fn test_bump_fee_absolute_reduce_change() { + let (wallet, _, _) = get_funded_wallet(get_test_wpkh()); + let addr = Address::from_str("2N1Ffz3WaNzbeLFBb51xyFMHYSEUXcbiSoX").unwrap(); + let (psbt, mut original_details) = wallet + .create_tx( + TxBuilder::with_recipients(vec![(addr.script_pubkey(), 25_000)]).enable_rbf(), + ) + .unwrap(); + let mut tx = psbt.extract_tx(); + let txid = tx.txid(); + // skip saving the new utxos, we know they can't be used anyways + for txin in &mut tx.input { + txin.witness.push([0x00; 108].to_vec()); // fake signature + wallet + .database + .borrow_mut() + .del_utxo(&txin.previous_output) + .unwrap(); + } + original_details.transaction = Some(tx); + wallet + .database + .borrow_mut() + .set_tx(&original_details) + .unwrap(); + + let (psbt, details) = wallet + .bump_fee(&txid, TxBuilder::new().fee_absolute(200)) + .unwrap(); + + assert_eq!(details.sent, original_details.sent); + assert_eq!( + details.received + details.fees, + original_details.received + original_details.fees + ); + assert!( + details.fees > original_details.fees, + "{} > {}", + details.fees, + original_details.fees + ); + + let tx = &psbt.global.unsigned_tx; + assert_eq!(tx.output.len(), 2); + assert_eq!( + tx.output + .iter() + .find(|txout| txout.script_pubkey == addr.script_pubkey()) + .unwrap() + .value, + 25_000 + ); + assert_eq!( + tx.output + .iter() + .find(|txout| txout.script_pubkey != addr.script_pubkey()) + .unwrap() + .value, + details.received + ); + + assert_eq!(details.fees, 200); + } + #[test] fn test_bump_fee_reduce_send_all() { let (wallet, _, _) = get_funded_wallet(get_test_wpkh()); @@ -2096,6 +2219,48 @@ mod test { assert_fee_rate!(psbt.extract_tx(), details.fees, FeeRate::from_sat_per_vb(2.5), @add_signature); } + #[test] + fn test_bump_fee_absolute_reduce_send_all() { + let (wallet, _, _) = get_funded_wallet(get_test_wpkh()); + let addr = Address::from_str("2N1Ffz3WaNzbeLFBb51xyFMHYSEUXcbiSoX").unwrap(); + let (psbt, mut original_details) = wallet + .create_tx( + TxBuilder::with_recipients(vec![(addr.script_pubkey(), 0)]) + .send_all() + .enable_rbf(), + ) + .unwrap(); + let mut tx = psbt.extract_tx(); + let txid = tx.txid(); + for txin in &mut tx.input { + txin.witness.push([0x00; 108].to_vec()); // fake signature + wallet + .database + .borrow_mut() + .del_utxo(&txin.previous_output) + .unwrap(); + } + original_details.transaction = Some(tx); + wallet + .database + .borrow_mut() + .set_tx(&original_details) + .unwrap(); + + let (psbt, details) = wallet + .bump_fee(&txid, TxBuilder::new().send_all().fee_absolute(300)) + .unwrap(); + + assert_eq!(details.sent, original_details.sent); + assert!(details.fees > original_details.fees); + + let tx = &psbt.global.unsigned_tx; + assert_eq!(tx.output.len(), 1); + assert_eq!(tx.output[0].value + details.fees, details.sent); + + assert_eq!(details.fees, 300); + } + #[test] #[should_panic(expected = "InsufficientFunds")] fn test_bump_fee_remove_send_all_output() { @@ -2211,6 +2376,68 @@ mod test { assert_fee_rate!(psbt.extract_tx(), details.fees, FeeRate::from_sat_per_vb(50.0), @add_signature); } + #[test] + fn test_bump_fee_absolute_add_input() { + let (wallet, descriptors, _) = get_funded_wallet(get_test_wpkh()); + wallet.database.borrow_mut().received_tx( + testutils! (@tx ( (@external descriptors, 0) => 25_000 ) (@confirmations 1)), + Some(100), + ); + + let addr = Address::from_str("2N1Ffz3WaNzbeLFBb51xyFMHYSEUXcbiSoX").unwrap(); + let (psbt, mut original_details) = wallet + .create_tx( + TxBuilder::with_recipients(vec![(addr.script_pubkey(), 45_000)]).enable_rbf(), + ) + .unwrap(); + let mut tx = psbt.extract_tx(); + let txid = tx.txid(); + // skip saving the new utxos, we know they can't be used anyways + for txin in &mut tx.input { + txin.witness.push([0x00; 108].to_vec()); // fake signature + wallet + .database + .borrow_mut() + .del_utxo(&txin.previous_output) + .unwrap(); + } + original_details.transaction = Some(tx); + wallet + .database + .borrow_mut() + .set_tx(&original_details) + .unwrap(); + + let (psbt, details) = wallet + .bump_fee(&txid, TxBuilder::new().fee_absolute(6_000)) + .unwrap(); + + assert_eq!(details.sent, original_details.sent + 25_000); + assert_eq!(details.fees + details.received, 30_000); + + let tx = &psbt.global.unsigned_tx; + assert_eq!(tx.input.len(), 2); + assert_eq!(tx.output.len(), 2); + assert_eq!( + tx.output + .iter() + .find(|txout| txout.script_pubkey == addr.script_pubkey()) + .unwrap() + .value, + 45_000 + ); + assert_eq!( + tx.output + .iter() + .find(|txout| txout.script_pubkey != addr.script_pubkey()) + .unwrap() + .value, + details.received + ); + + assert_eq!(details.fees, 6_000); + } + #[test] fn test_bump_fee_no_change_add_input_and_change() { let (wallet, descriptors, _) = get_funded_wallet(get_test_wpkh()); @@ -2422,6 +2649,78 @@ mod test { assert_fee_rate!(psbt.extract_tx(), details.fees, FeeRate::from_sat_per_vb(5.0), @add_signature); } + #[test] + fn test_bump_fee_absolute_force_add_input() { + let (wallet, descriptors, _) = get_funded_wallet(get_test_wpkh()); + let incoming_txid = wallet.database.borrow_mut().received_tx( + testutils! (@tx ( (@external descriptors, 0) => 25_000 ) (@confirmations 1)), + Some(100), + ); + + let addr = Address::from_str("2N1Ffz3WaNzbeLFBb51xyFMHYSEUXcbiSoX").unwrap(); + let (psbt, mut original_details) = wallet + .create_tx( + TxBuilder::with_recipients(vec![(addr.script_pubkey(), 45_000)]).enable_rbf(), + ) + .unwrap(); + let mut tx = psbt.extract_tx(); + let txid = tx.txid(); + // skip saving the new utxos, we know they can't be used anyways + for txin in &mut tx.input { + txin.witness.push([0x00; 108].to_vec()); // fake signature + wallet + .database + .borrow_mut() + .del_utxo(&txin.previous_output) + .unwrap(); + } + original_details.transaction = Some(tx); + wallet + .database + .borrow_mut() + .set_tx(&original_details) + .unwrap(); + + // the new fee_rate is low enough that just reducing the change would be fine, but we force + // the addition of an extra input with `add_utxo()` + let (psbt, details) = wallet + .bump_fee( + &txid, + TxBuilder::new() + .add_utxo(OutPoint { + txid: incoming_txid, + vout: 0, + }) + .fee_absolute(250), + ) + .unwrap(); + + assert_eq!(details.sent, original_details.sent + 25_000); + assert_eq!(details.fees + details.received, 30_000); + + let tx = &psbt.global.unsigned_tx; + assert_eq!(tx.input.len(), 2); + assert_eq!(tx.output.len(), 2); + assert_eq!( + tx.output + .iter() + .find(|txout| txout.script_pubkey == addr.script_pubkey()) + .unwrap() + .value, + 45_000 + ); + assert_eq!( + tx.output + .iter() + .find(|txout| txout.script_pubkey != addr.script_pubkey()) + .unwrap() + .value, + details.received + ); + + assert_eq!(details.fees, 250); + } + #[test] fn test_sign_single_xprv() { let (wallet, _, _) = get_funded_wallet("wpkh(tprv8ZgxMBicQKsPd3EupYiPRhaMooHKUHJxNsTfYuScep13go8QFfHdtkG9nRkFGb7busX4isf6X9dURGCoKgitaApQ6MupRhZMcELAxTBRJgS/*)"); diff --git a/src/wallet/tx_builder.rs b/src/wallet/tx_builder.rs index 309825bc..d693f771 100644 --- a/src/wallet/tx_builder.rs +++ b/src/wallet/tx_builder.rs @@ -60,7 +60,7 @@ use crate::types::{FeeRate, UTXO}; pub struct TxBuilder> { pub(crate) recipients: Vec<(Script, u64)>, pub(crate) send_all: bool, - pub(crate) fee_rate: Option, + pub(crate) fee_policy: Option, pub(crate) policy_path: Option>>, pub(crate) utxos: Option>, pub(crate) unspendable: Option>, @@ -76,6 +76,12 @@ pub struct TxBuilder> { phantom: PhantomData, } +#[derive(Debug)] +pub enum FeePolicy { + FeeRate(FeeRate), + FeeAmount(u64), +} + // Unfortunately derive doesn't work with `PhantomData`: https://github.com/rust-lang/rust/issues/26925 impl> Default for TxBuilder where @@ -85,7 +91,7 @@ where TxBuilder { recipients: Default::default(), send_all: Default::default(), - fee_rate: Default::default(), + fee_policy: Default::default(), policy_path: Default::default(), utxos: Default::default(), unspendable: Default::default(), @@ -140,7 +146,13 @@ impl> TxBuilder { /// Set a custom fee rate pub fn fee_rate(mut self, fee_rate: FeeRate) -> Self { - self.fee_rate = Some(fee_rate); + self.fee_policy = Some(FeePolicy::FeeRate(fee_rate)); + self + } + + /// Set an absolute fee + pub fn fee_absolute(mut self, fee_amount: u64) -> Self { + self.fee_policy = Some(FeePolicy::FeeAmount(fee_amount)); self } @@ -287,7 +299,7 @@ impl> TxBuilder { TxBuilder { recipients: self.recipients, send_all: self.send_all, - fee_rate: self.fee_rate, + fee_policy: self.fee_policy, policy_path: self.policy_path, utxos: self.utxos, unspendable: self.unspendable, @@ -303,6 +315,17 @@ impl> TxBuilder { phantom: PhantomData, } } + + /// Returns true if an absolute fee was specified + pub fn has_absolute_fee(&self) -> bool { + if self.fee_policy.is_none() { + return false; + }; + match self.fee_policy.as_ref().unwrap() { + FeePolicy::FeeAmount(_) => true, + _ => false, + } + } } /// Ordering of the transaction's inputs and outputs