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

Serhii committed Apr 13, 2026 at 20:22 UTC 7b2c9d51303ce0fc38aecd7d4a1cdd595e91a6ec
3 files changed +952 -29
cw_bitcoin/lib/electrum.dart
+194 -26
@@ -144,7 +144,7 @@ class ElectrumClient {
144
145 void _parseResponse(String message) {
146 try {
147 - final response = json.decode(message) as Map<String, dynamic>;
147 + final response = json.decode(message);
148 _handleResponse(response);
149 } on FormatException catch (e) {
150 final msg = e.message.toLowerCase();
@@ -159,7 +159,7 @@ class ElectrumClient {
159 }
160
161 if (isJSONStringCorrect(unterminatedString)) {
162 - final response = json.decode(unterminatedString) as Map<String, dynamic>;
162 + final response = json.decode(unterminatedString);
163 _handleResponse(response);
164 unterminatedString = '';
165 }
@@ -172,8 +172,7 @@ class ElectrumClient {
172 unterminatedString += message;
173
174 if (isJSONStringCorrect(unterminatedString)) {
175 - final response = json.decode(unterminatedString) as Map<String, dynamic>;
176 - _handleResponse(response);
175 + final response = json.decode(unterminatedString);
176 // unterminatedString = null;
177 unterminatedString = '';
178 }
@@ -303,6 +302,137 @@ class ElectrumClient {
302 return '';
303 });
304
305 + Future<Map<String, List<Map<String, dynamic>>>> getBatchHistory(
306 + List<String> scriptHashes, {
307 + int timeout = 10000,
308 + }) async {
309 + final paramsList = scriptHashes.map((h) => <Object>[h]).toList(growable: false);
310 +
311 + final batchResults = await callBatchWithTimeout(
312 + method: 'blockchain.scripthash.get_history',
313 + paramsList: paramsList,
314 + timeout: timeout,
315 + );
316 +
317 + final historyMap = <String, List<Map<String, dynamic>>>{};
318 +
319 + for (int i = 0; i < scriptHashes.length; i++) {
320 + final sh = scriptHashes[i];
321 +
322 + if (i >= batchResults.length) {
323 + historyMap[sh] = const [];
324 + continue;
325 + }
326 +
327 + final result = batchResults[i];
328 +
329 + if (result is List) {
330 + historyMap[sh] = result
331 + .whereType<Map<dynamic, dynamic>>()
332 + .map((m) => m.map((k, v) => MapEntry(k.toString(), v)))
333 + .cast<Map<String, dynamic>>()
334 + .toList();
335 + } else {
336 + historyMap[sh] = const [];
337 + }
338 + }
339 +
340 + return historyMap;
341 + }
342 +
343 + Future<Map<String, Map<String, dynamic>>> getBatchTransactionVerbose(
344 + List<String> hashes, {
345 + int timeout = 10000,
346 + }) async {
347 + final result = <String, Map<String, dynamic>>{};
348 + if (hashes.isEmpty) return result;
349 +
350 + final paramsList = hashes.map((h) => <Object>[h, true]).toList(growable: false);
351 + final batchResults = await callBatchWithTimeout(
352 + method: 'blockchain.transaction.get',
353 + paramsList: paramsList,
354 + timeout: timeout,
355 + );
356 +
357 + for (var i = 0; i < hashes.length; i++) {
358 + final txid = hashes[i];
359 + final r = (i < batchResults.length) ? batchResults[i] : null;
360 + if (r is Map<String, dynamic>) {
361 + result[txid] = r;
362 + } else {
363 + result[txid] = <String, dynamic>{};
364 + }
365 + }
366 +
367 + return result;
368 + }
369 +
370 + Future<Map<String, String?>> getBatchTransactionHex(
371 + List<String> hashes, {
372 + int timeout = 10000,
373 + }) async {
374 + final result = <String, String?>{};
375 + if (hashes.isEmpty) return result;
376 +
377 + final paramsList = hashes.map((h) => <Object>[h]).toList(growable: false);
378 + final batchResults = await callBatchWithTimeout(
379 + method: 'blockchain.transaction.get',
380 + paramsList: paramsList,
381 + timeout: timeout,
382 + );
383 +
384 + for (var i = 0; i < hashes.length; i++) {
385 + final txid = hashes[i];
386 + final r = (i < batchResults.length) ? batchResults[i] : null;
387 + if (r is String && r.isNotEmpty) {
388 + result[txid] = r;
389 + } else {
390 + result[txid] = null;
391 + }
392 + }
393 +
394 + return result;
395 + }
396 +
397 + Future<List<dynamic>> callBatchWithTimeout({
398 + required String method,
399 + required List<List<Object>> paramsList,
400 + int timeout = 10000,
401 + }) async {
402 + if (!isConnected) return [];
403 +
404 + final completer = Completer<List<dynamic>>();
405 + final int batchBaseId = _id += 1;
406 + final String internalBatchKey = "batch_$batchBaseId";
407 +
408 + // Build the Batch Array
409 + final List<Map<String, dynamic>> batchPayload = [];
410 + for (int i = 0; i < paramsList.length; i++) {
411 + batchPayload.add({
412 + "jsonrpc": "2.0",
413 + "method": method,
414 + "params": paramsList[i],
415 + "id": "$batchBaseId-$i"
416 + });
417 + }
418 +
419 + // Register the task
420 + _tasks[internalBatchKey] = SocketTask(completer: completer, isSubscription: false);
421 +
422 + // Write to socket
423 + socket!.write(json.encode(batchPayload) + "\n");
424 +
425 + // Timeout Logic
426 + Timer(Duration(milliseconds: timeout), () {
427 + if (!completer.isCompleted) {
428 + _tasks.remove(internalBatchKey);
429 + completer.completeError(RequestFailedTimeoutException("BATCH_$method", batchBaseId));
430 + }
431 + });
432 +
433 + return completer.future;
434 + }
435 +
436 Future<String> broadcastTransaction(
437 {required String transactionRaw,
438 BasedUtxoNetwork? network,
@@ -569,35 +699,73 @@ class ElectrumClient {
699 }
700 }
701
572 - void _handleResponse(Map<String, dynamic> response) {
573 - final method = response['method'];
574 - final id = response['id'] as String?;
575 - final result = response['result'];
702 + void _handleResponse(dynamic response) {
703
577 - try {
578 - final error = response['error'] as Map<String, dynamic>?;
579 - if (error != null) {
580 - final errorMessage = error['message'] as String?;
581 - if (errorMessage != null) {
582 - _errors[id!] = errorMessage;
704 + // Handle batch response
705 + if (response is List) {
706 + if (response.isEmpty) return;
707 +
708 + // Sort responses by ID to ensure correct order for batch processing
709 + response.sort((a, b) {
710 + try {
711 + final idA = int.parse(a['id'].toString().split('-').last);
712 + final idB = int.parse(b['id'].toString().split('-').last);
713 + return idA.compareTo(idB);
714 + } catch (_) {
715 + return 0;
716 }
584 - }
585 - } catch (_) {}
717 + });
718
587 - try {
588 - final error = response['error'] as String?;
589 - if (error != null) {
590 - _errors[id!] = error;
591 - }
592 - } catch (_) {}
719 + final firstItem = response.first as Map<String, dynamic>;
720 + final String firstIdAttr = firstItem['id'].toString();
721 +
722 + final String batchKey = firstIdAttr.contains('-')
723 + ? "batch_${firstIdAttr.split('-')[0].replaceAll('batch_', '')}"
724 + : firstIdAttr;
725
594 - if (method is String) {
595 - _methodHandler(method: method, request: response);
726 + // Extract the results from each item in the batch
727 + final results = response.map((item) {
728 + if (item is Map) {
729 + return item['result'] ?? item['error'];
730 + }
731 + return null;
732 + }).toList();
733 +
734 + _finish(batchKey, results);
735 return;
736 }
737
599 - if (id != null) {
600 - _finish(id, result);
738 + // Handle single response
739 + if (response is Map<String, dynamic>) {
740 + final method = response['method'];
741 + final id = response['id'] as String?;
742 + final result = response['result'];
743 +
744 + try {
745 + final error = response['error'] as Map<String, dynamic>?;
746 + if (error != null) {
747 + final errorMessage = error['message'] as String?;
748 + if (errorMessage != null) {
749 + _errors[id!] = errorMessage;
750 + }
751 + }
752 + } catch (_) {}
753 +
754 + try {
755 + final error = response['error'] as String?;
756 + if (error != null) {
757 + _errors[id!] = error;
758 + }
759 + } catch (_) {}
760 +
761 + if (method is String) {
762 + _methodHandler(method: method, request: response);
763 + return;
764 + }
765 +
766 + if (id != null) {
767 + _finish(id, result);
768 + }
769 }
770 }
771
cw_bitcoin/lib/electrum_wallet.dart
+721 -2
@@ -279,6 +279,18 @@ abstract class ElectrumWalletBase
279 seedBytes, network != null ? getKeyNetVersion(network, hardwareWalletType) : null);
280 }
281
282 + static const int addressHistoryChunkSize = 150;
283 + static const int transactionChunkSize = 150;
284 + static const int inputTransactionChunkSize = 150;
285 + static const int discoveryHistoryChunkSize = 20;
286 +
287 + static const int transactionBatchTimeoutMs = 15000;
288 +
289 + static const int batchTestTimeoutMs = 4000;
290 + static const int batchTestHashesCount = 2;
291 +
292 + static const bool useBatchForHistory = true;
293 +
294 @observable
295 bool? alwaysScan;
296
@@ -360,6 +372,8 @@ abstract class ElectrumWalletBase
372 bool silentPaymentsScanningActive = false;
373
374 bool _isTryingToConnect = false;
375 + bool? _isBatchSupported;
376 + DateTime? _syncBenchmarkStartTime;
377
378 Completer<SharedPreferences> sharedPrefs = Completer();
379
@@ -639,6 +653,11 @@ abstract class ElectrumWalletBase
653 return;
654 }
655
656 + if (_syncBenchmarkStartTime == null) {
657 + _syncBenchmarkStartTime = DateTime.now();
658 + printV('[ELECTRUM_WALLET SYNC] Starting: ${_syncBenchmarkStartTime!}');
659 + }
660 +
661 syncStatus = SyncronizingSyncStatus();
662
663 if (hasSilentPaymentsScanning) {
@@ -671,6 +690,7 @@ abstract class ElectrumWalletBase
690 }
691
692 await subscribeForUpdates();
693 + await _checkIfBatchSupported();
694 await updateTransactions();
695
696 await updateAllUnspents();
@@ -686,11 +706,28 @@ abstract class ElectrumWalletBase
706 if (syncStatus is LostConnectionSyncStatus) {
707 return;
708 }
709 +
710 + final syncEnd = DateTime.now();
711 + final totalMs = _syncBenchmarkStartTime != null
712 + ? syncEnd.difference(_syncBenchmarkStartTime!).inMilliseconds
713 + : 0;
714 +
715 + printV('[ELECTRUM_WALLET SYNC] Finished: $syncEnd, took ${totalMs} ms');
716 +
717 + _syncBenchmarkStartTime = null;
718 syncStatus = SyncedSyncStatus();
719 }
720 } catch (e, stacktrace) {
721 + final syncEnd = DateTime.now();
722 + final totalMs = _syncBenchmarkStartTime != null
723 + ? syncEnd.difference(_syncBenchmarkStartTime!).inMilliseconds
724 + : 0;
725 +
726 printV(stacktrace);
727 printV("startSync $e");
728 + printV('[ELECTRUM_WALLET SYNC] Finished: $syncEnd, took ${totalMs} ms');
729 +
730 + _syncBenchmarkStartTime = null;
731 syncStatus = FailedSyncStatus();
732 }
733 }
@@ -786,6 +823,7 @@ abstract class ElectrumWalletBase
823 @override
824 Future<void> connectToNode({required Node node}) async {
825 this.node = node;
826 + _isBatchSupported = null;
827
828 if (syncStatus is ConnectingSyncStatus) return;
829
@@ -794,6 +832,7 @@ abstract class ElectrumWalletBase
832
833 await _receiveStream?.cancel();
834 await electrumClient.close();
835 + _isBatchSupported = null;
836
837 electrumClient.onConnectionStatusChange = _onConnectionStatusChange;
838
@@ -1711,6 +1750,7 @@ abstract class ElectrumWalletBase
1750 try {
1751 await _receiveStream?.cancel();
1752 await electrumClient.close();
1753 + _isBatchSupported = null;
1754 } catch (_) {}
1755 _autoSaveTimer?.cancel();
1756 _updateFeeRateTimer?.cancel();
@@ -2337,10 +2377,14 @@ abstract class ElectrumWalletBase
2377 Future<Map<String, ElectrumTransactionInfo>> fetchTransactions() async {
2378 try {
2379 final Map<String, ElectrumTransactionInfo> historiesWithDetails = {};
2380 + final shouldUseBatchForHistory = useBatchForHistory && _isBatchSupported == true;
2381 +
2382 + printV('[BATCH_TEST] Fetching transactions with batch: $shouldUseBatchForHistory');
2383
2384 if (type == WalletType.bitcoin) {
2342 - await Future.wait(BITCOIN_ADDRESS_TYPES
2343 - .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type)));
2385 + await Future.wait(BITCOIN_ADDRESS_TYPES.map((type) => shouldUseBatchForHistory
2386 + ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type)
2387 + : fetchTransactionsForAddressType(historiesWithDetails, type)));
2388 } else if (type == WalletType.bitcoinCash) {
2389 await Future.wait(BITCOIN_CASH_ADDRESS_TYPES
2390 .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type)));
@@ -2508,6 +2552,644 @@ abstract class ElectrumWalletBase
2552 }
2553 }
2554
2555 + Future<void> fetchTransactionsForAddressTypeBatch(
2556 + Map<String, ElectrumTransactionInfo> historiesWithDetails, BitcoinAddressType type) async {
2557 + final addressesByType =
2558 + walletAddresses.allAddresses.where((addr) => addr.type == type).toList();
2559 + final hiddenAddresses = addressesByType.where((addr) => addr.isHidden).toList();
2560 + final receiveAddresses = addressesByType.where((addr) => !addr.isHidden).toList();
2561 + walletAddresses.hiddenAddresses.addAll(hiddenAddresses.map((e) => e.address));
2562 + await walletAddresses.saveAddressesInBox();
2563 +
2564 + final tip = await getCurrentChainTip();
2565 +
2566 + final addressHistory = await _processChunksToMap<BitcoinAddressRecord, String, ElectrumTransactionInfo>(
2567 + items: addressesByType,
2568 + chunkSize: addressHistoryChunkSize,
2569 + processChunk: (chunk) => _fetchBatchAddressHistory(chunk, tip, addressHistoryChunkSize),
2570 + );
2571 +
2572 + if (addressHistory.isNotEmpty) historiesWithDetails.addAll(addressHistory);
2573 +
2574 + for (final addressRecord in addressesByType) {
2575 + final matchedAddresses = addressRecord.isHidden ? hiddenAddresses : receiveAddresses;
2576 +
2577 + final isUsedAddressUnderGap =
2578 + matchedAddresses.indexOf(addressRecord) >=
2579 + matchedAddresses.length - ElectrumWalletAddressesBase.gap;
2580 +
2581 + if (isUsedAddressUnderGap && addressRecord.isUsed) {
2582 + final prevLength = walletAddresses.allAddresses.length;
2583 +
2584 +
2585 + await walletAddresses.discoverAddressesBatch(
2586 + matchedAddresses,
2587 + addressRecord.isHidden,
2588 + (newAddresses) async {
2589 + await _fetchBatchAddressHistory(
2590 + newAddresses,
2591 + tip,
2592 + discoveryHistoryChunkSize,
2593 + );
2594 +
2595 + return newAddresses
2596 + .where((addressRecord) => addressRecord.isUsed)
2597 + .map((addressRecord) => addressRecord.address)
2598 + .toSet();
2599 + },
2600 + type: type,
2601 + );
2602 +
2603 + final newLength = walletAddresses.allAddresses.length;
2604 +
2605 + if (newLength > prevLength) {
2606 + await fetchTransactionsForAddressTypeBatch(
2607 + historiesWithDetails,
2608 + type);
2609 + return;
2610 + }
2611 + }
2612 + }
2613 + }
2614 +
2615 + Future<Map<String, ElectrumTransactionInfo>> _fetchBatchAddressHistory(
2616 + List<BitcoinAddressRecord> addressRecords,
2617 + int? currentHeight,
2618 + int historyChunkSize) async {
2619 + String lastTxId = '';
2620 + bool didUpdateHistory = false;
2621 +
2622 + try {
2623 + final Map<String, ElectrumTransactionInfo> historiesWithDetails = {};
2624 +
2625 + // List of script hashes for the given address records
2626 + final scriptHashes = addressRecords.map((a) => a.getScriptHash(network)).toList();
2627 +
2628 + final historyByScriptHash =
2629 + await _processChunksToMap<String, String, List<Map<String, dynamic>>>(
2630 + items: scriptHashes,
2631 + chunkSize: historyChunkSize,
2632 + processChunk: _getHistoryBatch
2633 + );
2634 +
2635 + // Map scriptHash -> addressRecord
2636 + final byScriptHash = <String, BitcoinAddressRecord>{};
2637 + for (final a in addressRecords) {
2638 + byScriptHash[a.getScriptHash(network)] = a;
2639 + }
2640 +
2641 + // Split into already-known txs vs missing txs
2642 + final missingHistoryItems = <Map<String, dynamic>>[];
2643 +
2644 + for (final entry in historyByScriptHash.entries) {
2645 + final sh = entry.key;
2646 + final addressRecord = byScriptHash[sh];
2647 + if (addressRecord == null) continue;
2648 +
2649 + final history = entry.value;
2650 + if (history.isEmpty) continue;
2651 +
2652 + addressRecord.setAsUsed();
2653 + walletAddresses.clearLockIfMatches(addressRecord.type, addressRecord.address);
2654 +
2655 + //removes transactions no longer returned by the api, presumed replaced/invalid.
2656 + if (this is BitcoinWallet) {
2657 + final beforeLen = transactionHistory.transactions.length;
2658 + transactionHistory.transactions.removeWhere((hash, tx) {
2659 + return tx.outputAddresses != null &&
2660 + tx.outputAddresses!.contains(addressRecord.address) &&
2661 + !history.any((h) => h['tx_hash'] == hash);
2662 + });
2663 + if (transactionHistory.transactions.length != beforeLen) {
2664 + didUpdateHistory = true;
2665 + }
2666 + }
2667 +
2668 + // 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.
2669 + for (final item in history) {
2670 + final txid = item['tx_hash'] as String?;
2671 + final height = item['height'] as int? ?? 0;
2672 + if (txid == null || txid.isEmpty) continue;
2673 +
2674 + lastTxId = txid;
2675 +
2676 + final storedTx = transactionHistory.transactions[txid];
2677 + if (storedTx != null) {
2678 + if (height > 0) {
2679 + final oldHeight = storedTx.height;
2680 + final oldConfs = storedTx.confirmations;
2681 + final oldPending = storedTx.isPending;
2682 +
2683 + storedTx.height = height;
2684 +
2685 + if ((currentHeight ?? 0) > 0) {
2686 + storedTx.confirmations = currentHeight! - height + 1;
2687 + }
2688 +
2689 + storedTx.isPending = storedTx.confirmations == 0;
2690 +
2691 + if (storedTx.height != oldHeight ||
2692 + storedTx.confirmations != oldConfs ||
2693 + storedTx.isPending != oldPending) {
2694 + transactionHistory.addOne(storedTx);
2695 + didUpdateHistory = true;
2696 + }
2697 + }
2698 +
2699 + historiesWithDetails[txid] = storedTx;
2700 + } else {
2701 + missingHistoryItems.add({
2702 + 'tx_hash': txid,
2703 + 'height': height,
2704 + 'script_hash': sh,
2705 + 'address': addressRecord.address,
2706 + });
2707 + }
2708 + }
2709 + }
2710 +
2711 + // Batch fetch missing tx verbose details
2712 + if (missingHistoryItems.isEmpty) {
2713 + if (didUpdateHistory) await transactionHistory.save();
2714 + return historiesWithDetails;
2715 + }
2716 +
2717 + for (var i = 0; i < missingHistoryItems.length; i += historyChunkSize) {
2718 + final end = (i + historyChunkSize < missingHistoryItems.length)
2719 + ? i + historyChunkSize
2720 + : missingHistoryItems.length;
2721 + final chunkHistory = missingHistoryItems.sublist(i, end);
2722 +
2723 + final hashes = chunkHistory
2724 + .map((e) => (e['tx_hash'] as String).trim())
2725 + .where((h) => h.isNotEmpty)
2726 + .toList(growable: false);
2727 +
2728 + final heightsByHash = <String, int?>{
2729 + for (final e in chunkHistory)
2730 + (e['tx_hash'] as String): (e['height'] as int?),
2731 + };
2732 +
2733 + final infosByHash = await fetchTransactionInfoBatch(
2734 + hashes: hashes,
2735 + heightsByHash: heightsByHash,
2736 + retryOnFailure: true,
2737 + retryDelay: const Duration(seconds: 1),
2738 + );
2739 +
2740 + for (final txid in hashes) {
2741 + final tx = infosByHash[txid];
2742 + if (tx == null) continue;
2743 +
2744 + historiesWithDetails[tx.id] = tx;
2745 +
2746 + // Litecoin peg-out tagging
2747 + if (this is LitecoinWallet) {
2748 + for (final tx2 in transactionHistory.transactions.values) {
2749 + final heightDiff = ((tx2.height ?? 0) - (tx.height ?? 0)).abs();
2750 + if (tx2.additionalInfo["isPegOut"] == true &&
2751 + tx2.amount == tx.amount &&
2752 + heightDiff <= 5) {
2753 + tx.additionalInfo["fromPegOut"] = true;
2754 + }
2755 + }
2756 + }
2757 +
2758 + transactionHistory.addOne(tx);
2759 + didUpdateHistory = true;
2760 + }
2761 + }
2762 +
2763 + if (didUpdateHistory) {
2764 + await transactionHistory.save();
2765 + }
2766 +
2767 + return historiesWithDetails;
2768 + } catch (e, stacktrace) {
2769 + final prefix = lastTxId.isNotEmpty ? '$lastTxId - ' : '';
2770 + _onError?.call(FlutterErrorDetails(
2771 + exception: '$prefix$e',
2772 + stack: stacktrace,
2773 + library: runtimeType.toString(),
2774 + ));
2775 + return {};
2776 + }
2777 + }
2778 +
2779 + Future<Map<String, Map<String, dynamic>>> _getTransactionVerboseBatch(
2780 + List<String> hashes) {
2781 + return electrumClient.getBatchTransactionVerbose(
2782 + hashes,
2783 + timeout: transactionBatchTimeoutMs,
2784 + );
2785 + }
2786 +
2787 + Future<Map<String, String?>> _getTransactionHexBatch(
2788 + List<String> hashes) {
2789 + return electrumClient.getBatchTransactionHex(
2790 + hashes,
2791 + timeout: transactionBatchTimeoutMs,
2792 + );
2793 + }
2794 +
2795 + Future<Map<String, List<Map<String, dynamic>>>> _getHistoryBatch(
2796 + List<String> scriptHashes) {
2797 + return electrumClient.getBatchHistory(
2798 + scriptHashes,
2799 + timeout: transactionBatchTimeoutMs,
2800 + );
2801 + }
2802 +
2803 + Future<Map<String, ElectrumTransactionInfo?>> fetchTransactionInfoBatch({
2804 + required List<String> hashes,
2805 + Map<String, int?>? heightsByHash,
2806 + bool retryOnFailure = false,
2807 + Duration retryDelay = const Duration(seconds: 2),
2808 + }) async {
2809 + final result = <String, ElectrumTransactionInfo?>{};
2810 + final uniqueHashes =
2811 + hashes.map((h) => h.trim()).where((h) => h.isNotEmpty).toSet().toList();
2812 +
2813 + if (uniqueHashes.isEmpty) return result;
2814 +
2815 + await _processTransactionInfoBatch(
2816 + txIds: uniqueHashes,
2817 + result: result,
2818 + heightsByHash: heightsByHash,
2819 + );
2820 +
2821 + if (retryOnFailure) {
2822 + final failedHashes = uniqueHashes.where((txId) => result[txId] == null).toList();
2823 +
2824 + if (failedHashes.isNotEmpty) {
2825 + await Future.delayed(retryDelay);
2826 +
2827 + await _processTransactionInfoBatch(
2828 + txIds: failedHashes,
2829 + result: result,
2830 + heightsByHash: heightsByHash,
2831 + );
2832 + }
2833 + }
2834 +
2835 + return result;
2836 + }
2837 +
2838 + Future<void> _processTransactionInfoBatch({
2839 + required List<String> txIds,
2840 + required Map<String, ElectrumTransactionInfo?> result,
2841 + required Map<String, int?>? heightsByHash,
2842 + }) async {
2843 + for (var i = 0; i < txIds.length; i += transactionChunkSize) {
2844 + final end = (i + transactionChunkSize < txIds.length)
2845 + ? i + transactionChunkSize
2846 + : txIds.length;
2847 + final chunk = txIds.sublist(i, end);
2848 +
2849 + final bundlesByHash = await getTransactionExpandedBatch(
2850 + hashes: chunk,
2851 + heightsByHash: heightsByHash,
2852 + );
2853 +
2854 + for (final txId in chunk) {
2855 + try {
2856 + final bundle = bundlesByHash[txId];
2857 + if (bundle == null) {
2858 + result[txId] = null;
2859 + continue;
2860 + }
2861 +
2862 + final info = ElectrumTransactionInfo.fromElectrumBundle(
2863 + bundle,
2864 + walletInfo.type,
2865 + network,
2866 + addresses: addressesSet,
2867 + height: heightsByHash?[txId],
2868 + );
2869 + info.id = txId;
2870 + result[txId] = info;
2871 + } catch (_) {
2872 + result[txId] = null;
2873 + }
2874 + }
2875 + }
2876 + }
2877 +
2878 + Future<Map<String, ElectrumTransactionBundle>> getTransactionExpandedBatch({
2879 + required List<String> hashes,
2880 + Map<String, int?>? heightsByHash}) async {
2881 + final bundles = <String, ElectrumTransactionBundle>{};
2882 + if (hashes.isEmpty) return bundles;
2883 +
2884 + final verboseByHash = await _fetchTransactionVerboseBatch(hashes);
2885 +
2886 + final originalByHash = _parseTransactions(verboseByHash);
2887 +
2888 + final inputTxIdsByHash = _collectInputTxIdsByHash(originalByHash);
2889 +
2890 + final inputVerboseByTxId = await _fetchInputTransactionVerboseBatch(
2891 + inputTxIdsByHash);
2892 +
2893 + final parsedInputTxById = _parseTransactions(inputVerboseByTxId);
2894 +
2895 + return _buildTransactionBundlesBatch(
2896 + unique: hashes,
2897 + heightsByHash: heightsByHash,
2898 + tip: await getUpdatedChainTip(),
2899 + originalByHash: originalByHash,
2900 + verboseByHash: verboseByHash,
2901 + inputTxidsByHash: inputTxIdsByHash,
2902 + parsedInputTxById: parsedInputTxById,
2903 + );
2904 + }
2905 +
2906 + Future<Map<String, Map<String, dynamic>>> _fetchTransactionVerboseBatch(
2907 + List<String> txIds) async {
2908 +
2909 + final verboseTransactionByHash = await _processChunksToMap<String, String, Map<String, dynamic>>(
2910 + items: txIds,
2911 + chunkSize: transactionChunkSize,
2912 + processChunk: _getTransactionVerboseBatch,
2913 + );
2914 +
2915 + final emptyHex = <String>[];
2916 + for (final txId in txIds) {
2917 + final vTx = verboseTransactionByHash[txId];
2918 + if (vTx == null || vTx.isEmpty || vTx['hex'] == null) {
2919 + emptyHex.add(txId);
2920 + }
2921 + }
2922 +
2923 + final hexByHash = await _processChunksToMap<String, String, String?>(
2924 + items: emptyHex,
2925 + chunkSize: transactionChunkSize,
2926 + processChunk: _getTransactionHexBatch,
2927 + );
2928 +
2929 + for (final txId in txIds) {
2930 + final verbose = verboseTransactionByHash[txId] ?? <String, dynamic>{};
2931 + if ((verbose['hex'] as String?) == null) {
2932 + final hex = hexByHash[txId];
2933 + if (hex != null && hex.isNotEmpty) {
2934 + verboseTransactionByHash[txId] = {
2935 + ...verbose,
2936 + 'hex': hex,
2937 + };
2938 + }
2939 + }
2940 + }
2941 +
2942 + return verboseTransactionByHash;
2943 + }
2944 +
2945 + Map<String, BtcTransaction> _parseTransactions(
2946 + Map<String, Map<String, dynamic>> verboseByHash,
2947 + ) {
2948 + final result = <String, BtcTransaction>{};
2949 +
2950 + for (final entry in verboseByHash.entries) {
2951 + final hex = entry.value['hex'] as String?;
2952 + if (hex == null || hex.isEmpty) continue;
2953 +
2954 + try {
2955 + result[entry.key] = BtcTransaction.fromRaw(hex);
2956 + } catch (_) {}
2957 + }
2958 +
2959 + return result;
2960 + }
2961 +
2962 +
2963 + Map<String, List<String>> _collectInputTxIdsByHash(
2964 + Map<String, BtcTransaction> originalByHash,
2965 + ) {
2966 + final inputTxIdsByHash = <String, List<String>>{};
2967 +
2968 + for (final entry in originalByHash.entries) {
2969 + final txId = entry.key;
2970 + final original = entry.value;
2971 +
2972 + final inputTxIds = <String>[];
2973 + for (final vin in original.inputs) {
2974 + inputTxIds.add(vin.txId);
2975 + }
2976 +
2977 + inputTxIdsByHash[txId] = inputTxIds;
2978 + }
2979 +
2980 + return inputTxIdsByHash;
2981 + }
2982 +
2983 +
2984 + Future<Map<String, Map<String, dynamic>>> _fetchInputTransactionVerboseBatch(
2985 + Map<String, List<String>> inputTxidsByHash) async {
2986 + final allInputTxids = <String>{};
2987 + for (final txids in inputTxidsByHash.values) {
2988 + allInputTxids.addAll(txids);
2989 + }
2990 +
2991 + final inputTxIds = allInputTxids.toList(growable: false);
2992 +
2993 + final verboseTransactionByHash =
2994 + await _processChunksToMap<String, String, Map<String, dynamic>>(
2995 + items: inputTxIds,
2996 + chunkSize: inputTransactionChunkSize,
2997 + processChunk: _getTransactionVerboseBatch,
2998 + onChunkError: (chunk, error) {
2999 + if (error is electrum.RequestFailedTimeoutException) {
3000 + printV(
3001 + 'fetchInputTransactionVerboseBatch timeout for ${chunk.length} txs: ${error.method}',
3002 + );
3003 + } else {
3004 + printV(
3005 + 'fetchInputTransactionVerboseBatch failed for ${chunk.length} txs: $error,',
3006 + );
3007 + }
3008 + },
3009 + );
3010 +
3011 + final emptyHex = <String>[];
3012 + for (final txId in inputTxIds) {
3013 + final vTx = verboseTransactionByHash[txId];
3014 + if (vTx == null || vTx.isEmpty || vTx['hex'] == null) {
3015 + emptyHex.add(txId);
3016 + }
3017 + }
3018 +
3019 + final hexByHash = await _processChunksToMap<String, String, String?>(
3020 + items: emptyHex,
3021 + chunkSize: inputTransactionChunkSize,
3022 + processChunk: _getTransactionHexBatch,
3023 + );
3024 +
3025 + for (final txId in inputTxIds) {
3026 + final verbose = verboseTransactionByHash[txId] ?? <String, dynamic>{};
3027 + if ((verbose['hex'] as String?) == null) {
3028 + final hex = hexByHash[txId];
3029 + if (hex != null && hex.isNotEmpty) {
3030 + verboseTransactionByHash[txId] = {
3031 + ...verbose,
3032 + 'hex': hex,
3033 + };
3034 + }
3035 + }
3036 + }
3037 +
3038 + return verboseTransactionByHash;
3039 + }
3040 +
3041 +
3042 + Future<Map<String, ElectrumTransactionBundle>> _buildTransactionBundlesBatch({
3043 + required List<String> unique,
3044 + required Map<String, int?>? heightsByHash,
3045 + required int tip,
3046 + required Map<String, BtcTransaction> originalByHash,
3047 + required Map<String, Map<String, dynamic>> verboseByHash,
3048 + required Map<String, List<String>> inputTxidsByHash,
3049 + required Map<String, BtcTransaction> parsedInputTxById,
3050 + }) async {
3051 + final bundles = <String, ElectrumTransactionBundle>{};
3052 +
3053 + // Identify heights that need mempool timestamp lookup
3054 + final heightsNeedingTime = <int>{};
3055 + for (final txid in originalByHash.keys) {
3056 + final verbose = verboseByHash[txid] ?? const <String, dynamic>{};
3057 + final time = verbose['time'] as int?;
3058 + final h = heightsByHash?[txid];
3059 + if (time == null && h != null && h > 0) {
3060 + heightsNeedingTime.add(h);
3061 + }
3062 + }
3063 +
3064 + final mempoolTimes = await _fetchBlockTimestampsFromMempoolByHeights(heightsNeedingTime);
3065 +
3066 + for (final txid in unique) {
3067 + final original = originalByHash[txid];
3068 + if (original == null) continue;
3069 +
3070 + final verbose = verboseByHash[txid] ?? const <String, dynamic>{};
3071 +
3072 + int? time = verbose['time'] as int?;
3073 + int? confirmations = verbose['confirmations'] as int?;
3074 + final h = heightsByHash?[txid];
3075 +
3076 + if (h != null) {
3077 + if (time == null && h > 0) {
3078 + final mp = mempoolTimes[h];
3079 + time = mp ?? (getDateByBitcoinHeight(h).millisecondsSinceEpoch / 1000).round();
3080 + }
3081 +
3082 + if (confirmations == null && tip > 0 && h > 0) {
3083 + confirmations = tip - h + 1;
3084 + if (confirmations < 0) confirmations = 0;
3085 + }
3086 + }
3087 +
3088 + final ins = <BtcTransaction>[];
3089 + final inputTxids = inputTxidsByHash[txid] ?? const <String>[];
3090 +
3091 + bool allInputsPresent = true;
3092 + for (final inputTxid in inputTxids) {
3093 + final inTx = parsedInputTxById[inputTxid];
3094 + if (inTx == null) {
3095 + allInputsPresent = false;
3096 + break;
3097 + }
3098 + ins.add(inTx);
3099 + }
3100 +
3101 + if (!allInputsPresent || ins.length != original.inputs.length) {
3102 + continue;
3103 + }
3104 +
3105 + bundles[txid] = ElectrumTransactionBundle(
3106 + original,
3107 + ins: ins,
3108 + time: time,
3109 + confirmations: confirmations ?? 0,
3110 + );
3111 + }
3112 +
3113 + return bundles;
3114 + }
3115 +
3116 + Future<Map<int, int>> _fetchBlockTimestampsFromMempoolByHeights(
3117 + Set<int> heights,
3118 + ) async {
3119 + final out = <int, int>{};
3120 + if (heights.isEmpty) return out;
3121 + if (!(await checkIfMempoolAPIIsEnabled())) return out;
3122 +
3123 + // Best-effort: if any call fails, we just skip that height.
3124 + await Future.wait(heights.map((h) async {
3125 + try {
3126 + final blockHashResp = await ProxyWrapper()
3127 + .get(
3128 + clearnetUri: Uri.parse(
3129 + 'https://mempool.cakewallet.com/api/v1/block-height/$h',
3130 + ),
3131 + )
3132 + .timeout(const Duration(seconds: 15));
3133 +
3134 + if (blockHashResp.statusCode != 200 || blockHashResp.body.isEmpty) return;
3135 +
3136 + final blockHash = blockHashResp.body.trim();
3137 + if (blockHash.isEmpty) return;
3138 +
3139 + final blockResp = await ProxyWrapper()
3140 + .get(
3141 + clearnetUri: Uri.parse(
3142 + 'https://mempool.cakewallet.com/api/v1/block/$blockHash',
3143 + ),
3144 + )
3145 + .timeout(const Duration(seconds: 15));
3146 +
3147 + if (blockResp.statusCode != 200 || blockResp.body.isEmpty) return;
3148 +
3149 + final decoded = jsonDecode(blockResp.body);
3150 + final ts = decoded is Map<String, dynamic> ? decoded['timestamp'] : null;
3151 + if (ts == null) return;
3152 +
3153 + final parsed = int.tryParse(ts.toString());
3154 + if (parsed == null) return;
3155 +
3156 + out[h] = parsed;
3157 + } catch (_) {
3158 + // ignore
3159 + }
3160 + }));
3161 +
3162 + return out;
3163 + }
3164 +
3165 + Future<Map<K, V>> _processChunksToMap<T, K, V>({
3166 + required List<T> items,
3167 + required int chunkSize,
3168 + required Future<Map<K, V>> Function(List<T> chunk) processChunk,
3169 + void Function(List<T> chunk, Object error)? onChunkError,
3170 + }) async {
3171 + final result = <K, V>{};
3172 +
3173 + for (var i = 0; i < items.length; i += chunkSize) {
3174 + final end = (i + chunkSize < items.length) ? i + chunkSize : items.length;
3175 + final chunk = items.sublist(i, end);
3176 +
3177 + try {
3178 + final chunkResult = await processChunk(chunk);
3179 + result.addAll(chunkResult);
3180 + } on electrum.RequestFailedTimeoutException catch (e) {
3181 + onChunkError?.call(chunk, e);
3182 + continue;
3183 + } catch (e) {
3184 + onChunkError?.call(chunk, e);
3185 + continue;
3186 + }
3187 + }
3188 +
3189 + return result;
3190 + }
3191 +
3192 +
3193 Future<void> updateTransactions() async {
3194 printV("updateTransactions() called!");
3195 try {
@@ -2719,6 +3401,43 @@ abstract class ElectrumWalletBase
3401 return base64Encode(decodedSig);
3402 }
3403
3404 + Future<void> _checkIfBatchSupported() async {
3405 +
3406 + if (_isBatchSupported != null) {
3407 + printV('[BATCH_TEST] Already checked: $_isBatchSupported');
3408 + return;
3409 + }
3410 +
3411 + final hashes = publicScriptHashes.take(batchTestHashesCount).toList();
3412 +
3413 + if (hashes.length < batchTestHashesCount) {
3414 + _isBatchSupported = false;
3415 + printV('[BATCH_TEST] Failed: not enough script hashes');
3416 + return;
3417 + }
3418 +
3419 + try {
3420 + final paramsList = hashes.map((hash) => <Object>[hash]).toList();
3421 +
3422 + printV('[BATCH_TEST] Start: hashes=${hashes.length}, timeout=${batchTestTimeoutMs}ms');
3423 +
3424 + await electrumClient.callBatchWithTimeout(
3425 + method: 'blockchain.scripthash.get_history',
3426 + paramsList: paramsList,
3427 + timeout: batchTestTimeoutMs,
3428 + );
3429 +
3430 + _isBatchSupported = true;
3431 + printV('[BATCH_TEST] Result: supported=true');
3432 + } on electrum.RequestFailedTimeoutException catch (e) {
3433 + _isBatchSupported = false;
3434 + printV('[BATCH_TEST] Timeout: $e');
3435 + } catch (e) {
3436 + _isBatchSupported = false;
3437 + printV('[BATCH_TEST] Exception: $e');
3438 + }
3439 + }
3440 +
3441 @override
3442 Future<bool> verifyMessage(String message, String signature, {String? address = null}) async {
3443 if (address == null) {
cw_bitcoin/lib/electrum_wallet_addresses.dart
+37 -1
@@ -704,7 +704,43 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
704 }
705 }
706
707 - Future<void> _generateInitialAddresses(
707 + @action
708 + Future<bool> discoverAddressesBatch(
709 + List<BitcoinAddressRecord> addressList,
710 + bool isHidden,
711 + Future<Set<String>> Function(List<BitcoinAddressRecord>) getUsedAddresses, {
712 + BitcoinAddressType type = SegwitAddresType.p2wpkh,
713 + }) async {
714 + final newAddresses = await _createNewAddresses(
715 + gap,
716 + startIndex: addressList.length,
717 + isHidden: isHidden,
718 + type: type,
719 + );
720 + addAddresses(newAddresses);
721 +
722 + final usedAddresses = await getUsedAddresses(newAddresses);
723 + final isLastAddressUsed =
724 + newAddresses.isNotEmpty && usedAddresses.contains(newAddresses.last.address);
725 +
726 + if (!isLastAddressUsed) {
727 + return false;
728 + }
729 +
730 + final updatedAddressList = [...addressList, ...newAddresses];
731 +
732 + await discoverAddressesBatch(
733 + updatedAddressList,
734 + isHidden,
735 + getUsedAddresses,
736 + type: type,
737 + );
738 +
739 + return true;
740 + }
741 +
742 +
743 + Future<void> _generateInitialAddresses(
744 {BitcoinAddressType type = SegwitAddresType.p2wpkh,
745 bool isLegacyDerivation = false }) async {
746