From 5f42b393b40cfe9c626e0076b5f367bbccac31cd Mon Sep 17 00:00:00 2001 From: malik1004x Date: Mon, 13 Apr 2026 18:46:08 +0200 Subject: [PATCH 1/5] fix monero.com block explorer link (#3174) * fix monero.com block explorer link * Rename transaction ID variable for consistency [skip ci] --------- Co-authored-by: Omar Hatem --- lib/view_model/transaction_details_view_model.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view_model/transaction_details_view_model.dart b/lib/view_model/transaction_details_view_model.dart index ed2c45e3b..cc2897336 100644 --- a/lib/view_model/transaction_details_view_model.dart +++ b/lib/view_model/transaction_details_view_model.dart @@ -397,7 +397,7 @@ abstract class TransactionDetailsViewModelBase with Store { String get _explorerUrl { - final txId = transactionInfo.id; + final txId = transactionInfo.txHash; if (wallet.chainId != null) { final explorerUrl = evm!.getExplorerUrlForChainId(wallet.chainId!); if (explorerUrl != null) return '$explorerUrl/tx/${txId}'; From 0bd69009cf63029faaf04806e610c2519d54b4a5 Mon Sep 17 00:00:00 2001 From: cyan Date: Mon, 13 Apr 2026 19:00:23 +0200 Subject: [PATCH 2/5] fix: disable dev options, keep btc logs (#3170) --- lib/src/screens/settings/other_settings_page.dart | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/src/screens/settings/other_settings_page.dart b/lib/src/screens/settings/other_settings_page.dart index db320bf96..1670d6631 100644 --- a/lib/src/screens/settings/other_settings_page.dart +++ b/lib/src/screens/settings/other_settings_page.dart @@ -145,8 +145,7 @@ class OtherSettingsPage extends BasePage { Navigator.of(context).pushNamed(Routes.signPage); }), ], - "dev": [ - if (_otherSettingsViewModel.walletType == WalletType.bitcoin) ...[ + if (_otherSettingsViewModel.walletType == WalletType.bitcoin) "btc_logging": [ ListItemRegularRow( keyValue: "export_lightning_logs", label: S.of(context).export_lightning_logs, @@ -155,21 +154,20 @@ class OtherSettingsPage extends BasePage { keyValue: "export_payjoin_logs", label: S.of(context).export_payjoin_logs, onTap: () => onExportPJLog(context)), - ], - if (FeatureFlag.hasDevOptions && _otherSettingsViewModel.walletType == WalletType.monero) + ], + "dev": FeatureFlag.hasDevOptions == false ? [] : [ + if (_otherSettingsViewModel.walletType == WalletType.monero) ListItemRegularRow( keyValue: "[dev] monero background sync", label: "[dev] monero background sync", onTap: () => Navigator.of(context).pushNamed(Routes.devMoneroBackgroundSync)), - if (FeatureFlag.hasDevOptions && - [WalletType.monero, WalletType.wownero, WalletType.zano] + if ([WalletType.monero, WalletType.wownero, WalletType.zano] .contains(_otherSettingsViewModel.walletType)) ListItemRegularRow( keyValue: "[dev] xmr call profiler", label: "[dev] xmr call profiler", onTap: () => Navigator.of(context).pushNamed(Routes.devMoneroCallProfiler)), - if (FeatureFlag.hasDevOptions && - [WalletType.monero].contains(_otherSettingsViewModel.walletType)) + if ([WalletType.monero].contains(_otherSettingsViewModel.walletType)) ListItemRegularRow( keyValue: '[dev] xmr wallet cache debug', label: '[dev] xmr wallet cache debug', From 7b2c9d51303ce0fc38aecd7d4a1cdd595e91a6ec Mon Sep 17 00:00:00 2001 From: Serhii <17529954+serhii-bor@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:22:05 +0300 Subject: [PATCH 3/5] CW-1193-Add-batch-fetching-of-transactions-to-Electrum (#3104) * support batched Electrum calls and processing for update transactions * add timeout handling * Revert "add timeout handling" * adjust batch sizes and add chunk error handling * fix merge conflict * add fallback from batch fetching to single-call flow --- cw_bitcoin/lib/electrum.dart | 220 +++++- cw_bitcoin/lib/electrum_wallet.dart | 723 +++++++++++++++++- cw_bitcoin/lib/electrum_wallet_addresses.dart | 38 +- 3 files changed, 952 insertions(+), 29 deletions(-) diff --git a/cw_bitcoin/lib/electrum.dart b/cw_bitcoin/lib/electrum.dart index e7993d4c2..095183426 100644 --- a/cw_bitcoin/lib/electrum.dart +++ b/cw_bitcoin/lib/electrum.dart @@ -144,7 +144,7 @@ class ElectrumClient { void _parseResponse(String message) { try { - final response = json.decode(message) as Map; + final response = json.decode(message); _handleResponse(response); } on FormatException catch (e) { final msg = e.message.toLowerCase(); @@ -159,7 +159,7 @@ class ElectrumClient { } if (isJSONStringCorrect(unterminatedString)) { - final response = json.decode(unterminatedString) as Map; + final response = json.decode(unterminatedString); _handleResponse(response); unterminatedString = ''; } @@ -172,8 +172,7 @@ class ElectrumClient { unterminatedString += message; if (isJSONStringCorrect(unterminatedString)) { - final response = json.decode(unterminatedString) as Map; - _handleResponse(response); + final response = json.decode(unterminatedString); // unterminatedString = null; unterminatedString = ''; } @@ -303,6 +302,137 @@ class ElectrumClient { return ''; }); + Future>>> getBatchHistory( + List scriptHashes, { + int timeout = 10000, + }) async { + final paramsList = scriptHashes.map((h) => [h]).toList(growable: false); + + final batchResults = await callBatchWithTimeout( + method: 'blockchain.scripthash.get_history', + paramsList: paramsList, + timeout: timeout, + ); + + final historyMap = >>{}; + + for (int i = 0; i < scriptHashes.length; i++) { + final sh = scriptHashes[i]; + + if (i >= batchResults.length) { + historyMap[sh] = const []; + continue; + } + + final result = batchResults[i]; + + if (result is List) { + historyMap[sh] = result + .whereType>() + .map((m) => m.map((k, v) => MapEntry(k.toString(), v))) + .cast>() + .toList(); + } else { + historyMap[sh] = const []; + } + } + + return historyMap; + } + + Future>> getBatchTransactionVerbose( + List hashes, { + int timeout = 10000, + }) async { + final result = >{}; + if (hashes.isEmpty) return result; + + final paramsList = hashes.map((h) => [h, true]).toList(growable: false); + final batchResults = await callBatchWithTimeout( + method: 'blockchain.transaction.get', + paramsList: paramsList, + timeout: timeout, + ); + + for (var i = 0; i < hashes.length; i++) { + final txid = hashes[i]; + final r = (i < batchResults.length) ? batchResults[i] : null; + if (r is Map) { + result[txid] = r; + } else { + result[txid] = {}; + } + } + + return result; + } + + Future> getBatchTransactionHex( + List hashes, { + int timeout = 10000, + }) async { + final result = {}; + if (hashes.isEmpty) return result; + + final paramsList = hashes.map((h) => [h]).toList(growable: false); + final batchResults = await callBatchWithTimeout( + method: 'blockchain.transaction.get', + paramsList: paramsList, + timeout: timeout, + ); + + for (var i = 0; i < hashes.length; i++) { + final txid = hashes[i]; + final r = (i < batchResults.length) ? batchResults[i] : null; + if (r is String && r.isNotEmpty) { + result[txid] = r; + } else { + result[txid] = null; + } + } + + return result; + } + + Future> callBatchWithTimeout({ + required String method, + required List> paramsList, + int timeout = 10000, + }) async { + if (!isConnected) return []; + + final completer = Completer>(); + final int batchBaseId = _id += 1; + final String internalBatchKey = "batch_$batchBaseId"; + + // Build the Batch Array + final List> batchPayload = []; + for (int i = 0; i < paramsList.length; i++) { + batchPayload.add({ + "jsonrpc": "2.0", + "method": method, + "params": paramsList[i], + "id": "$batchBaseId-$i" + }); + } + + // Register the task + _tasks[internalBatchKey] = SocketTask(completer: completer, isSubscription: false); + + // Write to socket + socket!.write(json.encode(batchPayload) + "\n"); + + // Timeout Logic + Timer(Duration(milliseconds: timeout), () { + if (!completer.isCompleted) { + _tasks.remove(internalBatchKey); + completer.completeError(RequestFailedTimeoutException("BATCH_$method", batchBaseId)); + } + }); + + return completer.future; + } + Future broadcastTransaction( {required String transactionRaw, BasedUtxoNetwork? network, @@ -569,35 +699,73 @@ class ElectrumClient { } } - void _handleResponse(Map response) { - final method = response['method']; - final id = response['id'] as String?; - final result = response['result']; + void _handleResponse(dynamic response) { - try { - final error = response['error'] as Map?; - if (error != null) { - final errorMessage = error['message'] as String?; - if (errorMessage != null) { - _errors[id!] = errorMessage; + // Handle batch response + if (response is List) { + if (response.isEmpty) return; + + // Sort responses by ID to ensure correct order for batch processing + response.sort((a, b) { + try { + final idA = int.parse(a['id'].toString().split('-').last); + final idB = int.parse(b['id'].toString().split('-').last); + return idA.compareTo(idB); + } catch (_) { + return 0; } - } - } catch (_) {} + }); - try { - final error = response['error'] as String?; - if (error != null) { - _errors[id!] = error; - } - } catch (_) {} + final firstItem = response.first as Map; + final String firstIdAttr = firstItem['id'].toString(); - if (method is String) { - _methodHandler(method: method, request: response); + final String batchKey = firstIdAttr.contains('-') + ? "batch_${firstIdAttr.split('-')[0].replaceAll('batch_', '')}" + : firstIdAttr; + + // Extract the results from each item in the batch + final results = response.map((item) { + if (item is Map) { + return item['result'] ?? item['error']; + } + return null; + }).toList(); + + _finish(batchKey, results); return; } - if (id != null) { - _finish(id, result); + // Handle single response + if (response is Map) { + final method = response['method']; + final id = response['id'] as String?; + final result = response['result']; + + try { + final error = response['error'] as Map?; + if (error != null) { + final errorMessage = error['message'] as String?; + if (errorMessage != null) { + _errors[id!] = errorMessage; + } + } + } catch (_) {} + + try { + final error = response['error'] as String?; + if (error != null) { + _errors[id!] = error; + } + } catch (_) {} + + if (method is String) { + _methodHandler(method: method, request: response); + return; + } + + if (id != null) { + _finish(id, result); + } } } diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index f3e2b7f13..b7983fd4e 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -279,6 +279,18 @@ abstract class ElectrumWalletBase seedBytes, network != null ? getKeyNetVersion(network, hardwareWalletType) : null); } + static const int addressHistoryChunkSize = 150; + static const int transactionChunkSize = 150; + static const int inputTransactionChunkSize = 150; + static const int discoveryHistoryChunkSize = 20; + + static const int transactionBatchTimeoutMs = 15000; + + static const int batchTestTimeoutMs = 4000; + static const int batchTestHashesCount = 2; + + static const bool useBatchForHistory = true; + @observable bool? alwaysScan; @@ -360,6 +372,8 @@ abstract class ElectrumWalletBase bool silentPaymentsScanningActive = false; bool _isTryingToConnect = false; + bool? _isBatchSupported; + DateTime? _syncBenchmarkStartTime; Completer sharedPrefs = Completer(); @@ -639,6 +653,11 @@ abstract class ElectrumWalletBase return; } + if (_syncBenchmarkStartTime == null) { + _syncBenchmarkStartTime = DateTime.now(); + printV('[ELECTRUM_WALLET SYNC] Starting: ${_syncBenchmarkStartTime!}'); + } + syncStatus = SyncronizingSyncStatus(); if (hasSilentPaymentsScanning) { @@ -671,6 +690,7 @@ abstract class ElectrumWalletBase } await subscribeForUpdates(); + await _checkIfBatchSupported(); await updateTransactions(); await updateAllUnspents(); @@ -686,11 +706,28 @@ abstract class ElectrumWalletBase if (syncStatus is LostConnectionSyncStatus) { return; } + + final syncEnd = DateTime.now(); + final totalMs = _syncBenchmarkStartTime != null + ? syncEnd.difference(_syncBenchmarkStartTime!).inMilliseconds + : 0; + + printV('[ELECTRUM_WALLET SYNC] Finished: $syncEnd, took ${totalMs} ms'); + + _syncBenchmarkStartTime = null; syncStatus = SyncedSyncStatus(); } } catch (e, stacktrace) { + final syncEnd = DateTime.now(); + final totalMs = _syncBenchmarkStartTime != null + ? syncEnd.difference(_syncBenchmarkStartTime!).inMilliseconds + : 0; + printV(stacktrace); printV("startSync $e"); + printV('[ELECTRUM_WALLET SYNC] Finished: $syncEnd, took ${totalMs} ms'); + + _syncBenchmarkStartTime = null; syncStatus = FailedSyncStatus(); } } @@ -786,6 +823,7 @@ abstract class ElectrumWalletBase @override Future connectToNode({required Node node}) async { this.node = node; + _isBatchSupported = null; if (syncStatus is ConnectingSyncStatus) return; @@ -794,6 +832,7 @@ abstract class ElectrumWalletBase await _receiveStream?.cancel(); await electrumClient.close(); + _isBatchSupported = null; electrumClient.onConnectionStatusChange = _onConnectionStatusChange; @@ -1711,6 +1750,7 @@ abstract class ElectrumWalletBase try { await _receiveStream?.cancel(); await electrumClient.close(); + _isBatchSupported = null; } catch (_) {} _autoSaveTimer?.cancel(); _updateFeeRateTimer?.cancel(); @@ -2337,10 +2377,14 @@ abstract class ElectrumWalletBase Future> fetchTransactions() async { try { final Map historiesWithDetails = {}; + final shouldUseBatchForHistory = useBatchForHistory && _isBatchSupported == true; + + printV('[BATCH_TEST] Fetching transactions with batch: $shouldUseBatchForHistory'); if (type == WalletType.bitcoin) { - await Future.wait(BITCOIN_ADDRESS_TYPES - .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type))); + await Future.wait(BITCOIN_ADDRESS_TYPES.map((type) => shouldUseBatchForHistory + ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type) + : fetchTransactionsForAddressType(historiesWithDetails, type))); } else if (type == WalletType.bitcoinCash) { await Future.wait(BITCOIN_CASH_ADDRESS_TYPES .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type))); @@ -2508,6 +2552,644 @@ abstract class ElectrumWalletBase } } + Future fetchTransactionsForAddressTypeBatch( + Map historiesWithDetails, BitcoinAddressType type) async { + final addressesByType = + walletAddresses.allAddresses.where((addr) => addr.type == type).toList(); + final hiddenAddresses = addressesByType.where((addr) => addr.isHidden).toList(); + final receiveAddresses = addressesByType.where((addr) => !addr.isHidden).toList(); + walletAddresses.hiddenAddresses.addAll(hiddenAddresses.map((e) => e.address)); + await walletAddresses.saveAddressesInBox(); + + final tip = await getCurrentChainTip(); + + final addressHistory = await _processChunksToMap( + items: addressesByType, + chunkSize: addressHistoryChunkSize, + processChunk: (chunk) => _fetchBatchAddressHistory(chunk, tip, addressHistoryChunkSize), + ); + + if (addressHistory.isNotEmpty) historiesWithDetails.addAll(addressHistory); + + for (final addressRecord in addressesByType) { + final matchedAddresses = addressRecord.isHidden ? hiddenAddresses : receiveAddresses; + + final isUsedAddressUnderGap = + matchedAddresses.indexOf(addressRecord) >= + matchedAddresses.length - ElectrumWalletAddressesBase.gap; + + if (isUsedAddressUnderGap && addressRecord.isUsed) { + final prevLength = walletAddresses.allAddresses.length; + + + await walletAddresses.discoverAddressesBatch( + matchedAddresses, + addressRecord.isHidden, + (newAddresses) async { + await _fetchBatchAddressHistory( + newAddresses, + tip, + discoveryHistoryChunkSize, + ); + + return newAddresses + .where((addressRecord) => addressRecord.isUsed) + .map((addressRecord) => addressRecord.address) + .toSet(); + }, + type: type, + ); + + final newLength = walletAddresses.allAddresses.length; + + if (newLength > prevLength) { + await fetchTransactionsForAddressTypeBatch( + historiesWithDetails, + type); + return; + } + } + } + } + + Future> _fetchBatchAddressHistory( + List addressRecords, + int? currentHeight, + int historyChunkSize) async { + String lastTxId = ''; + bool didUpdateHistory = false; + + try { + final Map historiesWithDetails = {}; + + // List of script hashes for the given address records + final scriptHashes = addressRecords.map((a) => a.getScriptHash(network)).toList(); + + final historyByScriptHash = + await _processChunksToMap>>( + items: scriptHashes, + chunkSize: historyChunkSize, + processChunk: _getHistoryBatch + ); + + // Map scriptHash -> addressRecord + final byScriptHash = {}; + for (final a in addressRecords) { + byScriptHash[a.getScriptHash(network)] = a; + } + + // Split into already-known txs vs missing txs + final missingHistoryItems = >[]; + + for (final entry in historyByScriptHash.entries) { + final sh = entry.key; + final addressRecord = byScriptHash[sh]; + if (addressRecord == null) continue; + + final history = entry.value; + if (history.isEmpty) continue; + + addressRecord.setAsUsed(); + walletAddresses.clearLockIfMatches(addressRecord.type, addressRecord.address); + + //removes transactions no longer returned by the api, presumed replaced/invalid. + if (this is BitcoinWallet) { + final beforeLen = transactionHistory.transactions.length; + transactionHistory.transactions.removeWhere((hash, tx) { + return tx.outputAddresses != null && + tx.outputAddresses!.contains(addressRecord.address) && + !history.any((h) => h['tx_hash'] == hash); + }); + if (transactionHistory.transactions.length != beforeLen) { + didUpdateHistory = true; + } + } + + // For each transaction in the history, check if we already have it in our transaction history. If we do, update its details if necessary. If we don't, add it to the list of missing history items to fetch later. + for (final item in history) { + final txid = item['tx_hash'] as String?; + final height = item['height'] as int? ?? 0; + if (txid == null || txid.isEmpty) continue; + + lastTxId = txid; + + final storedTx = transactionHistory.transactions[txid]; + if (storedTx != null) { + if (height > 0) { + final oldHeight = storedTx.height; + final oldConfs = storedTx.confirmations; + final oldPending = storedTx.isPending; + + storedTx.height = height; + + if ((currentHeight ?? 0) > 0) { + storedTx.confirmations = currentHeight! - height + 1; + } + + storedTx.isPending = storedTx.confirmations == 0; + + if (storedTx.height != oldHeight || + storedTx.confirmations != oldConfs || + storedTx.isPending != oldPending) { + transactionHistory.addOne(storedTx); + didUpdateHistory = true; + } + } + + historiesWithDetails[txid] = storedTx; + } else { + missingHistoryItems.add({ + 'tx_hash': txid, + 'height': height, + 'script_hash': sh, + 'address': addressRecord.address, + }); + } + } + } + + // Batch fetch missing tx verbose details + if (missingHistoryItems.isEmpty) { + if (didUpdateHistory) await transactionHistory.save(); + return historiesWithDetails; + } + + for (var i = 0; i < missingHistoryItems.length; i += historyChunkSize) { + final end = (i + historyChunkSize < missingHistoryItems.length) + ? i + historyChunkSize + : missingHistoryItems.length; + final chunkHistory = missingHistoryItems.sublist(i, end); + + final hashes = chunkHistory + .map((e) => (e['tx_hash'] as String).trim()) + .where((h) => h.isNotEmpty) + .toList(growable: false); + + final heightsByHash = { + for (final e in chunkHistory) + (e['tx_hash'] as String): (e['height'] as int?), + }; + + final infosByHash = await fetchTransactionInfoBatch( + hashes: hashes, + heightsByHash: heightsByHash, + retryOnFailure: true, + retryDelay: const Duration(seconds: 1), + ); + + for (final txid in hashes) { + final tx = infosByHash[txid]; + if (tx == null) continue; + + historiesWithDetails[tx.id] = tx; + + // Litecoin peg-out tagging + if (this is LitecoinWallet) { + for (final tx2 in transactionHistory.transactions.values) { + final heightDiff = ((tx2.height ?? 0) - (tx.height ?? 0)).abs(); + if (tx2.additionalInfo["isPegOut"] == true && + tx2.amount == tx.amount && + heightDiff <= 5) { + tx.additionalInfo["fromPegOut"] = true; + } + } + } + + transactionHistory.addOne(tx); + didUpdateHistory = true; + } + } + + if (didUpdateHistory) { + await transactionHistory.save(); + } + + return historiesWithDetails; + } catch (e, stacktrace) { + final prefix = lastTxId.isNotEmpty ? '$lastTxId - ' : ''; + _onError?.call(FlutterErrorDetails( + exception: '$prefix$e', + stack: stacktrace, + library: runtimeType.toString(), + )); + return {}; + } + } + + Future>> _getTransactionVerboseBatch( + List hashes) { + return electrumClient.getBatchTransactionVerbose( + hashes, + timeout: transactionBatchTimeoutMs, + ); + } + + Future> _getTransactionHexBatch( + List hashes) { + return electrumClient.getBatchTransactionHex( + hashes, + timeout: transactionBatchTimeoutMs, + ); + } + + Future>>> _getHistoryBatch( + List scriptHashes) { + return electrumClient.getBatchHistory( + scriptHashes, + timeout: transactionBatchTimeoutMs, + ); + } + + Future> fetchTransactionInfoBatch({ + required List hashes, + Map? heightsByHash, + bool retryOnFailure = false, + Duration retryDelay = const Duration(seconds: 2), + }) async { + final result = {}; + final uniqueHashes = + hashes.map((h) => h.trim()).where((h) => h.isNotEmpty).toSet().toList(); + + if (uniqueHashes.isEmpty) return result; + + await _processTransactionInfoBatch( + txIds: uniqueHashes, + result: result, + heightsByHash: heightsByHash, + ); + + if (retryOnFailure) { + final failedHashes = uniqueHashes.where((txId) => result[txId] == null).toList(); + + if (failedHashes.isNotEmpty) { + await Future.delayed(retryDelay); + + await _processTransactionInfoBatch( + txIds: failedHashes, + result: result, + heightsByHash: heightsByHash, + ); + } + } + + return result; + } + + Future _processTransactionInfoBatch({ + required List txIds, + required Map result, + required Map? heightsByHash, + }) async { + for (var i = 0; i < txIds.length; i += transactionChunkSize) { + final end = (i + transactionChunkSize < txIds.length) + ? i + transactionChunkSize + : txIds.length; + final chunk = txIds.sublist(i, end); + + final bundlesByHash = await getTransactionExpandedBatch( + hashes: chunk, + heightsByHash: heightsByHash, + ); + + for (final txId in chunk) { + try { + final bundle = bundlesByHash[txId]; + if (bundle == null) { + result[txId] = null; + continue; + } + + final info = ElectrumTransactionInfo.fromElectrumBundle( + bundle, + walletInfo.type, + network, + addresses: addressesSet, + height: heightsByHash?[txId], + ); + info.id = txId; + result[txId] = info; + } catch (_) { + result[txId] = null; + } + } + } + } + + Future> getTransactionExpandedBatch({ + required List hashes, + Map? heightsByHash}) async { + final bundles = {}; + if (hashes.isEmpty) return bundles; + + final verboseByHash = await _fetchTransactionVerboseBatch(hashes); + + final originalByHash = _parseTransactions(verboseByHash); + + final inputTxIdsByHash = _collectInputTxIdsByHash(originalByHash); + + final inputVerboseByTxId = await _fetchInputTransactionVerboseBatch( + inputTxIdsByHash); + + final parsedInputTxById = _parseTransactions(inputVerboseByTxId); + + return _buildTransactionBundlesBatch( + unique: hashes, + heightsByHash: heightsByHash, + tip: await getUpdatedChainTip(), + originalByHash: originalByHash, + verboseByHash: verboseByHash, + inputTxidsByHash: inputTxIdsByHash, + parsedInputTxById: parsedInputTxById, + ); + } + + Future>> _fetchTransactionVerboseBatch( + List txIds) async { + + final verboseTransactionByHash = await _processChunksToMap>( + items: txIds, + chunkSize: transactionChunkSize, + processChunk: _getTransactionVerboseBatch, + ); + + final emptyHex = []; + for (final txId in txIds) { + final vTx = verboseTransactionByHash[txId]; + if (vTx == null || vTx.isEmpty || vTx['hex'] == null) { + emptyHex.add(txId); + } + } + + final hexByHash = await _processChunksToMap( + items: emptyHex, + chunkSize: transactionChunkSize, + processChunk: _getTransactionHexBatch, + ); + + for (final txId in txIds) { + final verbose = verboseTransactionByHash[txId] ?? {}; + if ((verbose['hex'] as String?) == null) { + final hex = hexByHash[txId]; + if (hex != null && hex.isNotEmpty) { + verboseTransactionByHash[txId] = { + ...verbose, + 'hex': hex, + }; + } + } + } + + return verboseTransactionByHash; + } + + Map _parseTransactions( + Map> verboseByHash, + ) { + final result = {}; + + for (final entry in verboseByHash.entries) { + final hex = entry.value['hex'] as String?; + if (hex == null || hex.isEmpty) continue; + + try { + result[entry.key] = BtcTransaction.fromRaw(hex); + } catch (_) {} + } + + return result; + } + + + Map> _collectInputTxIdsByHash( + Map originalByHash, + ) { + final inputTxIdsByHash = >{}; + + for (final entry in originalByHash.entries) { + final txId = entry.key; + final original = entry.value; + + final inputTxIds = []; + for (final vin in original.inputs) { + inputTxIds.add(vin.txId); + } + + inputTxIdsByHash[txId] = inputTxIds; + } + + return inputTxIdsByHash; + } + + + Future>> _fetchInputTransactionVerboseBatch( + Map> inputTxidsByHash) async { + final allInputTxids = {}; + for (final txids in inputTxidsByHash.values) { + allInputTxids.addAll(txids); + } + + final inputTxIds = allInputTxids.toList(growable: false); + + final verboseTransactionByHash = + await _processChunksToMap>( + items: inputTxIds, + chunkSize: inputTransactionChunkSize, + processChunk: _getTransactionVerboseBatch, + onChunkError: (chunk, error) { + if (error is electrum.RequestFailedTimeoutException) { + printV( + 'fetchInputTransactionVerboseBatch timeout for ${chunk.length} txs: ${error.method}', + ); + } else { + printV( + 'fetchInputTransactionVerboseBatch failed for ${chunk.length} txs: $error,', + ); + } + }, + ); + + final emptyHex = []; + for (final txId in inputTxIds) { + final vTx = verboseTransactionByHash[txId]; + if (vTx == null || vTx.isEmpty || vTx['hex'] == null) { + emptyHex.add(txId); + } + } + + final hexByHash = await _processChunksToMap( + items: emptyHex, + chunkSize: inputTransactionChunkSize, + processChunk: _getTransactionHexBatch, + ); + + for (final txId in inputTxIds) { + final verbose = verboseTransactionByHash[txId] ?? {}; + if ((verbose['hex'] as String?) == null) { + final hex = hexByHash[txId]; + if (hex != null && hex.isNotEmpty) { + verboseTransactionByHash[txId] = { + ...verbose, + 'hex': hex, + }; + } + } + } + + return verboseTransactionByHash; + } + + + Future> _buildTransactionBundlesBatch({ + required List unique, + required Map? heightsByHash, + required int tip, + required Map originalByHash, + required Map> verboseByHash, + required Map> inputTxidsByHash, + required Map parsedInputTxById, + }) async { + final bundles = {}; + + // Identify heights that need mempool timestamp lookup + final heightsNeedingTime = {}; + for (final txid in originalByHash.keys) { + final verbose = verboseByHash[txid] ?? const {}; + final time = verbose['time'] as int?; + final h = heightsByHash?[txid]; + if (time == null && h != null && h > 0) { + heightsNeedingTime.add(h); + } + } + + final mempoolTimes = await _fetchBlockTimestampsFromMempoolByHeights(heightsNeedingTime); + + for (final txid in unique) { + final original = originalByHash[txid]; + if (original == null) continue; + + final verbose = verboseByHash[txid] ?? const {}; + + int? time = verbose['time'] as int?; + int? confirmations = verbose['confirmations'] as int?; + final h = heightsByHash?[txid]; + + if (h != null) { + if (time == null && h > 0) { + final mp = mempoolTimes[h]; + time = mp ?? (getDateByBitcoinHeight(h).millisecondsSinceEpoch / 1000).round(); + } + + if (confirmations == null && tip > 0 && h > 0) { + confirmations = tip - h + 1; + if (confirmations < 0) confirmations = 0; + } + } + + final ins = []; + final inputTxids = inputTxidsByHash[txid] ?? const []; + + bool allInputsPresent = true; + for (final inputTxid in inputTxids) { + final inTx = parsedInputTxById[inputTxid]; + if (inTx == null) { + allInputsPresent = false; + break; + } + ins.add(inTx); + } + + if (!allInputsPresent || ins.length != original.inputs.length) { + continue; + } + + bundles[txid] = ElectrumTransactionBundle( + original, + ins: ins, + time: time, + confirmations: confirmations ?? 0, + ); + } + + return bundles; + } + + Future> _fetchBlockTimestampsFromMempoolByHeights( + Set heights, + ) async { + final out = {}; + if (heights.isEmpty) return out; + if (!(await checkIfMempoolAPIIsEnabled())) return out; + + // Best-effort: if any call fails, we just skip that height. + await Future.wait(heights.map((h) async { + try { + final blockHashResp = await ProxyWrapper() + .get( + clearnetUri: Uri.parse( + 'https://mempool.cakewallet.com/api/v1/block-height/$h', + ), + ) + .timeout(const Duration(seconds: 15)); + + if (blockHashResp.statusCode != 200 || blockHashResp.body.isEmpty) return; + + final blockHash = blockHashResp.body.trim(); + if (blockHash.isEmpty) return; + + final blockResp = await ProxyWrapper() + .get( + clearnetUri: Uri.parse( + 'https://mempool.cakewallet.com/api/v1/block/$blockHash', + ), + ) + .timeout(const Duration(seconds: 15)); + + if (blockResp.statusCode != 200 || blockResp.body.isEmpty) return; + + final decoded = jsonDecode(blockResp.body); + final ts = decoded is Map ? decoded['timestamp'] : null; + if (ts == null) return; + + final parsed = int.tryParse(ts.toString()); + if (parsed == null) return; + + out[h] = parsed; + } catch (_) { + // ignore + } + })); + + return out; + } + + Future> _processChunksToMap({ + required List items, + required int chunkSize, + required Future> Function(List chunk) processChunk, + void Function(List chunk, Object error)? onChunkError, + }) async { + final result = {}; + + for (var i = 0; i < items.length; i += chunkSize) { + final end = (i + chunkSize < items.length) ? i + chunkSize : items.length; + final chunk = items.sublist(i, end); + + try { + final chunkResult = await processChunk(chunk); + result.addAll(chunkResult); + } on electrum.RequestFailedTimeoutException catch (e) { + onChunkError?.call(chunk, e); + continue; + } catch (e) { + onChunkError?.call(chunk, e); + continue; + } + } + + return result; + } + + Future updateTransactions() async { printV("updateTransactions() called!"); try { @@ -2719,6 +3401,43 @@ abstract class ElectrumWalletBase return base64Encode(decodedSig); } + Future _checkIfBatchSupported() async { + + if (_isBatchSupported != null) { + printV('[BATCH_TEST] Already checked: $_isBatchSupported'); + return; + } + + final hashes = publicScriptHashes.take(batchTestHashesCount).toList(); + + if (hashes.length < batchTestHashesCount) { + _isBatchSupported = false; + printV('[BATCH_TEST] Failed: not enough script hashes'); + return; + } + + try { + final paramsList = hashes.map((hash) => [hash]).toList(); + + printV('[BATCH_TEST] Start: hashes=${hashes.length}, timeout=${batchTestTimeoutMs}ms'); + + await electrumClient.callBatchWithTimeout( + method: 'blockchain.scripthash.get_history', + paramsList: paramsList, + timeout: batchTestTimeoutMs, + ); + + _isBatchSupported = true; + printV('[BATCH_TEST] Result: supported=true'); + } on electrum.RequestFailedTimeoutException catch (e) { + _isBatchSupported = false; + printV('[BATCH_TEST] Timeout: $e'); + } catch (e) { + _isBatchSupported = false; + printV('[BATCH_TEST] Exception: $e'); + } + } + @override Future verifyMessage(String message, String signature, {String? address = null}) async { if (address == null) { diff --git a/cw_bitcoin/lib/electrum_wallet_addresses.dart b/cw_bitcoin/lib/electrum_wallet_addresses.dart index 9c1620a8e..2cfad69dd 100644 --- a/cw_bitcoin/lib/electrum_wallet_addresses.dart +++ b/cw_bitcoin/lib/electrum_wallet_addresses.dart @@ -704,7 +704,43 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { } } - Future _generateInitialAddresses( + @action + Future discoverAddressesBatch( + List addressList, + bool isHidden, + Future> Function(List) getUsedAddresses, { + BitcoinAddressType type = SegwitAddresType.p2wpkh, + }) async { + final newAddresses = await _createNewAddresses( + gap, + startIndex: addressList.length, + isHidden: isHidden, + type: type, + ); + addAddresses(newAddresses); + + final usedAddresses = await getUsedAddresses(newAddresses); + final isLastAddressUsed = + newAddresses.isNotEmpty && usedAddresses.contains(newAddresses.last.address); + + if (!isLastAddressUsed) { + return false; + } + + final updatedAddressList = [...addressList, ...newAddresses]; + + await discoverAddressesBatch( + updatedAddressList, + isHidden, + getUsedAddresses, + type: type, + ); + + return true; + } + + + Future _generateInitialAddresses( {BitcoinAddressType type = SegwitAddresType.p2wpkh, bool isLegacyDerivation = false }) async { From 3b614ba04a3536315461bddd45fcd2ada2c8e46a Mon Sep 17 00:00:00 2001 From: malik1004x Date: Mon, 13 Apr 2026 19:52:48 +0200 Subject: [PATCH 4/5] disable swap frome xternal when switching to wallet-supported currency (#3175) --- lib/new-ui/pages/swap_page.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/new-ui/pages/swap_page.dart b/lib/new-ui/pages/swap_page.dart index c3b88bfd8..2e4ab06de 100644 --- a/lib/new-ui/pages/swap_page.dart +++ b/lib/new-ui/pages/swap_page.dart @@ -390,6 +390,8 @@ class _NewSwapPageState extends State { final isCurrentTypeWallet = exchangeViewModel.useSameWalletAddress(currency); if (key == depositKey && !isCurrentTypeWallet) exchangeViewModel.isSendFromExternal = true; + if (key == depositKey && isCurrentTypeWallet) exchangeViewModel.isSendFromExternal = false; + key.currentState!.changeSelectedCurrency(currency); From 547fae891a7646821886ae9cef0ff5949c28762c Mon Sep 17 00:00:00 2001 From: Serhii <17529954+serhii-bor@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:05:26 +0300 Subject: [PATCH 5/5] show Bitcoin address derivation path (#3177) * show Bitcoin address derivation path * Revert "show Bitcoin address derivation path" * Update cw_bitcoin/lib/bitcoin_address_record.dart [skip ci] * Update cw_bitcoin/lib/bitcoin_address_record.dart [skip ci] --------- Co-authored-by: Omar Hatem --- assets/images/info_icon.svg | 21 +++++++++ cw_bitcoin/lib/bitcoin_address_record.dart | 45 ++++++++++++++++++ lib/bitcoin/cw_bitcoin.dart | 9 ++-- lib/di.dart | 6 +++ lib/new-ui/pages/addresses_page.dart | 10 ++++ .../widgets/addresses_page/address_info.dart | 46 +++++++++++++++++++ ...let_address_edit_or_create_view_model.dart | 3 ++ .../wallet_address_list_item.dart | 2 + .../wallet_address_list_view_model.dart | 5 +- tool/configure.dart | 7 ++- 10 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 assets/images/info_icon.svg create mode 100644 lib/new-ui/widgets/addresses_page/address_info.dart diff --git a/assets/images/info_icon.svg b/assets/images/info_icon.svg new file mode 100644 index 000000000..31518cd17 --- /dev/null +++ b/assets/images/info_icon.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/cw_bitcoin/lib/bitcoin_address_record.dart b/cw_bitcoin/lib/bitcoin_address_record.dart index 400e325be..d6de05051 100644 --- a/cw_bitcoin/lib/bitcoin_address_record.dart +++ b/cw_bitcoin/lib/bitcoin_address_record.dart @@ -54,6 +54,8 @@ abstract class BaseBitcoinAddressRecord { BitcoinAddressType type; + String get derivationPath; + String toJSON(); } @@ -118,6 +120,31 @@ class BitcoinAddressRecord extends BaseBitcoinAddressRecord { String? scriptHash; + static int _purposeForType(BitcoinAddressType type) { + if (type == P2pkhAddressType.p2pkh) return 44; + if (type == P2shAddressType.p2wpkhInP2sh) return 49; + if (type == SegwitAddresType.p2wsh) return 48; + if (type == SegwitAddresType.p2tr) return 86; + return 84; + } + + int _coinTypeForNetwork() { + if (!(network?.isMainnet ?? true)) return 1; + + switch (network) { + case BitcoinNetwork.mainnet: + return 0; + case LitecoinNetwork.mainnet: + return 2; + case BitcoinCashNetwork.mainnet: + return 145; + case DogecoinNetwork.mainnet: + return 3; + default: + return 0; + } + } + String getScriptHash(BasedUtxoNetwork network) { if (scriptHash != null) return scriptHash!; try { @@ -127,6 +154,21 @@ class BitcoinAddressRecord extends BaseBitcoinAddressRecord { } return scriptHash!; } + @override + String get derivationPath { + if (type == SegwitAddresType.mweb) { + return "m/1000'/$index"; + } + + final coinType = _coinTypeForNetwork(); + final purpose = _purposeForType(type); + final accountPath = isLegacyDerivation + ? electrum_path + : "m/$purpose'/$coinType'/0'"; + + final chain = isHidden ? 1 : 0; + return "$accountPath/$chain/$index"; + } @override String toJSON() => json.encode({ @@ -186,6 +228,9 @@ class BitcoinSilentPaymentAddressRecord extends BaseBitcoinAddressRecord { final String? silentPaymentTweak; final String spendDerivationPath; + @override + String get derivationPath => spendDerivationPath; + @override String toJSON() => json.encode({ 'address': address, diff --git a/lib/bitcoin/cw_bitcoin.dart b/lib/bitcoin/cw_bitcoin.dart index 62db2a63e..6f5b9bfa4 100644 --- a/lib/bitcoin/cw_bitcoin.dart +++ b/lib/bitcoin/cw_bitcoin.dart @@ -185,7 +185,8 @@ class CWBitcoin extends Bitcoin { txCount: addr.txCount, balance: addr.balance, isChange: addr.isHidden, - isLegacyDerivation: addr.isLegacyDerivation)) + isLegacyDerivation: addr.isLegacyDerivation, + derivationPath: addr.derivationPath)) .toList(); } @@ -574,7 +575,8 @@ class CWBitcoin extends Bitcoin { address: addr.address, txCount: addr.txCount, balance: addr.balance, - isChange: addr.isHidden)) + isChange: addr.isHidden, + derivationPath: addr.derivationPath)) .toList(); } @@ -589,7 +591,8 @@ class CWBitcoin extends Bitcoin { address: addr.address, txCount: addr.txCount, balance: addr.balance, - isChange: addr.isHidden)) + isChange: addr.isHidden, + derivationPath: addr.derivationPath)) .toList(); } diff --git a/lib/di.dart b/lib/di.dart index 715c88660..7a8a8185a 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -310,6 +310,7 @@ import 'buy/kryptonim/kryptonim.dart'; import 'buy/meld/meld_buy_provider.dart'; import 'dogecoin/dogecoin.dart'; import 'new-ui/viewmodels/card_customizer/card_customizer_bloc.dart'; +import 'new-ui/widgets/addresses_page/address_info.dart'; import 'src/screens/buy/buy_sell_page.dart'; final getIt = GetIt.instance; @@ -880,6 +881,11 @@ Future setup({ walletAddressEditOrCreateViewModel: getIt.get(param1: item))); + getIt.registerFactoryParam((dynamic item, _) => + AddressInfoPopup( + walletAddressEditOrCreateViewModel: + getIt.get(param1: item))); + getIt.registerFactoryParam((dynamic item, _) => ReceiveLabelModal( walletAddressEditOrCreateViewModel: diff --git a/lib/new-ui/pages/addresses_page.dart b/lib/new-ui/pages/addresses_page.dart index a7a81d282..9016116d0 100644 --- a/lib/new-ui/pages/addresses_page.dart +++ b/lib/new-ui/pages/addresses_page.dart @@ -4,6 +4,7 @@ import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/monero/monero.dart'; import 'package:cake_wallet/new-ui/long_press_popup.dart'; +import 'package:cake_wallet/new-ui/widgets/addresses_page/address_info.dart'; import 'package:cake_wallet/new-ui/widgets/addresses_page/address_label_input.dart'; import 'package:cake_wallet/new-ui/widgets/coins_page/cards/balance_card.dart'; import 'package:cake_wallet/new-ui/widgets/long_press_menu.dart'; @@ -355,6 +356,15 @@ class AddressRow extends StatelessWidget { onAddressHidden(); }, color: Theme.of(context).colorScheme.error), + LongPressMenuItem( + label: 'Info', + iconPath: "assets/images/info_icon.svg", + onSelected: () async { + Navigator.of(context, rootNavigator: true).pop(); + await showPopUp( + context: context, + builder: (context) => getIt.get(param1: item)); + }), ], ), child: AnimatedContainer( diff --git a/lib/new-ui/widgets/addresses_page/address_info.dart b/lib/new-ui/widgets/addresses_page/address_info.dart new file mode 100644 index 000000000..310b3d4e4 --- /dev/null +++ b/lib/new-ui/widgets/addresses_page/address_info.dart @@ -0,0 +1,46 @@ +import 'dart:ui'; + +import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_edit_or_create_view_model.dart'; +import 'package:flutter/material.dart'; + +class AddressInfoPopup extends StatelessWidget { + const AddressInfoPopup({ + super.key, + required this.walletAddressEditOrCreateViewModel, + }); + + final WalletAddressEditOrCreateViewModel walletAddressEditOrCreateViewModel; + + @override + Widget build(BuildContext context) { + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadiusGeometry.lerp( + BorderRadius.circular(12), + BorderRadius.circular(24), + 0.5, + ), + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Text('Index: ${walletAddressEditOrCreateViewModel.index}'), + SizedBox(height: 16), + Text( + 'Derivation Path: ${walletAddressEditOrCreateViewModel.derivationPath}'), + ], + ), + ), + ), + ]), + ); + } +} diff --git a/lib/view_model/wallet_address_list/wallet_address_edit_or_create_view_model.dart b/lib/view_model/wallet_address_list/wallet_address_edit_or_create_view_model.dart index 269b93241..52c5954d8 100644 --- a/lib/view_model/wallet_address_list/wallet_address_edit_or_create_view_model.dart +++ b/lib/view_model/wallet_address_list/wallet_address_edit_or_create_view_model.dart @@ -51,6 +51,9 @@ abstract class WalletAddressEditOrCreateViewModelBase with Store { _wallet.type == WalletType.litecoin || _wallet.type == WalletType.dogecoin; + String get derivationPath => _item?.derivationPath ?? ''; + String get index => _item?.id.toString() ?? ''; + Future save() async { try { state = AddressIsSaving(); diff --git a/lib/view_model/wallet_address_list/wallet_address_list_item.dart b/lib/view_model/wallet_address_list/wallet_address_list_item.dart index 2c5b0a3d9..97309173d 100644 --- a/lib/view_model/wallet_address_list/wallet_address_list_item.dart +++ b/lib/view_model/wallet_address_list/wallet_address_list_item.dart @@ -14,6 +14,7 @@ class WalletAddressListItem extends ListItem { this.isHidden = false, this.isManual = false, this.isLegacyDerivation = false, + this.derivationPath }) : super(); final int? id; @@ -26,6 +27,7 @@ class WalletAddressListItem extends ListItem { bool isHidden; bool isManual; bool isLegacyDerivation; + String? derivationPath; final bool? isOneTimeReceiveAddress; @override diff --git a/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart b/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart index a517cc24b..4fc003077 100644 --- a/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart +++ b/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart @@ -258,6 +258,7 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo balance: _appStore.amountParsingProxy .getDisplayCryptoString(address.balance, walletTypeToCryptoCurrency(type)), isChange: address.isChange, + derivationPath: address.derivationPath, ); }); addressList.addAll(addressItems); @@ -275,6 +276,7 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo .getDisplayCryptoString(address.balance, walletTypeToCryptoCurrency(type)), isChange: address.isChange, isOneTimeReceiveAddress: true, + derivationPath: address.derivationPath, ); }); addressList.addAll(receivedAddressItems); @@ -291,7 +293,8 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo balance: _appStore.amountParsingProxy .getDisplayCryptoString(subaddress.balance, walletTypeToCryptoCurrency(type)), isChange: subaddress.isChange, - isLegacyDerivation: subaddress.isLegacyDerivation); + isLegacyDerivation: subaddress.isLegacyDerivation, + derivationPath: subaddress.derivationPath); }); // don't show all 1000+ mweb addresses: diff --git a/tool/configure.dart b/tool/configure.dart index 8e8da7183..c6ee96bc6 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -161,7 +161,7 @@ import "package:breez_sdk_spark_flutter/src/rust/errors.dart"; const bitcoinCwPart = "part 'cw_bitcoin.dart';"; const bitcoinContent = """ - class ElectrumSubAddress { +class ElectrumSubAddress { ElectrumSubAddress({ required this.id, required this.name, @@ -169,13 +169,16 @@ import "package:breez_sdk_spark_flutter/src/rust/errors.dart"; required this.txCount, required this.balance, required this.isChange, - this.isLegacyDerivation = false}); + this.derivationPath, + this.isLegacyDerivation = false + }); final int id; final String name; final String address; final int txCount; final int balance; final bool isChange; + final String? derivationPath; final bool isLegacyDerivation; }