Skip to content

Commit 48a44b7

Browse files
committed
Set anti-fee-sniping locktime on bitcoin sends
Tip + ~10% backdate (Core DiscourageFeeSniping) via shared helper, wired into normal sends (path A) and payjoin/PSBT (path B). 0 when unsynced.
1 parent 7928c91 commit 48a44b7

5 files changed

Lines changed: 111 additions & 2 deletions

File tree

cw_bitcoin/lib/bitcoin_wallet.dart

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import 'package:cw_bitcoin/electrum_derivations.dart';
1414
import 'package:cw_bitcoin/electrum_transaction_info.dart';
1515
import 'package:cw_bitcoin/electrum_wallet.dart';
1616
import 'package:cw_bitcoin/electrum_wallet_snapshot.dart';
17+
import 'package:cw_bitcoin/locktime.dart';
1718
import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart';
1819
import 'package:cw_bitcoin/lightning/lightning_wallet.dart';
1920
import 'package:cw_bitcoin/hardware/bitcoin_ledger_service.dart';
@@ -30,6 +31,7 @@ import 'package:cw_core/encryption_file_utils.dart';
3031
import 'package:cw_core/output_info.dart';
3132
import 'package:cw_core/payjoin_session.dart';
3233
import 'package:cw_core/pending_transaction.dart';
34+
import 'package:cw_core/sync_status.dart';
3335
import 'package:cw_core/unspent_coin_type.dart';
3436
import 'package:cw_core/unspent_coins_info.dart';
3537
import 'package:cw_core/utils/print_verbose.dart';
@@ -442,8 +444,17 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
442444
));
443445
}
444446

447+
final locktime = antiFeeSnipingLocktime(
448+
chainTip: await getCurrentChainTip(),
449+
synced: syncStatus is SyncedSyncStatus,
450+
);
451+
445452
return PSBTTransactionBuild(
446-
inputs: psbtReadyInputs, outputs: outputs, enableRBF: enableRBF, cwOutputs: cwOutputs)
453+
inputs: psbtReadyInputs,
454+
outputs: outputs,
455+
enableRBF: enableRBF,
456+
cwOutputs: cwOutputs,
457+
locktime: locktime)
447458
.psbt;
448459
}
449460

cw_bitcoin/lib/electrum_wallet.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'dart:isolate';
55

66
import 'package:bitcoin_base/bitcoin_base.dart';
77
import 'package:cw_bitcoin/lightning/lightning_wallet.dart';
8+
import 'package:cw_bitcoin/locktime.dart';
89
import 'package:cw_core/hardware/hardware_wallet_service.dart';
910
import 'package:cw_core/root_dir.dart';
1011
import 'package:cw_core/utils/proxy_wrapper.dart';
@@ -459,6 +460,15 @@ abstract class ElectrumWalletBase
459460
return currentChainTip ?? 0;
460461
}
461462

463+
/// Anti-fee-sniping locktime (Core `DiscourageFeeSniping`), LE-encoded for
464+
/// `BitcoinTransactionBuilder`.
465+
Future<List<int>> _antiFeeSnipingLocktime() async {
466+
return locktimeToBytes(antiFeeSnipingLocktime(
467+
chainTip: await getCurrentChainTip(),
468+
synced: syncStatus is SyncedSyncStatus,
469+
));
470+
}
471+
462472
@override
463473
BitcoinWalletKeys get keys {
464474
String? wif;
@@ -1521,6 +1531,8 @@ abstract class ElectrumWalletBase
15211531
});
15221532
}
15231533

1534+
final locktime = await _antiFeeSnipingLocktime();
1535+
15241536
BasedBitcoinTransacationBuilder txb;
15251537
if (network is BitcoinCashNetwork) {
15261538
txb = ForkedTransactionBuilder(
@@ -1541,6 +1553,7 @@ abstract class ElectrumWalletBase
15411553
memo: estimatedTx.memo,
15421554
outputOrdering: BitcoinOrdering.none,
15431555
enableRBF: !estimatedTx.spendsUnconfirmedTX,
1556+
locktime: locktime,
15441557
);
15451558
}
15461559

