Skip to content

Commit 4649e8d

Browse files
committed
test: add unit tests for anti-fee-sniping feature
1 parent bd15c17 commit 4649e8d

File tree

7 files changed

+219
-15
lines changed

7 files changed

+219
-15
lines changed

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/input_candidates.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use alloc::vec::Vec;
1+
use alloc::{vec, vec::Vec};
22
use core::fmt;
33
use core::ops::Deref;
44

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ pub use rbf::*;
3131
pub use selection::*;
3232
pub use selector::*;
3333
pub use signer::*;
34-
pub use utils::*;
34+
use utils::*;
3535

3636
#[cfg(feature = "std")]
3737
pub(crate) mod collections {

src/output.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ pub enum ScriptSource {
1010
/// bitcoin script
1111
Script(ScriptBuf),
1212
/// definite descriptor
13-
Descriptor(DefiniteDescriptor),
13+
Descriptor(Box<DefiniteDescriptor>),
1414
}
1515

1616
impl From<ScriptBuf> for ScriptSource {
@@ -33,7 +33,7 @@ impl ScriptSource {
3333

3434
/// From descriptor
3535
pub fn from_descriptor(descriptor: DefiniteDescriptor) -> Self {
36-
Self::Descriptor(descriptor)
36+
Self::Descriptor(Box::new(descriptor))
3737
}
3838

3939
/// To ScriptBuf

src/selection.rs

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,3 +282,207 @@ impl Selection {
282282
)
283283
}
284284
}
285+
286+
#[cfg(test)]
287+
mod tests {
288+
use super::*;
289+
use bitcoin::{
290+
absolute::{self, Height, Time},
291+
secp256k1::Secp256k1,
292+
transaction::{self, Version},
293+
Amount, ScriptBuf, Transaction, TxIn, TxOut,
294+
};
295+
// use bdk_tx::apply_anti_fee_sniping;
296+
use miniscript::{plan::Assets, Descriptor, DescriptorPublicKey};
297+
298+
pub fn setup_test_input(confirmation_height: u32) -> anyhow::Result<(Input, absolute::Height)> {
299+
let secp = Secp256k1::new();
300+
let s = "tr([83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*)";
301+
let desc = Descriptor::parse_descriptor(&secp, s).unwrap().0;
302+
let def_desc = desc.at_derivation_index(0).unwrap();
303+
let script_pubkey = def_desc.script_pubkey();
304+
let desc_pk: DescriptorPublicKey = "[83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*".parse()?;
305+
let assets = Assets::new().add(desc_pk);
306+
let plan = def_desc.plan(&assets).expect("failed to create plan");
307+
308+
let prev_tx = Transaction {
309+
version: transaction::Version::TWO,
310+
lock_time: absolute::LockTime::ZERO,
311+
input: vec![TxIn::default()],
312+
output: vec![TxOut {
313+
script_pubkey,
314+
value: Amount::from_sat(10_000),
315+
}],
316+
};
317+
318+
let status = crate::TxStatus {
319+
height: absolute::Height::from_consensus(confirmation_height)?,
320+
time: Time::from_consensus(500_000_000)?,
321+
};
322+
323+
let input = Input::from_prev_tx(plan, prev_tx, 0, Some(status))?;
324+
let current_height = absolute::Height::from_consensus(confirmation_height + 50)?;
325+
326+
Ok((input, current_height))
327+
}
328+
329+
#[test]
330+
fn test_anti_fee_sniping_disabled() -> anyhow::Result<()> {
331+
let current_height = 2_500;
332+
let (input, _) = setup_test_input(2_000).unwrap();
333+
let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000));
334+
let selection = Selection {
335+
inputs: vec![input],
336+
outputs: vec![output],
337+
};
338+
339+
// Disabled - default behavior is disable
340+
let psbt = selection.create_psbt(PsbtParams {
341+
fallback_locktime: absolute::LockTime::from_consensus(current_height),
342+
fallback_sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
343+
..Default::default()
344+
})?;
345+
let tx = psbt.unsigned_tx;
346+
assert_eq!(tx.lock_time.to_consensus_u32(), current_height);
347+
348+
Ok(())
349+
}
350+
351+
#[test]
352+
fn test_anti_fee_sniping_invalid_locktime_error() -> anyhow::Result<()> {
353+
let (input, _) = setup_test_input(2_000).unwrap();
354+
let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000));
355+
let selection = Selection {
356+
inputs: vec![input],
357+
outputs: vec![output],
358+
};
359+
360+
// Use time-based locktime instead of height-based
361+
let result = selection.create_psbt(PsbtParams {
362+
fallback_locktime: LockTime::from_consensus(500_000_000), // Time-based
363+
enable_anti_fee_sniping: true,
364+
..Default::default()
365+
});
366+
367+
assert!(
368+
matches!(result, Err(CreatePsbtError::InvalidLockTime(_))),
369+
"should return InvalidLockTime error for time-based locktime"
370+
);
371+
372+
Ok(())
373+
}
374+
375+
#[test]
376+
fn test_anti_fee_sniping_protection() {
377+
let current_height = 2_500;
378+
let (input, _) = setup_test_input(2_000).unwrap();
379+
380+
let mut used_locktime = false;
381+
let mut used_sequence = false;
382+
383+
for _ in 0..100 {
384+
let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000));
385+
let selection = Selection {
386+
inputs: vec![input.clone()],
387+
outputs: vec![output],
388+
};
389+
let psbt = selection
390+
.create_psbt(PsbtParams {
391+
fallback_locktime: absolute::LockTime::from_consensus(current_height),
392+
enable_anti_fee_sniping: true,
393+
fallback_sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
394+
..Default::default()
395+
})
396+
.unwrap();
397+
let tx = psbt.unsigned_tx;
398+
399+
if tx.lock_time > absolute::LockTime::ZERO {
400+
used_locktime = true;
401+
let locktime_value = tx.lock_time.to_consensus_u32();
402+
let min_height = current_height.saturating_sub(100);
403+
assert!((min_height..=current_height).contains(&tx.lock_time.to_consensus_u32()));
404+
assert!(locktime_value <= current_height);
405+
assert!(locktime_value >= current_height.saturating_sub(100));
406+
} else {
407+
used_sequence = true;
408+
let sequence_value = tx.input[0].sequence.to_consensus_u32();
409+
let confirmations =
410+
input.confirmations(absolute::Height::from_consensus(current_height).unwrap());
411+
412+
let min_sequence = confirmations.saturating_sub(100);
413+
assert!((min_sequence..=confirmations).contains(&sequence_value));
414+
assert!(sequence_value >= 1, "Sequence must be at least 1");
415+
assert!(sequence_value <= confirmations);
416+
assert!(sequence_value >= confirmations.saturating_sub(100));
417+
}
418+
}
419+
420+
assert!(used_locktime, "Should have used locktime at least once");
421+
assert!(used_sequence, "Should have used sequence at least once");
422+
}
423+
424+
#[test]
425+
fn test_anti_fee_sniping_multiple_taproot_inputs() -> anyhow::Result<()> {
426+
let current_height = 3_000;
427+
let (input1, _) = setup_test_input(2_500).unwrap();
428+
let (input2, _) = setup_test_input(2_700).unwrap();
429+
let (input3, _) = setup_test_input(3_000).unwrap();
430+
let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(18_000));
431+
432+
let mut used_locktime = false;
433+
let mut used_sequence = false;
434+
435+
for _ in 0..50 {
436+
let selection = Selection {
437+
inputs: vec![input1.clone(), input2.clone(), input3.clone()],
438+
outputs: vec![output.clone()],
439+
};
440+
let psbt = selection.create_psbt(PsbtParams {
441+
fallback_locktime: absolute::LockTime::from_consensus(current_height),
442+
enable_anti_fee_sniping: true,
443+
fallback_sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
444+
..Default::default()
445+
})?;
446+
let tx = psbt.unsigned_tx;
447+
448+
if tx.lock_time > absolute::LockTime::ZERO {
449+
used_locktime = true;
450+
} else {
451+
used_sequence = true;
452+
// One of the inputs should have modified sequence
453+
let has_modified_sequence = tx.input.iter().any(|txin| {
454+
dbg!(&txin.sequence.to_consensus_u32());
455+
txin.sequence.to_consensus_u32() > 0 && txin.sequence.to_consensus_u32() < 65535
456+
});
457+
assert!(has_modified_sequence);
458+
}
459+
}
460+
461+
assert!(used_locktime || used_sequence);
462+
Ok(())
463+
}
464+
465+
#[test]
466+
fn test_anti_fee_sniping_unsupported_version_error() {
467+
let (input, current_height) = setup_test_input(800_000).unwrap();
468+
let inputs = vec![input];
469+
470+
let mut tx = Transaction {
471+
version: Version::ONE,
472+
lock_time: LockTime::from_height(current_height.to_consensus_u32()).unwrap(),
473+
input: vec![TxIn {
474+
previous_output: inputs[0].prev_outpoint(),
475+
..Default::default()
476+
}],
477+
output: vec![],
478+
};
479+
480+
let current_height = Height::from_consensus(800_050).unwrap();
481+
let result = apply_anti_fee_sniping(&mut tx, &inputs, current_height, true);
482+
483+
assert!(
484+
matches!(result, Err(CreatePsbtError::UnsupportedVersion(_))),
485+
"should return UnsupportedVersion error for version < 2"
486+
);
487+
}
488+
}

