Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,29 @@ license = "MIT OR Apache-2.0"
readme = "README.md"

[dependencies]
miniscript = { version = "12", default-features = false }
miniscript = { version = "12.3.5", default-features = false }
bdk_coin_select = "0.4.0"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ValuedMammal what do you think about rand as additional dependency? I don't want to fall in the slippery slope of adding new dependencies with each feature, but I wouldn't implement anti-fee sniping without rand, so, is anti-fee sniping something bdk-tx should ship by default? Should we consider adding it as a feature dependency?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally we want to support no-std, so my question is how are we providing a source of randomness for no-std environments.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approach we took for bdk_wallet is to depend on rand_core in order to have RngCore trait and essentially split the interface based on whether std feature is enabled. If std, then we have access to rand via bitcoin::secp256k1::rand. If no-std then the user can pass in something that implements RngCore.

/// Create PSBT.
#[cfg(feature = "std")]
pub fn create_psbt(&self, params: PsbtParams) -> Result<bitcoin::Psbt, CreatePsbtError> {
    self.create_psbt_with_rng(params, &mut rand::thread_rng())
}

/// Create PSBT with `rng`.
pub fn create_psbt_with_rng(&self, params: PsbtParams, rng: &mut impl RngCore) -> Result<bitcoin::Psbt, CreatePsbtError> { ... }

The other option is to feature-gate the enable_anti_fee_sniping option entirely but that seems a bit less useful.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aagbotemi could you adapt your code here and the current state of the PR to the above solution? Using feature gates

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, will do that.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you are missing the std implementation using rand full features.

Copy link
Contributor

@nymius nymius Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good overall, not sure if any review comments are still unresolved.

@ValuedMammal I'm not sure we have fulfilled this requirement yet. Do you think we should roll a custom random range even if the user has std available?

rand_core = { version = "0.6.4", default-features = false }
rand = { version = "0.8", optional = true }

[dev-dependencies]
anyhow = "1"
bdk_tx = { path = "." }
bitcoin = { version = "0.32", features = ["rand-std"] }
bitcoin = { version = "0.32", default-features = false, features = ["rand-std"] }
bdk_testenv = "0.13.0"
bdk_bitcoind_rpc = "0.20.0"
bdk_chain = { version = "0.23.0" }

[features]
default = ["std"]
std = ["miniscript/std"]
std = ["miniscript/std", "rand/std"]

[[example]]
name = "synopsis"

[[example]]
name = "common"
crate-type = ["lib"]

[[example]]
name = "anti_fee_sniping"
151 changes: 151 additions & 0 deletions examples/anti_fee_sniping.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#![allow(dead_code)]
use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv};
use bdk_tx::{
filter_unspendable_now, group_by_spk, selection_algorithm_lowest_fee_bnb, Output, PsbtParams,
ScriptSource, SelectorParams,
};
use bitcoin::{absolute::LockTime, key::Secp256k1, Amount, FeeRate, Sequence};
use miniscript::Descriptor;

mod common;

use common::Wallet;

fn main() -> anyhow::Result<()> {
let secp = Secp256k1::new();
let (external, _) = Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[0])?;
let (internal, _) = Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[1])?;

let env = TestEnv::new()?;
let genesis_hash = env.genesis_hash()?;
env.mine_blocks(101, None)?;

let mut wallet = Wallet::new(genesis_hash, external, internal.clone())?;
wallet.sync(&env)?;

let addr = wallet.next_address().expect("must derive address");

let txid1 = env.send(&addr, Amount::ONE_BTC)?;
env.mine_blocks(1, None)?;
wallet.sync(&env)?;
println!("Received confirmed input: {}", txid1);

let txid2 = env.send(&addr, Amount::ONE_BTC)?;
env.mine_blocks(1, None)?;
wallet.sync(&env)?;
println!("Received confirmed input: {}", txid2);

println!("Balance (confirmed): {}", wallet.balance());

let (tip_height, tip_time) = wallet.tip_info(env.rpc_client())?;
println!("Current height: {}", tip_height);
let longterm_feerate = FeeRate::from_sat_per_vb_unchecked(1);

let recipient_addr = env
.rpc_client()
.get_new_address(None, None)?
.assume_checked();

