Skip to content

Commit ed9df3c

Browse files
committed
feat: implement anti-fee-sniping protection
1 parent 1f1da01 commit ed9df3c

File tree

5 files changed

+659
-17
lines changed

5 files changed

+659
-17
lines changed

Cargo.toml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,29 @@ license = "MIT OR Apache-2.0"
1111
readme = "README.md"
1212

1313
[dependencies]
14-
miniscript = { version = "12", default-features = false }
14+
miniscript = { version = "12.3.5", default-features = false }
1515
bdk_coin_select = "0.4.0"
16+
rand_core = { version = "0.6.4", default-features = false }
17+
rand = { version = "0.8", optional = true }
1618

1719
[dev-dependencies]
1820
anyhow = "1"
1921
bdk_tx = { path = "." }
20-
bitcoin = { version = "0.32", features = ["rand-std"] }
22+
bitcoin = { version = "0.32", default-features = false, features = ["rand-std"] }
2123
bdk_testenv = "0.13.0"
2224
bdk_bitcoind_rpc = "0.20.0"
2325
bdk_chain = { version = "0.23.0" }
2426

2527
[features]
2628
default = ["std"]
27-
std = ["miniscript/std"]
29+
std = ["miniscript/std", "rand/std"]
2830

2931
[[example]]
3032
name = "synopsis"
3133

3234
[[example]]
3335
name = "common"
3436
crate-type = ["lib"]
37+
38+
[[example]]
39+
name = "anti_fee_sniping"

examples/anti_fee_sniping.rs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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+
ScriptSource, 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 txid1 = env.send(&addr, Amount::ONE_BTC)?;
29+
env.mine_blocks(1, None)?;
30+
wallet.sync(&env)?;
31+
println!("Received confirmed input: {}", txid1);
32+
33+
let txid2 = env.send(&addr, Amount::ONE_BTC)?;
34+
env.mine_blocks(1, None)?;
35+
wallet.sync(&env)?;
36+
println!("Received confirmed input: {}", txid2);
37+
38+
println!("Balance (confirmed): {}", wallet.balance());
39+
40+
let (tip_height, tip_time) = wallet.tip_info(env.rpc_client())?;
41+
println!("Current height: {}", tip_height);
42+
let longterm_feerate = FeeRate::from_sat_per_vb_unchecked(1);
43+
44+
let recipient_addr = env
45+
.rpc_client()
46+
.get_new_address(None, None)?
47+
.assume_checked();
48+
49+
// When anti-fee-sniping is enabled, the transaction will either use nLockTime or nSequence.
50+
//
51+
// Locktime approach is used when:
52+
// - RBF is disabled, OR
53+
// - Any input requires locktime (non-taproot, unconfirmed, or >65535 confirmations), OR
54+
// - There are no taproot inputs, OR
55+
// - Random 50/50 coin flip chose locktime
56+
//
57+
// Sequence approach is used otherwise:
58+
// - Sets tx.lock_time to ZERO
59+
// - Modifies one randomly selected taproot input's sequence
60+
//
61+
// Once the approach is selected, to reduce transaction fingerprinting,
62+
// - For nLockTime: With 10% probability, subtract a random 0-99 block offset from current height
63+
// - For nSequence: With 10% probability, subtract a random 0-99 block offset (minimum value of 1)
64+
//
65+
// Note: When locktime is used, all sequence values remain unchanged.
66+
67+
let mut locktime_count = 0;
68+
let mut sequence_count = 0;
69+
70+
for _ in 0..10 {
71+
let selection = wallet
72+
.all_candidates()
73+
.regroup(group_by_spk())
74+
.filter(filter_unspendable_now(tip_height, tip_time))
75+
.into_selection(
76+
selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000),
77+
SelectorParams::new(
78+
FeeRate::from_sat_per_vb_unchecked(10),
79+
vec![Output::with_script(
80+
recipient_addr.script_pubkey(),
81+
Amount::from_sat(50_000_000),
82+
)],
83+
ScriptSource::Descriptor(Box::new(internal.at_derivation_index(0)?)),
84+
bdk_tx::ChangePolicyType::NoDustAndLeastWaste { longterm_feerate },
85+
wallet.change_weight(),
86+
),
87+
)?;
88+
89+
let fallback_locktime: LockTime = LockTime::from_consensus(tip_height.to_consensus_u32());
90+
91+
let selection_inputs = selection.inputs.clone();
92+
93+
let psbt = selection.create_psbt(PsbtParams {
94+
enable_anti_fee_sniping: true,
95+
fallback_locktime,
96+
fallback_sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
97+
..Default::default()
98+
})?;
99+
100+
let tx = psbt.unsigned_tx;
101+
102+
if tx.lock_time != LockTime::ZERO {
103+
locktime_count += 1;
104+
let locktime_value = tx.lock_time.to_consensus_u32();
105+
let current_height = tip_height.to_consensus_u32();
106+
107+
let offset = current_height.saturating_sub(locktime_value);
108+
if offset > 0 {
109+
println!(
110+
"nLockTime = {} (tip height: {}, offset: -{})",
111+
locktime_value, current_height, offset
112+
);
113+
} else {
114+
println!(
115+
"nLockTime = {} (tip height: {}, no offset)",
116+
locktime_value, current_height
117+
);
118+
}
119+
} else {
120+
sequence_count += 1;
121+
122+
for (i, inp) in tx.input.iter().enumerate() {
123+
let sequence_value = inp.sequence.to_consensus_u32();
124+
125+
if (1..0xFFFFFFFD).contains(&sequence_value) {
126+
let input_confirmations = selection_inputs[i].confirmations(tip_height);
127+
let offset = input_confirmations.saturating_sub(sequence_value);
128+
129+
if offset > 0 {
130+
println!(
131+
"nSequence[{}] = {} (confirmations: {}, offset: -{})",
132+
i, sequence_value, input_confirmations, offset
133+
);
134+
} else {
135+
println!(
136+
"nSequence[{}] = {} (confirmations: {}, no offset)",
137+
i, sequence_value, input_confirmations
138+
);
139+
}
140+
141+
break;
142+
}
143+
}
144+
}
145+
}
146+
147+
println!("nLockTime approach used: {} times", locktime_count);
148+
println!("nSequence approach used: {} times", sequence_count);
149+
150+
Ok(())
151+
}

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ mod rbf;
1717
mod selection;
1818
mod selector;
1919
mod signer;
20+
mod utils;
2021

2122
pub use canonical_unspents::*;
2223
pub use finalizer::*;
@@ -30,6 +31,7 @@ pub use rbf::*;
3031
pub use selection::*;
3132
pub use selector::*;
3233
pub use signer::*;
34+
use utils::*;
3335

3436
#[cfg(feature = "std")]
3537
pub(crate) mod collections {

0 commit comments

Comments
 (0)