From ca00370ca4437f68af9f79a0171d944dac13bfdc Mon Sep 17 00:00:00 2001 From: Cindy Date: Wed, 19 Aug 2026 07:43:59 -0300 Subject: [PATCH] Set anti-fee-sniping locktime (exact-tip) on bitcoin sends (#3385) * Set anti-fee-sniping locktime on bitcoin sends Current tip (exact-tip) via shared helper, wired into normal sends (path A) and payjoin/PSBT (path B). 0 when unsynced. Converges with the exact-tip cluster (payjoin-cli, ldk-node, Bull Bitcoin) and skips the ~10% backdate. * Update cw_bitcoin/lib/locktime.dart Co-authored-by: Konstantin Ullrich * Update cw_bitcoin/lib/locktime.dart Co-authored-by: Konstantin Ullrich * Update cw_bitcoin/lib/locktime.dart [skip ci] --------- Co-authored-by: Konstantin Ullrich Co-authored-by: Omar Hatem --- cw_bitcoin/lib/bitcoin_wallet.dart | 13 +++++++- cw_bitcoin/lib/electrum_wallet.dart | 14 +++++++++ cw_bitcoin/lib/locktime.dart | 20 ++++++++++++ cw_bitcoin/lib/psbt/transaction_builder.dart | 4 ++- cw_bitcoin/test/locktime_test.dart | 32 ++++++++++++++++++++ 5 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 cw_bitcoin/lib/locktime.dart create mode 100644 cw_bitcoin/test/locktime_test.dart diff --git a/cw_bitcoin/lib/bitcoin_wallet.dart b/cw_bitcoin/lib/bitcoin_wallet.dart index b8e53d11b..4f791aeb4 100644 --- a/cw_bitcoin/lib/bitcoin_wallet.dart +++ b/cw_bitcoin/lib/bitcoin_wallet.dart @@ -14,6 +14,7 @@ import 'package:cw_bitcoin/electrum_derivations.dart'; import 'package:cw_bitcoin/electrum_transaction_info.dart'; import 'package:cw_bitcoin/electrum_wallet.dart'; import 'package:cw_bitcoin/electrum_wallet_snapshot.dart'; +import 'package:cw_bitcoin/locktime.dart'; import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart'; import 'package:cw_bitcoin/lightning/lightning_wallet.dart'; import 'package:cw_bitcoin/hardware/bitcoin_ledger_service.dart'; @@ -31,6 +32,7 @@ import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/output_info.dart'; import 'package:cw_core/payjoin_session.dart'; import 'package:cw_core/pending_transaction.dart'; +import 'package:cw_core/sync_status.dart'; import 'package:cw_core/unspent_coin_type.dart'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/utils/print_verbose.dart'; @@ -447,8 +449,17 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { )); } + final locktime = antiFeeSnipingLocktime( + chainTip: await getCurrentChainTip(), + synced: syncStatus is SyncedSyncStatus, + ); + return PSBTTransactionBuild( - inputs: psbtReadyInputs, outputs: outputs, enableRBF: enableRBF, cwOutputs: cwOutputs) + inputs: psbtReadyInputs, + outputs: outputs, + enableRBF: enableRBF, + cwOutputs: cwOutputs, + locktime: locktime) .psbt; } diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index 857317a90..b69fec588 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -4,6 +4,7 @@ import 'dart:isolate'; import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:cw_bitcoin/lightning/lightning_wallet.dart'; +import 'package:cw_bitcoin/locktime.dart'; import 'package:cw_core/hardware/hardware_wallet_service.dart'; import 'package:cw_core/root_dir.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; @@ -448,6 +449,15 @@ abstract class ElectrumWalletBase return currentChainTip ?? 0; } + /// Anti-fee-sniping locktime (current tip, exact-tip), LE-encoded for + /// `BitcoinTransactionBuilder`. + Future> _antiFeeSnipingLocktime() async { + return locktimeToBytes(antiFeeSnipingLocktime( + chainTip: await getCurrentChainTip(), + synced: syncStatus is SyncedSyncStatus, + )); + } + @override BitcoinWalletKeys get keys { String? wif; @@ -1516,6 +1526,8 @@ abstract class ElectrumWalletBase }); } + final locktime = await _antiFeeSnipingLocktime(); + BasedBitcoinTransacationBuilder txb; if (network is BitcoinCashNetwork) { txb = ForkedTransactionBuilder( @@ -1540,6 +1552,7 @@ abstract class ElectrumWalletBase inputOrdering: BitcoinOrdering.shuffle, outputOrdering: BitcoinOrdering.shuffle, enableRBF: !estimatedTx.spendsUnconfirmedTX, + locktime: locktime, ); } @@ -2306,6 +2319,7 @@ abstract class ElectrumWalletBase inputOrdering: BitcoinOrdering.shuffle, outputOrdering: BitcoinOrdering.shuffle, enableRBF: true, + locktime: await _antiFeeSnipingLocktime(), ); final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) { diff --git a/cw_bitcoin/lib/locktime.dart b/cw_bitcoin/lib/locktime.dart new file mode 100644 index 000000000..639e6e1d0 --- /dev/null +++ b/cw_bitcoin/lib/locktime.dart @@ -0,0 +1,20 @@ +import "dart:typed_data"; + +/// Anti-fee-sniping locktime, set to the current chain tip (exact-tip). +/// +/// Matches the exact-tip cluster (payjoin-cli, ldk-node, Bull Bitcoin) and Core's +/// default for externally-constructed transactions, and takes Cake off its unique +/// `nLockTime = 0` fingerprint. It intentionally skips the ~10% backdate +/// Core/Electrum apply for delayed-broadcast privacy, which does not apply here. +/// Returns 0 when not synced or the tip is unknown, to avoid emitting a stale +/// height. +int antiFeeSnipingLocktime({ + required int chainTip, + required bool synced, +}) { + if (!synced || chainTip <= 0) return 0; + return chainTip; +} + +/// Little-endian 4-byte encoding for `BtcTransaction.locktime`. +List locktimeToBytes(int locktime) => (ByteData(4)..setUint32(0, locktime, Endian.little)).buffer.asUint8List(); diff --git a/cw_bitcoin/lib/psbt/transaction_builder.dart b/cw_bitcoin/lib/psbt/transaction_builder.dart index 7d2ead134..ab107bfeb 100644 --- a/cw_bitcoin/lib/psbt/transaction_builder.dart +++ b/cw_bitcoin/lib/psbt/transaction_builder.dart @@ -14,8 +14,10 @@ class PSBTTransactionBuild { {required List inputs, required List outputs, required List cwOutputs, - bool enableRBF = true}) { + bool enableRBF = true, + int locktime = 0}) { psbt.setGlobalTxVersion(2); + psbt.setGlobalFallbackLocktime(locktime); psbt.setGlobalInputCount(inputs.length); psbt.setGlobalOutputCount(outputs.length); diff --git a/cw_bitcoin/test/locktime_test.dart b/cw_bitcoin/test/locktime_test.dart new file mode 100644 index 000000000..cd20e0205 --- /dev/null +++ b/cw_bitcoin/test/locktime_test.dart @@ -0,0 +1,32 @@ +import 'package:cw_bitcoin/locktime.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('antiFeeSnipingLocktime', () { + test('returns 0 when not synced', () { + expect(antiFeeSnipingLocktime(chainTip: 800000, synced: false), 0); + }); + + test('returns 0 when chainTip <= 0', () { + expect(antiFeeSnipingLocktime(chainTip: 0, synced: true), 0); + }); + + test('returns the current tip when synced', () { + expect(antiFeeSnipingLocktime(chainTip: 800000, synced: true), 800000); + }); + }); + + group('locktimeToBytes', () { + test('encodes 0 as four zero bytes', () { + expect(locktimeToBytes(0), [0, 0, 0, 0]); + }); + + test('encodes a block height little-endian', () { + expect(locktimeToBytes(800000), [0x00, 0x35, 0x0C, 0x00]); + }); + + test('encodes max 32-bit value', () { + expect(locktimeToBytes(0xFFFFFFFF), [0xFF, 0xFF, 0xFF, 0xFF]); + }); + }); +}