Skip to content

Commit a46bf9b

Browse files
Discourage fee sniping with nLockTime
By default bdk sets the transaction's nLockTime to current_height to discourage fee sniping. current_height can be provided by the user through TxParams; if the user didn't provide it, we use the last sync height, or 0 if we never synced. Fixes #533
1 parent 3269923 commit a46bf9b

File tree

3 files changed

+83
-2
lines changed

3 files changed

+83
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
- Creation of Taproot PSBTs (BIP-371)
1919
- Signing Taproot PSBTs (key spend and script spend)
2020
- Support for `tr()` descriptors in the `descriptor!()` macro
21+
- Transaction nlocktime defaults to `current_height`, which can be specified through `TxParams` - if not specified, the last sync height is used
2122

2223
## [v0.18.0] - [v0.17.0]
2324

src/wallet/mod.rs

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -619,9 +619,25 @@ where
619619
_ => 1,
620620
};
621621

622+
let last_sync_height = self
623+
.database()
624+
.get_sync_time()?
625+
.map(|sync_time| sync_time.block_time.height);
626+
627+
// If they didn't tell us the current height, we assume it's the latest sync height.
628+
let current_height = params.current_height.or(last_sync_height);
629+
622630
let lock_time = match params.locktime {
623-
// No nLockTime, default to 0
624-
None => requirements.timelock.unwrap_or(0),
631+
// When no nLockTime is specified, we try to prevent fee sniping, if possible
632+
None => {
633+
// Fee sniping can be partially prevented by setting the timelock
634+
// to current_height. If we don't know the current_height,
635+
// we default to 0.
636+
let fee_sniping_height = current_height.unwrap_or(0);
637+
// We choose the bigger between the required height and the fee sniping
638+
// height
639+
std::cmp::max(requirements.timelock.unwrap_or(0), fee_sniping_height)
640+
}
625641
// Specific nLockTime required and we have no constraints, so just set to that value
626642
Some(x) if requirements.timelock.is_none() => x,
627643
// Specific nLockTime required and it's compatible with the constraints
@@ -1980,9 +1996,59 @@ pub(crate) mod test {
19801996
builder.add_recipient(addr.script_pubkey(), 25_000);
19811997
let (psbt, _) = builder.finish().unwrap();
19821998

1999+
// Since we never synced the wallet we don't have a last_sync_height
2000+
// we could use to try to prevent fee sniping. We default to 0.
19832001
assert_eq!(psbt.unsigned_tx.lock_time, 0);
19842002
}
19852003

2004+
#[test]
2005+
fn test_create_tx_fee_sniping_locktime_provided_height() {
2006+
let (wallet, _, _) = get_funded_wallet(get_test_wpkh());
2007+
let addr = wallet.get_address(New).unwrap();
2008+
let mut builder = wallet.build_tx();
2009+
builder.add_recipient(addr.script_pubkey(), 25_000);
2010+
let sync_time = SyncTime {
2011+
block_time: BlockTime {
2012+
height: 24,
2013+
timestamp: 0,
2014+
},
2015+
};
2016+
wallet
2017+
.database
2018+
.borrow_mut()
2019+
.set_sync_time(sync_time)
2020+
.unwrap();
2021+
let current_height = 25;
2022+
builder.set_current_height(current_height);
2023+
let (psbt, _) = builder.finish().unwrap();
2024+
2025+
// current_height will override the last sync height
2026+
assert_eq!(psbt.unsigned_tx.lock_time, current_height);
2027+
}
2028+
2029+
#[test]
2030+
fn test_create_tx_fee_sniping_locktime_last_sync() {
2031+
let (wallet, _, _) = get_funded_wallet(get_test_wpkh());
2032+
let addr = wallet.get_address(New).unwrap();
2033+
let mut builder = wallet.build_tx();
2034+
builder.add_recipient(addr.script_pubkey(), 25_000);
2035+
let sync_time = SyncTime {
2036+
block_time: BlockTime {
2037+
height: 25,
2038+
timestamp: 0,
2039+
},
2040+
};
2041+
wallet
2042+
.database
2043+
.borrow_mut()
2044+
.set_sync_time(sync_time.clone())
2045+
.unwrap();
2046+
let (psbt, _) = builder.finish().unwrap();
2047+
2048+
// If there's no current_height we're left with using the last sync height
2049+
assert_eq!(psbt.unsigned_tx.lock_time, sync_time.block_time.height);
2050+
}
2051+
19862052
#[test]
19872053
fn test_create_tx_default_locktime_cltv() {
19882054
let (wallet, _, _) = get_funded_wallet(get_test_single_sig_cltv());
@@ -2001,9 +2067,13 @@ pub(crate) mod test {
20012067
let mut builder = wallet.build_tx();
20022068
builder
20032069
.add_recipient(addr.script_pubkey(), 25_000)
2070+
.set_current_height(630_001)
20042071
.nlocktime(630_000);
20052072
let (psbt, _) = builder.finish().unwrap();
20062073

2074+
// When we explicitly specify a nlocktime
2075+
// we don't try any fee sniping prevention trick
2076+
// (we ignore the current_height)
20072077
assert_eq!(psbt.unsigned_tx.lock_time, 630_000);
20082078
}
20092079

src/wallet/tx_builder.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ pub(crate) struct TxParams {
147147
pub(crate) add_global_xpubs: bool,
148148
pub(crate) include_output_redeem_witness_script: bool,
149149
pub(crate) bumping_fee: Option<PreviousFee>,
150+
pub(crate) current_height: Option<u32>,
150151
}
151152

152153
#[derive(Clone, Copy, Debug)]
@@ -543,6 +544,15 @@ impl<'a, D: BatchDatabase, Cs: CoinSelectionAlgorithm<D>, Ctx: TxBuilderContext>
543544
self.params.rbf = Some(RbfValue::Value(nsequence));
544545
self
545546
}
547+
548+
/// Set the current blockchain height.
549+
///
550+
/// This will be used to set the nLockTime for preventing fee sniping. If the current height is
551+
/// not provided, the last sync height will be used instead.
552+
pub fn set_current_height(&mut self, height: u32) -> &mut Self {
553+
self.params.current_height = Some(height);
554+
self
555+
}
546556
}
547557

548558
impl<'a, D: BatchDatabase, Cs: CoinSelectionAlgorithm<D>> TxBuilder<'a, D, Cs, CreateTx> {

0 commit comments

Comments
 (0)