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, required EncryptionFileUtils encryptionFileUtils, List? seedBytes, String? mnemonic, String? xpub, String? addressPageType, BasedUtxoNetwork? networkParam, List? initialAddresses, ElectrumBalance? initialBalance, Map? initialRegularAddressIndex, Map? initialChangeAddressIndex, String? passphrase, List? 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 create({ required String mnemonic, required String password, required WalletInfo walletInfo, required Box unspentCoinsInfo, required EncryptionFileUtils encryptionFileUtils, String? passphrase, String? addressPageType, BasedUtxoNetwork? network, List? initialAddresses, List? initialSilentAddresses, ElectrumBalance? initialBalance, Map? initialRegularAddressIndex, Map? initialChangeAddressIndex, int initialSilentAddressIndex = 0, required bool mempoolAPIEnabled, }) async { List? seedBytes = null; final Map hdWallets = {}; if (walletInfo.isRecovery) { for (final derivation in walletInfo.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]!; } } 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 open({ required String name, required WalletInfo walletInfo, required Box 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? seedBytes = null; final Map hdWallets = {}; final mnemonic = keysData.mnemonic; final passphrase = keysData.passphrase; if (mnemonic != null) { final derivations = walletInfo.derivations ?? []; 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 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 && 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 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, ); 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 buildHardwareWalletTransaction({ required List outputs, required BigInt fee, required BasedUtxoNetwork network, required List utxos, required Map publicKeys, String? memo, bool enableRBF = false, BitcoinOrdering inputOrdering = BitcoinOrdering.bip69, BitcoinOrdering outputOrdering = BitcoinOrdering.bip69, }) async { final masterFingerprint = await _bitcoinLedgerApp!.getMasterFingerprint(); final psbtReadyInputs = []; 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 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 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 updateAllUnspents() async { List 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 startSync() async { await _setInitialScanHeight(); await super.startSync(); if (alwaysScan == true) { _setListeners(walletInfo.restoreHeight); } } @action @override Future rescan({required int height, bool? doSingleScan}) async { silentPaymentsScanningActive = true; _setListeners(height, doSingleScan: doSingleScan); } // @action // Future 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 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 handleWorkerResponse(dynamic message) async { super.handleWorkerResponse(message); Map messageJson; if (message is String) { messageJson = jsonDecode(message) as Map; } else { messageJson = message as Map; } 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 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 _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> fetchTransactions() async { throw UnimplementedError(); // try { // final Map 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 updateTransactions([List? 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 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 onHeadersResponse(ElectrumHeaderResponse response) async { super.onHeadersResponse(response); _setInitialScanHeight(); // New headers received, start scanning if (alwaysScan == true && syncStatus is SyncedSyncStatus) { _setListeners(walletInfo.restoreHeight); } } Future _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 calcFee({ required List utxos, required List outputs, String? memo, required int feeRate, List? inputPrivKeyInfos, List? 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 utxos = []; List vinOutpoints = []; List inputPrivKeyInfos = []; final publicKeys = {}; 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 estimateSendAllTx( List 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 estimateTxForAmount( int credentialsAmount, List outputs, int feeRate, { List 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 createTransaction(Object credentials) async { try { final outputs = []; final transactionCredentials = credentials as BitcoinTransactionCredentials; final hasMultiDestination = transactionCredentials.outputs.length > 1; final sendAll = !hasMultiDestination && transactionCredentials.outputs.first.sendAll; final memo = transactionCredentials.outputs.first.memo; 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; } } }