@@ -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+ }
0 commit comments