dev
dart 4,565 lines 151 KB
Raw
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';
8 import 'package:cw_bitcoin/locktime.dart';
9 import 'package:cw_core/hardware/hardware_wallet_service.dart';
10 import 'package:cw_core/root_dir.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';
18 import 'package:collection/collection.dart';
19 import 'package:cw_bitcoin/address_from_output.dart';
20 import 'package:cw_bitcoin/bitcoin_address_record.dart';
21 import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart';
22 import 'package:cw_bitcoin/bitcoin_transaction_priority.dart';
23 import 'package:cw_bitcoin/bitcoin_unspent.dart';
24 import 'package:cw_bitcoin/bitcoin_wallet_keys.dart';
25 import 'package:cw_bitcoin/electrum.dart' as electrum;
26 import 'package:cw_bitcoin/electrum_balance.dart';
27 import 'package:cw_bitcoin/electrum_derivations.dart';
28 import 'package:cw_bitcoin/electrum_transaction_history.dart';
29 import 'package:cw_bitcoin/electrum_transaction_info.dart';
30 import 'package:cw_bitcoin/electrum_wallet_addresses.dart';
31 import 'package:cw_bitcoin/exceptions.dart';
32 import 'package:cw_bitcoin/pending_bitcoin_transaction.dart';
33 import 'package:cw_bitcoin/utils.dart';
34 import 'package:cw_core/amount/money.dart';
35 import 'package:cw_core/crypto_currency.dart';
36 import 'package:cw_core/encryption_file_utils.dart';
37 import 'package:cw_core/get_height_by_date.dart';
38 import 'package:cw_core/node.dart';
39 import 'package:cw_core/output_info.dart';
40 import 'package:cw_core/pending_transaction.dart';
41 import 'package:cw_core/sync_status.dart';
42 import 'package:cw_core/transaction_direction.dart';
43 import 'package:cw_core/transaction_priority.dart';
44 import 'package:cw_core/unspent_coin_type.dart';
45 import 'package:cw_core/unspent_coins_info.dart';
46 import 'package:cw_core/utils/socket_health_logger.dart';
47 import 'package:cw_core/utils/tor/abstract.dart';
48 import 'package:cw_core/wallet_base.dart';
49 import 'package:cw_core/wallet_info.dart';
50 import 'package:cw_core/wallet_keys_file.dart';
51 import 'package:cw_core/wallet_type.dart';
52 import 'package:flutter/foundation.dart';
53 import 'package:hex/hex.dart';
54 import 'package:hive/hive.dart';
55 import 'package:mobx/mobx.dart';
56 import 'package:rxdart/subjects.dart';
57 import 'package:sp_scanner/sp_scanner.dart';
58
59 part 'electrum_wallet.g.dart';
60
61 class ElectrumWallet = ElectrumWalletBase with _$ElectrumWallet;
62
63 abstract class ElectrumWalletBase
64 extends WalletBase<ElectrumBalance, ElectrumTransactionHistory, ElectrumTransactionInfo>
65 with Store, WalletKeysFile {
66 ElectrumWalletBase({
67 required String password,
68 required WalletInfo walletInfo,
69 required DerivationInfo derivationInfo,
70 required Box<UnspentCoinsInfo> unspentCoinsInfo,
71 required this.network,
72 required this.encryptionFileUtils,
73 String? xpub,
74 String? mnemonic,
75 Uint8List? seedBytes,
76 this.passphrase,
77 List<BitcoinAddressRecord>? initialAddresses,
78 electrum.ElectrumClient? electrumClient,
79 ElectrumBalance? initialBalance,
80 CryptoCurrency? currency,
81 bool? alwaysScan,
82 bool useLightning = true,
83 }) : _masterHD = getMasterHD(seedBytes, network, walletInfo.hardwareWalletType),
84 accountHD = getAccountHDWallet(
85 currency, network, seedBytes, xpub, derivationInfo, walletInfo.hardwareWalletType),
86 syncStatus = NotConnectedSyncStatus(),
87 _password = password,
88 _feeRates = <int>[],
89 _isTransactionUpdating = false,
90 isEnabledAutoGenerateSubaddress = true,
91 unspentCoins = [],
92 _scripthashesUpdateSubject = {},
93 this.alwaysScan = alwaysScan,
94 silentPaymentsScanningActive = alwaysScan ?? false,
95 balance = ObservableMap<CryptoCurrency, ElectrumBalance>.of(currency != null
96 ? {
97 currency: initialBalance ??
98 ElectrumBalance(
99 confirmed: Money.zero(currency),
100 unconfirmed: Money.zero(currency),
101 frozen: Money.zero(currency),
102 )
103 }
104 : {}),
105 this.unspentCoinsInfo = unspentCoinsInfo,
106 this.isTestnet = !network.isMainnet,
107 this._mnemonic = mnemonic,
108 _useLightning = useLightning,
109 super(walletInfo, derivationInfo) {
110 this.electrumClient = electrumClient ?? electrum.ElectrumClient();
111 this.walletInfo = walletInfo;
112 this.derivationInfo = derivationInfo;
113 transactionHistory = ElectrumTransactionHistory(
114 walletInfo: walletInfo,
115 password: password,
116 encryptionFileUtils: encryptionFileUtils,
117 );
118
119 reaction((_) => syncStatus, _syncStatusReaction);
120
121 sharedPrefs.complete(SharedPreferences.getInstance());
122
123 final supportedTypes = supportedAddressTypes(walletInfo.type);
124 mainHdByType = <BitcoinAddressType, Bip32Slip10Secp256k1>{};
125 sideHdByType = <BitcoinAddressType, Bip32Slip10Secp256k1>{};
126
127 final isElectrumDerivation = derivationInfo.derivationType == DerivationType.electrum;
128
129 final canDeriveFromSeed = _masterHD != null && currency != null;
130
131 if (isElectrumDerivation) {
132 // Electrum derivation does not follow BIP44/49/84 etc. standards
133 for (final type in supportedTypes) {
134 mainHdByType[type] = mainHd; // accountHD.child(0)
135 sideHdByType[type] = sideHd; // accountHD.child(1)
136 }
137 } else if (canDeriveFromSeed) {
138 final coinType = _coinTypeFor(currency);
139 final accountIndex = _parseAccountIndex(derivationInfo.derivationPath);
140
141 for (final type in supportedTypes) {
142 final purpose = _purposeForType(type);
143 final accountPath = "m/$purpose'/$coinType'/$accountIndex'";
144
145 mainHdByType[type] = _masterHD!.derivePath("$accountPath/0") as Bip32Slip10Secp256k1;
146 sideHdByType[type] = _masterHD!.derivePath("$accountPath/1") as Bip32Slip10Secp256k1;
147 }
148 } else {
149 // View-only wallet (xpub only)
150 for (final type in supportedTypes) {
151 mainHdByType[type] = mainHd;
152 sideHdByType[type] = sideHd;
153 }
154 }
155 }
156
157 int _purposeForType(BitcoinAddressType type) {
158 switch (type.value) {
159 case 'P2PKH':
160 return 44;
161 case 'P2SH/P2WPKH':
162 return 49;
163 case 'P2WPKH':
164 return 84;
165 case 'P2TR':
166 return 86;
167 default:
168 return 84;
169 }
170 }
171
172 int _coinTypeFor(CryptoCurrency cur) {
173 if (!network.isMainnet) return 1;
174 switch (cur) {
175 case CryptoCurrency.btc:
176 case CryptoCurrency.tbtc:
177 return 0;
178 case CryptoCurrency.ltc:
179 return 2;
180 case CryptoCurrency.bch:
181 return 145;
182 case CryptoCurrency.doge:
183 return 3;
184 default:
185 return 0;
186 }
187 }
188
189 /// Returns the BIP32 account derivation path (m/purpose'/coinType'/0') for STANDARD addresses.
190 /// For LEGACY addresses, returns the wallet's legacy derivation base (derivationInfo.derivationPath)
191 /// which is already the account path used historically (e.g. m/0' or m/84'/0'/0').
192 String _accountDerivationPathForRecord(BaseBitcoinAddressRecord record) {
193 if (derivationInfo.derivationType == DerivationType.electrum) {
194 return derivationInfo.derivationPath ?? electrum_path; // m/0'
195 }
196
197 if (record.isLegacyDerivation) {
198 return derivationInfo.derivationPath ?? electrum_path;
199 }
200
201 final coinType = _coinTypeFor(currency);
202 final purpose = _purposeForType(record.type);
203 final accountIndex = _parseAccountIndex(derivationInfo.derivationPath);
204 return "m/$purpose'/$coinType'/$accountIndex'";
205 }
206
207 List<BitcoinAddressType> supportedAddressTypes(WalletType type) {
208 switch (type) {
209 case WalletType.bitcoin:
210 return BITCOIN_ADDRESS_TYPES;
211 case WalletType.bitcoinCash:
212 return BITCOIN_CASH_ADDRESS_TYPES;
213 case WalletType.dogecoin:
214 return DOGECOIN_ADDRESS_TYPES;
215 case WalletType.litecoin:
216 return LITECOIN_ADDRESS_TYPES;
217 default:
218 return BITCOIN_ADDRESS_TYPES;
219 }
220 }
221
222 static Bip32Slip10Secp256k1 getAccountHDWallet(
223 CryptoCurrency? currency,
224 BasedUtxoNetwork network,
225 Uint8List? seedBytes,
226 String? xpub,
227 DerivationInfo? derivationInfo,
228 HardwareWalletType? hardwareWalletType) {
229 if (seedBytes == null && xpub == null) {
230 throw Exception(
231 "To create a Wallet you need either a seed or an xpub. This should not happen");
232 }
233
234 if (seedBytes != null) {
235 switch (currency) {
236 case CryptoCurrency.btc:
237 case CryptoCurrency.ltc:
238 case CryptoCurrency.tbtc:
239 return Bip32Slip10Secp256k1.fromSeed(
240 seedBytes, getKeyNetVersion(network, hardwareWalletType))
241 .derivePath(
242 _hardenedDerivationPath(derivationInfo?.derivationPath ?? electrum_path))
243 as Bip32Slip10Secp256k1;
244 case CryptoCurrency.bch:
245 return bitcoinCashHDWallet(seedBytes);
246 case CryptoCurrency.doge:
247 return dogecoinHDWallet(seedBytes);
248 default:
249 throw Exception("Unsupported currency");
250 }
251 }
252
253 return Bip32Slip10Secp256k1.fromExtendedKey(
254 xpub!, getKeyNetVersion(network, hardwareWalletType));
255 }
256
257 static Bip32Slip10Secp256k1 bitcoinCashHDWallet(Uint8List seedBytes) =>
258 Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath("m/44'/145'/0'") as Bip32Slip10Secp256k1;
259
260 static Bip32Slip10Secp256k1 dogecoinHDWallet(Uint8List seedBytes) =>
261 Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath("m/44'/3'/0'") as Bip32Slip10Secp256k1;
262
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) {
292 if (derivationPath == null) return 0;
293 final parts = derivationPath.split('/');
294 if (parts.length < 4) return 0;
295 return int.tryParse(parts[3].replaceAll("'", "")) ?? 0;
296 }
297
298 static Bip32KeyNetVersions? getKeyNetVersion(BasedUtxoNetwork network,
299 [HardwareWalletType? hardwareWalletType]) {
300 switch (network) {
301 case LitecoinNetwork.mainnet:
302 if ([HardwareWalletType.ledger, HardwareWalletType.trezor].contains(hardwareWalletType))
303 return Bip44Conf.litecoinMainNet.altKeyNetVer;
304 return null;
305 default:
306 return null;
307 }
308 }
309
310 static Bip32Slip10Secp256k1? getMasterHD(Uint8List? seedBytes,
311 [BasedUtxoNetwork? network, HardwareWalletType? hardwareWalletType]) {
312 if (seedBytes == null) return null;
313
314 return Bip32Slip10Secp256k1.fromSeed(
315 seedBytes, network != null ? getKeyNetVersion(network, hardwareWalletType) : null);
316 }
317
318 static const int addressHistoryChunkSize = 150;
319 static const int transactionChunkSize = 150;
320 static const int inputTransactionChunkSize = 150;
321 static const int discoveryHistoryChunkSize = 20;
322
323 static const int transactionBatchTimeoutMs = 15000;
324
325 static const int batchTestTimeoutMs = 4000;
326 static const int batchTestHashesCount = 2;
327
328 static const bool useBatchForHistory = true;
329
330 @observable
331 bool? alwaysScan;
332
333 @computed
334 bool get useLightning => _useLightning && LightningWallet.isAvailable;
335
336 set useLightning(bool val) => _useLightning = val && LightningWallet.isAvailable;
337
338 @observable
339 bool _useLightning;
340
341 final Bip32Slip10Secp256k1? _masterHD;
342 final Bip32Slip10Secp256k1 accountHD;
343 final String? _mnemonic;
344
345 late final Map<BitcoinAddressType, Bip32Slip10Secp256k1> mainHdByType;
346 late final Map<BitcoinAddressType, Bip32Slip10Secp256k1> sideHdByType;
347
348 Bip32Slip10Secp256k1 get mainHd => accountHD.childKey(Bip32KeyIndex(0));
349
350 Bip32Slip10Secp256k1 get sideHd => accountHD.childKey(Bip32KeyIndex(1));
351
352 final EncryptionFileUtils encryptionFileUtils;
353
354 @override
355 final String? passphrase;
356
357 @override
358 @observable
359 bool isEnabledAutoGenerateSubaddress;
360
361 late electrum.ElectrumClient electrumClient;
362 Box<UnspentCoinsInfo> unspentCoinsInfo;
363
364 @override
365 late ElectrumWalletAddresses walletAddresses;
366
367 @override
368 @observable
369 late ObservableMap<CryptoCurrency, ElectrumBalance> balance;
370
371 @override
372 @observable
373 SyncStatus syncStatus;
374
375 Set<String> get addressesSet => walletAddresses.allAddresses
376 .where((element) => element.type != SegwitAddresType.mweb)
377 .map((addr) => addr.address)
378 .toSet();
379
380 List<String> get scriptHashes => walletAddresses.addressesByReceiveType
381 .where((addr) => RegexUtils.addressTypeFromStr(addr.address, network) is! MwebAddress)
382 .map((addr) => (addr as BitcoinAddressRecord).getScriptHash(network))
383 .toList();
384
385 List<String> get publicScriptHashes => walletAddresses.allAddresses
386 .where((addr) => !addr.isHidden)
387 .where((addr) => RegexUtils.addressTypeFromStr(addr.address, network) is! MwebAddress)
388 .map((addr) => addr.getScriptHash(network))
389 .toList();
390
391 String get xpub => accountHD.publicKey.toExtended;
392
393 bool get shouldUseBatchFetching => useBatchForHistory && _isBatchSupported == true;
394
395 @override
396 String? get seed => _mnemonic;
397
398 @override
399 WalletKeysData get walletKeysData =>
400 WalletKeysData(mnemonic: _mnemonic, xPub: xpub, passphrase: passphrase);
401
402 @override
403 String get password => _password;
404
405 BasedUtxoNetwork network;
406
407 @override
408 bool isTestnet;
409
410 @override
411 bool get hasSilentPaymentsScanning => type == WalletType.bitcoin && keys.privateKey.isNotEmpty;
412
413 @observable
414 bool nodeSupportsSilentPayments = true;
415 @observable
416 bool silentPaymentsScanningActive = false;
417
418 bool _isTryingToConnect = false;
419 bool? _isBatchSupported;
420 DateTime? _syncBenchmarkStartTime;
421
422 Completer<SharedPreferences> sharedPrefs = Completer();
423
424 Future<bool> checkIfMempoolAPIIsEnabled() async {
425 bool isMempoolAPIEnabled = (await sharedPrefs.future).getBool("use_mempool_fee_api") ?? true;
426 return isMempoolAPIEnabled;
427 }
428
429 @action
430 Future<void> setSilentPaymentsScanning(bool active) async {
431 silentPaymentsScanningActive = active;
432
433 if (active) {
434 syncStatus = AttemptingScanSyncStatus();
435
436 final tip = await getUpdatedChainTip();
437
438 if (tip == walletInfo.restoreHeight) {
439 syncStatus = SyncedTipSyncStatus(tip);
440 return;
441 }
442
443 if (tip > walletInfo.restoreHeight) {
444 _setListeners(walletInfo.restoreHeight, chainTipParam: currentChainTip);
445 }
446 } else {
447 alwaysScan = false;
448
449 _isolate?.then((value) => value.kill(priority: Isolate.immediate));
450
451 if (electrumClient.isConnected) {
452 syncStatus = SyncedSyncStatus();
453 } else {
454 syncStatus = NotConnectedSyncStatus();
455 }
456 }
457 }
458
459 int? currentChainTip;
460
461 Future<int> getCurrentChainTip() async {
462 if ((currentChainTip ?? 0) > 0) {
463 return currentChainTip!;
464 }
465 currentChainTip = await electrumClient.getCurrentBlockChainTip() ?? 0;
466
467 return currentChainTip!;
468 }
469
470 Future<int> getUpdatedChainTip() async {
471 final newTip = await electrumClient.getCurrentBlockChainTip();
472 if (newTip != null && newTip > (currentChainTip ?? 0)) {
473 currentChainTip = newTip;
474 }
475 return currentChainTip ?? 0;
476 }
477
478 /// Anti-fee-sniping locktime (current tip, exact-tip), LE-encoded for
479 /// `BitcoinTransactionBuilder`.
480 Future<List<int>> _antiFeeSnipingLocktime() async {
481 return locktimeToBytes(antiFeeSnipingLocktime(
482 chainTip: await getCurrentChainTip(),
483 synced: syncStatus is SyncedSyncStatus,
484 ));
485 }
486
487 @override
488 BitcoinWalletKeys get keys {
489 String? wif;
490 String? privateKey;
491 String? publicKey;
492
493 final hd = mainHdByType[SegwitAddresType.p2wpkh] ?? mainHd;
494
495 try {
496 wif = WifEncoder.encode(hd.privateKey.raw, netVer: network.wifNetVer);
497 } catch (_) {}
498 try {
499 privateKey = hd.privateKey.toHex();
500 } catch (_) {}
501 try {
502 publicKey = hd.publicKey.toHex();
503 } catch (_) {}
504
505 return BitcoinWalletKeys(
506 wif: wif ?? '',
507 privateKey: privateKey ?? '',
508 publicKey: publicKey ?? '',
509 xpub: xpub,
510 masterFingerprint: _masterHD?.fingerPrint.toHex() ?? '',
511 );
512 }
513
514 String _password;
515 List<BitcoinUnspent> unspentCoins;
516 List<int> _feeRates;
517
518 // ignore: prefer_final_fields
519 Map<String, BehaviorSubject<Object>?> _scripthashesUpdateSubject;
520
521 // ignore: prefer_final_fields
522 BehaviorSubject<Object>? _chainTipUpdateSubject;
523 bool _isTransactionUpdating;
524 Future<Isolate>? _isolate;
525
526 void Function(FlutterErrorDetails)? _onError;
527 Timer? _autoSaveTimer;
528 StreamSubscription<dynamic>? _receiveStream;
529 Timer? _updateFeeRateTimer;
530 static const int _autoSaveInterval = 1;
531
532 Future<void> init() async {
533 await walletAddresses.init();
534 await transactionHistory.init();
535 await cleanUpDuplicateUnspentCoins();
536 await save();
537
538 _autoSaveTimer =
539 Timer.periodic(Duration(minutes: _autoSaveInterval), (_) async => await save());
540 }
541
542 @action
543 Future<void> _setListeners(int height,
544 {int? chainTipParam, bool? doSingleScan, List<int>? rescanHeights}) async {
545 if (this is! BitcoinWallet) return;
546 if (isHardwareWallet) return;
547 if (seed?.isEmpty ?? true) return;
548
549 final chainTip = chainTipParam ?? await getUpdatedChainTip();
550 final shouldUpdateSyncStatus = rescanHeights == null || rescanHeights.isEmpty;
551
552 if (chainTip == height) {
553 syncStatus = SyncedSyncStatus();
554 return;
555 }
556
557 if (shouldUpdateSyncStatus) syncStatus = AttemptingScanSyncStatus();
558
559 if (_isolate != null) {
560 final runningIsolate = await _isolate!;
561 runningIsolate.kill(priority: Isolate.immediate);
562 }
563
564 final appDir = await getAppDir();
565 String debugLogPath = "${appDir.path}/logs/debug.log";
566
567 final receivePort = ReceivePort();
568 _isolate = Isolate.spawn(
569 _handleScanSilentPayments,
570 ScanData(
571 sendPort: receivePort.sendPort,
572 silentAddress: walletAddresses.silentAddress!,
573 masterHD: _masterHD!,
574 network: network,
575 height: height,
576 chainTip: chainTip,
577 electrumClient: electrum.ElectrumClient(),
578 transactionHistoryIds: transactionHistory.transactions.keys.toList(),
579 node: (await getNodeSupportsSilentPayments()) == true
580 ? ScanNode(node!.uri, node!.useSSL)
581 : null,
582 labels: walletAddresses.labels,
583 labelIndexes: walletAddresses.silentAddresses
584 .where((addr) => addr.type == SilentPaymentsAddresType.p2sp && addr.index >= 1)
585 .map((addr) => addr.index)
586 .toList(),
587 isSingleScan: doSingleScan ?? false,
588 debugLogPath: debugLogPath,
589 rescanHeights: rescanHeights,
590 ),
591 );
592
593 await _receiveStream?.cancel();
594 _receiveStream = receivePort.listen((var message) async {
595 if (message is Map<String, ElectrumTransactionInfo>) {
596 for (final map in message.entries) {
597 final txid = map.key;
598 final tx = map.value;
599
600 if (tx.unspents != null) {
601 final existingTxInfo = transactionHistory.transactions[txid];
602 final txAlreadyExisted = existingTxInfo != null;
603
604 // Updating tx after re-scanned
605 if (txAlreadyExisted) {
606 existingTxInfo.amount = tx.amount;
607 existingTxInfo.confirmations = tx.confirmations;
608 existingTxInfo.height = tx.height;
609 existingTxInfo.date = tx.date;
610 existingTxInfo.isReceivedSilentPayment = tx.isReceivedSilentPayment;
611 existingTxInfo.direction = tx.direction;
612 existingTxInfo.isPending = tx.isPending;
613 existingTxInfo.unspents = tx.unspents;
614
615 final newUnspents = tx.unspents!
616 .where((unspent) => !(existingTxInfo.unspents?.any((element) =>
617 element.hash.contains(unspent.hash) &&
618 element.vout == unspent.vout &&
619 element.value == unspent.value) ??
620 false))
621 .toList();
622
623 if (newUnspents.isNotEmpty) {
624 newUnspents.forEach(_updateSilentAddressRecord);
625
626 existingTxInfo.unspents ??= [];
627 existingTxInfo.unspents!.addAll(newUnspents);
628
629 final newAmount = newUnspents.length > 1
630 ? newUnspents.map((e) => e.value).reduce((value, unspent) => value + unspent)
631 : newUnspents[0].value;
632
633 if (existingTxInfo.direction == TransactionDirection.incoming) {
634 existingTxInfo.amount += Money.fromInt(newAmount, currency);
635 }
636
637 // Updates existing TX
638 transactionHistory.addOne(existingTxInfo);
639 // Update balance record
640 balance[currency]!.confirmed += Money.fromInt(newAmount, currency);
641 }
642 } else {
643 // else: First time seeing this TX after scanning
644 tx.unspents!.forEach(_updateSilentAddressRecord);
645
646 // Add new TX record
647 transactionHistory.addMany(message);
648
649 // Update balance record
650 balance[currency]!.confirmed += tx.amount;
651
652 await save();
653 }
654
655 await updateAllUnspents();
656 }
657 }
658 }
659
660 if (message is SyncResponse) {
661 if (message.syncStatus is UnsupportedSyncStatus) {
662 nodeSupportsSilentPayments = false;
663 }
664
665 if (message.syncStatus is SyncingSyncStatus) {
666 var status = message.syncStatus as SyncingSyncStatus;
667 if (shouldUpdateSyncStatus) syncStatus = SyncingSyncStatus(status.blocksLeft, status.ptc);
668 } else {
669 if (shouldUpdateSyncStatus) syncStatus = message.syncStatus;
670 }
671
672 await walletInfo.updateRestoreHeight(message.height);
673 }
674 });
675 }
676
677 void _updateSilentAddressRecord(BitcoinSilentPaymentsUnspent unspent) {
678 final silentAddress = walletAddresses.silentAddress!;
679 final silentPaymentAddress = SilentPaymentAddress(
680 version: silentAddress.version,
681 B_scan: silentAddress.B_scan,
682 B_spend: unspent.silentPaymentLabel != null
683 ? silentAddress.B_spend.tweakAdd(
684 BigintUtils.fromBytes(BytesUtils.fromHexString(unspent.silentPaymentLabel!)),
685 )
686 : silentAddress.B_spend,
687 );
688
689 final addressRecord = walletAddresses.silentAddresses
690 .firstWhereOrNull((address) => address.address == silentPaymentAddress.toString());
691 addressRecord?.txCount += 1;
692 addressRecord?.balance += unspent.value;
693
694 walletAddresses.addSilentAddresses(
695 [unspent.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord],
696 );
697 }
698
699 DateTime? _lastSilentPaymentsScan;
700 static const Duration _silentPaymentsScanDelay = Duration(minutes: 1);
701
702 @action
703 @override
704 Future<void> startSync() async {
705 try {
706 if (syncStatus is SyncronizingSyncStatus) {
707 return;
708 }
709
710 if (_syncBenchmarkStartTime == null) {
711 _syncBenchmarkStartTime = DateTime.now();
712 printV('[ELECTRUM_WALLET SYNC] Starting: ${_syncBenchmarkStartTime!}');
713 }
714
715 syncStatus = SyncronizingSyncStatus();
716
717 if (hasSilentPaymentsScanning) {
718 silentPaymentsScanningActive = alwaysScan ?? false;
719 await _setInitialHeight();
720
721 final now = DateTime.now();
722 final shouldForceRescan = _lastSilentPaymentsScan == null ||
723 now.difference(_lastSilentPaymentsScan!) >= _silentPaymentsScanDelay;
724
725 // Timer prevents server failure and this infinite looping and requesting
726 if (shouldForceRescan) {
727 _lastSilentPaymentsScan = now;
728
729 final rescanHeights = <int>[];
730
731 transactionHistory.transactions.values.forEach((tx) {
732 if (tx.unspents != null && tx.unspents!.isNotEmpty)
733 for (final unspent in tx.unspents!) {
734 if (unspent.silentPaymentTweak != null && tx.height != null && tx.height! > 0) {
735 rescanHeights.add(tx.height!);
736 break;
737 }
738 }
739 });
740
741 if (rescanHeights.isNotEmpty)
742 _setListeners(walletInfo.restoreHeight, rescanHeights: rescanHeights);
743 }
744 }
745
746 await subscribeForUpdates();
747 await checkIfBatchSupported();
748 await updateTransactions();
749
750 await updateAllUnspents();
751 await updateBalance();
752 await updateFeeRates();
753
754 _updateFeeRateTimer ??=
755 Timer.periodic(const Duration(minutes: 1), (timer) async => await updateFeeRates());
756
757 if (alwaysScan == true) {
758 setSilentPaymentsScanning(true);
759 } else {
760 if (syncStatus is LostConnectionSyncStatus) {
761 return;
762 }
763
764 final syncEnd = DateTime.now();
765 final totalMs = _syncBenchmarkStartTime != null
766 ? syncEnd.difference(_syncBenchmarkStartTime!).inMilliseconds
767 : 0;
768
769 printV('[ELECTRUM_WALLET SYNC] Finished: $syncEnd, took ${totalMs} ms');
770
771 _syncBenchmarkStartTime = null;
772 syncStatus = SyncedSyncStatus();
773 }
774 } catch (e, stacktrace) {
775 final syncEnd = DateTime.now();
776 final totalMs = _syncBenchmarkStartTime != null
777 ? syncEnd.difference(_syncBenchmarkStartTime!).inMilliseconds
778 : 0;
779
780 printV(stacktrace);
781 printV("startSync $e");
782 printV('[ELECTRUM_WALLET SYNC] Finished: $syncEnd, took ${totalMs} ms');
783
784 _syncBenchmarkStartTime = null;
785 syncStatus = FailedSyncStatus();
786 }
787 }
788
789 static bool _isValidFeeRates(List<int> feeRates) =>
790 feeRates.length == 3 && feeRates.every((rate) => rate > 0);
791
792 @action
793 Future<void> updateFeeRates() async {
794 if (await checkIfMempoolAPIIsEnabled() && type == WalletType.bitcoin) {
795 try {
796 final response = await ProxyWrapper()
797 .get(clearnetUri: Uri.parse("https://mempool.cakewallet.com/api/v1/fees/recommended"))
798 .timeout(Duration(seconds: 15));
799
800 final result = json.decode(response.body) as Map<String, dynamic>;
801 final slowFee = (result['economyFee'] as num?)?.toInt() ?? 0;
802 int mediumFee = (result['hourFee'] as num?)?.toInt() ?? 0;
803 int fastFee = (result['fastestFee'] as num?)?.toInt() ?? 0;
804 if (slowFee == mediumFee) {
805 mediumFee++;
806 }
807 while (fastFee <= mediumFee) {
808 fastFee++;
809 }
810 _feeRates = [slowFee, mediumFee, fastFee];
811 return;
812 } catch (e) {
813 printV(e);
814 }
815 }
816
817 final feeRates = await electrumClient.feeRates(network: network);
818 if (_isValidFeeRates(feeRates)) {
819 _feeRates = feeRates;
820 } else if (isTestnet) {
821 _feeRates = [1, 1, 1];
822 }
823 }
824
825 Node? node;
826
827 Future<bool> getNodeIsElectrs() async {
828 if (node == null) {
829 return false;
830 }
831
832 final version = await electrumClient.version();
833
834 if (version.isNotEmpty) {
835 final server = version[0];
836
837 if (server.toLowerCase().contains('electrs')) {
838 node!.isElectrs = true;
839 // TODO figure out why condition was needed
840 // if (node!.isInBox) {
841 node!.save();
842 // }
843 return node!.isElectrs!;
844 }
845 }
846
847 node!.isElectrs = false;
848 return node!.isElectrs!;
849 }
850
851 Future<bool> getNodeSupportsSilentPayments() async {
852 // As of today (august 2024), only ElectrumRS supports silent payments
853 if (!(await getNodeIsElectrs())) {
854 return false;
855 }
856
857 if (node == null) {
858 return false;
859 }
860
861 try {
862 final tweaksResponse = await electrumClient.getTweaks(height: 0);
863
864 if (tweaksResponse != null) {
865 node!.supportsSilentPayments = true;
866 node!.save();
867 return node!.supportsSilentPayments!;
868 }
869 } on electrum.RequestFailedTimeoutException catch (_) {
870 node!.supportsSilentPayments = false;
871 node!.save();
872 return node!.supportsSilentPayments!;
873 } catch (_) {}
874
875 node!.supportsSilentPayments = false;
876 node!.save();
877 return node!.supportsSilentPayments!;
878 }
879
880 @action
881 @override
882 Future<void> connectToNode({required Node node}) async {
883 this.node = node;
884 _isBatchSupported = null;
885
886 if (syncStatus is ConnectingSyncStatus) return;
887
888 try {
889 syncStatus = ConnectingSyncStatus();
890
891 await _receiveStream?.cancel();
892 await electrumClient.close();
893 _isBatchSupported = null;
894
895 electrumClient.onConnectionStatusChange = _onConnectionStatusChange;
896
897 await electrumClient.connectToUri(node.uri, useSSL: node.useSSL);
898 } catch (e, stacktrace) {
899 printV(stacktrace);
900 printV("connectToNode $e");
901 syncStatus = FailedSyncStatus();
902 }
903 }
904
905 BigInt get networkDustAmount => BigInt.from(546);
906
907 bool _isBelowDust(BigInt amount) =>
908 amount <= networkDustAmount && network != BitcoinNetwork.testnet;
909
910 // Random draw priority per outpoint. Assigned lazily with a secure RNG and kept
911 // until the next createTransaction call, so the recursive estimate passes of one
912 // transaction build all see the same input order (reshuffling between passes made
913 // fee estimation unstable). Cleared per transaction so each send is a fresh draw.
914 final Random _coinSelectionRng = Random.secure();
915 final Map<String, int> _coinSelectionOrder = {};
916
917 int _coinSelectionPriority(BitcoinUnspent utx) => _coinSelectionOrder.putIfAbsent(
918 '${utx.hash}:${utx.vout}', () => _coinSelectionRng.nextInt(1 << 32));
919
920 UtxoDetails _createUTXOS({
921 required bool sendAll,
922 required bool paysToSilentPayment,
923 int credentialsAmount = 0,
924 int? inputsCount,
925 int feeRate = 0,
926 int? outputsVBytes,
927 UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any,
928 }) {
929 List<UtxoWithAddress> utxos = [];
930 List<Outpoint> vinOutpoints = [];
931 List<ECPrivateInfo> inputPrivKeyInfos = [];
932 final publicKeys = <String, PublicKeyWithDerivationPath>{};
933 int allInputsAmount = 0;
934 bool spendsSilentPayment = false;
935 bool spendsUnconfirmedTX = false;
936
937 int leftAmount = credentialsAmount;
938 var availableInputs = unspentCoins.where((utx) {
939 if (!utx.isSending || utx.isFrozen) {
940 return false;
941 }
942
943 switch (coinTypeToSpendFrom) {
944 case UnspentCoinType.mweb:
945 return utx.bitcoinAddressRecord.type == SegwitAddresType.mweb;
946 case UnspentCoinType.nonMweb:
947 return utx.bitcoinAddressRecord.type != SegwitAddresType.mweb;
948 case UnspentCoinType.any:
949 case UnspentCoinType.lightning:
950 return true;
951 }
952 }).toList();
953 final unconfirmedCoins = availableInputs.where((utx) => utx.confirmations == 0).toList();
954
955 // Single Random Draw: order the pool by each coin's random priority so selection is
956 // non-deterministic, removing the predictable address/scan order (a fingerprint).
957 // The priority is stable across the repeated calls of one transaction build.
958 // MWEB coins are kept last afterwards.
959 availableInputs.sort((a, b) {
960 final byPriority = _coinSelectionPriority(a).compareTo(_coinSelectionPriority(b));
961 if (byPriority != 0) return byPriority;
962 return '${a.hash}:${a.vout}'.compareTo('${b.hash}:${b.vout}');
963 });
964 availableInputs = [
965 ...availableInputs.where((u) => u.bitcoinAddressRecord.type != SegwitAddresType.mweb),
966 ...availableInputs.where((u) => u.bitcoinAddressRecord.type == SegwitAddresType.mweb),
967 ];
968
969 // Branch-and-bound: prefer an input set whose excess over the amount plus its own fee
970 // stays below dust, so the caller drops the change output and the send is changeless.
971 // When no such set exists, the shuffled pool below acts as a single random draw.
972 final canTryChangeless = !sendAll &&
973 inputsCount == null &&
974 credentialsAmount > 0 &&
975 feeRate > 0 &&
976 outputsVBytes != null &&
977 outputsVBytes > 0 &&
978 !availableInputs.any((u) => u.bitcoinAddressRecord.type == SegwitAddresType.mweb);
979 if (canTryChangeless) {
980 final match = changelessMatch(
981 values: [for (final u in availableInputs) u.value],
982 // estimatedTransactionSize(0, 0) is the fixed tx overhead (version,
983 // counters, locktime); the outputs' own vbytes come pre-computed per type.
984 target: credentialsAmount + (estimatedTransactionSize(0, 0) + outputsVBytes!) * feeRate,
985 inputCosts: [
986 for (final u in availableInputs)
987 estimatedInputSize(u.bitcoinAddressRecord.type) * feeRate
988 ],
989 window: networkDustAmount.toInt(),
990 );
991 if (match != null) {
992 final chosen = match.indices.toSet();
993 availableInputs = [
994 for (final i in match.indices) availableInputs[i],
995 for (var i = 0; i < availableInputs.length; i++)
996 if (!chosen.contains(i)) availableInputs[i],
997 ];
998 inputsCount = match.indices.length;
999 }
1000 }
1001
1002 for (int i = 0; i < availableInputs.length; i++) {
1003 final utx = availableInputs[i];
1004 if (!spendsUnconfirmedTX) spendsUnconfirmedTX = utx.confirmations == 0;
1005
1006 if (paysToSilentPayment) {
1007 // Check inputs for shared secret derivation
1008 if (utx.bitcoinAddressRecord.type == SegwitAddresType.p2wsh) {
1009 throw BitcoinTransactionSilentPaymentsNotSupported();
1010 }
1011 }
1012
1013 allInputsAmount += utx.value;
1014 leftAmount = leftAmount - utx.value;
1015
1016 final address = RegexUtils.addressTypeFromStr(utx.address, network);
1017 ECPrivate? privkey;
1018 bool? isSilentPayment = false;
1019 final hd = _hdFor(record: utx.bitcoinAddressRecord);
1020
1021 if (utx.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) {
1022 final unspentAddress = utx.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord;
1023 privkey = ECPrivate.fromHex(
1024 _masterHD!.derivePath(unspentAddress.spendDerivationPath).privateKey.toHex())
1025 .tweakAdd(
1026 BigintUtils.fromBytes(BytesUtils.fromHexString(unspentAddress.silentPaymentTweak!)),
1027 );
1028 spendsSilentPayment = true;
1029 isSilentPayment = true;
1030 } else if (!isHardwareWallet && keys.privateKey.isNotEmpty) {
1031 privkey =
1032 generateECPrivate(hd: hd, index: utx.bitcoinAddressRecord.index, network: network);
1033 }
1034
1035 vinOutpoints.add(Outpoint(txid: utx.hash, index: utx.vout));
1036 String pubKeyHex;
1037
1038 if (privkey != null) {
1039 inputPrivKeyInfos.add(ECPrivateInfo(
1040 privkey,
1041 address.type == SegwitAddresType.p2tr,
1042 tweak: !isSilentPayment,
1043 ));
1044
1045 pubKeyHex = privkey.getPublic().toHex();
1046 } else {
1047 pubKeyHex = hd.childKey(Bip32KeyIndex(utx.bitcoinAddressRecord.index)).publicKey.toHex();
1048 }
1049
1050 final baseDerivationPath = _accountDerivationPathForRecord(utx.bitcoinAddressRecord);
1051
1052 final derivationPath = "${_hardenedDerivationPath(baseDerivationPath)}"
1053 "/${utx.bitcoinAddressRecord.isHidden ? "1" : "0"}"
1054 "/${utx.bitcoinAddressRecord.index}";
1055 publicKeys[address.pubKeyHash()] = PublicKeyWithDerivationPath(pubKeyHex, derivationPath);
1056
1057 utxos.add(
1058 UtxoWithAddress(
1059 utxo: BitcoinUtxo(
1060 txHash: utx.hash,
1061 value: BigInt.from(utx.value),
1062 vout: utx.vout,
1063 scriptType: _getScriptType(address),
1064 isSilentPayment: isSilentPayment,
1065 ),
1066 ownerDetails: UtxoAddressDetails(
1067 publicKey: pubKeyHex,
1068 address: address,
1069 ),
1070 ),
1071 );
1072
1073 // sendAll continues for all inputs
1074 if (!sendAll) {
1075 bool amountIsAcquired = leftAmount <= 0;
1076 if ((inputsCount == null && amountIsAcquired) || inputsCount == i + 1) {
1077 break;
1078 }
1079 }
1080 }
1081
1082 if (utxos.isEmpty) {
1083 throw BitcoinTransactionNoInputsException();
1084 }
1085
1086 return UtxoDetails(
1087 availableInputs: availableInputs,
1088 unconfirmedCoins: unconfirmedCoins,
1089 utxos: utxos,
1090 vinOutpoints: vinOutpoints,
1091 inputPrivKeyInfos: inputPrivKeyInfos,
1092 publicKeys: publicKeys,
1093 allInputsAmount: allInputsAmount,
1094 spendsSilentPayment: spendsSilentPayment,
1095 spendsUnconfirmedTX: spendsUnconfirmedTX,
1096 );
1097 }
1098
1099 Future<EstimatedTxResult> estimateSendAllTx(
1100 List<BitcoinOutput> outputs,
1101 int feeRate, {
1102 String? memo,
1103 bool hasSilentPayment = false,
1104 UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any,
1105 }) async {
1106 final utxoDetails = _createUTXOS(
1107 sendAll: true,
1108 paysToSilentPayment: hasSilentPayment,
1109 coinTypeToSpendFrom: coinTypeToSpendFrom,
1110 );
1111
1112 int fee = await calcFee(
1113 utxos: utxoDetails.utxos,
1114 outputs: outputs,
1115 network: network,
1116 memo: memo,
1117 feeRate: feeRate,
1118 inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
1119 vinOutpoints: utxoDetails.vinOutpoints,
1120 );
1121
1122 if (fee <= 0) {
1123 throw BitcoinTransactionNoFeeException();
1124 }
1125
1126 // Here, when sending all, the output amount equals to the input value - fee to fully spend every input on the transaction and have no amount left for change
1127 final amount = BigInt.from(utxoDetails.allInputsAmount - fee);
1128
1129 if (amount <= BigInt.zero) {
1130 throw BitcoinTransactionWrongBalanceException(amount: utxoDetails.allInputsAmount + fee);
1131 }
1132
1133 // Attempting to send less than the dust limit
1134 if (_isBelowDust(amount)) {
1135 throw BitcoinTransactionNoDustException();
1136 }
1137
1138 if (outputs.length == 1) {
1139 outputs[0] = BitcoinOutput(
1140 address: outputs.last.address,
1141 value: amount,
1142 isSilentPayment: hasSilentPayment,
1143 );
1144 }
1145
1146 return EstimatedTxResult(
1147 utxos: utxoDetails.utxos,
1148 inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
1149 publicKeys: utxoDetails.publicKeys,
1150 fee: Money.fromInt(fee, currency),
1151 amount: Money(amount, currency),
1152 isSendAll: true,
1153 hasChange: false,
1154 memo: memo,
1155 spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX,
1156 spendsSilentPayment: utxoDetails.spendsSilentPayment,
1157 );
1158 }
1159
1160 Future<EstimatedTxResult> estimateTxForAmount(
1161 Money credentialsAmount,
1162 List<BitcoinOutput> outputs,
1163 List<BitcoinOutput> updatedOutputs,
1164 int feeRate, {
1165 int? inputsCount,
1166 String? memo,
1167 bool? useUnconfirmed,
1168 bool hasSilentPayment = false,
1169 UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any,
1170 }) async {
1171 // Attempting to send less than the dust limit
1172 if (_isBelowDust(credentialsAmount.amount)) {
1173 throw BitcoinTransactionNoDustException();
1174 }
1175
1176 // if mweb isn't enabled, don't consider spending mweb coins:
1177 if (this is LitecoinWallet) {
1178 var mwebEnabled = (this as LitecoinWallet).mwebEnabled;
1179 if (!mwebEnabled) {
1180 coinTypeToSpendFrom = UnspentCoinType.nonMweb;
1181 }
1182 }
1183
1184 // If there is only one output, and the amount to send is more than the max spendable amount
1185 // then it is actually a send all transaction
1186
1187 if (outputs.length == 1) {
1188 final maxSpendable = await _maxSpendableNoChangeAmount(
1189 initialOutput: outputs.first,
1190 feeRate: feeRate,
1191 memo: memo,
1192 hasSilentPayment: hasSilentPayment,
1193 coinTypeToSpendFrom: coinTypeToSpendFrom,
1194 );
1195 if (credentialsAmount > maxSpendable) {
1196 throw BitcoinTransactionWrongBalanceException();
1197 }
1198 if (credentialsAmount >= maxSpendable) {
1199 final estimateOutput = [
1200 BitcoinOutput(
1201 address: outputs.first.address,
1202 value: BigInt.zero,
1203 isSilentPayment: outputs.first.isSilentPayment,
1204 )
1205 ];
1206 return estimateSendAllTx(
1207 estimateOutput,
1208 feeRate,
1209 memo: memo,
1210 hasSilentPayment: hasSilentPayment,
1211 coinTypeToSpendFrom: coinTypeToSpendFrom,
1212 );
1213 }
1214 }
1215
1216 // Per-type output sizes for the changeless target. MWEB outputs follow a
1217 // different size model entirely, so they disable the changeless path (null).
1218 int? outputsVBytes = 0;
1219 for (final out in outputs) {
1220 final type =
1221 out.isSilentPayment == true ? SilentPaymentsAddresType.p2sp : _getScriptType(out.address);
1222 if (type == SegwitAddresType.mweb) {
1223 outputsVBytes = null;
1224 break;
1225 }
1226 outputsVBytes = outputsVBytes! + estimatedOutputSize(type);
1227 }
1228
1229 final utxoDetails = _createUTXOS(
1230 sendAll: false,
1231 credentialsAmount: credentialsAmount.amount.toInt(),
1232 inputsCount: inputsCount,
1233 feeRate: feeRate,
1234 outputsVBytes: outputsVBytes,
1235 paysToSilentPayment: hasSilentPayment,
1236 coinTypeToSpendFrom: coinTypeToSpendFrom,
1237 );
1238
1239 final spendingAllCoins = utxoDetails.availableInputs.length == utxoDetails.utxos.length;
1240 final spendingAllConfirmedCoins = !utxoDetails.spendsUnconfirmedTX &&
1241 utxoDetails.utxos.length ==
1242 utxoDetails.availableInputs.length - utxoDetails.unconfirmedCoins.length;
1243
1244 final amountLeftForChangeAndFee =
1245 utxoDetails.allInputsAmount - credentialsAmount.amount.toInt();
1246
1247 if (amountLeftForChangeAndFee <= 0) {
1248 if (!spendingAllCoins) {
1249 return estimateTxForAmount(
1250 credentialsAmount,
1251 outputs,
1252 updatedOutputs,
1253 feeRate,
1254 inputsCount: utxoDetails.utxos.length + 1,
1255 memo: memo,
1256 hasSilentPayment: hasSilentPayment,
1257 coinTypeToSpendFrom: coinTypeToSpendFrom,
1258 );
1259 }
1260
1261 throw BitcoinTransactionWrongBalanceException();
1262 }
1263
1264 final changeAddress = await walletAddresses.getChangeAddress(
1265 inputs: utxoDetails.availableInputs,
1266 outputs: updatedOutputs,
1267 coinTypeToSpendFrom: coinTypeToSpendFrom,
1268 );
1269 final address = RegexUtils.addressTypeFromStr(changeAddress.address, network);
1270 updatedOutputs.add(BitcoinOutput(
1271 address: address,
1272 value: BigInt.from(amountLeftForChangeAndFee),
1273 isChange: true,
1274 ));
1275 outputs.add(BitcoinOutput(
1276 address: address,
1277 value: BigInt.from(amountLeftForChangeAndFee),
1278 isChange: true,
1279 ));
1280
1281 // Must match the address' account root (purpose/coinType) and legacy derivation when applicable.
1282 final changeBaseDerivationPath = _accountDerivationPathForRecord(changeAddress);
1283 final changeDerivationPath = "${_hardenedDerivationPath(changeBaseDerivationPath)}"
1284 "/${changeAddress.isHidden ? "1" : "0"}"
1285 "/${changeAddress.index}";
1286 utxoDetails.publicKeys[address.pubKeyHash()] =
1287 PublicKeyWithDerivationPath('', changeDerivationPath);
1288
1289 // calcFee updates the silent payment outputs to calculate the tx size accounting
1290 // for taproot addresses, but if more inputs are needed to make up for fees,
1291 // the silent payment outputs need to be recalculated for the new inputs
1292 var temp = outputs.map((output) => output).toList();
1293 int fee = await calcFee(
1294 utxos: utxoDetails.utxos,
1295 // Always take only not updated bitcoin outputs here so for every estimation
1296 // the SP outputs are re-generated to the proper taproot addresses
1297 outputs: temp,
1298 network: network,
1299 memo: memo,
1300 feeRate: feeRate,
1301 inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
1302 vinOutpoints: utxoDetails.vinOutpoints,
1303 );
1304
1305 updatedOutputs.clear();
1306 for (int i = 0; i < temp.length; i++) {
1307 final output = temp[i];
1308 final oldOutput = outputs[i];
1309
1310 updatedOutputs.add(BitcoinOutput(
1311 address: output.address,
1312 value: output.value,
1313 isSilentPayment: oldOutput.isSilentPayment,
1314 isChange: output.isChange,
1315 ));
1316 }
1317
1318 if (fee <= 0) {
1319 throw BitcoinTransactionNoFeeException();
1320 }
1321
1322 var amount = credentialsAmount;
1323 final lastOutput = updatedOutputs.last;
1324 final amountLeftForChange = BigInt.from(amountLeftForChangeAndFee - fee);
1325
1326 if (_isBelowDust(amountLeftForChange)) {
1327 // If has change that is lower than dust, will end up with tx rejected by network rules
1328 // so remove the change amount
1329 updatedOutputs.removeLast();
1330 outputs.removeLast();
1331
1332 // If the computed change is negative or below dust:
1333 // - negative: try a no-change tx (recalculate fee without change)
1334 // - non-negative but dust: drop change and add remainder to fee
1335 if (amountLeftForChange < BigInt.zero) {
1336 final tempNoChange = outputs.map((o) => o).toList();
1337 final feeNoChange = await calcFee(
1338 utxos: utxoDetails.utxos,
1339 outputs: tempNoChange,
1340 network: network,
1341 memo: memo,
1342 feeRate: feeRate,
1343 inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
1344 vinOutpoints: utxoDetails.vinOutpoints,
1345 );
1346 final leftover =
1347 utxoDetails.allInputsAmount - credentialsAmount.amount.toInt() - feeNoChange;
1348
1349 if (leftover >= 0) {
1350 final finalFee = feeNoChange + leftover; // absorb tiny remainder
1351 return EstimatedTxResult(
1352 utxos: utxoDetails.utxos,
1353 inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
1354 publicKeys: utxoDetails.publicKeys,
1355 fee: Money.fromInt(finalFee, currency),
1356 amount: amount,
1357 hasChange: false,
1358 isSendAll: spendingAllCoins,
1359 memo: memo,
1360 spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX,
1361 spendsSilentPayment: utxoDetails.spendsSilentPayment,
1362 );
1363 }
1364
1365 if (!spendingAllCoins) {
1366 return estimateTxForAmount(
1367 credentialsAmount,
1368 outputs,
1369 updatedOutputs,
1370 feeRate,
1371 inputsCount: utxoDetails.utxos.length + 1,
1372 memo: memo,
1373 useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins,
1374 hasSilentPayment: hasSilentPayment,
1375 coinTypeToSpendFrom: coinTypeToSpendFrom,
1376 );
1377 } else {
1378 throw BitcoinTransactionWrongBalanceException();
1379 }
1380 }
1381
1382 // if the amount left for change is less than dust, but not less than 0
1383 // then add it to the fees
1384 fee += amountLeftForChange.toInt();
1385
1386 return EstimatedTxResult(
1387 utxos: utxoDetails.utxos,
1388 inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
1389 publicKeys: utxoDetails.publicKeys,
1390 fee: Money.fromInt(fee, currency),
1391 amount: amount,
1392 hasChange: false,
1393 isSendAll: spendingAllCoins,
1394 memo: memo,
1395 spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX,
1396 spendsSilentPayment: utxoDetails.spendsSilentPayment,
1397 );
1398 } else {
1399 // Here, lastOutput already is change, return the amount left without the fee to the user's address.
1400 updatedOutputs[updatedOutputs.length - 1] = BitcoinOutput(
1401 address: lastOutput.address,
1402 value: amountLeftForChange,
1403 isSilentPayment: lastOutput.isSilentPayment,
1404 isChange: true,
1405 );
1406 outputs[outputs.length - 1] = BitcoinOutput(
1407 address: lastOutput.address,
1408 value: amountLeftForChange,
1409 isSilentPayment: lastOutput.isSilentPayment,
1410 isChange: true,
1411 );
1412
1413 return EstimatedTxResult(
1414 utxos: utxoDetails.utxos,
1415 inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
1416 publicKeys: utxoDetails.publicKeys,
1417 fee: Money.fromInt(fee, currency),
1418 amount: amount,
1419 hasChange: true,
1420 isSendAll: spendingAllCoins,
1421 memo: memo,
1422 spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX,
1423 spendsSilentPayment: utxoDetails.spendsSilentPayment,
1424 );
1425 }
1426 }
1427
1428 Future<Money> _maxSpendableNoChangeAmount({
1429 required BitcoinOutput initialOutput,
1430 required int feeRate,
1431 String? memo,
1432 bool hasSilentPayment = false,
1433 UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any,
1434 }) async {
1435 final utxoDetailsAll = _createUTXOS(
1436 sendAll: true,
1437 paysToSilentPayment: hasSilentPayment,
1438 coinTypeToSpendFrom: coinTypeToSpendFrom,
1439 );
1440
1441 final output = [
1442 BitcoinOutput(
1443 address: initialOutput.address,
1444 value: BigInt.zero,
1445 isSilentPayment: initialOutput.isSilentPayment,
1446 )
1447 ];
1448
1449 final feeNoChange = await calcFee(
1450 utxos: utxoDetailsAll.utxos,
1451 outputs: output,
1452 network: network,
1453 memo: memo,
1454 feeRate: feeRate,
1455 inputPrivKeyInfos: utxoDetailsAll.inputPrivKeyInfos,
1456 vinOutpoints: utxoDetailsAll.vinOutpoints,
1457 );
1458
1459 final maxSpendable = utxoDetailsAll.allInputsAmount - feeNoChange;
1460 return Money.fromInt(maxSpendable > 0 ? maxSpendable : 0, currency);
1461 }
1462
1463 Future<int> calcFee({
1464 required List<UtxoWithAddress> utxos,
1465 required List<BitcoinBaseOutput> outputs,
1466 required BasedUtxoNetwork network,
1467 String? memo,
1468 required int feeRate,
1469 List<ECPrivateInfo>? inputPrivKeyInfos,
1470 List<Outpoint>? vinOutpoints,
1471 }) async {
1472 int estimatedSize;
1473 if (network is BitcoinCashNetwork) {
1474 estimatedSize = ForkedTransactionBuilder.estimateTransactionSize(
1475 utxos: utxos,
1476 outputs: outputs,
1477 network: network,
1478 memo: memo,
1479 );
1480 } else {
1481 estimatedSize = BitcoinTransactionBuilder.estimateTransactionSize(
1482 utxos: utxos,
1483 outputs: outputs,
1484 network: network,
1485 memo: memo,
1486 inputPrivKeyInfos: inputPrivKeyInfos,
1487 vinOutpoints: vinOutpoints,
1488 );
1489 }
1490
1491 return feeAmountWithFeeRate(feeRate, 0, 0, size: estimatedSize);
1492 }
1493
1494 @override
1495 Future<PendingTransaction> createTransaction(Object credentials) async {
1496 try {
1497 // New transaction, new random draw: drop the previous input ordering so this
1498 // build gets fresh priorities, then keep them fixed for all estimate passes.
1499 _coinSelectionOrder.clear();
1500
1501 // start by updating unspent coins
1502 await updateAllUnspents();
1503
1504 final outputs = <BitcoinOutput>[];
1505 final transactionCredentials = credentials as BitcoinTransactionCredentials;
1506 final hasMultiDestination = transactionCredentials.outputs.length > 1;
1507 final sendAll = !hasMultiDestination && transactionCredentials.outputs.first.sendAll;
1508 final memo = transactionCredentials.outputs.first.memo;
1509 final coinTypeToSpendFrom = transactionCredentials.coinTypeToSpendFrom;
1510
1511 var credentialsAmount = Money.zero(currency);
1512 var hasSilentPayment = false;
1513
1514 for (final out in transactionCredentials.outputs) {
1515 final outputAmount = out.cryptoAmount;
1516
1517 if (!sendAll && _isBelowDust(outputAmount.amount)) {
1518 throw BitcoinTransactionNoDustException();
1519 }
1520
1521 if (hasMultiDestination) {
1522 if (out.sendAll) {
1523 throw BitcoinTransactionWrongBalanceException();
1524 }
1525 }
1526
1527 credentialsAmount += outputAmount;
1528
1529 final address = RegexUtils.addressTypeFromStr(
1530 out.isParsedAddress ? out.extractedAddress! : out.address, network);
1531 final isSilentPayment = address is SilentPaymentAddress;
1532
1533 if (isSilentPayment) {
1534 hasSilentPayment = true;
1535 }
1536
1537 if (sendAll) {
1538 // The value will be changed after estimating the Tx size and deducting the fee from the total to be sent
1539 outputs.add(BitcoinOutput(
1540 address: address,
1541 value: BigInt.zero,
1542 isSilentPayment: isSilentPayment,
1543 ));
1544 } else {
1545 outputs.add(BitcoinOutput(
1546 address: address,
1547 value: outputAmount.amount,
1548 isSilentPayment: isSilentPayment,
1549 ));
1550 }
1551 }
1552
1553 final feeRateInt = transactionCredentials.feeRate != null
1554 ? transactionCredentials.feeRate!
1555 : feeRate(transactionCredentials.priority!);
1556
1557 EstimatedTxResult estimatedTx;
1558 final updatedOutputs = outputs
1559 .map((e) => BitcoinOutput(
1560 address: e.address,
1561 value: e.value,
1562 isSilentPayment: e.isSilentPayment,
1563 isChange: e.isChange,
1564 ))
1565 .toList();
1566
1567 if (sendAll) {
1568 estimatedTx = await estimateSendAllTx(
1569 updatedOutputs,
1570 feeRateInt,
1571 memo: memo,
1572 hasSilentPayment: hasSilentPayment,
1573 coinTypeToSpendFrom: coinTypeToSpendFrom,
1574 );
1575 } else {
1576 estimatedTx = await estimateTxForAmount(
1577 credentialsAmount,
1578 outputs,
1579 updatedOutputs,
1580 feeRateInt,
1581 memo: memo,
1582 hasSilentPayment: hasSilentPayment,
1583 coinTypeToSpendFrom: coinTypeToSpendFrom,
1584 );
1585 }
1586
1587 for (final output in updatedOutputs) {
1588 // TODO: get from server
1589 // if (output.isSilentPayment && output.value.toInt() > silentPaymentsMin) {
1590 if (output.isSilentPayment && output.value.toInt() <= 1000) {
1591 throw BitcoinTransactionNoDustException();
1592 }
1593 }
1594
1595 if (walletInfo.isHardwareWallet) {
1596 final transaction = await buildHardwareWalletTransaction(
1597 utxos: estimatedTx.utxos,
1598 outputs: updatedOutputs,
1599 publicKeys: estimatedTx.publicKeys,
1600 fee: estimatedTx.fee.amount,
1601 network: network,
1602 memo: estimatedTx.memo,
1603 // Shuffle so the change output isn't placed deterministically last
1604 // (privacy fingerprint). Applied by orderOutputs in the builder.
1605 outputOrdering: BitcoinOrdering.shuffle,
1606 enableRBF: true,
1607 cwOutputs: transactionCredentials.outputs,
1608 );
1609
1610 return PendingBitcoinTransaction(
1611 transaction,
1612 type,
1613 electrumClient: electrumClient,
1614 amount: estimatedTx.amount,
1615 fee: estimatedTx.fee,
1616 feeRate: feeRateInt.toString(),
1617 network: network,
1618 hasChange: estimatedTx.hasChange,
1619 isSendAll: estimatedTx.isSendAll,
1620 hasTaprootInputs: false,
1621 // ToDo: (Konsti) Support Taproot,
1622 isViewOnly: false,
1623 )..addListener((transaction) async {
1624 transactionHistory.addOne(transaction);
1625 await updateBalance();
1626 await updateAllUnspents();
1627 });
1628 }
1629
1630 final locktime = await _antiFeeSnipingLocktime();
1631
1632 BasedBitcoinTransacationBuilder txb;
1633 if (network is BitcoinCashNetwork) {
1634 txb = ForkedTransactionBuilder(
1635 utxos: estimatedTx.utxos,
1636 outputs: updatedOutputs,
1637 fee: estimatedTx.fee.amount,
1638 network: network,
1639 memo: estimatedTx.memo,
1640 // Shuffle so the change output isn't placed deterministically last
1641 // (privacy fingerprint). Change is found by isChange, not position.
1642 inputOrdering: BitcoinOrdering.shuffle,
1643 outputOrdering: BitcoinOrdering.shuffle,
1644 enableRBF: !estimatedTx.spendsUnconfirmedTX,
1645 );
1646 } else {
1647 txb = BitcoinTransactionBuilder(
1648 utxos: estimatedTx.utxos,
1649 outputs: updatedOutputs,
1650 fee: estimatedTx.fee.amount,
1651 network: network,
1652 memo: estimatedTx.memo,
1653 inputOrdering: BitcoinOrdering.shuffle,
1654 outputOrdering: BitcoinOrdering.shuffle,
1655 enableRBF: !estimatedTx.spendsUnconfirmedTX,
1656 locktime: locktime,
1657 );
1658 }
1659
1660 bool hasTaprootInputs = false;
1661
1662 final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) {
1663 if (keys.privateKey.isEmpty) return "";
1664 String error = "Cannot find private key.";
1665
1666 ECPrivateInfo? key;
1667
1668 if (estimatedTx.inputPrivKeyInfos.isEmpty) {
1669 error += "\nNo private keys generated.";
1670 } else {
1671 error += "\nAddress: ${utxo.ownerDetails.address.toAddress(network)}";
1672
1673 key = estimatedTx.inputPrivKeyInfos.firstWhereOrNull((element) {
1674 final elemPubkey = element.privkey.getPublic().toHex();
1675 if (elemPubkey == publicKey) {
1676 return true;
1677 } else {
1678 error += "\nExpected: $publicKey";
1679 error += "\nPubkey: $elemPubkey";
1680 return false;
1681 }
1682 });
1683 }
1684
1685 if (key == null) {
1686 throw Exception(error);
1687 }
1688
1689 if (utxo.utxo.isP2tr()) {
1690 hasTaprootInputs = true;
1691 return key.privkey.signTapRoot(
1692 txDigest,
1693 sighash: sighash,
1694 tweak: utxo.utxo.isSilentPayment != true,
1695 );
1696 } else {
1697 return key.privkey.signInput(txDigest, sigHash: sighash);
1698 }
1699 });
1700
1701 return PendingBitcoinTransaction(
1702 transaction,
1703 type,
1704 electrumClient: electrumClient,
1705 amount: estimatedTx.amount,
1706 fee: estimatedTx.fee,
1707 feeRate: feeRateInt.toString(),
1708 network: network,
1709 hasChange: estimatedTx.hasChange,
1710 isSendAll: estimatedTx.isSendAll,
1711 hasTaprootInputs: hasTaprootInputs,
1712 utxos: estimatedTx.utxos,
1713 derivedOutputs: updatedOutputs,
1714 publicKeys: estimatedTx.publicKeys,
1715 isViewOnly: keys.privateKey.isEmpty,
1716 )..addListener((transaction) async {
1717 transactionHistory.addOne(transaction);
1718 if (estimatedTx.spendsSilentPayment) {
1719 transactionHistory.transactions.values.forEach((tx) {
1720 tx.unspents?.removeWhere(
1721 (unspent) => estimatedTx.utxos.any((e) => e.utxo.txHash == unspent.hash));
1722 transactionHistory.addOne(tx);
1723 });
1724 }
1725
1726 unspentCoins
1727 .removeWhere((utxo) => estimatedTx.utxos.any((e) => e.utxo.txHash == utxo.hash));
1728
1729 await updateBalance();
1730 await updateAllUnspents();
1731 });
1732 } catch (e) {
1733 throw e;
1734 }
1735 }
1736
1737 HardwareWalletService? hardwareWalletService;
1738
1739 Future<BtcTransaction> buildHardwareWalletTransaction({
1740 required List<BitcoinBaseOutput> outputs,
1741 required BigInt fee,
1742 required BasedUtxoNetwork network,
1743 required List<UtxoWithAddress> utxos,
1744 required List<OutputInfo> cwOutputs,
1745 required Map<String, PublicKeyWithDerivationPath> publicKeys,
1746 String? memo,
1747 bool enableRBF = false,
1748 BitcoinOrdering inputOrdering = BitcoinOrdering.bip69,
1749 BitcoinOrdering outputOrdering = BitcoinOrdering.bip69,
1750 }) async =>
1751 throw UnimplementedError();
1752
1753 String toJSON() => json.encode({
1754 'mnemonic': _mnemonic,
1755 'xpub': xpub,
1756 'passphrase': passphrase ?? '',
1757 'account_index': walletAddresses.currentReceiveAddressIndexByType,
1758 'change_address_index': walletAddresses.currentChangeAddressIndexByType,
1759 'addresses': walletAddresses.allAddresses.map((addr) => addr.toJSON()).toList(),
1760 'address_page_type': walletInfo.addressPageType == null
1761 ? SegwitAddresType.p2wpkh.toString()
1762 : walletInfo.addressPageType.toString(),
1763 'balance': balance[currency]?.toJSON(),
1764 'lightningBalance': balance[CryptoCurrency.btcln]?.toJSON(),
1765 'derivationTypeIndex': derivationInfo.derivationType?.index,
1766 'derivationPath': derivationInfo.derivationPath,
1767 'silent_addresses': walletAddresses.silentAddresses.map((addr) => addr.toJSON()).toList(),
1768 'silent_address_index': walletAddresses.currentSilentAddressIndex.toString(),
1769 'mweb_addresses': walletAddresses.mwebAddresses.map((addr) => addr.toJSON()).toList(),
1770 'alwaysScan': alwaysScan,
1771 'useLightning': useLightning,
1772 'cachedLightningAddress': walletAddresses.lightningAddress
1773 });
1774
1775 int feeRate(TransactionPriority priority) {
1776 try {
1777 if (priority is BitcoinTransactionPriority) {
1778 return _feeRates[priority.raw];
1779 }
1780
1781 return 0;
1782 } catch (_) {
1783 return 0;
1784 }
1785 }
1786
1787 int feeAmountForPriority(TransactionPriority priority, int inputsCount, int outputsCount,
1788 {int? size}) =>
1789 feeRate(priority) * (size ?? estimatedTransactionSize(inputsCount, outputsCount));
1790
1791 int feeAmountWithFeeRate(int feeRate, int inputsCount, int outputsCount, {int? size}) =>
1792 feeRate * (size ?? estimatedTransactionSize(inputsCount, outputsCount));
1793
1794 @override
1795 int calculateEstimatedFee(TransactionPriority? priority, int? amount,
1796 {int? outputsCount, int? size}) {
1797 if (priority is BitcoinTransactionPriority) {
1798 return calculateEstimatedFeeWithFeeRate(feeRate(priority), amount,
1799 outputsCount: outputsCount, size: size);
1800 }
1801
1802 return 0;
1803 }
1804
1805 int calculateEstimatedFeeWithFeeRate(int feeRate, int? amount, {int? outputsCount, int? size}) {
1806 if (size != null) {
1807 return feeAmountWithFeeRate(feeRate, 0, 0, size: size);
1808 }
1809
1810 int inputsCount = 0;
1811
1812 if (amount != null) {
1813 int totalValue = 0;
1814
1815 for (final input in unspentCoins) {
1816 if (totalValue >= amount) {
1817 break;
1818 }
1819
1820 if (input.isSending) {
1821 totalValue += input.value;
1822 inputsCount += 1;
1823 }
1824 }
1825
1826 if (totalValue < amount) return 0;
1827 } else {
1828 for (final input in unspentCoins) {
1829 if (input.isSending) {
1830 inputsCount += 1;
1831 }
1832 }
1833 }
1834
1835 // If send all, then we have no change value
1836 final _outputsCount = outputsCount ?? (amount != null ? 2 : 1);
1837
1838 return feeAmountWithFeeRate(feeRate, inputsCount, _outputsCount);
1839 }
1840
1841 @override
1842 Future<void> save() async {
1843 if (!(await WalletKeysFile.hasKeysFile(walletInfo.name, walletInfo.type))) {
1844 await saveKeysFile(_password, encryptionFileUtils);
1845 saveKeysFile(_password, encryptionFileUtils, true);
1846 }
1847
1848 final path = await makePath();
1849 await encryptionFileUtils.write(path: path, password: _password, data: toJSON());
1850 await transactionHistory.save();
1851 }
1852
1853 @override
1854 Future<void> changePassword(String password) async {
1855 _password = password;
1856 await save();
1857 await transactionHistory.changePassword(password);
1858 }
1859
1860 @action
1861 @override
1862 Future<void> rescan({required int height, bool? doSingleScan}) async {
1863 if (keys.privateKey.isEmpty) return;
1864
1865 silentPaymentsScanningActive = true;
1866 _setListeners(height, doSingleScan: doSingleScan);
1867 }
1868
1869 @override
1870 Future<void> close({bool shouldCleanup = false}) async {
1871 try {
1872 await _receiveStream?.cancel();
1873 await electrumClient.close();
1874 _isBatchSupported = null;
1875 } catch (_) {}
1876 _autoSaveTimer?.cancel();
1877 _updateFeeRateTimer?.cancel();
1878 }
1879
1880 @action
1881 Future<void> updateAllUnspents() async {
1882 List<BitcoinUnspent> updatedUnspentCoins = [];
1883
1884 final previousUnspentCoins = List<BitcoinUnspent>.from(unspentCoins.where((utxo) =>
1885 utxo.bitcoinAddressRecord.type != SegwitAddresType.mweb &&
1886 utxo.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord));
1887
1888 if (hasSilentPaymentsScanning) {
1889 // Update unspents stored from scanned silent payment transactions
1890 transactionHistory.transactions.values.forEach((tx) {
1891 if (tx.unspents != null) {
1892 updatedUnspentCoins.addAll(tx.unspents!);
1893 }
1894 });
1895 }
1896
1897 // Set the balance of all non-silent payment and non-mweb addresses to 0 before updating
1898
1899 final targetAddresses = walletAddresses.allAddresses
1900 .where((element) => element.type != SegwitAddresType.mweb)
1901 .toList();
1902
1903 for (final addr in targetAddresses) {
1904 if (addr is! BitcoinSilentPaymentAddressRecord) {
1905 addr.balance = 0;
1906 }
1907 }
1908
1909 final results = shouldUseBatchFetching
1910 ? await _fetchUnspentsBatch(targetAddresses)
1911 : await _fetchUnspentsRegular(targetAddresses);
1912
1913 final failedCount = results.where((result) => result == null).length;
1914
1915 if (failedCount == 0) {
1916 for (final result in results) {
1917 updatedUnspentCoins.addAll(result!);
1918 }
1919 unspentCoins = updatedUnspentCoins;
1920 } else {
1921 if (updatedUnspentCoins.isEmpty) {
1922 unspentCoins = handleFailedUtxoFetch(
1923 failedCount: failedCount,
1924 previousUnspentCoins: previousUnspentCoins,
1925 updatedUnspentCoins: updatedUnspentCoins,
1926 results: results,
1927 );
1928 } else {
1929 unspentCoins = updatedUnspentCoins;
1930 }
1931 }
1932
1933 final currentWalletUnspentCoins =
1934 unspentCoinsInfo.values.where((element) => element.walletId == id);
1935
1936 if (currentWalletUnspentCoins.length != updatedUnspentCoins.length) {
1937 unspentCoins.forEach((coin) => addCoinInfo(coin));
1938 }
1939
1940 await updateCoins(unspentCoins);
1941 await _refreshUnspentCoinsInfo();
1942 }
1943
1944 Future<List<List<BitcoinUnspent>?>> _fetchUnspentsRegular(
1945 List<BitcoinAddressRecord> addresses,
1946 ) async {
1947 final addressFutures = addresses.map((address) => fetchUnspent(address)).toList();
1948 return Future.wait(addressFutures);
1949 }
1950
1951 Future<List<List<BitcoinUnspent>?>> _fetchUnspentsBatch(
1952 List<BitcoinAddressRecord> addresses,
1953 ) async {
1954 final byScriptHash = <String, BitcoinAddressRecord>{
1955 for (final address in addresses) address.getScriptHash(network): address,
1956 };
1957
1958 final scriptHashes = byScriptHash.keys.toList();
1959
1960 try {
1961 final unspentByScriptHash =
1962 await _processChunksToMap<String, String, List<Map<String, dynamic>>>(
1963 items: scriptHashes,
1964 chunkSize: addressHistoryChunkSize,
1965 processChunk: _getListUnspentBatch,
1966 );
1967
1968 final txHashes = <String>{};
1969 final coinsByScriptHash = <String, List<BitcoinUnspent>>{};
1970
1971 for (final entry in unspentByScriptHash.entries) {
1972 final addressRecord = byScriptHash[entry.key];
1973 if (addressRecord == null) continue;
1974
1975 final coins = <BitcoinUnspent>[];
1976
1977 for (final unspent in entry.value) {
1978 final coin = BitcoinUnspent.fromJSON(addressRecord, unspent);
1979 coin.isChange = addressRecord.isHidden;
1980 coins.add(coin);
1981 txHashes.add(coin.hash);
1982 }
1983
1984 coinsByScriptHash[entry.key] = coins;
1985 }
1986
1987 final txInfoByHash = await fetchTransactionInfoBatch(
1988 hashes: txHashes.toList(),
1989 retryOnFailure: true,
1990 retryDelay: const Duration(seconds: 1),
1991 );
1992
1993 for (final coins in coinsByScriptHash.values) {
1994 for (final coin in coins) {
1995 final tx = txInfoByHash[coin.hash];
1996 coin.confirmations = tx?.confirmations;
1997 coin.isPegOut = tx?.isHogEx;
1998 }
1999 }
2000
2001 return addresses.map((address) {
2002 final scriptHash = address.getScriptHash(network);
2003 return coinsByScriptHash[scriptHash] ?? <BitcoinUnspent>[];
2004 }).toList();
2005 } catch (e) {
2006 printV('fetchUnspentsBatch failed: $e');
2007 return List<List<BitcoinUnspent>?>.filled(addresses.length, null);
2008 }
2009 }
2010
2011 List<BitcoinUnspent> handleFailedUtxoFetch({
2012 required int failedCount,
2013 required List<BitcoinUnspent> previousUnspentCoins,
2014 required List<BitcoinUnspent> updatedUnspentCoins,
2015 required List<List<BitcoinUnspent>?> results,
2016 }) {
2017 if (failedCount == results.length) {
2018 printV("All UTXOs failed to fetch, falling back to previous UTXOs");
2019 return previousUnspentCoins;
2020 }
2021
2022 final successfulUtxos = <BitcoinUnspent>[];
2023 for (final result in results) {
2024 if (result != null) {
2025 successfulUtxos.addAll(result);
2026 }
2027 }
2028
2029 if (failedCount > 0 && successfulUtxos.isEmpty) {
2030 printV("Some UTXOs failed, but no successful UTXOs, falling back to previous UTXOs");
2031 return previousUnspentCoins;
2032 }
2033
2034 if (failedCount > 0) {
2035 printV("Some UTXOs failed, updating with successful UTXOs");
2036 updatedUnspentCoins.addAll(successfulUtxos);
2037 }
2038
2039 return updatedUnspentCoins;
2040 }
2041
2042 Future<void> updateCoins(List<BitcoinUnspent> newUnspentCoins) async {
2043 if (newUnspentCoins.isEmpty) {
2044 return;
2045 }
2046
2047 newUnspentCoins.forEach((coin) {
2048 final coinInfoList = unspentCoinsInfo.values.where(
2049 (element) =>
2050 element.walletId.contains(id) &&
2051 element.hash.contains(coin.hash) &&
2052 element.vout == coin.vout,
2053 );
2054
2055 if (coinInfoList.isNotEmpty) {
2056 final coinInfo = coinInfoList.first;
2057
2058 coin.isFrozen = coinInfo.isFrozen;
2059 coin.isSending = coinInfo.isSending;
2060 coin.note = coinInfo.note;
2061
2062 if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord)
2063 coin.bitcoinAddressRecord.balance += coinInfo.value;
2064 } else {
2065 addCoinInfo(coin);
2066 }
2067 });
2068 }
2069
2070 @action
2071 Future<void> updateUnspentsForAddress(BitcoinAddressRecord address) async {
2072 final newUnspentCoins = await fetchUnspent(address);
2073 await updateCoins(newUnspentCoins ?? []);
2074 }
2075
2076 @action
2077 Future<List<BitcoinUnspent>?> fetchUnspent(BitcoinAddressRecord address) async {
2078 List<BitcoinUnspent> updatedUnspentCoins = [];
2079
2080 final unspents = await electrumClient.getListUnspent(address.getScriptHash(network));
2081
2082 // Failed to fetch unspents
2083 if (unspents == null) return null;
2084
2085 await Future.wait(unspents.map((unspent) async {
2086 try {
2087 final coin = BitcoinUnspent.fromJSON(address, unspent);
2088 final tx = await fetchTransactionInfo(hash: coin.hash);
2089 coin.isChange = address.isHidden;
2090 coin.confirmations = tx?.confirmations;
2091 coin.isPegOut = tx?.isHogEx;
2092
2093 updatedUnspentCoins.add(coin);
2094 } catch (_) {}
2095 }));
2096
2097 return updatedUnspentCoins;
2098 }
2099
2100 @action
2101 Future<void> addCoinInfo(BitcoinUnspent coin) async {
2102 // Check if the coin is already in the unspentCoinsInfo for the wallet
2103 final existingCoinInfo = unspentCoinsInfo.values.firstWhereOrNull(
2104 (element) =>
2105 element.walletId == walletInfo.id &&
2106 element.hash == coin.hash &&
2107 element.vout == coin.vout,
2108 );
2109
2110 if (existingCoinInfo == null) {
2111 final newInfo = UnspentCoinsInfo(
2112 walletId: id,
2113 hash: coin.hash,
2114 isFrozen: coin.isFrozen,
2115 isSending: coin.isSending,
2116 noteRaw: coin.note,
2117 address: coin.bitcoinAddressRecord.address,
2118 value: coin.value,
2119 vout: coin.vout,
2120 isChange: coin.isChange,
2121 isSilentPayment: coin is BitcoinSilentPaymentsUnspent,
2122 );
2123
2124 await unspentCoinsInfo.add(newInfo);
2125 }
2126 }
2127
2128 Future<void> _refreshUnspentCoinsInfo() async {
2129 try {
2130 final List<dynamic> keys = [];
2131 final currentWalletUnspentCoins =
2132 unspentCoinsInfo.values.where((record) => record.walletId == id);
2133
2134 for (final element in currentWalletUnspentCoins) {
2135 if (element.isFrozen) continue;
2136 if (RegexUtils.addressTypeFromStr(element.address, network) is MwebAddress) continue;
2137
2138 final existUnspentCoins = unspentCoins.where((coin) => element == coin);
2139
2140 if (existUnspentCoins.isEmpty) {
2141 keys.add(element.key);
2142 }
2143 }
2144
2145 if (keys.isNotEmpty) {
2146 await unspentCoinsInfo.deleteAll(keys);
2147 }
2148 } catch (e) {
2149 printV("refreshUnspentCoinsInfo $e");
2150 }
2151 }
2152
2153 Future<void> cleanUpDuplicateUnspentCoins() async {
2154 final currentWalletUnspentCoins =
2155 unspentCoinsInfo.values.where((element) => element.walletId == id);
2156 final Map<String, UnspentCoinsInfo> uniqueUnspentCoins = {};
2157 final List<dynamic> duplicateKeys = [];
2158
2159 for (final unspentCoin in currentWalletUnspentCoins) {
2160 final key = '${unspentCoin.hash}:${unspentCoin.vout}';
2161 if (!uniqueUnspentCoins.containsKey(key)) {
2162 uniqueUnspentCoins[key] = unspentCoin;
2163 } else {
2164 duplicateKeys.add(unspentCoin.key);
2165 }
2166 }
2167
2168 if (duplicateKeys.isNotEmpty) await unspentCoinsInfo.deleteAll(duplicateKeys);
2169 }
2170
2171 int transactionVSize(String transactionHex) => BtcTransaction.fromRaw(transactionHex).getVSize();
2172
2173 Future<String?> canReplaceByFee(ElectrumTransactionInfo tx) async {
2174 try {
2175 final bundle = await getTransactionExpanded(hash: tx.txHash);
2176 _updateInputsAndOutputs(tx, bundle);
2177 if (bundle.confirmations > 0) return null;
2178 return bundle.originalTransaction.canReplaceByFee ? bundle.originalTransaction.toHex() : null;
2179 } catch (e) {
2180 return null;
2181 }
2182 }
2183
2184 Future<bool> isChangeSufficientForFee(String txId, int newFee) async {
2185 final bundle = await getTransactionExpanded(hash: txId);
2186 final outputs = bundle.originalTransaction.outputs;
2187
2188 final ownAddresses = walletAddresses.allAddresses.map((addr) => addr.address).toSet();
2189
2190 final receiverAmount = outputs
2191 .where((output) =>
2192 !ownAddresses.contains(addressFromOutputScript(output.scriptPubKey, network)))
2193 .fold<int>(0, (sum, output) => sum + output.amount.toInt());
2194
2195 if (receiverAmount == 0) {
2196 throw Exception("Receiver output not found.");
2197 }
2198
2199 final availableInputs = unspentCoins.where((utxo) => utxo.isSending && !utxo.isFrozen).toList();
2200 int totalBalance = availableInputs.fold<int>(
2201 0, (previousValue, element) => previousValue + element.value.toInt());
2202
2203 int allInputsAmount = 0;
2204 for (int i = 0; i < bundle.originalTransaction.inputs.length; i++) {
2205 final input = bundle.originalTransaction.inputs[i];
2206 final inputTransaction = bundle.ins[i];
2207 if (inputTransaction == null) {
2208 throw Exception("Missing input transaction for fee calculation");
2209 }
2210 final vout = input.txIndex;
2211 final outTransaction = inputTransaction.outputs[vout];
2212 allInputsAmount += outTransaction.amount.toInt();
2213 }
2214
2215 final totalOutAmount = bundle.originalTransaction.outputs
2216 .fold<int>(0, (previousValue, element) => previousValue + element.amount.toInt());
2217 var currentFee = allInputsAmount - totalOutAmount;
2218
2219 final remainingFee = (newFee - currentFee > 0) ? newFee - currentFee : newFee;
2220 return totalBalance - receiverAmount - remainingFee >= networkDustAmount.toInt();
2221 }
2222
2223 Future<PendingBitcoinTransaction> replaceByFee(String hash, int newFee) async {
2224 try {
2225 final bundle = await getTransactionExpanded(hash: hash);
2226
2227 final utxos = <UtxoWithAddress>[];
2228 final outputs = <BitcoinOutput>[];
2229 List<ECPrivate> privateKeys = [];
2230
2231 var allInputsAmount = 0;
2232 String? memo;
2233
2234 // Add original inputs
2235 for (var i = 0; i < bundle.originalTransaction.inputs.length; i++) {
2236 final input = bundle.originalTransaction.inputs[i];
2237 final inputTransaction = bundle.ins[i];
2238 if (inputTransaction == null) {
2239 throw Exception("Missing input transaction for replace-by-fee");
2240 }
2241 final vout = input.txIndex;
2242 final outTransaction = inputTransaction.outputs[vout];
2243 final address = addressFromOutputScript(outTransaction.scriptPubKey, network);
2244 allInputsAmount += outTransaction.amount.toInt();
2245
2246 final addressRecord =
2247 walletAddresses.allAddresses.firstWhere((element) => element.address == address);
2248 final btcAddress = RegexUtils.addressTypeFromStr(addressRecord.address, network);
2249
2250 final hd = _hdFor(record: addressRecord);
2251
2252 final privkey = generateECPrivate(hd: hd, index: addressRecord.index, network: network);
2253
2254 privateKeys.add(privkey);
2255
2256 utxos.add(
2257 UtxoWithAddress(
2258 utxo: BitcoinUtxo(
2259 txHash: input.txId,
2260 value: outTransaction.amount,
2261 vout: vout,
2262 scriptType: _getScriptType(btcAddress),
2263 ),
2264 ownerDetails:
2265 UtxoAddressDetails(publicKey: privkey.getPublic().toHex(), address: btcAddress),
2266 ),
2267 );
2268 }
2269
2270 // Add original outputs
2271 for (final out in bundle.originalTransaction.outputs) {
2272 final script = out.scriptPubKey.script;
2273 if (script.contains('OP_RETURN') && memo == null) {
2274 final index = script.indexOf('OP_RETURN');
2275 if (index + 1 <= script.length) {
2276 try {
2277 final opReturnData = script[index + 1].toString();
2278 memo = utf8.decode(HEX.decode(opReturnData));
2279 continue;
2280 } catch (_) {
2281 throw Exception('Cannot decode OP_RETURN data');
2282 }
2283 }
2284 }
2285
2286 final address = addressFromOutputScript(out.scriptPubKey, network);
2287 final btcAddress = RegexUtils.addressTypeFromStr(address, network);
2288 outputs.add(BitcoinOutput(address: btcAddress, value: BigInt.from(out.amount.toInt())));
2289 }
2290
2291 // Calculate the total amount and fees
2292 int totalOutAmount =
2293 outputs.fold<int>(0, (previousValue, output) => previousValue + output.value.toInt());
2294 int currentFee = allInputsAmount - totalOutAmount;
2295 var remainingFee = BigInt.from(newFee - currentFee);
2296
2297 if (remainingFee <= BigInt.zero) {
2298 throw Exception("New fee must be higher than the current fee.");
2299 }
2300
2301 // Deduct fee from change outputs first, if possible
2302 if (remainingFee > BigInt.zero) {
2303 final changeAddresses = walletAddresses.allAddresses.where((element) => element.isHidden);
2304 for (int i = outputs.length - 1; i >= 0; i--) {
2305 final output = outputs[i];
2306 final isChange = changeAddresses
2307 .any((element) => element.address == output.address.toAddress(network));
2308
2309 if (isChange) {
2310 final outputAmount = output.value;
2311 if (outputAmount > networkDustAmount) {
2312 final deduction = (outputAmount - networkDustAmount >= remainingFee)
2313 ? remainingFee
2314 : outputAmount - networkDustAmount;
2315 outputs[i] = BitcoinOutput(address: output.address, value: outputAmount - deduction);
2316 remainingFee -= deduction;
2317
2318 if (remainingFee <= BigInt.zero) break;
2319 }
2320 }
2321 }
2322 }
2323
2324 // If still not enough, add UTXOs until the fee is covered, drawing them at
2325 // random instead of in the predictable wallet scan order (address, then age).
2326 if (remainingFee > BigInt.zero) {
2327 final unusedUtxos = unspentCoins
2328 .where((utxo) => utxo.isSending && !utxo.isFrozen && utxo.confirmations! > 0)
2329 .toList()
2330 ..shuffle(Random.secure());
2331
2332 for (final utxo in unusedUtxos) {
2333 final address = RegexUtils.addressTypeFromStr(utxo.address, network);
2334
2335 final hd = _hdFor(record: utxo.bitcoinAddressRecord);
2336
2337 final privkey = generateECPrivate(
2338 hd: hd,
2339 index: utxo.bitcoinAddressRecord.index,
2340 network: network,
2341 );
2342 privateKeys.add(privkey);
2343
2344 utxos.add(UtxoWithAddress(
2345 utxo: BitcoinUtxo(
2346 txHash: utxo.hash,
2347 value: BigInt.from(utxo.value),
2348 vout: utxo.vout,
2349 scriptType: _getScriptType(address)),
2350 ownerDetails:
2351 UtxoAddressDetails(publicKey: privkey.getPublic().toHex(), address: address),
2352 ));
2353
2354 allInputsAmount += utxo.value;
2355 remainingFee -= BigInt.from(utxo.value);
2356
2357 if (remainingFee < BigInt.zero) {
2358 final changeOutput = outputs.firstWhereOrNull((output) => walletAddresses.allAddresses
2359 .any((addr) => addr.address == output.address.toAddress(network)));
2360 if (changeOutput != null) {
2361 final newValue = changeOutput.value + (-remainingFee);
2362 outputs[outputs.indexOf(changeOutput)] =
2363 BitcoinOutput(address: changeOutput.address, value: newValue);
2364 } else {
2365 final changeAddress = await walletAddresses.getChangeAddress();
2366 outputs.add(BitcoinOutput(
2367 address: RegexUtils.addressTypeFromStr(changeAddress.address, network),
2368 value: -remainingFee));
2369 }
2370
2371 remainingFee = BigInt.zero;
2372 break;
2373 }
2374
2375 if (remainingFee <= BigInt.zero) break;
2376 }
2377 }
2378
2379 // Deduct from the receiver's output if remaining fee is still greater than 0
2380 if (remainingFee > BigInt.zero) {
2381 for (int i = 0; i < outputs.length; i++) {
2382 final output = outputs[i];
2383 final outputAmount = output.value;
2384
2385 if (outputAmount > networkDustAmount) {
2386 final deduction = (outputAmount - networkDustAmount >= remainingFee)
2387 ? remainingFee
2388 : outputAmount - networkDustAmount;
2389
2390 outputs[i] = BitcoinOutput(address: output.address, value: outputAmount - deduction);
2391 remainingFee -= deduction;
2392
2393 if (remainingFee <= BigInt.zero) break;
2394 }
2395 }
2396 }
2397
2398 // Final check if the remaining fee couldn't be deducted
2399 if (remainingFee > BigInt.zero) {
2400 throw Exception("Not enough funds to cover the fee.");
2401 }
2402
2403 // Identify all change outputs
2404 final changeAddresses = walletAddresses.allAddresses.where((element) => element.isHidden);
2405 final List<BitcoinOutput> changeOutputs = outputs
2406 .where((output) => changeAddresses
2407 .any((element) => element.address == output.address.toAddress(network)))
2408 .toList();
2409
2410 int totalChangeAmount =
2411 changeOutputs.fold<int>(0, (sum, output) => sum + output.value.toInt());
2412
2413 // The final amount that the receiver will receive
2414 int sendingAmount = allInputsAmount - newFee - totalChangeAmount;
2415
2416 final txb = BitcoinTransactionBuilder(
2417 utxos: utxos,
2418 outputs: outputs,
2419 fee: BigInt.from(newFee),
2420 network: network,
2421 memo: memo,
2422 inputOrdering: BitcoinOrdering.shuffle,
2423 outputOrdering: BitcoinOrdering.shuffle,
2424 enableRBF: true,
2425 locktime: await _antiFeeSnipingLocktime(),
2426 );
2427
2428 final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) {
2429 final key =
2430 privateKeys.firstWhereOrNull((element) => element.getPublic().toHex() == publicKey);
2431 if (key == null) {
2432 throw Exception("Cannot find private key");
2433 }
2434
2435 if (utxo.utxo.isP2tr()) {
2436 return key.signTapRoot(txDigest, sighash: sighash);
2437 } else {
2438 return key.signInput(txDigest, sigHash: sighash);
2439 }
2440 });
2441
2442 return PendingBitcoinTransaction(
2443 transaction,
2444 type,
2445 electrumClient: electrumClient,
2446 amount: Money.fromInt(sendingAmount, currency),
2447 fee: Money.fromInt(newFee, currency),
2448 network: network,
2449 hasChange: changeOutputs.isNotEmpty,
2450 feeRate: newFee.toString(),
2451 isViewOnly: keys.privateKey.isEmpty,
2452 )..addListener((transaction) async {
2453 transactionHistory.transactions.values.forEach((tx) {
2454 if (tx.id == hash) {
2455 tx.isReplaced = true;
2456 tx.isPending = false;
2457 transactionHistory.addOne(tx);
2458 }
2459 });
2460 transactionHistory.addOne(transaction);
2461 await updateBalance();
2462 await updateAllUnspents();
2463 });
2464 } catch (e) {
2465 throw e;
2466 }
2467 }
2468
2469 Future<ElectrumTransactionBundle> getTransactionExpanded(
2470 {required String hash, int? height}) async {
2471 String transactionHex;
2472 int? time;
2473 int? confirmations;
2474
2475 final verboseTransaction = await electrumClient.getTransactionVerbose(hash: hash);
2476
2477 if (verboseTransaction.isEmpty) {
2478 transactionHex = await electrumClient.getTransactionHex(hash: hash);
2479
2480 if (height != null && height > 0 && await checkIfMempoolAPIIsEnabled()) {
2481 try {
2482 final blockHash = await ProxyWrapper()
2483 .get(
2484 clearnetUri: Uri.parse(
2485 "https://mempool.cakewallet.com/api/v1/block-height/$height",
2486 ),
2487 )
2488 .timeout(Duration(seconds: 15));
2489
2490 if (blockHash.statusCode == 200 &&
2491 blockHash.body.isNotEmpty &&
2492 jsonDecode(blockHash.body) != null) {
2493 final blockResponse = await ProxyWrapper()
2494 .get(
2495 clearnetUri: Uri.parse(
2496 "https://mempool.cakewallet.com/api/v1/block/${blockHash.body}",
2497 ),
2498 )
2499 .timeout(Duration(seconds: 15));
2500 if (blockResponse.statusCode == 200 &&
2501 blockResponse.body.isNotEmpty &&
2502 jsonDecode(blockResponse.body)['timestamp'] != null) {
2503 time = int.parse(jsonDecode(blockResponse.body)['timestamp'].toString());
2504 }
2505 }
2506 } catch (_) {}
2507 }
2508 } else {
2509 transactionHex = verboseTransaction['hex'] as String;
2510 time = verboseTransaction['time'] as int?;
2511 confirmations = verboseTransaction['confirmations'] as int?;
2512 }
2513
2514 if (height != null) {
2515 if (time == null && height > 0) {
2516 time = (getDateByBitcoinHeight(height).millisecondsSinceEpoch / 1000).round();
2517 }
2518
2519 if (confirmations == null) {
2520 final tip = await getUpdatedChainTip();
2521 if (tip > 0 && height > 0) {
2522 // Add one because the block itself is the first confirmation
2523 confirmations = tip - height + 1;
2524 }
2525 }
2526 }
2527
2528 final original = BtcTransaction.fromRaw(transactionHex);
2529 final ins = <BtcTransaction?>[];
2530
2531 for (final vin in original.inputs) {
2532 try {
2533 final verboseTransaction = await electrumClient.getTransactionVerbose(hash: vin.txId);
2534
2535 final String inputTransactionHex;
2536
2537 if (verboseTransaction.isEmpty) {
2538 inputTransactionHex = await electrumClient.getTransactionHex(hash: vin.txId);
2539 } else {
2540 inputTransactionHex = verboseTransaction['hex'] as String;
2541 }
2542
2543 ins.add(inputTransactionHex.isEmpty ? null : BtcTransaction.fromRaw(inputTransactionHex));
2544 } catch (_) {
2545 ins.add(null);
2546 }
2547 }
2548
2549 return ElectrumTransactionBundle(
2550 original,
2551 ins: ins,
2552 time: time,
2553 confirmations: confirmations ?? 0,
2554 );
2555 }
2556
2557 Future<ElectrumTransactionInfo?> fetchTransactionInfo(
2558 {required String hash, int? height, bool? retryOnFailure}) async {
2559 try {
2560 return ElectrumTransactionInfo.fromElectrumBundle(
2561 await getTransactionExpanded(hash: hash, height: height),
2562 walletInfo.type,
2563 network,
2564 addresses: addressesSet,
2565 height: height,
2566 );
2567 } catch (e) {
2568 if (e is FormatException && retryOnFailure == true) {
2569 await Future.delayed(const Duration(seconds: 2));
2570 return fetchTransactionInfo(hash: hash, height: height);
2571 }
2572 return null;
2573 }
2574 }
2575
2576 bool isMine(Script script) {
2577 final derivedAddress = addressFromOutputScript(script, network);
2578 return addressesSet.contains(derivedAddress);
2579 }
2580
2581 @override
2582 Future<Map<String, ElectrumTransactionInfo>> fetchTransactions() async {
2583 try {
2584 final Map<String, ElectrumTransactionInfo> historiesWithDetails = {};
2585 ;
2586
2587 printV('[BATCH_TEST] Fetching transactions with batch: $shouldUseBatchFetching');
2588
2589 if (type == WalletType.bitcoin) {
2590 await Future.wait(BITCOIN_ADDRESS_TYPES.map((type) => shouldUseBatchFetching
2591 ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type)
2592 : fetchTransactionsForAddressType(historiesWithDetails, type)));
2593 } else if (type == WalletType.bitcoinCash) {
2594 await Future.wait(BITCOIN_CASH_ADDRESS_TYPES.map((type) => shouldUseBatchFetching
2595 ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type)
2596 : fetchTransactionsForAddressType(historiesWithDetails, type)));
2597 } else if (type == WalletType.litecoin) {
2598 await Future.wait(LITECOIN_ADDRESS_TYPES.where((type) => type != SegwitAddresType.mweb).map(
2599 (type) => shouldUseBatchFetching
2600 ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type)
2601 : fetchTransactionsForAddressType(historiesWithDetails, type)));
2602 } else if (type == WalletType.dogecoin) {
2603 await Future.wait(DOGECOIN_ADDRESS_TYPES.map((type) => shouldUseBatchFetching
2604 ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type)
2605 : fetchTransactionsForAddressType(historiesWithDetails, type)));
2606 }
2607
2608 transactionHistory.transactions.values.forEach((tx) async {
2609 final isPendingSilentPaymentUtxo =
2610 (tx.isPending || tx.confirmations == 0) && historiesWithDetails[tx.id] == null;
2611
2612 if (isPendingSilentPaymentUtxo) {
2613 final info =
2614 await fetchTransactionInfo(hash: tx.id, height: tx.height, retryOnFailure: true);
2615
2616 if (info != null) {
2617 tx.confirmations = info.confirmations;
2618 tx.isPending = tx.confirmations == 0;
2619 transactionHistory.addOne(tx);
2620 await transactionHistory.save();
2621 }
2622 }
2623 });
2624
2625 return historiesWithDetails;
2626 } catch (e) {
2627 printV("fetchTransactions $e");
2628 return {};
2629 }
2630 }
2631
2632 Future<void> fetchTransactionsForAddressType(
2633 Map<String, ElectrumTransactionInfo> historiesWithDetails,
2634 BitcoinAddressType type,
2635 ) async {
2636 final addressesByType =
2637 walletAddresses.allAddresses.where((addr) => addr.type == type).toList();
2638
2639 final receiveStandard = getAddressBranchByType(hidden: false, legacy: false, type: type);
2640 final changeStandard = getAddressBranchByType(hidden: true, legacy: false, type: type);
2641 final receiveLegacy = getAddressBranchByType(hidden: false, legacy: true, type: type);
2642 final changeLegacy = getAddressBranchByType(hidden: true, legacy: true, type: type);
2643
2644 walletAddresses.hiddenAddresses
2645 .addAll([...changeStandard, ...changeLegacy].map((e) => e.address));
2646 await walletAddresses.saveAddressesInBox();
2647 await Future.wait(addressesByType.map((addressRecord) async {
2648 final history = await _fetchAddressHistory(addressRecord, await getCurrentChainTip());
2649
2650 if (history.isNotEmpty) {
2651 addressRecord.txCount = history.length;
2652 historiesWithDetails.addAll(history);
2653
2654 final matchedAddresses = addressRecord.isHidden
2655 ? (addressRecord.isLegacyDerivation ? changeLegacy : changeStandard)
2656 : (addressRecord.isLegacyDerivation ? receiveLegacy : receiveStandard);
2657 final isUsedAddressAboveGap = matchedAddresses.toList().indexOf(addressRecord) >=
2658 matchedAddresses.length -
2659 (addressRecord.isHidden
2660 ? ElectrumWalletAddressesBase.defaultChangeAddressesCount
2661 : ElectrumWalletAddressesBase.defaultReceiveAddressesCount);
2662
2663 if (isUsedAddressAboveGap) {
2664 final prevLength = walletAddresses.allAddresses.length;
2665
2666 // Discover new addresses for the same address type until the gap limit is respected
2667 await walletAddresses.discoverAddresses(
2668 matchedAddresses.toList(),
2669 addressRecord.isHidden,
2670 (address) async {
2671 await subscribeForUpdates();
2672 return _fetchAddressHistory(address, await getCurrentChainTip())
2673 .then((history) => history.isNotEmpty ? address.address : null);
2674 },
2675 type: type,
2676 isLegacyDerivation: addressRecord.isLegacyDerivation,
2677 );
2678
2679 final newLength = walletAddresses.allAddresses.length;
2680
2681 if (newLength > prevLength) {
2682 await fetchTransactionsForAddressType(historiesWithDetails, type);
2683 }
2684 }
2685 }
2686 }));
2687 }
2688
2689 Future<Map<String, ElectrumTransactionInfo>> _fetchAddressHistory(
2690 BitcoinAddressRecord addressRecord, int? currentHeight) async {
2691 String txid = "";
2692
2693 try {
2694 final Map<String, ElectrumTransactionInfo> historiesWithDetails = {};
2695
2696 final history = await electrumClient.getHistory(addressRecord.getScriptHash(network));
2697
2698 if (history.isNotEmpty) {
2699 addressRecord.setAsUsed();
2700 walletAddresses.clearLockIfMatches(addressRecord.type, addressRecord.address);
2701
2702 if (this is BitcoinWallet) {
2703 //removes transactions no longer returned by the api, presumed replaced/invalid.
2704 transactionHistory.transactions.removeWhere(
2705 (hash, tx) =>
2706 tx.outputAddresses != null &&
2707 tx.outputAddresses!.contains(addressRecord.address) &&
2708 !history.any((newTransaction) => newTransaction['tx_hash'] == hash),
2709 );
2710 }
2711
2712 await Future.wait(history.map((transaction) async {
2713 txid = transaction['tx_hash'] as String;
2714 final height = transaction['height'] as int;
2715 final storedTx = transactionHistory.transactions[txid];
2716
2717 if (storedTx != null) {
2718 if (height > 0) {
2719 storedTx.height = height;
2720 // the tx's block itself is the first confirmation so add 1
2721 if ((currentHeight ?? 0) > 0) {
2722 storedTx.confirmations = currentHeight! - height + 1;
2723 }
2724 storedTx.isPending = storedTx.confirmations == 0;
2725 }
2726
2727 historiesWithDetails[txid] = storedTx;
2728 } else {
2729 final tx = await fetchTransactionInfo(hash: txid, height: height, retryOnFailure: true);
2730
2731 if (tx != null) {
2732 historiesWithDetails[txid] = tx;
2733
2734 // Got a new transaction fetched, add it to the transaction history
2735 // instead of waiting all to finish, and next time it will be faster
2736
2737 _applyLitecoinPegOutTag(tx);
2738 transactionHistory.addOne(tx);
2739 await transactionHistory.save();
2740 }
2741 }
2742
2743 return Future.value(null);
2744 }));
2745 }
2746
2747 return historiesWithDetails;
2748 } catch (e, stacktrace) {
2749 _onError?.call(FlutterErrorDetails(
2750 exception: "$txid - $e",
2751 stack: stacktrace,
2752 library: this.runtimeType.toString(),
2753 ));
2754 return {};
2755 }
2756 }
2757
2758 Future<void> fetchTransactionsForAddressTypeBatch(
2759 Map<String, ElectrumTransactionInfo> historiesWithDetails, BitcoinAddressType type) async {
2760 final receiveStandard = getAddressBranchByType(hidden: false, legacy: false, type: type);
2761 final changeStandard = getAddressBranchByType(hidden: true, legacy: false, type: type);
2762 final receiveLegacy = getAddressBranchByType(hidden: false, legacy: true, type: type);
2763 final changeLegacy = getAddressBranchByType(hidden: true, legacy: true, type: type);
2764
2765 walletAddresses.hiddenAddresses
2766 .addAll([...changeStandard, ...changeLegacy].map((e) => e.address));
2767 await walletAddresses.saveAddressesInBox();
2768
2769 await fetchTransactionsForAddressesBranchBatch(
2770 historiesWithDetails,
2771 type,
2772 receiveStandard,
2773 isHidden: false,
2774 isLegacyDerivation: false,
2775 );
2776
2777 await fetchTransactionsForAddressesBranchBatch(
2778 historiesWithDetails,
2779 type,
2780 changeStandard,
2781 isHidden: true,
2782 isLegacyDerivation: false,
2783 );
2784
2785 await fetchTransactionsForAddressesBranchBatch(
2786 historiesWithDetails,
2787 type,
2788 receiveLegacy,
2789 isHidden: false,
2790 isLegacyDerivation: true,
2791 );
2792
2793 await fetchTransactionsForAddressesBranchBatch(
2794 historiesWithDetails,
2795 type,
2796 changeLegacy,
2797 isHidden: true,
2798 isLegacyDerivation: true,
2799 );
2800 }
2801
2802 Future<void> fetchTransactionsForAddressesBranchBatch(
2803 Map<String, ElectrumTransactionInfo> historiesWithDetails,
2804 BitcoinAddressType type,
2805 List<BitcoinAddressRecord> branchAddresses, {
2806 required bool isHidden,
2807 required bool isLegacyDerivation,
2808 }) async {
2809 if (branchAddresses.isEmpty) return;
2810
2811 final tip = await getCurrentChainTip();
2812 final currentBranch = [...branchAddresses];
2813
2814 final initialHistory =
2815 await _processChunksToMap<BitcoinAddressRecord, String, ElectrumTransactionInfo>(
2816 items: currentBranch,
2817 chunkSize: addressHistoryChunkSize,
2818 processChunk: (chunk) => _fetchBatchAddressHistory(
2819 chunk,
2820 tip,
2821 addressHistoryChunkSize,
2822 ),
2823 );
2824
2825 if (initialHistory.isNotEmpty) {
2826 historiesWithDetails.addAll(initialHistory);
2827 }
2828
2829 final gapLimit = isHidden
2830 ? ElectrumWalletAddressesBase.defaultChangeAddressesCount
2831 : ElectrumWalletAddressesBase.defaultReceiveAddressesCount;
2832
2833 final highestUsedIndex = _highestUsedIndex(currentBranch);
2834 final shouldDiscover =
2835 highestUsedIndex >= 0 && highestUsedIndex >= currentBranch.length - gapLimit;
2836
2837 if (!shouldDiscover) return;
2838
2839 final newAddresses = await walletAddresses.discoverAddressesBatch(
2840 currentBranch,
2841 isHidden,
2842 (newAddresses) async {
2843 final newHistory = await _fetchBatchAddressHistory(
2844 newAddresses,
2845 tip,
2846 discoveryHistoryChunkSize,
2847 );
2848
2849 if (newHistory.isNotEmpty) {
2850 historiesWithDetails.addAll(newHistory);
2851 }
2852
2853 return newAddresses
2854 .where((addressRecord) => addressRecord.isUsed)
2855 .map((addressRecord) => addressRecord.address)
2856 .toSet();
2857 },
2858 type: type,
2859 isLegacyDerivation: isLegacyDerivation,
2860 );
2861
2862 if (newAddresses.isNotEmpty) {
2863 currentBranch.addAll(newAddresses);
2864
2865 if (isHidden) {
2866 walletAddresses.hiddenAddresses.addAll(newAddresses.map((e) => e.address));
2867 await walletAddresses.saveAddressesInBox();
2868 }
2869 }
2870 }
2871
2872 List<BitcoinAddressRecord> getAddressBranchByType(
2873 {required bool hidden, required bool legacy, required BitcoinAddressType type}) =>
2874 walletAddresses.allAddresses
2875 .where((addr) =>
2876 addr.type == type && addr.isHidden == hidden && addr.isLegacyDerivation == legacy)
2877 .toList()
2878 ..sort((a, b) => a.index.compareTo(b.index));
2879
2880 int _highestUsedIndex(List<BitcoinAddressRecord> addresses) {
2881 for (int i = addresses.length - 1; i >= 0; i--) {
2882 if (addresses[i].isUsed) return i;
2883 }
2884 return -1;
2885 }
2886
2887 Future<Map<String, ElectrumTransactionInfo>> _fetchBatchAddressHistory(
2888 List<BitcoinAddressRecord> addressRecords, int? currentHeight, int historyChunkSize) async {
2889 String lastTxId = '';
2890 bool didUpdateHistory = false;
2891
2892 try {
2893 final Map<String, ElectrumTransactionInfo> historiesWithDetails = {};
2894
2895 // List of script hashes for the given address records
2896 final scriptHashes = addressRecords.map((a) => a.getScriptHash(network)).toList();
2897
2898 final historyByScriptHash =
2899 await _processChunksToMap<String, String, List<Map<String, dynamic>>>(
2900 items: scriptHashes, chunkSize: historyChunkSize, processChunk: _getHistoryBatch);
2901
2902 // Map scriptHash -> addressRecord
2903 final byScriptHash = <String, BitcoinAddressRecord>{};
2904 for (final a in addressRecords) {
2905 byScriptHash[a.getScriptHash(network)] = a;
2906 }
2907
2908 // Split into already-known txs vs missing txs
2909 final missingHistoryItems = <Map<String, dynamic>>[];
2910
2911 for (final entry in historyByScriptHash.entries) {
2912 final sh = entry.key;
2913 final addressRecord = byScriptHash[sh];
2914 if (addressRecord == null) continue;
2915
2916 final history = entry.value;
2917 if (history.isEmpty) continue;
2918
2919 addressRecord.setAsUsed();
2920 walletAddresses.clearLockIfMatches(addressRecord.type, addressRecord.address);
2921
2922 //removes transactions no longer returned by the api, presumed replaced/invalid.
2923 if (this is BitcoinWallet) {
2924 final beforeLen = transactionHistory.transactions.length;
2925 transactionHistory.transactions.removeWhere((hash, tx) {
2926 return tx.outputAddresses != null &&
2927 tx.outputAddresses!.contains(addressRecord.address) &&
2928 !history.any((h) => h['tx_hash'] == hash);
2929 });
2930 if (transactionHistory.transactions.length != beforeLen) {
2931 didUpdateHistory = true;
2932 }
2933 }
2934
2935 // For each transaction in the history, check if we already have it in our transaction history. If we do, update its details if necessary. If we don't, add it to the list of missing history items to fetch later.
2936 for (final item in history) {
2937 final txid = item['tx_hash'] as String?;
2938 final height = item['height'] as int? ?? 0;
2939 if (txid == null || txid.isEmpty) continue;
2940
2941 lastTxId = txid;
2942
2943 final storedTx = transactionHistory.transactions[txid];
2944 if (storedTx != null) {
2945 if (height > 0) {
2946 final oldHeight = storedTx.height;
2947 final oldConfs = storedTx.confirmations;
2948 final oldPending = storedTx.isPending;
2949
2950 storedTx.height = height;
2951
2952 if ((currentHeight ?? 0) > 0) {
2953 storedTx.confirmations = currentHeight! - height + 1;
2954 }
2955
2956 storedTx.isPending = storedTx.confirmations == 0;
2957
2958 if (storedTx.height != oldHeight ||
2959 storedTx.confirmations != oldConfs ||
2960 storedTx.isPending != oldPending) {
2961 transactionHistory.addOne(storedTx);
2962 didUpdateHistory = true;
2963 }
2964 }
2965
2966 historiesWithDetails[txid] = storedTx;
2967 } else {
2968 missingHistoryItems.add({
2969 'tx_hash': txid,
2970 'height': height,
2971 'script_hash': sh,
2972 'address': addressRecord.address,
2973 });
2974 }
2975 }
2976 }
2977
2978 // Batch fetch missing tx verbose details
2979 if (missingHistoryItems.isEmpty) {
2980 if (didUpdateHistory) await transactionHistory.save();
2981 return historiesWithDetails;
2982 }
2983
2984 for (var i = 0; i < missingHistoryItems.length; i += historyChunkSize) {
2985 final end = (i + historyChunkSize < missingHistoryItems.length)
2986 ? i + historyChunkSize
2987 : missingHistoryItems.length;
2988 final chunkHistory = missingHistoryItems.sublist(i, end);
2989
2990 final hashes = chunkHistory
2991 .map((e) => (e['tx_hash'] as String).trim())
2992 .where((h) => h.isNotEmpty)
2993 .toList(growable: false);
2994
2995 final heightsByHash = <String, int?>{
2996 for (final e in chunkHistory) (e['tx_hash'] as String): (e['height'] as int?),
2997 };
2998
2999 final infosByHash = await fetchTransactionInfoBatch(
3000 hashes: hashes,
3001 heightsByHash: heightsByHash,
3002 retryOnFailure: true,
3003 retryDelay: const Duration(seconds: 1),
3004 );
3005
3006 for (final txid in hashes) {
3007 final tx = infosByHash[txid];
3008 if (tx == null) continue;
3009
3010 historiesWithDetails[tx.id] = tx;
3011
3012 _applyLitecoinPegOutTag(tx);
3013
3014 transactionHistory.addOne(tx);
3015 didUpdateHistory = true;
3016 }
3017 }
3018
3019 if (didUpdateHistory) {
3020 await transactionHistory.save();
3021 }
3022
3023 return historiesWithDetails;
3024 } catch (e, stacktrace) {
3025 final prefix = lastTxId.isNotEmpty ? '$lastTxId - ' : '';
3026 _onError?.call(FlutterErrorDetails(
3027 exception: '$prefix$e',
3028 stack: stacktrace,
3029 library: runtimeType.toString(),
3030 ));
3031 return {};
3032 }
3033 }
3034
3035 Future<Map<String, Map<String, dynamic>>> _getTransactionVerboseBatch(List<String> hashes) {
3036 return electrumClient.getBatchTransactionVerbose(
3037 hashes,
3038 timeout: transactionBatchTimeoutMs,
3039 );
3040 }
3041
3042 Future<Map<String, String?>> _getTransactionHexBatch(List<String> hashes) {
3043 return electrumClient.getBatchTransactionHex(
3044 hashes,
3045 timeout: transactionBatchTimeoutMs,
3046 );
3047 }
3048
3049 Future<Map<String, List<Map<String, dynamic>>>> _getHistoryBatch(List<String> scriptHashes) {
3050 return electrumClient.getBatchHistory(
3051 scriptHashes,
3052 timeout: transactionBatchTimeoutMs,
3053 );
3054 }
3055
3056 Future<Map<String, List<Map<String, dynamic>>>> _getListUnspentBatch(List<String> scriptHashes) {
3057 return electrumClient.getBatchUnspent(
3058 scriptHashes,
3059 timeout: transactionBatchTimeoutMs,
3060 );
3061 }
3062
3063 Future<Map<String, Map<String, dynamic>>> _getBalanceBatch(List<String> scriptHashes) {
3064 return electrumClient.getBatchBalance(
3065 scriptHashes,
3066 timeout: transactionBatchTimeoutMs,
3067 );
3068 }
3069
3070 Future<Map<String, ElectrumTransactionInfo?>> fetchTransactionInfoBatch({
3071 required List<String> hashes,
3072 Map<String, int?>? heightsByHash,
3073 bool retryOnFailure = false,
3074 Duration retryDelay = const Duration(seconds: 2),
3075 }) async {
3076 final result = <String, ElectrumTransactionInfo?>{};
3077 final uniqueHashes = hashes.map((h) => h.trim()).where((h) => h.isNotEmpty).toSet().toList();
3078
3079 if (uniqueHashes.isEmpty) return result;
3080
3081 await _processTransactionInfoBatch(
3082 txIds: uniqueHashes,
3083 result: result,
3084 heightsByHash: heightsByHash,
3085 );
3086
3087 if (retryOnFailure) {
3088 final failedHashes = uniqueHashes.where((txId) => result[txId] == null).toList();
3089
3090 if (failedHashes.isNotEmpty) {
3091 await Future.delayed(retryDelay);
3092
3093 await _processTransactionInfoBatch(
3094 txIds: failedHashes,
3095 result: result,
3096 heightsByHash: heightsByHash,
3097 );
3098 }
3099 }
3100
3101 return result;
3102 }
3103
3104 Future<void> _processTransactionInfoBatch({
3105 required List<String> txIds,
3106 required Map<String, ElectrumTransactionInfo?> result,
3107 required Map<String, int?>? heightsByHash,
3108 }) async {
3109 for (var i = 0; i < txIds.length; i += transactionChunkSize) {
3110 final end =
3111 (i + transactionChunkSize < txIds.length) ? i + transactionChunkSize : txIds.length;
3112 final chunk = txIds.sublist(i, end);
3113
3114 final bundlesByHash = await getTransactionExpandedBatch(
3115 hashes: chunk,
3116 heightsByHash: heightsByHash,
3117 );
3118
3119 for (final txId in chunk) {
3120 try {
3121 final bundle = bundlesByHash[txId];
3122 if (bundle == null) {
3123 result[txId] = null;
3124 continue;
3125 }
3126
3127 final info = ElectrumTransactionInfo.fromElectrumBundle(
3128 bundle,
3129 walletInfo.type,
3130 network,
3131 addresses: addressesSet,
3132 height: heightsByHash?[txId],
3133 );
3134 info.id = txId;
3135 result[txId] = info;
3136 } catch (_) {
3137 result[txId] = null;
3138 }
3139 }
3140 }
3141 }
3142
3143 Future<Map<String, ElectrumTransactionBundle>> getTransactionExpandedBatch(
3144 {required List<String> hashes, Map<String, int?>? heightsByHash}) async {
3145 final bundles = <String, ElectrumTransactionBundle>{};
3146 if (hashes.isEmpty) return bundles;
3147
3148 final verboseByHash = await _fetchTransactionVerboseBatch(hashes);
3149
3150 final originalByHash = _parseTransactions(verboseByHash);
3151
3152 final inputTxIdsByHash = _collectInputTxIdsByHash(originalByHash);
3153
3154 final allInputTxids = <String>{};
3155 for (final txids in inputTxIdsByHash.values) {
3156 allInputTxids.addAll(txids);
3157 }
3158
3159 final inputTxIds = allInputTxids.toList(growable: false);
3160
3161 final inputVerboseByTxId = inputTxIds.isEmpty
3162 ? <String, Map<String, dynamic>>{}
3163 : await _fetchTransactionVerboseBatch(inputTxIds);
3164
3165 final parsedInputTxById = _parseTransactions(inputVerboseByTxId);
3166
3167 return _buildTransactionBundlesBatch(
3168 unique: hashes,
3169 heightsByHash: heightsByHash,
3170 tip: await getUpdatedChainTip(),
3171 originalByHash: originalByHash,
3172 verboseByHash: verboseByHash,
3173 inputTxidsByHash: inputTxIdsByHash,
3174 parsedInputTxById: parsedInputTxById,
3175 );
3176 }
3177
3178 Future<Map<String, Map<String, dynamic>>> _fetchTransactionVerboseBatch(
3179 List<String> txIds) async {
3180 final verboseTransactionByHash =
3181 await _processChunksToMap<String, String, Map<String, dynamic>>(
3182 items: txIds,
3183 chunkSize: transactionChunkSize,
3184 processChunk: _getTransactionVerboseBatch,
3185 );
3186
3187 final emptyHex = <String>[];
3188 for (final txId in txIds) {
3189 final vTx = verboseTransactionByHash[txId];
3190 if (vTx == null || vTx.isEmpty || vTx['hex'] == null) {
3191 emptyHex.add(txId);
3192 }
3193 }
3194
3195 final hexByHash = await _processChunksToMap<String, String, String?>(
3196 items: emptyHex,
3197 chunkSize: transactionChunkSize,
3198 processChunk: _getTransactionHexBatch,
3199 );
3200
3201 for (final txId in txIds) {
3202 final verbose = verboseTransactionByHash[txId] ?? <String, dynamic>{};
3203 if ((verbose['hex'] as String?) == null) {
3204 final hex = hexByHash[txId];
3205 if (hex != null && hex.isNotEmpty) {
3206 verboseTransactionByHash[txId] = {
3207 ...verbose,
3208 'hex': hex,
3209 };
3210 }
3211 }
3212 }
3213
3214 return verboseTransactionByHash;
3215 }
3216
3217 Map<String, BtcTransaction> _parseTransactions(
3218 Map<String, Map<String, dynamic>> verboseByHash,
3219 ) {
3220 final result = <String, BtcTransaction>{};
3221
3222 for (final entry in verboseByHash.entries) {
3223 final hex = entry.value['hex'] as String?;
3224 if (hex == null || hex.isEmpty) continue;
3225
3226 try {
3227 result[entry.key] = BtcTransaction.fromRaw(hex);
3228 } catch (_) {}
3229 }
3230
3231 return result;
3232 }
3233
3234 Map<String, List<String>> _collectInputTxIdsByHash(
3235 Map<String, BtcTransaction> originalByHash,
3236 ) {
3237 final inputTxIdsByHash = <String, List<String>>{};
3238
3239 for (final entry in originalByHash.entries) {
3240 final txId = entry.key;
3241 final original = entry.value;
3242
3243 final inputTxIds = <String>[];
3244 for (final vin in original.inputs) {
3245 inputTxIds.add(vin.txId);
3246 }
3247
3248 inputTxIdsByHash[txId] = inputTxIds;
3249 }
3250
3251 return inputTxIdsByHash;
3252 }
3253
3254 Future<Map<String, ElectrumTransactionBundle>> _buildTransactionBundlesBatch({
3255 required List<String> unique,
3256 required Map<String, int?>? heightsByHash,
3257 required int tip,
3258 required Map<String, BtcTransaction> originalByHash,
3259 required Map<String, Map<String, dynamic>> verboseByHash,
3260 required Map<String, List<String>> inputTxidsByHash,
3261 required Map<String, BtcTransaction> parsedInputTxById,
3262 }) async {
3263 final bundles = <String, ElectrumTransactionBundle>{};
3264
3265 // Identify heights that need mempool timestamp lookup
3266 final heightsNeedingTime = <int>{};
3267 for (final txid in originalByHash.keys) {
3268 final verbose = verboseByHash[txid] ?? const <String, dynamic>{};
3269 final time = verbose['time'] as int?;
3270 final h = heightsByHash?[txid];
3271 if (time == null && h != null && h > 0) {
3272 heightsNeedingTime.add(h);
3273 }
3274 }
3275
3276 final mempoolTimes = await _fetchBlockTimestampsFromMempoolByHeights(heightsNeedingTime);
3277
3278 for (final txid in unique) {
3279 final original = originalByHash[txid];
3280 if (original == null) continue;
3281
3282 final verbose = verboseByHash[txid] ?? const <String, dynamic>{};
3283
3284 int? time = verbose['time'] as int?;
3285 int? confirmations = verbose['confirmations'] as int?;
3286 final h = heightsByHash?[txid];
3287
3288 if (h != null) {
3289 if (time == null && h > 0) {
3290 final mp = mempoolTimes[h];
3291 time = mp ?? (getDateByBitcoinHeight(h).millisecondsSinceEpoch / 1000).round();
3292 }
3293
3294 if (confirmations == null && tip > 0 && h > 0) {
3295 confirmations = tip - h + 1;
3296 if (confirmations < 0) confirmations = 0;
3297 }
3298 }
3299
3300 final inputTxids = inputTxidsByHash[txid] ?? const <String>[];
3301
3302 final ins = <BtcTransaction?>[
3303 for (final inputTxid in inputTxids) parsedInputTxById[inputTxid],
3304 ];
3305
3306 bundles[txid] = ElectrumTransactionBundle(
3307 original,
3308 ins: ins,
3309 time: time,
3310 confirmations: confirmations ?? 0,
3311 );
3312 }
3313
3314 return bundles;
3315 }
3316
3317 Future<Map<int, int>> _fetchBlockTimestampsFromMempoolByHeights(
3318 Set<int> heights,
3319 ) async {
3320 final out = <int, int>{};
3321 if (heights.isEmpty) return out;
3322 if (!(await checkIfMempoolAPIIsEnabled())) return out;
3323
3324 // Best-effort: if any call fails, we just skip that height.
3325 await Future.wait(heights.map((h) async {
3326 try {
3327 final blockHashResp = await ProxyWrapper()
3328 .get(
3329 clearnetUri: Uri.parse(
3330 'https://mempool.cakewallet.com/api/v1/block-height/$h',
3331 ),
3332 )
3333 .timeout(const Duration(seconds: 15));
3334
3335 if (blockHashResp.statusCode != 200 || blockHashResp.body.isEmpty) return;
3336
3337 final blockHash = blockHashResp.body.trim();
3338 if (blockHash.isEmpty) return;
3339
3340 final blockResp = await ProxyWrapper()
3341 .get(
3342 clearnetUri: Uri.parse(
3343 'https://mempool.cakewallet.com/api/v1/block/$blockHash',
3344 ),
3345 )
3346 .timeout(const Duration(seconds: 15));
3347
3348 if (blockResp.statusCode != 200 || blockResp.body.isEmpty) return;
3349
3350 final decoded = jsonDecode(blockResp.body);
3351 final ts = decoded is Map<String, dynamic> ? decoded['timestamp'] : null;
3352 if (ts == null) return;
3353
3354 final parsed = int.tryParse(ts.toString());
3355 if (parsed == null) return;
3356
3357 out[h] = parsed;
3358 } catch (_) {
3359 // ignore
3360 }
3361 }));
3362
3363 return out;
3364 }
3365
3366 Future<Map<K, V>> _processChunksToMap<T, K, V>({
3367 required List<T> items,
3368 required int chunkSize,
3369 required Future<Map<K, V>> Function(List<T> chunk) processChunk,
3370 void Function(List<T> chunk, Object error)? onChunkError,
3371 }) async {
3372 final result = <K, V>{};
3373
3374 for (var i = 0; i < items.length; i += chunkSize) {
3375 final end = (i + chunkSize < items.length) ? i + chunkSize : items.length;
3376 final chunk = items.sublist(i, end);
3377
3378 try {
3379 final chunkResult = await processChunk(chunk);
3380 result.addAll(chunkResult);
3381 } on electrum.RequestFailedTimeoutException catch (e) {
3382 onChunkError?.call(chunk, e);
3383 continue;
3384 } catch (e) {
3385 onChunkError?.call(chunk, e);
3386 continue;
3387 }
3388 }
3389
3390 return result;
3391 }
3392
3393 Future<void> updateTransactions() async {
3394 printV("updateTransactions() called!");
3395 try {
3396 if (_isTransactionUpdating) {
3397 return;
3398 }
3399 currentChainTip = await getUpdatedChainTip();
3400
3401 bool updated = false;
3402 transactionHistory.transactions.values.forEach((tx) {
3403 if ((tx.height ?? 0) > 0 && (currentChainTip ?? 0) > 0) {
3404 var confirmations = currentChainTip! - tx.height! + 1;
3405 if (confirmations < 0) {
3406 // if our chain tip is outdated then it could lead to negative confirmations so this is just a failsafe:
3407 confirmations = 0;
3408 }
3409 if (confirmations != tx.confirmations) {
3410 updated = true;
3411 tx.confirmations = confirmations;
3412 transactionHistory.addOne(tx);
3413 }
3414 }
3415 });
3416
3417 if (updated) {
3418 await transactionHistory.save();
3419 }
3420
3421 _isTransactionUpdating = true;
3422 await fetchTransactions();
3423 walletAddresses.updateReceiveAddresses();
3424 _isTransactionUpdating = false;
3425 } catch (e, stacktrace) {
3426 printV(stacktrace);
3427 printV(e);
3428 _isTransactionUpdating = false;
3429 }
3430 }
3431
3432 Future<void> subscribeForUpdates() async {
3433 final unsubscribedScriptHashes = walletAddresses.allAddresses.where(
3434 (address) =>
3435 !_scripthashesUpdateSubject.containsKey(address.getScriptHash(network)) &&
3436 address.type != SegwitAddresType.mweb,
3437 );
3438
3439 await Future.wait(unsubscribedScriptHashes.map((address) async {
3440 final sh = address.getScriptHash(network);
3441 if (!(_scripthashesUpdateSubject[sh]?.isClosed ?? true)) {
3442 try {
3443 await _scripthashesUpdateSubject[sh]?.close();
3444 } catch (e) {
3445 printV("failed to close: $e");
3446 }
3447 }
3448 try {
3449 _scripthashesUpdateSubject[sh] = await electrumClient.scripthashUpdate(sh);
3450 } catch (e) {
3451 printV("failed scripthashUpdate: $e");
3452 }
3453 _scripthashesUpdateSubject[sh]?.listen((event) async {
3454 try {
3455 await updateUnspentsForAddress(address);
3456
3457 await updateBalance();
3458
3459 await _fetchAddressHistory(address, await getCurrentChainTip());
3460 } catch (e, s) {
3461 printV("sub error: $e");
3462 _onError?.call(FlutterErrorDetails(
3463 exception: e,
3464 stack: s,
3465 library: this.runtimeType.toString(),
3466 ));
3467 }
3468 }, onError: (e, s) {
3469 printV("sub_listen error: $e $s");
3470 });
3471 }));
3472 }
3473
3474 Future<List<Map<String, dynamic>>> fetchBalancesBatch(
3475 List<BitcoinAddressRecord> addresses,
3476 ) async {
3477 final scriptHashes = addresses.map((address) => address.getScriptHash(network)).toList();
3478
3479 if (scriptHashes.isEmpty) {
3480 return <Map<String, dynamic>>[];
3481 }
3482
3483 try {
3484 final balancesByScriptHash = await _processChunksToMap<String, String, Map<String, dynamic>>(
3485 items: scriptHashes,
3486 chunkSize: addressHistoryChunkSize,
3487 processChunk: _getBalanceBatch,
3488 );
3489
3490 final balances = scriptHashes
3491 .map((scriptHash) => balancesByScriptHash[scriptHash] ?? <String, dynamic>{})
3492 .toList();
3493
3494 final hasMissingBalance = balances.any((balance) => balance['confirmed'] == null);
3495 if (hasMissingBalance) {
3496 printV('fetchBalancesBatch returned missing balances, falling back to regular flow');
3497 return fetchBalancesRegular(addresses);
3498 }
3499
3500 return balances;
3501 } catch (e) {
3502 printV('fetchBalancesBatch failed, falling back to regular flow: $e');
3503 return fetchBalancesRegular(addresses);
3504 }
3505 }
3506
3507 Future<List<Map<String, dynamic>>> fetchBalancesRegular(
3508 List<BitcoinAddressRecord> addresses,
3509 ) async {
3510 final balanceFutures = <Future<Map<String, dynamic>>>[];
3511
3512 for (final address in addresses) {
3513 final sh = address.getScriptHash(network);
3514 balanceFutures.add(electrumClient.getBalance(sh));
3515 }
3516
3517 return Future.wait(balanceFutures);
3518 }
3519
3520 Future<ElectrumBalance> fetchBalances() async {
3521 final addresses = walletAddresses.allAddresses
3522 .where((address) => address.address.isNotEmpty)
3523 .where((address) => RegexUtils.addressTypeFromStr(address.address, network) is! MwebAddress)
3524 .toList();
3525
3526 final balances = shouldUseBatchFetching
3527 ? await fetchBalancesBatch(addresses)
3528 : await fetchBalancesRegular(addresses);
3529
3530 printV(
3531 'Fetched balances for ${addresses.length} addresses. Batch fetching: $shouldUseBatchFetching');
3532
3533 var totalFrozen = 0;
3534 var totalConfirmed = 0;
3535 var totalUnconfirmed = 0;
3536
3537 if (hasSilentPaymentsScanning) {
3538 // Add values from unspent coins that are not fetched by the address list
3539 // i.e. scanned silent payments
3540 transactionHistory.transactions.values.forEach((tx) {
3541 if (tx.unspents != null) {
3542 tx.unspents!.forEach((unspent) {
3543 if (unspent.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) {
3544 if (unspent.isFrozen) totalFrozen += unspent.value;
3545 totalConfirmed += unspent.value;
3546 }
3547 });
3548 }
3549 });
3550 }
3551
3552 unspentCoinsInfo.values.forEach((info) {
3553 unspentCoins.forEach((element) {
3554 if (element.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) return;
3555
3556 if (element.hash == info.hash &&
3557 element.vout == info.vout &&
3558 element.bitcoinAddressRecord.address == info.address &&
3559 element.value == info.value) {
3560 if (info.isFrozen) {
3561 totalFrozen += element.value;
3562 }
3563 }
3564 });
3565 });
3566
3567 if (balances.isNotEmpty && balances.first['confirmed'] == null) {
3568 // if we got null balance responses from the server, set our connection status to lost and return our last known balance:
3569 printV("got null balance responses from the server, setting connection status to lost");
3570 syncStatus = LostConnectionSyncStatus();
3571 return balance[currency] ??
3572 ElectrumBalance(
3573 confirmed: Money.zero(currency),
3574 unconfirmed: Money.zero(currency),
3575 frozen: Money.zero(currency),
3576 );
3577 }
3578
3579 for (var i = 0; i < balances.length; i++) {
3580 final addressRecord = addresses[i];
3581 final balance = balances[i];
3582 final confirmed = balance['confirmed'] as int? ?? 0;
3583 final unconfirmed = balance['unconfirmed'] as int? ?? 0;
3584 totalConfirmed += confirmed;
3585 totalUnconfirmed += unconfirmed;
3586
3587 addressRecord.balance = confirmed + unconfirmed;
3588 if (confirmed > 0 || unconfirmed > 0) {
3589 addressRecord.setAsUsed();
3590 walletAddresses.clearLockIfMatches(addressRecord.type, addressRecord.address);
3591 }
3592 }
3593
3594 return ElectrumBalance(
3595 confirmed: Money.fromInt(totalConfirmed, currency),
3596 unconfirmed: Money.fromInt(totalUnconfirmed, currency),
3597 frozen: Money.fromInt(totalFrozen, currency),
3598 );
3599 }
3600
3601 Future<void> updateBalance() async {
3602 printV("updateBalance() called!");
3603 balance[currency] = await fetchBalances();
3604 await save();
3605 }
3606
3607 @override
3608 Future<bool> checkNodeHealth() async {
3609 try {
3610 final addresses = walletAddresses.allAddresses
3611 .where(
3612 (address) => RegexUtils.addressTypeFromStr(address.address, network) is! MwebAddress)
3613 .toList();
3614
3615 if (addresses.isEmpty) {
3616 return false;
3617 }
3618
3619 final firstAddress = addresses.first;
3620 final sh = firstAddress.getScriptHash(network);
3621 await electrumClient.getBalance(sh, throwOnError: true);
3622 return true;
3623 } catch (e) {
3624 return false;
3625 }
3626 }
3627
3628 @override
3629 void setExceptionHandler(void Function(FlutterErrorDetails) onError) => _onError = onError;
3630
3631 @override
3632 Future<String> signMessage(String message, {String? address = null}) async {
3633 final addressRecord = address != null
3634 ? walletAddresses.allAddresses.firstWhereOrNull((addr) => addr.address == address)
3635 : null;
3636
3637 if (addressRecord != null && addressRecord.type == SegwitAddresType.p2tr) {
3638 throw UnsupportedError("Cannot sign message with Taproot address");
3639 }
3640
3641 final hd = addressRecord != null
3642 ? _hdFor(record: addressRecord).childKey(Bip32KeyIndex(addressRecord.index))
3643 : mainHd;
3644
3645 final priv = ECPrivate.fromHex(hd.privateKey.privKey.toHex());
3646
3647 String messagePrefix = '\x18Bitcoin Signed Message:\n';
3648 final hexEncoded = priv.signMessage(utf8.encode(message), messagePrefix: messagePrefix);
3649 final decodedSig = hex.decode(hexEncoded);
3650 return base64Encode(decodedSig);
3651 }
3652
3653 void _applyLitecoinPegOutTag(ElectrumTransactionInfo tx) {
3654 if (this is! LitecoinWallet) return;
3655
3656 // if we have a peg out transaction with the same value
3657 // that matches this received transaction, mark it as being from a peg out:
3658 for (final tx2 in transactionHistory.transactions.values) {
3659 final heightDiff = ((tx2.height ?? 0) - (tx.height ?? 0)).abs();
3660 // this isn't a perfect matching algorithm since we don't have the right input/output information from these transaction models (the addresses are in different formats), but this should be more than good enough for now as it's extremely unlikely a user receives the EXACT same amount from 2 different sources and one of them is a peg out and the other isn't WITHIN 5 blocks of each other
3661 if (tx2.additionalInfo["isPegOut"] == true && tx2.amount == tx.amount && heightDiff <= 5) {
3662 tx.additionalInfo["fromPegOut"] = true;
3663 }
3664 }
3665 }
3666
3667 Future<void> checkIfBatchSupported() async {
3668 if (_isBatchSupported != null) {
3669 printV('[BATCH_TEST] Already checked: $_isBatchSupported');
3670 return;
3671 }
3672
3673 final hashes = publicScriptHashes.take(batchTestHashesCount).toList();
3674
3675 if (hashes.length < batchTestHashesCount) {
3676 _isBatchSupported = false;
3677 printV('[BATCH_TEST] Failed: not enough script hashes');
3678 return;
3679 }
3680
3681 try {
3682 final paramsList = hashes.map((hash) => <Object>[hash]).toList();
3683
3684 printV('[BATCH_TEST] Start: hashes=${hashes.length}, timeout=${batchTestTimeoutMs}ms');
3685
3686 final result = await electrumClient.callBatchWithTimeout(
3687 method: 'blockchain.scripthash.get_history',
3688 paramsList: paramsList,
3689 timeout: batchTestTimeoutMs,
3690 );
3691
3692 final hasError = result.any((item) =>
3693 item is Map<String, dynamic> && item.containsKey('error') && item['error'] != null);
3694
3695 if (hasError) {
3696 _isBatchSupported = false;
3697 printV('[BATCH_TEST] Result: supported=false (server returned error)');
3698 return;
3699 }
3700
3701 _isBatchSupported = true;
3702 printV('[BATCH_TEST] Result: supported=true');
3703 } on electrum.RequestFailedTimeoutException catch (e) {
3704 _isBatchSupported = false;
3705 printV('[BATCH_TEST] Timeout: $e');
3706 } catch (e) {
3707 _isBatchSupported = false;
3708 printV('[BATCH_TEST] Exception: $e');
3709 }
3710 }
3711
3712 @override
3713 Future<bool> verifyMessage(String message, String signature, {String? address = null}) async {
3714 if (address == null) {
3715 return false;
3716 }
3717
3718 List<int> sigDecodedBytes = [];
3719
3720 if (signature.endsWith('=')) {
3721 sigDecodedBytes = base64.decode(signature);
3722 } else {
3723 sigDecodedBytes = hex.decode(signature);
3724 }
3725
3726 if (sigDecodedBytes.length != 64 && sigDecodedBytes.length != 65) {
3727 throw ArgumentException(
3728 "signature must be 64 bytes without recover-id or 65 bytes with recover-id");
3729 }
3730
3731 String messagePrefix = '\x18Bitcoin Signed Message:\n';
3732 final messageHash = QuickCrypto.sha256Hash(
3733 BitcoinSignerUtils.magicMessage(utf8.encode(message), messagePrefix));
3734
3735 List<int> correctSignature =
3736 sigDecodedBytes.length == 65 ? sigDecodedBytes.sublist(1) : List.from(sigDecodedBytes);
3737 List<int> rBytes = correctSignature.sublist(0, 32);
3738 List<int> sBytes = correctSignature.sublist(32);
3739 final sig = ECDSASignature(BigintUtils.fromBytes(rBytes), BigintUtils.fromBytes(sBytes));
3740
3741 List<int> possibleRecoverIds = [0, 1];
3742
3743 final baseAddress = RegexUtils.addressTypeFromStr(address, network);
3744
3745 for (int recoveryId in possibleRecoverIds) {
3746 final pubKey = sig.recoverPublicKey(messageHash, Curves.generatorSecp256k1, recoveryId);
3747
3748 final recoveredPub = ECPublic.fromBytes(pubKey!.toBytes());
3749
3750 String? recoveredAddress;
3751
3752 if (baseAddress is P2pkAddress) {
3753 recoveredAddress = recoveredPub.toP2pkAddress().toAddress(network);
3754 } else if (baseAddress is P2pkhAddress) {
3755 recoveredAddress = recoveredPub.toP2pkhAddress().toAddress(network);
3756 } else if (baseAddress is P2wshAddress) {
3757 recoveredAddress = recoveredPub.toP2wshAddress().toAddress(network);
3758 } else if (baseAddress is P2wpkhAddress) {
3759 recoveredAddress = recoveredPub.toP2wpkhAddress().toAddress(network);
3760 }
3761
3762 if (recoveredAddress == address) {
3763 return true;
3764 }
3765 }
3766
3767 return false;
3768 }
3769
3770 Future<void> _setInitialHeight() async {
3771 if (_chainTipUpdateSubject != null) return;
3772
3773 currentChainTip = await getUpdatedChainTip();
3774
3775 if ((currentChainTip == null || currentChainTip! == 0) && walletInfo.restoreHeight == 0) {
3776 await walletInfo.updateRestoreHeight(currentChainTip!);
3777 }
3778
3779 _chainTipUpdateSubject = electrumClient.chainTipSubscribe();
3780 _chainTipUpdateSubject?.listen((e) async {
3781 final event = e as Map<String, dynamic>;
3782 final height = int.tryParse(event['height'].toString());
3783
3784 if (height != null) {
3785 currentChainTip = height;
3786
3787 if (alwaysScan == true && syncStatus is SyncedSyncStatus) {
3788 _setListeners(walletInfo.restoreHeight);
3789 }
3790 }
3791 });
3792 }
3793
3794 static String _hardenedDerivationPath(String derivationPath) =>
3795 derivationPath.substring(0, derivationPath.lastIndexOf("'") + 1);
3796
3797 @action
3798 void _onConnectionStatusChange(electrum.ConnectionStatus status) {
3799 switch (status) {
3800 case electrum.ConnectionStatus.connected:
3801 if (syncStatus is NotConnectedSyncStatus ||
3802 syncStatus is LostConnectionSyncStatus ||
3803 syncStatus is ConnectingSyncStatus) {
3804 syncStatus = ConnectedSyncStatus();
3805 }
3806
3807 break;
3808 case electrum.ConnectionStatus.disconnected:
3809 // Always show disconnected status when connection is lost, regardless of current sync state
3810 if (syncStatus is! NotConnectedSyncStatus) {
3811 syncStatus = NotConnectedSyncStatus();
3812 }
3813 break;
3814 case electrum.ConnectionStatus.failed:
3815 if (syncStatus is! LostConnectionSyncStatus) {
3816 syncStatus = LostConnectionSyncStatus();
3817 }
3818 break;
3819 case electrum.ConnectionStatus.connecting:
3820 if (syncStatus is! ConnectingSyncStatus) {
3821 syncStatus = ConnectingSyncStatus();
3822 }
3823 break;
3824 }
3825 }
3826
3827 void _syncStatusReaction(SyncStatus syncStatus) async {
3828 printV("SYNC_STATUS_CHANGE: ${syncStatus}");
3829 if (syncStatus is SyncingSyncStatus) {
3830 return;
3831 }
3832
3833 if (syncStatus is NotConnectedSyncStatus || syncStatus is LostConnectionSyncStatus) {
3834 // Needs to re-subscribe to all scripthashes when reconnected
3835 _scripthashesUpdateSubject = {};
3836
3837 if (_isTryingToConnect) return;
3838
3839 _isTryingToConnect = true;
3840
3841 Timer(Duration(seconds: 5), () {
3842 if (this.syncStatus is NotConnectedSyncStatus ||
3843 this.syncStatus is LostConnectionSyncStatus) {
3844 this.electrumClient.connectToUri(
3845 node!.uri,
3846 useSSL: node!.useSSL ?? false,
3847 );
3848 }
3849 _isTryingToConnect = false;
3850 });
3851 }
3852
3853 // Message is shown on the UI for 3 seconds, revert to synced
3854 if (syncStatus is SyncedTipSyncStatus) {
3855 Timer(Duration(seconds: 3), () {
3856 if (this.syncStatus is SyncedTipSyncStatus) this.syncStatus = SyncedSyncStatus();
3857 });
3858 }
3859 }
3860
3861 void _updateInputsAndOutputs(ElectrumTransactionInfo tx, ElectrumTransactionBundle bundle) {
3862 tx.inputAddresses = tx.inputAddresses?.where((address) => address.isNotEmpty).toList();
3863
3864 if (tx.inputAddresses == null ||
3865 tx.inputAddresses!.isEmpty ||
3866 tx.outputAddresses == null ||
3867 tx.outputAddresses!.isEmpty) {
3868 List<String> inputAddresses = [];
3869 List<String> outputAddresses = [];
3870
3871 for (int i = 0; i < bundle.originalTransaction.inputs.length; i++) {
3872 final input = bundle.originalTransaction.inputs[i];
3873 final inputTransaction = bundle.ins[i];
3874 if (inputTransaction == null) continue;
3875 final vout = input.txIndex;
3876 final outTransaction = inputTransaction.outputs[vout];
3877 final address = addressFromOutputScript(outTransaction.scriptPubKey, network);
3878
3879 if (address.isNotEmpty) inputAddresses.add(address);
3880 }
3881
3882 for (int i = 0; i < bundle.originalTransaction.outputs.length; i++) {
3883 final out = bundle.originalTransaction.outputs[i];
3884 final address = addressFromOutputScript(out.scriptPubKey, network);
3885
3886 if (address.isNotEmpty) outputAddresses.add(address);
3887
3888 // Check if the script contains OP_RETURN
3889 final script = out.scriptPubKey.script;
3890 if (script.contains('OP_RETURN')) {
3891 final index = script.indexOf('OP_RETURN');
3892 if (index + 1 <= script.length) {
3893 try {
3894 final opReturnData = script[index + 1].toString();
3895 final decodedString = utf8.decode(HEX.decode(opReturnData));
3896 outputAddresses.add('OP_RETURN:$decodedString');
3897 } catch (_) {
3898 outputAddresses.add('OP_RETURN:');
3899 }
3900 }
3901 }
3902 }
3903 tx.inputAddresses = inputAddresses;
3904 tx.outputAddresses = outputAddresses;
3905
3906 transactionHistory.addOne(tx);
3907 }
3908 }
3909
3910 /// Checks the health of the socket connection
3911 /// and triggers a full reconnection if needed
3912 @override
3913 Future<bool> checkSocketHealth() async {
3914 try {
3915 SocketHealthLogger().logHealthCheck(
3916 walletType: type,
3917 walletName: name,
3918 syncStatus: syncStatus.toString(),
3919 wasReconnected: false,
3920 trigger: 'socket_health_check_start',
3921 );
3922
3923 if (!electrumClient.isConnected || !electrumClient.isInternalStateConsistent) {
3924 if (!electrumClient.isConnected) {
3925 SocketHealthLogger().logHealthCheck(
3926 walletType: type,
3927 walletName: name,
3928 isHealthy: false,
3929 syncStatus: syncStatus.toString(),
3930 wasReconnected: false,
3931 trigger: 'socket_health_check_socket_not_connected',
3932 );
3933 }
3934
3935 if (!electrumClient.isInternalStateConsistent) {
3936 SocketHealthLogger().logHealthCheck(
3937 walletType: type,
3938 walletName: name,
3939 isHealthy: false,
3940 syncStatus: syncStatus.toString(),
3941 wasReconnected: false,
3942 trigger: 'socket_health_check_internal_state_inconsistent',
3943 );
3944 }
3945
3946 await _performFullReconnection();
3947
3948 SocketHealthLogger().logHealthCheck(
3949 walletType: type,
3950 walletName: name,
3951 isHealthy: true,
3952 syncStatus: syncStatus.toString(),
3953 wasReconnected: true,
3954 trigger:
3955 'socket_health_check_reconnection_success_for_unhealthy_basic_check_or_internal_state_inconsistent',
3956 );
3957
3958 return true;
3959 }
3960
3961 // Make a call to the server to check if the connection is healthy
3962 // If the call fails, we need to reconnect
3963 try {
3964 final result = await electrumClient.call(
3965 method: 'server.version',
3966 params: ['', '1.4'],
3967 );
3968
3969 if (result == null) {
3970 throw Exception('Call mechanism test returned null');
3971 }
3972
3973 SocketHealthLogger().logHealthCheck(
3974 walletType: type,
3975 walletName: name,
3976 isHealthy: true,
3977 syncStatus: syncStatus.toString(),
3978 wasReconnected: false,
3979 trigger: 'socket_health_check_server_state_ok',
3980 );
3981
3982 return true;
3983 } catch (e) {
3984 SocketHealthLogger().logHealthCheck(
3985 walletType: type,
3986 walletName: name,
3987 isHealthy: false,
3988 error: e.toString(),
3989 syncStatus: syncStatus.toString(),
3990 wasReconnected: false,
3991 trigger: 'socket_health_check_server_state_failed',
3992 );
3993
3994 await _performFullReconnection();
3995
3996 SocketHealthLogger().logHealthCheck(
3997 walletType: type,
3998 walletName: name,
3999 isHealthy: true,
4000 syncStatus: syncStatus.toString(),
4001 wasReconnected: true,
4002 trigger: 'socket_health_check_reconnection_success_for_server_state_failed',
4003 );
4004
4005 return true;
4006 }
4007 } catch (e) {
4008 return false;
4009 }
4010 }
4011
4012 Future<void> _performFullReconnection() async {
4013 try {
4014 SocketHealthLogger().logHealthCheck(
4015 walletType: type,
4016 walletName: name,
4017 syncStatus: syncStatus.toString(),
4018 wasReconnected: true,
4019 trigger: 'full_reconnection_start',
4020 );
4021
4022 await _receiveStream?.cancel();
4023
4024 await electrumClient.close();
4025
4026 if (node != null) {
4027 electrumClient.onConnectionStatusChange = _onConnectionStatusChange;
4028
4029 await electrumClient.connectToUri(node!.uri, useSSL: node!.useSSL);
4030
4031 await startSync();
4032
4033 SocketHealthLogger().logHealthCheck(
4034 walletType: type,
4035 walletName: name,
4036 isHealthy: true,
4037 syncStatus: syncStatus.toString(),
4038 wasReconnected: true,
4039 trigger: 'full_reconnection_success',
4040 );
4041 }
4042 } catch (e) {
4043 SocketHealthLogger().logHealthCheck(
4044 walletType: type,
4045 walletName: name,
4046 isHealthy: false,
4047 error: e.toString(),
4048 syncStatus: syncStatus.toString(),
4049 wasReconnected: false,
4050 trigger: 'full_reconnection_failed',
4051 );
4052
4053 syncStatus = FailedSyncStatus();
4054 }
4055 }
4056
4057 Bip32Slip10Secp256k1 _hdFor({required BaseBitcoinAddressRecord record}) {
4058 final addrType = record.type;
4059
4060 if (record.isLegacyDerivation) {
4061 if (record.isHidden) {
4062 return walletAddresses.legacySideHd;
4063 } else {
4064 return walletAddresses.legacyMainHd;
4065 }
4066 }
4067
4068 if (record.isHidden) {
4069 return sideHdByType[addrType] ?? sideHd;
4070 } else {
4071 return mainHdByType[addrType] ?? mainHd;
4072 }
4073 }
4074 }
4075
4076 class ScanNode {
4077 final Uri uri;
4078 final bool? useSSL;
4079
4080 ScanNode(this.uri, this.useSSL);
4081 }
4082
4083 class ScanData {
4084 final SendPort sendPort;
4085 final SilentPaymentOwner silentAddress;
4086 final Bip32Slip10Secp256k1 masterHD;
4087 final int height;
4088 final ScanNode? node;
4089 final BasedUtxoNetwork network;
4090 final int chainTip;
4091 final electrum.ElectrumClient electrumClient;
4092 final List<String> transactionHistoryIds;
4093 final Map<String, String> labels;
4094 final List<int> labelIndexes;
4095 final bool isSingleScan;
4096 final String debugLogPath;
4097 final List<int>? rescanHeights;
4098
4099 ScanData({
4100 required this.sendPort,
4101 required this.silentAddress,
4102 required this.masterHD,
4103 required this.height,
4104 required this.node,
4105 required this.network,
4106 required this.chainTip,
4107 required this.electrumClient,
4108 required this.transactionHistoryIds,
4109 required this.labels,
4110 required this.labelIndexes,
4111 required this.isSingleScan,
4112 required this.debugLogPath,
4113 required this.rescanHeights,
4114 });
4115
4116 factory ScanData.fromHeight(ScanData scanData, int newHeight) {
4117 return ScanData(
4118 sendPort: scanData.sendPort,
4119 silentAddress: scanData.silentAddress,
4120 masterHD: scanData.masterHD,
4121 height: newHeight,
4122 node: scanData.node,
4123 network: scanData.network,
4124 chainTip: scanData.chainTip,
4125 transactionHistoryIds: scanData.transactionHistoryIds,
4126 electrumClient: scanData.electrumClient,
4127 labels: scanData.labels,
4128 labelIndexes: scanData.labelIndexes,
4129 isSingleScan: scanData.isSingleScan,
4130 debugLogPath: scanData.debugLogPath,
4131 rescanHeights: scanData.rescanHeights,
4132 );
4133 }
4134 }
4135
4136 class SyncResponse {
4137 final int height;
4138 final SyncStatus syncStatus;
4139
4140 SyncResponse(this.height, this.syncStatus);
4141 }
4142
4143 Future<void> _handleScanSilentPayments(ScanData scanData) async {
4144 final shouldUpdateSyncStatus = scanData.rescanHeights == null || scanData.rescanHeights!.isEmpty;
4145 final hasForcedRescanHeights = !shouldUpdateSyncStatus;
4146 CakeTor.instance = await CakeTorInstance.getInstance();
4147
4148 var node = scanData.node?.uri ?? Uri.parse("tcp://electrs.cakewallet.com:50001");
4149
4150 void log(String message, LogLevel level) {
4151 printV("[Scanning] $message", file: scanData.debugLogPath, level: level);
4152 }
4153
4154 try {
4155 // if (scanData.shouldSwitchNodes) {
4156 var scanningClient = await ElectrumProvider.connect(
4157 ElectrumTCPService.connect(node),
4158 );
4159 // }
4160
4161 log("connected to ${node.toString()}", LogLevel.info);
4162
4163 final receivers = [
4164 Receiver(
4165 scanData.silentAddress.b_scan.toHex(),
4166 scanData.silentAddress.B_spend.toHex(),
4167 scanData.network == BitcoinNetwork.testnet,
4168 scanData.labelIndexes,
4169 scanData.labelIndexes.length,
4170 ),
4171 Receiver(
4172 scanData.masterHD.derivePath(SILENT_PAYMENTS_SCAN_PATH_TESTNET).privateKey.toHex(),
4173 scanData.masterHD.derivePath(SILENT_PAYMENTS_SPEND_PATH_TESTNET).publicKey.toHex(),
4174 scanData.network == BitcoinNetwork.testnet,
4175 scanData.labelIndexes,
4176 scanData.labelIndexes.length,
4177 )
4178 ];
4179
4180 log(
4181 "using receiver: b_scan: ${scanData.silentAddress.b_scan.toHex()}, b_spend: ${scanData.silentAddress.B_spend.toHex()}, network: ${scanData.network.value}, labelIndexes: ${scanData.labelIndexes}",
4182 LogLevel.info,
4183 );
4184 log(
4185 "using receiver: b_scan: ${receivers[1].bScan}, b_spend: ${receivers[1].BSpend}, network: ${scanData.network.value}, labelIndexes: ${scanData.labelIndexes}",
4186 LogLevel.info,
4187 );
4188
4189 void scan(int syncHeight, bool isSingleScan) async {
4190 int initialSyncHeight = syncHeight;
4191
4192 int getCountToScanPerRequest(int syncHeight) {
4193 if (isSingleScan) {
4194 return 1;
4195 }
4196
4197 final amountLeft = scanData.chainTip - syncHeight + 1;
4198 return amountLeft;
4199 }
4200
4201 // Initial status UI update, send how many blocks in total to scan
4202 if (shouldUpdateSyncStatus)
4203 scanData.sendPort.send(SyncResponse(syncHeight, StartingScanSyncStatus(syncHeight)));
4204
4205 final req = ElectrumTweaksSubscribe(
4206 height: syncHeight,
4207 count: getCountToScanPerRequest(syncHeight),
4208 historicalMode: hasForcedRescanHeights,
4209 );
4210
4211 var _scanningStream = await scanningClient.subscribe(req);
4212
4213 log(
4214 "initial request: height: $syncHeight, count: ${getCountToScanPerRequest(syncHeight)}",
4215 LogLevel.info,
4216 );
4217
4218 void endScanningSuccesfully() {
4219 if (isSingleScan) {
4220 scanData.sendPort.send(SyncResponse(syncHeight, SyncedSyncStatus()));
4221 } else {
4222 scanData.sendPort.send(
4223 SyncResponse(syncHeight, SyncedTipSyncStatus(scanData.chainTip)),
4224 );
4225 }
4226
4227 _scanningStream?.close();
4228 _scanningStream = null;
4229
4230 log(
4231 "ended: syncHeight: $syncHeight, chainTip: ${scanData.chainTip}, isSingleScan: ${isSingleScan}",
4232 LogLevel.info,
4233 );
4234 }
4235
4236 void listenFn(Map<String, dynamic> event, ElectrumTweaksSubscribe req) async {
4237 final response = req.onResponse(event);
4238
4239 if (response == null || _scanningStream == null) {
4240 log(
4241 "ending: response = $response, stream = $_scanningStream",
4242 LogLevel.error,
4243 );
4244 return;
4245 }
4246
4247 // is success or error msg
4248 final noData = response.message != null;
4249
4250 if (noData) {
4251 if (isSingleScan) {
4252 log("ending: noData and isSingleScan", LogLevel.info);
4253
4254 endScanningSuccesfully();
4255 return;
4256 }
4257
4258 // re-subscribe to continue receiving messages, starting from the next unscanned height
4259 final nextHeight = syncHeight + 1;
4260
4261 if (nextHeight <= scanData.chainTip) {
4262 log(
4263 "resubscribing: nextHeight: $nextHeight, count: ${getCountToScanPerRequest(nextHeight)}",
4264 LogLevel.info,
4265 );
4266
4267 final nextStream = scanningClient.subscribe(
4268 ElectrumTweaksSubscribe(
4269 height: nextHeight,
4270 count: getCountToScanPerRequest(nextHeight),
4271 historicalMode: hasForcedRescanHeights,
4272 ),
4273 );
4274
4275 if (nextStream != null) {
4276 nextStream.listen((event) => listenFn(event, req));
4277 } else {
4278 if (shouldUpdateSyncStatus)
4279 scanData.sendPort.send(
4280 SyncResponse(scanData.height, LostConnectionSyncStatus()),
4281 );
4282 }
4283 }
4284
4285 log(
4286 "ending: resubscribing: nextHeight: $nextHeight, count: ${getCountToScanPerRequest(nextHeight)}",
4287 LogLevel.info,
4288 );
4289 return;
4290 }
4291
4292 final tweakHeight = response.block;
4293
4294 // Continuous status UI update, send how many blocks left to scan
4295 final syncingStatus = isSingleScan
4296 ? SyncingSyncStatus(1, 0)
4297 : SyncingSyncStatus.fromHeightValues(scanData.chainTip, initialSyncHeight, tweakHeight);
4298
4299 if (shouldUpdateSyncStatus) scanData.sendPort.send(SyncResponse(syncHeight, syncingStatus));
4300
4301 try {
4302 final blockTweaks = response.blockTweaks;
4303
4304 var blockDate = DateTime.now();
4305 bool isDateNow = true;
4306
4307 for (final txid in blockTweaks.keys) {
4308 final tweakData = blockTweaks[txid];
4309 final outputPubkeys = tweakData!.outputPubkeys;
4310 final tweak = tweakData.tweak;
4311
4312 try {
4313 final addToWallet = <String, dynamic>{};
4314
4315 receivers.forEach((receiver) {
4316 final preparedList = outputPubkeys.keys.toList().map((e) => [e]).toList();
4317 // NOTE: scanOutputs, from sp_scanner package, called from rust here
4318 final scanResult = scanOutputs(preparedList, tweak, receiver);
4319
4320 if (scanResult.isEmpty) return;
4321
4322 if (addToWallet[receiver.BSpend] == null) {
4323 addToWallet[receiver.BSpend] = scanResult;
4324 } else {
4325 addToWallet[receiver.BSpend].addAll(scanResult);
4326 }
4327 });
4328
4329 if (addToWallet.isEmpty) {
4330 // no results tx, continue to next tx
4331 continue;
4332 }
4333
4334 log(
4335 "FOUND: addToWallet: ${addToWallet.length}, txid: $txid, tweak: $tweak, height: $tweakHeight",
4336 LogLevel.info,
4337 );
4338
4339 // Every tx in the block has the same date (the block date)
4340 // So, if blockDate exists, reuse
4341 if (isDateNow) {
4342 try {
4343 final rootURL = "https://cake.mempool.space";
4344 final tweakBlockHash = await ProxyWrapper()
4345 .get(clearnetUri: Uri.parse("$rootURL/api/block-height/$tweakHeight"))
4346 .timeout(Duration(seconds: 15));
4347 final blockResponse = await ProxyWrapper()
4348 .get(clearnetUri: Uri.parse("$rootURL/api/block/${tweakBlockHash.body}"))
4349 .timeout(Duration(seconds: 15));
4350
4351 if (blockResponse.statusCode == 200 &&
4352 blockResponse.body.isNotEmpty &&
4353 jsonDecode(blockResponse.body)['timestamp'] != null) {
4354 blockDate = DateTime.fromMillisecondsSinceEpoch(
4355 int.parse(jsonDecode(blockResponse.body)['timestamp'].toString()) * 1000,
4356 );
4357 isDateNow = false;
4358 }
4359 } catch (e, stacktrace) {
4360 printV(stacktrace);
4361 printV(e.toString());
4362 }
4363 }
4364
4365 // initial placeholder ElectrumTransactionInfo object to update values based on new scanned unspent(s) on the following loop
4366 final txInfo = ElectrumTransactionInfo(
4367 WalletType.bitcoin,
4368 id: txid,
4369 height: tweakHeight,
4370 amount: Money.zero(CryptoCurrency.btc),
4371 fee: Money.zero(CryptoCurrency.btc),
4372 direction: TransactionDirection.incoming,
4373 isReplaced: false,
4374 date: scanData.network == BitcoinNetwork.mainnet
4375 ? (isDateNow ? getDateByBitcoinHeight(tweakHeight) : blockDate)
4376 : DateTime.now(),
4377 confirmations: scanData.chainTip - tweakHeight + 1,
4378 isReceivedSilentPayment: true,
4379 isPending: false,
4380 unspents: [],
4381 );
4382
4383 List<BitcoinUnspent> unspents = [];
4384
4385 addToWallet.forEach((BSpend, scanResultPerLabel) {
4386 scanResultPerLabel.forEach((label, scanOutput) {
4387 final labelValue = label == "None" ? null : label.toString();
4388
4389 (scanOutput as Map<String, dynamic>).forEach((outputPubkey, tweak) {
4390 final t_k = tweak as String;
4391
4392 final receivingOutputAddress = ECPublic.fromHex(outputPubkey)
4393 .toTaprootAddress(tweak: false)
4394 .toAddress(scanData.network);
4395
4396 final matchingOutput = outputPubkeys[outputPubkey]!;
4397 final amount = matchingOutput.amount;
4398 final pos = matchingOutput.vout;
4399 final spent = matchingOutput.spendingInput;
4400
4401 final matchingReceiver =
4402 receivers.indexWhere((receiver) => receiver.BSpend == BSpend);
4403
4404 // final labelIndex = labelValue != null ? scanData.labels[label] : 0;
4405 // final balance = ElectrumBalance();
4406 // balance.confirmed = amount;
4407
4408 final receivedAddressRecord = BitcoinSilentPaymentAddressRecord(
4409 receivingOutputAddress,
4410 index: 0,
4411 isHidden: false,
4412 isUsed: true,
4413 network: scanData.network,
4414 silentPaymentTweak: t_k,
4415 type: SegwitAddresType.p2tr,
4416 txCount: 1,
4417 balance: amount,
4418 spendDerivationPath: matchingReceiver == 0
4419 ? SILENT_PAYMENTS_SPEND_PATH
4420 : SILENT_PAYMENTS_SPEND_PATH_TESTNET,
4421 );
4422
4423 final unspent = BitcoinSilentPaymentsUnspent(
4424 receivedAddressRecord,
4425 txid,
4426 amount,
4427 pos,
4428 silentPaymentTweak: t_k,
4429 silentPaymentLabel: labelValue,
4430 );
4431
4432 if (spent == null) {
4433 unspents.add(unspent);
4434 txInfo.unspents!.add(unspent);
4435 }
4436
4437 txInfo.amount += Money.fromInt(unspent.value, txInfo.amount.currency);
4438 });
4439 });
4440 });
4441
4442 scanData.sendPort.send({txInfo.id: txInfo});
4443 } catch (e, stacktrace) {
4444 if (shouldUpdateSyncStatus)
4445 scanData.sendPort.send(
4446 SyncResponse(syncHeight, LostConnectionSyncStatus()),
4447 );
4448
4449 log(stacktrace.toString(), LogLevel.error);
4450 log(e.toString(), LogLevel.error);
4451 return;
4452 }
4453 }
4454 } catch (e, stacktrace) {
4455 if (shouldUpdateSyncStatus)
4456 scanData.sendPort.send(
4457 SyncResponse(syncHeight, LostConnectionSyncStatus()),
4458 );
4459
4460 log(stacktrace.toString(), LogLevel.error);
4461 log(e.toString(), LogLevel.error);
4462 return;
4463 }
4464
4465 syncHeight = tweakHeight;
4466
4467 if ((tweakHeight >= scanData.chainTip) || isSingleScan) {
4468 endScanningSuccesfully();
4469 }
4470 }
4471
4472 _scanningStream?.listen((event) => listenFn(event, req));
4473 }
4474
4475 if (scanData.rescanHeights != null) {
4476 for (final height in scanData.rescanHeights!) {
4477 log("rescanning from height: $height", LogLevel.info);
4478 scan(height, true);
4479 }
4480 } else {
4481 scan(scanData.height, scanData.isSingleScan);
4482 }
4483 } catch (e) {
4484 log("Error in _handleScanSilentPayments: $e", LogLevel.error);
4485 if (shouldUpdateSyncStatus)
4486 scanData.sendPort.send(SyncResponse(scanData.height, LostConnectionSyncStatus()));
4487 }
4488 }
4489
4490 class EstimatedTxResult {
4491 EstimatedTxResult({
4492 required this.utxos,
4493 required this.inputPrivKeyInfos,
4494 required this.publicKeys,
4495 required this.fee,
4496 required this.amount,
4497 required this.hasChange,
4498 required this.isSendAll,
4499 this.memo,
4500 required this.spendsSilentPayment,
4501 required this.spendsUnconfirmedTX,
4502 });
4503
4504 final List<UtxoWithAddress> utxos;
4505 final List<ECPrivateInfo> inputPrivKeyInfos;
4506 final Map<String, PublicKeyWithDerivationPath> publicKeys; // PubKey to derivationPath
4507 final Money fee;
4508 final Money amount;
4509 final bool spendsSilentPayment;
4510
4511 // final bool sendsToSilentPayment;
4512 final bool hasChange;
4513 final bool isSendAll;
4514 final String? memo;
4515 final bool spendsUnconfirmedTX;
4516 }
4517
4518 class PublicKeyWithDerivationPath {
4519 const PublicKeyWithDerivationPath(this.publicKey, this.derivationPath);
4520
4521 final String derivationPath;
4522 final String publicKey;
4523 }
4524
4525 BitcoinAddressType _getScriptType(BitcoinBaseAddress type) {
4526 if (type is P2pkhAddress) {
4527 return P2pkhAddressType.p2pkh;
4528 } else if (type is P2shAddress) {
4529 return P2shAddressType.p2wpkhInP2sh;
4530 } else if (type is P2wshAddress) {
4531 return SegwitAddresType.p2wsh;
4532 } else if (type is P2trAddress) {
4533 return SegwitAddresType.p2tr;
4534 } else if (type is MwebAddress) {
4535 return SegwitAddresType.mweb;
4536 } else if (type is SilentPaymentsAddresType) {
4537 return SilentPaymentsAddresType.p2sp;
4538 } else {
4539 return SegwitAddresType.p2wpkh;
4540 }
4541 }
4542
4543 class UtxoDetails {
4544 final List<BitcoinUnspent> availableInputs;
4545 final List<BitcoinUnspent> unconfirmedCoins;
4546 final List<UtxoWithAddress> utxos;
4547 final List<Outpoint> vinOutpoints;
4548 final List<ECPrivateInfo> inputPrivKeyInfos;
4549 final Map<String, PublicKeyWithDerivationPath> publicKeys; // PubKey to derivationPath
4550 final int allInputsAmount;
4551 final bool spendsSilentPayment;
4552 final bool spendsUnconfirmedTX;
4553
4554 UtxoDetails({
4555 required this.availableInputs,
4556 required this.unconfirmedCoins,
4557 required this.utxos,
4558 required this.vinOutpoints,
4559 required this.inputPrivKeyInfos,
4560 required this.publicKeys,
4561 required this.allInputsAmount,
4562 required this.spendsSilentPayment,
4563 required this.spendsUnconfirmedTX,
4564 });
4565 }