import 'dart:async'; import 'dart:convert'; import 'dart:isolate'; import 'dart:math' show Random; import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:cw_bitcoin/lightning/lightning_addres_type.dart'; import 'package:cw_bitcoin/lightning/lightning_wallet.dart'; import 'package:cw_bitcoin/locktime.dart'; import 'package:cw_core/hardware/hardware_wallet_service.dart'; import 'package:cw_core/root_dir.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_bitcoin/bitcoin_wallet.dart'; import 'package:cw_bitcoin/coin_selection.dart'; import 'package:cw_bitcoin/litecoin_wallet.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:collection/collection.dart'; import 'package:cw_bitcoin/address_from_output.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart'; import 'package:cw_bitcoin/bitcoin_transaction_priority.dart'; import 'package:cw_bitcoin/bitcoin_unspent.dart'; import 'package:cw_bitcoin/bitcoin_wallet_keys.dart'; import 'package:cw_bitcoin/electrum.dart' as electrum; import 'package:cw_bitcoin/electrum_balance.dart'; import 'package:cw_bitcoin/electrum_derivations.dart'; import 'package:cw_bitcoin/electrum_transaction_history.dart'; import 'package:cw_bitcoin/electrum_transaction_info.dart'; import 'package:cw_bitcoin/electrum_wallet_addresses.dart'; import 'package:cw_bitcoin/exceptions.dart'; import 'package:cw_bitcoin/pending_bitcoin_transaction.dart'; import 'package:cw_bitcoin/utils.dart'; import 'package:cw_core/amount/money.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/get_height_by_date.dart'; import 'package:cw_core/node.dart'; import 'package:cw_core/output_info.dart'; import 'package:cw_core/pending_transaction.dart'; import 'package:cw_core/sync_status.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_priority.dart'; import 'package:cw_core/unspent_coin_type.dart'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/utils/socket_health_logger.dart'; import 'package:cw_core/utils/tor/abstract.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_keys_file.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:flutter/foundation.dart'; import 'package:hex/hex.dart'; import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; import 'package:rxdart/subjects.dart'; import 'package:sp_scanner/sp_scanner.dart'; part 'electrum_wallet.g.dart'; class ElectrumWallet = ElectrumWalletBase with _$ElectrumWallet; abstract class ElectrumWalletBase extends WalletBase with Store, WalletKeysFile { ElectrumWalletBase({ required String password, required WalletInfo walletInfo, required DerivationInfo derivationInfo, required Box unspentCoinsInfo, required this.network, required this.encryptionFileUtils, String? xpub, String? mnemonic, Uint8List? seedBytes, this.passphrase, List? initialAddresses, electrum.ElectrumClient? electrumClient, ElectrumBalance? initialBalance, CryptoCurrency? currency, bool? alwaysScan, bool useLightning = true, }) : _masterHD = getMasterHD(seedBytes, network, walletInfo.hardwareWalletType), accountHD = getAccountHDWallet( currency, network, seedBytes, xpub, derivationInfo, walletInfo.hardwareWalletType), syncStatus = NotConnectedSyncStatus(), _password = password, _feeRates = [], _isTransactionUpdating = false, isEnabledAutoGenerateSubaddress = true, unspentCoins = [], _scripthashesUpdateSubject = {}, this.alwaysScan = alwaysScan, silentPaymentsScanningActive = alwaysScan ?? false, balance = ObservableMap.of( currency != null ? {currency: initialBalance ?? ElectrumBalance.zero(currency)} : {}), this.unspentCoinsInfo = unspentCoinsInfo, this.isTestnet = !network.isMainnet, this._mnemonic = mnemonic, this.electrumClient = electrumClient ?? electrum.ElectrumClient(), _useLightning = useLightning, super(walletInfo, derivationInfo) { transactionHistory = ElectrumTransactionHistory( walletInfo: walletInfo, password: password, encryptionFileUtils: encryptionFileUtils, ); reaction((_) => syncStatus, _syncStatusReaction); sharedPrefs.complete(SharedPreferences.getInstance()); // Probably not needed as init will be called right after creating the wallet. _prepareHdForAccount(currentAccountIndex, currency); } int _purposeForType(BitcoinAddressType type) { switch (type.value) { case 'P2PKH': return 44; case 'P2SH/P2WPKH': return 49; case 'P2WPKH': return 84; case 'P2TR': return 86; default: return 84; } } int _coinTypeFor(CryptoCurrency cur) { if (!network.isMainnet) return 1; switch (cur) { case CryptoCurrency.btc: case CryptoCurrency.tbtc: return 0; case CryptoCurrency.ltc: return 2; case CryptoCurrency.bch: return 145; case CryptoCurrency.doge: return 3; default: return 0; } } /// Returns the BIP32 account derivation path (m/purpose'/coinType'/0') for STANDARD addresses. /// For LEGACY addresses, returns the wallet's legacy derivation base (derivationInfo.derivationPath) /// which is already the account path used historically (e.g. m/0' or m/84'/0'/0'). String _accountDerivationPathForRecord(BaseBitcoinAddressRecord record) { if (derivationInfo.derivationType == DerivationType.electrum) { return derivationInfo.derivationPath ?? electrum_path; // m/0' } if (record.isLegacyDerivation) { return derivationInfo.derivationPath ?? electrum_path; } final coinType = _coinTypeFor(currency); final purpose = _purposeForType(record.type); return "m/$purpose'/$coinType'/${record.accountIndex}'"; } List supportedAddressTypes(WalletType type) { switch (type) { case WalletType.bitcoin: return BITCOIN_ADDRESS_TYPES; case WalletType.bitcoinCash: return BITCOIN_CASH_ADDRESS_TYPES; case WalletType.dogecoin: return DOGECOIN_ADDRESS_TYPES; case WalletType.litecoin: return LITECOIN_ADDRESS_TYPES; default: return BITCOIN_ADDRESS_TYPES; } } static Bip32Slip10Secp256k1 getAccountHDWallet( CryptoCurrency? currency, BasedUtxoNetwork network, Uint8List? seedBytes, String? xpub, DerivationInfo? derivationInfo, HardwareWalletType? hardwareWalletType) { if (seedBytes == null && xpub == null) { throw Exception( "To create a Wallet you need either a seed or an xpub. This should not happen"); } if (seedBytes != null) { switch (currency) { case CryptoCurrency.btc: case CryptoCurrency.ltc: case CryptoCurrency.tbtc: return Bip32Slip10Secp256k1.fromSeed( seedBytes, getKeyNetVersion(network, hardwareWalletType)) .derivePath( _hardenedDerivationPath(derivationInfo?.derivationPath ?? electrum_path)) as Bip32Slip10Secp256k1; case CryptoCurrency.bch: return bitcoinCashHDWallet(seedBytes); case CryptoCurrency.doge: return dogecoinHDWallet(seedBytes); default: throw Exception("Unsupported currency"); } } return Bip32Slip10Secp256k1.fromExtendedKey( xpub!, getKeyNetVersion(network, hardwareWalletType)); } static Bip32Slip10Secp256k1 bitcoinCashHDWallet(Uint8List seedBytes) => Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath("m/44'/145'/0'") as Bip32Slip10Secp256k1; static Bip32Slip10Secp256k1 dogecoinHDWallet(Uint8List seedBytes) => Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath("m/44'/3'/0'") as Bip32Slip10Secp256k1; static int estimatedTransactionSize(int inputsCount, int outputsCounts) => inputsCount * 68 + outputsCounts * 34 + 10; // vbytes an input of the given script type adds to a transaction. The generic // estimate above assumes P2WPKH (68); legacy and taproot inputs differ enough // to break changeless-match arithmetic if not accounted for. static int estimatedInputSize(BitcoinAddressType type) { if (type == P2pkhAddressType.p2pkh) return 148; if (type == P2shAddressType.p2wpkhInP2sh) return 91; if (type == SegwitAddresType.p2tr) return 58; if (type == SegwitAddresType.p2wsh) return 105; if (type == SilentPaymentsAddresType.p2sp) return 58; // spent via taproot return 68; } // vbytes an output of the given script type adds to a transaction. Silent // payment outputs are delivered as taproot. static int estimatedOutputSize(BitcoinAddressType type) { if (type == P2pkhAddressType.p2pkh) return 34; if (type == P2shAddressType.p2wpkhInP2sh) return 32; if (type == SegwitAddresType.p2tr) return 43; if (type == SegwitAddresType.p2wsh) return 43; if (type == SilentPaymentsAddresType.p2sp) return 43; return 31; } // Parses the account index from a BIP-44/49/84/86 derivation path. // e.g. "m/84'/0'/1'" → 1. Returns 0 for unrecognised formats. static int _parseAccountIndex(String? derivationPath) { if (derivationPath == null) return 0; final parts = derivationPath.split('/'); if (parts.length < 4) return 0; return int.tryParse(parts[3].replaceAll("'", "")) ?? 0; } static Bip32KeyNetVersions? getKeyNetVersion(BasedUtxoNetwork network, [HardwareWalletType? hardwareWalletType]) { switch (network) { case LitecoinNetwork.mainnet: if ([HardwareWalletType.ledger, HardwareWalletType.trezor].contains(hardwareWalletType)) return Bip44Conf.litecoinMainNet.altKeyNetVer; return null; default: return null; } } static Bip32Slip10Secp256k1? getMasterHD(Uint8List? seedBytes, [BasedUtxoNetwork? network, HardwareWalletType? hardwareWalletType]) { if (seedBytes == null) return null; return Bip32Slip10Secp256k1.fromSeed( seedBytes, network != null ? getKeyNetVersion(network, hardwareWalletType) : null); } static const int addressHistoryChunkSize = 150; static const int transactionChunkSize = 150; static const int discoveryHistoryChunkSize = 20; static const int transactionBatchTimeoutMs = 15000; static const int batchTestTimeoutMs = 4000; static const int batchTestHashesCount = 2; static const bool useBatchForHistory = true; static const int maxProbAccounts = 3; final Map> _appliedCoinKeysByAddress = {}; @observable bool? alwaysScan; @computed bool get useLightning => _useLightning && LightningWallet.isAvailable; set useLightning(bool val) => _useLightning = val && LightningWallet.isAvailable; @observable bool _useLightning; @observable ObservableMap accountBalances = ObservableMap(); final Bip32Slip10Secp256k1? _masterHD; final Bip32Slip10Secp256k1 accountHD; final String? _mnemonic; final mainHdByTypeAndAccount = >{}; final sideHdByTypeAndAccount = >{}; Bip32Slip10Secp256k1 get mainHd => accountHD.childKey(Bip32KeyIndex(0)); Bip32Slip10Secp256k1 get sideHd => accountHD.childKey(Bip32KeyIndex(1)); final EncryptionFileUtils encryptionFileUtils; @override final String? passphrase; @override @observable bool isEnabledAutoGenerateSubaddress; late electrum.ElectrumClient electrumClient; Box unspentCoinsInfo; @override late ElectrumWalletAddresses walletAddresses; @override @observable late ObservableMap balance; @override @observable SyncStatus syncStatus; bool _hasCompleteUnspentSet = false; bool _isSyncing = false; Future loadCurrentAccount() async { final accounts = await walletInfo.getAccounts(); final current = accounts.firstWhere( (account) => account.accountIndex == currentAccountIndex, orElse: () => accounts.first, ); return current; } Future> loadAccountIndexes() async { if (!hasAccountsSupport) return [0]; final accounts = await walletInfo.getAccounts(); return accounts.map((account) => account.accountIndex).toList(); } void _prepareHdForAccount(int accountIndex, CryptoCurrency? currency) { // For the first account, we support all address types. For extra accounts, we only support SegwitAddresType.p2wpkh. final supportedTypes = accountIndex == 0 ? supportedAddressTypes(walletInfo.type) : EXTRA_ACCOUNT_ADDRESS_TYPES; final isElectrumDerivation = derivationInfo.derivationType == DerivationType.electrum; final canDeriveFromSeed = _masterHD != null && currency != null; final mainMap = mainHdByTypeAndAccount[accountIndex] ??= {}; final sideMap = sideHdByTypeAndAccount[accountIndex] ??= {}; if (canDeriveFromSeed && !isElectrumDerivation) { final coinType = _coinTypeFor(currency); for (final type in supportedTypes) { final purpose = _purposeForType(type); final accountPath = "m/$purpose'/$coinType'/$accountIndex'"; mainMap[type] = _masterHD!.derivePath("$accountPath/0") as Bip32Slip10Secp256k1; sideMap[type] = _masterHD!.derivePath("$accountPath/1") as Bip32Slip10Secp256k1; } } else { // case for Electrum derivation or when we don't have seed but only xpub (view only wallet) for (final type in supportedTypes) { mainMap[type] = mainHd; // accountHD.child(0) sideMap[type] = sideHd; // accountHD.child(1) } } } ElectrumBalance balanceForAccount(int accountIndex) => accountBalances[accountIndex] ?? ElectrumBalance.zero(currency); void _updateAccountBalancesFromUnspents() { if (!_hasCompleteUnspentSet) return; final newBalances = {}; for (final coin in unspentCoins) { final accountIndex = coin.bitcoinAddressRecord.accountIndex; final current = newBalances[accountIndex] ?? ElectrumBalance.zero(currency); final amount = Money.fromInt(coin.value, currency); if (coin.isFrozen) { current.frozen += amount; } if (coin.confirmations != null && coin.confirmations! > 0) { current.confirmed += amount; } else { current.unconfirmed += amount; } newBalances[accountIndex] = current; } for (final accountIndex in walletAddresses.accountIndexes) { newBalances.putIfAbsent(accountIndex, () => ElectrumBalance.zero(currency)); } accountBalances = ObservableMap.of(newBalances); _updateCurrentAccountBalance(); } void _updateCurrentAccountBalance({int? accountIndex}) { if (type != WalletType.bitcoin) return; final targetAccountIndex = accountIndex ?? currentAccountIndex; final newBalance = accountBalances[targetAccountIndex]; if (newBalance == null) { // If the account balance is not found, set it to zero for the current currency. balance[currency] = ElectrumBalance.zero(currency); return; } balance[currency] = newBalance.copy(); } Map> get addressesSetByAccount { final result = >{}; for (final addr in walletAddresses.allAddresses) { if (addr.type == SegwitAddresType.mweb) continue; (result[addr.accountIndex] ??= {}).add(addr.address); } return result; } int? _accountForTransaction( ElectrumTransactionBundle bundle, Map> addressesByAccount, ) { if (type != WalletType.bitcoin) return null; int? accountForAddress(String address) { for (final entry in addressesByAccount.entries) { if (entry.value.contains(address)) return entry.key; } return null; } for (var i = 0; i < bundle.originalTransaction.inputs.length; i++) { final inputTransaction = bundle.ins[i]; if (inputTransaction == null) continue; final outTransaction = inputTransaction.outputs[bundle.originalTransaction.inputs[i].txIndex]; final account = accountForAddress(addressFromOutputScript(outTransaction.scriptPubKey, network)); if (account != null) return account; } for (final out in bundle.originalTransaction.outputs) { final account = accountForAddress(addressFromOutputScript(out.scriptPubKey, network)); if (account != null) return account; } return null; } Set get addressesSet => walletAddresses.allAddresses .where((element) => element.type != SegwitAddresType.mweb) .map((addr) => addr.address) .toSet(); List get scriptHashes => walletAddresses.addressesByReceiveType .where((addr) => RegexUtils.addressTypeFromStr(addr.address, network) is! MwebAddress) .map((addr) => (addr as BitcoinAddressRecord).getScriptHash(network)) .toList(); List get publicScriptHashes => walletAddresses.allAddresses .where((addr) => !addr.isHidden) .where((addr) => RegexUtils.addressTypeFromStr(addr.address, network) is! MwebAddress) .map((addr) => addr.getScriptHash(network)) .toList(); String get xpub => accountHD.publicKey.toExtended; int get currentAccountIndex => walletInfo.currentAccountIndex; @action Future setCurrentAccount(int accountIndex) async { final isNewAccount = !walletAddresses.accountIndexes.contains(accountIndex); if (isNewAccount) { walletAddresses.accountIndexes.add(accountIndex); accountBalances[accountIndex] = ElectrumBalance.zero(currency); } await walletInfo.setSelectedAccount(accountIndex); _prepareHdForAccount(accountIndex, currency); walletAddresses.currentAccountIndex = accountIndex; final currentPageType = walletAddresses.addressPageType; await walletAddresses.setAddressType(currentPageType); // For extra accounts, we only prepare SegwitAddresType.p2wpkh type. if (isNewAccount) { await walletAddresses.prepareAccountAddresses( accountIndex, types: accountIndex == 0 ? BITCOIN_ADDRESS_TYPES : EXTRA_ACCOUNT_ADDRESS_TYPES, ); await walletAddresses.updateAddressesInBox(); } walletAddresses.updateReceiveAddresses(); walletAddresses.updateChangeAddresses(); _updateCurrentAccountBalance(accountIndex: accountIndex); unawaited(() async { try { if (isNewAccount && !_isSyncing) { final histories = {}; final typesToFetch = accountIndex == 0 ? BITCOIN_ADDRESS_TYPES : EXTRA_ACCOUNT_ADDRESS_TYPES; // Explicit for clarity — fetchTransactionsForAddressTypeBatch already skips types // with no addresses for this account, so this doesn't change RPC calls, just intent. for (final addressType in typesToFetch) { if (shouldUseBatchFetching) { await fetchTransactionsForAddressTypeBatch(histories, addressType, accountIndex: accountIndex); } else { await fetchTransactionsForAddressType(histories, addressType, accountIndex: accountIndex); } } await resolveQueuedTransactionDetails(); } await updateAllUnspents(); } catch (e) { printV('setCurrentAccount balance update failed: $e'); } }()); } bool get shouldUseBatchFetching => useBatchForHistory && _isBatchSupported == true; bool get isInitialBitcoinAccountsSync => hasAccountsSupport && walletInfo.isMultiAccountsEnabled == true && (walletInfo.accountDiscoveryLimit ?? 0) < maxProbAccounts; @override String? get seed => _mnemonic; @override WalletKeysData get walletKeysData => WalletKeysData(mnemonic: _mnemonic, xPub: xpub, passphrase: passphrase); @override String get password => _password; BasedUtxoNetwork network; @override bool isTestnet; @override bool get hasSilentPaymentsScanning => type == WalletType.bitcoin && keys.privateKey.isNotEmpty; @override bool get hasCoinControl => true; @override bool get hasAccountsSupport => type == WalletType.bitcoin && isSoftwareWallet && _masterHD != null && // xpub / watch-only: can't derive other (hardened) accounts derivationInfo.derivationType != DerivationType.electrum; @observable bool nodeSupportsSilentPayments = true; @observable bool silentPaymentsScanningActive = false; bool _isTryingToConnect = false; bool? _isBatchSupported; DateTime? _syncBenchmarkStartTime; DateTime? _syncStartedAt; static const Duration maxSyncDuration = Duration(minutes: 2); Completer sharedPrefs = Completer(); Future checkIfMempoolAPIIsEnabled() async { bool isMempoolAPIEnabled = (await sharedPrefs.future).getBool("use_mempool_fee_api") ?? true; return isMempoolAPIEnabled; } @action Future setSilentPaymentsScanning(bool active) async { silentPaymentsScanningActive = active; if (active) { syncStatus = AttemptingScanSyncStatus(); final tip = await getUpdatedChainTip(); if (tip == walletInfo.restoreHeight) { syncStatus = SyncedTipSyncStatus(tip); return; } if (tip > walletInfo.restoreHeight) { _setListeners(walletInfo.restoreHeight, chainTipParam: currentChainTip); } } else { alwaysScan = false; _isolate?.then((value) => value.kill(priority: Isolate.immediate)); if (electrumClient.isConnected) { if (!_isSyncing) syncStatus = SyncedSyncStatus(); } else { syncStatus = NotConnectedSyncStatus(); } } } int? currentChainTip; Future getCurrentChainTip() async { if ((currentChainTip ?? 0) > 0) { return currentChainTip!; } currentChainTip = await electrumClient.getCurrentBlockChainTip() ?? 0; return currentChainTip!; } Future getUpdatedChainTip() async { final newTip = await electrumClient.getCurrentBlockChainTip(); if (newTip != null && newTip > (currentChainTip ?? 0)) { currentChainTip = newTip; } return currentChainTip ?? 0; } /// Anti-fee-sniping locktime (current tip, exact-tip), LE-encoded for /// `BitcoinTransactionBuilder`. Future> _antiFeeSnipingLocktime() async { return locktimeToBytes(antiFeeSnipingLocktime( chainTip: await getCurrentChainTip(), synced: syncStatus is SyncedSyncStatus, )); } @override BitcoinWalletKeys get keys { String? wif; String? privateKey; String? publicKey; // `keys` is wallet-level metadata final hd = mainHdByTypeAndAccount[0]?[SegwitAddresType.p2wpkh] ?? mainHd; try { wif = WifEncoder.encode(hd.privateKey.raw, netVer: network.wifNetVer); } catch (_) {} try { privateKey = hd.privateKey.toHex(); } catch (_) {} try { publicKey = hd.publicKey.toHex(); } catch (_) {} return BitcoinWalletKeys( wif: wif ?? '', privateKey: privateKey ?? '', publicKey: publicKey ?? '', xpub: xpub, masterFingerprint: _masterHD?.fingerPrint.toHex() ?? '', ); } String _password; List unspentCoins; List _feeRates; // ignore: prefer_final_fields Map?> _scripthashesUpdateSubject; // ignore: prefer_final_fields BehaviorSubject? _chainTipUpdateSubject; bool _isTransactionUpdating; Future? _isolate; final Map> _missingHistoryQueue = {}; Future? _resolveMissingHistoryFuture; final Map _unresolvedAccountRetryCount = {}; static const int _maxUnresolvedAccountRetries = 3; void Function(FlutterErrorDetails)? _onError; Timer? _autoSaveTimer; StreamSubscription? _receiveStream; Timer? _updateFeeRateTimer; static const int _autoSaveInterval = 1; Future init() async { final accountIndexes = await loadAccountIndexes(); for (final accountIndex in accountIndexes) { _prepareHdForAccount(accountIndex, currency); } await walletAddresses.init(accountIndexes: accountIndexes); await transactionHistory.init(); await cleanUpDuplicateUnspentCoins(); _seedAccountBalancesFromAddressRecords(); await save(); _autoSaveTimer = Timer.periodic(Duration(minutes: _autoSaveInterval), (_) async => await save()); } @action Future _setListeners(int height, {int? chainTipParam, bool? doSingleScan, List? rescanHeights}) async { if (this is! BitcoinWallet) return; if (isHardwareWallet) return; if (seed?.isEmpty ?? true) return; final chainTip = chainTipParam ?? await getUpdatedChainTip(); final shouldUpdateSyncStatus = rescanHeights == null || rescanHeights.isEmpty; if (chainTip == height) { syncStatus = SyncedSyncStatus(); return; } if (shouldUpdateSyncStatus) syncStatus = AttemptingScanSyncStatus(); if (_isolate != null) { final runningIsolate = await _isolate!; runningIsolate.kill(priority: Isolate.immediate); } final appDir = await getAppDir(); String debugLogPath = "${appDir.path}/logs/debug.log"; final receivePort = ReceivePort(); _isolate = Isolate.spawn( _handleScanSilentPayments, ScanData( sendPort: receivePort.sendPort, silentAddress: walletAddresses.silentAddress!, masterHD: _masterHD!, network: network, height: height, chainTip: chainTip, electrumClient: electrum.ElectrumClient(), transactionHistoryIds: transactionHistory.transactions.keys.toList(), node: (await getNodeSupportsSilentPayments()) == true ? ScanNode(node!.uri, node!.useSSL) : null, labels: walletAddresses.labels, labelIndexes: walletAddresses.silentAddresses .where((addr) => addr.type == SilentPaymentsAddresType.p2sp && addr.index >= 1) .map((addr) => addr.index) .toList(), isSingleScan: doSingleScan ?? false, debugLogPath: debugLogPath, rescanHeights: rescanHeights, ), ); await _receiveStream?.cancel(); _receiveStream = receivePort.listen((var message) async { if (message is Map) { for (final map in message.entries) { final txid = map.key; final tx = map.value; if (tx.unspents != null) { final existingTxInfo = transactionHistory.transactions[txid]; final txAlreadyExisted = existingTxInfo != null; // Updating tx after re-scanned if (txAlreadyExisted) { existingTxInfo.amount = tx.amount; existingTxInfo.confirmations = tx.confirmations; existingTxInfo.height = tx.height; existingTxInfo.date = tx.date; existingTxInfo.isReceivedSilentPayment = tx.isReceivedSilentPayment; existingTxInfo.direction = tx.direction; existingTxInfo.isPending = tx.isPending; existingTxInfo.unspents = tx.unspents; final newUnspents = tx.unspents! .where((unspent) => !(existingTxInfo.unspents?.any((element) => element.hash.contains(unspent.hash) && element.vout == unspent.vout && element.value == unspent.value) ?? false)) .toList(); if (newUnspents.isNotEmpty) { newUnspents.forEach(_updateSilentAddressRecord); existingTxInfo.unspents ??= []; existingTxInfo.unspents!.addAll(newUnspents); final newAmount = newUnspents.length > 1 ? newUnspents.map((e) => e.value).reduce((value, unspent) => value + unspent) : newUnspents[0].value; if (existingTxInfo.direction == TransactionDirection.incoming) { existingTxInfo.amount += Money.fromInt(newAmount, currency); } // Updates existing TX transactionHistory.addOne(existingTxInfo); // Update balance record balance[currency]!.confirmed += Money.fromInt(newAmount, currency); } } else { // else: First time seeing this TX after scanning tx.unspents!.forEach(_updateSilentAddressRecord); // Add new TX record transactionHistory.addMany(message); // Update balance record balance[currency]!.confirmed += tx.amount; await save(); } await updateAllUnspents(); } } } if (message is SyncResponse) { if (message.syncStatus is UnsupportedSyncStatus) { nodeSupportsSilentPayments = false; } if (message.syncStatus is SyncingSyncStatus) { var status = message.syncStatus as SyncingSyncStatus; if (shouldUpdateSyncStatus) syncStatus = SyncingSyncStatus(status.blocksLeft, status.ptc); } else { if (shouldUpdateSyncStatus) syncStatus = message.syncStatus; } await walletInfo.updateRestoreHeight(message.height); } }); } void _updateSilentAddressRecord(BitcoinSilentPaymentsUnspent unspent) { final silentAddress = walletAddresses.silentAddress!; final silentPaymentAddress = SilentPaymentAddress( version: silentAddress.version, B_scan: silentAddress.B_scan, B_spend: unspent.silentPaymentLabel != null ? silentAddress.B_spend.tweakAdd( BigintUtils.fromBytes(BytesUtils.fromHexString(unspent.silentPaymentLabel!)), ) : silentAddress.B_spend, ); final addressRecord = walletAddresses.silentAddresses .firstWhereOrNull((address) => address.address == silentPaymentAddress.toString()); addressRecord?.txCount += 1; addressRecord?.balance += unspent.value; walletAddresses.addSilentAddresses( [unspent.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord], ); } DateTime? _lastSilentPaymentsScan; static const Duration _silentPaymentsScanDelay = Duration(minutes: 1); Iterable get unspentCoinsForCurrentAccount { if (type != WalletType.bitcoin) { return unspentCoins; } return unspentCoins .where((coin) => coin.bitcoinAddressRecord.accountIndex == currentAccountIndex); } @action @override Future startSync() async { if (_isSyncing) { final startedAt = _syncStartedAt; if (startedAt != null && DateTime.now().difference(startedAt) > maxSyncDuration) { printV('startSync: previous sync appears stuck, allowing a new one'); _isSyncing = false; } else { printV('startSync: already syncing, skipping'); return; } } _isSyncing = true; _syncStartedAt = DateTime.now(); try { if (_syncBenchmarkStartTime == null) { _syncBenchmarkStartTime = DateTime.now(); printV('[ELECTRUM_WALLET SYNC] Starting: ${_syncBenchmarkStartTime!}'); } syncStatus = SyncronizingSyncStatus(); if (hasSilentPaymentsScanning) { silentPaymentsScanningActive = alwaysScan ?? false; await _setInitialHeight(); final now = DateTime.now(); final shouldForceRescan = _lastSilentPaymentsScan == null || now.difference(_lastSilentPaymentsScan!) >= _silentPaymentsScanDelay; // Timer prevents server failure and this infinite looping and requesting if (shouldForceRescan) { _lastSilentPaymentsScan = now; final rescanHeights = []; transactionHistory.transactions.values.forEach((tx) { if (tx.unspents != null && tx.unspents!.isNotEmpty) for (final unspent in tx.unspents!) { if (unspent.silentPaymentTweak != null && tx.height != null && tx.height! > 0) { rescanHeights.add(tx.height!); break; } } }); if (rescanHeights.isNotEmpty) _setListeners(walletInfo.restoreHeight, rescanHeights: rescanHeights); } } await subscribeForUpdates(); await checkIfBatchSupported(); await updateTransactions(); await updateAllUnspents(); await updateBalance(); if (alwaysScan == true) { setSilentPaymentsScanning(true); } else if (syncStatus is LostConnectionSyncStatus) { _syncBenchmarkStartTime = null; } else { final syncEnd = DateTime.now(); final totalMs = _syncBenchmarkStartTime != null ? syncEnd.difference(_syncBenchmarkStartTime!).inMilliseconds : 0; printV('[ELECTRUM_WALLET SYNC] Finished: $syncEnd, took ${totalMs} ms'); _syncBenchmarkStartTime = null; syncStatus = SyncedSyncStatus(); } await updateFeeRates(); _updateFeeRateTimer ??= Timer.periodic(const Duration(minutes: 1), (timer) async => await updateFeeRates()); unawaited(resolveQueuedTransactionDetails()); } catch (e, stacktrace) { final syncEnd = DateTime.now(); final totalMs = _syncBenchmarkStartTime != null ? syncEnd.difference(_syncBenchmarkStartTime!).inMilliseconds : 0; printV(stacktrace); printV("startSync $e"); printV('[ELECTRUM_WALLET SYNC] Finished: $syncEnd, took ${totalMs} ms'); _syncBenchmarkStartTime = null; syncStatus = FailedSyncStatus(); } finally { _isSyncing = false; } } static bool _isValidFeeRates(List feeRates) => feeRates.length == 3 && feeRates.every((rate) => rate > 0); @action Future updateFeeRates() async { if (await checkIfMempoolAPIIsEnabled() && type == WalletType.bitcoin) { try { final response = await ProxyWrapper() .get(clearnetUri: Uri.parse("https://mempool.space/api/v1/fees/recommended")) .timeout(Duration(seconds: 15)); final result = json.decode(response.body) as Map; final slowFee = (result['economyFee'] as num?)?.toInt() ?? 0; int mediumFee = (result['hourFee'] as num?)?.toInt() ?? 0; int fastFee = (result['fastestFee'] as num?)?.toInt() ?? 0; if (slowFee == mediumFee) { mediumFee++; } while (fastFee <= mediumFee) { fastFee++; } _feeRates = [slowFee, mediumFee, fastFee]; return; } catch (e) { printV(e); } } final feeRates = await electrumClient.feeRates(network: network); if (_isValidFeeRates(feeRates)) { _feeRates = feeRates; } else if (isTestnet) { _feeRates = [1, 1, 1]; } } Node? node; Future getNodeIsElectrs() async { if (node == null) { return false; } final version = await electrumClient.version(); if (version.isNotEmpty) { final server = version[0]; if (server.toLowerCase().contains('electrs')) { node!.isElectrs = true; // TODO figure out why condition was needed // if (node!.isInBox) { node!.save(); // } return node!.isElectrs!; } } node!.isElectrs = false; return node!.isElectrs!; } Future getNodeSupportsSilentPayments() async { // As of today (august 2024), only ElectrumRS supports silent payments if (!(await getNodeIsElectrs())) { return false; } if (node == null) { return false; } try { final tweaksResponse = await electrumClient.getTweaks(height: 0); if (tweaksResponse != null) { node!.supportsSilentPayments = true; node!.save(); return node!.supportsSilentPayments!; } } on electrum.RequestFailedTimeoutException catch (_) { node!.supportsSilentPayments = false; node!.save(); return node!.supportsSilentPayments!; } catch (_) {} node!.supportsSilentPayments = false; node!.save(); return node!.supportsSilentPayments!; } @action @override Future connectToNode({required Node node}) async { this.node = node; _isBatchSupported = null; if (syncStatus is ConnectingSyncStatus) return; try { syncStatus = ConnectingSyncStatus(); await _receiveStream?.cancel(); await electrumClient.close(); _isBatchSupported = null; electrumClient.onConnectionStatusChange = _onConnectionStatusChange; await electrumClient.connectToUri(node.uri, useSSL: node.useSSL); } catch (e, stacktrace) { printV(stacktrace); printV("connectToNode $e"); syncStatus = FailedSyncStatus(); } } BigInt get networkDustAmount => BigInt.from(546); bool _isBelowDust(BigInt amount) => amount <= networkDustAmount && network != BitcoinNetwork.testnet; // Random draw priority per outpoint. Assigned lazily with a secure RNG and kept // until the next createTransaction call, so the recursive estimate passes of one // transaction build all see the same input order (reshuffling between passes made // fee estimation unstable). Cleared per transaction so each send is a fresh draw. final Random _coinSelectionRng = Random.secure(); final Map _coinSelectionOrder = {}; int _coinSelectionPriority(BitcoinUnspent utx) => _coinSelectionOrder.putIfAbsent( '${utx.hash}:${utx.vout}', () => _coinSelectionRng.nextInt(1 << 32)); UtxoDetails _createUTXOS({ required bool sendAll, required bool paysToSilentPayment, int credentialsAmount = 0, int? inputsCount, int feeRate = 0, int? outputsVBytes, UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, }) { List utxos = []; List vinOutpoints = []; List inputPrivKeyInfos = []; final publicKeys = {}; int allInputsAmount = 0; bool spendsSilentPayment = false; bool spendsUnconfirmedTX = false; int leftAmount = credentialsAmount; var availableInputs = unspentCoinsForCurrentAccount.where((utx) { if (!utx.isSending || utx.isFrozen) { return false; } switch (coinTypeToSpendFrom) { case UnspentCoinType.mweb: return utx.bitcoinAddressRecord.type == SegwitAddresType.mweb; case UnspentCoinType.nonMweb: return utx.bitcoinAddressRecord.type != SegwitAddresType.mweb; case UnspentCoinType.any: case UnspentCoinType.lightning: return true; } }).toList(); final unconfirmedCoins = availableInputs.where((utx) => utx.confirmations == 0).toList(); // Single Random Draw: order the pool by each coin's random priority so selection is // non-deterministic, removing the predictable address/scan order (a fingerprint). // The priority is stable across the repeated calls of one transaction build. // MWEB coins are kept last afterwards. availableInputs.sort((a, b) { final byPriority = _coinSelectionPriority(a).compareTo(_coinSelectionPriority(b)); if (byPriority != 0) return byPriority; return '${a.hash}:${a.vout}'.compareTo('${b.hash}:${b.vout}'); }); availableInputs = [ ...availableInputs.where((u) => u.bitcoinAddressRecord.type != SegwitAddresType.mweb), ...availableInputs.where((u) => u.bitcoinAddressRecord.type == SegwitAddresType.mweb), ]; // Branch-and-bound: prefer an input set whose excess over the amount plus its own fee // stays below dust, so the caller drops the change output and the send is changeless. // When no such set exists, the shuffled pool below acts as a single random draw. final canTryChangeless = !sendAll && inputsCount == null && credentialsAmount > 0 && feeRate > 0 && outputsVBytes != null && outputsVBytes > 0 && !availableInputs.any((u) => u.bitcoinAddressRecord.type == SegwitAddresType.mweb); if (canTryChangeless) { final match = changelessMatch( values: [for (final u in availableInputs) u.value], // estimatedTransactionSize(0, 0) is the fixed tx overhead (version, // counters, locktime); the outputs' own vbytes come pre-computed per type. target: credentialsAmount + (estimatedTransactionSize(0, 0) + outputsVBytes!) * feeRate, inputCosts: [ for (final u in availableInputs) estimatedInputSize(u.bitcoinAddressRecord.type) * feeRate ], window: networkDustAmount.toInt(), ); if (match != null) { final chosen = match.indices.toSet(); availableInputs = [ for (final i in match.indices) availableInputs[i], for (var i = 0; i < availableInputs.length; i++) if (!chosen.contains(i)) availableInputs[i], ]; inputsCount = match.indices.length; } } for (int i = 0; i < availableInputs.length; i++) { final utx = availableInputs[i]; if (!spendsUnconfirmedTX) spendsUnconfirmedTX = utx.confirmations == 0; if (paysToSilentPayment) { // Check inputs for shared secret derivation if (utx.bitcoinAddressRecord.type == SegwitAddresType.p2wsh) { throw BitcoinTransactionSilentPaymentsNotSupported(); } } allInputsAmount += utx.value; leftAmount = leftAmount - utx.value; final address = RegexUtils.addressTypeFromStr(utx.address, network); ECPrivate? privkey; bool? isSilentPayment = false; final hd = _hdForAddressRecord(record: utx.bitcoinAddressRecord); if (utx.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) { final unspentAddress = utx.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord; privkey = ECPrivate.fromHex( _masterHD!.derivePath(unspentAddress.spendDerivationPath).privateKey.toHex()) .tweakAdd( BigintUtils.fromBytes(BytesUtils.fromHexString(unspentAddress.silentPaymentTweak!)), ); spendsSilentPayment = true; isSilentPayment = true; } else if (!isHardwareWallet && keys.privateKey.isNotEmpty) { privkey = generateECPrivate(hd: hd, index: utx.bitcoinAddressRecord.index, network: network); } vinOutpoints.add(Outpoint(txid: utx.hash, index: utx.vout)); String pubKeyHex; if (privkey != null) { inputPrivKeyInfos.add(ECPrivateInfo( privkey, address.type == SegwitAddresType.p2tr, tweak: !isSilentPayment, )); pubKeyHex = privkey.getPublic().toHex(); } else { pubKeyHex = hd.childKey(Bip32KeyIndex(utx.bitcoinAddressRecord.index)).publicKey.toHex(); } final baseDerivationPath = _accountDerivationPathForRecord(utx.bitcoinAddressRecord); final derivationPath = "${_hardenedDerivationPath(baseDerivationPath)}" "/${utx.bitcoinAddressRecord.isHidden ? "1" : "0"}" "/${utx.bitcoinAddressRecord.index}"; publicKeys[address.pubKeyHash()] = PublicKeyWithDerivationPath(pubKeyHex, derivationPath); utxos.add( UtxoWithAddress( utxo: BitcoinUtxo( txHash: utx.hash, value: BigInt.from(utx.value), vout: utx.vout, scriptType: _getScriptType(address), isSilentPayment: isSilentPayment, ), ownerDetails: UtxoAddressDetails( publicKey: pubKeyHex, address: address, ), ), ); // sendAll continues for all inputs if (!sendAll) { bool amountIsAcquired = leftAmount <= 0; if ((inputsCount == null && amountIsAcquired) || inputsCount == i + 1) { break; } } } if (utxos.isEmpty) { throw BitcoinTransactionNoInputsException(); } return UtxoDetails( availableInputs: availableInputs, unconfirmedCoins: unconfirmedCoins, utxos: utxos, vinOutpoints: vinOutpoints, inputPrivKeyInfos: inputPrivKeyInfos, publicKeys: publicKeys, allInputsAmount: allInputsAmount, spendsSilentPayment: spendsSilentPayment, spendsUnconfirmedTX: spendsUnconfirmedTX, ); } Future estimateSendAllTx( List outputs, int feeRate, { String? memo, bool hasSilentPayment = false, UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, }) async { final utxoDetails = _createUTXOS( sendAll: true, paysToSilentPayment: hasSilentPayment, coinTypeToSpendFrom: coinTypeToSpendFrom, ); int fee = await calcFee( utxos: utxoDetails.utxos, outputs: outputs, network: network, memo: memo, feeRate: feeRate, inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, vinOutpoints: utxoDetails.vinOutpoints, ); if (fee <= 0) { throw BitcoinTransactionNoFeeException(); } // 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 final amount = BigInt.from(utxoDetails.allInputsAmount - fee); if (amount <= BigInt.zero) { throw BitcoinTransactionWrongBalanceException(amount: utxoDetails.allInputsAmount + fee); } // Attempting to send less than the dust limit if (_isBelowDust(amount)) { throw BitcoinTransactionNoDustException(); } if (outputs.length == 1) { outputs[0] = BitcoinOutput( address: outputs.last.address, value: amount, isSilentPayment: hasSilentPayment, ); } return EstimatedTxResult( utxos: utxoDetails.utxos, inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, publicKeys: utxoDetails.publicKeys, fee: Money.fromInt(fee, currency), amount: Money(amount, currency), isSendAll: true, hasChange: false, memo: memo, spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, spendsSilentPayment: utxoDetails.spendsSilentPayment, ); } Future estimateTxForAmount( Money credentialsAmount, List outputs, List updatedOutputs, int feeRate, { int? inputsCount, String? memo, bool? useUnconfirmed, bool hasSilentPayment = false, UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, }) async { // Attempting to send less than the dust limit if (_isBelowDust(credentialsAmount.amount)) { throw BitcoinTransactionNoDustException(); } // if mweb isn't enabled, don't consider spending mweb coins: if (this is LitecoinWallet) { var mwebEnabled = (this as LitecoinWallet).mwebEnabled; if (!mwebEnabled) { coinTypeToSpendFrom = UnspentCoinType.nonMweb; } } // If there is only one output, and the amount to send is more than the max spendable amount // then it is actually a send all transaction if (outputs.length == 1) { final maxSpendable = await _maxSpendableNoChangeAmount( initialOutput: outputs.first, feeRate: feeRate, memo: memo, hasSilentPayment: hasSilentPayment, coinTypeToSpendFrom: coinTypeToSpendFrom, ); if (credentialsAmount > maxSpendable) { throw BitcoinTransactionWrongBalanceException(); } if (credentialsAmount >= maxSpendable) { final estimateOutput = [ BitcoinOutput( address: outputs.first.address, value: BigInt.zero, isSilentPayment: outputs.first.isSilentPayment, ) ]; return estimateSendAllTx( estimateOutput, feeRate, memo: memo, hasSilentPayment: hasSilentPayment, coinTypeToSpendFrom: coinTypeToSpendFrom, ); } } // Per-type output sizes for the changeless target. MWEB outputs follow a // different size model entirely, so they disable the changeless path (null). int? outputsVBytes = 0; for (final out in outputs) { final type = out.isSilentPayment == true ? SilentPaymentsAddresType.p2sp : _getScriptType(out.address); if (type == SegwitAddresType.mweb) { outputsVBytes = null; break; } outputsVBytes = outputsVBytes! + estimatedOutputSize(type); } final utxoDetails = _createUTXOS( sendAll: false, credentialsAmount: credentialsAmount.amount.toInt(), inputsCount: inputsCount, feeRate: feeRate, outputsVBytes: outputsVBytes, paysToSilentPayment: hasSilentPayment, coinTypeToSpendFrom: coinTypeToSpendFrom, ); final spendingAllCoins = utxoDetails.availableInputs.length == utxoDetails.utxos.length; final spendingAllConfirmedCoins = !utxoDetails.spendsUnconfirmedTX && utxoDetails.utxos.length == utxoDetails.availableInputs.length - utxoDetails.unconfirmedCoins.length; final amountLeftForChangeAndFee = utxoDetails.allInputsAmount - credentialsAmount.amount.toInt(); if (amountLeftForChangeAndFee <= 0) { if (!spendingAllCoins) { return estimateTxForAmount( credentialsAmount, outputs, updatedOutputs, feeRate, inputsCount: utxoDetails.utxos.length + 1, memo: memo, hasSilentPayment: hasSilentPayment, coinTypeToSpendFrom: coinTypeToSpendFrom, ); } throw BitcoinTransactionWrongBalanceException(); } final changeAddress = await walletAddresses.getChangeAddress( inputs: utxoDetails.availableInputs, outputs: updatedOutputs, coinTypeToSpendFrom: coinTypeToSpendFrom, ); final address = RegexUtils.addressTypeFromStr(changeAddress.address, network); updatedOutputs.add(BitcoinOutput( address: address, value: BigInt.from(amountLeftForChangeAndFee), isChange: true, )); outputs.add(BitcoinOutput( address: address, value: BigInt.from(amountLeftForChangeAndFee), isChange: true, )); // Must match the address' account root (purpose/coinType) and legacy derivation when applicable. final changeBaseDerivationPath = _accountDerivationPathForRecord(changeAddress); final changeDerivationPath = "${_hardenedDerivationPath(changeBaseDerivationPath)}" "/${changeAddress.isHidden ? "1" : "0"}" "/${changeAddress.index}"; final changeHd = _hdForAddressRecord(record: changeAddress).childKey(Bip32KeyIndex(changeAddress.index)); final changePubKeyHex = changeHd.publicKey.toHex(); utxoDetails.publicKeys[address.pubKeyHash()] = PublicKeyWithDerivationPath(changePubKeyHex, changeDerivationPath); // calcFee updates the silent payment outputs to calculate the tx size accounting // for taproot addresses, but if more inputs are needed to make up for fees, // the silent payment outputs need to be recalculated for the new inputs var temp = outputs.map((output) => output).toList(); int fee = await calcFee( utxos: utxoDetails.utxos, // Always take only not updated bitcoin outputs here so for every estimation // the SP outputs are re-generated to the proper taproot addresses outputs: temp, network: network, memo: memo, feeRate: feeRate, inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, vinOutpoints: utxoDetails.vinOutpoints, ); updatedOutputs.clear(); for (int i = 0; i < temp.length; i++) { final output = temp[i]; final oldOutput = outputs[i]; updatedOutputs.add(BitcoinOutput( address: output.address, value: output.value, isSilentPayment: oldOutput.isSilentPayment, isChange: output.isChange, )); } if (fee <= 0) { throw BitcoinTransactionNoFeeException(); } var amount = credentialsAmount; final lastOutput = updatedOutputs.last; final amountLeftForChange = BigInt.from(amountLeftForChangeAndFee - fee); if (_isBelowDust(amountLeftForChange)) { // If has change that is lower than dust, will end up with tx rejected by network rules // so remove the change amount updatedOutputs.removeLast(); outputs.removeLast(); // If the computed change is negative or below dust: // - negative: try a no-change tx (recalculate fee without change) // - non-negative but dust: drop change and add remainder to fee if (amountLeftForChange < BigInt.zero) { final tempNoChange = outputs.map((o) => o).toList(); final feeNoChange = await calcFee( utxos: utxoDetails.utxos, outputs: tempNoChange, network: network, memo: memo, feeRate: feeRate, inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, vinOutpoints: utxoDetails.vinOutpoints, ); final leftover = utxoDetails.allInputsAmount - credentialsAmount.amount.toInt() - feeNoChange; if (leftover >= 0) { final finalFee = feeNoChange + leftover; // absorb tiny remainder return EstimatedTxResult( utxos: utxoDetails.utxos, inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, publicKeys: utxoDetails.publicKeys, fee: Money.fromInt(finalFee, currency), amount: amount, hasChange: false, isSendAll: spendingAllCoins, memo: memo, spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, spendsSilentPayment: utxoDetails.spendsSilentPayment, ); } if (!spendingAllCoins) { return estimateTxForAmount( credentialsAmount, outputs, updatedOutputs, feeRate, inputsCount: utxoDetails.utxos.length + 1, memo: memo, useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins, hasSilentPayment: hasSilentPayment, coinTypeToSpendFrom: coinTypeToSpendFrom, ); } else { throw BitcoinTransactionWrongBalanceException(); } } // if the amount left for change is less than dust, but not less than 0 // then add it to the fees fee += amountLeftForChange.toInt(); return EstimatedTxResult( utxos: utxoDetails.utxos, inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, publicKeys: utxoDetails.publicKeys, fee: Money.fromInt(fee, currency), amount: amount, hasChange: false, isSendAll: spendingAllCoins, memo: memo, spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, spendsSilentPayment: utxoDetails.spendsSilentPayment, ); } else { // Here, lastOutput already is change, return the amount left without the fee to the user's address. updatedOutputs[updatedOutputs.length - 1] = BitcoinOutput( address: lastOutput.address, value: amountLeftForChange, isSilentPayment: lastOutput.isSilentPayment, isChange: true, ); outputs[outputs.length - 1] = BitcoinOutput( address: lastOutput.address, value: amountLeftForChange, isSilentPayment: lastOutput.isSilentPayment, isChange: true, ); return EstimatedTxResult( utxos: utxoDetails.utxos, inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, publicKeys: utxoDetails.publicKeys, fee: Money.fromInt(fee, currency), amount: amount, hasChange: true, isSendAll: spendingAllCoins, memo: memo, spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, spendsSilentPayment: utxoDetails.spendsSilentPayment, ); } } Future _maxSpendableNoChangeAmount({ required BitcoinOutput initialOutput, required int feeRate, String? memo, bool hasSilentPayment = false, UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, }) async { final utxoDetailsAll = _createUTXOS( sendAll: true, paysToSilentPayment: hasSilentPayment, coinTypeToSpendFrom: coinTypeToSpendFrom, ); final output = [ BitcoinOutput( address: initialOutput.address, value: BigInt.zero, isSilentPayment: initialOutput.isSilentPayment, ) ]; final feeNoChange = await calcFee( utxos: utxoDetailsAll.utxos, outputs: output, network: network, memo: memo, feeRate: feeRate, inputPrivKeyInfos: utxoDetailsAll.inputPrivKeyInfos, vinOutpoints: utxoDetailsAll.vinOutpoints, ); final maxSpendable = utxoDetailsAll.allInputsAmount - feeNoChange; return Money.fromInt(maxSpendable > 0 ? maxSpendable : 0, currency); } Future calcFee({ required List utxos, required List outputs, required BasedUtxoNetwork network, String? memo, required int feeRate, List? inputPrivKeyInfos, List? vinOutpoints, }) async { int estimatedSize; if (network is BitcoinCashNetwork) { estimatedSize = ForkedTransactionBuilder.estimateTransactionSize( utxos: utxos, outputs: outputs, network: network, memo: memo, ); } else { estimatedSize = BitcoinTransactionBuilder.estimateTransactionSize( utxos: utxos, outputs: outputs, network: network, memo: memo, inputPrivKeyInfos: inputPrivKeyInfos, vinOutpoints: vinOutpoints, ); } return feeAmountWithFeeRate(feeRate, 0, 0, size: estimatedSize); } @override Future createTransaction(Object credentials) async { try { // New transaction, new random draw: drop the previous input ordering so this // build gets fresh priorities, then keep them fixed for all estimate passes. _coinSelectionOrder.clear(); // start by updating unspent coins await updateAllUnspents(); final outputs = []; final transactionCredentials = credentials as BitcoinTransactionCredentials; final hasMultiDestination = transactionCredentials.outputs.length > 1; final sendAll = !hasMultiDestination && transactionCredentials.outputs.first.sendAll; final memo = transactionCredentials.outputs.first.memo; final coinTypeToSpendFrom = transactionCredentials.coinTypeToSpendFrom; var credentialsAmount = Money.zero(currency); var hasSilentPayment = false; for (final out in transactionCredentials.outputs) { final outputAmount = out.cryptoAmount; if (!sendAll && _isBelowDust(outputAmount.amount)) { throw BitcoinTransactionNoDustException(); } if (hasMultiDestination) { if (out.sendAll) { throw BitcoinTransactionWrongBalanceException(); } } credentialsAmount += outputAmount; final address = RegexUtils.addressTypeFromStr( out.isParsedAddress ? out.extractedAddress! : out.address, network); final isSilentPayment = address is SilentPaymentAddress; if (isSilentPayment) { hasSilentPayment = true; } if (sendAll) { // The value will be changed after estimating the Tx size and deducting the fee from the total to be sent outputs.add(BitcoinOutput( address: address, value: BigInt.zero, isSilentPayment: isSilentPayment, )); } else { outputs.add(BitcoinOutput( address: address, value: outputAmount.amount, isSilentPayment: isSilentPayment, )); } } final feeRateInt = transactionCredentials.feeRate != null ? transactionCredentials.feeRate! : feeRate(transactionCredentials.priority!); EstimatedTxResult estimatedTx; final updatedOutputs = outputs .map((e) => BitcoinOutput( address: e.address, value: e.value, isSilentPayment: e.isSilentPayment, isChange: e.isChange, )) .toList(); if (sendAll) { estimatedTx = await estimateSendAllTx( updatedOutputs, feeRateInt, memo: memo, hasSilentPayment: hasSilentPayment, coinTypeToSpendFrom: coinTypeToSpendFrom, ); } else { estimatedTx = await estimateTxForAmount( credentialsAmount, outputs, updatedOutputs, feeRateInt, memo: memo, hasSilentPayment: hasSilentPayment, coinTypeToSpendFrom: coinTypeToSpendFrom, ); } for (final output in updatedOutputs) { // TODO: get from server // if (output.isSilentPayment && output.value.toInt() > silentPaymentsMin) { if (output.isSilentPayment && output.value.toInt() <= 1000) { throw BitcoinTransactionNoDustException(); } } if (walletInfo.isHardwareWallet) { final transaction = await buildHardwareWalletTransaction( utxos: estimatedTx.utxos, outputs: updatedOutputs, publicKeys: estimatedTx.publicKeys, fee: estimatedTx.fee.amount, network: network, memo: estimatedTx.memo, // Shuffle so the change output isn't placed deterministically last // (privacy fingerprint). Applied by orderOutputs in the builder. outputOrdering: BitcoinOrdering.shuffle, enableRBF: true, cwOutputs: transactionCredentials.outputs, ); return PendingBitcoinTransaction( transaction, type, accountIndex: currentAccountIndex, electrumClient: electrumClient, amount: estimatedTx.amount, fee: estimatedTx.fee, feeRate: feeRateInt.toString(), network: network, hasChange: estimatedTx.hasChange, isSendAll: estimatedTx.isSendAll, hasTaprootInputs: false, // ToDo: (Konsti) Support Taproot, isViewOnly: false, )..addListener((transaction) async { transactionHistory.addOne(transaction); await updateAllUnspents(); await updateBalance(); }); } final locktime = await _antiFeeSnipingLocktime(); BasedBitcoinTransacationBuilder txb; if (network is BitcoinCashNetwork) { txb = ForkedTransactionBuilder( utxos: estimatedTx.utxos, outputs: updatedOutputs, fee: estimatedTx.fee.amount, network: network, memo: estimatedTx.memo, // Shuffle so the change output isn't placed deterministically last // (privacy fingerprint). Change is found by isChange, not position. inputOrdering: BitcoinOrdering.shuffle, outputOrdering: BitcoinOrdering.shuffle, enableRBF: !estimatedTx.spendsUnconfirmedTX, ); } else { txb = BitcoinTransactionBuilder( utxos: estimatedTx.utxos, outputs: updatedOutputs, fee: estimatedTx.fee.amount, network: network, memo: estimatedTx.memo, inputOrdering: BitcoinOrdering.shuffle, outputOrdering: BitcoinOrdering.shuffle, enableRBF: !estimatedTx.spendsUnconfirmedTX, locktime: locktime, ); } bool hasTaprootInputs = false; final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) { if (keys.privateKey.isEmpty) return ""; String error = "Cannot find private key."; ECPrivateInfo? key; if (estimatedTx.inputPrivKeyInfos.isEmpty) { error += "\nNo private keys generated."; } else { error += "\nAddress: ${utxo.ownerDetails.address.toAddress(network)}"; key = estimatedTx.inputPrivKeyInfos.firstWhereOrNull((element) { final elemPubkey = element.privkey.getPublic().toHex(); if (elemPubkey == publicKey) { return true; } else { error += "\nExpected: $publicKey"; error += "\nPubkey: $elemPubkey"; return false; } }); } if (key == null) { throw Exception(error); } if (utxo.utxo.isP2tr()) { hasTaprootInputs = true; return key.privkey.signTapRoot( txDigest, sighash: sighash, tweak: utxo.utxo.isSilentPayment != true, ); } else { return key.privkey.signInput(txDigest, sigHash: sighash); } }); return PendingBitcoinTransaction( transaction, type, accountIndex: currentAccountIndex, electrumClient: electrumClient, amount: estimatedTx.amount, fee: estimatedTx.fee, feeRate: feeRateInt.toString(), network: network, hasChange: estimatedTx.hasChange, isSendAll: estimatedTx.isSendAll, hasTaprootInputs: hasTaprootInputs, utxos: estimatedTx.utxos, derivedOutputs: updatedOutputs, publicKeys: estimatedTx.publicKeys, isViewOnly: keys.privateKey.isEmpty, )..addListener((transaction) async { transactionHistory.addOne(transaction); if (estimatedTx.spendsSilentPayment) { transactionHistory.transactions.values.forEach((tx) { tx.unspents?.removeWhere( (unspent) => estimatedTx.utxos.any((e) => e.utxo.txHash == unspent.hash)); transactionHistory.addOne(tx); }); } unspentCoins .removeWhere((utxo) => estimatedTx.utxos.any((e) => e.utxo.txHash == utxo.hash)); await updateAllUnspents(); await updateBalance(); }); } catch (e) { throw e; } } HardwareWalletService? hardwareWalletService; Future buildHardwareWalletTransaction({ required List outputs, required BigInt fee, required BasedUtxoNetwork network, required List utxos, required List cwOutputs, required Map publicKeys, String? memo, bool enableRBF = false, BitcoinOrdering inputOrdering = BitcoinOrdering.bip69, BitcoinOrdering outputOrdering = BitcoinOrdering.bip69, }) async => throw UnimplementedError(); String toJSON() => json.encode({ 'mnemonic': _mnemonic, 'xpub': xpub, 'passphrase': passphrase ?? '', 'account_index': walletAddresses.currentReceiveAddressIndexByType, 'change_address_index': walletAddresses.currentChangeAddressIndexByType, 'addresses': walletAddresses.allAddresses.map((addr) => addr.toJSON()).toList(), 'address_page_type': walletInfo.addressPageType == null ? SegwitAddresType.p2wpkh.toString() : walletInfo.addressPageType.toString(), 'balance': balance[currency]?.toJSON(), 'lightningBalance': balance[CryptoCurrency.btcln]?.toJSON(), 'derivationTypeIndex': derivationInfo.derivationType?.index, 'derivationPath': derivationInfo.derivationPath, 'silent_addresses': walletAddresses.silentAddresses.map((addr) => addr.toJSON()).toList(), 'silent_address_index': walletAddresses.currentSilentAddressIndex.toString(), 'mweb_addresses': walletAddresses.mwebAddresses.map((addr) => addr.toJSON()).toList(), 'alwaysScan': alwaysScan, 'useLightning': useLightning, 'cachedLightningAddress': walletAddresses.lightningAddress }); int feeRate(TransactionPriority priority) { try { if (priority is BitcoinTransactionPriority) { return _feeRates[priority.raw]; } return 0; } catch (_) { return 0; } } int feeAmountForPriority(TransactionPriority priority, int inputsCount, int outputsCount, {int? size}) => feeRate(priority) * (size ?? estimatedTransactionSize(inputsCount, outputsCount)); int feeAmountWithFeeRate(int feeRate, int inputsCount, int outputsCount, {int? size}) => feeRate * (size ?? estimatedTransactionSize(inputsCount, outputsCount)); @override int calculateEstimatedFee(TransactionPriority? priority, int? amount, {int? outputsCount, int? size}) { if (priority is BitcoinTransactionPriority) { return calculateEstimatedFeeWithFeeRate(feeRate(priority), amount, outputsCount: outputsCount, size: size); } return 0; } int calculateEstimatedFeeWithFeeRate(int feeRate, int? amount, {int? outputsCount, int? size}) { if (size != null) { return feeAmountWithFeeRate(feeRate, 0, 0, size: size); } int inputsCount = 0; if (amount != null) { int totalValue = 0; for (final input in unspentCoinsForCurrentAccount) { if (totalValue >= amount) { break; } if (input.isSending) { totalValue += input.value; inputsCount += 1; } } if (totalValue < amount) return 0; } else { for (final input in unspentCoinsForCurrentAccount) { if (input.isSending) { inputsCount += 1; } } } // If send all, then we have no change value final _outputsCount = outputsCount ?? (amount != null ? 2 : 1); return feeAmountWithFeeRate(feeRate, inputsCount, _outputsCount); } @override Future save() async { if (!(await WalletKeysFile.hasKeysFile(walletInfo.name, walletInfo.type))) { await saveKeysFile(_password, encryptionFileUtils); saveKeysFile(_password, encryptionFileUtils, true); } final path = await makePath(); await encryptionFileUtils.write(path: path, password: _password, data: toJSON()); await transactionHistory.save(); } @override Future changePassword(String password) async { _password = password; await save(); await transactionHistory.changePassword(password); } @action @override Future rescan({required int height, bool? doSingleScan}) async { if (keys.privateKey.isEmpty) return; silentPaymentsScanningActive = true; _setListeners(height, doSingleScan: doSingleScan); } @override Future close({bool shouldCleanup = false}) async { _missingHistoryQueue.clear(); _unresolvedAccountRetryCount.clear(); try { await _receiveStream?.cancel(); await electrumClient.close(); _isBatchSupported = null; } catch (_) {} _autoSaveTimer?.cancel(); _updateFeeRateTimer?.cancel(); } @action Future updateAllUnspents() async { List updatedUnspentCoins = []; final previousUnspentCoins = List.from(unspentCoins.where((utxo) => utxo.bitcoinAddressRecord.type != SegwitAddresType.mweb && utxo.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord)); if (hasSilentPaymentsScanning) { // Update unspents stored from scanned silent payment transactions transactionHistory.transactions.values.forEach((tx) { if (tx.unspents != null) { updatedUnspentCoins.addAll(tx.unspents!); } }); } // Set the balance of all non-silent payment and non-mweb addresses to 0 before updating final targetAddresses = walletAddresses.allAddresses .where((element) => element.type != SegwitAddresType.mweb) .toList(); for (final addr in targetAddresses) { if (addr is! BitcoinSilentPaymentAddressRecord) { addr.balance = 0; _appliedCoinKeysByAddress[addr.address]?.clear(); } } final results = shouldUseBatchFetching ? await _fetchUnspentsBatch(targetAddresses) : await _fetchUnspentsRegular(targetAddresses); final failedCount = results.where((result) => result == null).length; if (failedCount == 0) { for (final result in results) { updatedUnspentCoins.addAll(result!); } unspentCoins = updatedUnspentCoins; _hasCompleteUnspentSet = true; } else { if (updatedUnspentCoins.isEmpty) { unspentCoins = handleFailedUtxoFetch( failedCount: failedCount, previousUnspentCoins: previousUnspentCoins, updatedUnspentCoins: updatedUnspentCoins, results: results, ); } else { unspentCoins = updatedUnspentCoins; } } final currentWalletUnspentCoins = unspentCoinsInfo.values.where((element) => element.walletId == id); if (currentWalletUnspentCoins.length != updatedUnspentCoins.length) { unspentCoins.forEach((coin) => addCoinInfo(coin)); } await updateCoins(unspentCoins); _updateAccountBalancesFromUnspents(); await _refreshUnspentCoinsInfo(); } Future?>> _fetchUnspentsRegular( List addresses, ) async { final addressFutures = addresses.map((address) => fetchUnspent(address)).toList(); return Future.wait(addressFutures); } Future?>> _fetchUnspentsBatch( List addresses, ) async { final byScriptHash = { for (final address in addresses) address.getScriptHash(network): address, }; final scriptHashes = byScriptHash.keys.toList(); try { final failedScriptHashes = {}; final unspentByScriptHash = await _processChunksToMap>>( items: scriptHashes, chunkSize: addressHistoryChunkSize, processChunk: _getListUnspentBatch, onChunkError: (chunk, _) => failedScriptHashes.addAll(chunk), ); final txHashes = {}; final coinsByScriptHash = >{}; for (final entry in unspentByScriptHash.entries) { final addressRecord = byScriptHash[entry.key]; if (addressRecord == null) continue; final coins = []; for (final unspent in entry.value) { final coin = BitcoinUnspent.fromJSON(addressRecord, unspent); coin.isChange = addressRecord.isHidden; coins.add(coin); txHashes.add(coin.hash); } coinsByScriptHash[entry.key] = coins; } final txInfoByHash = await fetchTransactionInfoBatch( hashes: txHashes.toList(), retryOnFailure: true, retryDelay: const Duration(seconds: 1), ); for (final coins in coinsByScriptHash.values) { for (final coin in coins) { final tx = txInfoByHash[coin.hash]; coin.confirmations = tx?.confirmations; coin.isPegOut = tx?.isHogEx; } } return addresses.map((address) { final scriptHash = address.getScriptHash(network); if (failedScriptHashes.contains(scriptHash)) return null; if (!unspentByScriptHash.containsKey(scriptHash)) return null; return coinsByScriptHash[scriptHash] ?? []; }).toList(); } catch (e) { printV('fetchUnspentsBatch failed: $e'); return List?>.filled(addresses.length, null); } } List handleFailedUtxoFetch({ required int failedCount, required List previousUnspentCoins, required List updatedUnspentCoins, required List?> results, }) { if (failedCount == results.length) { printV("All UTXOs failed to fetch, falling back to previous UTXOs"); return previousUnspentCoins; } final successfulUtxos = []; for (final result in results) { if (result != null) { successfulUtxos.addAll(result); } } if (failedCount > 0 && successfulUtxos.isEmpty) { printV("Some UTXOs failed, but no successful UTXOs, falling back to previous UTXOs"); return previousUnspentCoins; } if (failedCount > 0) { printV("Some UTXOs failed, updating with successful UTXOs"); updatedUnspentCoins.addAll(successfulUtxos); } return updatedUnspentCoins; } Future updateCoins(List newUnspentCoins) async { if (newUnspentCoins.isEmpty) { return; } newUnspentCoins.forEach((coin) { final coinInfoList = unspentCoinsInfo.values.where( (element) => element.walletId.contains(id) && element.hash.contains(coin.hash) && element.vout == coin.vout, ); if (coinInfoList.isNotEmpty) { final coinInfo = coinInfoList.first; coin.isFrozen = coinInfo.isFrozen; coin.isSending = coinInfo.isSending; coin.note = coinInfo.note; if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord) { final addr = coin.bitcoinAddressRecord.address; final coinKey = '${coin.hash}:${coin.vout}'; final appliedKeys = _appliedCoinKeysByAddress.putIfAbsent(addr, () => {}); if (appliedKeys.add(coinKey)) { coin.bitcoinAddressRecord.balance += coinInfo.value; } } } else { addCoinInfo(coin); } }); } @action Future updateUnspentsForAddress(BitcoinAddressRecord address) async { final newUnspentCoins = await fetchUnspent(address); await updateCoins(newUnspentCoins ?? []); } @action Future?> fetchUnspent(BitcoinAddressRecord address) async { List updatedUnspentCoins = []; final unspents = await electrumClient.getListUnspent(address.getScriptHash(network)); // Failed to fetch unspents if (unspents == null) return null; await Future.wait(unspents.map((unspent) async { try { final coin = BitcoinUnspent.fromJSON(address, unspent); final tx = await fetchTransactionInfo(hash: coin.hash); coin.isChange = address.isHidden; coin.confirmations = tx?.confirmations; coin.isPegOut = tx?.isHogEx; updatedUnspentCoins.add(coin); } catch (_) {} })); return updatedUnspentCoins; } @action Future addCoinInfo(BitcoinUnspent coin) async { // Check if the coin is already in the unspentCoinsInfo for the wallet final existingCoinInfo = unspentCoinsInfo.values.firstWhereOrNull( (element) => element.walletId == walletInfo.id && element.hash == coin.hash && element.vout == coin.vout, ); if (existingCoinInfo == null) { final newInfo = UnspentCoinsInfo( walletId: id, hash: coin.hash, isFrozen: coin.isFrozen, isSending: coin.isSending, noteRaw: coin.note, address: coin.bitcoinAddressRecord.address, value: coin.value, vout: coin.vout, isChange: coin.isChange, isSilentPayment: coin is BitcoinSilentPaymentsUnspent, ); await unspentCoinsInfo.add(newInfo); } } Future _refreshUnspentCoinsInfo() async { try { final List keys = []; final currentWalletUnspentCoins = unspentCoinsInfo.values.where((record) => record.walletId == id); for (final element in currentWalletUnspentCoins) { if (element.isFrozen) continue; if (RegexUtils.addressTypeFromStr(element.address, network) is MwebAddress) continue; final existUnspentCoins = unspentCoins.where((coin) => element == coin); if (existUnspentCoins.isEmpty) { keys.add(element.key); } } if (keys.isNotEmpty) { await unspentCoinsInfo.deleteAll(keys); } } catch (e) { printV("refreshUnspentCoinsInfo $e"); } } Future cleanUpDuplicateUnspentCoins() async { final currentWalletUnspentCoins = unspentCoinsInfo.values.where((element) => element.walletId == id); final Map uniqueUnspentCoins = {}; final List duplicateKeys = []; for (final unspentCoin in currentWalletUnspentCoins) { final key = '${unspentCoin.hash}:${unspentCoin.vout}'; if (!uniqueUnspentCoins.containsKey(key)) { uniqueUnspentCoins[key] = unspentCoin; } else { duplicateKeys.add(unspentCoin.key); } } if (duplicateKeys.isNotEmpty) await unspentCoinsInfo.deleteAll(duplicateKeys); } void _seedAccountBalancesFromAddressRecords() { if (type != WalletType.bitcoin) return; if (accountBalances.isNotEmpty) return; final newBalances = {}; for (final addr in walletAddresses.allAddresses) { if (addr.type == SegwitAddresType.mweb) continue; final current = newBalances[addr.accountIndex] ?? ElectrumBalance.zero(currency); current.confirmed += Money.fromInt(addr.balance, currency); newBalances[addr.accountIndex] = current; } for (final accountIndex in walletAddresses.accountIndexes) { newBalances.putIfAbsent(accountIndex, () => ElectrumBalance.zero(currency)); } // balance[currency] came from the wallet file and is authoritative for the // selected account — prefer it if the address records look stale. final persisted = balance[currency]; final seeded = newBalances[currentAccountIndex]; if (persisted != null && seeded != null) { final seededIsZero = seeded.confirmed == Money.zero(currency) && seeded.unconfirmed == Money.zero(currency); final persistedIsNonZero = persisted.confirmed != Money.zero(currency) || persisted.unconfirmed != Money.zero(currency); if (seededIsZero && persistedIsNonZero) { newBalances[currentAccountIndex] = persisted; } } accountBalances = ObservableMap.of(newBalances); _updateCurrentAccountBalance(); } int transactionVSize(String transactionHex) => BtcTransaction.fromRaw(transactionHex).getVSize(); Future canReplaceByFee(ElectrumTransactionInfo tx) async { try { final bundle = await getTransactionExpanded(hash: tx.txHash); _updateInputsAndOutputs(tx, bundle); if (bundle.confirmations > 0) return null; return bundle.originalTransaction.canReplaceByFee ? bundle.originalTransaction.toHex() : null; } catch (e) { return null; } } Future isChangeSufficientForFee(String txId, int newFee) async { final bundle = await getTransactionExpanded(hash: txId); final outputs = bundle.originalTransaction.outputs; final ownAddresses = walletAddresses.allAddresses.map((addr) => addr.address).toSet(); final receiverAmount = outputs .where((output) => !ownAddresses.contains(addressFromOutputScript(output.scriptPubKey, network))) .fold(0, (sum, output) => sum + output.amount.toInt()); if (receiverAmount == 0) { throw Exception("Receiver output not found."); } final availableInputs = unspentCoinsForCurrentAccount.where((utxo) => utxo.isSending && !utxo.isFrozen).toList(); int totalBalance = availableInputs.fold( 0, (previousValue, element) => previousValue + element.value.toInt()); int allInputsAmount = 0; for (int i = 0; i < bundle.originalTransaction.inputs.length; i++) { final input = bundle.originalTransaction.inputs[i]; final inputTransaction = bundle.ins[i]; if (inputTransaction == null) { throw Exception("Missing input transaction for fee calculation"); } final vout = input.txIndex; final outTransaction = inputTransaction.outputs[vout]; allInputsAmount += outTransaction.amount.toInt(); } final totalOutAmount = bundle.originalTransaction.outputs .fold(0, (previousValue, element) => previousValue + element.amount.toInt()); var currentFee = allInputsAmount - totalOutAmount; final remainingFee = (newFee - currentFee > 0) ? newFee - currentFee : newFee; return totalBalance - receiverAmount - remainingFee >= networkDustAmount.toInt(); } Future replaceByFee(String hash, int newFee) async { try { final bundle = await getTransactionExpanded(hash: hash); final utxos = []; final outputs = []; List privateKeys = []; var allInputsAmount = 0; String? memo; // Add original inputs for (var i = 0; i < bundle.originalTransaction.inputs.length; i++) { final input = bundle.originalTransaction.inputs[i]; final inputTransaction = bundle.ins[i]; if (inputTransaction == null) { throw Exception("Missing input transaction for replace-by-fee"); } final vout = input.txIndex; final outTransaction = inputTransaction.outputs[vout]; final address = addressFromOutputScript(outTransaction.scriptPubKey, network); allInputsAmount += outTransaction.amount.toInt(); final addressRecord = walletAddresses.allAddresses.firstWhere((element) => element.address == address); final btcAddress = RegexUtils.addressTypeFromStr(addressRecord.address, network); final hd = _hdForAddressRecord(record: addressRecord); final privkey = generateECPrivate(hd: hd, index: addressRecord.index, network: network); privateKeys.add(privkey); utxos.add( UtxoWithAddress( utxo: BitcoinUtxo( txHash: input.txId, value: outTransaction.amount, vout: vout, scriptType: _getScriptType(btcAddress), ), ownerDetails: UtxoAddressDetails(publicKey: privkey.getPublic().toHex(), address: btcAddress), ), ); } // Add original outputs for (final out in bundle.originalTransaction.outputs) { final script = out.scriptPubKey.script; if (script.contains('OP_RETURN') && memo == null) { final index = script.indexOf('OP_RETURN'); if (index + 1 <= script.length) { try { final opReturnData = script[index + 1].toString(); memo = utf8.decode(HEX.decode(opReturnData)); continue; } catch (_) { throw Exception('Cannot decode OP_RETURN data'); } } } final address = addressFromOutputScript(out.scriptPubKey, network); final btcAddress = RegexUtils.addressTypeFromStr(address, network); outputs.add(BitcoinOutput(address: btcAddress, value: BigInt.from(out.amount.toInt()))); } // Calculate the total amount and fees int totalOutAmount = outputs.fold(0, (previousValue, output) => previousValue + output.value.toInt()); int currentFee = allInputsAmount - totalOutAmount; var remainingFee = BigInt.from(newFee - currentFee); if (remainingFee <= BigInt.zero) { throw Exception("New fee must be higher than the current fee."); } // Deduct fee from change outputs first, if possible if (remainingFee > BigInt.zero) { final changeAddresses = walletAddresses.allAddresses.where((element) => element.isHidden); for (int i = outputs.length - 1; i >= 0; i--) { final output = outputs[i]; final isChange = changeAddresses .any((element) => element.address == output.address.toAddress(network)); if (isChange) { final outputAmount = output.value; if (outputAmount > networkDustAmount) { final deduction = (outputAmount - networkDustAmount >= remainingFee) ? remainingFee : outputAmount - networkDustAmount; outputs[i] = BitcoinOutput(address: output.address, value: outputAmount - deduction); remainingFee -= deduction; if (remainingFee <= BigInt.zero) break; } } } } // If still not enough, add UTXOs until the fee is covered, drawing them at // random instead of in the predictable wallet scan order (address, then age). if (remainingFee > BigInt.zero) { final unusedUtxos = unspentCoinsForCurrentAccount .where((utxo) => utxo.isSending && !utxo.isFrozen && utxo.confirmations! > 0) .toList() ..shuffle(Random.secure()); for (final utxo in unusedUtxos) { final address = RegexUtils.addressTypeFromStr(utxo.address, network); final hd = _hdForAddressRecord(record: utxo.bitcoinAddressRecord); final privkey = generateECPrivate( hd: hd, index: utxo.bitcoinAddressRecord.index, network: network, ); privateKeys.add(privkey); utxos.add(UtxoWithAddress( utxo: BitcoinUtxo( txHash: utxo.hash, value: BigInt.from(utxo.value), vout: utxo.vout, scriptType: _getScriptType(address)), ownerDetails: UtxoAddressDetails(publicKey: privkey.getPublic().toHex(), address: address), )); allInputsAmount += utxo.value; remainingFee -= BigInt.from(utxo.value); if (remainingFee < BigInt.zero) { final changeOutput = outputs.firstWhereOrNull((output) => walletAddresses.allAddresses .any((addr) => addr.address == output.address.toAddress(network))); if (changeOutput != null) { final newValue = changeOutput.value + (-remainingFee); outputs[outputs.indexOf(changeOutput)] = BitcoinOutput(address: changeOutput.address, value: newValue); } else { final changeAddress = await walletAddresses.getChangeAddress(); outputs.add(BitcoinOutput( address: RegexUtils.addressTypeFromStr(changeAddress.address, network), value: -remainingFee)); } remainingFee = BigInt.zero; break; } if (remainingFee <= BigInt.zero) break; } } // Deduct from the receiver's output if remaining fee is still greater than 0 if (remainingFee > BigInt.zero) { for (int i = 0; i < outputs.length; i++) { final output = outputs[i]; final outputAmount = output.value; if (outputAmount > networkDustAmount) { final deduction = (outputAmount - networkDustAmount >= remainingFee) ? remainingFee : outputAmount - networkDustAmount; outputs[i] = BitcoinOutput(address: output.address, value: outputAmount - deduction); remainingFee -= deduction; if (remainingFee <= BigInt.zero) break; } } } // Final check if the remaining fee couldn't be deducted if (remainingFee > BigInt.zero) { throw Exception("Not enough funds to cover the fee."); } // Identify all change outputs final changeAddresses = walletAddresses.allAddresses.where((element) => element.isHidden); final List changeOutputs = outputs .where((output) => changeAddresses .any((element) => element.address == output.address.toAddress(network))) .toList(); int totalChangeAmount = changeOutputs.fold(0, (sum, output) => sum + output.value.toInt()); // The final amount that the receiver will receive int sendingAmount = allInputsAmount - newFee - totalChangeAmount; final txb = BitcoinTransactionBuilder( utxos: utxos, outputs: outputs, fee: BigInt.from(newFee), network: network, memo: memo, inputOrdering: BitcoinOrdering.shuffle, outputOrdering: BitcoinOrdering.shuffle, enableRBF: true, locktime: await _antiFeeSnipingLocktime(), ); final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) { final key = privateKeys.firstWhereOrNull((element) => element.getPublic().toHex() == publicKey); if (key == null) { throw Exception("Cannot find private key"); } if (utxo.utxo.isP2tr()) { return key.signTapRoot(txDigest, sighash: sighash); } else { return key.signInput(txDigest, sigHash: sighash); } }); return PendingBitcoinTransaction( transaction, type, accountIndex: transactionHistory.transactions[hash]?.accountIndex ?? currentAccountIndex, electrumClient: electrumClient, amount: Money.fromInt(sendingAmount, currency), fee: Money.fromInt(newFee, currency), network: network, hasChange: changeOutputs.isNotEmpty, feeRate: newFee.toString(), isViewOnly: keys.privateKey.isEmpty, )..addListener((transaction) async { transactionHistory.transactions.values.forEach((tx) { if (tx.id == hash) { tx.isReplaced = true; tx.isPending = false; transactionHistory.addOne(tx); } }); transactionHistory.addOne(transaction); await updateAllUnspents(); await updateBalance(); }); } catch (e) { throw e; } } Future getTransactionExpanded( {required String hash, int? height}) async { String transactionHex; int? time; int? confirmations; final verboseTransaction = await electrumClient.getTransactionVerbose(hash: hash); if (verboseTransaction.isEmpty) { transactionHex = await electrumClient.getTransactionHex(hash: hash); if (height != null && height > 0 && await checkIfMempoolAPIIsEnabled()) { try { final blockHash = await ProxyWrapper() .get( clearnetUri: Uri.parse( "https://mempool.space/api/block-height/$height", ), ) .timeout(Duration(seconds: 15)); if (blockHash.statusCode == 200 && blockHash.body.isNotEmpty && jsonDecode(blockHash.body) != null) { final blockResponse = await ProxyWrapper() .get( clearnetUri: Uri.parse( "https://mempool.space/api/block/${blockHash.body}", ), ) .timeout(Duration(seconds: 15)); if (blockResponse.statusCode == 200 && blockResponse.body.isNotEmpty && jsonDecode(blockResponse.body)['timestamp'] != null) { time = int.parse(jsonDecode(blockResponse.body)['timestamp'].toString()); } } } catch (_) {} } } else { transactionHex = verboseTransaction['hex'] as String; time = verboseTransaction['time'] as int?; confirmations = verboseTransaction['confirmations'] as int?; } if (height != null) { if (time == null && height > 0) { time = (getDateByBitcoinHeight(height).millisecondsSinceEpoch / 1000).round(); } if (confirmations == null) { final tip = await getUpdatedChainTip(); if (tip > 0 && height > 0) { // Add one because the block itself is the first confirmation confirmations = tip - height + 1; } } } final original = BtcTransaction.fromRaw(transactionHex); final ins = []; for (final vin in original.inputs) { try { final verboseTransaction = await electrumClient.getTransactionVerbose(hash: vin.txId); final String inputTransactionHex; if (verboseTransaction.isEmpty) { inputTransactionHex = await electrumClient.getTransactionHex(hash: vin.txId); } else { inputTransactionHex = verboseTransaction['hex'] as String; } ins.add(inputTransactionHex.isEmpty ? null : BtcTransaction.fromRaw(inputTransactionHex)); } catch (_) { ins.add(null); } } return ElectrumTransactionBundle( original, ins: ins, time: time, confirmations: confirmations ?? 0, ); } Future fetchTransactionInfo( {required String hash, int? height, bool? retryOnFailure}) async { try { final bundle = await getTransactionExpanded(hash: hash, height: height); final addressesByAccount = addressesSetByAccount; final owner = _accountForTransaction(bundle, addressesByAccount); final info = ElectrumTransactionInfo.fromElectrumBundle( bundle, walletInfo.type, network, addresses: owner != null ? addressesByAccount[owner]! : addressesSet, height: height, ); info.accountIndex = owner; return info; } catch (e) { if (e is FormatException && retryOnFailure == true) { await Future.delayed(const Duration(seconds: 2)); return fetchTransactionInfo(hash: hash, height: height); } return null; } } bool isMine(Script script) { final derivedAddress = addressFromOutputScript(script, network); return addressesSet.contains(derivedAddress); } @override Future> fetchTransactions() async { try { final Map historiesWithDetails = {}; printV("[BATCH_TEST] Fetching transactions with batch: $shouldUseBatchFetching"); if (type == WalletType.bitcoin) { if (isInitialBitcoinAccountsSync) { await fetchBitcoinTransactionsForInitialRestore(historiesWithDetails); } else { await Future.wait(BITCOIN_ADDRESS_TYPES.map((type) => shouldUseBatchFetching ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type) : fetchTransactionsForAddressType(historiesWithDetails, type))); } } else if (type == WalletType.bitcoinCash) { await Future.wait(BITCOIN_CASH_ADDRESS_TYPES.map((type) => shouldUseBatchFetching ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type) : fetchTransactionsForAddressType(historiesWithDetails, type))); } else if (type == WalletType.litecoin) { await Future.wait(LITECOIN_ADDRESS_TYPES.where((type) => type != SegwitAddresType.mweb).map( (type) => shouldUseBatchFetching ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type) : fetchTransactionsForAddressType(historiesWithDetails, type))); } else if (type == WalletType.dogecoin) { await Future.wait(DOGECOIN_ADDRESS_TYPES.map((type) => shouldUseBatchFetching ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type) : fetchTransactionsForAddressType(historiesWithDetails, type))); } printV('[QUICK_SYNC] deferred ${_missingHistoryQueue.length} transactions to background'); transactionHistory.transactions.values.forEach((tx) async { final isPendingSilentPaymentUtxo = (tx.isPending || tx.confirmations == 0) && historiesWithDetails[tx.id] == null && !_missingHistoryQueue.containsKey(tx.id); if (isPendingSilentPaymentUtxo) { final info = await fetchTransactionInfo(hash: tx.id, height: tx.height, retryOnFailure: true); if (info != null) { tx.confirmations = info.confirmations; tx.isPending = tx.confirmations == 0; transactionHistory.addOne(tx); await transactionHistory.save(); } } }); return historiesWithDetails; } catch (e) { printV("fetchTransactions $e"); return {}; } } Future fetchTransactionsForAddressType( Map historiesWithDetails, BitcoinAddressType type, { int? accountIndex, }) async { final addressesByType = walletAddresses.allAddresses .where((addr) => addr.type == type) .where((addr) => accountIndex == null || addr.accountIndex == accountIndex) .toList(); final branchCache = >{}; List branchFor(BitcoinAddressRecord record) => branchCache.putIfAbsent( '${record.accountIndex}:${record.isHidden}:${record.isLegacyDerivation}', () => getAddressBranchByType( type: type, hidden: record.isHidden, legacy: record.isLegacyDerivation, accountIndex: record.accountIndex, ), ); walletAddresses.hiddenAddresses .addAll(addressesByType.where((addr) => addr.isHidden).map((e) => e.address)); await walletAddresses.saveAddressesInBox(); await Future.wait(addressesByType.map((addressRecord) async { final result = await _fetchAddressHistory(addressRecord, await getCurrentChainTip()); if (result.hasHistory) { historiesWithDetails.addAll(result.transactions); final matchedAddresses = branchFor(addressRecord); final isUsedAddressAboveGap = matchedAddresses.indexOf(addressRecord) >= matchedAddresses.length - (addressRecord.isHidden ? ElectrumWalletAddressesBase.defaultChangeAddressesCount : ElectrumWalletAddressesBase.defaultReceiveAddressesCount); if (isUsedAddressAboveGap) { // Discover new addresses for the same address type until the gap limit is respected await walletAddresses.discoverAddresses( matchedAddresses, addressRecord.isHidden, (address) async { await subscribeForUpdates(); return _fetchAddressHistory(address, await getCurrentChainTip()) .then((result) => result.hasHistory ? address.address : null); }, type: type, isLegacyDerivation: addressRecord.isLegacyDerivation, accountIndex: addressRecord.accountIndex, ); } } }),); } Future _fetchAddressHistory( BitcoinAddressRecord addressRecord, int? currentHeight) => _fetchAddressHistories([addressRecord], currentHeight, addressHistoryChunkSize); Future _fetchAddressHistories( List addressRecords, int? currentHeight, int historyChunkSize) async { final scan = await _scanAddressHistories(addressRecords, historyChunkSize); final split = await _markUsedAndSplitHistory(scan, addressRecords, currentHeight); _enqueueMissingHistory(split.missingHistoryItems); return split; } void _enqueueMissingHistory(List> missingHistoryItems) { for (final item in missingHistoryItems) { final txid = item['tx_hash'] as String?; if (txid == null || txid.isEmpty) continue; _missingHistoryQueue.putIfAbsent(txid, () => item); } } Future resolveQueuedTransactionDetails() { final inProgress = _resolveMissingHistoryFuture; if (inProgress != null) return inProgress; if (_missingHistoryQueue.isEmpty) return Future.value(); final future = _drainMissingHistoryQueue(); _resolveMissingHistoryFuture = future; return future.whenComplete(() => _resolveMissingHistoryFuture = null); } Future _drainMissingHistoryQueue() async { final watch = Stopwatch()..start(); var resolvedCount = 0; try { printV('[QUICK_SYNC] resolving ${_missingHistoryQueue.length} deferred transactions'); while (_missingHistoryQueue.isNotEmpty) { final queued = _missingHistoryQueue.values.toList(growable: false); _missingHistoryQueue.clear(); final resolved = await _resolveTransactions(queued, addressHistoryChunkSize); resolvedCount += resolved.length; } } finally { printV( '[QUICK_SYNC] resolved $resolvedCount deferred transactions in ${watch.elapsedMilliseconds} ms'); } } Future _scanAddressHistories( List addressRecords, int historyChunkSize) async { final historyByScriptHash = >>{}; var hadFailures = false; if (shouldUseBatchFetching) { final scriptHashes = addressRecords.map((a) => a.getScriptHash(network)).toList(); historyByScriptHash.addAll( await _processChunksToMap>>( items: scriptHashes, chunkSize: historyChunkSize, processChunk: _getHistoryBatch, onChunkError: (chunk, error) { hadFailures = true; printV('address history scan failed for ${chunk.length} scripthashes: $error'); }, ), ); return AddressHistoryScan( historyByScriptHash: historyByScriptHash, hadFailures: hadFailures, ); } await Future.wait(addressRecords.map((addressRecord) async { final scriptHash = addressRecord.getScriptHash(network); try { historyByScriptHash[scriptHash] = await electrumClient.getHistory(scriptHash); } catch (e) { hadFailures = true; printV('address history scan failed for ${addressRecord.address}: $e'); } })); return AddressHistoryScan( historyByScriptHash: historyByScriptHash, hadFailures: hadFailures, ); } Future _markUsedAndSplitHistory(AddressHistoryScan scan, List addressRecords, int? currentHeight) async { String lastTxId = ''; bool didUpdateHistory = false; try { final Map transactions = {}; final missingHistoryItems = >[]; var hasHistory = false; final byScriptHash = {}; for (final a in addressRecords) { byScriptHash[a.getScriptHash(network)] = a; } for (final entry in scan.historyByScriptHash.entries) { final sh = entry.key; final addressRecord = byScriptHash[sh]; if (addressRecord == null) continue; final history = entry.value; if (history.isEmpty) continue; hasHistory = true; addressRecord.setAsUsed(); addressRecord.txCount = history.length; walletAddresses.clearLockIfMatches(addressRecord.type, addressRecord.address); //removes transactions no longer returned by the api, presumed replaced/invalid. if (this is BitcoinWallet) { final beforeLen = transactionHistory.transactions.length; transactionHistory.transactions.removeWhere((hash, tx) { return tx.outputAddresses != null && tx.outputAddresses!.contains(addressRecord.address) && !history.any((h) => h['tx_hash'] == hash); }); if (transactionHistory.transactions.length != beforeLen) { didUpdateHistory = true; } } for (final item in history) { final txid = item['tx_hash'] as String?; final height = item['height'] as int? ?? 0; if (txid == null || txid.isEmpty) continue; lastTxId = txid; final storedTx = transactionHistory.transactions[txid]; // If we've already tried resolving this tx's account owner // several times and it still can't be determined, stop treating it // as "missing" every scan - otherwise we'd refetch its full raw tx // (and all its inputs' raw txs) forever with no chance of ever // converging. final hasExhaustedAccountRetries = (_unresolvedAccountRetryCount[txid] ?? 0) >= _maxUnresolvedAccountRetries; final needsAccount = type == WalletType.bitcoin && storedTx != null && storedTx.accountIndex == null && !hasExhaustedAccountRetries; if (storedTx != null && !needsAccount) { if (height > 0) { final oldHeight = storedTx.height; final oldConfs = storedTx.confirmations; final oldPending = storedTx.isPending; storedTx.height = height; // the tx's block itself is the first confirmation so add 1 if ((currentHeight ?? 0) > 0) { storedTx.confirmations = currentHeight! - height + 1; } storedTx.isPending = storedTx.confirmations == 0; if (storedTx.height != oldHeight || storedTx.confirmations != oldConfs || storedTx.isPending != oldPending) { transactionHistory.addOne(storedTx); didUpdateHistory = true; } } transactions[txid] = storedTx; } else { missingHistoryItems.add({ 'tx_hash': txid, 'height': height, 'script_hash': sh, 'address': addressRecord.address, }); } } } if (didUpdateHistory) { await transactionHistory.save(); } return AddressHistoryResult( transactions: transactions, missingHistoryItems: missingHistoryItems, hasHistory: hasHistory, hadFailures: scan.hadFailures, ); } catch (e, stacktrace) { final prefix = lastTxId.isNotEmpty ? '$lastTxId - ' : ''; _onError?.call(FlutterErrorDetails( exception: '$prefix$e', stack: stacktrace, library: runtimeType.toString(), )); return AddressHistoryResult( transactions: {}, missingHistoryItems: const [], hasHistory: false, hadFailures: true, ); } } // Tracks how many times we've resolved a tx and still couldn't determine // its owning account. Used by `_markUsedAndSplitHistory` to stop // requeueing a tx forever when its account can never be determined. void _trackAccountResolution(ElectrumTransactionInfo tx) { if (type != WalletType.bitcoin) return; if (tx.accountIndex == null) { _unresolvedAccountRetryCount.update(tx.id, (count) => count + 1, ifAbsent: () => 1); } else { _unresolvedAccountRetryCount.remove(tx.id); } } Future> _resolveTransactions( List> missingHistoryItems, int historyChunkSize) async { final Map resolved = {}; if (missingHistoryItems.isEmpty) return resolved; if (!shouldUseBatchFetching) { await Future.wait(missingHistoryItems.map((item) async { final txid = (item['tx_hash'] as String).trim(); if (txid.isEmpty) return; try { final tx = await fetchTransactionInfo( hash: txid, height: item['height'] as int?, retryOnFailure: true, ); if (tx == null) return; resolved[tx.id] = tx; _trackAccountResolution(tx); _applyLitecoinPegOutTag(tx); transactionHistory.addOne(tx); await transactionHistory.save(); } catch (e, stacktrace) { _onError?.call(FlutterErrorDetails( exception: '$txid - $e', stack: stacktrace, library: runtimeType.toString(), )); } })); return resolved; } try { for (var i = 0; i < missingHistoryItems.length; i += historyChunkSize) { final end = (i + historyChunkSize < missingHistoryItems.length) ? i + historyChunkSize : missingHistoryItems.length; final chunkHistory = missingHistoryItems.sublist(i, end); final hashes = chunkHistory .map((e) => (e['tx_hash'] as String).trim()) .where((h) => h.isNotEmpty) .toList(growable: false); final heightsByHash = { for (final e in chunkHistory) (e['tx_hash'] as String): (e['height'] as int?), }; final infosByHash = await fetchTransactionInfoBatch( hashes: hashes, heightsByHash: heightsByHash, retryOnFailure: true, retryDelay: const Duration(seconds: 1), ); bool chunkUpdatedHistory = false; for (final txid in hashes) { final tx = infosByHash[txid]; if (tx == null) continue; resolved[tx.id] = tx; _trackAccountResolution(tx); _applyLitecoinPegOutTag(tx); transactionHistory.addOne(tx); chunkUpdatedHistory = true; } if (chunkUpdatedHistory) { await transactionHistory.save(); } } return resolved; } catch (e, stacktrace) { _onError?.call(FlutterErrorDetails( exception: '$e', stack: stacktrace, library: runtimeType.toString(), )); return resolved; } } Future fetchBitcoinTransactionsForInitialRestore( Map historiesWithDetails, ) async { int accountIndex = 0; while (accountIndex < maxProbAccounts) { final isAccountProbe = accountIndex > 0; if (!mainHdByTypeAndAccount.containsKey(accountIndex)) { _prepareHdForAccount(accountIndex, currency); } if (!walletAddresses.accountIndexes.contains(accountIndex)) { walletAddresses.accountIndexes.add(accountIndex); await walletAddresses.prepareAccountAddresses( accountIndex, types: isAccountProbe ? EXTRA_ACCOUNT_ADDRESS_TYPES : BITCOIN_ADDRESS_TYPES, ); } final probeTypes = isAccountProbe ? EXTRA_ACCOUNT_ADDRESS_TYPES : BITCOIN_ADDRESS_TYPES; for (final addressType in probeTypes) { if (shouldUseBatchFetching) { await fetchTransactionsForAddressTypeBatch( historiesWithDetails, addressType, accountIndex: accountIndex, ); } else { await fetchTransactionsForAddressType( historiesWithDetails, addressType, accountIndex: accountIndex, ); } } final accountHasHistory = walletAddresses.allAddresses .where((addr) => addr.accountIndex == accountIndex) .any((addr) => addr.isUsed); if (!accountHasHistory) { final persistedAccounts = await walletInfo.getAccounts(); final isUserAccount = persistedAccounts.any((account) => account.accountIndex == accountIndex); if (isAccountProbe && !isUserAccount) { walletAddresses.accountIndexes.remove(accountIndex); walletAddresses.removeAddressesForAccount(accountIndex); } break; } // If the account has history, add it to the wallet (if it's not the first account). if (isAccountProbe) { await walletInfo.addAccount( accountIndex: accountIndex, label: "Account $accountIndex", ); } // Move on to the next account index accountIndex++; } if (electrumClient.isConnected) { await walletInfo.setAccountDiscoveryLimit(maxProbAccounts); } } Future fetchTransactionsForAddressTypeBatch( Map historiesWithDetails, BitcoinAddressType type, { int? accountIndex, }) async { final addressesByType = walletAddresses.allAddresses .where((addr) => addr.type == type) .where((addr) => accountIndex == null || addr.accountIndex == accountIndex) .toList(); walletAddresses.hiddenAddresses .addAll(addressesByType.where((addr) => addr.isHidden).map((e) => e.address)); await walletAddresses.saveAddressesInBox(); final accountIndexes = addressesByType.map((addr) => addr.accountIndex).toSet(); for (final account in accountIndexes) { for (final isHidden in [false, true]) { for (final isLegacyDerivation in [false, true]) { await fetchTransactionsForAddressesBranchBatch( historiesWithDetails, type, getAddressBranchByType( type: type, hidden: isHidden, legacy: isLegacyDerivation, accountIndex: account, ), isHidden: isHidden, isLegacyDerivation: isLegacyDerivation, accountIndex: account, ); } } } } Future fetchTransactionsForAddressesBranchBatch( Map historiesWithDetails, BitcoinAddressType type, List branchAddresses, { required bool isHidden, required bool isLegacyDerivation, required int accountIndex, }) async { if (branchAddresses.isEmpty) return; final tip = await getCurrentChainTip(); final currentBranch = [...branchAddresses]; final initialHistory = await _processChunksToMap( items: currentBranch, chunkSize: addressHistoryChunkSize, processChunk: (chunk) async => (await _fetchAddressHistories(chunk, tip, addressHistoryChunkSize)).transactions, ); if (initialHistory.isNotEmpty) { historiesWithDetails.addAll(initialHistory); } final gapLimit = isHidden ? ElectrumWalletAddressesBase.defaultChangeAddressesCount : ElectrumWalletAddressesBase.defaultReceiveAddressesCount; final highestUsedIndex = _highestUsedIndex(currentBranch); final shouldDiscover = highestUsedIndex >= 0 && highestUsedIndex >= currentBranch.length - gapLimit; if (!shouldDiscover) return; final newAddresses = await walletAddresses.discoverAddressesBatch( currentBranch, isHidden, (newAddresses) async { final newHistory = await _fetchAddressHistories(newAddresses, tip, discoveryHistoryChunkSize); if (newHistory.transactions.isNotEmpty) { historiesWithDetails.addAll(newHistory.transactions); } return newAddresses .where((addressRecord) => addressRecord.isUsed) .map((addressRecord) => addressRecord.address) .toSet(); }, type: type, isLegacyDerivation: isLegacyDerivation, accountIndex: accountIndex, ); if (newAddresses.isNotEmpty) { currentBranch.addAll(newAddresses); if (isHidden) { walletAddresses.hiddenAddresses.addAll(newAddresses.map((e) => e.address)); await walletAddresses.saveAddressesInBox(); } } } List getAddressBranchByType({ required BitcoinAddressType type, required bool hidden, required bool legacy, int? accountIndex, }) => walletAddresses.allAddresses .where((addr) => addr.type == type && (accountIndex == null || addr.accountIndex == accountIndex) && addr.isHidden == hidden && addr.isLegacyDerivation == legacy) .toList() ..sort((a, b) => a.index.compareTo(b.index)); int _highestUsedIndex(List addresses) { for (int i = addresses.length - 1; i >= 0; i--) { if (addresses[i].isUsed) return i; } return -1; } Future>> _getTransactionVerboseBatch(List hashes) { return electrumClient.getBatchTransactionVerbose( hashes, timeout: transactionBatchTimeoutMs, ); } Future> _getTransactionHexBatch(List hashes) { return electrumClient.getBatchTransactionHex( hashes, timeout: transactionBatchTimeoutMs, ); } Future>>> _getHistoryBatch(List scriptHashes) { return electrumClient.getBatchHistory( scriptHashes, timeout: transactionBatchTimeoutMs, ); } Future>>> _getListUnspentBatch(List scriptHashes) { return electrumClient.getBatchUnspent( scriptHashes, timeout: transactionBatchTimeoutMs, ); } Future>> _getBalanceBatch(List scriptHashes) { return electrumClient.getBatchBalance( scriptHashes, timeout: transactionBatchTimeoutMs, ); } Future> fetchTransactionInfoBatch({ required List hashes, Map? heightsByHash, bool retryOnFailure = false, Duration retryDelay = const Duration(seconds: 2), }) async { final result = {}; final uniqueHashes = hashes.map((h) => h.trim()).where((h) => h.isNotEmpty).toSet().toList(); if (uniqueHashes.isEmpty) return result; await _processTransactionInfoBatch( txIds: uniqueHashes, result: result, heightsByHash: heightsByHash, ); if (retryOnFailure) { final failedHashes = uniqueHashes.where((txId) => result[txId] == null).toList(); if (failedHashes.isNotEmpty) { await Future.delayed(retryDelay); await _processTransactionInfoBatch( txIds: failedHashes, result: result, heightsByHash: heightsByHash, ); } } return result; } Future _processTransactionInfoBatch({ required List txIds, required Map result, required Map? heightsByHash, }) async { final addressesByAccount = addressesSetByAccount; for (var i = 0; i < txIds.length; i += transactionChunkSize) { final end = (i + transactionChunkSize < txIds.length) ? i + transactionChunkSize : txIds.length; final chunk = txIds.sublist(i, end); final bundlesByHash = await getTransactionExpandedBatch( hashes: chunk, heightsByHash: heightsByHash, ); for (final txId in chunk) { try { final bundle = bundlesByHash[txId]; if (bundle == null) { result[txId] = null; continue; } final owner = _accountForTransaction(bundle, addressesByAccount); final info = ElectrumTransactionInfo.fromElectrumBundle( bundle, walletInfo.type, network, addresses: owner != null ? addressesByAccount[owner]! : addressesSet, height: heightsByHash?[txId], ); info.id = txId; info.accountIndex = owner; result[txId] = info; } catch (_) { result[txId] = null; } } } } Future> getTransactionExpandedBatch( {required List hashes, Map? heightsByHash}) async { final bundles = {}; if (hashes.isEmpty) return bundles; final verboseByHash = await _fetchTransactionVerboseBatch(hashes); final originalByHash = _parseTransactions(verboseByHash); final inputTxIdsByHash = _collectInputTxIdsByHash(originalByHash); final allInputTxids = {}; for (final txids in inputTxIdsByHash.values) { allInputTxids.addAll(txids); } final inputTxIds = allInputTxids.toList(growable: false); final inputVerboseByTxId = inputTxIds.isEmpty ? >{} : await _fetchTransactionVerboseBatch(inputTxIds); final parsedInputTxById = _parseTransactions(inputVerboseByTxId); return _buildTransactionBundlesBatch( unique: hashes, heightsByHash: heightsByHash, tip: await getUpdatedChainTip(), originalByHash: originalByHash, verboseByHash: verboseByHash, inputTxidsByHash: inputTxIdsByHash, parsedInputTxById: parsedInputTxById, ); } Future>> _fetchTransactionVerboseBatch( List txIds) async { final verboseTransactionByHash = await _processChunksToMap>( items: txIds, chunkSize: transactionChunkSize, processChunk: _getTransactionVerboseBatch, ); final emptyHex = []; for (final txId in txIds) { final vTx = verboseTransactionByHash[txId]; if (vTx == null || vTx.isEmpty || vTx['hex'] == null) { emptyHex.add(txId); } } final hexByHash = await _processChunksToMap( items: emptyHex, chunkSize: transactionChunkSize, processChunk: _getTransactionHexBatch, ); for (final txId in txIds) { final verbose = verboseTransactionByHash[txId] ?? {}; if ((verbose['hex'] as String?) == null) { final hex = hexByHash[txId]; if (hex != null && hex.isNotEmpty) { verboseTransactionByHash[txId] = { ...verbose, 'hex': hex, }; } } } return verboseTransactionByHash; } Map _parseTransactions( Map> verboseByHash, ) { final result = {}; for (final entry in verboseByHash.entries) { final hex = entry.value['hex'] as String?; if (hex == null || hex.isEmpty) continue; try { result[entry.key] = BtcTransaction.fromRaw(hex); } catch (_) {} } return result; } Map> _collectInputTxIdsByHash( Map originalByHash, ) { final inputTxIdsByHash = >{}; for (final entry in originalByHash.entries) { final txId = entry.key; final original = entry.value; final inputTxIds = []; for (final vin in original.inputs) { inputTxIds.add(vin.txId); } inputTxIdsByHash[txId] = inputTxIds; } return inputTxIdsByHash; } Future> _buildTransactionBundlesBatch({ required List unique, required Map? heightsByHash, required int tip, required Map originalByHash, required Map> verboseByHash, required Map> inputTxidsByHash, required Map parsedInputTxById, }) async { final bundles = {}; // Identify heights that need mempool timestamp lookup final heightsNeedingTime = {}; for (final txid in originalByHash.keys) { final verbose = verboseByHash[txid] ?? const {}; final time = verbose['time'] as int?; final h = heightsByHash?[txid]; if (time == null && h != null && h > 0) { heightsNeedingTime.add(h); } } final mempoolTimes = await _fetchBlockTimestampsFromMempoolByHeights(heightsNeedingTime); for (final txid in unique) { final original = originalByHash[txid]; if (original == null) continue; final verbose = verboseByHash[txid] ?? const {}; int? time = verbose['time'] as int?; int? confirmations = verbose['confirmations'] as int?; final h = heightsByHash?[txid]; if (h != null) { if (time == null && h > 0) { final mp = mempoolTimes[h]; time = mp ?? (getDateByBitcoinHeight(h).millisecondsSinceEpoch / 1000).round(); } if (confirmations == null && tip > 0 && h > 0) { confirmations = tip - h + 1; if (confirmations < 0) confirmations = 0; } } final inputTxids = inputTxidsByHash[txid] ?? const []; final ins = [ for (final inputTxid in inputTxids) parsedInputTxById[inputTxid], ]; bundles[txid] = ElectrumTransactionBundle( original, ins: ins, time: time, confirmations: confirmations ?? 0, ); } return bundles; } Future> _fetchBlockTimestampsFromMempoolByHeights( Set heights, ) async { final out = {}; if (heights.isEmpty) return out; if (!(await checkIfMempoolAPIIsEnabled())) return out; // Best-effort: if any call fails, we just skip that height. await Future.wait(heights.map((h) async { try { final blockHashResp = await ProxyWrapper() .get( clearnetUri: Uri.parse( 'https://mempool.space/api/block-height/$h', ), ) .timeout(const Duration(seconds: 15)); if (blockHashResp.statusCode != 200 || blockHashResp.body.isEmpty) return; final blockHash = blockHashResp.body.trim(); if (blockHash.isEmpty) return; final blockResp = await ProxyWrapper() .get( clearnetUri: Uri.parse( 'https://mempool.space/api/block/$blockHash', ), ) .timeout(const Duration(seconds: 15)); if (blockResp.statusCode != 200 || blockResp.body.isEmpty) return; final decoded = jsonDecode(blockResp.body); final ts = decoded is Map ? decoded['timestamp'] : null; if (ts == null) return; final parsed = int.tryParse(ts.toString()); if (parsed == null) return; out[h] = parsed; } catch (_) { // ignore } })); return out; } Future> _processChunksToMap({ required List items, required int chunkSize, required Future> Function(List chunk) processChunk, void Function(List chunk, Object error)? onChunkError, }) async { final result = {}; for (var i = 0; i < items.length; i += chunkSize) { final end = (i + chunkSize < items.length) ? i + chunkSize : items.length; final chunk = items.sublist(i, end); try { final chunkResult = await processChunk(chunk); result.addAll(chunkResult); } on electrum.RequestFailedTimeoutException catch (e) { onChunkError?.call(chunk, e); continue; } catch (e) { onChunkError?.call(chunk, e); continue; } } return result; } Future updateTransactions() async { printV("updateTransactions() called!"); if (_isTransactionUpdating) { printV("updateTransactions: already running, skipping"); return; } _isTransactionUpdating = true; try { currentChainTip = await getUpdatedChainTip(); bool updated = false; transactionHistory.transactions.values.forEach((tx) { if ((tx.height ?? 0) > 0 && (currentChainTip ?? 0) > 0) { var confirmations = currentChainTip! - tx.height! + 1; if (confirmations < 0) { // if our chain tip is outdated then it could lead to negative confirmations so this is just a failsafe: confirmations = 0; } if (confirmations != tx.confirmations) { updated = true; tx.confirmations = confirmations; transactionHistory.addOne(tx); } } }); if (updated) { await transactionHistory.save(); } await fetchTransactions(); walletAddresses.updateReceiveAddresses(); } catch (e, stacktrace) { printV(stacktrace); printV(e); } finally { _isTransactionUpdating = false; } } Future subscribeForUpdates() async { final unsubscribedScriptHashes = walletAddresses.allAddresses.where( (address) => !_scripthashesUpdateSubject.containsKey(address.getScriptHash(network)) && address.type != SegwitAddresType.mweb, ); await Future.wait(unsubscribedScriptHashes.map((address) async { final sh = address.getScriptHash(network); if (!(_scripthashesUpdateSubject[sh]?.isClosed ?? true)) { try { await _scripthashesUpdateSubject[sh]?.close(); } catch (e) { printV("failed to close: $e"); } } try { _scripthashesUpdateSubject[sh] = await electrumClient.scripthashUpdate(sh); } catch (e) { printV("failed scripthashUpdate: $e"); } _scripthashesUpdateSubject[sh]?.listen((event) async { try { await updateUnspentsForAddress(address); await updateBalance(); await _fetchAddressHistory(address, await getCurrentChainTip()); await resolveQueuedTransactionDetails(); } catch (e, s) { printV("sub error: $e"); _onError?.call(FlutterErrorDetails( exception: e, stack: s, library: this.runtimeType.toString(), )); } }, onError: (e, s) { printV("sub_listen error: $e $s"); }); })); } Future>> fetchBalancesBatch( List addresses, ) async { final scriptHashes = addresses.map((address) => address.getScriptHash(network)).toList(); if (scriptHashes.isEmpty) { return >[]; } try { final balancesByScriptHash = await _processChunksToMap>( items: scriptHashes, chunkSize: addressHistoryChunkSize, processChunk: _getBalanceBatch, ); final balances = scriptHashes .map((scriptHash) => balancesByScriptHash[scriptHash] ?? {}) .toList(); final hasMissingBalance = balances.any((balance) => balance['confirmed'] == null); if (hasMissingBalance) { printV('fetchBalancesBatch returned missing balances, falling back to regular flow'); return fetchBalancesRegular(addresses); } return balances; } catch (e) { printV('fetchBalancesBatch failed, falling back to regular flow: $e'); return fetchBalancesRegular(addresses); } } Future>> fetchBalancesRegular( List addresses, ) async { final balanceFutures = >>[]; for (final address in addresses) { final sh = address.getScriptHash(network); balanceFutures.add(electrumClient.getBalance(sh)); } return Future.wait(balanceFutures); } Future fetchBalances() async { final addresses = walletAddresses.allAddresses .where((address) => address.address.isNotEmpty) .where((address) => RegexUtils.addressTypeFromStr(address.address, network) is! MwebAddress) .toList(); List> balances; try { balances = shouldUseBatchFetching ? await fetchBalancesBatch(addresses) : await fetchBalancesRegular(addresses); } catch (e) { printV("fetchBalances network error, returning last known balance: $e"); syncStatus = LostConnectionSyncStatus(); return balance[currency] ?? ElectrumBalance( confirmed: Money.zero(currency), unconfirmed: Money.zero(currency), frozen: Money.zero(currency), ); } printV( 'Fetched balances for ${addresses.length} addresses. Batch fetching: $shouldUseBatchFetching'); // If the balances list is empty (no addresses or all fetches silently returned nothing), // preserve the last known balance rather than returning zero. if (balances.isEmpty) { return balance[currency] ?? ElectrumBalance( confirmed: Money.zero(currency), unconfirmed: Money.zero(currency), frozen: Money.zero(currency), ); } var totalFrozen = 0; var totalConfirmed = 0; var totalUnconfirmed = 0; final confirmedByAccount = {}; final unconfirmedByAccount = {}; final frozenByAccount = {}; if (hasSilentPaymentsScanning) { // Add values from unspent coins that are not fetched by the address list // i.e. scanned silent payments transactionHistory.transactions.values.forEach((tx) { if (tx.unspents != null) { tx.unspents!.forEach((unspent) { if (unspent.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) { final account = unspent.bitcoinAddressRecord.accountIndex; if (unspent.isFrozen) { totalFrozen += unspent.value; frozenByAccount[account] = (frozenByAccount[account] ?? 0) + unspent.value; } totalConfirmed += unspent.value; confirmedByAccount[account] = (confirmedByAccount[account] ?? 0) + unspent.value; } }); } }); } unspentCoinsInfo.values.forEach((info) { unspentCoins.forEach((element) { if (element.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) return; if (element.hash == info.hash && element.vout == info.vout && element.bitcoinAddressRecord.address == info.address && element.value == info.value) { if (info.isFrozen) { totalFrozen += element.value; final account = element.bitcoinAddressRecord.accountIndex; frozenByAccount[account] = (frozenByAccount[account] ?? 0) + element.value; } } }); }); // Check if ALL returned balances have null confirmed — indicates server is unreachable. final allNull = balances.every((b) => b['confirmed'] == null); if (allNull) { // if we got null balance responses from the server, set our connection status to lost and return our last known balance: printV("got null balance responses from the server, setting connection status to lost"); syncStatus = LostConnectionSyncStatus(); return balance[currency] ?? ElectrumBalance( confirmed: Money.zero(currency), unconfirmed: Money.zero(currency), frozen: Money.zero(currency), ); } for (var i = 0; i < balances.length; i++) { final addressRecord = addresses[i]; final balance = balances[i]; final confirmed = balance['confirmed'] as int? ?? 0; final unconfirmed = balance['unconfirmed'] as int? ?? 0; totalConfirmed += confirmed; totalUnconfirmed += unconfirmed; final account = addressRecord.accountIndex; confirmedByAccount[account] = (confirmedByAccount[account] ?? 0) + confirmed; unconfirmedByAccount[account] = (unconfirmedByAccount[account] ?? 0) + unconfirmed; addressRecord.balance = confirmed + unconfirmed; if (confirmed > 0 || unconfirmed > 0) { addressRecord.setAsUsed(); walletAddresses.clearLockIfMatches(addressRecord.type, addressRecord.address); } } if (hasAccountsSupport) { final perAccount = {}; final accounts = { ...confirmedByAccount.keys, ...unconfirmedByAccount.keys, ...frozenByAccount.keys, ...walletAddresses.accountIndexes, }; for (final account in accounts) { perAccount[account] = ElectrumBalance( confirmed: Money.fromInt(confirmedByAccount[account] ?? 0, currency), unconfirmed: Money.fromInt(unconfirmedByAccount[account] ?? 0, currency), frozen: Money.fromInt(frozenByAccount[account] ?? 0, currency), ); } accountBalances = ObservableMap.of(perAccount); } return ElectrumBalance( confirmed: Money.fromInt(totalConfirmed, currency), unconfirmed: Money.fromInt(totalUnconfirmed, currency), frozen: Money.fromInt(totalFrozen, currency), ); } List get currentAccountBitcoinTransactions { final all = transactionHistory.transactions.values; if (!hasAccountsSupport) return all.toList(); final accountIndex = currentAccountIndex; final accountAddresses = walletAddresses.allAddresses .where((a) => a.accountIndex == accountIndex) .map((a) => a.address) .toSet(); return all.where((tx) { if (tx.additionalInfo["isLightning"] == true) return accountIndex == 0; if (tx.isReceivedSilentPayment) return accountIndex == 0; // Locally created transactions store the account index explicitly, // so use it first instead of checking the addresses. if (tx.accountIndex != null) return tx.accountIndex == accountIndex; return (tx.inputAddresses ?? const []).any(accountAddresses.contains) || (tx.outputAddresses ?? const []).any(accountAddresses.contains); }).toList(); } Future updateBalance() async { printV("updateBalance() called!"); if (!electrumClient.isConnected) { printV("updateBalance: not connected, keeping existing balance"); return; } try { final fetchedTotal = await fetchBalances(); if (!electrumClient.isConnected || syncStatus is LostConnectionSyncStatus) { printV("updateBalance: connection lost during fetch, keeping existing balance"); return; } if (type == WalletType.bitcoin && hasAccountsSupport) { // If the wallet has accounts support, we only want to update the balance for the current account. final accountBalance = accountBalances[currentAccountIndex]; if (accountBalance != null) { balance[currency] = accountBalance.copy(); } else { printV("updateBalance: no balance for account $currentAccountIndex, keeping existing"); } } else { balance[currency] = fetchedTotal; } await save(); } catch (e) { printV("updateBalance failed: $e"); } } @override Future checkNodeHealth() async { try { final addresses = walletAddresses.allAddresses .where( (address) => RegexUtils.addressTypeFromStr(address.address, network) is! MwebAddress) .toList(); if (addresses.isEmpty) { return false; } final firstAddress = addresses.first; final sh = firstAddress.getScriptHash(network); await electrumClient.getBalance(sh, throwOnError: true); return true; } catch (e) { return false; } } @override void setExceptionHandler(void Function(FlutterErrorDetails) onError) => _onError = onError; @override Future signMessage(String message, {String? address = null}) async { final addressRecord = address != null ? walletAddresses.allAddresses.firstWhereOrNull((addr) => addr.address == address) : null; if (addressRecord != null && addressRecord.type == SegwitAddresType.p2tr) { throw UnsupportedError("Cannot sign message with Taproot address"); } final hd = addressRecord != null ? _hdForAddressRecord(record: addressRecord).childKey(Bip32KeyIndex(addressRecord.index)) : mainHd; final priv = ECPrivate.fromHex(hd.privateKey.privKey.toHex()); String messagePrefix = '\x18Bitcoin Signed Message:\n'; final hexEncoded = priv.signMessage(utf8.encode(message), messagePrefix: messagePrefix); final decodedSig = hex.decode(hexEncoded); return base64Encode(decodedSig); } void _applyLitecoinPegOutTag(ElectrumTransactionInfo tx) { if (this is! LitecoinWallet) return; // if we have a peg out transaction with the same value // that matches this received transaction, mark it as being from a peg out: for (final tx2 in transactionHistory.transactions.values) { final heightDiff = ((tx2.height ?? 0) - (tx.height ?? 0)).abs(); // 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 if (tx2.additionalInfo["isPegOut"] == true && tx2.amount == tx.amount && heightDiff <= 5) { tx.additionalInfo["fromPegOut"] = true; } } } Future checkIfBatchSupported() async { if (_isBatchSupported != null) { printV('[BATCH_TEST] Already checked: $_isBatchSupported'); return; } final hashes = publicScriptHashes.take(batchTestHashesCount).toList(); if (hashes.length < batchTestHashesCount) { _isBatchSupported = false; printV('[BATCH_TEST] Failed: not enough script hashes'); return; } try { final paramsList = hashes.map((hash) => [hash]).toList(); printV('[BATCH_TEST] Start: hashes=${hashes.length}, timeout=${batchTestTimeoutMs}ms'); final result = await electrumClient.callBatchWithTimeout( method: 'blockchain.scripthash.get_history', paramsList: paramsList, timeout: batchTestTimeoutMs, ); final hasError = result.any((item) => item is Map && item.containsKey('error') && item['error'] != null); if (hasError) { _isBatchSupported = false; printV('[BATCH_TEST] Result: supported=false (server returned error)'); return; } _isBatchSupported = true; printV('[BATCH_TEST] Result: supported=true'); } on electrum.RequestFailedTimeoutException catch (e) { _isBatchSupported = false; printV('[BATCH_TEST] Timeout: $e'); } catch (e) { _isBatchSupported = false; printV('[BATCH_TEST] Exception: $e'); } } @override Future verifyMessage(String message, String signature, {String? address = null}) async { if (address == null) { return false; } List sigDecodedBytes = []; if (signature.endsWith('=')) { sigDecodedBytes = base64.decode(signature); } else { sigDecodedBytes = hex.decode(signature); } if (sigDecodedBytes.length != 64 && sigDecodedBytes.length != 65) { throw ArgumentException( "signature must be 64 bytes without recover-id or 65 bytes with recover-id"); } String messagePrefix = '\x18Bitcoin Signed Message:\n'; final messageHash = QuickCrypto.sha256Hash( BitcoinSignerUtils.magicMessage(utf8.encode(message), messagePrefix)); List correctSignature = sigDecodedBytes.length == 65 ? sigDecodedBytes.sublist(1) : List.from(sigDecodedBytes); List rBytes = correctSignature.sublist(0, 32); List sBytes = correctSignature.sublist(32); final sig = ECDSASignature(BigintUtils.fromBytes(rBytes), BigintUtils.fromBytes(sBytes)); List possibleRecoverIds = [0, 1]; final baseAddress = RegexUtils.addressTypeFromStr(address, network); for (int recoveryId in possibleRecoverIds) { final pubKey = sig.recoverPublicKey(messageHash, Curves.generatorSecp256k1, recoveryId); final recoveredPub = ECPublic.fromBytes(pubKey!.toBytes()); String? recoveredAddress; if (baseAddress is P2pkAddress) { recoveredAddress = recoveredPub.toP2pkAddress().toAddress(network); } else if (baseAddress is P2pkhAddress) { recoveredAddress = recoveredPub.toP2pkhAddress().toAddress(network); } else if (baseAddress is P2wshAddress) { recoveredAddress = recoveredPub.toP2wshAddress().toAddress(network); } else if (baseAddress is P2wpkhAddress) { recoveredAddress = recoveredPub.toP2wpkhAddress().toAddress(network); } if (recoveredAddress == address) { return true; } } return false; } Future _setInitialHeight() async { if (_chainTipUpdateSubject != null) return; currentChainTip = await getUpdatedChainTip(); if ((currentChainTip == null || currentChainTip! == 0) && walletInfo.restoreHeight == 0) { await walletInfo.updateRestoreHeight(currentChainTip!); } _chainTipUpdateSubject = electrumClient.chainTipSubscribe(); _chainTipUpdateSubject?.listen((e) async { final event = e as Map; final height = int.tryParse(event['height'].toString()); if (height != null) { currentChainTip = height; if (alwaysScan == true && syncStatus is SyncedSyncStatus) { _setListeners(walletInfo.restoreHeight); } } }); } static String _hardenedDerivationPath(String derivationPath) => derivationPath.substring(0, derivationPath.lastIndexOf("'") + 1); @action void _onConnectionStatusChange(electrum.ConnectionStatus status) { switch (status) { case electrum.ConnectionStatus.connected: if (syncStatus is NotConnectedSyncStatus || syncStatus is LostConnectionSyncStatus || syncStatus is ConnectingSyncStatus) { syncStatus = ConnectedSyncStatus(); } break; case electrum.ConnectionStatus.disconnected: // Always show disconnected status when connection is lost, regardless of current sync state if (syncStatus is! NotConnectedSyncStatus) { syncStatus = NotConnectedSyncStatus(); } break; case electrum.ConnectionStatus.failed: if (syncStatus is! LostConnectionSyncStatus) { syncStatus = LostConnectionSyncStatus(); } break; case electrum.ConnectionStatus.connecting: if (syncStatus is! ConnectingSyncStatus) { syncStatus = ConnectingSyncStatus(); } break; } } void _syncStatusReaction(SyncStatus syncStatus) async { printV("SYNC_STATUS_CHANGE: ${syncStatus}"); if (syncStatus is SyncingSyncStatus) { return; } if (syncStatus is NotConnectedSyncStatus || syncStatus is LostConnectionSyncStatus) { // Needs to re-subscribe to all scripthashes when reconnected _scripthashesUpdateSubject = {}; if (_isTryingToConnect) return; _isTryingToConnect = true; Timer(Duration(seconds: 5), () { if (this.syncStatus is NotConnectedSyncStatus || this.syncStatus is LostConnectionSyncStatus) { this.electrumClient.connectToUri( node!.uri, useSSL: node!.useSSL ?? false, ); } _isTryingToConnect = false; }); } // Message is shown on the UI for 3 seconds, revert to synced if (syncStatus is SyncedTipSyncStatus) { Timer(Duration(seconds: 3), () { if (this.syncStatus is SyncedTipSyncStatus) this.syncStatus = SyncedSyncStatus(); }); } } void _updateInputsAndOutputs(ElectrumTransactionInfo tx, ElectrumTransactionBundle bundle) { tx.inputAddresses = tx.inputAddresses?.where((address) => address.isNotEmpty).toList(); if (tx.inputAddresses == null || tx.inputAddresses!.isEmpty || tx.outputAddresses == null || tx.outputAddresses!.isEmpty) { List inputAddresses = []; List outputAddresses = []; for (int i = 0; i < bundle.originalTransaction.inputs.length; i++) { final input = bundle.originalTransaction.inputs[i]; final inputTransaction = bundle.ins[i]; if (inputTransaction == null) continue; final vout = input.txIndex; final outTransaction = inputTransaction.outputs[vout]; final address = addressFromOutputScript(outTransaction.scriptPubKey, network); if (address.isNotEmpty) inputAddresses.add(address); } for (int i = 0; i < bundle.originalTransaction.outputs.length; i++) { final out = bundle.originalTransaction.outputs[i]; final address = addressFromOutputScript(out.scriptPubKey, network); if (address.isNotEmpty) outputAddresses.add(address); // Check if the script contains OP_RETURN final script = out.scriptPubKey.script; if (script.contains('OP_RETURN')) { final index = script.indexOf('OP_RETURN'); if (index + 1 <= script.length) { try { final opReturnData = script[index + 1].toString(); final decodedString = utf8.decode(HEX.decode(opReturnData)); outputAddresses.add('OP_RETURN:$decodedString'); } catch (_) { outputAddresses.add('OP_RETURN:'); } } } } tx.inputAddresses = inputAddresses; tx.outputAddresses = outputAddresses; transactionHistory.addOne(tx); } } /// Checks the health of the socket connection /// and triggers a full reconnection if needed @override Future checkSocketHealth() async { try { SocketHealthLogger().logHealthCheck( walletType: type, walletName: name, syncStatus: syncStatus.toString(), wasReconnected: false, trigger: 'socket_health_check_start', ); if (!electrumClient.isConnected || !electrumClient.isInternalStateConsistent) { if (!electrumClient.isConnected) { SocketHealthLogger().logHealthCheck( walletType: type, walletName: name, isHealthy: false, syncStatus: syncStatus.toString(), wasReconnected: false, trigger: 'socket_health_check_socket_not_connected', ); } if (!electrumClient.isInternalStateConsistent) { SocketHealthLogger().logHealthCheck( walletType: type, walletName: name, isHealthy: false, syncStatus: syncStatus.toString(), wasReconnected: false, trigger: 'socket_health_check_internal_state_inconsistent', ); } await _performFullReconnection(); SocketHealthLogger().logHealthCheck( walletType: type, walletName: name, isHealthy: true, syncStatus: syncStatus.toString(), wasReconnected: true, trigger: 'socket_health_check_reconnection_success_for_unhealthy_basic_check_or_internal_state_inconsistent', ); return true; } // Make a call to the server to check if the connection is healthy // If the call fails, we need to reconnect try { final result = await electrumClient.call( method: 'server.version', params: ['', '1.4'], ); if (result == null) { throw Exception('Call mechanism test returned null'); } SocketHealthLogger().logHealthCheck( walletType: type, walletName: name, isHealthy: true, syncStatus: syncStatus.toString(), wasReconnected: false, trigger: 'socket_health_check_server_state_ok', ); return true; } catch (e) { SocketHealthLogger().logHealthCheck( walletType: type, walletName: name, isHealthy: false, error: e.toString(), syncStatus: syncStatus.toString(), wasReconnected: false, trigger: 'socket_health_check_server_state_failed', ); await _performFullReconnection(); SocketHealthLogger().logHealthCheck( walletType: type, walletName: name, isHealthy: true, syncStatus: syncStatus.toString(), wasReconnected: true, trigger: 'socket_health_check_reconnection_success_for_server_state_failed', ); return true; } } catch (e) { return false; } } Future _performFullReconnection() async { try { SocketHealthLogger().logHealthCheck( walletType: type, walletName: name, syncStatus: syncStatus.toString(), wasReconnected: true, trigger: 'full_reconnection_start', ); await _receiveStream?.cancel(); await electrumClient.close(); if (node != null) { electrumClient.onConnectionStatusChange = _onConnectionStatusChange; await electrumClient.connectToUri(node!.uri, useSSL: node!.useSSL); await startSync(); SocketHealthLogger().logHealthCheck( walletType: type, walletName: name, isHealthy: true, syncStatus: syncStatus.toString(), wasReconnected: true, trigger: 'full_reconnection_success', ); } } catch (e) { SocketHealthLogger().logHealthCheck( walletType: type, walletName: name, isHealthy: false, error: e.toString(), syncStatus: syncStatus.toString(), wasReconnected: false, trigger: 'full_reconnection_failed', ); syncStatus = FailedSyncStatus(); } } Bip32Slip10Secp256k1 _hdForAddressRecord({required BaseBitcoinAddressRecord record}) { final addrType = record.type; if (record.isLegacyDerivation) { return record.isHidden ? walletAddresses.legacySideHd : walletAddresses.legacyMainHd; } final accountIndex = record.accountIndex; if (record.isHidden) { return sideHdByTypeAndAccount[accountIndex]?[addrType] ?? sideHd; } else { return mainHdByTypeAndAccount[accountIndex]?[addrType] ?? mainHd; } } } class ScanNode { final Uri uri; final bool? useSSL; ScanNode(this.uri, this.useSSL); } class ScanData { final SendPort sendPort; final SilentPaymentOwner silentAddress; final Bip32Slip10Secp256k1 masterHD; final int height; final ScanNode? node; final BasedUtxoNetwork network; final int chainTip; final electrum.ElectrumClient electrumClient; final List transactionHistoryIds; final Map labels; final List labelIndexes; final bool isSingleScan; final String debugLogPath; final List? rescanHeights; ScanData({ required this.sendPort, required this.silentAddress, required this.masterHD, required this.height, required this.node, required this.network, required this.chainTip, required this.electrumClient, required this.transactionHistoryIds, required this.labels, required this.labelIndexes, required this.isSingleScan, required this.debugLogPath, required this.rescanHeights, }); factory ScanData.fromHeight(ScanData scanData, int newHeight) { return ScanData( sendPort: scanData.sendPort, silentAddress: scanData.silentAddress, masterHD: scanData.masterHD, height: newHeight, node: scanData.node, network: scanData.network, chainTip: scanData.chainTip, transactionHistoryIds: scanData.transactionHistoryIds, electrumClient: scanData.electrumClient, labels: scanData.labels, labelIndexes: scanData.labelIndexes, isSingleScan: scanData.isSingleScan, debugLogPath: scanData.debugLogPath, rescanHeights: scanData.rescanHeights, ); } } class SyncResponse { final int height; final SyncStatus syncStatus; SyncResponse(this.height, this.syncStatus); } Future _handleScanSilentPayments(ScanData scanData) async { final shouldUpdateSyncStatus = scanData.rescanHeights == null || scanData.rescanHeights!.isEmpty; final hasForcedRescanHeights = !shouldUpdateSyncStatus; CakeTor.instance = await CakeTorInstance.getInstance(); var node = scanData.node?.uri ?? Uri.parse("tcp://electrs.cakewallet.com:50001"); void log(String message, LogLevel level) { printV("[Scanning] $message", file: scanData.debugLogPath, level: level); } try { // if (scanData.shouldSwitchNodes) { var scanningClient = await ElectrumProvider.connect( ElectrumTCPService.connect(node), ); // } log("connected to ${node.toString()}", LogLevel.info); final receivers = [ Receiver( scanData.silentAddress.b_scan.toHex(), scanData.silentAddress.B_spend.toHex(), scanData.network == BitcoinNetwork.testnet, scanData.labelIndexes, scanData.labelIndexes.length, ), Receiver( scanData.masterHD.derivePath(SILENT_PAYMENTS_SCAN_PATH_TESTNET).privateKey.toHex(), scanData.masterHD.derivePath(SILENT_PAYMENTS_SPEND_PATH_TESTNET).publicKey.toHex(), scanData.network == BitcoinNetwork.testnet, scanData.labelIndexes, scanData.labelIndexes.length, ) ]; log( "using receiver: b_scan: ${scanData.silentAddress.b_scan.toHex()}, b_spend: ${scanData.silentAddress.B_spend.toHex()}, network: ${scanData.network.value}, labelIndexes: ${scanData.labelIndexes}", LogLevel.info, ); log( "using receiver: b_scan: ${receivers[1].bScan}, b_spend: ${receivers[1].BSpend}, network: ${scanData.network.value}, labelIndexes: ${scanData.labelIndexes}", LogLevel.info, ); void scan(int syncHeight, bool isSingleScan) async { int initialSyncHeight = syncHeight; int getCountToScanPerRequest(int syncHeight) { if (isSingleScan) { return 1; } final amountLeft = scanData.chainTip - syncHeight + 1; return amountLeft; } // Initial status UI update, send how many blocks in total to scan if (shouldUpdateSyncStatus) scanData.sendPort.send(SyncResponse(syncHeight, StartingScanSyncStatus(syncHeight))); final req = ElectrumTweaksSubscribe( height: syncHeight, count: getCountToScanPerRequest(syncHeight), historicalMode: hasForcedRescanHeights, ); var _scanningStream = await scanningClient.subscribe(req); log( "initial request: height: $syncHeight, count: ${getCountToScanPerRequest(syncHeight)}", LogLevel.info, ); void endScanningSuccesfully() { if (isSingleScan) { scanData.sendPort.send(SyncResponse(syncHeight, SyncedSyncStatus())); } else { scanData.sendPort.send( SyncResponse(syncHeight, SyncedTipSyncStatus(scanData.chainTip)), ); } _scanningStream?.close(); _scanningStream = null; log( "ended: syncHeight: $syncHeight, chainTip: ${scanData.chainTip}, isSingleScan: ${isSingleScan}", LogLevel.info, ); } void listenFn(Map event, ElectrumTweaksSubscribe req) async { final response = req.onResponse(event); if (response == null || _scanningStream == null) { log( "ending: response = $response, stream = $_scanningStream", LogLevel.error, ); return; } // is success or error msg final noData = response.message != null; if (noData) { if (isSingleScan) { log("ending: noData and isSingleScan", LogLevel.info); endScanningSuccesfully(); return; } // re-subscribe to continue receiving messages, starting from the next unscanned height final nextHeight = syncHeight + 1; if (nextHeight <= scanData.chainTip) { log( "resubscribing: nextHeight: $nextHeight, count: ${getCountToScanPerRequest(nextHeight)}", LogLevel.info, ); final nextStream = scanningClient.subscribe( ElectrumTweaksSubscribe( height: nextHeight, count: getCountToScanPerRequest(nextHeight), historicalMode: hasForcedRescanHeights, ), ); if (nextStream != null) { nextStream.listen((event) => listenFn(event, req)); } else { if (shouldUpdateSyncStatus) scanData.sendPort.send( SyncResponse(scanData.height, LostConnectionSyncStatus()), ); } } log( "ending: resubscribing: nextHeight: $nextHeight, count: ${getCountToScanPerRequest(nextHeight)}", LogLevel.info, ); return; } final tweakHeight = response.block; // Continuous status UI update, send how many blocks left to scan final syncingStatus = isSingleScan ? SyncingSyncStatus(1, 0) : SyncingSyncStatus.fromHeightValues(scanData.chainTip, initialSyncHeight, tweakHeight); if (shouldUpdateSyncStatus) scanData.sendPort.send(SyncResponse(syncHeight, syncingStatus)); try { final blockTweaks = response.blockTweaks; var blockDate = DateTime.now(); bool isDateNow = true; for (final txid in blockTweaks.keys) { final tweakData = blockTweaks[txid]; final outputPubkeys = tweakData!.outputPubkeys; final tweak = tweakData.tweak; try { final addToWallet = {}; receivers.forEach((receiver) { final preparedList = outputPubkeys.keys.toList().map((e) => [e]).toList(); // NOTE: scanOutputs, from sp_scanner package, called from rust here final scanResult = scanOutputs(preparedList, tweak, receiver); if (scanResult.isEmpty) return; if (addToWallet[receiver.BSpend] == null) { addToWallet[receiver.BSpend] = scanResult; } else { addToWallet[receiver.BSpend].addAll(scanResult); } }); if (addToWallet.isEmpty) { // no results tx, continue to next tx continue; } log( "FOUND: addToWallet: ${addToWallet.length}, txid: $txid, tweak: $tweak, height: $tweakHeight", LogLevel.info, ); // Every tx in the block has the same date (the block date) // So, if blockDate exists, reuse if (isDateNow) { try { final rootURL = "https://mempool.space"; final tweakBlockHash = await ProxyWrapper() .get(clearnetUri: Uri.parse("$rootURL/api/block-height/$tweakHeight")) .timeout(Duration(seconds: 15)); final blockResponse = await ProxyWrapper() .get(clearnetUri: Uri.parse("$rootURL/api/block/${tweakBlockHash.body}")) .timeout(Duration(seconds: 15)); if (blockResponse.statusCode == 200 && blockResponse.body.isNotEmpty && jsonDecode(blockResponse.body)['timestamp'] != null) { blockDate = DateTime.fromMillisecondsSinceEpoch( int.parse(jsonDecode(blockResponse.body)['timestamp'].toString()) * 1000, ); isDateNow = false; } } catch (e, stacktrace) { printV(stacktrace); printV(e.toString()); } } // initial placeholder ElectrumTransactionInfo object to update values based on new scanned unspent(s) on the following loop final txInfo = ElectrumTransactionInfo( WalletType.bitcoin, id: txid, height: tweakHeight, amount: Money.zero(CryptoCurrency.btc), fee: Money.zero(CryptoCurrency.btc), direction: TransactionDirection.incoming, isReplaced: false, date: scanData.network == BitcoinNetwork.mainnet ? (isDateNow ? getDateByBitcoinHeight(tweakHeight) : blockDate) : DateTime.now(), confirmations: scanData.chainTip - tweakHeight + 1, isReceivedSilentPayment: true, isPending: false, unspents: [], ); List unspents = []; addToWallet.forEach((BSpend, scanResultPerLabel) { scanResultPerLabel.forEach((label, scanOutput) { final labelValue = label == "None" ? null : label.toString(); (scanOutput as Map).forEach((outputPubkey, tweak) { final t_k = tweak as String; final receivingOutputAddress = ECPublic.fromHex(outputPubkey) .toTaprootAddress(tweak: false) .toAddress(scanData.network); final matchingOutput = outputPubkeys[outputPubkey]!; final amount = matchingOutput.amount; final pos = matchingOutput.vout; final spent = matchingOutput.spendingInput; final matchingReceiver = receivers.indexWhere((receiver) => receiver.BSpend == BSpend); // final labelIndex = labelValue != null ? scanData.labels[label] : 0; // final balance = ElectrumBalance(); // balance.confirmed = amount; final receivedAddressRecord = BitcoinSilentPaymentAddressRecord( receivingOutputAddress, index: 0, isHidden: false, isUsed: true, network: scanData.network, silentPaymentTweak: t_k, type: SegwitAddresType.p2tr, txCount: 1, balance: amount, spendDerivationPath: matchingReceiver == 0 ? SILENT_PAYMENTS_SPEND_PATH : SILENT_PAYMENTS_SPEND_PATH_TESTNET, ); final unspent = BitcoinSilentPaymentsUnspent( receivedAddressRecord, txid, amount, pos, silentPaymentTweak: t_k, silentPaymentLabel: labelValue, ); if (spent == null) { unspents.add(unspent); txInfo.unspents!.add(unspent); } txInfo.amount += Money.fromInt(unspent.value, txInfo.amount.currency); }); }); }); scanData.sendPort.send({txInfo.id: txInfo}); } catch (e, stacktrace) { if (shouldUpdateSyncStatus) scanData.sendPort.send( SyncResponse(syncHeight, LostConnectionSyncStatus()), ); log(stacktrace.toString(), LogLevel.error); log(e.toString(), LogLevel.error); return; } } } catch (e, stacktrace) { if (shouldUpdateSyncStatus) scanData.sendPort.send( SyncResponse(syncHeight, LostConnectionSyncStatus()), ); log(stacktrace.toString(), LogLevel.error); log(e.toString(), LogLevel.error); return; } syncHeight = tweakHeight; if ((tweakHeight >= scanData.chainTip) || isSingleScan) { endScanningSuccesfully(); } } _scanningStream?.listen((event) => listenFn(event, req)); } if (scanData.rescanHeights != null) { for (final height in scanData.rescanHeights!) { log("rescanning from height: $height", LogLevel.info); scan(height, true); } } else { scan(scanData.height, scanData.isSingleScan); } } catch (e) { log("Error in _handleScanSilentPayments: $e", LogLevel.error); if (shouldUpdateSyncStatus) scanData.sendPort.send(SyncResponse(scanData.height, LostConnectionSyncStatus())); } } class EstimatedTxResult { EstimatedTxResult({ required this.utxos, required this.inputPrivKeyInfos, required this.publicKeys, required this.fee, required this.amount, required this.hasChange, required this.isSendAll, this.memo, required this.spendsSilentPayment, required this.spendsUnconfirmedTX, }); final List utxos; final List inputPrivKeyInfos; final Map publicKeys; // PubKey to derivationPath final Money fee; final Money amount; final bool spendsSilentPayment; // final bool sendsToSilentPayment; final bool hasChange; final bool isSendAll; final String? memo; final bool spendsUnconfirmedTX; } class PublicKeyWithDerivationPath { const PublicKeyWithDerivationPath(this.publicKey, this.derivationPath); final String derivationPath; final String publicKey; } BitcoinAddressType _getScriptType(BitcoinBaseAddress type) { if (type is P2pkhAddress) { return P2pkhAddressType.p2pkh; } else if (type is P2shAddress) { return P2shAddressType.p2wpkhInP2sh; } else if (type is P2wshAddress) { return SegwitAddresType.p2wsh; } else if (type is P2trAddress) { return SegwitAddresType.p2tr; } else if (type is MwebAddress) { return SegwitAddresType.mweb; } else if (type is SilentPaymentsAddresType) { return SilentPaymentsAddresType.p2sp; } else { return SegwitAddresType.p2wpkh; } } class UtxoDetails { final List availableInputs; final List unconfirmedCoins; final List utxos; final List vinOutpoints; final List inputPrivKeyInfos; final Map publicKeys; // PubKey to derivationPath final int allInputsAmount; final bool spendsSilentPayment; final bool spendsUnconfirmedTX; UtxoDetails({ required this.availableInputs, required this.unconfirmedCoins, required this.utxos, required this.vinOutpoints, required this.inputPrivKeyInfos, required this.publicKeys, required this.allInputsAmount, required this.spendsSilentPayment, required this.spendsUnconfirmedTX, }); } class AddressHistoryScan { AddressHistoryScan({ required this.historyByScriptHash, required this.hadFailures, }); final Map>> historyByScriptHash; final bool hadFailures; } class AddressHistoryResult { AddressHistoryResult({ required this.transactions, required this.missingHistoryItems, required this.hasHistory, required this.hadFailures, }); final Map transactions; final List> missingHistoryItems; final bool hasHistory; final bool hadFailures; }