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 <konstantinullrich12@gmail.com> * Update cw_bitcoin/lib/locktime.dart Co-authored-by: Konstantin Ullrich <konstantinullrich12@gmail.com> * Update cw_bitcoin/lib/locktime.dart [skip ci] --------- Co-authored-by: Konstantin Ullrich <konstantinullrich12@gmail.com> Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>

Cindy committed Aug 19, 2026 at 07:43 UTC ca00370ca4437f68af9f79a0171d944dac13bfdc
5 files changed +81 -2
cw_bitcoin/lib/bitcoin_wallet.dart
+12 -1
@@ -14,6 +14,7 @@ import 'package:cw_bitcoin/electrum_derivations.dart';
14 import 'package:cw_bitcoin/electrum_transaction_info.dart';
15 import 'package:cw_bitcoin/electrum_wallet.dart';
16 import 'package:cw_bitcoin/electrum_wallet_snapshot.dart';
17 +import 'package:cw_bitcoin/locktime.dart';
18 import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart';
19 import 'package:cw_bitcoin/lightning/lightning_wallet.dart';
20 import 'package:cw_bitcoin/hardware/bitcoin_ledger_service.dart';
@@ -31,6 +32,7 @@ import 'package:cw_core/encryption_file_utils.dart';
32 import 'package:cw_core/output_info.dart';
33 import 'package:cw_core/payjoin_session.dart';
34 import 'package:cw_core/pending_transaction.dart';
35 +import 'package:cw_core/sync_status.dart';
36 import 'package:cw_core/unspent_coin_type.dart';
37 import 'package:cw_core/unspent_coins_info.dart';
38 import 'package:cw_core/utils/print_verbose.dart';
@@ -447,8 +449,17 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
449 ));
450 }
451
452 + final locktime = antiFeeSnipingLocktime(
453 + chainTip: await getCurrentChainTip(),
454 + synced: syncStatus is SyncedSyncStatus,
455 + );
456 +
457 return PSBTTransactionBuild(
451 - inputs: psbtReadyInputs, outputs: outputs, enableRBF: enableRBF, cwOutputs: cwOutputs)
458 + inputs: psbtReadyInputs,
459 + outputs: outputs,
460 + enableRBF: enableRBF,
461 + cwOutputs: cwOutputs,
462 + locktime: locktime)
463 .psbt;
464 }
465
cw_bitcoin/lib/electrum_wallet.dart
+14
@@ -4,6 +4,7 @@ import 'dart:isolate';
4
5 import 'package:bitcoin_base/bitcoin_base.dart';
6 import 'package:cw_bitcoin/lightning/lightning_wallet.dart';
7 +import 'package:cw_bitcoin/locktime.dart';
8 import 'package:cw_core/hardware/hardware_wallet_service.dart';
9 import 'package:cw_core/root_dir.dart';
10 import 'package:cw_core/utils/proxy_wrapper.dart';
@@ -448,6 +449,15 @@ abstract class ElectrumWalletBase
449 return currentChainTip ?? 0;
450 }
451
452 + /// Anti-fee-sniping locktime (current tip, exact-tip), LE-encoded for
453 + /// `BitcoinTransactionBuilder`.
454 + Future<List<int>> _antiFeeSnipingLocktime() async {
455 + return locktimeToBytes(antiFeeSnipingLocktime(
456 + chainTip: await getCurrentChainTip(),
457 + synced: syncStatus is SyncedSyncStatus,
458 + ));
459 + }
460 +
461 @override
462 BitcoinWalletKeys get keys {
463 String? wif;
@@ -1516,6 +1526,8 @@ abstract class ElectrumWalletBase
1526 });
1527 }
1528
1529 + final locktime = await _antiFeeSnipingLocktime();
1530 +
1531 BasedBitcoinTransacationBuilder txb;
1532 if (network is BitcoinCashNetwork) {
1533 txb = ForkedTransactionBuilder(
@@ -1540,6 +1552,7 @@ abstract class ElectrumWalletBase
1552 inputOrdering: BitcoinOrdering.shuffle,
1553 outputOrdering: BitcoinOrdering.shuffle,
1554 enableRBF: !estimatedTx.spendsUnconfirmedTX,
1555 + locktime: locktime,
1556 );
1557 }
1558
@@ -2306,6 +2319,7 @@ abstract class ElectrumWalletBase
2319 inputOrdering: BitcoinOrdering.shuffle,
2320 outputOrdering: BitcoinOrdering.shuffle,
2321 enableRBF: true,
2322 + locktime: await _antiFeeSnipingLocktime(),
2323 );
2324
2325 final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) {
cw_bitcoin/lib/locktime.dart new
+20
@@ -0,0 +1,20 @@
1 +import "dart:typed_data";
2 +
3 +/// Anti-fee-sniping locktime, set to the current chain tip (exact-tip).
4 +///
5 +/// Matches the exact-tip cluster (payjoin-cli, ldk-node, Bull Bitcoin) and Core's
6 +/// default for externally-constructed transactions, and takes Cake off its unique
7 +/// `nLockTime = 0` fingerprint. It intentionally skips the ~10% backdate
8 +/// Core/Electrum apply for delayed-broadcast privacy, which does not apply here.
9 +/// Returns 0 when not synced or the tip is unknown, to avoid emitting a stale
10 +/// height.
11 +int antiFeeSnipingLocktime({
12 + required int chainTip,
13 + required bool synced,
14 +}) {
15 + if (!synced || chainTip <= 0) return 0;
16 + return chainTip;
17 +}
18 +
19 +/// Little-endian 4-byte encoding for `BtcTransaction.locktime`.
20 +List<int> locktimeToBytes(int locktime) => (ByteData(4)..setUint32(0, locktime, Endian.little)).buffer.asUint8List();
cw_bitcoin/lib/psbt/transaction_builder.dart
+3 -1
@@ -14,8 +14,10 @@ class PSBTTransactionBuild {
14 {required List<PSBTReadyUtxoWithAddress> inputs,
15 required List<BitcoinBaseOutput> outputs,
16 required List<OutputInfo> cwOutputs,
17 - bool enableRBF = true}) {
17 + bool enableRBF = true,
18 + int locktime = 0}) {
19 psbt.setGlobalTxVersion(2);
20 + psbt.setGlobalFallbackLocktime(locktime);
21 psbt.setGlobalInputCount(inputs.length);
22 psbt.setGlobalOutputCount(outputs.length);
23
cw_bitcoin/test/locktime_test.dart new
+32
@@ -0,0 +1,32 @@
1 +import 'package:cw_bitcoin/locktime.dart';
2 +import 'package:flutter_test/flutter_test.dart';
3 +
4 +void main() {
5 + group('antiFeeSnipingLocktime', () {
6 + test('returns 0 when not synced', () {
7 + expect(antiFeeSnipingLocktime(chainTip: 800000, synced: false), 0);
8 + });
9 +
10 + test('returns 0 when chainTip <= 0', () {
11 + expect(antiFeeSnipingLocktime(chainTip: 0, synced: true), 0);
12 + });
13 +
14 + test('returns the current tip when synced', () {
15 + expect(antiFeeSnipingLocktime(chainTip: 800000, synced: true), 800000);
16 + });
17 + });
18 +
19 + group('locktimeToBytes', () {
20 + test('encodes 0 as four zero bytes', () {
21 + expect(locktimeToBytes(0), [0, 0, 0, 0]);
22 + });
23 +
24 + test('encodes a block height little-endian', () {
25 + expect(locktimeToBytes(800000), [0x00, 0x35, 0x0C, 0x00]);
26 + });
27 +
28 + test('encodes max 32-bit value', () {
29 + expect(locktimeToBytes(0xFFFFFFFF), [0xFF, 0xFF, 0xFF, 0xFF]);
30 + });
31 + });
32 +}