Skip to content

Commit ff19f53

Browse files
committed
test: add unit tests for anti-fee-sniping feature
1 parent 15468fa commit ff19f53

File tree

2 files changed

+204
-1
lines changed

2 files changed

+204
-1
lines changed

src/selection.rs

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

src/utils.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ use rand_core::{OsRng, RngCore};
4040
/// # use miniscript::bitcoin::{
4141
/// # absolute::{Height, LockTime}, transaction::Version, Transaction, TxIn, TxOut, ScriptBuf, Amount
4242
/// # };
43-
///
43+
///
4444
/// fn main() -> Result<(), Box<dyn std::error::Error>> {
4545
/// let inputs: Vec<Input> = vec![];
4646
/// let mut tx = Transaction {

0 commit comments

Comments
 (0)