11use alloc:: vec:: Vec ;
2+ use bdk_chain:: TxGraph ;
23use core:: fmt;
4+ use rand_core:: { OsRng , RngCore } ;
35
46use bitcoin:: {
57 absolute, transaction, Amount , FeeRate , OutPoint , Psbt , ScriptBuf , Sequence , SignedAmount ,
6- Transaction , TxIn , TxOut , Weight ,
8+ Transaction , TxIn , TxOut , Txid , Weight ,
79} ;
810use miniscript:: { bitcoin, plan:: Plan } ;
911
10- use crate :: { DataProvider , Finalizer , PsbtUpdater , UpdatePsbtError } ;
12+ use crate :: { is_taproot , DataProvider , Finalizer , PsbtUpdater , UpdatePsbtError } ;
1113
1214/// A UTXO with spend plan
1315#[ derive( Debug , Clone ) ]
@@ -76,6 +78,7 @@ pub struct Builder {
7678
7779 sequence : Option < Sequence > ,
7880 check_fee : CheckFee ,
81+ current_height : Option < absolute:: LockTime > ,
7982}
8083
8184impl Builder {
@@ -486,6 +489,159 @@ impl Builder {
486489 . sum :: < Amount > ( )
487490 . checked_sub ( tx. output . iter ( ) . map ( |txo| txo. value ) . sum :: < Amount > ( ) )
488491 }
492+
493+ /// Apply anti fee-sniping protection according to BIP326
494+ ///
495+ /// This method enhances privacy for off-chain protocols by setting either `nLockTime` or
496+ /// `nSequence` on taproot transactions, creating an anonymity set with PTLC-based settlements.
497+ /// With 50% probability, it uses `nLockTime` to restrict mining to the next block; otherwise,
498+ /// it sets `nSequence` to mimic a relative locktime. A 10% chance adjusts values further back.
499+ ///
500+ /// * `provider` - Provides transaction and descriptor data.
501+ /// * `tx_graph` - Tracks blockchain state for confirmation counts.
502+ /// * `enable_rbf` - Whether to signal Replace-By-Fee (RBF).
503+ pub fn apply_anti_fee_sniping < D > (
504+ & mut self ,
505+ provider : & mut D ,
506+ tx_graph : TxGraph ,
507+ enable_rbf : bool ,
508+ ) -> Result < & mut Self , Error >
509+ where
510+ D : DataProvider ,
511+ {
512+ const USE_NLOCKTIME_PROBABILITY : f64 = 0.5 ;
513+ const FURTHER_BACK_PROBABILITY : f64 = 0.1 ;
514+ const MAX_RANDOM_OFFSET : u32 = 99 ;
515+ const MAX_SEQUENCE_VALUE : u32 = 65535 ;
516+ const MIN_SEQUENCE_VALUE : u32 = 1 ;
517+ const SEQUENCE_NO_RBF : u32 = 0xFFFFFFFE ;
518+
519+ if self . version . is_none ( ) {
520+ self . version ( transaction:: Version :: TWO ) ;
521+ }
522+
523+ let current_height = self
524+ . current_height
525+ . and_then ( |h| h. is_block_height ( ) . then ( || h. to_consensus_u32 ( ) ) )
526+ . ok_or ( Error :: InvalidBlockHeight {
527+ height : self . current_height . unwrap ( ) ,
528+ } ) ?;
529+
530+ // Collect info about UTXOs being spent
531+ let mut utxos_info = Vec :: new ( ) ;
532+ let mut all_taproot = true ;
533+ let mut any_unconfirmed = false ;
534+ let mut any_high_confirmation = false ;
535+
536+ for utxo in & self . utxos {
537+ let desc =
538+ provider
539+ . get_descriptor_for_txout ( & utxo. txout )
540+ . ok_or ( Error :: MissingDescriptor {
541+ outpoint : utxo. outpoint ,
542+ } ) ?;
543+ let is_taproot = is_taproot ( desc. desc_type ( ) ) ;
544+ if !is_taproot {
545+ all_taproot = false ;
546+ }
547+
548+ let highest_anchor = tx_graph
549+ . get_tx_node ( utxo. outpoint . txid )
550+ . ok_or ( Error :: MissingTxNode {
551+ txid : utxo. outpoint . txid ,
552+ } ) ?
553+ . anchors
554+ . iter ( )
555+ . max_by_key ( |anchor| anchor. block_id . height ) ;
556+
557+ match highest_anchor {
558+ Some ( anchor) => {
559+ let height = anchor. block_id . height ;
560+
561+ if height > current_height {
562+ return Err ( Error :: InvalidBlockchainState {
563+ current_height,
564+ anchor_height : height,
565+ } ) ;
566+ }
567+
568+ let confirmation = current_height - height + 1 ;
569+
570+ if confirmation > MAX_SEQUENCE_VALUE {
571+ any_high_confirmation = true ;
572+ }
573+
574+ utxos_info. push ( ( utxo. outpoint , confirmation) ) ;
575+ }
576+ None => {
577+ any_unconfirmed = true ;
578+ utxos_info. push ( ( utxo. outpoint , 0 ) ) ;
579+ }
580+ } ;
581+ }
582+
583+ if utxos_info. is_empty ( ) {
584+ return Err ( Error :: NoValidUtxos ) ;
585+ }
586+
587+ // Determine if we should use nLockTime or nSequence
588+ let use_locktime = !enable_rbf
589+ || any_high_confirmation
590+ || !all_taproot
591+ || any_unconfirmed
592+ || random_probability ( USE_NLOCKTIME_PROBABILITY ) ;
593+
594+ if use_locktime {
595+ let mut locktime = current_height;
596+
597+ if random_probability ( FURTHER_BACK_PROBABILITY ) {
598+ let random_offset = random_range ( 0 , MAX_RANDOM_OFFSET ) ;
599+ locktime = locktime. saturating_sub ( random_offset) ;
600+ }
601+
602+ self . locktime (
603+ absolute:: LockTime :: from_height ( locktime) . expect ( "height within valid range" ) ,
604+ ) ;
605+
606+ if enable_rbf {
607+ self . sequence ( Sequence :: ENABLE_RBF_NO_LOCKTIME ) ;
608+ } else {
609+ self . sequence ( Sequence :: from_consensus ( SEQUENCE_NO_RBF ) ) ;
610+ }
611+ } else {
612+ let input_index = random_range ( 0 , self . utxos . len ( ) as u32 ) as usize ;
613+ let ( _, confirmation) = utxos_info[ input_index] ;
614+
615+ let mut sequence_value = confirmation;
616+
617+ if random_probability ( FURTHER_BACK_PROBABILITY ) {
618+ let random_offset = random_range ( 0 , MAX_RANDOM_OFFSET ) ;
619+ sequence_value = sequence_value
620+ . saturating_sub ( random_offset)
621+ . max ( MIN_SEQUENCE_VALUE ) ;
622+ }
623+
624+ self . sequence ( Sequence :: from_consensus ( sequence_value) ) ;
625+ self . locktime ( absolute:: LockTime :: ZERO ) ;
626+ }
627+
628+ Ok ( self )
629+ }
630+ }
631+
632+ // Helper functions for randomization
633+ fn random_probability ( probability : f64 ) -> bool {
634+ let mut rng = OsRng ;
635+ let rand_val = rng. next_u32 ( ) % 100 ;
636+ ( rand_val as f64 / 100.0 ) < probability
637+ }
638+
639+ fn random_range ( min : u32 , max : u32 ) -> u32 {
640+ if min >= max {
641+ return min;
642+ }
643+ let mut rng = OsRng ;
644+ min + ( rng. next_u32 ( ) % ( max - min) )
489645}
490646
491647/// Checks that the given `sequence` is compatible with `csv`. To be compatible, both
@@ -556,6 +712,31 @@ pub enum Error {
556712 TooManyOpReturn ,
557713 /// error when updating a PSBT
558714 Update ( UpdatePsbtError ) ,
715+ /// Indicates an invalid blockchain state where anchor height exceeds current height
716+ InvalidBlockchainState {
717+ /// block height
718+ current_height : u32 ,
719+ /// anchor height
720+ anchor_height : u32 ,
721+ } ,
722+
723+ /// Error when the current height is not valid
724+ InvalidBlockHeight {
725+ /// the block height
726+ height : absolute:: LockTime ,
727+ } ,
728+ /// Error when the descriptor is missing
729+ MissingDescriptor {
730+ /// the outpoint
731+ outpoint : OutPoint ,
732+ } ,
733+ /// Error when the transaction node is missing
734+ MissingTxNode {
735+ /// the transaction id
736+ txid : Txid ,
737+ } ,
738+ /// Error when no valid UTXOs are available for anti-fee-sniping
739+ NoValidUtxos ,
559740}
560741
561742impl fmt:: Display for Error {
@@ -579,6 +760,26 @@ impl fmt::Display for Error {
579760 } => write ! ( f, "{requested} is incompatible with required {required}" ) ,
580761 Self :: TooManyOpReturn => write ! ( f, "non-standard: only 1 OP_RETURN output permitted" ) ,
581762 Self :: Update ( e) => e. fmt ( f) ,
763+ Self :: InvalidBlockchainState {
764+ current_height,
765+ anchor_height,
766+ } => write ! (
767+ f,
768+ "Invalid blockchain state: anchor height {} exceeds current height {}" ,
769+ anchor_height, current_height
770+ ) ,
771+ Self :: InvalidBlockHeight { height } => {
772+ write ! ( f, "Current height is not valid: {}" , height)
773+ }
774+ Self :: MissingDescriptor { outpoint } => {
775+ write ! ( f, "{}" , outpoint)
776+ }
777+ Self :: MissingTxNode { txid } => {
778+ write ! ( f, "{}" , txid)
779+ }
780+ Self :: NoValidUtxos => {
781+ write ! ( f, "No valid UTXOs available for anti-fee-sniping" )
782+ }
582783 }
583784 }
584785}
@@ -1044,4 +1245,27 @@ mod test {
10441245 . iter( )
10451246 . all( |txo| txo. value. to_sat( ) == 500_000 ) ) ;
10461247 }
1248+
1249+ #[ test]
1250+ fn test_apply_anti_fee_sniping ( ) {
1251+ // Setup test environment
1252+ let mut graph = init_graph ( & get_single_sig_tr_xprv ( ) ) ;
1253+
1254+ let utxos = graph. planned_utxos ( ) ;
1255+ let mut builder = Builder :: new ( ) ;
1256+ let tx_graph = graph. graph . graph ( ) . clone ( ) ;
1257+
1258+ builder. add_inputs ( utxos. iter ( ) . take ( 1 ) . cloned ( ) ) ;
1259+
1260+ // Set current height and apply BIP326
1261+ builder. current_height = Some ( absolute:: LockTime :: from_height ( 120 ) . unwrap ( ) ) ;
1262+
1263+ builder
1264+ . apply_anti_fee_sniping ( & mut graph, tx_graph, true )
1265+ . unwrap ( ) ;
1266+
1267+ assert ! ( builder. locktime. is_some( ) ) ;
1268+ assert ! ( builder. locktime. unwrap( ) . is_block_height( ) ) ;
1269+ assert ! ( builder. sequence. is_some( ) ) ;
1270+ }
10471271}
0 commit comments