import 'dart:async'; import 'dart:convert'; import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart'; import 'package:cw_bitcoin/electrum_worker/methods/methods.dart'; import 'package:cw_bitcoin/exceptions.dart'; import 'package:cw_bitcoin/pending_bitcoin_transaction.dart'; import 'package:cw_bitcoin/psbt_transaction_builder.dart'; import 'package:cw_bitcoin/bitcoin_unspent.dart'; import 'package:cw_bitcoin/electrum_transaction_info.dart'; import 'package:cw_bitcoin/electrum_wallet_addresses.dart'; import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_bitcoin/electrum_derivations.dart'; import 'package:cw_bitcoin/bitcoin_wallet_addresses.dart'; import 'package:cw_bitcoin/electrum_balance.dart'; import 'package:cw_bitcoin/electrum_wallet.dart'; import 'package:cw_bitcoin/electrum_wallet_snapshot.dart'; import 'package:cw_core/crypto_currency.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/unspent_coin_type.dart'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_keys_file.dart'; import 'package:hive/hive.dart'; import 'package:ledger_bitcoin/ledger_bitcoin.dart'; import 'package:ledger_flutter_plus/ledger_flutter_plus.dart'; import 'package:mobx/mobx.dart'; part 'bitcoin_wallet.g.dart'; class BitcoinWallet = BitcoinWalletBase with _$BitcoinWallet; abstract class BitcoinWalletBase extends ElectrumWallet with Store { @observable bool nodeSupportsSilentPayments = true; @observable bool silentPaymentsScanningActive = false; @observable bool allowedToSwitchNodesForScanning = false; BitcoinWalletBase({ required String password, required WalletInfo walletInfo, required Box<UnspentCoinsInfo> unspentCoinsInfo, required EncryptionFileUtils encryptionFileUtils, List<int>? seedBytes, String? mnemonic, String? xpub, String? addressPageType, BasedUtxoNetwork? networkParam, List<BitcoinAddressRecord>? initialAddresses, ElectrumBalance? initialBalance, Map<String, int>? initialRegularAddressIndex, Map<String, int>? initialChangeAddressIndex, String? passphrase, List<BitcoinSilentPaymentAddressRecord>? initialSilentAddresses, int initialSilentAddressIndex = 0, bool? alwaysScan, required bool mempoolAPIEnabled, super.hdWallets, super.initialUnspentCoins, }) : super( mnemonic: mnemonic, passphrase: passphrase, xpub: xpub, password: password, walletInfo: walletInfo, unspentCoinsInfo: unspentCoinsInfo, network: networkParam == null ? BitcoinNetwork.mainnet : networkParam == BitcoinNetwork.mainnet ? BitcoinNetwork.mainnet : BitcoinNetwork.testnet, initialAddresses: initialAddresses, initialBalance: initialBalance, seedBytes: seedBytes, encryptionFileUtils: encryptionFileUtils, currency: networkParam == BitcoinNetwork.testnet ? CryptoCurrency.tbtc : CryptoCurrency.btc, alwaysScan: alwaysScan, mempoolAPIEnabled: mempoolAPIEnabled, ) { walletAddresses = BitcoinWalletAddresses( walletInfo, initialAddresses: initialAddresses, initialSilentAddresses: initialSilentAddresses, network: networkParam ?? network, isHardwareWallet: walletInfo.isHardwareWallet, hdWallets: hdWallets, ); autorun((_) { this.walletAddresses.isEnabledAutoGenerateSubaddress = this.isEnabledAutoGenerateSubaddress; }); } static Future<BitcoinWallet> create({ required String mnemonic, required String password, required WalletInfo walletInfo, required Box<UnspentCoinsInfo> unspentCoinsInfo, required EncryptionFileUtils encryptionFileUtils, String? passphrase, String? addressPageType, BasedUtxoNetwork? network, List<BitcoinAddressRecord>? initialAddresses, List<BitcoinSilentPaymentAddressRecord>? initialSilentAddresses, ElectrumBalance? initialBalance, Map<String, int>? initialRegularAddressIndex, Map<String, int>? initialChangeAddressIndex, int initialSilentAddressIndex = 0, required bool mempoolAPIEnabled, }) async { List<int>? seedBytes = null; final Map<CWBitcoinDerivationType, Bip32Slip10Secp256k1> hdWallets = {}; if (walletInfo.isRecovery) { for (final derivation in walletInfo.derivations ?? <DerivationInfo>[]) { if (derivation.description?.contains("SP") ?? false) { continue; } if (derivation.derivationType == DerivationType.bip39) { seedBytes = Bip39SeedGenerator.generateFromString(mnemonic, passphrase); hdWallets[CWBitcoinDerivationType.bip39] = Bip32Slip10Secp256k1.fromSeed(seedBytes); break; } else { try { seedBytes = ElectrumV2SeedGenerator.generateFromString(mnemonic, passphrase); hdWallets[CWBitcoinDerivationType.electrum] = Bip32Slip10Secp256k1.fromSeed(seedBytes); } catch (e) { printV("electrum_v2 seed error: $e"); try { seedBytes = ElectrumV1SeedGenerator(mnemonic).generate(); hdWallets[CWBitcoinDerivationType.electrum] = Bip32Slip10Secp256k1.fromSeed(seedBytes); } catch (e) { printV("electrum_v1 seed error: $e"); } } break; } } if (hdWallets[CWBitcoinDerivationType.bip39] != null) { hdWallets[CWBitcoinDerivationType.old_bip39] = hdWallets[CWBitcoinDerivationType.bip39]!; } if (hdWallets[CWBitcoinDerivationType.electrum] != null) { hdWallets[CWBitcoinDerivationType.old_electrum] = hdWallets[CWBitcoinDerivationType.electrum]!; } } else { switch (walletInfo.derivationInfo?.derivationType) { case DerivationType.bip39: seedBytes = await Bip39SeedGenerator.generateFromString(mnemonic, passphrase); hdWallets[CWBitcoinDerivationType.bip39] = Bip32Slip10Secp256k1.fromSeed(seedBytes); break; case DerivationType.electrum: default: seedBytes = await ElectrumV2SeedGenerator.generateFromString(mnemonic, passphrase); hdWallets[CWBitcoinDerivationType.electrum] = Bip32Slip10Secp256k1.fromSeed(seedBytes); break; } } return BitcoinWallet( mnemonic: mnemonic, passphrase: passphrase ?? "", password: password, walletInfo: walletInfo, unspentCoinsInfo: unspentCoinsInfo, initialAddresses: initialAddresses, initialSilentAddresses: initialSilentAddresses, initialSilentAddressIndex: initialSilentAddressIndex, initialBalance: initialBalance, encryptionFileUtils: encryptionFileUtils, seedBytes: seedBytes, hdWallets: hdWallets, initialRegularAddressIndex: initialRegularAddressIndex, initialChangeAddressIndex: initialChangeAddressIndex, addressPageType: addressPageType, networkParam: network, mempoolAPIEnabled: mempoolAPIEnabled, initialUnspentCoins: [], ); } static Future<BitcoinWallet> open({ required String name, required WalletInfo walletInfo, required Box<UnspentCoinsInfo> unspentCoinsInfo, required String password, required EncryptionFileUtils encryptionFileUtils, required bool alwaysScan, required bool mempoolAPIEnabled, }) async { final network = walletInfo.network != null ? BasedUtxoNetwork.fromName(walletInfo.network!) : BitcoinNetwork.mainnet; final hasKeysFile = await WalletKeysFile.hasKeysFile(name, walletInfo.type); ElectrumWalletSnapshot? snp = null; try { snp = await ElectrumWalletSnapshot.load( encryptionFileUtils, name, walletInfo.type, password, network, ); } catch (e) { if (!hasKeysFile) rethrow; } final WalletKeysData keysData; // Migrate wallet from the old scheme to then new .keys file scheme if (!hasKeysFile) { keysData = WalletKeysData( mnemonic: snp!.mnemonic, xPub: snp.xpub, passphrase: snp.passphrase, ); } else { keysData = await WalletKeysFile.readKeysFile( name, walletInfo.type, password, encryptionFileUtils, ); } walletInfo.derivationInfo ??= DerivationInfo(); // set the default if not present: walletInfo.derivationInfo!.derivationPath ??= snp?.derivationPath ?? electrum_path; walletInfo.derivationInfo!.derivationType ??= snp?.derivationType ?? DerivationType.electrum; List<int>? seedBytes = null; final Map<CWBitcoinDerivationType, Bip32Slip10Secp256k1> hdWallets = {}; final mnemonic = keysData.mnemonic; final passphrase = keysData.passphrase; if (mnemonic != null) { final derivations = walletInfo.derivations ?? <DerivationInfo>[]; for (final derivation in derivations) { if (derivation.description?.contains("SP") ?? false) { continue; } if (derivation.derivationType == DerivationType.bip39) { seedBytes = Bip39SeedGenerator.generateFromString(mnemonic, passphrase); hdWallets[CWBitcoinDerivationType.bip39] = Bip32Slip10Secp256k1.fromSeed(seedBytes); break; } else { try { seedBytes = ElectrumV2SeedGenerator.generateFromString(mnemonic, passphrase); hdWallets[CWBitcoinDerivationType.electrum] = Bip32Slip10Secp256k1.fromSeed(seedBytes); } catch (e) { printV("electrum_v2 seed error: $e"); try { seedBytes = ElectrumV1SeedGenerator(mnemonic).generate(); hdWallets[CWBitcoinDerivationType.electrum] = Bip32Slip10Secp256k1.fromSeed(seedBytes); } catch (e) { printV("electrum_v1 seed error: $e"); } } break; } } if (hdWallets[CWBitcoinDerivationType.bip39] != null) { hdWallets[CWBitcoinDerivationType.old_bip39] = hdWallets[CWBitcoinDerivationType.bip39]!; } if (hdWallets[CWBitcoinDerivationType.electrum] != null) { hdWallets[CWBitcoinDerivationType.old_electrum] = hdWallets[CWBitcoinDerivationType.electrum]!; } if (derivations.isEmpty) { switch (walletInfo.derivationInfo?.derivationType) { case DerivationType.bip39: seedBytes = await Bip39SeedGenerator.generateFromString(mnemonic, passphrase); hdWallets[CWBitcoinDerivationType.bip39] = Bip32Slip10Secp256k1.fromSeed(seedBytes); break; case DerivationType.electrum: default: seedBytes = await ElectrumV2SeedGenerator.generateFromString(mnemonic, passphrase); hdWallets[CWBitcoinDerivationType.electrum] = Bip32Slip10Secp256k1.fromSeed(seedBytes); break; } } } return BitcoinWallet( mnemonic: mnemonic, xpub: keysData.xPub, password: password, passphrase: passphrase, walletInfo: walletInfo, unspentCoinsInfo: unspentCoinsInfo, initialAddresses: snp?.addresses, initialSilentAddresses: snp?.silentAddresses, initialSilentAddressIndex: snp?.silentAddressIndex ?? 0, initialBalance: snp?.balance, encryptionFileUtils: encryptionFileUtils, seedBytes: seedBytes, initialRegularAddressIndex: snp?.regularAddressIndex, initialChangeAddressIndex: snp?.changeAddressIndex, addressPageType: snp?.addressPageType, networkParam: network, alwaysScan: alwaysScan, mempoolAPIEnabled: mempoolAPIEnabled, hdWallets: hdWallets, initialUnspentCoins: snp?.unspentCoins ?? [], ); } Future<bool> getNodeIsElectrs() async { if (node?.isElectrs != null) { return node!.isElectrs!; } final isNamedElectrs = node?.uri.host.contains("electrs") ?? false; if (isNamedElectrs) { node!.isElectrs = true; } final isNamedFulcrum = node!.uri.host.contains("fulcrum"); if (isNamedFulcrum) { node!.isElectrs = false; } if (node!.isElectrs == null) { final version = await sendWorker(ElectrumWorkerGetVersionRequest()); if (version is List<String> && version.isNotEmpty) { final server = version[0]; if (server.toLowerCase().contains('electrs')) { node!.isElectrs = true; } } else if (version is String && version.toLowerCase().contains('electrs')) { node!.isElectrs = true; } else { node!.isElectrs = false; } } node!.save(); return node!.isElectrs!; } Future<bool> getNodeSupportsSilentPayments() async { if (node?.supportsSilentPayments != null) { return node!.supportsSilentPayments!; } // As of today (august 2024), only ElectrumRS supports silent payments final isElectrs = await getNodeIsElectrs(); if (!isElectrs) { node!.supportsSilentPayments = false; } if (node!.supportsSilentPayments == null) { try { final workerResponse = (await sendWorker(ElectrumWorkerCheckTweaksRequest())) as String; final tweaksResponse = ElectrumWorkerCheckTweaksResponse.fromJson( json.decode(workerResponse) as Map<String, dynamic>, ); final supportsScanning = tweaksResponse.result == true; if (supportsScanning) { node!.supportsSilentPayments = true; } else { node!.supportsSilentPayments = false; } } catch (_) { node!.supportsSilentPayments = false; } } node!.save(); return node!.supportsSilentPayments!; } LedgerConnection? _ledgerConnection; BitcoinLedgerApp? _bitcoinLedgerApp; @override void setLedgerConnection(LedgerConnection connection) { _ledgerConnection = connection; _bitcoinLedgerApp = BitcoinLedgerApp(_ledgerConnection!, derivationPath: walletInfo.derivationInfo!.derivationPath!); } @override Future<BtcTransaction> buildHardwareWalletTransaction({ required List<BitcoinBaseOutput> outputs, required BigInt fee, required BasedUtxoNetwork network, required List<UtxoWithAddress> utxos, required Map<String, PublicKeyWithDerivationPath> publicKeys, String? memo, bool enableRBF = false, BitcoinOrdering inputOrdering = BitcoinOrdering.bip69, BitcoinOrdering outputOrdering = BitcoinOrdering.bip69, }) async { final masterFingerprint = await _bitcoinLedgerApp!.getMasterFingerprint(); final psbtReadyInputs = <PSBTReadyUtxoWithAddress>[]; for (final utxo in utxos) { final rawTx = (await getTransactionExpanded(hash: utxo.utxo.txHash)).originalTransaction.toHex(); final publicKeyAndDerivationPath = publicKeys[utxo.ownerDetails.address.pubKeyHash()]!; psbtReadyInputs.add(PSBTReadyUtxoWithAddress( utxo: utxo.utxo, rawTx: rawTx, ownerDetails: utxo.ownerDetails, ownerDerivationPath: publicKeyAndDerivationPath.derivationPath, ownerMasterFingerprint: masterFingerprint, ownerPublicKey: publicKeyAndDerivationPath.publicKey, )); } final psbt = PSBTTransactionBuild(inputs: psbtReadyInputs, outputs: outputs, enableRBF: enableRBF); final rawHex = await _bitcoinLedgerApp!.signPsbt(psbt: psbt.psbt); return BtcTransaction.fromRaw(BytesUtils.toHexString(rawHex)); } @override Future<String> signMessage(String message, {String? address = null}) async { if (walletInfo.isHardwareWallet) { final addressEntry = address != null ? walletAddresses.allAddresses.firstWhere((element) => element.address == address) : null; final index = addressEntry?.index ?? 0; final isChange = addressEntry?.isChange == true ? 1 : 0; final accountPath = walletInfo.derivationInfo?.derivationPath; final derivationPath = accountPath != null ? "$accountPath/$isChange/$index" : null; final signature = await _bitcoinLedgerApp! .signMessage(message: ascii.encode(message), signDerivationPath: derivationPath); return base64Encode(signature); } return super.signMessage(message, address: address); } @action Future<void> setSilentPaymentsScanning(bool active) async { silentPaymentsScanningActive = active; final nodeSupportsSilentPayments = await getNodeSupportsSilentPayments(); final isAllowedToScan = nodeSupportsSilentPayments || allowedToSwitchNodesForScanning; if (active && isAllowedToScan) { syncStatus = AttemptingScanSyncStatus(); final tip = currentChainTip!; if (tip == walletInfo.restoreHeight) { syncStatus = SyncedTipSyncStatus(tip); return; } if (tip > walletInfo.restoreHeight) { _setListeners(walletInfo.restoreHeight); } } else if (syncStatus is! SyncedSyncStatus) { await sendWorker(ElectrumWorkerStopScanningRequest()); await startSync(); } } @override @action Future<void> updateAllUnspents() async { List<BitcoinUnspent> updatedUnspentCoins = []; // Update unspents stored from scanned silent payment transactions transactionHistory.transactions.values.forEach((tx) { if (tx.unspents != null) { updatedUnspentCoins.addAll(tx.unspents!); } }); unspentCoins.addAll(updatedUnspentCoins); await super.updateAllUnspents(); final walletAddresses = this.walletAddresses as BitcoinWalletAddresses; walletAddresses.silentPaymentAddresses.forEach((addressRecord) { addressRecord.txCount = 0; addressRecord.balance = 0; }); walletAddresses.receivedSPAddresses.forEach((addressRecord) { addressRecord.txCount = 0; addressRecord.balance = 0; }); final silentPaymentWallet = walletAddresses.silentPaymentWallet; unspentCoins.forEach((unspent) { if (unspent.bitcoinAddressRecord is BitcoinReceivedSPAddressRecord) { _updateSilentAddressRecord(unspent); final receiveAddressRecord = unspent.bitcoinAddressRecord as BitcoinReceivedSPAddressRecord; final silentPaymentAddress = SilentPaymentAddress( version: silentPaymentWallet!.version, B_scan: silentPaymentWallet.B_scan, B_spend: receiveAddressRecord.labelHex != null ? silentPaymentWallet.B_spend.tweakAdd( BigintUtils.fromBytes( BytesUtils.fromHexString(receiveAddressRecord.labelHex!), ), ) : silentPaymentWallet.B_spend, ); walletAddresses.silentPaymentAddresses.forEach((addressRecord) { if (addressRecord.address == silentPaymentAddress.toAddress(network)) { addressRecord.txCount += 1; addressRecord.balance += unspent.value; } }); walletAddresses.receivedSPAddresses.forEach((addressRecord) { if (addressRecord.address == receiveAddressRecord.address) { addressRecord.txCount += 1; addressRecord.balance += unspent.value; } }); } }); await walletAddresses.updateAddressesInBox(); } @override void updateCoin(BitcoinUnspent 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 @override Future<void> startSync() async { await _setInitialScanHeight(); await super.startSync(); if (alwaysScan == true) { _setListeners(walletInfo.restoreHeight); } } @action @override Future<void> rescan({required int height, bool? doSingleScan}) async { silentPaymentsScanningActive = true; _setListeners(height, doSingleScan: doSingleScan); } // @action // Future<void> registerSilentPaymentsKey(bool register) 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(); // } // } // } @action Future<void> registerSilentPaymentsKey() async { // final registered = await electrumClient.tweaksRegister( // secViewKey: walletAddresses.silentAddress!.b_scan.toHex(), // pubSpendKey: walletAddresses.silentAddress!.B_spend.toHex(), // labels: walletAddresses.silentAddresses // .where((addr) => addr.type == SilentPaymentsAddresType.p2sp && addr.labelIndex >= 1) // .map((addr) => addr.labelIndex) // .toList(), // ); // printV("registered: $registered"); } @action void _updateSilentAddressRecord(BitcoinUnspent unspent) { final walletAddresses = this.walletAddresses as BitcoinWalletAddresses; walletAddresses.addReceivedSPAddresses( [unspent.bitcoinAddressRecord as BitcoinReceivedSPAddressRecord], ); } @override @action Future<void> handleWorkerResponse(dynamic message) async { super.handleWorkerResponse(message); Map<String, dynamic> messageJson; if (message is String) { messageJson = jsonDecode(message) as Map<String, dynamic>; } else { messageJson = message as Map<String, dynamic>; } final workerMethod = messageJson['method'] as String; final workerError = messageJson['error'] as String?; switch (workerMethod) { case ElectrumRequestMethods.tweaksSubscribeMethod: if (workerError != null) { printV(messageJson); // _onConnectionStatusChange(ConnectionStatus.failed); break; } final response = ElectrumWorkerTweaksSubscribeResponse.fromJson(messageJson); onTweaksSyncResponse(response.result); break; } } @action Future<void> onTweaksSyncResponse(TweaksSyncResponse result) async { if (result.transactions?.isNotEmpty == true) { (walletAddresses as BitcoinWalletAddresses).silentPaymentAddresses.forEach((addressRecord) { addressRecord.txCount = 0; addressRecord.balance = 0; }); (walletAddresses as BitcoinWalletAddresses).receivedSPAddresses.forEach((addressRecord) { addressRecord.txCount = 0; addressRecord.balance = 0; }); for (final map in result.transactions!.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); transactionHistory.addOne(tx); balance[currency]!.confirmed += tx.amount; } await updateAllUnspents(); } } } final newSyncStatus = result.syncStatus; if (newSyncStatus != null) { if (newSyncStatus is UnsupportedSyncStatus) { nodeSupportsSilentPayments = false; } if (newSyncStatus is SyncingSyncStatus) { syncStatus = SyncingSyncStatus(newSyncStatus.blocksLeft, newSyncStatus.ptc); } else { syncStatus = newSyncStatus; if (newSyncStatus is SyncedSyncStatus) { silentPaymentsScanningActive = false; } } final height = result.height; if (height != null) { await walletInfo.updateRestoreHeight(height); } } } @action Future<void> _setListeners(int height, {bool? doSingleScan}) async { if (currentChainTip == null) { throw Exception("currentChainTip is null"); } final chainTip = currentChainTip!; if (chainTip == height) { syncStatus = SyncedSyncStatus(); return; } syncStatus = AttemptingScanSyncStatus(); final walletAddresses = this.walletAddresses as BitcoinWalletAddresses; workerSendPort!.send( ElectrumWorkerTweaksSubscribeRequest( scanData: ScanData( silentPaymentsWallets: walletAddresses.silentPaymentWallets, network: network, height: height, chainTip: chainTip, transactionHistoryIds: transactionHistory.transactions.keys.toList(), labels: walletAddresses.labels, labelIndexes: walletAddresses.silentPaymentAddresses .where((addr) => addr.addressType == SilentPaymentsAddresType.p2sp && addr.labelIndex >= 1) .map((addr) => addr.labelIndex) .toList(), isSingleScan: doSingleScan ?? false, shouldSwitchNodes: !(await getNodeSupportsSilentPayments()) && allowedToSwitchNodesForScanning, ), ).toJson(), ); } @override @action Future<Map<String, ElectrumTransactionInfo>> fetchTransactions() async { throw UnimplementedError(); // try { // final Map<String, ElectrumTransactionInfo> historiesWithDetails = {}; // await Future.wait( // BITCOIN_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); // if (info != null) { // tx.confirmations = info.confirmations; // tx.isPending = tx.confirmations == 0; // transactionHistory.addOne(tx); // await transactionHistory.save(); // } // } // }); // return historiesWithDetails; // } catch (e) { // printV("fetchTransactions $e"); // return {}; // } } @override @action Future<void> updateTransactions([List<BitcoinAddressRecord>? addresses]) async { super.updateTransactions(); transactionHistory.transactions.values.forEach((tx) { if (tx.unspents != null && tx.unspents!.isNotEmpty && tx.height != null && tx.height! > 0 && (currentChainTip ?? 0) > 0) { tx.confirmations = currentChainTip! - tx.height! + 1; } }); } // @action // Future<ElectrumBalance> fetchBalances() async { // final balance = await super.fetchBalances(); // int totalFrozen = balance.frozen; // int totalConfirmed = balance.confirmed; // // 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; // } // }); // } // }); // return ElectrumBalance( // confirmed: totalConfirmed, // unconfirmed: balance.unconfirmed, // frozen: totalFrozen, // ); // } @override @action Future<void> onHeadersResponse(ElectrumHeaderResponse response) async { super.onHeadersResponse(response); _setInitialScanHeight(); // New headers received, start scanning if (alwaysScan == true && syncStatus is SyncedSyncStatus) { _setListeners(walletInfo.restoreHeight); } } Future<void> _setInitialScanHeight() async { final validChainTip = currentChainTip != null && currentChainTip != 0; if (validChainTip && walletInfo.restoreHeight == 0) { await walletInfo.updateRestoreHeight(currentChainTip!); } } @override @action void syncStatusReaction(SyncStatus syncStatus) { switch (syncStatus.runtimeType) { case SyncingSyncStatus: return; case SyncedTipSyncStatus: silentPaymentsScanningActive = false; // Message is shown on the UI for 3 seconds, then reverted to synced Timer(Duration(seconds: 3), () { if (this.syncStatus is SyncedTipSyncStatus) this.syncStatus = SyncedSyncStatus(); }); break; default: super.syncStatusReaction(syncStatus); } } @override Future<int> calcFee({ required List<UtxoWithAddress> utxos, required List<BitcoinBaseOutput> outputs, String? memo, required int feeRate, List<ECPrivateInfo>? inputPrivKeyInfos, List<Outpoint>? vinOutpoints, }) async => feeRate * BitcoinTransactionBuilder.estimateTransactionSize( utxos: utxos, outputs: outputs, network: network, memo: memo, inputPrivKeyInfos: inputPrivKeyInfos, vinOutpoints: vinOutpoints, ); @override TxCreateUtxoDetails createUTXOS({ required bool sendAll, bool paysToSilentPayment = false, int credentialsAmount = 0, int? inputsCount, UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, }) { List<UtxoWithAddress> utxos = []; List<Outpoint> vinOutpoints = []; List<ECPrivateInfo> inputPrivKeyInfos = []; final publicKeys = <String, PublicKeyWithDerivationPath>{}; int allInputsAmount = 0; bool spendsSilentPayment = false; bool spendsUnconfirmedTX = false; int leftAmount = credentialsAmount; var availableInputs = unspentCoins.where((utx) { if (!utx.isSending || utx.isFrozen) { return false; } return true; }).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.addressType == SegwitAddresType.p2wsh) { throw BitcoinTransactionSilentPaymentsNotSupported(); } } allInputsAmount += utx.value; leftAmount = leftAmount - utx.value; final address = RegexUtils.addressTypeFromStr(utx.address, network); ECPrivate? privkey; bool? isSilentPayment = false; if (utx.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) { privkey = (utx.bitcoinAddressRecord as BitcoinReceivedSPAddressRecord).getSpendKey( (walletAddresses as BitcoinWalletAddresses).silentPaymentWallets, network, ); spendsSilentPayment = true; isSilentPayment = true; } else if (!isHardwareWallet) { final addressRecord = (utx.bitcoinAddressRecord as BitcoinAddressRecord); final path = addressRecord.derivationInfo.derivationPath .addElem(Bip32KeyIndex( BitcoinAddressUtils.getAccountFromChange(addressRecord.isChange), )) .addElem(Bip32KeyIndex(addressRecord.index)); privkey = ECPrivate.fromBip32(bip32: bip32.derive(path)); } 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 = walletAddresses.hdWallet .childKey(Bip32KeyIndex(utx.bitcoinAddressRecord.index)) .publicKey .toHex(); } if (utx.bitcoinAddressRecord is BitcoinAddressRecord) { final derivationPath = (utx.bitcoinAddressRecord as BitcoinAddressRecord) .derivationInfo .derivationPath .toString(); publicKeys[address.pubKeyHash()] = PublicKeyWithDerivationPath(pubKeyHex, derivationPath); } utxos.add( UtxoWithAddress( utxo: BitcoinUtxo( txHash: utx.hash, value: BigInt.from(utx.value), vout: utx.vout, scriptType: BitcoinAddressUtils.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 TxCreateUtxoDetails( availableInputs: availableInputs, unconfirmedCoins: unconfirmedCoins, utxos: utxos, vinOutpoints: vinOutpoints, inputPrivKeyInfos: inputPrivKeyInfos, publicKeys: publicKeys, allInputsAmount: allInputsAmount, spendsSilentPayment: spendsSilentPayment, spendsUnconfirmedTX: spendsUnconfirmedTX, ); } @override Future<EstimatedTxResult> estimateSendAllTx( List<BitcoinOutput> outputs, int feeRate, { String? memo, bool hasSilentPayment = false, UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, }) async { final utxoDetails = createUTXOS(sendAll: true, paysToSilentPayment: hasSilentPayment); int fee = await calcFee( utxos: utxoDetails.utxos, outputs: outputs, 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); } // Attempting to send less than the dust limit if (isBelowDust(amount)) { throw BitcoinTransactionNoDustException(); } if (outputs.length == 1) { outputs[0] = BitcoinOutput(address: outputs.last.address, value: 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, ); } @override Future<EstimatedTxResult> estimateTxForAmount( int credentialsAmount, List<BitcoinOutput> outputs, int feeRate, { List<BitcoinOutput> updatedOutputs = const [], int? inputsCount, String? memo, bool? useUnconfirmed, bool hasSilentPayment = false, UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, }) async { // Attempting to send less than the dust limit if (isBelowDust(credentialsAmount)) { throw BitcoinTransactionNoDustException(); } 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, updatedOutputs: updatedOutputs, inputsCount: utxoDetails.utxos.length + 1, memo: memo, hasSilentPayment: hasSilentPayment, ); } throw BitcoinTransactionWrongBalanceException(); } final changeAddress = await walletAddresses.getChangeAddress( inputs: utxoDetails.availableInputs, outputs: updatedOutputs, ); final address = RegexUtils.addressTypeFromStr(changeAddress.address, network); updatedOutputs.add(BitcoinOutput( address: address, value: BigInt.from(amountLeftForChangeAndFee), isChange: true, )); outputs.add(BitcoinOutput( address: address, value: BigInt.from(amountLeftForChangeAndFee), isChange: true, )); // Get Derivation path for change Address since it is needed in Litecoin and BitcoinCash hardware Wallets final changeDerivationPath = changeAddress.derivationInfo.derivationPath.toString(); utxoDetails.publicKeys[address.pubKeyHash()] = PublicKeyWithDerivationPath('', changeDerivationPath); // calcFee updates the silent payment outputs to calculate the tx size accounting // for taproot addresses, but if more inputs are needed to make up for fees, // the silent payment outputs need to be recalculated for the new inputs var temp = outputs.map((output) => output).toList(); int fee = await calcFee( utxos: utxoDetails.utxos, // Always take only not updated bitcoin outputs here so for every estimation // the SP outputs are re-generated to the proper taproot addresses outputs: temp, memo: memo, feeRate: feeRate, inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, vinOutpoints: utxoDetails.vinOutpoints, ); updatedOutputs.clear(); updatedOutputs.addAll(temp); if (fee == 0) { throw BitcoinTransactionNoFeeException(); } int amount = credentialsAmount; final lastOutput = updatedOutputs.last; final amountLeftForChange = amountLeftForChangeAndFee - fee; if (isBelowDust(amountLeftForChange)) { // If has change that is lower than dust, will end up with tx rejected by network rules // so remove the change amount updatedOutputs.removeLast(); outputs.removeLast(); if (amountLeftForChange < 0) { if (!spendingAllCoins) { return estimateTxForAmount( credentialsAmount, outputs, feeRate, updatedOutputs: updatedOutputs, inputsCount: utxoDetails.utxos.length + 1, memo: memo, useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins, hasSilentPayment: hasSilentPayment, ); } else { throw BitcoinTransactionWrongBalanceException(); } } return EstimatedTxResult( utxos: utxoDetails.utxos, inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, publicKeys: utxoDetails.publicKeys, fee: fee, amount: amount, hasChange: false, isSendAll: spendingAllCoins, memo: memo, spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, spendsSilentPayment: utxoDetails.spendsSilentPayment, ); } else { // Here, lastOutput already is change, return the amount left without the fee to the user's address. updatedOutputs[updatedOutputs.length - 1] = BitcoinOutput( address: lastOutput.address, value: BigInt.from(amountLeftForChange), isSilentPayment: lastOutput.isSilentPayment, isChange: true, ); outputs[outputs.length - 1] = BitcoinOutput( address: lastOutput.address, value: BigInt.from(amountLeftForChange), isSilentPayment: lastOutput.isSilentPayment, isChange: true, ); return EstimatedTxResult( utxos: utxoDetails.utxos, inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, publicKeys: utxoDetails.publicKeys, fee: fee, amount: amount, hasChange: true, isSendAll: spendingAllCoins, memo: memo, spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, spendsSilentPayment: utxoDetails.spendsSilentPayment, ); } } @override Future<PendingTransaction> createTransaction(Object credentials) async { try { final outputs = <BitcoinOutput>[]; final transactionCredentials = credentials as BitcoinTransactionCredentials; final hasMultiDestination = transactionCredentials.outputs.length > 1; final sendAll = !hasMultiDestination && transactionCredentials.outputs.first.sendAll; final memo = transactionCredentials.outputs.first.memo; final coinTypeToSpendFrom = transactionCredentials.coinTypeToSpendFrom; 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; final updatedOutputs = outputs .map((e) => BitcoinOutput( address: e.address, value: e.value, isSilentPayment: e.isSilentPayment, isChange: e.isChange, )) .toList(); if (sendAll) { estimatedTx = await estimateSendAllTx( updatedOutputs, feeRateInt, memo: memo, hasSilentPayment: hasSilentPayment, coinTypeToSpendFrom: coinTypeToSpendFrom, ); } else { estimatedTx = await estimateTxForAmount( credentialsAmount, outputs, feeRateInt, updatedOutputs: updatedOutputs, memo: memo, hasSilentPayment: hasSilentPayment, coinTypeToSpendFrom: coinTypeToSpendFrom, ); } if (walletInfo.isHardwareWallet) { final transaction = await buildHardwareWalletTransaction( utxos: estimatedTx.utxos, outputs: updatedOutputs, publicKeys: estimatedTx.publicKeys, fee: BigInt.from(estimatedTx.fee), network: network, memo: estimatedTx.memo, outputOrdering: BitcoinOrdering.none, enableRBF: true, ); return PendingBitcoinTransaction( transaction, type, sendWorker: sendWorker, amount: estimatedTx.amount, fee: estimatedTx.fee, feeRate: feeRateInt.toString(), hasChange: estimatedTx.hasChange, isSendAll: estimatedTx.isSendAll, hasTaprootInputs: false, // ToDo: (Konsti) Support Taproot )..addListener((transaction) async { transactionHistory.addOne(transaction); await updateBalance(); }); } final txb = BitcoinTransactionBuilder( utxos: estimatedTx.utxos, outputs: updatedOutputs, 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)}"; try { key = estimatedTx.inputPrivKeyInfos.firstWhere((element) { final elemPubkey = element.privkey.getPublic().toHex(); if (elemPubkey == publicKey) { return true; } else { error += "\nExpected: $publicKey"; error += "\nPubkey: $elemPubkey"; return false; } }); } catch (_) { throw Exception(error); } } 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, sendWorker: sendWorker, amount: estimatedTx.amount, fee: estimatedTx.fee, feeRate: feeRateInt.toString(), hasChange: estimatedTx.hasChange, isSendAll: estimatedTx.isSendAll, hasTaprootInputs: hasTaprootInputs, utxos: estimatedTx.utxos, hasSilentPayment: hasSilentPayment, )..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; } } }