use alloc::{boxed::Box, string::String, vec::Vec};
use core::fmt;
use alloc::sync::Arc;
use bitcoin::psbt::{self, Psbt};
use bitcoin::script::PushBytes;
use bitcoin::{
absolute, transaction::Version, Amount, FeeRate, OutPoint, ScriptBuf, Sequence, Transaction,
TxIn, TxOut, Txid, Weight,
};
use rand_core::RngCore;
use super::coin_selection::CoinSelectionAlgorithm;
use super::utils::shuffle_slice;
use super::{CreateTxError, Wallet};
use crate::collections::{BTreeMap, HashMap, HashSet};
use crate::{KeychainKind, LocalOutput, Utxo, WeightedUtxo};
#[derive(Debug)]
pub struct TxBuilder<'a, Cs> {
pub(crate) wallet: &'a mut Wallet,
pub(crate) params: TxParams,
pub(crate) coin_selection: Cs,
}
#[derive(Default, Debug, Clone)]
pub(crate) struct TxParams {
pub(crate) recipients: Vec<(ScriptBuf, Amount)>,
pub(crate) drain_wallet: bool,
pub(crate) drain_to: Option<ScriptBuf>,
pub(crate) fee_policy: Option<FeePolicy>,
pub(crate) internal_policy_path: Option<BTreeMap<String, Vec<usize>>>,
pub(crate) external_policy_path: Option<BTreeMap<String, Vec<usize>>>,
pub(crate) utxos: Vec<WeightedUtxo>,
pub(crate) unspendable: HashSet<OutPoint>,
pub(crate) manually_selected_only: bool,
pub(crate) sighash: Option<psbt::PsbtSighashType>,
pub(crate) ordering: TxOrdering,
pub(crate) locktime: Option<absolute::LockTime>,
pub(crate) sequence: Option<Sequence>,
pub(crate) version: Option<Version>,
pub(crate) change_policy: ChangeSpendPolicy,
pub(crate) only_witness_utxo: bool,
pub(crate) add_global_xpubs: bool,
pub(crate) bumping_fee: Option<PreviousFee>,
pub(crate) current_height: Option<absolute::LockTime>,
pub(crate) allow_dust: bool,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct PreviousFee {
pub absolute: Amount,
pub rate: FeeRate,
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum FeePolicy {
FeeRate(FeeRate),
FeeAmount(Amount),
}
impl Default for FeePolicy {
fn default() -> Self {
FeePolicy::FeeRate(FeeRate::BROADCAST_MIN)
}
}
impl<'a, Cs> TxBuilder<'a, Cs> {
pub fn fee_rate(&mut self, fee_rate: FeeRate) -> &mut Self {
self.params.fee_policy = Some(FeePolicy::FeeRate(fee_rate));
self
}
pub fn fee_absolute(&mut self, fee_amount: Amount) -> &mut Self {
self.params.fee_policy = Some(FeePolicy::FeeAmount(fee_amount));
self
}
pub fn policy_path(
&mut self,
policy_path: BTreeMap<String, Vec<usize>>,
keychain: KeychainKind,
) -> &mut Self {
let to_update = match keychain {
KeychainKind::Internal => &mut self.params.internal_policy_path,
KeychainKind::External => &mut self.params.external_policy_path,
};
*to_update = Some(policy_path);
self
}
pub fn add_utxos(&mut self, outpoints: &[OutPoint]) -> Result<&mut Self, AddUtxoError> {
let unspent: HashMap<OutPoint, LocalOutput> = self
.wallet
.list_unspent()
.map(|output| (output.outpoint, output))
.collect();
let mut visited = <HashSet<OutPoint>>::new();
let utxos: Vec<WeightedUtxo> = outpoints
.iter()
.filter(|&&op| visited.insert(op))
.map(|&op| -> Result<_, AddUtxoError> {
let output = unspent
.get(&op)
.cloned()
.ok_or(AddUtxoError::UnknownUtxo(op))?;
Ok(WeightedUtxo {
satisfaction_weight: self
.wallet
.public_descriptor(output.keychain)
.max_weight_to_satisfy()
.expect("descriptor should be satisfiable"),
utxo: Utxo::Local(output),
})
})
.collect::<Result<_, _>>()?;
self.params
.utxos
.retain(|wutxo| !visited.contains(&wutxo.utxo.outpoint()));
self.params.utxos.extend_from_slice(&utxos);
Ok(self)
}
pub fn add_utxo(&mut self, outpoint: OutPoint) -> Result<&mut Self, AddUtxoError> {
self.add_utxos(&[outpoint])
}
pub fn add_foreign_utxo(
&mut self,
outpoint: OutPoint,
psbt_input: psbt::Input,
satisfaction_weight: Weight,
) -> Result<&mut Self, AddForeignUtxoError> {
self.add_foreign_utxo_with_sequence(
outpoint,
psbt_input,
satisfaction_weight,
Sequence::MAX,
)
}
pub fn add_foreign_utxo_with_sequence(
&mut self,
outpoint: OutPoint,
psbt_input: psbt::Input,
satisfaction_weight: Weight,
sequence: Sequence,
) -> Result<&mut Self, AddForeignUtxoError> {
if psbt_input.witness_utxo.is_none() {
match psbt_input.non_witness_utxo.as_ref() {
Some(tx) => {
if tx.compute_txid() != outpoint.txid {
return Err(AddForeignUtxoError::InvalidTxid {
input_txid: tx.compute_txid(),
foreign_utxo: outpoint,
});
}
if tx.output.len() <= outpoint.vout as usize {
return Err(AddForeignUtxoError::InvalidOutpoint(outpoint));
}
}
None => {
return Err(AddForeignUtxoError::MissingUtxo);
}
}
}
let mut existing_index: Option<usize> = None;
for (idx, wutxo) in self.params.utxos.iter().enumerate() {
if wutxo.utxo.outpoint() == outpoint {
match wutxo.utxo {
Utxo::Local(..) => return Ok(self),
Utxo::Foreign { .. } => {
existing_index = Some(idx);
break;
}
}
}
}
if let Some(idx) = existing_index {
self.params.utxos.remove(idx);
}
self.params.utxos.push(WeightedUtxo {
satisfaction_weight,
utxo: Utxo::Foreign {
outpoint,
sequence,
psbt_input: Box::new(psbt_input),
},
});
Ok(self)
}
pub fn manually_selected_only(&mut self) -> &mut Self {
self.params.manually_selected_only = true;
self
}
pub fn unspendable(&mut self, unspendable: Vec<OutPoint>) -> &mut Self {
self.params.unspendable = unspendable.into_iter().collect();
self
}
pub fn add_unspendable(&mut self, unspendable: OutPoint) -> &mut Self {
self.params.unspendable.insert(unspendable);
self
}
pub fn exclude_below_confirmations(&mut self, min_confirms: u32) -> &mut Self {
let tip_height = self.wallet.latest_checkpoint().height();
let to_exclude = self
.wallet
.list_unspent()
.filter(|utxo| {
utxo.chain_position
.confirmation_height_upper_bound()
.map_or(0, |h| tip_height.saturating_add(1).saturating_sub(h))
< min_confirms
})
.map(|utxo| utxo.outpoint);
for op in to_exclude {
self.params.unspendable.insert(op);
}
self
}
pub fn exclude_unconfirmed(&mut self) -> &mut Self {
self.exclude_below_confirmations(1)
}
pub fn sighash(&mut self, sighash: psbt::PsbtSighashType) -> &mut Self {
self.params.sighash = Some(sighash);
self
}
pub fn ordering(&mut self, ordering: TxOrdering) -> &mut Self {
self.params.ordering = ordering;
self
}
pub fn nlocktime(&mut self, locktime: absolute::LockTime) -> &mut Self {
self.params.locktime = Some(locktime);
self
}
pub fn version(&mut self, version: i32) -> &mut Self {
self.params.version = Some(Version(version));
self
}
pub fn do_not_spend_change(&mut self) -> &mut Self {
self.params.change_policy = ChangeSpendPolicy::ChangeForbidden;
self
}
pub fn only_spend_change(&mut self) -> &mut Self {
self.params.change_policy = ChangeSpendPolicy::OnlyChange;
self
}
pub fn change_policy(&mut self, change_policy: ChangeSpendPolicy) -> &mut Self {
self.params.change_policy = change_policy;
self
}
pub fn only_witness_utxo(&mut self) -> &mut Self {
self.params.only_witness_utxo = true;
self
}
pub fn add_global_xpubs(&mut self) -> &mut Self {
self.params.add_global_xpubs = true;
self
}
pub fn drain_wallet(&mut self) -> &mut Self {
self.params.drain_wallet = true;
self
}
pub fn coin_selection<P: CoinSelectionAlgorithm>(self, coin_selection: P) -> TxBuilder<'a, P> {
TxBuilder {
wallet: self.wallet,
params: self.params,
coin_selection,
}
}
pub fn set_exact_sequence(&mut self, n_sequence: Sequence) -> &mut Self {
self.params.sequence = Some(n_sequence);
self
}
pub fn current_height(&mut self, height: u32) -> &mut Self {
self.params.current_height =
Some(absolute::LockTime::from_height(height).expect("Invalid height"));
self
}
pub fn allow_dust(&mut self, allow_dust: bool) -> &mut Self {
self.params.allow_dust = allow_dust;
self
}
pub fn set_recipients(&mut self, recipients: Vec<(ScriptBuf, Amount)>) -> &mut Self {
self.params.recipients = recipients;
self
}
pub fn add_recipient(
&mut self,
script_pubkey: impl Into<ScriptBuf>,
amount: Amount,
) -> &mut Self {
self.params.recipients.push((script_pubkey.into(), amount));
self
}
pub fn add_data<T: AsRef<PushBytes>>(&mut self, data: &T) -> &mut Self {
let script = ScriptBuf::new_op_return(data);
self.add_recipient(script, Amount::ZERO);
self
}
pub fn drain_to(&mut self, script_pubkey: ScriptBuf) -> &mut Self {
self.params.drain_to = Some(script_pubkey);
self
}
}
impl<Cs: CoinSelectionAlgorithm> TxBuilder<'_, Cs> {
#[cfg(feature = "std")]
pub fn finish(self) -> Result<Psbt, CreateTxError> {
self.finish_with_aux_rand(&mut bitcoin::key::rand::thread_rng())
}
pub fn finish_with_aux_rand(self, rng: &mut impl RngCore) -> Result<Psbt, CreateTxError> {
self.wallet.create_tx(self.coin_selection, self.params, rng)
}
}
#[derive(Debug)]
pub enum AddUtxoError {
UnknownUtxo(OutPoint),
}
impl fmt::Display for AddUtxoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnknownUtxo(outpoint) => write!(
f,
"UTXO not found in the internal database for txid: {} with vout: {}",
outpoint.txid, outpoint.vout
),
}
}
}
impl core::error::Error for AddUtxoError {}
#[derive(Debug)]
pub enum AddForeignUtxoError {
InvalidTxid {
input_txid: Txid,
foreign_utxo: OutPoint,
},
InvalidOutpoint(OutPoint),
MissingUtxo,
}
impl fmt::Display for AddForeignUtxoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidTxid {
input_txid,
foreign_utxo,
} => write!(
f,
"Foreign UTXO outpoint txid: {} does not match PSBT input txid: {}",
foreign_utxo.txid, input_txid,
),
Self::InvalidOutpoint(outpoint) => write!(
f,
"Requested outpoint doesn't exist for txid: {} with vout: {}",
outpoint.txid, outpoint.vout,
),
Self::MissingUtxo => write!(f, "Foreign utxo missing witness_utxo or non_witness_utxo"),
}
}
}
impl core::error::Error for AddForeignUtxoError {}
type TxSort<T> = dyn (Fn(&T, &T) -> core::cmp::Ordering) + Send + Sync;
#[derive(Clone, Default)]
pub enum TxOrdering {
#[default]
Shuffle,
Untouched,
Custom {
input_sort: Arc<TxSort<TxIn>>,
output_sort: Arc<TxSort<TxOut>>,
},
}
impl core::fmt::Debug for TxOrdering {
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
match self {
TxOrdering::Shuffle => write!(f, "Shuffle"),
TxOrdering::Untouched => write!(f, "Untouched"),
TxOrdering::Custom { .. } => write!(f, "Custom"),
}
}
}
impl TxOrdering {
#[cfg(feature = "std")]
pub fn sort_tx(&self, tx: &mut Transaction) {
self.sort_tx_with_aux_rand(tx, &mut bitcoin::key::rand::thread_rng())
}
pub fn sort_tx_with_aux_rand(&self, tx: &mut Transaction, rng: &mut impl RngCore) {
match self {
TxOrdering::Untouched => {}
TxOrdering::Shuffle => {
shuffle_slice(&mut tx.input, rng);
shuffle_slice(&mut tx.output, rng);
}
TxOrdering::Custom {
input_sort,
output_sort,
} => {
tx.input.sort_unstable_by(|a, b| input_sort(a, b));
tx.output.sort_unstable_by(|a, b| output_sort(a, b));
}
}
}
}
#[derive(Default, Debug, Ord, PartialOrd, Eq, PartialEq, Hash, Clone, Copy)]
pub enum ChangeSpendPolicy {
#[default]
ChangeAllowed,
OnlyChange,
ChangeForbidden,
}
impl ChangeSpendPolicy {
pub(crate) fn is_satisfied_by(&self, utxo: &LocalOutput) -> bool {
match self {
ChangeSpendPolicy::ChangeAllowed => true,
ChangeSpendPolicy::OnlyChange => utxo.keychain == KeychainKind::Internal,
ChangeSpendPolicy::ChangeForbidden => utxo.keychain == KeychainKind::External,
}
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod test {
const ORDERING_TEST_TX: &str = "0200000003c26f3eb7932f7acddc5ddd26602b77e7516079b03090a16e2c2f54\
85d1fd600f0100000000ffffffffc26f3eb7932f7acddc5ddd26602b77e75160\
79b03090a16e2c2f5485d1fd600f0000000000ffffffff571fb3e02278217852\
dd5d299947e2b7354a639adc32ec1fa7b82cfb5dec530e0500000000ffffffff\
03e80300000000000002aaeee80300000000000001aa200300000000000001ff\
00000000";
macro_rules! ordering_test_tx {
() => {
deserialize::<bitcoin::Transaction>(&Vec::<u8>::from_hex(ORDERING_TEST_TX).unwrap())
.unwrap()
};
}
use bitcoin::consensus::deserialize;
use bitcoin::hex::FromHex;
use bitcoin::TxOut;
use super::*;
#[test]
fn test_output_ordering_untouched() {
let original_tx = ordering_test_tx!();
let mut tx = original_tx.clone();
TxOrdering::Untouched.sort_tx(&mut tx);
assert_eq!(original_tx, tx);
}
#[test]
fn test_output_ordering_shuffle() {
let original_tx = ordering_test_tx!();
let mut tx = original_tx.clone();
(0..40)
.find(|_| {
TxOrdering::Shuffle.sort_tx(&mut tx);
original_tx.input != tx.input
})
.expect("it should have moved the inputs at least once");
let mut tx = original_tx.clone();
(0..40)
.find(|_| {
TxOrdering::Shuffle.sort_tx(&mut tx);
original_tx.output != tx.output
})
.expect("it should have moved the outputs at least once");
}
#[test]
fn test_output_ordering_custom_but_bip69() {
use core::str::FromStr;
let original_tx = ordering_test_tx!();
let mut tx = original_tx;
let bip69_txin_cmp = |tx_a: &TxIn, tx_b: &TxIn| {
let project_outpoint = |t: &TxIn| (t.previous_output.txid, t.previous_output.vout);
project_outpoint(tx_a).cmp(&project_outpoint(tx_b))
};
let bip69_txout_cmp = |tx_a: &TxOut, tx_b: &TxOut| {
let project_utxo = |t: &TxOut| (t.value, t.script_pubkey.clone());
project_utxo(tx_a).cmp(&project_utxo(tx_b))
};
let custom_bip69_ordering = TxOrdering::Custom {
input_sort: Arc::new(bip69_txin_cmp),
output_sort: Arc::new(bip69_txout_cmp),
};
custom_bip69_ordering.sort_tx(&mut tx);
assert_eq!(
tx.input[0].previous_output,
bitcoin::OutPoint::from_str(
"0e53ec5dfb2cb8a71fec32dc9a634a35b7e24799295ddd5278217822e0b31f57:5"
)
.unwrap()
);
assert_eq!(
tx.input[1].previous_output,
bitcoin::OutPoint::from_str(
"0f60fdd185542f2c6ea19030b0796051e7772b6026dd5ddccd7a2f93b73e6fc2:0"
)
.unwrap()
);
assert_eq!(
tx.input[2].previous_output,
bitcoin::OutPoint::from_str(
"0f60fdd185542f2c6ea19030b0796051e7772b6026dd5ddccd7a2f93b73e6fc2:1"
)
.unwrap()
);
assert_eq!(tx.output[0].value.to_sat(), 800);
assert_eq!(tx.output[1].script_pubkey, ScriptBuf::from(vec![0xAA]));
assert_eq!(
tx.output[2].script_pubkey,
ScriptBuf::from(vec![0xAA, 0xEE])
);
}
#[test]
fn test_output_ordering_custom_with_sha256() {
use bitcoin::hashes::{sha256, Hash};
let original_tx = ordering_test_tx!();
let mut tx_1 = original_tx.clone();
let mut tx_2 = original_tx.clone();
let shared_secret = "secret_tweak";
let hash_txin_with_shared_secret_seed = Arc::new(|tx_a: &TxIn, tx_b: &TxIn| {
let secret_digest_from_txin = |txin: &TxIn| {
sha256::Hash::hash(
&[
&txin.previous_output.txid.to_raw_hash()[..],
&txin.previous_output.vout.to_be_bytes(),
shared_secret.as_bytes(),
]
.concat(),
)
};
secret_digest_from_txin(tx_a).cmp(&secret_digest_from_txin(tx_b))
});
let hash_txout_with_shared_secret_seed = Arc::new(|tx_a: &TxOut, tx_b: &TxOut| {
let secret_digest_from_txout = |txin: &TxOut| {
sha256::Hash::hash(
&[
&txin.value.to_sat().to_be_bytes(),
&txin.script_pubkey.clone().into_bytes()[..],
shared_secret.as_bytes(),
]
.concat(),
)
};
secret_digest_from_txout(tx_a).cmp(&secret_digest_from_txout(tx_b))
});
let custom_ordering_from_salted_sha256_1 = TxOrdering::Custom {
input_sort: hash_txin_with_shared_secret_seed.clone(),
output_sort: hash_txout_with_shared_secret_seed.clone(),
};
let custom_ordering_from_salted_sha256_2 = TxOrdering::Custom {
input_sort: hash_txin_with_shared_secret_seed,
output_sort: hash_txout_with_shared_secret_seed,
};
custom_ordering_from_salted_sha256_1.sort_tx(&mut tx_1);
custom_ordering_from_salted_sha256_2.sort_tx(&mut tx_2);
assert_eq!(tx_1, tx_2);
assert_ne!(tx_1, original_tx);
assert_ne!(tx_2, original_tx);
}
fn get_test_utxos() -> Vec<LocalOutput> {
use bitcoin::hashes::Hash;
vec![
LocalOutput {
outpoint: OutPoint {
txid: bitcoin::Txid::from_slice(&[0; 32]).unwrap(),
vout: 0,
},
txout: TxOut::NULL,
keychain: KeychainKind::External,
is_spent: false,
chain_position: chain::ChainPosition::Unconfirmed {
first_seen: Some(1),
last_seen: Some(1),
},
derivation_index: 0,
},
LocalOutput {
outpoint: OutPoint {
txid: bitcoin::Txid::from_slice(&[0; 32]).unwrap(),
vout: 1,
},
txout: TxOut::NULL,
keychain: KeychainKind::Internal,
is_spent: false,
chain_position: chain::ChainPosition::Confirmed {
anchor: chain::ConfirmationBlockTime {
block_id: chain::BlockId {
height: 32,
hash: bitcoin::BlockHash::all_zeros(),
},
confirmation_time: 42,
},
transitively: None,
},
derivation_index: 1,
},
]
}
#[test]
fn test_change_spend_policy_default() {
let change_spend_policy = ChangeSpendPolicy::default();
let filtered = get_test_utxos()
.into_iter()
.filter(|u| change_spend_policy.is_satisfied_by(u))
.count();
assert_eq!(filtered, 2);
}
#[test]
fn test_change_spend_policy_no_internal() {
let change_spend_policy = ChangeSpendPolicy::ChangeForbidden;
let filtered = get_test_utxos()
.into_iter()
.filter(|u| change_spend_policy.is_satisfied_by(u))
.collect::<Vec<_>>();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].keychain, KeychainKind::External);
}
#[test]
fn test_change_spend_policy_only_internal() {
let change_spend_policy = ChangeSpendPolicy::OnlyChange;
let filtered = get_test_utxos()
.into_iter()
.filter(|u| change_spend_policy.is_satisfied_by(u))
.collect::<Vec<_>>();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].keychain, KeychainKind::Internal);
}
#[test]
fn test_exclude_unconfirmed() {
use crate::test_utils::*;
use bdk_chain::BlockId;
use bitcoin::{hashes::Hash, BlockHash, Network};
let mut wallet = Wallet::create_single(get_test_tr_single_sig())
.network(Network::Regtest)
.create_wallet_no_persist()
.unwrap();
let recipient = wallet.next_unused_address(KeychainKind::External).address;
insert_checkpoint(
&mut wallet,
BlockId {
height: 1,
hash: BlockHash::all_zeros(),
},
);
insert_checkpoint(
&mut wallet,
BlockId {
height: 2,
hash: BlockHash::all_zeros(),
},
);
receive_output(
&mut wallet,
Amount::ONE_BTC,
ReceiveTo::Block(chain::ConfirmationBlockTime {
block_id: BlockId {
height: 1,
hash: BlockHash::all_zeros(),
},
confirmation_time: 1,
}),
);
receive_output(
&mut wallet,
Amount::ONE_BTC * 2,
ReceiveTo::Block(chain::ConfirmationBlockTime {
block_id: BlockId {
height: 2,
hash: BlockHash::all_zeros(),
},
confirmation_time: 2,
}),
);
receive_output(&mut wallet, Amount::ONE_BTC * 3, ReceiveTo::Mempool(100));
{
let mut builder = wallet.build_tx();
builder
.fee_rate(FeeRate::ZERO)
.exclude_below_confirmations(0)
.drain_wallet()
.drain_to(recipient.script_pubkey());
let tx = builder.finish().unwrap();
let output = tx.unsigned_tx.output.first().expect("must have one output");
assert_eq!(output.value, Amount::ONE_BTC * 6);
}
{
let mut builder = wallet.build_tx();
builder
.fee_rate(FeeRate::ZERO)
.exclude_below_confirmations(1)
.drain_wallet()
.drain_to(recipient.script_pubkey());
let tx = builder.finish().unwrap();
let output = tx.unsigned_tx.output.first().expect("must have one output");
assert_eq!(output.value, Amount::ONE_BTC * 3);
}
{
let mut builder = wallet.build_tx();
builder
.fee_rate(FeeRate::ZERO)
.exclude_below_confirmations(2)
.drain_wallet()
.drain_to(recipient.script_pubkey());
let tx = builder.finish().unwrap();
let output = tx.unsigned_tx.output.first().expect("must have one output");
assert_eq!(output.value, Amount::ONE_BTC);
}
}
#[test]
fn test_build_fee_bump_remove_change_output_single_desc() {
use crate::test_utils::*;
use bdk_chain::BlockId;
use bitcoin::{hashes::Hash, BlockHash, Network};
let mut wallet = Wallet::create_single(get_test_tr_single_sig())
.network(Network::Regtest)
.create_wallet_no_persist()
.unwrap();
insert_checkpoint(
&mut wallet,
BlockId {
height: 1,
hash: BlockHash::all_zeros(),
},
);
receive_output_in_latest_block(&mut wallet, Amount::ONE_BTC);
let recip = ScriptBuf::from_hex(
"5120e8f5c4dc2f5d6a7595e7b108cb063da9c7550312da1e22875d78b9db62b59cd5",
)
.unwrap();
let mut builder = wallet.build_tx();
builder.add_recipient(recip.clone(), Amount::from_sat(15_000));
builder.fee_absolute(Amount::from_sat(1_000));
let psbt = builder.finish().unwrap();
let tx = psbt.extract_tx().unwrap();
let txid = tx.compute_txid();
let feerate = wallet.calculate_fee_rate(&tx).unwrap().to_sat_per_kwu();
insert_tx(&mut wallet, tx);
let mut builder = wallet.build_fee_bump(txid).unwrap();
assert_eq!(
builder.params.recipients,
vec![(recip, Amount::from_sat(15_000))]
);
builder.fee_rate(FeeRate::from_sat_per_kwu(feerate + 250));
let _ = builder.finish().unwrap();
}
#[test]
fn duplicated_utxos_in_add_utxos_are_only_added_once() {
use crate::test_utils::get_funded_wallet_wpkh;
let (mut wallet, _) = get_funded_wallet_wpkh();
let utxo = wallet.list_unspent().next().unwrap();
let op = utxo.outpoint;
let mut tx_builder = wallet.build_tx();
tx_builder.add_utxos(&[op, op, op]).unwrap();
assert_eq!(tx_builder.params.utxos.len(), 1);
}
#[test]
fn not_duplicated_utxos_in_required_list() {
use crate::test_utils::get_funded_wallet_wpkh;
let (mut wallet1, _) = get_funded_wallet_wpkh();
let utxo1 @ LocalOutput { outpoint, .. } = wallet1.list_unspent().next().unwrap();
let mut builder = wallet1.build_tx();
let fake_weighted_utxo = WeightedUtxo {
satisfaction_weight: Weight::from_wu(107),
utxo: Utxo::Local(utxo1.clone()),
};
for _ in 0..3 {
builder.add_utxo(outpoint).expect("should add");
}
assert_eq!(vec![fake_weighted_utxo], builder.params.utxos);
}
#[test]
fn not_duplicated_foreign_utxos_with_same_outpoint_but_different_weight() {
use crate::test_utils::{get_funded_wallet_single, get_funded_wallet_wpkh, get_test_wpkh};
let (wallet1, txid1) = get_funded_wallet_wpkh();
let (mut wallet2, txid2) = get_funded_wallet_single(get_test_wpkh());
assert_ne!(txid1, txid2);
let utxo1 = wallet1.list_unspent().next().unwrap();
let tx1 = wallet1.get_tx(txid1).unwrap().tx_node.tx.clone();
let satisfaction_weight = wallet1
.public_descriptor(KeychainKind::External)
.max_weight_to_satisfy()
.unwrap();
let mut builder = wallet2.build_tx();
assert!(builder
.add_foreign_utxo(
utxo1.outpoint,
psbt::Input {
non_witness_utxo: Some(tx1.as_ref().clone()),
..Default::default()
},
satisfaction_weight,
)
.is_ok());
let modified_satisfaction_weight = satisfaction_weight - Weight::from_wu(6);
assert_ne!(satisfaction_weight, modified_satisfaction_weight);
assert!(builder
.add_foreign_utxo(
utxo1.outpoint,
psbt::Input {
non_witness_utxo: Some(tx1.as_ref().clone()),
..Default::default()
},
modified_satisfaction_weight,
)
.is_ok());
assert_eq!(builder.params.utxos.len(), 1);
assert_eq!(
builder.params.utxos[0].satisfaction_weight,
modified_satisfaction_weight
);
}
#[test]
fn test_local_utxos_have_precedence_over_foreign_utxos() {
use crate::test_utils::get_funded_wallet_wpkh;
let (mut wallet, _) = get_funded_wallet_wpkh();
let utxo = wallet.list_unspent().next().unwrap();
let outpoint = utxo.outpoint;
let mut builder = wallet.build_tx();
builder.add_utxo(outpoint).unwrap();
assert_eq!(builder.params.utxos[0].utxo.outpoint(), outpoint);
builder
.add_foreign_utxo(
outpoint,
psbt::Input {
witness_utxo: Some(utxo.txout.clone()),
..Default::default()
},
Weight::from_wu(107),
)
.unwrap();
assert_eq!(builder.params.utxos.len(), 1);
assert!(matches!(
&builder.params.utxos[0].utxo,
Utxo::Local(output) if output.outpoint == outpoint,
));
builder.params = TxParams::default();
builder
.add_foreign_utxo(
outpoint,
psbt::Input {
witness_utxo: Some(utxo.txout),
..Default::default()
},
Weight::from_wu(107),
)
.unwrap();
assert_eq!(builder.params.utxos[0].utxo.outpoint(), outpoint);
builder.add_utxo(outpoint).unwrap();
assert_eq!(builder.params.utxos.len(), 1);
assert!(
matches!(&builder.params.utxos[0].utxo, Utxo::Local(output) if output.outpoint == outpoint)
);
}
}