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
6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ readme = "README.md"
[dependencies]
miniscript = { version = "12", default-features = false }
bdk_coin_select = "0.4.0"
rand = "0.9.2"

[dev-dependencies]
anyhow = "1"
bdk_tx = { path = "." }
bitcoin = { version = "0.32", features = ["rand-std"] }
bitcoin = { version = "0.32", default-features = false }
bdk_testenv = "0.13.0"
bdk_bitcoind_rpc = "0.20.0"
bdk_chain = { version = "0.23.0" }
Expand All @@ -32,3 +33,6 @@ name = "synopsis"
[[example]]
name = "common"
crate-type = ["lib"]

[[example]]
name = "anti_fee_sniping"
152 changes: 152 additions & 0 deletions examples/anti_fee_sniping.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#![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);
println!("Both approaches provide anti-fee-sniping protection:");
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: this isn't providing new information, I recommend to remove it


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