From 9041b2f4bbdb87ba02a4b1ae653907ad5f6bc1d4 Mon Sep 17 00:00:00 2001 From: aagbotemi Date: Tue, 6 May 2025 12:24:52 +0100 Subject: [PATCH 1/4] feat: enable random anti-fee sniping feat: bip326 anti-fee sniping made compatible with new current library fix(clippy): fixing clippy CI build error with Box due to large enum variant example to test anti fee snipping, extracted the height from tx checking expected range of values --- Cargo.toml | 6 +- examples/anti_fee_snipping.rs | 133 ++++++++++++++++++++++++++++++++++ src/input.rs | 11 +-- src/lib.rs | 2 + src/output.rs | 6 +- src/selection.rs | 59 ++++++++++++--- src/utils.rs | 98 +++++++++++++++++++++++++ 7 files changed, 295 insertions(+), 20 deletions(-) create mode 100644 examples/anti_fee_snipping.rs create mode 100644 src/utils.rs diff --git a/Cargo.toml b/Cargo.toml index bc5f5be..35682d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,11 +13,12 @@ readme = "README.md" [dependencies] miniscript = { version = "12", default-features = false } bdk_coin_select = "0.4.0" +rand_core = { version = "0.6.0", features = ["getrandom"] } [dev-dependencies] anyhow = "1" bdk_tx = { path = "." } -bitcoin = { version = "0.32", features = ["rand-std"] } +bitcoin = { version = "0.32", default-features = false } bdk_testenv = "0.13.0" bdk_bitcoind_rpc = "0.20.0" bdk_chain = { version = "0.23.0" } @@ -32,3 +33,6 @@ name = "synopsis" [[example]] name = "common" crate-type = ["lib"] + +[[example]] +name = "anti_fee_snipping" diff --git a/examples/anti_fee_snipping.rs b/examples/anti_fee_snipping.rs new file mode 100644 index 0000000..a23ca2b --- /dev/null +++ b/examples/anti_fee_snipping.rs @@ -0,0 +1,133 @@ +#![allow(dead_code)] +use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; +use bdk_tx::{ + filter_unspendable_now, group_by_spk, selection_algorithm_lowest_fee_bnb, Output, PsbtParams, + SelectorParams, +}; +use bitcoin::{absolute::LockTime, key::Secp256k1, Amount, FeeRate, Sequence}; +use miniscript::Descriptor; + +mod common; + +use common::Wallet; + +fn main() -> anyhow::Result<()> { + let secp = Secp256k1::new(); + let (external, _) = Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[0])?; + let (internal, _) = Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[1])?; + + let env = TestEnv::new()?; + let genesis_hash = env.genesis_hash()?; + env.mine_blocks(101, None)?; + + let mut wallet = Wallet::new(genesis_hash, external, internal.clone())?; + wallet.sync(&env)?; + + let addr = wallet.next_address().expect("must derive address"); + + let txid = env.send(&addr, Amount::ONE_BTC)?; + env.mine_blocks(1, None)?; + wallet.sync(&env)?; + println!("Received {}", txid); + println!("Balance (confirmed): {}", wallet.balance()); + + let txid = env.send(&addr, Amount::ONE_BTC)?; + wallet.sync(&env)?; + println!("Received {txid}"); + println!("Balance (pending): {}", wallet.balance()); + + let (tip_height, tip_time) = wallet.tip_info(env.rpc_client())?; + println!("Height: {}", tip_height); + let longterm_feerate = FeeRate::from_sat_per_vb_unchecked(1); + + let recipient_addr = env + .rpc_client() + .get_new_address(None, None)? + .assume_checked(); + + // Okay now create tx. + let selection = wallet + .all_candidates() + .regroup(group_by_spk()) + .filter(filter_unspendable_now(tip_height, tip_time)) + .into_selection( + selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000), + SelectorParams::new( + FeeRate::from_sat_per_vb_unchecked(10), + vec![Output::with_script( + recipient_addr.script_pubkey(), + Amount::from_sat(21_000_000), + )], + internal.at_derivation_index(0)?, + bdk_tx::ChangePolicyType::NoDustAndLeastWaste { longterm_feerate }, + ), + )?; + + // Convert the consensus‐height (u32) into an absolute::LockTime + let fallback_locktime: LockTime = LockTime::from_consensus(tip_height.to_consensus_u32()); + + let psbt = selection.create_psbt(PsbtParams { + enable_anti_fee_sniping: true, + fallback_locktime, + fallback_sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + ..Default::default() + })?; + + let tx = psbt.unsigned_tx; + + // Locktime is used, if rbf is disabled or any input requires locktime + // (e.g. non-taproot, unconfirmed, or >65535 confirmation) or there are + // no taproot inputs or the 50/50 coin flip chose locktime (USE_NLOCKTIME_PROBABILITY) + // Further-back randomness with 10% chance (FURTHER_BACK_PROBABILITY), + // will subtract a random 0–99 block offset to desynchronize from tip + // + // Sequence will use the opposite condition of locktime, and locktime will + // be set to zero. Further-back randomness: with 10% chance, will + // subtract a random 0–99 block offset (but at least 1). + // + // Whenever locktime is used, the sequence value will remain as it is. + + if tx.lock_time != LockTime::ZERO { + let height_val = tx.lock_time.to_consensus_u32(); + let min_expected = tip_height.to_consensus_u32().saturating_sub(99); + let max_expected = tip_height.to_consensus_u32(); + + assert!( + (min_expected..=max_expected).contains(&height_val), + "Value {} is out of range {}..={}", + height_val, + min_expected, + max_expected + ); + + if height_val >= min_expected && height_val <= max_expected { + println!("✓ Locktime is within expected range"); + } else { + println!("⚠ Locktime is outside expected range"); + } + } else { + for (i, inp) in tx.input.iter().enumerate() { + let sequence_value = inp.sequence.to_consensus_u32(); + + let min_expected = 1; + let max_expected = Sequence(0xFFFFFFFE).to_consensus_u32(); + let index = i + 1; + + if sequence_value >= min_expected && sequence_value <= max_expected { + println!( + "✓ Input #{}: sequence {} is within anti-fee sniping range", + index, sequence_value + ); + } else if sequence_value == 0xfffffffd || sequence_value == 0xfffffffe { + println!("✓ Input #{}: using standard RBF sequence", index); + } else { + println!( + "⚠ Input #{}: sequence {} outside typical ranges", + index, sequence_value + ); + } + } + } + + Ok(()) +} diff --git a/src/input.rs b/src/input.rs index 0e19e72..f0859ea 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1,3 +1,4 @@ +use alloc::boxed::Box; use alloc::sync::Arc; use alloc::vec::Vec; use core::fmt; @@ -34,9 +35,9 @@ impl TxStatus { #[derive(Debug, Clone)] enum PlanOrPsbtInput { - Plan(Plan), + Plan(Box), PsbtInput { - psbt_input: psbt::Input, + psbt_input: Box, sequence: Sequence, absolute_timelock: absolute::LockTime, satisfaction_weight: usize, @@ -57,7 +58,7 @@ impl PlanOrPsbtInput { return Err(FromPsbtInputError::UtxoCheck); } Ok(Self::PsbtInput { - psbt_input, + psbt_input: Box::new(psbt_input), sequence, absolute_timelock: absolute::LockTime::ZERO, satisfaction_weight, @@ -216,7 +217,7 @@ impl Input { prev_outpoint: OutPoint::new(tx.compute_txid(), output_index as _), prev_txout: tx.tx_out(output_index).cloned()?, prev_tx: Some(tx), - plan: PlanOrPsbtInput::Plan(plan), + plan: PlanOrPsbtInput::Plan(Box::new(plan)), status, is_coinbase, }) @@ -234,7 +235,7 @@ impl Input { prev_outpoint, prev_txout, prev_tx: None, - plan: PlanOrPsbtInput::Plan(plan), + plan: PlanOrPsbtInput::Plan(Box::new(plan)), status, is_coinbase, } diff --git a/src/lib.rs b/src/lib.rs index bcdf894..9938369 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ mod rbf; mod selection; mod selector; mod signer; +mod utils; pub use canonical_unspents::*; pub use finalizer::*; @@ -34,6 +35,7 @@ pub use rbf::*; pub use selection::*; pub use selector::*; pub use signer::*; +use utils::*; #[cfg(feature = "std")] pub(crate) mod collections { diff --git a/src/output.rs b/src/output.rs index 92e972a..b6da1bf 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,3 +1,5 @@ +use alloc::boxed::Box; + use bitcoin::{Amount, ScriptBuf, TxOut}; use miniscript::bitcoin; @@ -9,7 +11,7 @@ pub enum ScriptSource { /// bitcoin script Script(ScriptBuf), /// definite descriptor - Descriptor(DefiniteDescriptor), + Descriptor(Box), } impl From for ScriptSource { @@ -32,7 +34,7 @@ impl ScriptSource { /// From descriptor pub fn from_descriptor(descriptor: DefiniteDescriptor) -> Self { - Self::Descriptor(descriptor) + Self::Descriptor(Box::new(descriptor)) } /// To ScriptBuf diff --git a/src/selection.rs b/src/selection.rs index d0c8d1a..717c441 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -1,12 +1,15 @@ +use alloc::{boxed::Box, vec::Vec}; use core::fmt::{Debug, Display}; -use std::vec::Vec; use bdk_coin_select::FeeRate; -use bitcoin::{absolute, transaction, Sequence}; +use bitcoin::{ + absolute::{self, LockTime}, + transaction, Psbt, Sequence, +}; use miniscript::bitcoin; use miniscript::psbt::PsbtExt; -use crate::{Finalizer, Input, Output}; +use crate::{apply_anti_fee_sniping, Finalizer, Input, Output}; const FALLBACK_SEQUENCE: bitcoin::Sequence = bitcoin::Sequence::ENABLE_LOCKTIME_NO_RBF; @@ -44,6 +47,9 @@ pub struct PsbtParams { /// /// [`non_witness_utxo`]: bitcoin::psbt::Input::non_witness_utxo pub mandate_full_tx_for_segwit_v0: bool, + + /// Whether to use BIP326 anti-fee-sniping + pub enable_anti_fee_sniping: bool, } impl Default for PsbtParams { @@ -53,6 +59,7 @@ impl Default for PsbtParams { fallback_locktime: absolute::LockTime::ZERO, fallback_sequence: FALLBACK_SEQUENCE, mandate_full_tx_for_segwit_v0: true, + enable_anti_fee_sniping: false, } } } @@ -63,13 +70,19 @@ pub enum CreatePsbtError { /// Attempted to mix locktime types. LockTypeMismatch, /// Missing tx for legacy input. - MissingFullTxForLegacyInput(Input), + MissingFullTxForLegacyInput(Box), /// Missing tx for segwit v0 input. - MissingFullTxForSegwitV0Input(Input), + MissingFullTxForSegwitV0Input(Box), /// Psbt error. Psbt(bitcoin::psbt::Error), /// Update psbt output with descriptor error. OutputUpdate(miniscript::psbt::OutputUpdateError), + /// Invalid locktime + InvalidLockTime(absolute::LockTime), + /// Invalid height + InvalidHeight(u32), + /// Unsupported version for anti fee snipping + UnsupportedVersion(transaction::Version), } impl core::fmt::Display for CreatePsbtError { @@ -90,6 +103,15 @@ impl core::fmt::Display for CreatePsbtError { CreatePsbtError::OutputUpdate(output_update_error) => { Display::fmt(&output_update_error, f) } + CreatePsbtError::InvalidLockTime(locktime) => { + write!(f, "The locktime - {}, is invalid", locktime) + } + CreatePsbtError::InvalidHeight(height) => { + write!(f, "The height - {}, is invalid", height) + } + CreatePsbtError::UnsupportedVersion(version) => { + write!(f, "Unsupported version {}", version) + } } } } @@ -127,7 +149,7 @@ impl Selection { /// Create psbt. pub fn create_psbt(&self, params: PsbtParams) -> Result { - let mut psbt = bitcoin::Psbt::from_unsigned_tx(bitcoin::Transaction { + let mut tx = bitcoin::Transaction { version: params.version, lock_time: Self::_accumulate_max_locktime( self.inputs @@ -146,8 +168,21 @@ impl Selection { }) .collect(), output: self.outputs.iter().map(|output| output.txout()).collect(), - }) - .map_err(CreatePsbtError::Psbt)?; + }; + + if params.enable_anti_fee_sniping { + let rbf_enabled = tx.is_explicitly_rbf(); + let current_height = match tx.lock_time { + LockTime::Blocks(height) => height, + LockTime::Seconds(_) => { + return Err(CreatePsbtError::InvalidLockTime(tx.lock_time)); + } + }; + + apply_anti_fee_sniping(&mut tx, &self.inputs, current_height, rbf_enabled)?; + }; + + let mut psbt = Psbt::from_unsigned_tx(tx).map_err(CreatePsbtError::Psbt)?; for (plan_input, psbt_input) in self.inputs.iter().zip(psbt.inputs.iter_mut()) { if let Some(finalized_psbt_input) = plan_input.psbt_input() { @@ -167,16 +202,16 @@ impl Selection { psbt_input.non_witness_utxo = plan_input.prev_tx().cloned(); if psbt_input.non_witness_utxo.is_none() { if witness_version.is_none() { - return Err(CreatePsbtError::MissingFullTxForLegacyInput( + return Err(CreatePsbtError::MissingFullTxForLegacyInput(Box::new( plan_input.clone(), - )); + ))); } if params.mandate_full_tx_for_segwit_v0 && witness_version == Some(bitcoin::WitnessVersion::V0) { - return Err(CreatePsbtError::MissingFullTxForSegwitV0Input( + return Err(CreatePsbtError::MissingFullTxForSegwitV0Input(Box::new( plan_input.clone(), - )); + ))); } } continue; diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..dd88b02 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,98 @@ +use crate::{CreatePsbtError, Input}; +use alloc::vec::Vec; +use miniscript::bitcoin::{ + absolute::{self, LockTime}, + transaction::Version, + Sequence, Transaction, WitnessVersion, +}; + +use rand_core::{OsRng, RngCore}; + +/// Applies BIP326 anti‐fee‐sniping +pub fn apply_anti_fee_sniping( + tx: &mut Transaction, + inputs: &[Input], + current_height: absolute::Height, + rbf_enabled: bool, +) -> Result<(), CreatePsbtError> { + const MAX_SEQUENCE_VALUE: u32 = 65_535; + const USE_NLOCKTIME_PROBABILITY: u32 = 2; + const MIN_SEQUENCE_VALUE: u32 = 1; + const FURTHER_BACK_PROBABILITY: u32 = 10; + const MAX_RANDOM_OFFSET: u32 = 99; + + let mut rng = OsRng; + + if tx.version < Version::TWO { + return Err(CreatePsbtError::UnsupportedVersion(tx.version)); + } + + let taproot_inputs: Vec = (0..tx.input.len()) + .filter(|&idx| { + // Check if this input is taproot using the corresponding Input data + inputs + .get(idx) + .and_then(|input| input.plan()) + .and_then(|plan| plan.witness_version()) + .map(|version| version == WitnessVersion::V1) + .unwrap_or(false) + }) + .collect(); + + // Check always‐locktime conditions + let must_use_locktime = inputs.iter().any(|input| { + let confirmation = input.confirmations(current_height); + confirmation == 0 + || confirmation > MAX_SEQUENCE_VALUE + || !matches!( + input.plan().and_then(|plan| plan.witness_version()), + Some(WitnessVersion::V1) + ) + }); + + let use_locktime = !rbf_enabled + || must_use_locktime + || taproot_inputs.is_empty() + || random_probability(&mut rng, USE_NLOCKTIME_PROBABILITY); + + if use_locktime { + // Use nLockTime + let mut locktime = current_height.to_consensus_u32(); + + if random_probability(&mut rng, FURTHER_BACK_PROBABILITY) { + let random_offset = random_range(&mut rng, MAX_RANDOM_OFFSET); + locktime = locktime.saturating_sub(random_offset); + } + + let new_locktime = LockTime::from_height(locktime) + .map_err(|_| CreatePsbtError::InvalidHeight(locktime))?; + + tx.lock_time = new_locktime; + } else { + // Use Sequence + tx.lock_time = LockTime::ZERO; + let input_index = random_range(&mut rng, taproot_inputs.len() as u32) as usize; + let confirmation = inputs[input_index].confirmations(current_height); + + let mut sequence_value = confirmation; + if random_probability(&mut rng, FURTHER_BACK_PROBABILITY) { + let random_offset = random_range(&mut rng, MAX_RANDOM_OFFSET); + sequence_value = sequence_value + .saturating_sub(random_offset) + .max(MIN_SEQUENCE_VALUE); + } + + tx.input[input_index].sequence = Sequence(sequence_value); + } + + Ok(()) +} + +fn random_probability(rng: &mut OsRng, probability: u32) -> bool { + let rand_val = rng.next_u32(); + rand_val % probability == 0 +} + +fn random_range(rng: &mut OsRng, max: u32) -> u32 { + rng.next_u32() % max +} From 47e4e173c8d51906375c8dbc9af83f6e4617a9c9 Mon Sep 17 00:00:00 2001 From: aagbotemi Date: Mon, 2 Jun 2025 21:54:03 +0100 Subject: [PATCH 2/4] refactor: address code review feedback - Rename MAX_SEQUENCE_VALUE to MAX_RELATIVE_HEIGHT for clarity - Use Script::is_p2tr() for more robust taproot input detection - Replace fallible LockTime::from_height with expect for valid Height - Remove unnecessary clippy allow attributes in lib.rs - Remove Box usage from error and enums - Rename anti_fee_snipping example to anti_fee_sniping --- Cargo.toml | 2 +- ...ti_fee_snipping.rs => anti_fee_sniping.rs} | 0 src/input.rs | 11 +++-- src/lib.rs | 4 -- src/output.rs | 6 +-- src/selection.rs | 38 +++++++--------- src/utils.rs | 44 ++++++++++--------- 7 files changed, 48 insertions(+), 57 deletions(-) rename examples/{anti_fee_snipping.rs => anti_fee_sniping.rs} (100%) diff --git a/Cargo.toml b/Cargo.toml index 35682d6..297417f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,4 +35,4 @@ name = "common" crate-type = ["lib"] [[example]] -name = "anti_fee_snipping" +name = "anti_fee_sniping" diff --git a/examples/anti_fee_snipping.rs b/examples/anti_fee_sniping.rs similarity index 100% rename from examples/anti_fee_snipping.rs rename to examples/anti_fee_sniping.rs diff --git a/src/input.rs b/src/input.rs index f0859ea..0e19e72 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1,4 +1,3 @@ -use alloc::boxed::Box; use alloc::sync::Arc; use alloc::vec::Vec; use core::fmt; @@ -35,9 +34,9 @@ impl TxStatus { #[derive(Debug, Clone)] enum PlanOrPsbtInput { - Plan(Box), + Plan(Plan), PsbtInput { - psbt_input: Box, + psbt_input: psbt::Input, sequence: Sequence, absolute_timelock: absolute::LockTime, satisfaction_weight: usize, @@ -58,7 +57,7 @@ impl PlanOrPsbtInput { return Err(FromPsbtInputError::UtxoCheck); } Ok(Self::PsbtInput { - psbt_input: Box::new(psbt_input), + psbt_input, sequence, absolute_timelock: absolute::LockTime::ZERO, satisfaction_weight, @@ -217,7 +216,7 @@ impl Input { prev_outpoint: OutPoint::new(tx.compute_txid(), output_index as _), prev_txout: tx.tx_out(output_index).cloned()?, prev_tx: Some(tx), - plan: PlanOrPsbtInput::Plan(Box::new(plan)), + plan: PlanOrPsbtInput::Plan(plan), status, is_coinbase, }) @@ -235,7 +234,7 @@ impl Input { prev_outpoint, prev_txout, prev_tx: None, - plan: PlanOrPsbtInput::Plan(Box::new(plan)), + plan: PlanOrPsbtInput::Plan(plan), status, is_coinbase, } diff --git a/src/lib.rs b/src/lib.rs index 9938369..ebfa713 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,4 @@ //! `bdk_tx` - -// FIXME: try to remove clippy "allows" -#![allow(clippy::large_enum_variant)] -#![allow(clippy::result_large_err)] #![warn(missing_docs)] #![no_std] diff --git a/src/output.rs b/src/output.rs index b6da1bf..92e972a 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,5 +1,3 @@ -use alloc::boxed::Box; - use bitcoin::{Amount, ScriptBuf, TxOut}; use miniscript::bitcoin; @@ -11,7 +9,7 @@ pub enum ScriptSource { /// bitcoin script Script(ScriptBuf), /// definite descriptor - Descriptor(Box), + Descriptor(DefiniteDescriptor), } impl From for ScriptSource { @@ -34,7 +32,7 @@ impl ScriptSource { /// From descriptor pub fn from_descriptor(descriptor: DefiniteDescriptor) -> Self { - Self::Descriptor(Box::new(descriptor)) + Self::Descriptor(descriptor) } /// To ScriptBuf diff --git a/src/selection.rs b/src/selection.rs index 717c441..896ba79 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -1,12 +1,13 @@ -use alloc::{boxed::Box, vec::Vec}; +use alloc::vec::Vec; use core::fmt::{Debug, Display}; use bdk_coin_select::FeeRate; -use bitcoin::{ +use miniscript::bitcoin; +use miniscript::bitcoin::{ absolute::{self, LockTime}, - transaction, Psbt, Sequence, + transaction, OutPoint, Psbt, Sequence, }; -use miniscript::bitcoin; + use miniscript::psbt::PsbtExt; use crate::{apply_anti_fee_sniping, Finalizer, Input, Output}; @@ -70,17 +71,15 @@ pub enum CreatePsbtError { /// Attempted to mix locktime types. LockTypeMismatch, /// Missing tx for legacy input. - MissingFullTxForLegacyInput(Box), + MissingFullTxForLegacyInput(OutPoint), /// Missing tx for segwit v0 input. - MissingFullTxForSegwitV0Input(Box), + MissingFullTxForSegwitV0Input(OutPoint), /// Psbt error. Psbt(bitcoin::psbt::Error), /// Update psbt output with descriptor error. OutputUpdate(miniscript::psbt::OutputUpdateError), /// Invalid locktime InvalidLockTime(absolute::LockTime), - /// Invalid height - InvalidHeight(u32), /// Unsupported version for anti fee snipping UnsupportedVersion(transaction::Version), } @@ -89,15 +88,15 @@ impl core::fmt::Display for CreatePsbtError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { CreatePsbtError::LockTypeMismatch => write!(f, "cannot mix locktime units"), - CreatePsbtError::MissingFullTxForLegacyInput(input) => write!( + CreatePsbtError::MissingFullTxForLegacyInput(outpoint) => write!( f, "legacy input that spends {} requires PSBT_IN_NON_WITNESS_UTXO", - input.prev_outpoint() + outpoint ), - CreatePsbtError::MissingFullTxForSegwitV0Input(input) => write!( + CreatePsbtError::MissingFullTxForSegwitV0Input(outpoint) => write!( f, "segwit v0 input that spends {} requires PSBT_IN_NON_WITNESS_UTXO", - input.prev_outpoint() + outpoint ), CreatePsbtError::Psbt(error) => Display::fmt(&error, f), CreatePsbtError::OutputUpdate(output_update_error) => { @@ -106,9 +105,6 @@ impl core::fmt::Display for CreatePsbtError { CreatePsbtError::InvalidLockTime(locktime) => { write!(f, "The locktime - {}, is invalid", locktime) } - CreatePsbtError::InvalidHeight(height) => { - write!(f, "The height - {}, is invalid", height) - } CreatePsbtError::UnsupportedVersion(version) => { write!(f, "Unsupported version {}", version) } @@ -202,16 +198,16 @@ impl Selection { psbt_input.non_witness_utxo = plan_input.prev_tx().cloned(); if psbt_input.non_witness_utxo.is_none() { if witness_version.is_none() { - return Err(CreatePsbtError::MissingFullTxForLegacyInput(Box::new( - plan_input.clone(), - ))); + return Err(CreatePsbtError::MissingFullTxForLegacyInput( + plan_input.prev_outpoint(), + )); } if params.mandate_full_tx_for_segwit_v0 && witness_version == Some(bitcoin::WitnessVersion::V0) { - return Err(CreatePsbtError::MissingFullTxForSegwitV0Input(Box::new( - plan_input.clone(), - ))); + return Err(CreatePsbtError::MissingFullTxForSegwitV0Input( + plan_input.prev_outpoint(), + )); } } continue; diff --git a/src/utils.rs b/src/utils.rs index dd88b02..4f4d81b 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -3,7 +3,7 @@ use alloc::vec::Vec; use miniscript::bitcoin::{ absolute::{self, LockTime}, transaction::Version, - Sequence, Transaction, WitnessVersion, + Sequence, Transaction, }; use rand_core::{OsRng, RngCore}; @@ -15,11 +15,11 @@ pub fn apply_anti_fee_sniping( current_height: absolute::Height, rbf_enabled: bool, ) -> Result<(), CreatePsbtError> { - const MAX_SEQUENCE_VALUE: u32 = 65_535; + const MAX_RELATIVE_HEIGHT: u32 = 65_535; const USE_NLOCKTIME_PROBABILITY: u32 = 2; const MIN_SEQUENCE_VALUE: u32 = 1; const FURTHER_BACK_PROBABILITY: u32 = 10; - const MAX_RANDOM_OFFSET: u32 = 99; + const MAX_RANDOM_OFFSET: u32 = 100; let mut rng = OsRng; @@ -27,15 +27,20 @@ pub fn apply_anti_fee_sniping( return Err(CreatePsbtError::UnsupportedVersion(tx.version)); } - let taproot_inputs: Vec = (0..tx.input.len()) - .filter(|&idx| { - // Check if this input is taproot using the corresponding Input data - inputs - .get(idx) - .and_then(|input| input.plan()) - .and_then(|plan| plan.witness_version()) - .map(|version| version == WitnessVersion::V1) - .unwrap_or(false) + // vector of input_index and associated Input ref. + let taproot_inputs: Vec<(usize, &Input)> = tx + .input + .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() { + Some((vin, input)) + } else { + None + } }) .collect(); @@ -43,11 +48,8 @@ pub fn apply_anti_fee_sniping( let must_use_locktime = inputs.iter().any(|input| { let confirmation = input.confirmations(current_height); confirmation == 0 - || confirmation > MAX_SEQUENCE_VALUE - || !matches!( - input.plan().and_then(|plan| plan.witness_version()), - Some(WitnessVersion::V1) - ) + || confirmation > MAX_RELATIVE_HEIGHT + || !input.prev_txout().script_pubkey.is_p2tr() }); let use_locktime = !rbf_enabled @@ -64,15 +66,15 @@ pub fn apply_anti_fee_sniping( locktime = locktime.saturating_sub(random_offset); } - let new_locktime = LockTime::from_height(locktime) - .map_err(|_| CreatePsbtError::InvalidHeight(locktime))?; + let new_locktime = LockTime::from_height(locktime).expect("must be valid Height"); tx.lock_time = new_locktime; } else { // Use Sequence tx.lock_time = LockTime::ZERO; - let input_index = random_range(&mut rng, taproot_inputs.len() as u32) as usize; - let confirmation = inputs[input_index].confirmations(current_height); + let random_index = random_range(&mut rng, taproot_inputs.len() as u32); + let (input_index, input) = taproot_inputs[random_index as usize]; + let confirmation = input.confirmations(current_height); let mut sequence_value = confirmation; if random_probability(&mut rng, FURTHER_BACK_PROBABILITY) { From 2c3e48ed07dc337ebcedaf820e81648c8c0386fe Mon Sep 17 00:00:00 2001 From: valued mammal Date: Mon, 9 Jun 2025 09:18:31 -0400 Subject: [PATCH 3/4] clippy: address `clippy::large_enum_variant` by using `Box` --- src/input.rs | 11 ++++++----- src/output.rs | 5 +++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/input.rs b/src/input.rs index 0e19e72..f0859ea 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1,3 +1,4 @@ +use alloc::boxed::Box; use alloc::sync::Arc; use alloc::vec::Vec; use core::fmt; @@ -34,9 +35,9 @@ impl TxStatus { #[derive(Debug, Clone)] enum PlanOrPsbtInput { - Plan(Plan), + Plan(Box), PsbtInput { - psbt_input: psbt::Input, + psbt_input: Box, sequence: Sequence, absolute_timelock: absolute::LockTime, satisfaction_weight: usize, @@ -57,7 +58,7 @@ impl PlanOrPsbtInput { return Err(FromPsbtInputError::UtxoCheck); } Ok(Self::PsbtInput { - psbt_input, + psbt_input: Box::new(psbt_input), sequence, absolute_timelock: absolute::LockTime::ZERO, satisfaction_weight, @@ -216,7 +217,7 @@ impl Input { prev_outpoint: OutPoint::new(tx.compute_txid(), output_index as _), prev_txout: tx.tx_out(output_index).cloned()?, prev_tx: Some(tx), - plan: PlanOrPsbtInput::Plan(plan), + plan: PlanOrPsbtInput::Plan(Box::new(plan)), status, is_coinbase, }) @@ -234,7 +235,7 @@ impl Input { prev_outpoint, prev_txout, prev_tx: None, - plan: PlanOrPsbtInput::Plan(plan), + plan: PlanOrPsbtInput::Plan(Box::new(plan)), status, is_coinbase, } diff --git a/src/output.rs b/src/output.rs index 92e972a..c6286f9 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,3 +1,4 @@ +use alloc::boxed::Box; use bitcoin::{Amount, ScriptBuf, TxOut}; use miniscript::bitcoin; @@ -9,7 +10,7 @@ pub enum ScriptSource { /// bitcoin script Script(ScriptBuf), /// definite descriptor - Descriptor(DefiniteDescriptor), + Descriptor(Box), } impl From for ScriptSource { @@ -32,7 +33,7 @@ impl ScriptSource { /// From descriptor pub fn from_descriptor(descriptor: DefiniteDescriptor) -> Self { - Self::Descriptor(descriptor) + Self::Descriptor(Box::new(descriptor)) } /// To ScriptBuf From 04256a7777fd4523b7b2928be02c54277cc65735 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Mon, 9 Jun 2025 10:20:49 -0400 Subject: [PATCH 4/4] test: test_enable_anti_fee_sniping --- src/selection.rs | 70 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/selection.rs b/src/selection.rs index 896ba79..70da81c 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -233,3 +233,73 @@ impl Selection { ) } } + +#[cfg(test)] +mod test { + use super::*; + + use bitcoin::{ + absolute, secp256k1::Secp256k1, transaction, Amount, ScriptBuf, Transaction, TxIn, TxOut, + }; + use miniscript::{plan::Assets, Descriptor, DescriptorPublicKey}; + + #[test] + fn test_enable_anti_fee_sniping() -> anyhow::Result<()> { + let secp = Secp256k1::new(); + + let s = "tr([83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*)"; + let desc = Descriptor::parse_descriptor(&secp, s).unwrap().0; + let def_desc = desc.at_derivation_index(0).unwrap(); + let script_pubkey = def_desc.script_pubkey(); + let desc_pk: DescriptorPublicKey = "[83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*".parse()?; + let assets = Assets::new().add(desc_pk); + let plan = def_desc.plan(&assets).expect("failed to create plan"); + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey, + value: Amount::from_sat(10_000), + }], + }; + + // Assume the current height is 2500, and previous tx confirms at height 2000. + let current_height = 2_500; + let status = crate::TxStatus { + height: absolute::Height::from_consensus(2_000)?, + time: absolute::Time::from_consensus(500_000_000)?, + }; + let input = Input::from_prev_tx(plan, prev_tx, 0, Some(status))?; + let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); + let selection = Selection { + inputs: vec![input.clone()], + outputs: vec![output], + }; + + let psbt = selection.create_psbt(PsbtParams { + fallback_locktime: absolute::LockTime::from_consensus(current_height), + enable_anti_fee_sniping: true, + fallback_sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + ..Default::default() + })?; + + let tx = psbt.unsigned_tx; + + if tx.lock_time > absolute::LockTime::ZERO { + // Check locktime + let min_height = current_height.saturating_sub(100); + assert!((min_height..=current_height).contains(&tx.lock_time.to_consensus_u32())); + } else { + // Check sequence + let confirmations = + input.confirmations(absolute::Height::from_consensus(current_height)?); + let min_sequence = confirmations.saturating_sub(100); + let sequence_value = tx.input[0].sequence.to_consensus_u32(); + assert!((min_sequence..=confirmations).contains(&sequence_value)); + } + + Ok(()) + } +}