From 47525fb301f8924ed49c69222d7df0a13b1bfa71 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 27 Dec 2024 21:37:21 -0600 Subject: [PATCH] feat: WIP BIP48 wallet type and marker interface --- lib/pages/wallet_view/wallet_view.dart | 3 +- .../interfaces/bip48_currency_interface.dart | 5 + lib/wallets/wallet/impl/bip48_wallet.dart | 989 ++++++++++++++++++ 3 files changed, 996 insertions(+), 1 deletion(-) create mode 100644 lib/wallets/crypto_currency/interfaces/bip48_currency_interface.dart create mode 100644 lib/wallets/wallet/impl/bip48_wallet.dart diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index eaf063b2d..71517dc6f 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -46,6 +46,7 @@ import '../../utilities/logger.dart'; import '../../utilities/show_loading.dart'; import '../../utilities/text_styles.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../wallets/crypto_currency/interfaces/bip48_currency_interface.dart'; import '../../wallets/crypto_currency/intermediate/frost_currency.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; @@ -1236,7 +1237,7 @@ class _WalletViewState extends ConsumerState { }, ), if (wallet.info.coin - is Bitcoin) // TODO [prio=low]: test if !isViewOnly is necessary... I think not, we should be able to make view-only shared multisig wallets. + is BIP48CurrencyInterface) // TODO [prio=low]: test if !isViewOnly is necessary... I think not, we should be able to make view-only shared multisig wallets. WalletNavigationBarItemData( label: "Make multisignature account", icon: const MultisigSetupNavIcon(), diff --git a/lib/wallets/crypto_currency/interfaces/bip48_currency_interface.dart b/lib/wallets/crypto_currency/interfaces/bip48_currency_interface.dart new file mode 100644 index 000000000..aa7da069e --- /dev/null +++ b/lib/wallets/crypto_currency/interfaces/bip48_currency_interface.dart @@ -0,0 +1,5 @@ +import '../intermediate/bip39_hd_currency.dart'; + +mixin BIP48CurrencyInterface on Bip39HDCurrency { + // This is just a marker interface. +} diff --git a/lib/wallets/wallet/impl/bip48_wallet.dart b/lib/wallets/wallet/impl/bip48_wallet.dart new file mode 100644 index 000000000..c91733729 --- /dev/null +++ b/lib/wallets/wallet/impl/bip48_wallet.dart @@ -0,0 +1,989 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:isar/isar.dart'; + +import '../../../electrumx_rpc/cached_electrumx_client.dart'; +import '../../../electrumx_rpc/electrumx_client.dart'; +import '../../../models/balance.dart'; +import '../../../models/isar/models/blockchain_data/address.dart'; +import '../../../models/isar/models/blockchain_data/transaction.dart'; +import '../../../models/isar/models/blockchain_data/utxo.dart'; +import '../../../models/isar/models/blockchain_data/v2/input_v2.dart'; +import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; +import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; +import '../../../models/paymint/fee_object_model.dart'; +import '../../../utilities/amount/amount.dart'; +import '../../../utilities/extensions/extensions.dart'; +import '../../../utilities/logger.dart'; +import '../../crypto_currency/crypto_currency.dart'; +import '../../crypto_currency/intermediate/bip39_hd_currency.dart'; +import '../../isar/models/wallet_info.dart'; +import '../../models/tx_data.dart'; +import '../wallet.dart'; +import '../wallet_mixin_interfaces/multi_address_interface.dart'; + +class BIP48Wallet extends Wallet + with MultiAddressInterface { + BIP48Wallet(CryptoCurrencyNetwork network) : super(Bitcoin(network) as T); + + late ElectrumXClient electrumXClient; + late CachedElectrumXClient electrumXCachedClient; + + Future sweepAllEstimate(int feeRate) async { + int available = 0; + int inputCount = 0; + final height = await chainHeight; + for (final output in (await mainDB.getUTXOs(walletId).findAll())) { + if (!output.isBlocked && + output.isConfirmed( + height, + cryptoCurrency.minConfirms, + cryptoCurrency.minCoinbaseConfirms, + )) { + available += output.value; + inputCount++; + } + } + + // transaction will only have 1 output minus the fee + final estimatedFee = _roughFeeEstimate(inputCount, 1, feeRate); + + return Amount( + rawValue: BigInt.from(available), + fractionDigits: cryptoCurrency.fractionDigits, + ) - + estimatedFee; + } + + // int _estimateTxFee({required int vSize, required int feeRatePerKB}) { + // return vSize * (feeRatePerKB / 1000).ceil(); + // } + + Amount _roughFeeEstimate(int inputCount, int outputCount, int feeRatePerKB) { + return Amount( + rawValue: BigInt.from( + ((42 + (272 * inputCount) + (128 * outputCount)) / 4).ceil() * + (feeRatePerKB / 1000).ceil(), + ), + fractionDigits: cryptoCurrency.fractionDigits, + ); + } + + // ==================== Overrides ============================================ + + @override + bool get supportsMultiRecipient => true; + + @override + int get isarTransactionVersion => 2; + + @override + FilterOperation? get changeAddressFilterOperation => FilterGroup.and( + [ + FilterCondition.equalTo( + property: r"type", + value: info.mainAddressType, + ), + const FilterCondition.equalTo( + property: r"subType", + value: AddressSubType.change, + ), + const FilterCondition.greaterThan( + property: r"derivationIndex", + value: 0, + ), + ], + ); + + @override + FilterOperation? get receivingAddressFilterOperation => FilterGroup.and( + [ + FilterCondition.equalTo( + property: r"type", + value: info.mainAddressType, + ), + const FilterCondition.equalTo( + property: r"subType", + value: AddressSubType.receiving, + ), + const FilterCondition.greaterThan( + property: r"derivationIndex", + value: 0, + ), + ], + ); + + @override + Future updateTransactions() async { + // Get all addresses. + final List
allAddressesOld = + await _fetchAddressesForElectrumXScan(); + + // Separate receiving and change addresses. + final Set receivingAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.receiving) + .map((e) => e.value) + .toSet(); + final Set changeAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.change) + .map((e) => e.value) + .toSet(); + + // Remove duplicates. + final allAddressesSet = {...receivingAddresses, ...changeAddresses}; + + final currentHeight = await chainHeight; + + // Fetch history from ElectrumX. + final List> allTxHashes = + await _fetchHistory(allAddressesSet); + + final List> allTransactions = []; + + for (final txHash in allTxHashes) { + final storedTx = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .txidEqualTo(txHash["tx_hash"] as String) + .findFirst(); + + if (storedTx == null || + !storedTx.isConfirmed( + currentHeight, + cryptoCurrency.minConfirms, + cryptoCurrency.minCoinbaseConfirms, + )) { + final tx = await electrumXCachedClient.getTransaction( + txHash: txHash["tx_hash"] as String, + verbose: true, + cryptoCurrency: cryptoCurrency, + ); + + if (!_duplicateTxCheck(allTransactions, tx["txid"] as String)) { + tx["height"] = txHash["height"]; + allTransactions.add(tx); + } + } + } + + // Parse all new txs. + final List txns = []; + for (final txData in allTransactions) { + bool wasSentFromThisWallet = false; + // Set to true if any inputs were detected as owned by this wallet. + + bool wasReceivedInThisWallet = false; + // Set to true if any outputs were detected as owned by this wallet. + + // Parse inputs. + BigInt amountReceivedInThisWallet = BigInt.zero; + BigInt changeAmountReceivedInThisWallet = BigInt.zero; + final List inputs = []; + for (final jsonInput in txData["vin"] as List) { + final map = Map.from(jsonInput as Map); + + final List addresses = []; + String valueStringSats = "0"; + OutpointV2? outpoint; + + final coinbase = map["coinbase"] as String?; + + if (coinbase == null) { + // Not a coinbase (ie a typical input). + final txid = map["txid"] as String; + final vout = map["vout"] as int; + + final inputTx = await electrumXCachedClient.getTransaction( + txHash: txid, + cryptoCurrency: cryptoCurrency, + ); + + final prevOutJson = Map.from( + (inputTx["vout"] as List).firstWhere((e) => e["n"] == vout) as Map, + ); + + final prevOut = OutputV2.fromElectrumXJson( + prevOutJson, + decimalPlaces: cryptoCurrency.fractionDigits, + isFullAmountNotSats: true, + walletOwns: false, // Doesn't matter here as this is not saved. + ); + + outpoint = OutpointV2.isarCantDoRequiredInDefaultConstructor( + txid: txid, + vout: vout, + ); + valueStringSats = prevOut.valueStringSats; + addresses.addAll(prevOut.addresses); + } + + InputV2 input = InputV2.fromElectrumxJson( + json: map, + outpoint: outpoint, + valueStringSats: valueStringSats, + addresses: addresses, + coinbase: coinbase, + // Need addresses before we can know if the wallet owns this input. + walletOwns: false, + ); + + // Check if input was from this wallet. + if (allAddressesSet.intersection(input.addresses.toSet()).isNotEmpty) { + wasSentFromThisWallet = true; + input = input.copyWith(walletOwns: true); + } + + inputs.add(input); + } + + // Parse outputs. + final List outputs = []; + for (final outputJson in txData["vout"] as List) { + OutputV2 output = OutputV2.fromElectrumXJson( + Map.from(outputJson as Map), + decimalPlaces: cryptoCurrency.fractionDigits, + isFullAmountNotSats: true, + // Need addresses before we can know if the wallet owns this input. + walletOwns: false, + ); + + // If output was to my wallet, add value to amount received. + if (receivingAddresses + .intersection(output.addresses.toSet()) + .isNotEmpty) { + wasReceivedInThisWallet = true; + amountReceivedInThisWallet += output.value; + output = output.copyWith(walletOwns: true); + } else if (changeAddresses + .intersection(output.addresses.toSet()) + .isNotEmpty) { + wasReceivedInThisWallet = true; + changeAmountReceivedInThisWallet += output.value; + output = output.copyWith(walletOwns: true); + } + + outputs.add(output); + } + + final totalOut = outputs + .map((e) => e.value) + .fold(BigInt.zero, (value, element) => value + element); + + TransactionType type; + TransactionSubType subType = TransactionSubType.none; + if (outputs.length > 1 && inputs.isNotEmpty) { + for (int i = 0; i < outputs.length; i++) { + final List? scriptChunks = + outputs[i].scriptPubKeyAsm?.split(" "); + if (scriptChunks?.length == 2 && scriptChunks?[0] == "OP_RETURN") { + final blindedPaymentCode = scriptChunks![1]; + final bytes = blindedPaymentCode.toUint8ListFromHex; + + // https://en.bitcoin.it/wiki/BIP_0047#Sending + if (bytes.length == 80 && bytes.first == 1) { + subType = TransactionSubType.bip47Notification; + break; + } + } + } + } + + // At least one input was owned by this wallet. + if (wasSentFromThisWallet) { + type = TransactionType.outgoing; + + if (wasReceivedInThisWallet) { + if (changeAmountReceivedInThisWallet + amountReceivedInThisWallet == + totalOut) { + // Definitely sent all to self. + type = TransactionType.sentToSelf; + } else if (amountReceivedInThisWallet == BigInt.zero) { + // Most likely just a typical send, do nothing here yet. + } + } + } else if (wasReceivedInThisWallet) { + // Only found outputs owned by this wallet. + type = TransactionType.incoming; + + // TODO: [prio=none] Check for special Bitcoin outputs like ordinals. + } else { + Logging.instance.log( + "Unexpected tx found (ignoring it): $txData", + level: LogLevel.Error, + ); + continue; + } + + final tx = TransactionV2( + walletId: walletId, + blockHash: txData["blockhash"] as String?, + hash: txData["hash"] as String, + txid: txData["txid"] as String, + height: txData["height"] as int?, + version: txData["version"] as int, + timestamp: txData["blocktime"] as int? ?? + DateTime.timestamp().millisecondsSinceEpoch ~/ 1000, + inputs: List.unmodifiable(inputs), + outputs: List.unmodifiable(outputs), + type: type, + subType: subType, + otherData: null, + ); + + txns.add(tx); + } + + await mainDB.updateOrPutTransactionV2s(txns); + } + + @override + Future checkSaveInitialReceivingAddress() async { + final address = await getCurrentReceivingAddress(); + if (address == null) { + // TODO derive address. + } + } + + @override + Future confirmSend({required TxData txData}) async { + try { + Logging.instance.log("confirmSend txData: $txData", level: LogLevel.Info); + + final hex = txData.raw!; + + final txHash = await electrumXClient.broadcastTransaction(rawTx: hex); + Logging.instance.log("Sent txHash: $txHash", level: LogLevel.Info); + + // mark utxos as used + final usedUTXOs = txData.utxos!.map((e) => e.copyWith(used: true)); + await mainDB.putUTXOs(usedUTXOs.toList()); + + txData = txData.copyWith( + utxos: usedUTXOs.toSet(), + txHash: txHash, + txid: txHash, + ); + + return txData; + } catch (e, s) { + Logging.instance.log( + "Exception rethrown from confirmSend(): $e\n$s", + level: LogLevel.Error, + ); + rethrow; + } + } + + @override + Future estimateFeeFor(Amount amount, int feeRate) async { + final available = info.cachedBalance.spendable; + + if (available == amount) { + return amount - (await sweepAllEstimate(feeRate)); + } else if (amount <= Amount.zero || amount > available) { + return _roughFeeEstimate(1, 2, feeRate); + } + + Amount runningBalance = Amount( + rawValue: BigInt.zero, + fractionDigits: cryptoCurrency.fractionDigits, + ); + int inputCount = 0; + for (final output in (await mainDB.getUTXOs(walletId).findAll())) { + if (!output.isBlocked) { + runningBalance += Amount( + rawValue: BigInt.from(output.value), + fractionDigits: cryptoCurrency.fractionDigits, + ); + 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 get fees async { + try { + // adjust numbers for different speeds? + const int f = 1, m = 5, s = 20; + + final fast = await electrumXClient.estimateFee(blocks: f); + final medium = await electrumXClient.estimateFee(blocks: m); + final slow = await electrumXClient.estimateFee(blocks: s); + + final feeObject = FeeObject( + numberOfBlocksFast: f, + numberOfBlocksAverage: m, + numberOfBlocksSlow: s, + fast: Amount.fromDecimal( + fast, + fractionDigits: cryptoCurrency.fractionDigits, + ).raw.toInt(), + medium: Amount.fromDecimal( + medium, + fractionDigits: cryptoCurrency.fractionDigits, + ).raw.toInt(), + slow: Amount.fromDecimal( + slow, + fractionDigits: cryptoCurrency.fractionDigits, + ).raw.toInt(), + ); + + Logging.instance.log("fetched fees: $feeObject", level: LogLevel.Info); + return feeObject; + } catch (e) { + Logging.instance + .log("Exception rethrown from _getFees(): $e", level: LogLevel.Error); + rethrow; + } + } + + @override + Future prepareSend({required TxData txData}) { + // TODO: implement prepareSendpu + throw UnimplementedError(); + } + + @override + Future recover({ + required bool isRescan, + String? serializedKeys, + String? multisigConfig, + }) async { + // TODO. + } + + @override + Future updateBalance() async { + final utxos = await mainDB.getUTXOs(walletId).findAll(); + + final currentChainHeight = await chainHeight; + + Amount satoshiBalanceTotal = Amount( + rawValue: BigInt.zero, + fractionDigits: cryptoCurrency.fractionDigits, + ); + Amount satoshiBalancePending = Amount( + rawValue: BigInt.zero, + fractionDigits: cryptoCurrency.fractionDigits, + ); + Amount satoshiBalanceSpendable = Amount( + rawValue: BigInt.zero, + fractionDigits: cryptoCurrency.fractionDigits, + ); + Amount satoshiBalanceBlocked = Amount( + rawValue: BigInt.zero, + fractionDigits: cryptoCurrency.fractionDigits, + ); + + for (final utxo in utxos) { + final utxoAmount = Amount( + rawValue: BigInt.from(utxo.value), + fractionDigits: cryptoCurrency.fractionDigits, + ); + + satoshiBalanceTotal += utxoAmount; + + if (utxo.isBlocked) { + satoshiBalanceBlocked += utxoAmount; + } else { + if (utxo.isConfirmed( + currentChainHeight, + cryptoCurrency.minConfirms, + cryptoCurrency.minCoinbaseConfirms, + )) { + satoshiBalanceSpendable += utxoAmount; + } else { + satoshiBalancePending += utxoAmount; + } + } + } + + final balance = Balance( + total: satoshiBalanceTotal, + spendable: satoshiBalanceSpendable, + blockedTotal: satoshiBalanceBlocked, + pendingSpendable: satoshiBalancePending, + ); + + await info.updateBalance(newBalance: balance, isar: mainDB.isar); + } + + @override + Future updateChainHeight() async { + final int height; + try { + final result = await electrumXClient.getBlockHeadTip(); + height = result["height"] as int; + } catch (e) { + rethrow; + } + + await info.updateCachedChainHeight( + newHeight: height, + isar: mainDB.isar, + ); + } + + @override + Future pingCheck() async { + try { + final result = await electrumXClient.ping(); + return result; + } catch (_) { + return false; + } + } + + @override + Future updateNode() async { + await _updateElectrumX(); + } + + @override + Future updateUTXOs() async { + final allAddresses = await _fetchAddressesForElectrumXScan(); + + try { + final fetchedUtxoList = >>[]; + for (int i = 0; i < allAddresses.length; i++) { + final scriptHash = cryptoCurrency.addressToScriptHash( + address: allAddresses[i].value, + ); + + final utxos = await electrumXClient.getUTXOs(scripthash: scriptHash); + if (utxos.isNotEmpty) { + fetchedUtxoList.add(utxos); + } + } + + 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); + } + } + + return await mainDB.updateUTXOs(walletId, outputArray); + } catch (e, s) { + Logging.instance.log( + "Output fetch unsuccessful: $e\n$s", + level: LogLevel.Error, + ); + return false; + } + } + + // =================== Private =============================================== + + Future _getCurrentElectrumXNode() async { + final node = getCurrentNode(); + + return ElectrumXNode( + address: node.host, + port: node.port, + name: node.name, + useSSL: node.useSSL, + id: node.id, + torEnabled: node.torEnabled, + clearnetEnabled: node.clearnetEnabled, + ); + } + + // TODO [prio=low]: Use ElectrumXInterface method. + Future _updateElectrumX() async { + final failovers = nodeService + .failoverNodesFor(currency: cryptoCurrency) + .map( + (e) => ElectrumXNode( + address: e.host, + port: e.port, + name: e.name, + id: e.id, + useSSL: e.useSSL, + torEnabled: e.torEnabled, + clearnetEnabled: e.clearnetEnabled, + ), + ) + .toList(); + + final newNode = await _getCurrentElectrumXNode(); + try { + await electrumXClient.closeAdapter(); + } catch (e) { + if (e.toString().contains("initialized")) { + // Ignore. This should happen every first time the wallet is opened. + } else { + Logging.instance.log( + "Error closing electrumXClient: $e", + level: LogLevel.Error, + ); + } + } + electrumXClient = ElectrumXClient.from( + node: newNode, + prefs: prefs, + failovers: failovers, + cryptoCurrency: cryptoCurrency, + ); + + electrumXCachedClient = CachedElectrumXClient.from( + electrumXClient: electrumXClient, + ); + } + + bool _duplicateTxCheck( + List> allTransactions, + String txid, + ) { + for (int i = 0; i < allTransactions.length; i++) { + if (allTransactions[i]["txid"] == txid) { + return true; + } + } + return false; + } + + Future _parseUTXO({ + required Map jsonUTXO, + }) async { + final txn = await electrumXCachedClient.getTransaction( + txHash: jsonUTXO["tx_hash"] as String, + verbose: true, + cryptoCurrency: cryptoCurrency, + ); + + 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 utxo = UTXO( + walletId: walletId, + txid: txn["txid"] as String, + vout: vout, + value: jsonUTXO["value"] as int, + name: "", + isBlocked: false, + blockedReason: null, + 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; + } + + @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(address: currentChange); + 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 _checkChangeAddressForTransactions" + "($cryptoCurrency): $e\n$s", + level: LogLevel.Error, + ); + rethrow; + } + } + + @override + Future checkReceivingAddressForTransactions() async { + if (info.otherData[WalletInfoKeys.reuseAddress] == true) { + try { + throw Exception(); + } catch (_, s) { + Logging.instance.log( + "checkReceivingAddressForTransactions called but reuse address flag set: $s", + level: LogLevel.Error, + ); + } + } + + 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(address: currentReceiving); + needsGenerate = txCount > 0 || currentReceiving.derivationIndex < 0; + } + + if (needsGenerate) { + await generateNewReceivingAddress(); + + // TODO: [prio=low] Make sure we scan all addresses but only show one. + if (info.otherData[WalletInfoKeys.reuseAddress] != true) { + // 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 generateNewChangeAddress() async { + final current = await getCurrentChangeAddress(); + const chain = 0; // TODO. + const index = 0; // TODO. + + Address? address; + while (address == null) { + try { + // TODO. + // address = await _generateAddress( + // change: chain, + // index: index, + // ); + } catch (e) { + rethrow; + } + } + + await mainDB.updateOrPutAddresses([address]); + } + + @override + Future generateNewReceivingAddress() async { + final current = await getCurrentReceivingAddress(); + // TODO: Handle null assertion below. + int index = current!.derivationIndex + 1; + const chain = 0; // receiving address + + Address? address; + while (address == null) { + try { + // TODO. + // address = await _generateAddress( + // change: chain, + // index: index, + // ); + } catch (e) { + rethrow; + } + } + + await mainDB.updateOrPutAddresses([address]); + await info.updateReceivingAddress( + newAddress: address.value, + isar: mainDB.isar, + ); + } + + Future lookAhead() async { + Address? currentReceiving = await getCurrentReceivingAddress(); + if (currentReceiving == null) { + await generateNewReceivingAddress(); + currentReceiving = await getCurrentReceivingAddress(); + } + Address? currentChange = await getCurrentChangeAddress(); + if (currentChange == null) { + await generateNewChangeAddress(); + currentChange = await getCurrentChangeAddress(); + } + + final List
nextReceivingAddresses = []; + final List
nextChangeAddresses = []; + + int receiveIndex = currentReceiving!.derivationIndex; + int changeIndex = currentChange!.derivationIndex; + for (int i = 0; i < 10; i++) { + final receiveAddress = await _generateAddressSafe( + chain: 0, + startingIndex: receiveIndex + 1, + ); + receiveIndex = receiveAddress.derivationIndex; + nextReceivingAddresses.add(receiveAddress); + + final changeAddress = await _generateAddressSafe( + chain: 1, + startingIndex: changeIndex + 1, + ); + changeIndex = changeAddress.derivationIndex; + nextChangeAddresses.add(changeAddress); + } + + int activeReceiveIndex = currentReceiving.derivationIndex; + int activeChangeIndex = currentChange.derivationIndex; + for (final address in nextReceivingAddresses) { + final txCount = await _fetchTxCount(address: address); + if (txCount > 0) { + activeReceiveIndex = max(activeReceiveIndex, address.derivationIndex); + } + } + for (final address in nextChangeAddresses) { + final txCount = await _fetchTxCount(address: address); + if (txCount > 0) { + activeChangeIndex = max(activeChangeIndex, address.derivationIndex); + } + } + + nextReceivingAddresses + .removeWhere((e) => e.derivationIndex > activeReceiveIndex); + if (nextReceivingAddresses.isNotEmpty) { + await mainDB.updateOrPutAddresses(nextReceivingAddresses); + await info.updateReceivingAddress( + newAddress: nextReceivingAddresses.last.value, + isar: mainDB.isar, + ); + } + nextChangeAddresses + .removeWhere((e) => e.derivationIndex > activeChangeIndex); + if (nextChangeAddresses.isNotEmpty) { + await mainDB.updateOrPutAddresses(nextChangeAddresses); + } + } + + Future
_generateAddressSafe({ + required final int chain, + required int startingIndex, + }) async { + Address? address; + while (address == null) { + try { + // TODO. + // address = await _generateAddress( + // change: chain, + // index: startingIndex, + // ); + } catch (e) { + rethrow; + } + } + + return address; + } + + Future _fetchTxCount({required Address address}) async { + final transactions = await electrumXClient.getHistory( + scripthash: cryptoCurrency.addressToScriptHash( + address: address.value, + ), + ); + return transactions.length; + } + + Future> _fetchAddressesForElectrumXScan() async { + final allAddresses = await mainDB + .getAddresses(walletId) + .filter() + .not() + .group( + (q) => q + .typeEqualTo(AddressType.nonWallet) + .or() + .subTypeEqualTo(AddressSubType.nonWallet), + ) + .findAll(); + return allAddresses; + } + + Future>> _fetchHistory( + Iterable allAddresses, + ) async { + try { + final List> allTxHashes = []; + for (int i = 0; i < allAddresses.length; i++) { + final addressString = allAddresses.elementAt(i); + final scriptHash = cryptoCurrency.addressToScriptHash( + address: addressString, + ); + + final response = await electrumXClient.getHistory( + scripthash: scriptHash, + ); + + for (int j = 0; j < response.length; j++) { + response[j]["address"] = addressString; + if (!allTxHashes.contains(response[j])) { + allTxHashes.add(response[j]); + } + } + } + + return allTxHashes; + } catch (e, s) { + Logging.instance.log( + "$runtimeType._fetchHistory: $e\n$s", + level: LogLevel.Error, + ); + rethrow; + } + } +}