Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
bdk_coin_select = { version = "0.4.1" }
miniscript = { version = "12.3.7", default-features = false }
rand_core = { version = "0.6.4", default-features = false }
rand = { version = "0.8", optional = true }

[dev-dependencies]
anyhow = "1"
Expand All @@ -27,6 +26,7 @@ bitcoin = { version = "0.32.10", default-features = false, features = ["rand-std
bdk_testenv = "0.13.0"
bdk_bitcoind_rpc = "0.22.0"
bdk_chain = { version = "0.23.3" }
rand = "0.8"

[features]
default = ["std"]
Expand Down
11 changes: 5 additions & 6 deletions examples/anti_fee_sniping.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#![allow(dead_code)]
use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv};
use bdk_tx::{
filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, Output, PsbtParams,
filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, BuildPsbtParams, Output,
SelectorParams,
};
use bitcoin::{absolute::LockTime, key::Secp256k1, Amount, FeeRate};
Expand Down Expand Up @@ -72,7 +72,7 @@ fn main() -> anyhow::Result<()> {
.all_candidates()
.regroup(group_by_spk())
.filter(filter_unspendable(tip_height, Some(tip_time)))
.into_selection(
.into_tx_template(
selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000),
SelectorParams {
// For waste optimization when deciding change.
Expand All @@ -90,10 +90,9 @@ fn main() -> anyhow::Result<()> {

let selection_inputs = selection.inputs().to_vec();

let psbt = selection.create_psbt(PsbtParams {
anti_fee_sniping: Some(tip_height),
..Default::default()
})?;
let (psbt, _) = selection
.apply_anti_fee_sniping(tip_height, &mut rand::thread_rng())?
.build_psbt(BuildPsbtParams::default())?;

let tx = psbt.unsigned_tx;

Expand Down
17 changes: 7 additions & 10 deletions examples/synopsis.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv};
use bdk_tx::{
filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, Output, PsbtParams,
filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, BuildPsbtParams, Output,
SelectorParams, Signer,
};
use bitcoin::{key::Secp256k1, Amount, FeeRate};
Expand Down Expand Up @@ -48,11 +48,11 @@ fn main() -> anyhow::Result<()> {
.assume_checked();

// Okay now create tx.
let selection = wallet
let (mut psbt, finalizer) = wallet
.all_candidates()
.regroup(group_by_spk())
.filter(filter_unspendable(tip_height, Some(tip_mtp)))
.into_selection(
.into_tx_template(
selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000),
SelectorParams {
// For waste-optimization when deciding change.
Expand All @@ -66,10 +66,8 @@ fn main() -> anyhow::Result<()> {
bdk_tx::ChangeScript::from_descriptor(internal.at_derivation_index(0)?),
)
},
)?;

let mut psbt = selection.create_psbt(PsbtParams::default())?;
let finalizer = selection.into_finalizer();
)?
.build_psbt(BuildPsbtParams::default())?;
Comment thread
evanlinjin marked this conversation as resolved.

let _ = psbt.sign(&signer, &secp);
let res = finalizer.finalize(&mut psbt);
Expand Down Expand Up @@ -122,7 +120,7 @@ fn main() -> anyhow::Result<()> {

let selection = rbf_candidates
// Do coin selection.
.into_selection(
.into_tx_template(
// Coin selection algorithm.
selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000),
SelectorParams {
Expand All @@ -146,7 +144,6 @@ fn main() -> anyhow::Result<()> {
},
)?;

let mut psbt = selection.create_psbt(PsbtParams::default())?;
println!(
"selected inputs: {:?}",
selection
Expand All @@ -156,7 +153,7 @@ fn main() -> anyhow::Result<()> {
.collect::<Vec<_>>()
);

let finalizer = selection.into_finalizer();
let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?;
psbt.sign(&signer, &secp).expect("failed to sign");
assert!(
finalizer.finalize(&mut psbt).is_finalized(),
Expand Down
79 changes: 42 additions & 37 deletions src/afs.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
use crate::{
no_std_rand::{random_probability, random_range},
Input,
TxTemplate,
};
use alloc::vec::Vec;
use miniscript::bitcoin::{
absolute::{self, LockTime},
transaction::Version,
Sequence, Transaction,
Sequence,
};
use rand_core::RngCore;

Expand All @@ -16,7 +16,7 @@ pub enum AntiFeeSnipingError {
/// Transaction `version` must be >= 2 for AFS to use relative locktimes.
UnsupportedVersion(Version),
/// AFS only supports height-based locktimes. The transaction's locktime is
/// time-based (MTP), which can originate from either `PsbtParams::min_locktime`
/// time-based (MTP), which can originate from either `TxTemplateParams::min_locktime`
/// or an input's time-based CLTV requirement.
UnsupportedLockTime(absolute::LockTime),
}
Expand Down Expand Up @@ -56,9 +56,11 @@ impl std::error::Error for AntiFeeSnipingError {}
/// Random offsets (0-99 blocks) are applied with 10% probability to avoid creating
/// a unique fingerprint that could identify transactions from this wallet.
///
/// Mutates `template` in place; sealing into a [`SealedTxTemplate`](crate::SealedTxTemplate)
/// is the caller's job (see [`TxTemplate::apply_anti_fee_sniping`](crate::TxTemplate::apply_anti_fee_sniping)).
///
/// # Parameters
/// - `tx`: The transaction to modify
/// - `inputs`: The inputs associated with the transaction
/// - `template`: The tx template to modify
/// - `tip_height`: The current blockchain height (used as the base for time locks)
/// - `rng`: Random number generator implementing `RngCore`
///
Expand All @@ -73,13 +75,12 @@ impl std::error::Error for AntiFeeSnipingError {}
/// # Errors
/// - [`AntiFeeSnipingError::UnsupportedVersion`] if `tx.version < 2`.
/// - [`AntiFeeSnipingError::UnsupportedLockTime`] if `tx.lock_time` is time-based
/// (either from `PsbtParams::min_locktime` or an input's time-based CLTV).
/// (either from `TxTemplateParams::min_locktime` or an input's time-based CLTV).
///
/// # See Also
/// [BIP326](https://github.com/bitcoin/bips/blob/master/bip-0326.mediawiki)
pub(crate) fn apply_anti_fee_sniping(
tx: &mut Transaction,
inputs: &[Input],
template: &mut TxTemplate,
tip_height: absolute::Height,
rng: &mut impl RngCore,
) -> Result<(), AntiFeeSnipingError> {
Expand All @@ -89,63 +90,63 @@ pub(crate) fn apply_anti_fee_sniping(
const TEN_PERCENT_PROBABILITY_RANGE: u32 = 10;
const MAX_RANDOM_OFFSET: u32 = 100;

if tx.version < Version::TWO {
return Err(AntiFeeSnipingError::UnsupportedVersion(tx.version));
if template.version() < Version::TWO {
return Err(AntiFeeSnipingError::UnsupportedVersion(template.version()));
}

if !tx.lock_time.is_block_height() {
return Err(AntiFeeSnipingError::UnsupportedLockTime(tx.lock_time));
if !template.lock_time().is_block_height() {
return Err(AntiFeeSnipingError::UnsupportedLockTime(
template.lock_time(),
));
}

let rbf_enabled = tx.is_explicitly_rbf();
// A tx signals RBF if at least one input has `nSequence < 0xfffffffe`.
let fallback = template.fallback_sequence();
let rbf_enabled = template
.inputs()
.iter()
.any(|input| input.sequence().unwrap_or(fallback).is_rbf());

// vector of input_index and associated Input ref.
let taproot_inputs: Vec<(usize, &Input)> = tx
.input
// Indices of taproot inputs without a relative timelock — candidates for the nSequence path.
let taproot_inputs: Vec<usize> = template
.inputs()
.iter()
.enumerate()
.filter_map(|(vin, txin)| {
let input = inputs
.iter()
.find(|input| input.prev_outpoint() == txin.previous_output)?;
if input.prev_txout().script_pubkey.is_p2tr() && input.relative_timelock().is_none() {
Some((vin, input))
} else {
None
}
.filter_map(|(i, input)| {
(input.prev_txout().script_pubkey.is_p2tr() && input.relative_timelock().is_none())
.then_some(i)
})
.collect();

// Conditions that force nLockTime (vs nSequence).
let must_use_locktime = taproot_inputs.is_empty()
|| inputs.iter().any(|input| {
|| template.inputs().iter().any(|input| {
let confirmation = input.confirmations(tip_height);
confirmation == 0 || confirmation > MAX_RELATIVE_HEIGHT
});
let use_locktime = !rbf_enabled
|| must_use_locktime
|| taproot_inputs.is_empty()
|| random_probability(rng, FIFTY_PERCENT_PROBABILITY_RANGE);

if use_locktime {
// Use nLockTime
let mut afs_height = tip_height.to_consensus_u32();

if random_probability(rng, TEN_PERCENT_PROBABILITY_RANGE) {
let random_offset = random_range(rng, MAX_RANDOM_OFFSET);
afs_height = afs_height.saturating_sub(random_offset);
}

let afs_locktime = LockTime::from_height(afs_height).expect("must be valid Height");

if tx.lock_time.is_implied_by(afs_locktime) {
tx.lock_time = afs_locktime;
// Only apply if it's a bump (i.e. doesn't regress an input's CLTV requirement).
if template.lock_time().is_implied_by(afs_locktime) {
template
.set_locktime_in_place(afs_locktime)
.expect("AFS picks a value ≥ current lock_time (same height-based unit)");
}
} else {
// Use Sequence
let random_index = random_range(rng, taproot_inputs.len() as u32);
let (input_index, input) = taproot_inputs[random_index as usize];
let confirmation = input.confirmations(tip_height);
let random_index = random_range(rng, taproot_inputs.len() as u32) as usize;
let input_index = taproot_inputs[random_index];
let outpoint = template.inputs()[input_index].prev_outpoint();
let confirmation = template.inputs()[input_index].confirmations(tip_height);

let mut sequence_value = confirmation;
if random_probability(rng, TEN_PERCENT_PROBABILITY_RANGE) {
Expand All @@ -155,7 +156,11 @@ pub(crate) fn apply_anti_fee_sniping(
.max(MIN_SEQUENCE_VALUE);
}

tx.input[input_index].sequence = Sequence(sequence_value);
template
.input_mut(outpoint)
.expect("taproot input index resolved above")
.set_sequence(Sequence(sequence_value))
.expect("AFS only picks inputs without timelock constraints");
}

Ok(())
Expand Down
75 changes: 75 additions & 0 deletions src/build_psbt.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//! Parameters and error type for [`TxTemplate::build_psbt`].
//!
//! The build logic itself lives as an inherent method on [`TxTemplate`]; only the standalone
//! parameter and error types are housed here to keep `tx_template.rs` focused on tx shaping.
//!
//! [`TxTemplate::build_psbt`]: crate::TxTemplate::build_psbt

use alloc::boxed::Box;
use core::fmt::Display;

use miniscript::bitcoin;

use crate::Input;

/// Parameters for emitting a [`Psbt`] from a [`TxTemplate`].
///
/// Carries only PSBT-specific options. Transaction-shape decisions (version, locktime,
/// sequence, anti-fee-sniping, input/output ordering) all live on [`TxTemplate`].
///
/// [`Psbt`]: bitcoin::Psbt
/// [`TxTemplate`]: crate::TxTemplate
#[derive(Debug, Clone)]
pub struct BuildPsbtParams {
/// Whether to require the full tx (aka [`non_witness_utxo`]) for segwit v0 inputs.
///
/// Default: `true`.
///
/// [`non_witness_utxo`]: bitcoin::psbt::Input::non_witness_utxo
pub mandate_full_tx_for_segwit_v0: bool,
}

impl Default for BuildPsbtParams {
fn default() -> Self {
Self {
mandate_full_tx_for_segwit_v0: true,
}
}
}

/// Error returned by [`TxTemplate::build_psbt`].
///
/// [`TxTemplate::build_psbt`]: crate::TxTemplate::build_psbt
#[derive(Debug)]
pub enum BuildPsbtError {
/// Missing tx for legacy input.
MissingFullTxForLegacyInput(Box<Input>),
/// Missing tx for segwit v0 input.
MissingFullTxForSegwitV0Input(Box<Input>),
/// Psbt error.
Psbt(bitcoin::psbt::Error),
/// Update psbt output with descriptor error.
OutputUpdate(miniscript::psbt::OutputUpdateError),
}

impl Display for BuildPsbtError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::MissingFullTxForLegacyInput(input) => write!(
f,
"legacy input that spends {} requires PSBT_IN_NON_WITNESS_UTXO",
input.prev_outpoint()
),
Self::MissingFullTxForSegwitV0Input(input) => write!(
f,
"segwit v0 input that spends {} requires PSBT_IN_NON_WITNESS_UTXO",
input.prev_outpoint()
),
Self::Psbt(e) => Display::fmt(e, f),
Self::OutputUpdate(e) => Display::fmt(e, f),
}
}
}

#[cfg(feature = "std")]
impl std::error::Error for BuildPsbtError {}
Loading