1
import 'dart:async';
2
import 'dart:convert';
3
import 'dart:isolate';
4
+import 'dart:math' show Random;
5
6
import 'package:bitcoin_base/bitcoin_base.dart';
7
import 'package:cw_bitcoin/lightning/lightning_wallet.dart';
11
import 'package:cw_core/utils/proxy_wrapper.dart';
12
import 'package:cw_core/utils/print_verbose.dart';
13
import 'package:cw_bitcoin/bitcoin_wallet.dart';
14
+import 'package:cw_bitcoin/coin_selection.dart';
15
import 'package:cw_bitcoin/litecoin_wallet.dart';
16
import 'package:shared_preferences/shared_preferences.dart';
17
import 'package:blockchain_utils/blockchain_utils.dart';
263
static int estimatedTransactionSize(int inputsCount, int outputsCounts) =>
264
inputsCount * 68 + outputsCounts * 34 + 10;
265
266
+ // vbytes an input of the given script type adds to a transaction. The generic
267
+ // estimate above assumes P2WPKH (68); legacy and taproot inputs differ enough
268
+ // to break changeless-match arithmetic if not accounted for.
269
+ static int estimatedInputSize(BitcoinAddressType type) {
270
+ if (type == P2pkhAddressType.p2pkh) return 148;
271
+ if (type == P2shAddressType.p2wpkhInP2sh) return 91;
272
+ if (type == SegwitAddresType.p2tr) return 58;
273
+ if (type == SegwitAddresType.p2wsh) return 105;
274
+ if (type == SilentPaymentsAddresType.p2sp) return 58; // spent via taproot
275
+ return 68;
276
+ }
277
+
278
+ // vbytes an output of the given script type adds to a transaction. Silent
279
+ // payment outputs are delivered as taproot.
280
+ static int estimatedOutputSize(BitcoinAddressType type) {
281
+ if (type == P2pkhAddressType.p2pkh) return 34;
282
+ if (type == P2shAddressType.p2wpkhInP2sh) return 32;
283
+ if (type == SegwitAddresType.p2tr) return 43;
284
+ if (type == SegwitAddresType.p2wsh) return 43;
285
+ if (type == SilentPaymentsAddresType.p2sp) return 43;
286
+ return 31;
287
+ }
288
+
289
// Parses the account index from a BIP-44/49/84/86 derivation path.
290
// e.g. "m/84'/0'/1'" → 1. Returns 0 for unrecognised formats.
291
static int _parseAccountIndex(String? derivationPath) {
906
bool _isBelowDust(BigInt amount) =>
907
amount <= networkDustAmount && network != BitcoinNetwork.testnet;
908
909
+ // Random draw priority per outpoint. Assigned lazily with a secure RNG and kept
910
+ // until the next createTransaction call, so the recursive estimate passes of one
911
+ // transaction build all see the same input order (reshuffling between passes made
912
+ // fee estimation unstable). Cleared per transaction so each send is a fresh draw.
913
+ final Random _coinSelectionRng = Random.secure();
914
+ final Map<String, int> _coinSelectionOrder = {};
915
+
916
+ int _coinSelectionPriority(BitcoinUnspent utx) => _coinSelectionOrder.putIfAbsent(
917
+ '${utx.hash}:${utx.vout}', () => _coinSelectionRng.nextInt(1 << 32));
918
+
919
UtxoDetails _createUTXOS({
920
required bool sendAll,
921
required bool paysToSilentPayment,
922
int credentialsAmount = 0,
923
int? inputsCount,
924
+ int feeRate = 0,
925
+ int? outputsVBytes,
926
UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any,
927
}) {
928
List<UtxoWithAddress> utxos = [];
951
}).toList();
952
final unconfirmedCoins = availableInputs.where((utx) => utx.confirmations == 0).toList();
953
917
- // sort the unconfirmed coins so that mweb coins are last:
918
- availableInputs.sort((a, b) => a.bitcoinAddressRecord.type == SegwitAddresType.mweb ? 1 : -1);
954
+ // Single Random Draw: order the pool by each coin's random priority so selection is
955
+ // non-deterministic, removing the predictable address/scan order (a fingerprint).
956
+ // The priority is stable across the repeated calls of one transaction build.
957
+ // MWEB coins are kept last afterwards.
958
+ availableInputs.sort((a, b) {
959
+ final byPriority = _coinSelectionPriority(a).compareTo(_coinSelectionPriority(b));
960
+ if (byPriority != 0) return byPriority;
961
+ return '${a.hash}:${a.vout}'.compareTo('${b.hash}:${b.vout}');
962
+ });
963
+ availableInputs = [
964
+ ...availableInputs.where((u) => u.bitcoinAddressRecord.type != SegwitAddresType.mweb),
965
+ ...availableInputs.where((u) => u.bitcoinAddressRecord.type == SegwitAddresType.mweb),
966
+ ];
967
+
968
+ // Branch-and-bound: prefer an input set whose excess over the amount plus its own fee
969
+ // stays below dust, so the caller drops the change output and the send is changeless.
970
+ // When no such set exists, the shuffled pool below acts as a single random draw.
971
+ final canTryChangeless = !sendAll &&
972
+ inputsCount == null &&
973
+ credentialsAmount > 0 &&
974
+ feeRate > 0 &&
975
+ outputsVBytes != null &&
976
+ outputsVBytes > 0 &&
977
+ !availableInputs.any((u) => u.bitcoinAddressRecord.type == SegwitAddresType.mweb);
978
+ if (canTryChangeless) {
979
+ final match = changelessMatch(
980
+ values: [for (final u in availableInputs) u.value],
981
+ // estimatedTransactionSize(0, 0) is the fixed tx overhead (version,
982
+ // counters, locktime); the outputs' own vbytes come pre-computed per type.
983
+ target: credentialsAmount + (estimatedTransactionSize(0, 0) + outputsVBytes!) * feeRate,
984
+ inputCosts: [
985
+ for (final u in availableInputs)
986
+ estimatedInputSize(u.bitcoinAddressRecord.type) * feeRate
987
+ ],
988
+ window: networkDustAmount.toInt(),
989
+ );
990
+ if (match != null) {
991
+ final chosen = match.indices.toSet();
992
+ availableInputs = [
993
+ for (final i in match.indices) availableInputs[i],
994
+ for (var i = 0; i < availableInputs.length; i++)
995
+ if (!chosen.contains(i)) availableInputs[i],
996
+ ];
997
+ inputsCount = match.indices.length;
998
+ }
999
+ }
1000
1001
for (int i = 0; i < availableInputs.length; i++) {
1002
final utx = availableInputs[i];
1212
}
1213
}
1214
1215
+ // Per-type output sizes for the changeless target. MWEB outputs follow a
1216
+ // different size model entirely, so they disable the changeless path (null).
1217
+ int? outputsVBytes = 0;
1218
+ for (final out in outputs) {
1219
+ final type =
1220
+ out.isSilentPayment == true ? SilentPaymentsAddresType.p2sp : _getScriptType(out.address);
1221
+ if (type == SegwitAddresType.mweb) {
1222
+ outputsVBytes = null;
1223
+ break;
1224
+ }
1225
+ outputsVBytes = outputsVBytes! + estimatedOutputSize(type);
1226
+ }
1227
+
1228
final utxoDetails = _createUTXOS(
1229
sendAll: false,
1230
credentialsAmount: credentialsAmount.amount.toInt(),
1231
inputsCount: inputsCount,
1232
+ feeRate: feeRate,
1233
+ outputsVBytes: outputsVBytes,
1234
paysToSilentPayment: hasSilentPayment,
1235
coinTypeToSpendFrom: coinTypeToSpendFrom,
1236
);
1493
@override
1494
Future<PendingTransaction> createTransaction(Object credentials) async {
1495
try {
1496
+ // New transaction, new random draw: drop the previous input ordering so this
1497
+ // build gets fresh priorities, then keep them fixed for all estimate passes.
1498
+ _coinSelectionOrder.clear();
1499
+
1500
// start by updating unspent coins
1501
await updateAllUnspents();
1502
2320
}
2321
}
2322
2223
- // If still not enough, add UTXOs until the fee is covered
2323
+ // If still not enough, add UTXOs until the fee is covered, drawing them at
2324
+ // random instead of in the predictable wallet scan order (address, then age).
2325
if (remainingFee > BigInt.zero) {
2326
final unusedUtxos = unspentCoins
2327
.where((utxo) => utxo.isSending && !utxo.isFrozen && utxo.confirmations! > 0)
2227
- .toList();
2328
+ .toList()
2329
+ ..shuffle(Random.secure());
2330
2331
for (final utxo in unusedUtxos) {
2332
final address = RegexUtils.addressTypeFromStr(utxo.address, network);