// When anti-fee-sniping is enabled, the transaction will either use nLockTime or nSequence.
//
// Locktime approach is used when:
// - RBF is disabled, OR
// - Any input requires locktime (non-taproot, unconfirmed, or >65535 confirmations), OR
// - There are no taproot inputs, OR
// - Random 50/50 coin flip chose locktime
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make the example more dynamic I would set up all the conditions to make the example dependent of this 50/50 coin flip. Because I'm never hitting the sequence anti fee sniping branch below.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay. I have fixed it.

//
// Sequence approach is used otherwise:
// - Sets tx.lock_time to ZERO
// - Modifies one randomly selected taproot input's sequence
//
// Once the approach is selected, to reduce transaction fingerprinting,
// - For nLockTime: With 10% probability, subtract a random 0-99 block offset from current height
// - For nSequence: With 10% probability, subtract a random 0-99 block offset (minimum value of 1)
//
// Note: When locktime is used, all sequence values remain unchanged.

let mut locktime_count = 0;
let mut sequence_count = 0;

for _ in 0..10 {
let selection = wallet
.all_candidates()
.regroup(group_by_spk())
.filter(filter_unspendable_now(tip_height, tip_time))
.into_selection(
selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000),
SelectorParams::new(
FeeRate::from_sat_per_vb_unchecked(10),
vec![Output::with_script(
recipient_addr.script_pubkey(),
Amount::from_sat(50_000_000),
)],
ScriptSource::Descriptor(Box::new(internal.at_derivation_index(0)?)),
bdk_tx::ChangePolicyType::NoDustAndLeastWaste { longterm_feerate },
wallet.change_weight(),
),
)?;

let fallback_locktime: LockTime = LockTime::from_consensus(tip_height.to_consensus_u32());

let selection_inputs = selection.inputs.clone();

let psbt = selection.create_psbt(PsbtParams {
enable_anti_fee_sniping: true,
fallback_locktime,
fallback_sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
..Default::default()
})?;

let tx = psbt.unsigned_tx;

if tx.lock_time != LockTime::ZERO {
locktime_count += 1;
let locktime_value = tx.lock_time.to_consensus_u32();
let current_height = tip_height.to_consensus_u32();

let offset = current_height.saturating_sub(locktime_value);
if offset > 0 {
println!(
"nLockTime = {} (tip height: {}, offset: -{})",
locktime_value, current_height, offset
);
} else {
println!(
"nLockTime = {} (tip height: {}, no offset)",
locktime_value, current_height
);
}
} else {
sequence_count += 1;

for (i, inp) in tx.input.iter().enumerate() {
let sequence_value = inp.sequence.to_consensus_u32();

if (1..0xFFFFFFFD).contains(&sequence_value) {
let input_confirmations = selection_inputs[i].confirmations(tip_height);
let offset = input_confirmations.saturating_sub(sequence_value);

if offset > 0 {
println!(
"nSequence[{}] = {} (confirmations: {}, offset: -{})",
i, sequence_value, input_confirmations, offset
);
} else {
println!(
"nSequence[{}] = {} (confirmations: {}, no offset)",
i, sequence_value, input_confirmations
);
}

break;
}
}
}
}

println!("nLockTime approach used: {} times", locktime_count);
println!("nSequence approach used: {} times", sequence_count);

Ok(())
}
6 changes: 2 additions & 4 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
//! `bdk_tx`

// FIXME: try to remove clippy "allows"
#![allow(clippy::large_enum_variant)]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is unrelated to anti fee sniping, I would add it in a separated commit, explaining why these are not needed anymore (and why they were here in the first place).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clippy allows were added because some types contained large variants. During implementation, I boxed those types to resolve the clippy warnings, so it's no longer needed.

All commits have been squashed into one already. I'll explain this change in the PR comment and also update the commit message.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But they were introduced in a previous different PR, and the implications are general to all the repository, that's why I recommend to move them to a previous separated commit.

#![allow(clippy::result_large_err)]
#![warn(missing_docs)]
#![no_std]

Expand All @@ -21,6 +17,7 @@ mod rbf;
mod selection;
mod selector;
mod signer;
mod utils;

pub use canonical_unspents::*;
pub use finalizer::*;
Expand All @@ -34,6 +31,7 @@ pub use rbf::*;
pub use selection::*;
pub use selector::*;
pub use signer::*;
use utils::*;

#[cfg(feature = "std")]
pub(crate) mod collections {
Expand Down
Loading