src/signer.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
use alloc::collections::BTreeMap;
12
use alloc::string::ToString;
23
use alloc::vec::Vec;
3-
use std::collections::BTreeMap;
44

55
use bitcoin::{
66
psbt::{GetKey, GetKeyError, KeyRequest},

src/utils.rs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ use crate::{CreatePsbtError, Input};
22
use alloc::vec::Vec;
33
use miniscript::bitcoin::{
44
absolute::{self, LockTime},
5-
secp256k1::rand::Rng,
65
transaction::Version,
76
Sequence, Transaction,
87
};
@@ -35,12 +34,12 @@ use rand_core::{OsRng, RngCore};
3534
/// - Transaction version is less than 2 [`CreatePsbtError::UnsupportedVersion`]
3635
///
3736
/// # Example
38-
/// ```
39-
/// # use bdk_tx::{apply_anti_fee_sniping, Input};
37+
/// ```ignore
38+
/// # use bdk_tx::Input;
4039
/// # use miniscript::bitcoin::{
4140
/// # absolute::{Height, LockTime}, transaction::Version, Transaction, TxIn, TxOut, ScriptBuf, Amount
4241
/// # };
43-
///
42+
///
4443
/// fn main() -> Result<(), Box<dyn std::error::Error>> {
4544
/// let inputs: Vec<Input> = vec![];
4645
/// let mut tx = Transaction {
@@ -141,7 +140,7 @@ pub fn apply_anti_fee_sniping(
141140

142141
/// Returns true with probability 1/n.
143142
fn random_probability(rng: &mut OsRng, probability: u32) -> bool {
144-
rng.gen_range(0..probability) == 0
143+
random_range(rng, probability) == 0
145144
}
146145

147146
// Return a random value in the range [0, end].

0 commit comments

Comments
 (0)