Skip to content

Commit de5dda7

Browse files
committed
feat: enable random anti-fee sniping
1 parent 686bdb6 commit de5dda7

File tree

3 files changed

+229
-4
lines changed

3 files changed

+229
-4
lines changed

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ readme = "README.md"
1010

1111
[dependencies]
1212
miniscript = { version = "12", default-features = false }
13+
rand_core = { version = "0.6.0", features = ["getrandom"] }
14+
bdk_chain = { version = "0.21" }
1315

1416
[dev-dependencies]
1517
anyhow = "1"
16-
bdk_chain = { version = "0.21" }
1718
bdk_tx = { path = "." }
1819
bitcoin = { version = "0.32", features = ["rand-std"] }
1920

src/builder.rs

Lines changed: 226 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
use alloc::vec::Vec;
2+
use bdk_chain::TxGraph;
23
use core::fmt;
4+
use rand_core::{OsRng, RngCore};
35

46
use bitcoin::{
57
absolute, transaction, Amount, FeeRate, OutPoint, Psbt, ScriptBuf, Sequence, SignedAmount,
6-
Transaction, TxIn, TxOut, Weight,
8+
Transaction, TxIn, TxOut, Txid, Weight,
79
};
810
use 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

8184
impl 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

561742
impl 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
}

src/updater.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ fn is_witness(desc_ty: DescriptorType) -> bool {
201201
}
202202

203203
/// Whether this descriptor type is `Tr`
204-
fn is_taproot(desc_ty: DescriptorType) -> bool {
204+
pub fn is_taproot(desc_ty: DescriptorType) -> bool {
205205
matches!(desc_ty, DescriptorType::Tr)
206206
}
207207

0 commit comments

Comments
 (0)