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
+}