Skip to content

Commit 9041b2f

Browse files
committed
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
1 parent 19d9578 commit 9041b2f

File tree

7 files changed

+295
-20
lines changed

7 files changed

+295
-20
lines changed

Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ readme = "README.md"
1313
[dependencies]
1414
miniscript = { version = "12", default-features = false }
1515
bdk_coin_select = "0.4.0"
16+
rand_core = { version = "0.6.0", features = ["getrandom"] }
1617

1718
[dev-dependencies]
1819
anyhow = "1"
1920
bdk_tx = { path = "." }
20-
bitcoin = { version = "0.32", features = ["rand-std"] }
21+
bitcoin = { version = "0.32", default-features = false }
2122
bdk_testenv = "0.13.0"
2223
bdk_bitcoind_rpc = "0.20.0"
2324
bdk_chain = { version = "0.23.0" }
@@ -32,3 +33,6 @@ name = "synopsis"
3233
[[example]]
3334
name = "common"
3435
crate-type = ["lib"]
36+
37+
[[example]]
38+
name = "anti_fee_snipping"

examples/anti_fee_snipping.rs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
#![allow(dead_code)]
2+
use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv};
3+
use bdk_tx::{
4+
filter_unspendable_now, group_by_spk, selection_algorithm_lowest_fee_bnb, Output, PsbtParams,
5+
SelectorParams,
6+
};
7+
use bitcoin::{absolute::LockTime, key::Secp256k1, Amount, FeeRate, Sequence};
8+
use miniscript::Descriptor;
9+
10+
mod common;
11+
12+
use common::Wallet;
13+
14+
fn main() -> anyhow::Result<()> {
15+
let secp = Secp256k1::new();
16+
let (external, _) = Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[0])?;
17+
let (internal, _) = Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[1])?;
18+
19+
let env = TestEnv::new()?;
20+
let genesis_hash = env.genesis_hash()?;
21+
env.mine_blocks(101, None)?;
22+
23+
let mut wallet = Wallet::new(genesis_hash, external, internal.clone())?;
24+
wallet.sync(&env)?;
25+
26+
let addr = wallet.next_address().expect("must derive address");
27+
28+
let txid = env.send(&addr, Amount::ONE_BTC)?;
29+
env.mine_blocks(1, None)?;
30+
wallet.sync(&env)?;
31+
println!("Received {}", txid);
32+
println!("Balance (confirmed): {}", wallet.balance());
33+
34+
let txid = env.send(&addr, Amount::ONE_BTC)?;
35+
wallet.sync(&env)?;
36+
println!("Received {txid}");
37+
println!("Balance (pending): {}", wallet.balance());
38+
39+
let (tip_height, tip_time) = wallet.tip_info(env.rpc_client())?;
40+
println!("Height: {}", tip_height);
41+
let longterm_feerate = FeeRate::from_sat_per_vb_unchecked(1);
42+
43+
let recipient_addr = env
44+
.rpc_client()
45+
.get_new_address(None, None)?
46+
.assume_checked();
47+
48+
// Okay now create tx.
49+
let selection = wallet
50+
.all_candidates()
51+
.regroup(group_by_spk())
52+
.filter(filter_unspendable_now(tip_height, tip_time))
53+
.into_selection(
54+
selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000),
55+
SelectorParams::new(
56+
FeeRate::from_sat_per_vb_unchecked(10),
57+
vec![Output::with_script(
58+
recipient_addr.script_pubkey(),
59+
Amount::from_sat(21_000_000),
60+
)],
61+
internal.at_derivation_index(0)?,
62+
bdk_tx::ChangePolicyType::NoDustAndLeastWaste { longterm_feerate },
63+
),
64+
)?;
65+
66+
// Convert the consensus‐height (u32) into an absolute::LockTime
67+
let fallback_locktime: LockTime = LockTime::from_consensus(tip_height.to_consensus_u32());
68+
69+
let psbt = selection.create_psbt(PsbtParams {
70+
enable_anti_fee_sniping: true,
71+
fallback_locktime,
72+
fallback_sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
73+
..Default::default()
74+
})?;
75+
76+
let tx = psbt.unsigned_tx;
77+
78+
// Locktime is used, if rbf is disabled or any input requires locktime
79+
// (e.g. non-taproot, unconfirmed, or >65535 confirmation) or there are
80+
// no taproot inputs or the 50/50 coin flip chose locktime (USE_NLOCKTIME_PROBABILITY)
81+
// Further-back randomness with 10% chance (FURTHER_BACK_PROBABILITY),
82+
// will subtract a random 0–99 block offset to desynchronize from tip
83+
//
84+
// Sequence will use the opposite condition of locktime, and locktime will
85+
// be set to zero. Further-back randomness: with 10% chance, will
86+
// subtract a random 0–99 block offset (but at least 1).
87+
//
88+
// Whenever locktime is used, the sequence value will remain as it is.
89+
90+
if tx.lock_time != LockTime::ZERO {
91+
let height_val = tx.lock_time.to_consensus_u32();
92+
let min_expected = tip_height.to_consensus_u32().saturating_sub(99);
93+
let max_expected = tip_height.to_consensus_u32();
94+
95+
assert!(
96+
(min_expected..=max_expected).contains(&height_val),
97+
"Value {} is out of range {}..={}",
98+
height_val,
99+
min_expected,
100+
max_expected
101+
);
102+
103+
if height_val >= min_expected && height_val <= max_expected {
104+
println!("✓ Locktime is within expected range");
105+
} else {
106+
println!("⚠ Locktime is outside expected range");
107+
}
108+
} else {
109+
for (i, inp) in tx.input.iter().enumerate() {
110+
let sequence_value = inp.sequence.to_consensus_u32();
111+
112+
let min_expected = 1;
113+
let max_expected = Sequence(0xFFFFFFFE).to_consensus_u32();
114+
let index = i + 1;
115+
116+
if sequence_value >= min_expected && sequence_value <= max_expected {
117+
println!(
118+
"✓ Input #{}: sequence {} is within anti-fee sniping range",
119+
index, sequence_value
120+
);
121+
} else if sequence_value == 0xfffffffd || sequence_value == 0xfffffffe {
122+
println!("✓ Input #{}: using standard RBF sequence", index);
123+
} else {
124+
println!(
125+
"⚠ Input #{}: sequence {} outside typical ranges",
126+
index, sequence_value
127+
);
128+
}
129+
}
130+
}
131+
132+
Ok(())
133+
}

