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