From 888a746ec1beae843a6337c91553b66b34997c49 Mon Sep 17 00:00:00 2001 From: aagbotemi Date: Tue, 25 Mar 2025 02:12:04 +0100 Subject: [PATCH 1/3] feat(wallet): enable random anti-fee sniping --- wallet/Cargo.toml | 2 +- wallet/src/wallet/tx_builder.rs | 369 ++++++++++++++++++++++++++++++++ 2 files changed, 370 insertions(+), 1 deletion(-) diff --git a/wallet/Cargo.toml b/wallet/Cargo.toml index 4d917c82..01d4dd1d 100644 --- a/wallet/Cargo.toml +++ b/wallet/Cargo.toml @@ -23,6 +23,7 @@ serde = { version = "^1.0", features = ["derive"] } serde_json = { version = "^1.0" } bdk_chain = { version = "0.21.1", features = [ "miniscript", "serde" ], default-features = false } bdk_file_store = { version = "0.18.1", optional = true } +rand = "^0.8" # Optional dependencies bip39 = { version = "2.0", optional = true } @@ -45,7 +46,6 @@ bdk_chain = { version = "0.21.1", features = ["rusqlite"] } bdk_wallet = { path = ".", features = ["rusqlite", "file_store", "test-utils"] } bdk_file_store = { version = "0.18.1" } anyhow = "1" -rand = "^0.8" [package.metadata.docs.rs] all-features = true diff --git a/wallet/src/wallet/tx_builder.rs b/wallet/src/wallet/tx_builder.rs index 7d693761..9977232f 100644 --- a/wallet/src/wallet/tx_builder.rs +++ b/wallet/src/wallet/tx_builder.rs @@ -37,7 +37,9 @@ //! ``` use alloc::{boxed::Box, string::String, vec::Vec}; +use bitcoin::absolute::LockTime; use core::fmt; +use rand::Rng; use alloc::sync::Arc; @@ -53,6 +55,7 @@ use super::coin_selection::CoinSelectionAlgorithm; use super::utils::shuffle_slice; use super::{CreateTxError, Wallet}; use crate::collections::{BTreeMap, HashMap, HashSet}; +use crate::descriptor::DescriptorMeta; use crate::{KeychainKind, LocalOutput, Utxo, WeightedUtxo}; /// A transaction builder @@ -191,6 +194,171 @@ impl<'a, Cs> TxBuilder<'a, Cs> { self } + /// anti-fee snipping + fn apply_anti_fee_sniping( + &mut self, + rng: &mut T, + ) -> Result<&mut Self, AntiFeeSnipingError> { + const USE_NLOCKTIME_PROBABILITY: f64 = 0.5; + const FURTHER_BACK_PROBABILITY: f64 = 0.1; + const MAX_RANDOM_OFFSET: u32 = 100; + const MAX_SEQUENCE_VALUE: u32 = 65535; + const MIN_SEQUENCE_VALUE: u32 = 1; + + if self.params.version.is_none() { + self.params.version = Some(Version(2)); + } + + let current_height = self + .params + .current_height + .and_then(|h| h.is_block_height().then(|| h.to_consensus_u32())) + .ok_or(AntiFeeSnipingError::InvalidBlockHeight { + height: self.params.current_height.unwrap(), + })?; + + let mut utxos_info = Vec::new(); + let mut can_use_nsequence = true; + + for (outpoint, weighted_utxo) in &self.params.utxos { + match &weighted_utxo.utxo { + Utxo::Local(output) => { + // Get transaction details + let wallet_tx = self.wallet.get_tx(output.outpoint.txid).ok_or({ + AntiFeeSnipingError::TransactionNotFound { + txid: output.outpoint.txid, + } + })?; + + let highest_anchor = wallet_tx + .tx_node + .anchors + .iter() + .max_by_key(|anchor| anchor.block_id.height); + + let confirmation_height = match highest_anchor { + Some(anchor) => { + let height = anchor.block_id.height; + if height > current_height { + return Err(AntiFeeSnipingError::InvalidBlockchainState { + current_height, + anchor_height: height, + }); + } + current_height - height + } + None => { + can_use_nsequence = false; + continue; + } + }; + + let is_taproot = self.wallet.public_descriptor(output.keychain).is_taproot(); + utxos_info.push((*outpoint, confirmation_height)); + + if confirmation_height > MAX_SEQUENCE_VALUE || !is_taproot { + can_use_nsequence = false; + continue; + } + } + Utxo::Foreign { .. } => { + // getting the confirmation count of foreign UTXO isn't reliable, + // so we set it to zero, we use locktime and not sequence + utxos_info.push((*outpoint, 0)); + can_use_nsequence = false; + } + } + } + + // If we have no valid UTXOs, we can't apply anti-fee-sniping + if utxos_info.is_empty() { + return Err(AntiFeeSnipingError::NoValidUtxos); + } + + // Get RBF setting - if user set sequence manually, preserve that + let is_rbf = self.params.sequence.map_or(true, |seq| seq.is_rbf()); + + // Determine if we should use nLockTime or nSequence + let use_nlocktime = !can_use_nsequence || rng.gen_bool(USE_NLOCKTIME_PROBABILITY); + + if use_nlocktime { + let mut locktime_height = current_height; + + if rng.gen_bool(FURTHER_BACK_PROBABILITY) { + let random_offset = rng.gen_range(0..MAX_RANDOM_OFFSET); + locktime_height = locktime_height.saturating_sub(random_offset); + } + + let lock_time = LockTime::from_height(locktime_height).map_err(|_| { + AntiFeeSnipingError::InvalidLocktime { + height: locktime_height, + } + })?; + + self.nlocktime(lock_time); + + if self.params.sequence.is_none() { + let seq = if is_rbf { + Sequence::ENABLE_RBF_NO_LOCKTIME + } else { + Sequence::from_consensus(0xFFFFFFFE) + }; + self.params.sequence = Some(seq); + } + } else { + self.params.locktime = Some(LockTime::ZERO); + + let selected_idx = rng.gen_range(0..utxos_info.len()); + let (_, confirmations) = utxos_info[selected_idx]; + + let mut sequence_value = confirmations; + + if rng.gen_bool(FURTHER_BACK_PROBABILITY) { + let random_offset = rng.gen_range(0..MAX_RANDOM_OFFSET); + sequence_value = sequence_value.saturating_sub(random_offset); + sequence_value = sequence_value.max(MIN_SEQUENCE_VALUE); + } + + let sequence = Sequence::from_height(sequence_value.try_into().unwrap()); + self.params.sequence = Some(sequence); + } + + Ok(self) + } + + /// Enables anti-fee-sniping protection as specified in BIP326. + /// + /// This will set either nLockTime or nSequence randomly to protect against fee sniping attacks. + /// For this to work properly, you should also call `current_height()` with the current + /// blockchain height. + /// + /// # Examples + /// + /// ``` + /// # use bdk_wallet::*; + /// # use bitcoin::*; + /// + /// # let mut wallet = doctest_wallet!(); + /// # let mut rng = rand::thread_rng(); + /// let mut tx_builder = wallet.build_tx(); + /// tx_builder + /// .current_height(800000) + /// .enable_anti_fee_sniping() + /// // ... other settings + /// ; + /// ``` + pub fn enable_anti_fee_sniping(&mut self) -> Result<&mut Self, AntiFeeSnipingError> { + self.enable_anti_fee_sniping_with_rng(&mut rand::thread_rng()) + } + + /// Enables anti-fee-sniping protection with a custom RNG. + pub fn enable_anti_fee_sniping_with_rng( + &mut self, + rng: &mut T, + ) -> Result<&mut Self, AntiFeeSnipingError> { + self.apply_anti_fee_sniping(rng) + } + /// Set the policy path to use while creating the transaction for a given keychain. /// /// This method accepts a map where the key is the policy node id (see @@ -842,6 +1010,69 @@ impl ChangeSpendPolicy { } } +/// Custom error enum for BIP326 anti-fee-sniping operations +#[derive(Debug)] +pub enum AntiFeeSnipingError { + /// Indicates an invalid blockchain state where anchor height exceeds current height + InvalidBlockchainState { + /// block height + current_height: u32, + /// anchor height + anchor_height: u32, + }, + + /// Error when transaction cannot be found in wallet + TransactionNotFound { + /// transaction ID + txid: bitcoin::Txid, + }, + + /// Error when no valid UTXOs are available for anti-fee-sniping + NoValidUtxos, + + /// Error when creating locktime fails + InvalidLocktime { + /// block height that cause locktime creation to fail + height: u32, + }, + + /// Error when the current height is not valid + InvalidBlockHeight { + /// the block height + height: LockTime, + }, +} + +impl fmt::Display for AntiFeeSnipingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AntiFeeSnipingError::InvalidBlockchainState { + current_height, + anchor_height, + } => write!( + f, + "Invalid blockchain state: anchor height {} exceeds current height {}", + anchor_height, current_height + ), + + AntiFeeSnipingError::TransactionNotFound { txid } => { + write!(f, "Transaction not found in wallet: {}", txid) + } + + AntiFeeSnipingError::NoValidUtxos => { + write!(f, "No valid UTXOs available for anti-fee-sniping") + } + + AntiFeeSnipingError::InvalidLocktime { height } => { + write!(f, "Cannot create locktime for height: {}", height) + } + AntiFeeSnipingError::InvalidBlockHeight { height } => { + write!(f, "Current height is not valid: {}", height) + } + } + } +} + #[cfg(test)] mod test { const ORDERING_TEST_TX: &str = "0200000003c26f3eb7932f7acddc5ddd26602b77e7516079b03090a16e2c2f54\ @@ -1346,4 +1577,142 @@ mod test { } )); } + + #[test] + fn test_anti_fee_sniping() { + use crate::test_utils::get_funded_wallet_wpkh; + + let (mut wallet, _) = get_funded_wallet_wpkh(); + let utxo = wallet.list_unspent().next().unwrap(); + + let satisfaction_weight_utxo = wallet + .public_descriptor(utxo.keychain) + .max_weight_to_satisfy() + .unwrap(); + + let mut tx_builder = wallet.build_tx(); + + tx_builder.params.utxos.insert( + utxo.outpoint, + WeightedUtxo { + satisfaction_weight: satisfaction_weight_utxo, + utxo: Utxo::Local(utxo.clone()), + }, + ); + + assert!(tx_builder.params.locktime.is_none()); + + let _ = tx_builder.current_height(8000).enable_anti_fee_sniping(); + + let locktime = tx_builder.params.locktime; + let sequence = tx_builder.params.sequence; + assert!(locktime.is_some()); + assert!(sequence.is_some()); + + let lower_bound = LockTime::from_height(7900).unwrap(); + let upper_bound = LockTime::from_height(8000).unwrap(); + assert!((lower_bound..=upper_bound).contains(&locktime.unwrap())); + } + + #[test] + fn test_anti_fee_sniping_with_foreign_utxo() { + use crate::test_utils::get_funded_wallet_wpkh; + + let (mut wallet, txid) = get_funded_wallet_wpkh(); + + let utxo = wallet.list_unspent().next().unwrap(); + let tx = wallet.get_tx(txid).unwrap().tx_node.tx.clone(); + + let satisfaction_weight = wallet + .public_descriptor(KeychainKind::External) + .max_weight_to_satisfy() + .unwrap(); + + let mut tx_builder = wallet.build_tx(); + + tx_builder + .add_foreign_utxo( + utxo.outpoint, + psbt::Input { + non_witness_utxo: Some(tx.as_ref().clone()), + ..Default::default() + }, + satisfaction_weight, + ) + .unwrap(); + + assert!(tx_builder.params.locktime.is_none()); + + let _ = tx_builder.current_height(8000).enable_anti_fee_sniping(); + + let locktime = tx_builder.params.locktime; + let sequence = tx_builder.params.sequence; + assert!(locktime.is_some()); + assert!(sequence.is_some()); + + let lower_bound = LockTime::from_height(7900).unwrap(); + let upper_bound = LockTime::from_height(8000).unwrap(); + assert!((lower_bound..=upper_bound).contains(&locktime.unwrap())); + } + + #[test] + fn test_anti_fee_sniping_with_taproot() { + use crate::test_utils::{get_funded_wallet_single, get_test_tr_single_sig}; + + let (mut wallet, _) = get_funded_wallet_single(get_test_tr_single_sig()); + + let utxo = wallet.list_unspent().next().unwrap(); + + let satisfaction_weight_utxo = wallet + .public_descriptor(utxo.keychain) + .max_weight_to_satisfy() + .unwrap(); + + let mut tx_builder = wallet.build_tx(); + + tx_builder.params.utxos.insert( + utxo.outpoint, + WeightedUtxo { + satisfaction_weight: satisfaction_weight_utxo, + utxo: Utxo::Local(utxo.clone()), + }, + ); + + assert!(tx_builder.params.locktime.is_none()); + + let block_height = 5000; + let _ = tx_builder + .current_height(block_height) + .enable_anti_fee_sniping(); + + let locktime = tx_builder.params.locktime; + let sequence = tx_builder.params.sequence; + assert!(locktime.is_some()); + assert!(sequence.is_some()); + + let lower_bound = LockTime::from_height(4900).unwrap(); + let upper_bound = LockTime::from_height(5000).unwrap(); + if locktime.unwrap() > absolute::LockTime::from_height(0).unwrap() || block_height > 65535 { + assert!((lower_bound..=upper_bound).contains(&locktime.unwrap())); + } else { + assert_eq!( + locktime.unwrap(), + absolute::LockTime::from_height(0).unwrap() + ); + } + } + + #[test] + fn test_anti_fee_sniping_empty_utxo() { + use crate::test_utils::{get_funded_wallet_single, get_test_tr_single_sig}; + + let (mut wallet, _) = get_funded_wallet_single(get_test_tr_single_sig()); + let mut tx_builder = wallet.build_tx(); + + let _ = tx_builder.current_height(7000).enable_anti_fee_sniping(); + + assert!(tx_builder.params.utxos.is_empty()); + assert!(tx_builder.params.locktime.is_none()); + assert!(tx_builder.params.sequence.is_none()); + } } From d808130d0321b8a9cbee51716a4ea2995e0450dc Mon Sep 17 00:00:00 2001 From: aagbotemi Date: Wed, 2 Apr 2025 01:37:10 +0100 Subject: [PATCH 2/3] fix(wallet): trait from rand_core used for anti-fee sniping with randomization --- wallet/Cargo.toml | 2 +- wallet/src/wallet/tx_builder.rs | 28 +++++++++++++++------------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/wallet/Cargo.toml b/wallet/Cargo.toml index 01d4dd1d..4d917c82 100644 --- a/wallet/Cargo.toml +++ b/wallet/Cargo.toml @@ -23,7 +23,6 @@ serde = { version = "^1.0", features = ["derive"] } serde_json = { version = "^1.0" } bdk_chain = { version = "0.21.1", features = [ "miniscript", "serde" ], default-features = false } bdk_file_store = { version = "0.18.1", optional = true } -rand = "^0.8" # Optional dependencies bip39 = { version = "2.0", optional = true } @@ -46,6 +45,7 @@ bdk_chain = { version = "0.21.1", features = ["rusqlite"] } bdk_wallet = { path = ".", features = ["rusqlite", "file_store", "test-utils"] } bdk_file_store = { version = "0.18.1" } anyhow = "1" +rand = "^0.8" [package.metadata.docs.rs] all-features = true diff --git a/wallet/src/wallet/tx_builder.rs b/wallet/src/wallet/tx_builder.rs index 9977232f..a734f543 100644 --- a/wallet/src/wallet/tx_builder.rs +++ b/wallet/src/wallet/tx_builder.rs @@ -39,7 +39,6 @@ use alloc::{boxed::Box, string::String, vec::Vec}; use bitcoin::absolute::LockTime; use core::fmt; -use rand::Rng; use alloc::sync::Arc; @@ -49,7 +48,7 @@ use bitcoin::{ absolute, transaction::Version, Amount, FeeRate, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Weight, }; -use rand_core::RngCore; +use rand_core::{OsRng, RngCore}; use super::coin_selection::CoinSelectionAlgorithm; use super::utils::shuffle_slice; @@ -195,9 +194,9 @@ impl<'a, Cs> TxBuilder<'a, Cs> { } /// anti-fee snipping - fn apply_anti_fee_sniping( + fn apply_anti_fee_sniping( &mut self, - rng: &mut T, + rng: &mut R, ) -> Result<&mut Self, AntiFeeSnipingError> { const USE_NLOCKTIME_PROBABILITY: f64 = 0.5; const FURTHER_BACK_PROBABILITY: f64 = 0.1; @@ -279,13 +278,15 @@ impl<'a, Cs> TxBuilder<'a, Cs> { let is_rbf = self.params.sequence.map_or(true, |seq| seq.is_rbf()); // Determine if we should use nLockTime or nSequence - let use_nlocktime = !can_use_nsequence || rng.gen_bool(USE_NLOCKTIME_PROBABILITY); + let use_nlocktime = !can_use_nsequence + || (rng.next_u32() as f64 / u32::MAX as f64) < USE_NLOCKTIME_PROBABILITY; if use_nlocktime { let mut locktime_height = current_height; - if rng.gen_bool(FURTHER_BACK_PROBABILITY) { - let random_offset = rng.gen_range(0..MAX_RANDOM_OFFSET); + if (rng.next_u32() as f64 / u32::MAX as f64) < FURTHER_BACK_PROBABILITY { + let random_offset = rng.next_u32() % MAX_RANDOM_OFFSET; + // let random_offset = rng.gen_range(0..MAX_RANDOM_OFFSET); locktime_height = locktime_height.saturating_sub(random_offset); } @@ -308,13 +309,13 @@ impl<'a, Cs> TxBuilder<'a, Cs> { } else { self.params.locktime = Some(LockTime::ZERO); - let selected_idx = rng.gen_range(0..utxos_info.len()); + let selected_idx = (rng.next_u32() % utxos_info.len() as u32) as usize; let (_, confirmations) = utxos_info[selected_idx]; let mut sequence_value = confirmations; - if rng.gen_bool(FURTHER_BACK_PROBABILITY) { - let random_offset = rng.gen_range(0..MAX_RANDOM_OFFSET); + if (rng.next_u32() as f64 / u32::MAX as f64) < FURTHER_BACK_PROBABILITY { + let random_offset = rng.next_u32() % MAX_RANDOM_OFFSET; sequence_value = sequence_value.saturating_sub(random_offset); sequence_value = sequence_value.max(MIN_SEQUENCE_VALUE); } @@ -348,13 +349,14 @@ impl<'a, Cs> TxBuilder<'a, Cs> { /// ; /// ``` pub fn enable_anti_fee_sniping(&mut self) -> Result<&mut Self, AntiFeeSnipingError> { - self.enable_anti_fee_sniping_with_rng(&mut rand::thread_rng()) + let mut os_rng = OsRng; + self.enable_anti_fee_sniping_with_rng(&mut os_rng) } /// Enables anti-fee-sniping protection with a custom RNG. - pub fn enable_anti_fee_sniping_with_rng( + pub fn enable_anti_fee_sniping_with_rng( &mut self, - rng: &mut T, + rng: &mut R, ) -> Result<&mut Self, AntiFeeSnipingError> { self.apply_anti_fee_sniping(rng) } From ebf751b92ec8ae5ce68d36866155b226a8a3c795 Mon Sep 17 00:00:00 2001 From: aagbotemi Date: Sat, 26 Apr 2025 21:20:47 +0100 Subject: [PATCH 3/3] fixing the CI build error --- wallet/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wallet/Cargo.toml b/wallet/Cargo.toml index 4d917c82..3c25a143 100644 --- a/wallet/Cargo.toml +++ b/wallet/Cargo.toml @@ -16,7 +16,7 @@ rust-version = "1.63" workspace = true [dependencies] -rand_core = { version = "0.6.0" } +rand_core = { version = "0.6.0", default-features = false, features = ["getrandom"] } miniscript = { version = "12.3.1", features = [ "serde" ], default-features = false } bitcoin = { version = "0.32.4", features = [ "serde", "base64" ], default-features = false } serde = { version = "^1.0", features = ["derive"] }