@@ -2331,6 +2344,7 @@ abstract class ElectrumWalletBase
23312344
memo: memo,
23322345
outputOrdering: BitcoinOrdering.none,
23332346
enableRBF: true,
2347+
locktime: await _antiFeeSnipingLocktime(),
23342348
);
23352349

23362350
final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) {

cw_bitcoin/lib/locktime.dart

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import 'dart:math';
2+
3+
typedef RandomBelow = int Function(int max);
4+
5+
final Random _rng = Random();
6+
int _defaultRandomBelow(int max) => _rng.nextInt(max);
7+
8+
/// Bitcoin Core `DiscourageFeeSniping` locktime policy.
9+
///
10+
/// locktime = chainTip; ~10% of the time backdate by rand(0..99), floored at 0.
11+
/// Not synced or unknown tip returns 0 to avoid emitting a unique stale-height
12+
/// fingerprint.
13+
int antiFeeSnipingLocktime({
14+
required int chainTip,
15+
required bool synced,
16+
RandomBelow? randomBelow,
17+
}) {
18+
final rand = randomBelow ?? _defaultRandomBelow;
19+
if (!synced || chainTip <= 0) return 0;
20+
var locktime = chainTip;
21+
if (rand(10) == 0) {
22+
locktime = max(0, locktime - rand(100));
23+
}
24+
return locktime;
25+
}
26+
27+
/// Little-endian 4-byte encoding for `BtcTransaction.locktime`.
28+
List<int> locktimeToBytes(int locktime) => [
29+
locktime & 0xff,
30+
(locktime >> 8) & 0xff,
31+
(locktime >> 16) & 0xff,
32+
(locktime >> 24) & 0xff,
33+
];

cw_bitcoin/lib/psbt/transaction_builder.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ class PSBTTransactionBuild {
1111
final PsbtV2 psbt = PsbtV2();
1212

1313
PSBTTransactionBuild(
14-
{required List<PSBTReadyUtxoWithAddress> inputs, required List<BitcoinBaseOutput> outputs, required List<OutputInfo> cwOutputs, bool enableRBF = true}) {
14+
{required List<PSBTReadyUtxoWithAddress> inputs, required List<BitcoinBaseOutput> outputs, required List<OutputInfo> cwOutputs, bool enableRBF = true, int locktime = 0}) {
1515
psbt.setGlobalTxVersion(2);
16+
psbt.setGlobalFallbackLocktime(locktime);
1617
psbt.setGlobalInputCount(inputs.length);
1718
psbt.setGlobalOutputCount(outputs.length);
1819

cw_bitcoin/test/locktime_test.dart

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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 tip when the 1-in-10 branch does not fire', () {
15+
expect(
16+
antiFeeSnipingLocktime(chainTip: 800000, synced: true, randomBelow: (_) => 1),
17+
800000,
18+
);
19+
});
20+
21+
test('backdates by rand(0..99) when the 1-in-10 branch fires', () {
22+
var calls = 0;
23+
int rng(int _) => (calls++ == 0) ? 0 : 37;
24+
expect(
25+
antiFeeSnipingLocktime(chainTip: 800000, synced: true, randomBelow: rng),
26+
799963,
27+
);
28+
});
29+
30+
test('backdate floors at 0 for low tips', () {
31+
var calls = 0;
32+
int rng(int _) => (calls++ == 0) ? 0 : 99;
33+
expect(antiFeeSnipingLocktime(chainTip: 50, synced: true, randomBelow: rng), 0);
34+
});
35+
});
36+
37+
group('locktimeToBytes', () {
38+
test('encodes 0 as four zero bytes', () {
39+
expect(locktimeToBytes(0), [0, 0, 0, 0]);
40+
});
41+
42+
test('encodes a block height little-endian', () {
43+
expect(locktimeToBytes(800000), [0x00, 0x35, 0x0C, 0x00]);
44+
});
45+
46+
test('encodes max 32-bit value', () {
47+
expect(locktimeToBytes(0xFFFFFFFF), [0xFF, 0xFF, 0xFF, 0xFF]);
48+
});
49+
});
50+
}

0 commit comments

Comments
 (0)