import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:isolate'; import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:cw_bitcoin/litecoin_wallet_addresses.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:cw_core/encryption_file_utils.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_amount_format.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'; 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/litecoin_wallet.dart'; import 'package:cw_bitcoin/pending_bitcoin_transaction.dart'; import 'package:cw_bitcoin/utils.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/node.dart'; import 'package:cw_core/pathForWallet.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_coins_info.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:cw_core/get_height_by_date.dart'; import 'package:flutter/foundation.dart'; import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; import 'package:rxdart/subjects.dart'; import 'package:sp_scanner/sp_scanner.dart'; import 'package:hex/hex.dart'; import 'package:http/http.dart' as http; 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 Box unspentCoinsInfo, required this.network, required this.encryptionFileUtils, String? xpub, String? mnemonic, Uint8List? seedBytes, this.passphrase, List? initialAddresses, ElectrumClient? electrumClient, ElectrumBalance? initialBalance, CryptoCurrency? currency, this.alwaysScan, }) : accountHD = getAccountHDWallet(currency, network, seedBytes, xpub, walletInfo.derivationInfo), syncStatus = NotConnectedSyncStatus(), _password = password, _feeRates = [], _isTransactionUpdating = false, isEnabledAutoGenerateSubaddress = true, unspentCoins = [], _scripthashesUpdateSubject = {}, balance = ObservableMap.of(currency != null ? { currency: initialBalance ?? ElectrumBalance( confirmed: 0, unconfirmed: 0, frozen: 0, ) } : {}), this.unspentCoinsInfo = unspentCoinsInfo, this.isTestnet = !network.isMainnet, this._mnemonic = mnemonic, super(walletInfo) { this.electrumClient = electrumClient ?? ElectrumClient(); this.walletInfo = walletInfo; transactionHistory = ElectrumTransactionHistory( walletInfo: walletInfo, password: password, encryptionFileUtils: encryptionFileUtils, ); reaction((_) => syncStatus, _syncStatusReaction); sharedPrefs.complete(SharedPreferences.getInstance()); } static Bip32Slip10Secp256k1 getAccountHDWallet(CryptoCurrency? currency, BasedUtxoNetwork network, Uint8List? seedBytes, String? xpub, DerivationInfo? derivationInfo) { 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: return Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath( _hardenedDerivationPath(derivationInfo?.derivationPath ?? electrum_path)) as Bip32Slip10Secp256k1; case CryptoCurrency.bch: return bitcoinCashHDWallet(seedBytes); default: throw Exception("Unsupported currency"); } } return Bip32Slip10Secp256k1.fromExtendedKey(xpub!); } static Bip32Slip10Secp256k1 bitcoinCashHDWallet(Uint8List seedBytes) => Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath("m/44'/145'/0'") as Bip32Slip10Secp256k1; static int estimatedTransactionSize(int inputsCount, int outputsCounts) => inputsCount * 68 + outputsCounts * 34 + 10; bool? alwaysScan; final Bip32Slip10Secp256k1 accountHD; final String? _mnemonic; Bip32Slip10Secp256k1 get hd => accountHD.childKey(Bip32KeyIndex(0)); Bip32Slip10Secp256k1 get sideHd => accountHD.childKey(Bip32KeyIndex(1)); final EncryptionFileUtils encryptionFileUtils; @override final String? passphrase; @override @observable bool isEnabledAutoGenerateSubaddress; late ElectrumClient electrumClient; Box unspentCoinsInfo; @override late ElectrumWalletAddresses walletAddresses; @override @observable late ObservableMap balance; @override @observable SyncStatus syncStatus; Set get addressesSet => walletAddresses.allAddresses.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; @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; bool get hasSilentPaymentsScanning => type == WalletType.bitcoin; @observable bool nodeSupportsSilentPayments = true; @observable bool silentPaymentsScanningActive = false; bool _isTryingToConnect = false; 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) { syncStatus = SyncedSyncStatus(); } else { syncStatus = NotConnectedSyncStatus(); } } } int? _currentChainTip; Future getCurrentChainTip() async { if (_currentChainTip != null) { 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; } @override BitcoinWalletKeys get keys => BitcoinWalletKeys( wif: WifEncoder.encode(hd.privateKey.raw, netVer: network.wifNetVer), privateKey: hd.privateKey.toHex(), publicKey: hd.publicKey.toHex(), ); String _password; List unspentCoins; List _feeRates; // ignore: prefer_final_fields Map?> _scripthashesUpdateSubject; // ignore: prefer_final_fields BehaviorSubject? _chainTipUpdateSubject; bool _isTransactionUpdating; Future? _isolate; void Function(FlutterErrorDetails)? _onError; Timer? _autoSaveTimer; StreamSubscription? _receiveStream; Timer? _updateFeeRateTimer; static const int _autoSaveInterval = 1; Future init() async { await walletAddresses.init(); await transactionHistory.init(); await save(); _autoSaveTimer = Timer.periodic(Duration(minutes: _autoSaveInterval), (_) async => await save()); } @action Future _setListeners(int height, {int? chainTipParam, bool? doSingleScan}) async { final chainTip = chainTipParam ?? await getUpdatedChainTip(); if (chainTip == height) { syncStatus = SyncedSyncStatus(); return; } syncStatus = AttemptingScanSyncStatus(); if (_isolate != null) { final runningIsolate = await _isolate!; runningIsolate.kill(priority: Isolate.immediate); } final receivePort = ReceivePort(); _isolate = Isolate.spawn( startRefresh, ScanData( sendPort: receivePort.sendPort, silentAddress: walletAddresses.silentAddress!, network: network, height: height, chainTip: chainTip, electrumClient: 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, )); _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; 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 += newAmount; } // Updates existing TX transactionHistory.addOne(existingTxInfo); // Update balance record balance[currency]!.confirmed += newAmount; } } 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 updateAllUnspents(); } } } if (message is SyncResponse) { if (message.syncStatus is UnsupportedSyncStatus) { nodeSupportsSilentPayments = false; } 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, network: network, ); final addressRecord = walletAddresses.silentAddresses .firstWhereOrNull((address) => address.address == silentPaymentAddress.toString()); addressRecord?.txCount += 1; addressRecord?.balance += unspent.value; walletAddresses.addSilentAddresses( [unspent.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord], ); } @action @override Future startSync() async { try { if (syncStatus is SyncronizingSyncStatus) { return; } syncStatus = SyncronizingSyncStatus(); if (hasSilentPaymentsScanning) { await _setInitialHeight(); } await subscribeForUpdates(); await updateTransactions(); await updateAllUnspents(); await updateBalance(); await updateFeeRates(); _updateFeeRateTimer ??= Timer.periodic(const Duration(minutes: 1), (timer) async => await updateFeeRates()); if (alwaysScan == true) { _setListeners(walletInfo.restoreHeight); } else { syncStatus = SyncedSyncStatus(); } } catch (e, stacktrace) { print(stacktrace); print(e.toString()); syncStatus = FailedSyncStatus(); } } @action Future updateFeeRates() async { if (await checkIfMempoolAPIIsEnabled()) { try { final response = await http.get(Uri.parse("http://mempool.cakewallet.com:8999/api/v1/fees/recommended")); final result = json.decode(response.body) as Map; final slowFee = result['economyFee']?.toInt() ?? 0; final mediumFee = result['hourFee']?.toInt() ?? 0; final fastFee = result['fastestFee']?.toInt() ?? 0; _feeRates = [slowFee, mediumFee, fastFee]; return; } catch (_) {} } final feeRates = await electrumClient.feeRates(network: network); if (feeRates != [0, 0, 0]) { _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; node!.save(); return node!.isElectrs!; } } node!.isElectrs = false; node!.save(); 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 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; try { syncStatus = ConnectingSyncStatus(); await _receiveStream?.cancel(); await electrumClient.close(); electrumClient.onConnectionStatusChange = _onConnectionStatusChange; await electrumClient.connectToUri(node.uri, useSSL: node.useSSL); } catch (e, stacktrace) { print(stacktrace); print(e.toString()); syncStatus = FailedSyncStatus(); } } int get _dustAmount => 546; bool _isBelowDust(int amount) => amount <= _dustAmount && network != BitcoinNetwork.testnet; UtxoDetails _createUTXOS({ required bool sendAll, required int credentialsAmount, required bool paysToSilentPayment, int? inputsCount, }) { List utxos = []; List vinOutpoints = []; List inputPrivKeyInfos = []; final publicKeys = {}; int allInputsAmount = 0; bool spendsSilentPayment = false; bool spendsUnconfirmedTX = false; int leftAmount = credentialsAmount; final availableInputs = unspentCoins.where((utx) => utx.isSending && !utx.isFrozen).toList(); final unconfirmedCoins = availableInputs.where((utx) => utx.confirmations == 0).toList(); 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 = utx.bitcoinAddressRecord.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd; if (utx.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) { final unspentAddress = utx.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord; privkey = walletAddresses.silentAddress!.b_spend.tweakAdd( BigintUtils.fromBytes( BytesUtils.fromHexString(unspentAddress.silentPaymentTweak!), ), ); spendsSilentPayment = true; isSilentPayment = true; } else if (!isHardwareWallet) { 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 derivationPath = "${_hardenedDerivationPath(walletInfo.derivationInfo?.derivationPath ?? electrum_path)}" "/${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, int credentialsAmount = 0, bool hasSilentPayment = false, }) async { final utxoDetails = _createUTXOS( sendAll: true, credentialsAmount: credentialsAmount, paysToSilentPayment: hasSilentPayment, ); 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 int amount = utxoDetails.allInputsAmount - fee; if (amount <= 0) { throw BitcoinTransactionWrongBalanceException(amount: utxoDetails.allInputsAmount + fee); } if (amount <= 0) { throw BitcoinTransactionWrongBalanceException(); } // Attempting to send less than the dust limit if (_isBelowDust(amount)) { throw BitcoinTransactionNoDustException(); } if (credentialsAmount > 0) { final amountLeftForFee = amount - credentialsAmount; if (amountLeftForFee > 0 && _isBelowDust(amountLeftForFee)) { amount -= amountLeftForFee; fee += amountLeftForFee; } } if (outputs.length == 1) { outputs[0] = BitcoinOutput(address: outputs.last.address, value: BigInt.from(amount)); } return EstimatedTxResult( utxos: utxoDetails.utxos, inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, publicKeys: utxoDetails.publicKeys, fee: fee, amount: amount, isSendAll: true, hasChange: false, memo: memo, spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, spendsSilentPayment: utxoDetails.spendsSilentPayment, ); } Future estimateTxForAmount( int credentialsAmount, List outputs, int feeRate, { int? inputsCount, String? memo, bool? useUnconfirmed, bool hasSilentPayment = false, }) async { final utxoDetails = _createUTXOS( sendAll: false, credentialsAmount: credentialsAmount, inputsCount: inputsCount, paysToSilentPayment: hasSilentPayment, ); final spendingAllCoins = utxoDetails.availableInputs.length == utxoDetails.utxos.length; final spendingAllConfirmedCoins = !utxoDetails.spendsUnconfirmedTX && utxoDetails.utxos.length == utxoDetails.availableInputs.length - utxoDetails.unconfirmedCoins.length; // How much is being spent - how much is being sent int amountLeftForChangeAndFee = utxoDetails.allInputsAmount - credentialsAmount; if (amountLeftForChangeAndFee <= 0) { if (!spendingAllCoins) { return estimateTxForAmount( credentialsAmount, outputs, feeRate, inputsCount: utxoDetails.utxos.length + 1, memo: memo, hasSilentPayment: hasSilentPayment, ); } throw BitcoinTransactionWrongBalanceException(); } final changeAddress = await walletAddresses.getChangeAddress( outputs: outputs, utxoDetails: utxoDetails, ); final address = RegexUtils.addressTypeFromStr(changeAddress, network); outputs.add(BitcoinOutput( address: address, value: BigInt.from(amountLeftForChangeAndFee), isChange: true, )); int fee = await calcFee( utxos: utxoDetails.utxos, outputs: outputs, network: network, memo: memo, feeRate: feeRate, ); if (fee == 0) { throw BitcoinTransactionNoFeeException(); } int amount = credentialsAmount; final lastOutput = outputs.last; final amountLeftForChange = amountLeftForChangeAndFee - fee; print(amountLeftForChangeAndFee); if (!_isBelowDust(amountLeftForChange)) { // Here, lastOutput already is change, return the amount left without the fee to the user's address. outputs[outputs.length - 1] = BitcoinOutput( address: lastOutput.address, value: BigInt.from(amountLeftForChange), isSilentPayment: lastOutput.isSilentPayment, isChange: true, ); } else { // If has change that is lower than dust, will end up with tx rejected by network rules, so estimate again without the added change outputs.removeLast(); // Still has inputs to spend before failing if (!spendingAllCoins) { return estimateTxForAmount( credentialsAmount, outputs, feeRate, inputsCount: utxoDetails.utxos.length + 1, memo: memo, useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins, ); } final estimatedSendAll = await estimateSendAllTx( outputs, feeRate, memo: memo, ); if (estimatedSendAll.amount == credentialsAmount) { return estimatedSendAll; } // Estimate to user how much is needed to send to cover the fee final maxAmountWithReturningChange = utxoDetails.allInputsAmount - _dustAmount - fee - 1; throw BitcoinTransactionNoDustOnChangeException( bitcoinAmountToString(amount: maxAmountWithReturningChange), bitcoinAmountToString(amount: estimatedSendAll.amount), ); } // Attempting to send less than the dust limit if (_isBelowDust(amount)) { throw BitcoinTransactionNoDustException(); } final totalAmount = amount + fee; if (totalAmount > (balance[currency]!.confirmed + balance[currency]!.secondConfirmed)) { throw BitcoinTransactionWrongBalanceException(); } if (totalAmount > utxoDetails.allInputsAmount) { if (spendingAllCoins) { throw BitcoinTransactionWrongBalanceException(); } else { outputs.removeLast(); return estimateTxForAmount( credentialsAmount, outputs, feeRate, inputsCount: utxoDetails.utxos.length + 1, memo: memo, useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins, hasSilentPayment: hasSilentPayment, ); } } return EstimatedTxResult( utxos: utxoDetails.utxos, inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, publicKeys: utxoDetails.publicKeys, fee: fee, amount: amount, hasChange: true, isSendAll: false, memo: memo, spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, spendsSilentPayment: utxoDetails.spendsSilentPayment, ); } 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 { 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; int credentialsAmount = 0; bool hasSilentPayment = false; for (final out in transactionCredentials.outputs) { final outputAmount = out.formattedCryptoAmount!; if (!sendAll && _isBelowDust(outputAmount)) { 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.from(0), isSilentPayment: isSilentPayment, )); } else { outputs.add(BitcoinOutput( address: address, value: BigInt.from(outputAmount), isSilentPayment: isSilentPayment, )); } } final feeRateInt = transactionCredentials.feeRate != null ? transactionCredentials.feeRate! : feeRate(transactionCredentials.priority!); EstimatedTxResult estimatedTx; if (sendAll) { estimatedTx = await estimateSendAllTx( outputs, feeRateInt, memo: memo, credentialsAmount: credentialsAmount, hasSilentPayment: hasSilentPayment, ); } else { estimatedTx = await estimateTxForAmount( credentialsAmount, outputs, feeRateInt, memo: memo, hasSilentPayment: hasSilentPayment, ); } if (walletInfo.isHardwareWallet) { final transaction = await buildHardwareWalletTransaction( utxos: estimatedTx.utxos, outputs: outputs, publicKeys: estimatedTx.publicKeys, fee: BigInt.from(estimatedTx.fee), network: network, memo: estimatedTx.memo, outputOrdering: BitcoinOrdering.none, enableRBF: true, ); return PendingBitcoinTransaction( transaction, type, 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 )..addListener((transaction) async { transactionHistory.addOne(transaction); await updateBalance(); }); } BasedBitcoinTransacationBuilder txb; if (network is BitcoinCashNetwork) { txb = ForkedTransactionBuilder( utxos: estimatedTx.utxos, outputs: outputs, fee: BigInt.from(estimatedTx.fee), network: network, memo: estimatedTx.memo, outputOrdering: BitcoinOrdering.none, enableRBF: !estimatedTx.spendsUnconfirmedTX, ); } else { txb = BitcoinTransactionBuilder( utxos: estimatedTx.utxos, outputs: outputs, fee: BigInt.from(estimatedTx.fee), network: network, memo: estimatedTx.memo, outputOrdering: BitcoinOrdering.none, enableRBF: !estimatedTx.spendsUnconfirmedTX, ); } bool hasTaprootInputs = false; final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) { 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, electrumClient: electrumClient, amount: estimatedTx.amount, fee: estimatedTx.fee, feeRate: feeRateInt.toString(), network: network, hasChange: estimatedTx.hasChange, isSendAll: estimatedTx.isSendAll, hasTaprootInputs: hasTaprootInputs, )..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 updateBalance(); }); } catch (e) { throw e; } } Future buildHardwareWalletTransaction({ required List outputs, required BigInt fee, required BasedUtxoNetwork network, required List utxos, 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(), 'derivationTypeIndex': walletInfo.derivationInfo?.derivationType?.index, 'derivationPath': walletInfo.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(), }); 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 unspentCoins) { if (totalValue >= amount) { break; } if (input.isSending) { totalValue += input.value; inputsCount += 1; } } if (totalValue < amount) return 0; } else { for (final input in unspentCoins) { 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 renameWalletFiles(String newWalletName) async { final currentWalletPath = await pathForWallet(name: walletInfo.name, type: type); final currentWalletFile = File(currentWalletPath); final currentDirPath = await pathForWalletDir(name: walletInfo.name, type: type); final currentTransactionsFile = File('$currentDirPath/$transactionsHistoryFileName'); // Copies current wallet files into new wallet name's dir and files if (currentWalletFile.existsSync()) { final newWalletPath = await pathForWallet(name: newWalletName, type: type); await currentWalletFile.copy(newWalletPath); } if (currentTransactionsFile.existsSync()) { final newDirPath = await pathForWalletDir(name: newWalletName, type: type); await currentTransactionsFile.copy('$newDirPath/$transactionsHistoryFileName'); } // Delete old name's dir and files await Directory(currentDirPath).delete(recursive: true); } @override Future changePassword(String password) async { _password = password; await save(); await transactionHistory.changePassword(password); } @action @override Future rescan({required int height, bool? doSingleScan}) async { silentPaymentsScanningActive = true; _setListeners(height, doSingleScan: doSingleScan); } @override Future close() async { try { await _receiveStream?.cancel(); await electrumClient.close(); } catch (_) {} _autoSaveTimer?.cancel(); _updateFeeRateTimer?.cancel(); } @action Future updateAllUnspents() async { List updatedUnspentCoins = []; if (hasSilentPaymentsScanning) { // Update unspents stored from scanned silent payment transactions transactionHistory.transactions.values.forEach((tx) { if (tx.unspents != null) { updatedUnspentCoins.addAll(tx.unspents!); } }); } await Future.wait(walletAddresses.allAddresses.map((address) async { updatedUnspentCoins.addAll(await fetchUnspent(address)); })); unspentCoins = updatedUnspentCoins; if (unspentCoinsInfo.length != updatedUnspentCoins.length) { unspentCoins.forEach((coin) => addCoinInfo(coin)); return; } await updateCoins(unspentCoins); await _refreshUnspentCoinsInfo(); } 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) 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> unspents = []; List updatedUnspentCoins = []; try { unspents = await electrumClient.getListUnspent(address.getScriptHash(network)); } catch (e, s) { print(e); print(s); return []; } 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; updatedUnspentCoins.add(coin); } catch (_) {} })); return updatedUnspentCoins; } @action Future addCoinInfo(BitcoinUnspent coin) async { 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((element) => element.walletId.contains(id)); if (currentWalletUnspentCoins.isNotEmpty) { currentWalletUnspentCoins.forEach((element) { final existUnspentCoins = unspentCoins .where((coin) => element.hash.contains(coin.hash) && element.vout == coin.vout); if (existUnspentCoins.isEmpty) { keys.add(element.key); } }); } if (keys.isNotEmpty) { await unspentCoinsInfo.deleteAll(keys); } } catch (e) { print(e.toString()); } } 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 changeAddresses = walletAddresses.allAddresses.where((element) => element.isHidden); // look for a change address in the outputs final changeOutput = outputs.firstWhereOrNull((output) => changeAddresses.any( (element) => element.address == addressFromOutputScript(output.scriptPubKey, network))); var allInputsAmount = 0; for (int i = 0; i < bundle.originalTransaction.inputs.length; i++) { final input = bundle.originalTransaction.inputs[i]; final inputTransaction = bundle.ins[i]; final vout = input.txIndex; final outTransaction = inputTransaction.outputs[vout]; allInputsAmount += outTransaction.amount.toInt(); } int totalOutAmount = bundle.originalTransaction.outputs .fold(0, (previousValue, element) => previousValue + element.amount.toInt()); var currentFee = allInputsAmount - totalOutAmount; int remainingFee = (newFee - currentFee > 0) ? newFee - currentFee : newFee; return changeOutput != null && changeOutput.amount.toInt() - remainingFee >= 0; } Future replaceByFee(String hash, int newFee) async { try { final bundle = await getTransactionExpanded(hash: hash); final utxos = []; List privateKeys = []; var allInputsAmount = 0; String? memo; // Add inputs for (var i = 0; i < bundle.originalTransaction.inputs.length; i++) { final input = bundle.originalTransaction.inputs[i]; final inputTransaction = bundle.ins[i]; 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 privkey = generateECPrivate( hd: addressRecord.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd, 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), ), ); } // Create a list of available outputs final outputs = []; for (final out in bundle.originalTransaction.outputs) { // Check if the script contains OP_RETURN 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; int remainingFee = newFee - currentFee; if (remainingFee <= 0) { throw Exception("New fee must be higher than the current fee."); } // Deduct Remaining Fee from Main Outputs if (remainingFee > 0) { for (int i = outputs.length - 1; i >= 0; i--) { int outputAmount = outputs[i].value.toInt(); if (outputAmount > _dustAmount) { int deduction = (outputAmount - _dustAmount >= remainingFee) ? remainingFee : outputAmount - _dustAmount; outputs[i] = BitcoinOutput( address: outputs[i].address, value: BigInt.from(outputAmount - deduction)); remainingFee -= deduction; if (remainingFee <= 0) break; } } } // Final check if the remaining fee couldn't be deducted if (remainingFee > 0) { 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, outputOrdering: BitcoinOrdering.none, enableRBF: true, ); 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, electrumClient: electrumClient, amount: sendingAmount, fee: newFee, network: network, hasChange: changeOutputs.isNotEmpty, feeRate: newFee.toString(), )..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 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 && await checkIfMempoolAPIIsEnabled()) { final blockHash = await http.get( Uri.parse( "http://mempool.cakewallet.com:8999/api/v1/block-height/$height", ), ); if (blockHash.statusCode == 200 && blockHash.body.isNotEmpty && jsonDecode(blockHash.body) != null) { final blockResponse = await http.get( Uri.parse( "http://mempool.cakewallet.com:8999/api/v1/block/${blockHash.body}", ), ); if (blockResponse.statusCode == 200 && blockResponse.body.isNotEmpty && jsonDecode(blockResponse.body)['timestamp'] != null) { time = int.parse(jsonDecode(blockResponse.body)['timestamp'].toString()); } } } } 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) { final verboseTransaction = await electrumClient.getTransactionVerbose(hash: vin.txId); final String inputTransactionHex; if (verboseTransaction.isEmpty) { inputTransactionHex = await electrumClient.getTransactionHex(hash: hash); } else { inputTransactionHex = verboseTransaction['hex'] as String; } ins.add(BtcTransaction.fromRaw(inputTransactionHex)); } return ElectrumTransactionBundle( original, ins: ins, time: time, confirmations: confirmations ?? 0, ); } Future fetchTransactionInfo( {required String hash, int? height, bool? retryOnFailure}) async { try { return ElectrumTransactionInfo.fromElectrumBundle( await getTransactionExpanded(hash: hash, height: height), walletInfo.type, network, addresses: addressesSet, height: height, ); } catch (e) { if (e is FormatException && retryOnFailure == true) { await Future.delayed(const Duration(seconds: 2)); return fetchTransactionInfo(hash: hash, height: height); } return null; } } @override Future> fetchTransactions() async { try { final Map historiesWithDetails = {}; if (type == WalletType.bitcoin) { await Future.wait(BITCOIN_ADDRESS_TYPES .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type))); } else if (type == WalletType.bitcoinCash) { await Future.wait(BITCOIN_CASH_ADDRESS_TYPES .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type))); } else if (type == WalletType.litecoin) { await Future.wait(LITECOIN_ADDRESS_TYPES .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type))); } transactionHistory.transactions.values.forEach((tx) async { final isPendingSilentPaymentUtxo = (tx.isPending || tx.confirmations == 0) && historiesWithDetails[tx.id] == null; 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) { print(e.toString()); return {}; } } Future fetchTransactionsForAddressType( Map historiesWithDetails, BitcoinAddressType type, ) async { final addressesByType = walletAddresses.allAddresses.where((addr) => addr.type == type); final hiddenAddresses = addressesByType.where((addr) => addr.isHidden == true); final receiveAddresses = addressesByType.where((addr) => addr.isHidden == false); await Future.wait(addressesByType.map((addressRecord) async { final history = await _fetchAddressHistory(addressRecord, await getCurrentChainTip()); if (history.isNotEmpty) { addressRecord.txCount = history.length; historiesWithDetails.addAll(history); final matchedAddresses = addressRecord.isHidden ? hiddenAddresses : receiveAddresses; final isUsedAddressUnderGap = matchedAddresses.toList().indexOf(addressRecord) >= matchedAddresses.length - (addressRecord.isHidden ? ElectrumWalletAddressesBase.defaultChangeAddressesCount : ElectrumWalletAddressesBase.defaultReceiveAddressesCount); if (isUsedAddressUnderGap) { final prevLength = walletAddresses.allAddresses.length; // Discover new addresses for the same address type until the gap limit is respected await walletAddresses.discoverAddresses( matchedAddresses.toList(), addressRecord.isHidden, (address) async { await subscribeForUpdates(); return _fetchAddressHistory(address, await getCurrentChainTip()) .then((history) => history.isNotEmpty ? address.address : null); }, type: type, ); final newLength = walletAddresses.allAddresses.length; if (newLength > prevLength) { await fetchTransactionsForAddressType(historiesWithDetails, type); } } } })); } Future> _fetchAddressHistory( BitcoinAddressRecord addressRecord, int? currentHeight) async { try { final Map historiesWithDetails = {}; final history = await electrumClient.getHistory(addressRecord.getScriptHash(network)); if (history.isNotEmpty) { addressRecord.setAsUsed(); await Future.wait(history.map((transaction) async { final txid = transaction['tx_hash'] as String; final height = transaction['height'] as int; final storedTx = transactionHistory.transactions[txid]; if (storedTx != null) { if (height > 0) { storedTx.height = height; // the tx's block itself is the first confirmation so add 1 if (currentHeight != null) storedTx.confirmations = currentHeight - height + 1; storedTx.isPending = storedTx.confirmations == 0; } historiesWithDetails[txid] = storedTx; } else { final tx = await fetchTransactionInfo(hash: txid, height: height, retryOnFailure: true); if (tx != null) { historiesWithDetails[txid] = tx; // Got a new transaction fetched, add it to the transaction history // instead of waiting all to finish, and next time it will be faster transactionHistory.addOne(tx); await transactionHistory.save(); } } return Future.value(null); })); } return historiesWithDetails; } catch (e) { print(e.toString()); return {}; } } Future updateTransactions() async { try { if (_isTransactionUpdating) { return; } await getCurrentChainTip(); transactionHistory.transactions.values.forEach((tx) async { if (tx.unspents != null && tx.unspents!.isNotEmpty && tx.height != null && tx.height! > 0) { tx.confirmations = await getCurrentChainTip() - tx.height! + 1; } }); _isTransactionUpdating = true; await fetchTransactions(); walletAddresses.updateReceiveAddresses(); _isTransactionUpdating = false; } catch (e, stacktrace) { print(stacktrace); print(e); _isTransactionUpdating = false; } } Future subscribeForUpdates() async { final unsubscribedScriptHashes = walletAddresses.allAddresses.where( (address) => !_scripthashesUpdateSubject.containsKey(address.getScriptHash(network)), ); await Future.wait(unsubscribedScriptHashes.map((address) async { final sh = address.getScriptHash(network); await _scripthashesUpdateSubject[sh]?.close(); _scripthashesUpdateSubject[sh] = await electrumClient.scripthashUpdate(sh); _scripthashesUpdateSubject[sh]?.listen((event) async { try { await updateUnspentsForAddress(address); await updateBalance(); await _fetchAddressHistory(address, await getCurrentChainTip()); } catch (e, s) { print(e.toString()); _onError?.call(FlutterErrorDetails( exception: e, stack: s, library: this.runtimeType.toString(), )); } }); })); } Future fetchBalances() async { final addresses = walletAddresses.allAddresses .where((address) => RegexUtils.addressTypeFromStr(address.address, network) is! MwebAddress) .toList(); final balanceFutures = >>[]; for (var i = 0; i < addresses.length; i++) { final addressRecord = addresses[i]; final sh = addressRecord.getScriptHash(network); final balanceFuture = electrumClient.getBalance(sh); balanceFutures.add(balanceFuture); } var totalFrozen = 0; var totalConfirmed = 0; var totalUnconfirmed = 0; 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) { if (unspent.isFrozen) totalFrozen += unspent.value; totalConfirmed += unspent.value; } }); } }); } final balances = await Future.wait(balanceFutures); 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; addressRecord.balance = confirmed + unconfirmed; if (confirmed > 0 || unconfirmed > 0) { addressRecord.setAsUsed(); } } return ElectrumBalance( confirmed: totalConfirmed, unconfirmed: totalUnconfirmed, frozen: totalFrozen, ); } Future updateBalance() async { balance[currency] = await fetchBalances(); await save(); } @override void setExceptionHandler(void Function(FlutterErrorDetails) onError) => _onError = onError; @override Future signMessage(String message, {String? address = null}) async { final index = address != null ? walletAddresses.allAddresses.firstWhere((element) => element.address == address).index : null; final HD = index == null ? hd : hd.childKey(Bip32KeyIndex(index)); 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); } @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(ConnectionStatus status) { switch (status) { case ConnectionStatus.connected: if (syncStatus is NotConnectedSyncStatus || syncStatus is LostConnectionSyncStatus || syncStatus is ConnectingSyncStatus) { syncStatus = AttemptingSyncStatus(); startSync(); } break; case ConnectionStatus.disconnected: syncStatus = NotConnectedSyncStatus(); break; case ConnectionStatus.failed: syncStatus = LostConnectionSyncStatus(); break; case ConnectionStatus.connecting: syncStatus = ConnectingSyncStatus(); break; default: } } void _syncStatusReaction(SyncStatus syncStatus) async { 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]; 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); } } } class ScanNode { final Uri uri; final bool? useSSL; ScanNode(this.uri, this.useSSL); } class ScanData { final SendPort sendPort; final SilentPaymentOwner silentAddress; final int height; final ScanNode? node; final BasedUtxoNetwork network; final int chainTip; final ElectrumClient electrumClient; final List transactionHistoryIds; final Map labels; final List labelIndexes; final bool isSingleScan; ScanData({ required this.sendPort, required this.silentAddress, 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, }); factory ScanData.fromHeight(ScanData scanData, int newHeight) { return ScanData( sendPort: scanData.sendPort, silentAddress: scanData.silentAddress, 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, ); } } class SyncResponse { final int height; final SyncStatus syncStatus; SyncResponse(this.height, this.syncStatus); } Future startRefresh(ScanData scanData) async { int syncHeight = scanData.height; int initialSyncHeight = syncHeight; BehaviorSubject? tweaksSubscription = null; final electrumClient = scanData.electrumClient; await electrumClient.connectToUri( scanData.node?.uri ?? Uri.parse("tcp://electrs.cakewallet.com:50001"), useSSL: scanData.node?.useSSL ?? false, ); int getCountPerRequest(int syncHeight) { if (scanData.isSingleScan) { return 1; } final amountLeft = scanData.chainTip - syncHeight + 1; return amountLeft; } if (tweaksSubscription == null) { final receiver = Receiver( scanData.silentAddress.b_scan.toHex(), scanData.silentAddress.B_spend.toHex(), scanData.network == BitcoinNetwork.testnet, scanData.labelIndexes, scanData.labelIndexes.length, ); // Initial status UI update, send how many blocks in total to scan final initialCount = getCountPerRequest(syncHeight); scanData.sendPort.send(SyncResponse(syncHeight, StartingScanSyncStatus(syncHeight))); tweaksSubscription = await electrumClient.tweaksSubscribe( height: syncHeight, count: initialCount, ); Future listenFn(t) async { final tweaks = t as Map; final msg = tweaks["message"]; // success or error msg final noData = msg != null; if (noData) { // re-subscribe to continue receiving messages, starting from the next unscanned height final nextHeight = syncHeight + 1; final nextCount = getCountPerRequest(nextHeight); if (nextCount > 0) { tweaksSubscription?.close(); final nextTweaksSubscription = electrumClient.tweaksSubscribe( height: nextHeight, count: nextCount, ); nextTweaksSubscription?.listen(listenFn); } return; } // Continuous status UI update, send how many blocks left to scan final syncingStatus = scanData.isSingleScan ? SyncingSyncStatus(1, 0) : SyncingSyncStatus.fromHeightValues(scanData.chainTip, initialSyncHeight, syncHeight); scanData.sendPort.send(SyncResponse(syncHeight, syncingStatus)); final blockHeight = tweaks.keys.first; final tweakHeight = int.parse(blockHeight); try { final blockTweaks = tweaks[blockHeight] as Map; for (var j = 0; j < blockTweaks.keys.length; j++) { final txid = blockTweaks.keys.elementAt(j); final details = blockTweaks[txid] as Map; final outputPubkeys = (details["output_pubkeys"] as Map); final tweak = details["tweak"].toString(); try { // scanOutputs called from rust here final addToWallet = scanOutputs( outputPubkeys.values.toList(), tweak, receiver, ); if (addToWallet.isEmpty) { // no results tx, continue to next tx continue; } // placeholder ElectrumTransactionInfo object to update values based on new scanned unspent(s) final txInfo = ElectrumTransactionInfo( WalletType.bitcoin, id: txid, height: tweakHeight, amount: 0, fee: 0, direction: TransactionDirection.incoming, isPending: false, isReplaced: false, date: scanData.network == BitcoinNetwork.mainnet ? getDateByBitcoinHeight(tweakHeight) : DateTime.now(), confirmations: scanData.chainTip - tweakHeight + 1, unspents: [], isReceivedSilentPayment: true, ); addToWallet.forEach((label, value) { (value as Map).forEach((output, tweak) { final t_k = tweak.toString(); final receivingOutputAddress = ECPublic.fromHex(output) .toTaprootAddress(tweak: false) .toAddress(scanData.network); int? amount; int? pos; outputPubkeys.entries.firstWhere((k) { final isMatchingOutput = k.value[0] == output; if (isMatchingOutput) { amount = int.parse(k.value[1].toString()); pos = int.parse(k.key.toString()); return true; } return false; }); final receivedAddressRecord = BitcoinSilentPaymentAddressRecord( receivingOutputAddress, index: 0, isHidden: false, isUsed: true, network: scanData.network, silentPaymentTweak: t_k, type: SegwitAddresType.p2tr, txCount: 1, balance: amount!, ); final unspent = BitcoinSilentPaymentsUnspent( receivedAddressRecord, txid, amount!, pos!, silentPaymentTweak: t_k, silentPaymentLabel: label == "None" ? null : label, ); txInfo.unspents!.add(unspent); txInfo.amount += unspent.value; }); }); scanData.sendPort.send({txInfo.id: txInfo}); } catch (_) {} } } catch (_) {} syncHeight = tweakHeight; if (tweakHeight >= scanData.chainTip || scanData.isSingleScan) { if (tweakHeight >= scanData.chainTip) scanData.sendPort.send(SyncResponse( syncHeight, SyncedTipSyncStatus(scanData.chainTip), )); if (scanData.isSingleScan) { scanData.sendPort.send(SyncResponse(syncHeight, SyncedSyncStatus())); } await tweaksSubscription!.close(); await electrumClient.close(); } } tweaksSubscription?.listen(listenFn); } if (tweaksSubscription == null) { return scanData.sendPort.send( SyncResponse(syncHeight, UnsupportedSyncStatus()), ); } } 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 int fee; final int 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, }); }