import 'dart:convert'; import 'dart:math'; import 'package:bip47/src/util.dart'; import 'package:coinlib_flutter/coinlib_flutter.dart' as coinlib; import 'package:decimal/decimal.dart'; import 'package:isar/isar.dart'; import 'package:stackwallet/electrumx_rpc/cached_electrumx.dart'; import 'package:stackwallet/electrumx_rpc/electrumx.dart'; import 'package:stackwallet/models/isar/models/isar_models.dart'; import 'package:stackwallet/models/paymint/fee_object_model.dart'; import 'package:stackwallet/services/mixins/paynym_wallet_interface.dart'; import 'package:stackwallet/utilities/amount/amount.dart'; import 'package:stackwallet/utilities/enums/coin_enum.dart'; import 'package:stackwallet/utilities/enums/derive_path_type_enum.dart'; import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/wallets/wallet/intermediate/bip39_hd_wallet.dart'; import 'package:uuid/uuid.dart'; mixin ElectrumXMixin on Bip39HDWallet { late ElectrumX electrumX; late CachedElectrumX electrumXCached; Future fetchChainHeight() async { try { final result = await electrumX.getBlockHeadTip(); return result["height"] as int; } catch (e) { rethrow; } } Future fetchTxCount({required String addressScriptHash}) async { final transactions = await electrumX.getHistory(scripthash: addressScriptHash); return transactions.length; } Future> fetchTxCountBatched({ required Map addresses, }) async { try { final Map> args = {}; for (final entry in addresses.entries) { args[entry.key] = [ cryptoCurrency.addressToScriptHash(address: entry.value), ]; } final response = await electrumX.getBatchHistory(args: args); final Map result = {}; for (final entry in response.entries) { result[entry.key] = entry.value.length; } return result; } catch (e, s) { Logging.instance.log( "Exception rethrown in _getBatchTxCount(address: $addresses: $e\n$s", level: LogLevel.Error); rethrow; } } Future> fetchTransactionsV1({ required List
addresses, required int currentChainHeight, }) async { final List<({String txHash, int height, String address})> allTxHashes = (await fetchHistory(addresses.map((e) => e.value).toList())) .map( (e) => ( txHash: e["tx_hash"] as String, height: e["height"] as int, address: e["address"] as String, ), ) .toList(); List> allTransactions = []; for (final data in allTxHashes) { final tx = await electrumXCached.getTransaction( txHash: data.txHash, verbose: true, coin: cryptoCurrency.coin, ); // check for duplicates before adding to list if (allTransactions .indexWhere((e) => e["txid"] == tx["txid"] as String) == -1) { tx["address"] = addresses.firstWhere((e) => e.value == data.address); tx["height"] = data.height; allTransactions.add(tx); } } final List<({Transaction transaction, Address address})> txnsData = []; for (final txObject in allTransactions) { final data = await _parseTransactionV1( txObject, addresses, ); txnsData.add(data); } return txnsData; } Future getCurrentElectrumXNode() async { final node = getCurrentNode(); return ElectrumXNode( address: node.host, port: node.port, name: node.name, useSSL: node.useSSL, id: node.id, ); } Future updateElectrumX({required ElectrumXNode newNode}) async { final failovers = nodeService .failoverNodesFor(coin: cryptoCurrency.coin) .map((e) => ElectrumXNode( address: e.host, port: e.port, name: e.name, id: e.id, useSSL: e.useSSL, )) .toList(); final newNode = await getCurrentElectrumXNode(); electrumX = ElectrumX.from( node: newNode, prefs: prefs, failovers: failovers, ); electrumXCached = CachedElectrumX.from( electrumXClient: electrumX, ); } //============================================================================ Future<({List
addresses, int index})> checkGaps( int txCountBatchSize, coinlib.HDPrivateKey root, DerivePathType type, int chain, ) async { List
addressArray = []; int gapCounter = 0; int highestIndexWithHistory = 0; for (int index = 0; index < cryptoCurrency.maxNumberOfIndexesToCheck && gapCounter < cryptoCurrency.maxUnusedAddressGap; index += txCountBatchSize) { List iterationsAddressArray = []; Logging.instance.log( "index: $index, \t GapCounter $chain ${type.name}: $gapCounter", level: LogLevel.Info); final _id = "k_$index"; Map txCountCallArgs = {}; for (int j = 0; j < txCountBatchSize; j++) { final derivePath = cryptoCurrency.constructDerivePath( derivePathType: type, chain: chain, index: index + j, ); final keys = root.derivePath(derivePath); final addressData = cryptoCurrency.getAddressForPublicKey( publicKey: keys.publicKey, derivePathType: type, ); final address = Address( walletId: walletId, value: addressData.address.toString(), publicKey: keys.publicKey.data, type: addressData.addressType, derivationIndex: index + j, derivationPath: DerivationPath()..value = derivePath, subType: chain == 0 ? AddressSubType.receiving : AddressSubType.change, ); addressArray.add(address); txCountCallArgs.addAll({ "${_id}_$j": addressData.address.toString(), }); } // get address tx counts final counts = await fetchTxCountBatched(addresses: txCountCallArgs); // check and add appropriate addresses for (int k = 0; k < txCountBatchSize; k++) { int count = counts["${_id}_$k"]!; if (count > 0) { iterationsAddressArray.add(txCountCallArgs["${_id}_$k"]!); // update highest highestIndexWithHistory = index + k; // reset counter gapCounter = 0; } // increase counter when no tx history found if (count == 0) { gapCounter++; } } // // cache all the transactions while waiting for the current function to finish. // unawaited(getTransactionCacheEarly(addressArray)); } return (index: highestIndexWithHistory, addresses: addressArray); } Future>> fetchHistory( Iterable allAddresses, ) async { try { List> allTxHashes = []; final Map>> batches = {}; final Map requestIdToAddressMap = {}; const batchSizeMax = 100; int batchNumber = 0; for (int i = 0; i < allAddresses.length; i++) { if (batches[batchNumber] == null) { batches[batchNumber] = {}; } final scriptHash = cryptoCurrency.addressToScriptHash( address: allAddresses.elementAt(i), ); final id = Logger.isTestEnv ? "$i" : const Uuid().v1(); requestIdToAddressMap[id] = allAddresses.elementAt(i); batches[batchNumber]!.addAll({ id: [scriptHash] }); if (i % batchSizeMax == batchSizeMax - 1) { batchNumber++; } } for (int i = 0; i < batches.length; i++) { final response = await electrumX.getBatchHistory(args: batches[i]!); for (final entry in response.entries) { for (int j = 0; j < entry.value.length; j++) { entry.value[j]["address"] = requestIdToAddressMap[entry.key]; if (!allTxHashes.contains(entry.value[j])) { allTxHashes.add(entry.value[j]); } } } } return allTxHashes; } catch (e, s) { Logging.instance.log("_fetchHistory: $e\n$s", level: LogLevel.Error); rethrow; } } /// The optional (nullable) param [checkBlock] is a callback that can be used /// to check if a utxo should be marked as blocked Future parseUTXO({ required Map jsonUTXO, }) async { final txn = await electrumXCached.getTransaction( txHash: jsonUTXO["tx_hash"] as String, verbose: true, coin: cryptoCurrency.coin, ); final vout = jsonUTXO["tx_pos"] as int; final outputs = txn["vout"] as List; String? scriptPubKey; String? utxoOwnerAddress; // get UTXO owner address for (final output in outputs) { if (output["n"] == vout) { scriptPubKey = output["scriptPubKey"]?["hex"] as String?; utxoOwnerAddress = output["scriptPubKey"]?["addresses"]?[0] as String? ?? output["scriptPubKey"]?["address"] as String?; } } final checkBlockResult = checkBlockUTXO(jsonUTXO, scriptPubKey, txn); final utxo = UTXO( walletId: walletId, txid: txn["txid"] as String, vout: vout, value: jsonUTXO["value"] as int, name: "", isBlocked: checkBlockResult.blocked, blockedReason: checkBlockResult.blockedReason, isCoinbase: txn["is_coinbase"] as bool? ?? false, blockHash: txn["blockhash"] as String?, blockHeight: jsonUTXO["height"] as int?, blockTime: txn["blocktime"] as int?, address: utxoOwnerAddress, ); return utxo; } Future<({Transaction transaction, Address address})> _parseTransactionV1( Map txData, List
myAddresses, ) async { Set receivingAddresses = myAddresses .where((e) => e.subType == AddressSubType.receiving || e.subType == AddressSubType.paynymReceive || e.subType == AddressSubType.paynymNotification) .map((e) => e.value) .toSet(); Set changeAddresses = myAddresses .where((e) => e.subType == AddressSubType.change) .map((e) => e.value) .toSet(); Set inputAddresses = {}; Set outputAddresses = {}; Amount totalInputValue = Amount( rawValue: BigInt.zero, fractionDigits: cryptoCurrency.coin.decimals, ); Amount totalOutputValue = Amount( rawValue: BigInt.zero, fractionDigits: cryptoCurrency.coin.decimals, ); Amount amountSentFromWallet = Amount( rawValue: BigInt.zero, fractionDigits: cryptoCurrency.coin.decimals, ); Amount amountReceivedInWallet = Amount( rawValue: BigInt.zero, fractionDigits: cryptoCurrency.coin.decimals, ); Amount changeAmount = Amount( rawValue: BigInt.zero, fractionDigits: cryptoCurrency.coin.decimals, ); // parse inputs for (final input in txData["vin"] as List) { final prevTxid = input["txid"] as String; final prevOut = input["vout"] as int; // fetch input tx to get address final inputTx = await electrumXCached.getTransaction( txHash: prevTxid, coin: cryptoCurrency.coin, ); for (final output in inputTx["vout"] as List) { // check matching output if (prevOut == output["n"]) { // get value final value = Amount.fromDecimal( Decimal.parse(output["value"].toString()), fractionDigits: cryptoCurrency.coin.decimals, ); // add value to total totalInputValue += value; // get input(prevOut) address final address = output["scriptPubKey"]?["addresses"]?[0] as String? ?? output["scriptPubKey"]?["address"] as String?; if (address != null) { inputAddresses.add(address); // if input was from my wallet, add value to amount sent if (receivingAddresses.contains(address) || changeAddresses.contains(address)) { amountSentFromWallet += value; } } } } } // parse outputs for (final output in txData["vout"] as List) { // get value final value = Amount.fromDecimal( Decimal.parse(output["value"].toString()), fractionDigits: cryptoCurrency.coin.decimals, ); // add value to total totalOutputValue += value; // get output address final address = output["scriptPubKey"]?["addresses"]?[0] as String? ?? output["scriptPubKey"]?["address"] as String?; if (address != null) { outputAddresses.add(address); // if output was to my wallet, add value to amount received if (receivingAddresses.contains(address)) { amountReceivedInWallet += value; } else if (changeAddresses.contains(address)) { changeAmount += value; } } } final mySentFromAddresses = [ ...receivingAddresses.intersection(inputAddresses), ...changeAddresses.intersection(inputAddresses) ]; final myReceivedOnAddresses = receivingAddresses.intersection(outputAddresses); final myChangeReceivedOnAddresses = changeAddresses.intersection(outputAddresses); final fee = totalInputValue - totalOutputValue; // this is the address initially used to fetch the txid Address transactionAddress = txData["address"] as Address; TransactionType type; Amount amount; if (mySentFromAddresses.isNotEmpty && myReceivedOnAddresses.isNotEmpty) { // tx is sent to self type = TransactionType.sentToSelf; // should be 0 amount = amountSentFromWallet - amountReceivedInWallet - fee - changeAmount; } else if (mySentFromAddresses.isNotEmpty) { // outgoing tx type = TransactionType.outgoing; amount = amountSentFromWallet - changeAmount - fee; // non wallet addresses found in tx outputs final nonWalletOutAddresses = outputAddresses.difference( myChangeReceivedOnAddresses, ); if (nonWalletOutAddresses.isNotEmpty) { final possible = nonWalletOutAddresses.first; if (transactionAddress.value != possible) { transactionAddress = Address( walletId: myAddresses.first.walletId, value: possible, derivationIndex: -1, derivationPath: null, subType: AddressSubType.nonWallet, type: AddressType.nonWallet, publicKey: [], ); } } else { // some other type of tx where the receiving address is // one of my change addresses type = TransactionType.sentToSelf; amount = changeAmount; } } else { // incoming tx type = TransactionType.incoming; amount = amountReceivedInWallet; } List outs = []; List ins = []; for (final json in txData["vin"] as List) { bool isCoinBase = json['coinbase'] != null; String? witness; if (json['witness'] != null && json['witness'] is String) { witness = json['witness'] as String; } else if (json['txinwitness'] != null) { if (json['txinwitness'] is List) { witness = jsonEncode(json['txinwitness']); } } final input = Input( txid: json['txid'] as String, vout: json['vout'] as int? ?? -1, scriptSig: json['scriptSig']?['hex'] as String?, scriptSigAsm: json['scriptSig']?['asm'] as String?, isCoinbase: isCoinBase ? isCoinBase : json['is_coinbase'] as bool?, sequence: json['sequence'] as int?, innerRedeemScriptAsm: json['innerRedeemscriptAsm'] as String?, witness: witness, ); ins.add(input); } for (final json in txData["vout"] as List) { final output = Output( scriptPubKey: json['scriptPubKey']?['hex'] as String?, scriptPubKeyAsm: json['scriptPubKey']?['asm'] as String?, scriptPubKeyType: json['scriptPubKey']?['type'] as String?, scriptPubKeyAddress: json["scriptPubKey"]?["addresses"]?[0] as String? ?? json['scriptPubKey']?['type'] as String? ?? "", value: Amount.fromDecimal( Decimal.parse(json["value"].toString()), fractionDigits: cryptoCurrency.coin.decimals, ).raw.toInt(), ); outs.add(output); } TransactionSubType txSubType = TransactionSubType.none; if (this is PaynymWalletInterface && outs.length > 1 && ins.isNotEmpty) { for (int i = 0; i < outs.length; i++) { List? scriptChunks = outs[i].scriptPubKeyAsm?.split(" "); if (scriptChunks?.length == 2 && scriptChunks?[0] == "OP_RETURN") { final blindedPaymentCode = scriptChunks![1]; final bytes = blindedPaymentCode.fromHex; // https://en.bitcoin.it/wiki/BIP_0047#Sending if (bytes.length == 80 && bytes.first == 1) { txSubType = TransactionSubType.bip47Notification; } } } } final tx = Transaction( walletId: myAddresses.first.walletId, txid: txData["txid"] as String, timestamp: txData["blocktime"] as int? ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000), type: type, subType: txSubType, // amount may overflow. Deprecated. Use amountString amount: amount.raw.toInt(), amountString: amount.toJsonString(), fee: fee.raw.toInt(), height: txData["height"] as int?, isCancelled: false, isLelantus: false, slateId: null, otherData: null, nonce: null, inputs: ins, outputs: outs, numberOfMessages: null, ); return (transaction: tx, address: transactionAddress); } //============================================================================ @override Future updateChainHeight() async { final height = await fetchChainHeight(); await info.updateCachedChainHeight( newHeight: height, isar: mainDB.isar, ); } @override Future pingCheck() async { try { final result = await electrumX.ping(); return result; } catch (_) { return false; } } @override Future updateNode() async { final node = await getCurrentElectrumXNode(); await updateElectrumX(newNode: node); } FeeObject? _cachedFees; @override Future get fees async { try { const int f = 1, m = 5, s = 20; final fast = await electrumX.estimateFee(blocks: f); final medium = await electrumX.estimateFee(blocks: m); final slow = await electrumX.estimateFee(blocks: s); final feeObject = FeeObject( numberOfBlocksFast: f, numberOfBlocksAverage: m, numberOfBlocksSlow: s, fast: Amount.fromDecimal( fast, fractionDigits: info.coin.decimals, ).raw.toInt(), medium: Amount.fromDecimal( medium, fractionDigits: info.coin.decimals, ).raw.toInt(), slow: Amount.fromDecimal( slow, fractionDigits: info.coin.decimals, ).raw.toInt(), ); Logging.instance.log("fetched fees: $feeObject", level: LogLevel.Info); _cachedFees = feeObject; return _cachedFees!; } catch (e) { Logging.instance.log( "Exception rethrown from _getFees(): $e", level: LogLevel.Error, ); if (_cachedFees == null) { rethrow; } else { return _cachedFees!; } } } @override Future estimateFeeFor(Amount amount, int feeRate) async { final available = info.cachedBalance.spendable; final utxos = _spendableUTXOs(await mainDB.getUTXOs(walletId).findAll()); if (available == amount) { return amount - (await _sweepAllEstimate(feeRate, utxos)); } else if (amount <= Amount.zero || amount > available) { return roughFeeEstimate(1, 2, feeRate); } Amount runningBalance = Amount( rawValue: BigInt.zero, fractionDigits: info.coin.decimals, ); int inputCount = 0; for (final output in utxos) { if (!output.isBlocked) { runningBalance += Amount( rawValue: BigInt.from(output.value), fractionDigits: info.coin.decimals, ); inputCount++; if (runningBalance > amount) { break; } } } final oneOutPutFee = roughFeeEstimate(inputCount, 1, feeRate); final twoOutPutFee = roughFeeEstimate(inputCount, 2, feeRate); if (runningBalance - amount > oneOutPutFee) { if (runningBalance - amount > oneOutPutFee + cryptoCurrency.dustLimit) { final change = runningBalance - amount - twoOutPutFee; if (change > cryptoCurrency.dustLimit && runningBalance - amount - change == twoOutPutFee) { return runningBalance - amount - change; } else { return runningBalance - amount; } } else { return runningBalance - amount; } } else if (runningBalance - amount == oneOutPutFee) { return oneOutPutFee; } else { return twoOutPutFee; } } @override Future checkReceivingAddressForTransactions() async { try { final currentReceiving = await getCurrentReceivingAddress(); final bool needsGenerate; if (currentReceiving == null) { // no addresses in db yet for some reason. // Should not happen at this point... needsGenerate = true; } else { final txCount = await fetchTxCount( addressScriptHash: currentReceiving.value, ); needsGenerate = txCount > 0 || currentReceiving.derivationIndex < 0; } if (needsGenerate) { await generateNewReceivingAddress(); // TODO: get rid of this? Could cause problems (long loading/infinite loop or something) // keep checking until address with no tx history is set as current await checkReceivingAddressForTransactions(); } } catch (e, s) { Logging.instance.log( "Exception rethrown from _checkReceivingAddressForTransactions" "($cryptoCurrency): $e\n$s", level: LogLevel.Error, ); rethrow; } } @override Future checkChangeAddressForTransactions() async { try { final currentChange = await getCurrentChangeAddress(); final bool needsGenerate; if (currentChange == null) { // no addresses in db yet for some reason. // Should not happen at this point... needsGenerate = true; } else { final txCount = await fetchTxCount( addressScriptHash: currentChange.value, ); needsGenerate = txCount > 0 || currentChange.derivationIndex < 0; } if (needsGenerate) { await generateNewChangeAddress(); // TODO: get rid of this? Could cause problems (long loading/infinite loop or something) // keep checking until address with no tx history is set as current await checkChangeAddressForTransactions(); } } catch (e, s) { Logging.instance.log( "Exception rethrown from _checkReceivingAddressForTransactions" "($cryptoCurrency): $e\n$s", level: LogLevel.Error, ); rethrow; } } @override Future recover({required bool isRescan}) async { final root = await getRootHDNode(); final List addresses})>> receiveFutures = []; final List addresses})>> changeFutures = []; const receiveChain = 0; const changeChain = 1; // actual size is 24 due to p2pkh and p2sh so 12x2 const txCountBatchSize = 12; try { await refreshMutex.protect(() async { if (isRescan) { // clear cache await electrumXCached.clearSharedTransactionCache(coin: info.coin); // clear blockchain info await mainDB.deleteWalletBlockchainData(walletId); } // receiving addresses Logging.instance.log( "checking receiving addresses...", level: LogLevel.Info, ); for (final type in cryptoCurrency.supportedDerivationPathTypes) { receiveFutures.add( checkGaps( txCountBatchSize, root, type, receiveChain, ), ); } // change addresses Logging.instance.log( "checking change addresses...", level: LogLevel.Info, ); for (final type in cryptoCurrency.supportedDerivationPathTypes) { changeFutures.add( checkGaps( txCountBatchSize, root, type, changeChain, ), ); } // io limitations may require running these linearly instead final futuresResult = await Future.wait([ Future.wait(receiveFutures), Future.wait(changeFutures), ]); final receiveResults = futuresResult[0]; final changeResults = futuresResult[1]; final List
addressesToStore = []; int highestReceivingIndexWithHistory = 0; for (final tuple in receiveResults) { if (tuple.addresses.isEmpty) { await checkReceivingAddressForTransactions(); } else { highestReceivingIndexWithHistory = max( tuple.index, highestReceivingIndexWithHistory, ); addressesToStore.addAll(tuple.addresses); } } int highestChangeIndexWithHistory = 0; // If restoring a wallet that never sent any funds with change, then set changeArray // manually. If we didn't do this, it'd store an empty array. for (final tuple in changeResults) { if (tuple.addresses.isEmpty) { await checkChangeAddressForTransactions(); } else { highestChangeIndexWithHistory = max( tuple.index, highestChangeIndexWithHistory, ); addressesToStore.addAll(tuple.addresses); } } // remove extra addresses to help minimize risk of creating a large gap addressesToStore.removeWhere((e) => e.subType == AddressSubType.change && e.derivationIndex > highestChangeIndexWithHistory); addressesToStore.removeWhere((e) => e.subType == AddressSubType.receiving && e.derivationIndex > highestReceivingIndexWithHistory); await mainDB.updateOrPutAddresses(addressesToStore); }); await refresh(); } catch (e, s) { Logging.instance.log( "Exception rethrown from electrumx_mixin recover(): $e\n$s", level: LogLevel.Info); rethrow; } } @override Future updateUTXOs() async { final allAddresses = await fetchAllOwnAddresses(); try { final fetchedUtxoList = >>[]; final Map>> batches = {}; const batchSizeMax = 10; int batchNumber = 0; for (int i = 0; i < allAddresses.length; i++) { if (batches[batchNumber] == null) { batches[batchNumber] = {}; } final scriptHash = cryptoCurrency.addressToScriptHash( address: allAddresses[i].value, ); batches[batchNumber]!.addAll({ scriptHash: [scriptHash] }); if (i % batchSizeMax == batchSizeMax - 1) { batchNumber++; } } for (int i = 0; i < batches.length; i++) { final response = await electrumX.getBatchUTXOs(args: batches[i]!); for (final entry in response.entries) { if (entry.value.isNotEmpty) { fetchedUtxoList.add(entry.value); } } } final List outputArray = []; for (int i = 0; i < fetchedUtxoList.length; i++) { for (int j = 0; j < fetchedUtxoList[i].length; j++) { final utxo = await parseUTXO( jsonUTXO: fetchedUtxoList[i][j], ); outputArray.add(utxo); } } await mainDB.updateUTXOs(walletId, outputArray); } catch (e, s) { Logging.instance.log( "Output fetch unsuccessful: $e\n$s", level: LogLevel.Error, ); } } // =========================================================================== // ========== Interface functions ============================================ Amount roughFeeEstimate(int inputCount, int outputCount, int feeRatePerKB); Future> fetchAllOwnAddresses(); /// Certain coins need to check if the utxo should be marked /// as blocked as well as give a reason. ({String? blockedReason, bool blocked}) checkBlockUTXO( Map jsonUTXO, String? scriptPubKeyHex, Map jsonTX, ); // =========================================================================== // ========== private helpers ================================================ List _spendableUTXOs(List utxos) { return utxos .where( (e) => !e.isBlocked && e.isConfirmed( info.cachedChainHeight, cryptoCurrency.minConfirms, ), ) .toList(); } Future _sweepAllEstimate(int feeRate, List usableUTXOs) async { final available = usableUTXOs .map((e) => BigInt.from(e.value)) .fold(BigInt.zero, (p, e) => p + e); final inputCount = usableUTXOs.length; // transaction will only have 1 output minus the fee final estimatedFee = roughFeeEstimate(inputCount, 1, feeRate); return Amount( rawValue: available, fractionDigits: info.coin.decimals, ) - estimatedFee; } // =========================================================================== }