diff --git a/cw_bitcoin/lib/bitcoin_wallet.dart b/cw_bitcoin/lib/bitcoin_wallet.dart index 7e76132a88..53d84728d9 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'; @@ -30,6 +31,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'; @@ -446,8 +448,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 bc7d45ec44..76e461ab7f 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'; @@ -455,6 +456,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; @@ -1517,6 +1527,8 @@ abstract class ElectrumWalletBase }); } + final locktime = await _antiFeeSnipingLocktime(); + BasedBitcoinTransacationBuilder txb; if (network is BitcoinCashNetwork) { txb = ForkedTransactionBuilder( @@ -1538,6 +1550,7 @@ abstract class ElectrumWalletBase inputOrdering: BitcoinOrdering.shuffle, outputOrdering: BitcoinOrdering.none, enableRBF: !estimatedTx.spendsUnconfirmedTX, + locktime: locktime, ); } @@ -2299,6 +2312,7 @@ abstract class ElectrumWalletBase inputOrdering: BitcoinOrdering.shuffle, outputOrdering: BitcoinOrdering.none, 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 0000000000..3ca9e6f23c --- /dev/null +++ b/cw_bitcoin/lib/locktime.dart @@ -0,0 +1,23 @@ +/// 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) => [ + locktime & 0xff, + (locktime >> 8) & 0xff, + (locktime >> 16) & 0xff, + (locktime >> 24) & 0xff, + ]; diff --git a/cw_bitcoin/lib/psbt/transaction_builder.dart b/cw_bitcoin/lib/psbt/transaction_builder.dart index 7d2ead134e..ab107bfeb6 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 0000000000..cd20e02054 --- /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]); + }); + }); +}