CW-1193-Add-batch-fetching-of-transactions-to-Electrum (#3104)
What changed, and why it matters
This commit rewrites how Cake Wallet talks to Electrum Bitcoin servers so it can request many transactions at once instead of one by one. The change is framed as a performance improvement. It also touches how incoming server responses are parsed and matched to waiting requests. There is no claim in the commit that this fixes a security bug, but any rewrite of network-and-state handling can introduce subtle correctness issues.
Treat as a functional/performance change rather than a confirmed security fix. Review the batch response parsing for robustness: ensure malformed or out-of-order Electrum responses cannot spoof task completion, that id parsing failures are handled safely, and that the timeout path cannot complete a completer twice. Consider fuzzing the _handleResponse batch branch and verifying the single-call fallback is always exercised when batch support is uncertain.
Security signals we found
Network protocol parsing changed: json.decode casts to Map<String, dynamic> removed, dynamic handling added for both List (batch) and Map (single) responses
New batch response demuxing relies on string splitting/parsing of response 'id' fields to reconstruct the internal task key
Batch capability is probed at runtime with a short timeout and cached in _isBatchSupported
Timeout logic added for batched socket calls via Timer; on timeout the completer is completed with an error and the task is removed
Transaction history update logic now removes transactions 'no longer returned by the api, presumed replaced/invalid' when address history is empty or missing entries
New recursive address discovery (discoverAddressesBatch) and chunked history fetching added
No explicit security framing, CVE, or advisory referenced in commit message or diff
Evidence from the diff
The patch adds batch JSON-RPC support to ElectrumClient (callBatchWithTimeout, getBatchHistory, getBatchTransactionVerbose, getBatchTransactionHex) and refactors _handleResponse to demux batched replies by parsing id strings of the form ‘baseId-index’. ElectrumWallet gains batch transaction fetching paths, chunked processing, a batch-capability probe, and fallback to single-call flow. Response parsing is loosened: json.decode results are no longer immediately cast to Map
Changed components
cw_bitcoin/lib/electrum.dartcw_bitcoin/lib/electrum_wallet.dartcw_bitcoin/lib/electrum_wallet_addresses.dartInspect captured patch +952 / −29
diff --git a/cw_bitcoin/lib/electrum.dart b/cw_bitcoin/lib/electrum.dart
index e7993d4c..09518342 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<String, dynamic>;
+ 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<String, dynamic>;
+ 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<String, dynamic>;
- _handleResponse(response);
+ final response = json.decode(unterminatedString);
// unterminatedString = null;
unterminatedString = '';
}
@@ -303,6 +302,137 @@ class ElectrumClient {
return '';
});
+ Future<Map<String, List<Map<String, dynamic>>>> getBatchHistory(
+ List<String> scriptHashes, {
+ int timeout = 10000,
+ }) async {
+ final paramsList = scriptHashes.map((h) => <Object>[h]).toList(growable: false);
+
+ final batchResults = await callBatchWithTimeout(
+ method: 'blockchain.scripthash.get_history',
+ paramsList: paramsList,
+ timeout: timeout,
+ );
+
+ final historyMap = <String, List<Map<String, dynamic>>>{};
+
+ 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<dynamic, dynamic>>()
+ .map((m) => m.map((k, v) => MapEntry(k.toString(), v)))
+ .cast<Map<String, dynamic>>()
+ .toList();
+ } else {
+ historyMap[sh] = const [];
+ }
+ }
+
+ return historyMap;
+ }
+
+ Future<Map<String, Map<String, dynamic>>> getBatchTransactionVerbose(
+ List<String> hashes, {
+ int timeout = 10000,
+ }) async {
+ final result = <String, Map<String, dynamic>>{};
+ if (hashes.isEmpty) return result;
+
+ final paramsList = hashes.map((h) => <Object>[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<String, dynamic>) {
+ result[txid] = r;
+ } else {
+ result[txid] = <String, dynamic>{};
+ }
+ }
+
+ return result;
+ }
+
+ Future<Map<String, String?>> getBatchTransactionHex(
+ List<String> hashes, {
+ int timeout = 10000,
+ }) async {
+ final result = <String, String?>{};
+ if (hashes.isEmpty) return result;
+
+ final paramsList = hashes.map((h) => <Object>[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<List<dynamic>> callBatchWithTimeout({
+ required String method,
+ required List<List<Object>> paramsList,
+ int timeout = 10000,
+ }) async {
+ if (!isConnected) return [];
+
+ final completer = Completer<List<dynamic>>();
+ final int batchBaseId = _id += 1;
+ final String internalBatchKey = "batch_$batchBaseId";
+
+ // Build the Batch Array
+ final List<Map<String, dynamic>> 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<String> broadcastTransaction(
{required String transactionRaw,
BasedUtxoNetwork? network,
@@ -569,35 +699,73 @@ class ElectrumClient {
}
}
- void _handleResponse(Map<String, dynamic> 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<String, dynamic>?;
- 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<String, dynamic>;
+ final String firstIdAttr = firstItem['id'].toString();
+
+ final String batchKey = firstIdAttr.contains('-')
+ ? "batch_${firstIdAttr.split('-')[0].replaceAll('batch_', '')}"
+ : firstIdAttr;
- if (method is String) {
- _methodHandler(method: method, request: response);
+ // 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<String, dynamic>) {
+ final method = response['method'];
+ final id = response['id'] as String?;
+ final result = response['result'];
+
+ try {
+ final error = response['error'] as Map<String, dynamic>?;
+ 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 f3e2b7f1..b7983fd4 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<SharedPreferences> 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<void> 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<Map<String, ElectrumTransactionInfo>> fetchTransactions() async {
try {
final Map<String, ElectrumTransactionInfo> 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<void> fetchTransactionsForAddressTypeBatch(
+ Map<String, ElectrumTransactionInfo> 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<BitcoinAddressRecord, String, ElectrumTransactionInfo>(
+ 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<Map<String, ElectrumTransactionInfo>> _fetchBatchAddressHistory(
+ List<BitcoinAddressRecord> addressRecords,
+ int? currentHeight,
+ int historyChunkSize) async {
+ String lastTxId = '';
+ bool didUpdateHistory = false;
+
+ try {
+ final Map<String, ElectrumTransactionInfo> historiesWithDetails = {};
+
+ // List of script hashes for the given address records
+ final scriptHashes = addressRecords.map((a) => a.getScriptHash(network)).toList();
+
+ final historyByScriptHash =
+ await _processChunksToMap<String, String, List<Map<String, dynamic>>>(
+ items: scriptHashes,
+ chunkSize: historyChunkSize,
+ processChunk: _getHistoryBatch
+ );
+
+ // Map scriptHash -> addressRecord
+ final byScriptHash = <String, BitcoinAddressRecord>{};
+ for (final a in addressRecords) {
+ byScriptHash[a.getScriptHash(network)] = a;
+ }
+
+ // Split into already-known txs vs missing txs
+ final missingHistoryItems = <Map<String, dynamic>>[];
+
+ 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 = <String, int?>{
+ 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<Map<String, Map<String, dynamic>>> _getTransactionVerboseBatch(
+ List<String> hashes) {
+ return electrumClient.getBatchTransactionVerbose(
+ hashes,
+ timeout: transactionBatchTimeoutMs,
+ );
+ }
+
+ Future<Map<String, String?>> _getTransactionHexBatch(
+ List<String> hashes) {
+ return electrumClient.getBatchTransactionHex(
+ hashes,
+ timeout: transactionBatchTimeoutMs,
+ );
+ }
+
+ Future<Map<String, List<Map<String, dynamic>>>> _getHistoryBatch(
+ List<String> scriptHashes) {
+ return electrumClient.getBatchHistory(
+ scriptHashes,
+ timeout: transactionBatchTimeoutMs,
+ );
+ }
+
+ Future<Map<String, ElectrumTransactionInfo?>> fetchTransactionInfoBatch({
+ required List<String> hashes,
+ Map<String, int?>? heightsByHash,
+ bool retryOnFailure = false,
+ Duration retryDelay = const Duration(seconds: 2),
+ }) async {
+ final result = <String, ElectrumTransactionInfo?>{};
+ 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<void> _processTransactionInfoBatch({
+ required List<String> txIds,
+ required Map<String, ElectrumTransactionInfo?> result,
+ required Map<String, int?>? 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<Map<String, ElectrumTransactionBundle>> getTransactionExpandedBatch({
+ required List<String> hashes,
+ Map<String, int?>? heightsByHash}) async {
+ final bundles = <String, ElectrumTransactionBundle>{};
+ 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<Map<String, Map<String, dynamic>>> _fetchTransactionVerboseBatch(
+ List<String> txIds) async {
+
+ final verboseTransactionByHash = await _processChunksToMap<String, String, Map<String, dynamic>>(
+ items: txIds,
+ chunkSize: transactionChunkSize,
+ processChunk: _getTransactionVerboseBatch,
+ );
+
+ final emptyHex = <String>[];
+ for (final txId in txIds) {
+ final vTx = verboseTransactionByHash[txId];
+ if (vTx == null || vTx.isEmpty || vTx['hex'] == null) {
+ emptyHex.add(txId);
+ }
+ }
+
+ final hexByHash = await _processChunksToMap<String, String, String?>(
+ items: emptyHex,
+ chunkSize: transactionChunkSize,
+ processChunk: _getTransactionHexBatch,
+ );
+
+ for (final txId in txIds) {
+ final verbose = verboseTransactionByHash[txId] ?? <String, dynamic>{};
+ if ((verbose['hex'] as String?) == null) {
+ final hex = hexByHash[txId];
+ if (hex != null && hex.isNotEmpty) {
+ verboseTransactionByHash[txId] = {
+ ...verbose,
+ 'hex': hex,
+ };
+ }
+ }
+ }
+
+ return verboseTransactionByHash;
+ }
+
+ Map<String, BtcTransaction> _parseTransactions(
+ Map<String, Map<String, dynamic>> verboseByHash,
+ ) {
+ final result = <String, BtcTransaction>{};
+
+ 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<String, List<String>> _collectInputTxIdsByHash(
+ Map<String, BtcTransaction> originalByHash,
+ ) {
+ final inputTxIdsByHash = <String, List<String>>{};
+
+ for (final entry in originalByHash.entries) {
+ final txId = entry.key;
+ final original = entry.value;
+
+ final inputTxIds = <String>[];
+ for (final vin in original.inputs) {
+ inputTxIds.add(vin.txId);
+ }
+
+ inputTxIdsByHash[txId] = inputTxIds;
+ }
+
+ return inputTxIdsByHash;
+ }
+
+
+ Future<Map<String, Map<String, dynamic>>> _fetchInputTransactionVerboseBatch(
+ Map<String, List<String>> inputTxidsByHash) async {
+ final allInputTxids = <String>{};
+ for (final txids in inputTxidsByHash.values) {
+ allInputTxids.addAll(txids);
+ }
+
+ final inputTxIds = allInputTxids.toList(growable: false);
+
+ final verboseTransactionByHash =
+ await _processChunksToMap<String, String, Map<String, dynamic>>(
+ 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 = <String>[];
+ for (final txId in inputTxIds) {
+ final vTx = verboseTransactionByHash[txId];
+ if (vTx == null || vTx.isEmpty || vTx['hex'] == null) {
+ emptyHex.add(txId);
+ }
+ }
+
+ final hexByHash = await _processChunksToMap<String, String, String?>(
+ items: emptyHex,
+ chunkSize: inputTransactionChunkSize,
+ processChunk: _getTransactionHexBatch,
+ );
+
+ for (final txId in inputTxIds) {
+ final verbose = verboseTransactionByHash[txId] ?? <String, dynamic>{};
+ if ((verbose['hex'] as String?) == null) {
+ final hex = hexByHash[txId];
+ if (hex != null && hex.isNotEmpty) {
+ verboseTransactionByHash[txId] = {
+ ...verbose,
+ 'hex': hex,
+ };
+ }
+ }
+ }
+
+ return verboseTransactionByHash;
+ }
+
+
+ Future<Map<String, ElectrumTransactionBundle>> _buildTransactionBundlesBatch({
+ required List<String> unique,
+ required Map<String, int?>? heightsByHash,
+ required int tip,
+ required Map<String, BtcTransaction> originalByHash,
+ required Map<String, Map<String, dynamic>> verboseByHash,
+ required Map<String, List<String>> inputTxidsByHash,
+ required Map<String, BtcTransaction> parsedInputTxById,
+ }) async {
+ final bundles = <String, ElectrumTransactionBundle>{};
+
+ // Identify heights that need mempool timestamp lookup
+ final heightsNeedingTime = <int>{};
+ for (final txid in originalByHash.keys) {
+ final verbose = verboseByHash[txid] ?? const <String, dynamic>{};
+ 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 <String, dynamic>{};
+
+ 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 = <BtcTransaction>[];
+ final inputTxids = inputTxidsByHash[txid] ?? const <String>[];
+
+ 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<Map<int, int>> _fetchBlockTimestampsFromMempoolByHeights(
+ Set<int> heights,
+ ) async {
+ final out = <int, int>{};
+ 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<String, dynamic> ? 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<Map<K, V>> _processChunksToMap<T, K, V>({
+ required List<T> items,
+ required int chunkSize,
+ required Future<Map<K, V>> Function(List<T> chunk) processChunk,
+ void Function(List<T> chunk, Object error)? onChunkError,
+ }) async {
+ final result = <K, V>{};
+
+ 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<void> updateTransactions() async {
printV("updateTransactions() called!");
try {
@@ -2719,6 +3401,43 @@ abstract class ElectrumWalletBase
return base64Encode(decodedSig);
}
+ Future<void> _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) => <Object>[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<bool> 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 9c1620a8..2cfad69d 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<void> _generateInitialAddresses(
+ @action
+ Future<bool> discoverAddressesBatch(
+ List<BitcoinAddressRecord> addressList,
+ bool isHidden,
+ Future<Set<String>> Function(List<BitcoinAddressRecord>) 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<void> _generateInitialAddresses(
{BitcoinAddressType type = SegwitAddresType.p2wpkh,
bool isLegacyDerivation = false }) async {
Why this scored 25/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.