Set anti-fee-sniping locktime (exact-tip) on bitcoin sends (#3385)
What changed, and why it matters
This commit changes how Cake Wallet sets the 'locktime' field on outgoing Bitcoin transactions. Previously, Cake Wallet apparently left this value at zero, which made its transactions stand out and easier to track. Now it sets the locktime to the current blockchain height when the wallet is synced, matching common behavior used by other Bitcoin wallets. This is a privacy improvement, not a fix for a vulnerability that lets someone steal funds directly.
Treat as a privacy-hardening improvement rather than an urgent security patch. Review whether the exact-tip approach is acceptable for the wallet's threat model, since it slightly differs from Bitcoin Core's randomized backdating. Ensure the getCurrentChainTip() source is trustworthy and that unsynced fallback to zero does not itself become a fingerprint.
Security signals we found
Privacy/fingerprinting reduction: removes unique nLockTime=0 wallet fingerprint
Anti-fee-sniping: aligns locktime with current chain tip
Defensive fallback: returns locktime 0 when not synced or tip unknown to avoid stale heights
No direct funds-loss vulnerability present in diff
Evidence from the diff
The patch introduces a shared helper antiFeeSnipingLocktime() in cw_bitcoin/lib/locktime.dart that returns the current chain tip when synced and zero otherwise. It wires this value into normal sends (BasedBitcoinTransacationBuilder / ForkedTransactionBuilder) and PSBT/payjoin builds (PSBTTransactionBuild.setGlobalFallbackLocktime). The goal is to remove the nLockTime=0 fingerprint and converge with the ‘exact-tip’ anti-fee-sniping cluster (payjoin-cli, ldk-node, Bull Bitcoin). It deliberately skips the ~10% backdate used by Core/Electrum because delayed-broadcast privacy does not apply here. Unit tests cover synced/unsynced and little-endian encoding.
Changed components
cw_bitcoin/lib/locktime.dartcw_bitcoin/lib/bitcoin_wallet.dartcw_bitcoin/lib/electrum_wallet.dartcw_bitcoin/lib/psbt/transaction_builder.dartcw_bitcoin/test/locktime_test.dartInspect captured patch +81 / −2
diff --git a/cw_bitcoin/lib/bitcoin_wallet.dart b/cw_bitcoin/lib/bitcoin_wallet.dart
index b8e53d1..4f791ae 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 857317a..b69fec5 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<List<int>> _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 0000000..639e6e1
--- /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<int> 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 7d2ead1..ab107bf 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<PSBTReadyUtxoWithAddress> inputs,
required List<BitcoinBaseOutput> outputs,
required List<OutputInfo> 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 0000000..cd20e02
--- /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]);
+ });
+ });
+}
Why this scored 30/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.