Skip to content

Commit cfe7b5b

Browse files
committed
example to test anti fee snipping, extracted the height from tx
checking expected range of values
1 parent 94237e8 commit cfe7b5b

6 files changed

Lines changed: 199 additions & 82 deletions

File tree

Cargo.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@ readme = "README.md"
1212
miniscript = { version = "12", default-features = false }
1313
bdk_coin_select = "0.4.0"
1414
rand_core = { version = "0.6.0", features = ["getrandom"] }
15-
bdk_chain = { version = "0.21" }
16-
bitcoin = { version = "0.32", features = ["rand-std"] }
15+
bitcoin = { version = "0.32", default-features = false }
1716

1817
[dev-dependencies]
1918
anyhow = "1"
2019
bdk_tx = { path = "." }
2120
bdk_testenv = "0.11.1"
2221
bdk_bitcoind_rpc = "0.18.0"
22+
bdk_chain = { version = "0.21" }
2323

2424
[features]
2525
default = ["std"]
@@ -31,3 +31,6 @@ name = "synopsis"
3131
[[example]]
3232
name = "common"
3333
crate-type = ["lib"]
34+
35+
[[example]]
36+
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
use alloc::boxed::Box;
12
use alloc::sync::Arc;
23
use alloc::vec::Vec;
34
use core::fmt;
4-
use std::boxed::Box;
55

66
use bitcoin::constants::COINBASE_MATURITY;
77
use bitcoin::transaction::OutputsIndexError;

src/output.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::boxed::Box;
1+
use alloc::boxed::Box;
22

33
use bitcoin::{Amount, ScriptBuf, TxOut};
44
use miniscript::bitcoin;

src/selection.rs

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
use alloc::{boxed::Box, vec::Vec};
12
use core::fmt::{Debug, Display};
2-
use std::{boxed::Box, 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

@@ -56,7 +59,7 @@ impl Default for PsbtParams {
5659
fallback_locktime: absolute::LockTime::ZERO,
5760
fallback_sequence: FALLBACK_SEQUENCE,
5861
mandate_full_tx_for_segwit_v0: true,
59-
enable_anti_fee_sniping: true,
62+
enable_anti_fee_sniping: false,
6063
}
6164
}
6265
}
@@ -78,6 +81,8 @@ pub enum CreatePsbtError {
7881
InvalidLockTime(absolute::LockTime),
7982
/// Invalid height
8083
InvalidHeight(u32),
84+
/// Unsupported version for anti fee snipping
85+
UnsupportedVersion(transaction::Version),
8186
}
8287

8388
impl core::fmt::Display for CreatePsbtError {
@@ -104,6 +109,9 @@ impl core::fmt::Display for CreatePsbtError {
104109
CreatePsbtError::InvalidHeight(height) => {
105110
write!(f, "The height - {}, is invalid", height)
106111
}
112+
CreatePsbtError::UnsupportedVersion(version) => {
113+
write!(f, "Unsupported version {}", version)
114+
}
107115
}
108116
}
109117
}
@@ -163,20 +171,18 @@ impl Selection {
163171
};
164172

165173
if params.enable_anti_fee_sniping {
166-
let rbf_enabled = params.fallback_sequence.is_rbf();
167-
let height = params
168-
.fallback_locktime
169-
.is_block_height()
170-
.then(|| params.fallback_locktime.to_consensus_u32())
171-
.ok_or(CreatePsbtError::InvalidLockTime(params.fallback_locktime))?;
172-
173-
let current_height = bitcoin::absolute::Height::from_consensus(height)
174-
.map_err(|_conversion_error| CreatePsbtError::InvalidHeight(height))?;
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+
};
175181

176-
apply_anti_fee_sniping(&mut tx, &self.inputs, current_height, rbf_enabled);
182+
apply_anti_fee_sniping(&mut tx, &self.inputs, current_height, rbf_enabled)?;
177183
};
178184

179-
let mut psbt = bitcoin::Psbt::from_unsigned_tx(tx).map_err(CreatePsbtError::Psbt)?;
185+
let mut psbt = Psbt::from_unsigned_tx(tx).map_err(CreatePsbtError::Psbt)?;
180186