src/input.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use alloc::boxed::Box;
12
use alloc::sync::Arc;
23
use alloc::vec::Vec;
34
use core::fmt;
@@ -34,9 +35,9 @@ impl TxStatus {
3435

3536
#[derive(Debug, Clone)]
3637
enum PlanOrPsbtInput {
37-
Plan(Plan),
38+
Plan(Box<Plan>),
3839
PsbtInput {
39-
psbt_input: psbt::Input,
40+
psbt_input: Box<psbt::Input>,
4041
sequence: Sequence,
4142
absolute_timelock: absolute::LockTime,
4243
satisfaction_weight: usize,
@@ -57,7 +58,7 @@ impl PlanOrPsbtInput {
5758
return Err(FromPsbtInputError::UtxoCheck);
5859
}
5960
Ok(Self::PsbtInput {
60-
psbt_input,
61+
psbt_input: Box::new(psbt_input),
6162
sequence,
6263
absolute_timelock: absolute::LockTime::ZERO,
6364
satisfaction_weight,
@@ -216,7 +217,7 @@ impl Input {
216217
prev_outpoint: OutPoint::new(tx.compute_txid(), output_index as _),
217218
prev_txout: tx.tx_out(output_index).cloned()?,
218219
prev_tx: Some(tx),
219-
plan: PlanOrPsbtInput::Plan(plan),
220+
plan: PlanOrPsbtInput::Plan(Box::new(plan)),
220221
status,
221222
is_coinbase,
222223
})
@@ -234,7 +235,7 @@ impl Input {
234235
prev_outpoint,
235236
prev_txout,
236237
prev_tx: None,
237-
plan: PlanOrPsbtInput::Plan(plan),
238+
plan: PlanOrPsbtInput::Plan(Box::new(plan)),
238239
status,
239240
is_coinbase,
240241
}

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ mod rbf;
2121
mod selection;
2222
mod selector;
2323
mod signer;
24+
mod utils;
2425

2526
pub use canonical_unspents::*;
2627
pub use finalizer::*;
@@ -34,6 +35,7 @@ pub use rbf::*;
3435
pub use selection::*;
3536
pub use selector::*;
3637
pub use signer::*;
38+
use utils::*;
3739

3840
#[cfg(feature = "std")]
3941
pub(crate) mod collections {

src/output.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use alloc::boxed::Box;
2+
13
use bitcoin::{Amount, ScriptBuf, TxOut};
24
use miniscript::bitcoin;
35

@@ -9,7 +11,7 @@ pub enum ScriptSource {
911
/// bitcoin script
1012
Script(ScriptBuf),
1113
/// definite descriptor
12-
Descriptor(DefiniteDescriptor),
14+
Descriptor(Box<DefiniteDescriptor>),
1315
}
1416

1517
impl From<ScriptBuf> for ScriptSource {
@@ -32,7 +34,7 @@ impl ScriptSource {
3234

3335
/// From descriptor
3436
pub fn from_descriptor(descriptor: DefiniteDescriptor) -> Self {
35-
Self::Descriptor(descriptor)
37+
Self::Descriptor(Box::new(descriptor))
3638
}
3739

3840
/// To ScriptBuf

src/selection.rs

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
use alloc::{boxed::Box, vec::Vec};
12
use core::fmt::{Debug, Display};
2-
use std::vec::Vec;
33

44
use bdk_coin_select::FeeRate;
5-
use bitcoin::{absolute, transaction, Sequence};
5+
use bitcoin::{
6+
absolute::{self, LockTime},
7+
transaction, Psbt, Sequence,
8+
};
69
use miniscript::bitcoin;
710
use miniscript::psbt::PsbtExt;
811

9-
use crate::{Finalizer, Input, Output};
12+
use crate::{apply_anti_fee_sniping, Finalizer, Input, Output};
1013

1114
const FALLBACK_SEQUENCE: bitcoin::Sequence = bitcoin::Sequence::ENABLE_LOCKTIME_NO_RBF;
1215

@@ -44,6 +47,9 @@ pub struct PsbtParams {
4447
///
4548
/// [`non_witness_utxo`]: bitcoin::psbt::Input::non_witness_utxo
4649
pub mandate_full_tx_for_segwit_v0: bool,
50+
51+
/// Whether to use BIP326 anti-fee-sniping
52+
pub enable_anti_fee_sniping: bool,
4753
}
4854

4955
impl Default for PsbtParams {
@@ -53,6 +59,7 @@ impl Default for PsbtParams {
5359
fallback_locktime: absolute::LockTime::ZERO,
5460
fallback_sequence: FALLBACK_SEQUENCE,
5561
mandate_full_tx_for_segwit_v0: true,
62+
enable_anti_fee_sniping: false,
5663
}
5764
}
5865
}
@@ -63,13 +70,19 @@ pub enum CreatePsbtError {
6370
/// Attempted to mix locktime types.
6471
LockTypeMismatch,
6572
/// Missing tx for legacy input.
66-
MissingFullTxForLegacyInput(Input),
73+
MissingFullTxForLegacyInput(Box<Input>),
6774
/// Missing tx for segwit v0 input.
68-
MissingFullTxForSegwitV0Input(Input),
75+
MissingFullTxForSegwitV0Input(Box<Input>),
6976
/// Psbt error.
7077
Psbt(bitcoin::psbt::Error),
7178
/// Update psbt output with descriptor error.
7279
OutputUpdate(miniscript::psbt::OutputUpdateError),
80+
/// Invalid locktime
81+
InvalidLockTime(absolute::LockTime),
82+
/// Invalid height
83+
InvalidHeight(u32),
84+
/// Unsupported version for anti fee snipping
85+
UnsupportedVersion(transaction::Version),
7386
}
7487

7588
impl core::fmt::Display for CreatePsbtError {
@@ -90,6 +103,15 @@ impl core::fmt::Display for CreatePsbtError {
90103
CreatePsbtError::OutputUpdate(output_update_error) => {
91104
Display::fmt(&output_update_error, f)
92105
}
106+
CreatePsbtError::InvalidLockTime(locktime) => {
107+
write!(f, "The locktime - {}, is invalid", locktime)
108+
}
109+
CreatePsbtError::InvalidHeight(height) => {
110+
write!(f, "The height - {}, is invalid", height)
111+
}
112+
CreatePsbtError::UnsupportedVersion(version) => {
113+
write!(f, "Unsupported version {}", version)
114+
}
93115
}
94116
}
95117
}
@@ -127,7 +149,7 @@ impl Selection {
127149

128150
/// Create psbt.
129151
pub fn create_psbt(&self, params: PsbtParams) -> Result<bitcoin::Psbt, CreatePsbtError> {
130-
let mut psbt = bitcoin::Psbt::from_unsigned_tx(bitcoin::Transaction {
152+
let mut tx = bitcoin::Transaction {
131153
version: params.version,
132154
lock_time: Self::_accumulate_max_locktime(
133155
self.inputs
@@ -146,8 +168,21 @@ impl Selection {
146168
})
147169
.collect(),
148170
output: self.outputs.iter().map(|output| output.txout()).collect(),
149-
})
150-
.map_err(CreatePsbtError::Psbt)?;
171+
};
172+
173+
if params.enable_anti_fee_sniping {
174+
let rbf_enabled = tx.is_explicitly_rbf();
175+
let current_height = match tx.lock_time {
176+
LockTime::Blocks(height) => height,
177+
LockTime::Seconds(_) => {
178+
return Err(CreatePsbtError::InvalidLockTime(tx.lock_time));
179+
}
180+
};
181+
182+
apply_anti_fee_sniping(&mut tx, &self.inputs, current_height, rbf_enabled)?;
183+
};
184+
185+
let mut psbt = Psbt::from_unsigned_tx(tx).map_err(CreatePsbtError::Psbt)?;
151186

152187
for (plan_input, psbt_input) in self.inputs.iter().zip(psbt.inputs.iter_mut()) {
153188
if let Some(finalized_psbt_input) = plan_input.psbt_input() {
@@ -167,16 +202,16 @@ impl Selection {
167202
psbt_input.non_witness_utxo = plan_input.prev_tx().cloned();
168203
if psbt_input.non_witness_utxo.is_none() {
169204
if witness_version.is_none() {
170-
return Err(CreatePsbtError::MissingFullTxForLegacyInput(
205+
return Err(CreatePsbtError::MissingFullTxForLegacyInput(Box::new(
171206
plan_input.clone(),
172-
));
207+
)));
173208
}
174209
if params.mandate_full_tx_for_segwit_v0
175210
&& witness_version == Some(bitcoin::WitnessVersion::V0)
176211
{
177-
return Err(CreatePsbtError::MissingFullTxForSegwitV0Input(
212+
return Err(CreatePsbtError::MissingFullTxForSegwitV0Input(Box::new(
178213
plan_input.clone(),
179-
));
214+
)));
180215
}
181216
}
182217
continue;

0 commit comments

Comments
 (0)