181187
for (plan_input, psbt_input) in self.inputs.iter().zip(psbt.inputs.iter_mut()) {
182188
if let Some(finalized_psbt_input) = plan_input.psbt_input() {

src/utils.rs

Lines changed: 39 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
use crate::Input;
2-
use bitcoin::{
1+
use crate::{CreatePsbtError, Input};
2+
use alloc::vec::Vec;
3+
use miniscript::bitcoin::{
34
absolute::{self, LockTime},
45
transaction::Version,
56
Sequence, Transaction, WitnessVersion,
67
};
7-
use std::vec::Vec;
88

99
use rand_core::{OsRng, RngCore};
1010

@@ -14,34 +14,31 @@ pub fn apply_anti_fee_sniping(
1414
inputs: &[Input],
1515
current_height: absolute::Height,
1616
rbf_enabled: bool,
17-
) {
17+
) -> Result<(), CreatePsbtError> {
1818
const MAX_SEQUENCE_VALUE: u32 = 65_535;
19-
const USE_NLOCKTIME_PROBABILITY: f64 = 0.5;
19+
const USE_NLOCKTIME_PROBABILITY: u32 = 2;
2020
const MIN_SEQUENCE_VALUE: u32 = 1;
21-
const FURTHER_BACK_PROBABILITY: f64 = 0.1;
21+
const FURTHER_BACK_PROBABILITY: u32 = 10;
2222
const MAX_RANDOM_OFFSET: u32 = 99;
2323

24-
tx.version = Version::TWO;
24+
let mut rng = OsRng;
2525

26-
let taproot_inputs: Vec<_> = inputs
27-
.iter()
28-
.enumerate()
29-
.filter(|(_, input)| {
30-
matches!(
31-
input.plan().and_then(|plan| plan.witness_version()),
32-
Some(WitnessVersion::V1)
33-
)
26+
if tx.version < Version::TWO {
27+
return Err(CreatePsbtError::UnsupportedVersion(tx.version));
28+
}
29+
30+
let taproot_inputs: Vec<usize> = (0..tx.input.len())
31+
.filter(|&idx| {
32+
// Check if this input is taproot using the corresponding Input data
33+
inputs
34+
.get(idx)
35+
.and_then(|input| input.plan())
36+
.and_then(|plan| plan.witness_version())
37+
.map(|version| version == WitnessVersion::V1)
38+
.unwrap_or(false)
3439
})
3540
.collect();
3641

37-
// Initialize all nsequence to indicate the requested RBF state
38-
for input in &mut tx.input {
39-
input.sequence = if rbf_enabled {
40-
Sequence(0xFFFFFFFF - 2) // 2^32 - 3
41-
} else {
42-
Sequence(0xFFFFFFFF - 1) // 2^32 - 2
43-
}
44-
}
4542
// Check always‐locktime conditions
4643
let must_use_locktime = inputs.iter().any(|input| {
4744
let confirmation = input.confirmations(current_height);
@@ -56,68 +53,46 @@ pub fn apply_anti_fee_sniping(
5653
let use_locktime = !rbf_enabled
5754
|| must_use_locktime
5855
|| taproot_inputs.is_empty()
59-
|| random_probability(USE_NLOCKTIME_PROBABILITY);
56+
|| random_probability(&mut rng, USE_NLOCKTIME_PROBABILITY);
6057

6158
if use_locktime {
6259
// Use nLockTime
6360
let mut locktime = current_height.to_consensus_u32();
6461

65-
if random_probability(FURTHER_BACK_PROBABILITY) {
66-
let random_offset = random_range(0, MAX_RANDOM_OFFSET);
62+
if random_probability(&mut rng, FURTHER_BACK_PROBABILITY) {
63+
let random_offset = random_range(&mut rng, MAX_RANDOM_OFFSET);
6764
locktime = locktime.saturating_sub(random_offset);
6865
}
6966

70-
tx.lock_time = LockTime::from_height(locktime).unwrap();
67+
let new_locktime = LockTime::from_height(locktime)
68+
.map_err(|_| CreatePsbtError::InvalidHeight(locktime))?;
69+
70+
tx.lock_time = new_locktime;
7171
} else {
7272
// Use Sequence
7373
tx.lock_time = LockTime::ZERO;
74-
75-
let input_index = random_range(0, taproot_inputs.len() as u32) as usize;
76-
77-
let (idx, input) = &taproot_inputs[input_index];
78-
79-
let confirmation = input.confirmations(current_height);
74+
let input_index = random_range(&mut rng, taproot_inputs.len() as u32) as usize;
75+
let confirmation = inputs[input_index].confirmations(current_height);
8076

8177
let mut sequence_value = confirmation;
82-
83-
if random_probability(FURTHER_BACK_PROBABILITY) {
84-
let random_offset = random_range(0, MAX_RANDOM_OFFSET);
78+
if random_probability(&mut rng, FURTHER_BACK_PROBABILITY) {
79+
let random_offset = random_range(&mut rng, MAX_RANDOM_OFFSET);
8580
sequence_value = sequence_value
8681
.saturating_sub(random_offset)
8782
.max(MIN_SEQUENCE_VALUE);
8883
}
8984

90-
tx.input[*idx].sequence = Sequence(sequence_value);
85+
tx.input[input_index].sequence = Sequence(sequence_value);
9186
}
92-
}
9387

94-
fn random_probability(probability: f64) -> bool {
95-
debug_assert!(
96-
(0.0..=1.0).contains(&probability),
97-
"Probability must be between 0.0 and 1.0"
98-
);
88+
Ok(())
89+
}
9990

100-
let mut rng = OsRng;
101-
let rand_val = rng.next_u32() as f64;
102-
let max_u32 = u32::MAX as f64;
103-
(rand_val / max_u32) < probability
91+
fn random_probability(rng: &mut OsRng, probability: u32) -> bool {
92+
let rand_val = rng.next_u32();
93+
rand_val % probability == 0
10494
}
10595

106-
fn random_range(min: u32, max: u32) -> u32 {
107-
if min >= max {
108-
return min;
109-
}
110-
let mut rng = OsRng;
111-
let range = max.saturating_sub(min);
112-
let threshold = u32::MAX.saturating_sub(u32::MAX % range);
113-
let min_val = min + (rng.next_u32() % (max - min));
114-
let mut r;
115-
116-
loop {
117-
r = rng.next_u32();
118-
if r < threshold {
119-
break;
120-
}
121-
}
122-
min_val.saturating_add(r % range)
96+
fn random_range(rng: &mut OsRng, max: u32) -> u32 {
97+
rng.next_u32() % max
12398
}

0 commit comments

Comments
 (0)