Cw 1438 v2 (#3252)

* refactor address discovery and response handling * refactor input tx fetching and mweb tagging * add batch unspent fetching * add batch balance fetching * extend batch fetching to all Electrum wallets * minor fixes * [skip ci] Update cw_bitcoin/lib/electrum_wallet.dart Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com> * Revert "minor fixes" This reverts commit 631e6258d25cc8717178bbc2b39407443f030677. * used addresses anywhere in gap * refresh receive and change addresses on set * add isLegacyDerivation flag to address flows --------- Co-authored-by: Serhii <17529954+serhii-bor@users.noreply.github.com>

Omar Hatem committed May 28, 2026 at 17:15 UTC 997201e0809351de005f8de76d6017e9edb5e35a
4 files changed +395 -158
cw_bitcoin/lib/electrum.dart
+75
@@ -173,6 +173,7 @@ class ElectrumClient {
173
174 if (isJSONStringCorrect(unterminatedString)) {
175 final response = json.decode(unterminatedString);
176 + _handleResponse(response);
177 // unterminatedString = null;
178 unterminatedString = '';
179 }
@@ -340,6 +341,80 @@ class ElectrumClient {
341 return historyMap;
342 }
343
344 + Future<Map<String, List<Map<String, dynamic>>>> getBatchUnspent(
345 + List<String> scriptHashes, {
346 + int timeout = 10000,
347 + }) async {
348 + final paramsList = scriptHashes.map((h) => <Object>[h]).toList(growable: false);
349 +
350 + final batchResults = await callBatchWithTimeout(
351 + method: 'blockchain.scripthash.listunspent',
352 + paramsList: paramsList,
353 + timeout: timeout,
354 + );
355 +
356 + final unspentMap = <String, List<Map<String, dynamic>>>{};
357 +
358 + for (int i = 0; i < scriptHashes.length; i++) {
359 + final sh = scriptHashes[i];
360 +
361 + if (i >= batchResults.length) {
362 + unspentMap[sh] = const [];
363 + continue;
364 + }
365 +
366 + final result = batchResults[i];
367 +
368 + if (result is List) {
369 + unspentMap[sh] = result
370 + .whereType<Map<dynamic, dynamic>>()
371 + .map((m) => m.map((k, v) => MapEntry(k.toString(), v)))
372 + .cast<Map<String, dynamic>>()
373 + .toList();
374 + } else {
375 + unspentMap[sh] = const [];
376 + }
377 + }
378 +
379 + return unspentMap;
380 + }
381 +
382 + Future<Map<String, Map<String, dynamic>>> getBatchBalance(
383 + List<String> scriptHashes, {
384 + int timeout = 10000,
385 + }) async {
386 + final paramsList = scriptHashes.map((h) => <Object>[h]).toList(growable: false);
387 +
388 + final batchResults = await callBatchWithTimeout(
389 + method: 'blockchain.scripthash.get_balance',
390 + paramsList: paramsList,
391 + timeout: timeout,
392 + );
393 +
394 + final balanceMap = <String, Map<String, dynamic>>{};
395 +
396 + for (int i = 0; i < scriptHashes.length; i++) {
397 + final sh = scriptHashes[i];
398 +
399 + if (i >= batchResults.length) {
400 + balanceMap[sh] = <String, dynamic>{};
401 + continue;
402 + }
403 +
404 + final result = batchResults[i];
405 +
406 + if (result is Map<String, dynamic>) {
407 + balanceMap[sh] = result;
408 + } else if (result is Map) {
409 + balanceMap[sh] = Map<String, dynamic>.from(result);
410 + } else {
411 + balanceMap[sh] = <String, dynamic>{};
412 + }
413 + }
414 +
415 + return balanceMap;
416 + }
417 +
418 Future<Map<String, Map<String, dynamic>>> getBatchTransactionVerbose(
419 List<String> hashes, {
420 int timeout = 10000,
cw_bitcoin/lib/electrum_wallet.dart
+306 -151
@@ -367,6 +367,8 @@ abstract class ElectrumWalletBase
367
368 String get xpub => accountHD.publicKey.toExtended;
369
370 + bool get shouldUseBatchFetching => useBatchForHistory && _isBatchSupported == true;
371 +
372 @override
373 String? get seed => _mnemonic;
374
@@ -708,7 +710,7 @@ abstract class ElectrumWalletBase
710 }
711
712 await subscribeForUpdates();
711 - await _checkIfBatchSupported();
713 + await checkIfBatchSupported();
714 await updateTransactions();
715
716 await updateAllUnspents();
@@ -1793,18 +1795,21 @@ abstract class ElectrumWalletBase
1795 }
1796
1797 // Set the balance of all non-silent payment and non-mweb addresses to 0 before updating
1796 - walletAddresses.allAddresses
1797 - .where((element) => element.type != SegwitAddresType.mweb)
1798 - .forEach((addr) {
1799 - if (addr is! BitcoinSilentPaymentAddressRecord) addr.balance = 0;
1800 - });
1798
1802 - final addressFutures = walletAddresses.allAddresses
1799 + final targetAddresses = walletAddresses.allAddresses
1800 .where((element) => element.type != SegwitAddresType.mweb)
1804 - .map((address) => fetchUnspent(address))
1801 .toList();
1802
1807 - final results = await Future.wait(addressFutures);
1803 + for (final addr in targetAddresses) {
1804 + if (addr is! BitcoinSilentPaymentAddressRecord) {
1805 + addr.balance = 0;
1806 + }
1807 + }
1808 +
1809 + final results = shouldUseBatchFetching
1810 + ? await _fetchUnspentsBatch(targetAddresses)
1811 + : await _fetchUnspentsRegular(targetAddresses);
1812 +
1813 final failedCount = results.where((result) => result == null).length;
1814
1815 if (failedCount == 0) {
@@ -1836,6 +1841,74 @@ abstract class ElectrumWalletBase
1841 await _refreshUnspentCoinsInfo();
1842 }
1843
1844 + Future<List<List<BitcoinUnspent>?>> _fetchUnspentsRegular(
1845 + List<BitcoinAddressRecord> addresses,
1846 + ) async {
1847 + final addressFutures = addresses.map((address) => fetchUnspent(address)).toList();
1848 + return Future.wait(addressFutures);
1849 + }
1850 +
1851 +
1852 + Future<List<List<BitcoinUnspent>?>> _fetchUnspentsBatch(
1853 + List<BitcoinAddressRecord> addresses,
1854 + ) async {
1855 + final byScriptHash = <String, BitcoinAddressRecord>{
1856 + for (final address in addresses) address.getScriptHash(network): address,
1857 + };
1858 +
1859 + final scriptHashes = byScriptHash.keys.toList();
1860 +
1861 + try {
1862 + final unspentByScriptHash =
1863 + await _processChunksToMap<String, String, List<Map<String, dynamic>>>(
1864 + items: scriptHashes,
1865 + chunkSize: addressHistoryChunkSize,
1866 + processChunk: _getListUnspentBatch,
1867 + );
1868 +
1869 + final txHashes = <String>{};
1870 + final coinsByScriptHash = <String, List<BitcoinUnspent>>{};
1871 +
1872 + for (final entry in unspentByScriptHash.entries) {
1873 + final addressRecord = byScriptHash[entry.key];
1874 + if (addressRecord == null) continue;
1875 +
1876 + final coins = <BitcoinUnspent>[];
1877 +
1878 + for (final unspent in entry.value) {
1879 + final coin = BitcoinUnspent.fromJSON(addressRecord, unspent);
1880 + coin.isChange = addressRecord.isHidden;
1881 + coins.add(coin);
1882 + txHashes.add(coin.hash);
1883 + }
1884 +
1885 + coinsByScriptHash[entry.key] = coins;
1886 + }
1887 +
1888 + final txInfoByHash = await fetchTransactionInfoBatch(
1889 + hashes: txHashes.toList(),
1890 + retryOnFailure: true,
1891 + retryDelay: const Duration(seconds: 1),
1892 + );
1893 +
1894 + for (final coins in coinsByScriptHash.values) {
1895 + for (final coin in coins) {
1896 + final tx = txInfoByHash[coin.hash];
1897 + coin.confirmations = tx?.confirmations;
1898 + coin.isPegOut = tx?.isHogEx;
1899 + }
1900 + }
1901 +
1902 + return addresses.map((address) {
1903 + final scriptHash = address.getScriptHash(network);
1904 + return coinsByScriptHash[scriptHash] ?? <BitcoinUnspent>[];
1905 + }).toList();
1906 + } catch (e) {
1907 + printV('fetchUnspentsBatch failed: $e');
1908 + return List<List<BitcoinUnspent>?>.filled(addresses.length, null);
1909 + }
1910 + }
1911 +
1912 List<BitcoinUnspent> handleFailedUtxoFetch({
1913 required int failedCount,
1914 required List<BitcoinUnspent> previousUnspentCoins,
@@ -2395,25 +2468,30 @@ abstract class ElectrumWalletBase
2468 @override
2469 Future<Map<String, ElectrumTransactionInfo>> fetchTransactions() async {
2470 try {
2398 - final Map<String, ElectrumTransactionInfo> historiesWithDetails = {};
2399 - final shouldUseBatchForHistory = useBatchForHistory && _isBatchSupported == true;
2471 + final Map<String, ElectrumTransactionInfo> historiesWithDetails = {};;
2472
2401 - printV('[BATCH_TEST] Fetching transactions with batch: $shouldUseBatchForHistory');
2473 + printV('[BATCH_TEST] Fetching transactions with batch: $shouldUseBatchFetching');
2474
2475 if (type == WalletType.bitcoin) {
2404 - await Future.wait(BITCOIN_ADDRESS_TYPES.map((type) => shouldUseBatchForHistory
2476 + await Future.wait(BITCOIN_ADDRESS_TYPES.map((type) => shouldUseBatchFetching
2477 ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type)
2478 : fetchTransactionsForAddressType(historiesWithDetails, type)));
2479 } else if (type == WalletType.bitcoinCash) {
2480 await Future.wait(BITCOIN_CASH_ADDRESS_TYPES
2409 - .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type)));
2481 + .map((type) => shouldUseBatchFetching
2482 + ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type)
2483 + : fetchTransactionsForAddressType(historiesWithDetails, type)));
2484 } else if (type == WalletType.litecoin) {
2485 await Future.wait(LITECOIN_ADDRESS_TYPES
2486 .where((type) => type != SegwitAddresType.mweb)
2413 - .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type)));
2487 + .map((type) => shouldUseBatchFetching
2488 + ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type)
2489 + : fetchTransactionsForAddressType(historiesWithDetails, type)));
2490 } else if (type == WalletType.dogecoin) {
2491 await Future.wait(DOGECOIN_ADDRESS_TYPES
2416 - .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type)));
2492 + .map((type) => shouldUseBatchFetching
2493 + ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type)
2494 + : fetchTransactionsForAddressType(historiesWithDetails, type)));
2495 }
2496
2497 transactionHistory.transactions.values.forEach((tx) async {
@@ -2457,13 +2535,13 @@ abstract class ElectrumWalletBase
2535 historiesWithDetails.addAll(history);
2536
2537 final matchedAddresses = addressRecord.isHidden ? hiddenAddresses : receiveAddresses;
2460 - final isUsedAddressUnderGap = matchedAddresses.toList().indexOf(addressRecord) >=
2538 + final isUsedAddressAboveGap = matchedAddresses.toList().indexOf(addressRecord) >=
2539 matchedAddresses.length -
2540 (addressRecord.isHidden
2541 ? ElectrumWalletAddressesBase.defaultChangeAddressesCount
2542 : ElectrumWalletAddressesBase.defaultReceiveAddressesCount);
2543
2466 - if (isUsedAddressUnderGap) {
2544 + if (isUsedAddressAboveGap) {
2545 final prevLength = walletAddresses.allAddresses.length;
2546
2547 // Discover new addresses for the same address type until the gap limit is respected
@@ -2538,19 +2616,7 @@ abstract class ElectrumWalletBase
2616 // Got a new transaction fetched, add it to the transaction history
2617 // instead of waiting all to finish, and next time it will be faster
2618
2541 - if (this is LitecoinWallet) {
2542 - // if we have a peg out transaction with the same value
2543 - // that matches this received transaction, mark it as being from a peg out:
2544 - for (final tx2 in transactionHistory.transactions.values) {
2545 - final heightDiff = ((tx2.height ?? 0) - (tx.height ?? 0)).abs();
2546 - // this isn't a perfect matching algorithm since we don't have the right input/output information from these transaction models (the addresses are in different formats), but this should be more than good enough for now as it's extremely unlikely a user receives the EXACT same amount from 2 different sources and one of them is a peg out and the other isn't WITHIN 5 blocks of each other
2547 - if (tx2.additionalInfo["isPegOut"] == true &&
2548 - tx2.amount == tx.amount &&
2549 - heightDiff <= 5) {
2550 - tx.additionalInfo["fromPegOut"] = true;
2551 - }
2552 - }
2553 - }
2619 + _applyLitecoinPegOutTag(tx);
2620 transactionHistory.addOne(tx);
2621 await transactionHistory.save();
2622 }
@@ -2572,65 +2638,128 @@ abstract class ElectrumWalletBase
2638 }
2639
2640 Future<void> fetchTransactionsForAddressTypeBatch(
2575 - Map<String, ElectrumTransactionInfo> historiesWithDetails, BitcoinAddressType type) async {
2641 + Map<String, ElectrumTransactionInfo> historiesWithDetails,
2642 + BitcoinAddressType type) async {
2643 final addressesByType =
2577 - walletAddresses.allAddresses.where((addr) => addr.type == type).toList();
2578 - final hiddenAddresses = addressesByType.where((addr) => addr.isHidden).toList();
2644 + walletAddresses.allAddresses.where((addr) => addr.type == type).toList();
2645 final receiveAddresses = addressesByType.where((addr) => !addr.isHidden).toList();
2646 + final hiddenAddresses = addressesByType.where((addr) => addr.isHidden).toList();
2647 +
2648 walletAddresses.hiddenAddresses.addAll(hiddenAddresses.map((e) => e.address));
2649 await walletAddresses.saveAddressesInBox();
2650
2651 + await fetchTransactionsForAddressesBranchBatch(
2652 + historiesWithDetails,
2653 + type,
2654 + receiveAddresses,
2655 + isHidden: false,
2656 + isLegacyDerivation: false,
2657 + );
2658 +
2659 + await fetchTransactionsForAddressesBranchBatch(
2660 + historiesWithDetails,
2661 + type,
2662 + hiddenAddresses,
2663 + isHidden: true,
2664 + isLegacyDerivation: false,
2665 + );
2666 +
2667 + await fetchTransactionsForAddressesBranchBatch(
2668 + historiesWithDetails,
2669 + type,
2670 + receiveAddresses,
2671 + isHidden: false,
2672 + isLegacyDerivation: true,
2673 + );
2674 +
2675 + await fetchTransactionsForAddressesBranchBatch(
2676 + historiesWithDetails,
2677 + type,
2678 + hiddenAddresses,
2679 + isHidden: true,
2680 + isLegacyDerivation: true,
2681 + );
2682 + }
2683 +
2684 +
2685 + Future<void> fetchTransactionsForAddressesBranchBatch(
2686 + Map<String, ElectrumTransactionInfo> historiesWithDetails,
2687 + BitcoinAddressType type,
2688 + List<BitcoinAddressRecord> branchAddresses, {
2689 + required bool isHidden,
2690 + required bool isLegacyDerivation,
2691 + }) async {
2692 + if (branchAddresses.isEmpty) return;
2693 +
2694 final tip = await getCurrentChainTip();
2695 + final currentBranch = [...branchAddresses];
2696
2585 - final addressHistory = await _processChunksToMap<BitcoinAddressRecord, String, ElectrumTransactionInfo>(
2586 - items: addressesByType,
2697 + final initialHistory =
2698 + await _processChunksToMap<BitcoinAddressRecord, String, ElectrumTransactionInfo>(
2699 + items: currentBranch,
2700 chunkSize: addressHistoryChunkSize,
2588 - processChunk: (chunk) => _fetchBatchAddressHistory(chunk, tip, addressHistoryChunkSize),
2701 + processChunk: (chunk) => _fetchBatchAddressHistory(
2702 + chunk,
2703 + tip,
2704 + addressHistoryChunkSize,
2705 + ),
2706 );
2707
2591 - if (addressHistory.isNotEmpty) historiesWithDetails.addAll(addressHistory);
2592 -
2593 - for (final addressRecord in addressesByType) {
2594 - final matchedAddresses = addressRecord.isHidden ? hiddenAddresses : receiveAddresses;
2708 + if (initialHistory.isNotEmpty) {
2709 + historiesWithDetails.addAll(initialHistory);
2710 + }
2711
2596 - final isUsedAddressUnderGap =
2597 - matchedAddresses.indexOf(addressRecord) >=
2598 - matchedAddresses.length - ElectrumWalletAddressesBase.gap;
2712 + final gapLimit = isHidden
2713 + ? ElectrumWalletAddressesBase.defaultChangeAddressesCount
2714 + : ElectrumWalletAddressesBase.defaultReceiveAddressesCount;
2715
2600 - if (isUsedAddressUnderGap && addressRecord.isUsed) {
2601 - final prevLength = walletAddresses.allAddresses.length;
2716 + final highestUsedIndex = _highestUsedIndex(currentBranch);
2717 + final shouldDiscover =
2718 + highestUsedIndex >= 0 && highestUsedIndex >= currentBranch.length - gapLimit;
2719
2720 + if (!shouldDiscover) return;
2721
2604 - await walletAddresses.discoverAddressesBatch(
2605 - matchedAddresses,
2606 - addressRecord.isHidden,
2607 - (newAddresses) async {
2608 - await _fetchBatchAddressHistory(
2609 - newAddresses,
2610 - tip,
2611 - discoveryHistoryChunkSize,
2612 - );
2722
2614 - return newAddresses
2615 - .where((addressRecord) => addressRecord.isUsed)
2616 - .map((addressRecord) => addressRecord.address)
2617 - .toSet();
2618 - },
2619 - type: type,
2723 + final newAddresses = await walletAddresses.discoverAddressesBatch(
2724 + currentBranch,
2725 + isHidden,
2726 + (newAddresses) async {
2727 + final newHistory = await _fetchBatchAddressHistory(
2728 + newAddresses,
2729 + tip,
2730 + discoveryHistoryChunkSize,
2731 );
2732
2622 - final newLength = walletAddresses.allAddresses.length;
2623 -
2624 - if (newLength > prevLength) {
2625 - await fetchTransactionsForAddressTypeBatch(
2626 - historiesWithDetails,
2627 - type);
2628 - return;
2733 + if (newHistory.isNotEmpty) {
2734 + historiesWithDetails.addAll(newHistory);
2735 }
2736 +
2737 + return newAddresses
2738 + .where((addressRecord) => addressRecord.isUsed)
2739 + .map((addressRecord) => addressRecord.address)
2740 + .toSet();
2741 + },
2742 + type: type,
2743 + isLegacyDerivation: isLegacyDerivation,
2744 + );
2745 +
2746 + if (newAddresses.isNotEmpty) {
2747 + currentBranch.addAll(newAddresses);
2748 +
2749 + if (isHidden) {
2750 + walletAddresses.hiddenAddresses.addAll(newAddresses.map((e) => e.address));
2751 + await walletAddresses.saveAddressesInBox();
2752 }
2753 }
2754 }
2755
2756 + int _highestUsedIndex(List<BitcoinAddressRecord> addresses) {
2757 + for (int i = addresses.length - 1; i >= 0; i--) {
2758 + if (addresses[i].isUsed) return i;
2759 + }
2760 + return -1;
2761 + }
2762 +
2763 Future<Map<String, ElectrumTransactionInfo>> _fetchBatchAddressHistory(
2764 List<BitcoinAddressRecord> addressRecords,
2765 int? currentHeight,
@@ -2762,17 +2891,7 @@ abstract class ElectrumWalletBase
2891
2892 historiesWithDetails[tx.id] = tx;
2893
2765 - // Litecoin peg-out tagging
2766 - if (this is LitecoinWallet) {
2767 - for (final tx2 in transactionHistory.transactions.values) {
2768 - final heightDiff = ((tx2.height ?? 0) - (tx.height ?? 0)).abs();
2769 - if (tx2.additionalInfo["isPegOut"] == true &&
2770 - tx2.amount == tx.amount &&
2771 - heightDiff <= 5) {
2772 - tx.additionalInfo["fromPegOut"] = true;
2773 - }
2774 - }
2775 - }
2894 + _applyLitecoinPegOutTag(tx);
2895
2896 transactionHistory.addOne(tx);
2897 didUpdateHistory = true;
@@ -2819,6 +2938,22 @@ abstract class ElectrumWalletBase
2938 );
2939 }
2940
2941 + Future<Map<String, List<Map<String, dynamic>>>> _getListUnspentBatch(
2942 + List<String> scriptHashes) {
2943 + return electrumClient.getBatchUnspent(
2944 + scriptHashes,
2945 + timeout: transactionBatchTimeoutMs,
2946 + );
2947 + }
2948 +
2949 + Future<Map<String, Map<String, dynamic>>> _getBalanceBatch(
2950 + List<String> scriptHashes) {
2951 + return electrumClient.getBatchBalance(
2952 + scriptHashes,
2953 + timeout: transactionBatchTimeoutMs,
2954 + );
2955 + }
2956 +
2957 Future<Map<String, ElectrumTransactionInfo?>> fetchTransactionInfoBatch({
2958 required List<String> hashes,
2959 Map<String, int?>? heightsByHash,
@@ -2906,8 +3041,16 @@ abstract class ElectrumWalletBase
3041
3042 final inputTxIdsByHash = _collectInputTxIdsByHash(originalByHash);
3043
2909 - final inputVerboseByTxId = await _fetchInputTransactionVerboseBatch(
2910 - inputTxIdsByHash);
3044 + final allInputTxids = <String>{};
3045 + for (final txids in inputTxIdsByHash.values) {
3046 + allInputTxids.addAll(txids);
3047 + }
3048 +
3049 + final inputTxIds = allInputTxids.toList(growable: false);
3050 +
3051 + final inputVerboseByTxId = inputTxIds.isEmpty
3052 + ? <String, Map<String, dynamic>>{}
3053 + : await _fetchTransactionVerboseBatch(inputTxIds);
3054
3055 final parsedInputTxById = _parseTransactions(inputVerboseByTxId);
3056
@@ -2999,65 +3142,6 @@ abstract class ElectrumWalletBase
3142 return inputTxIdsByHash;
3143 }
3144
3002 -
3003 - Future<Map<String, Map<String, dynamic>>> _fetchInputTransactionVerboseBatch(
3004 - Map<String, List<String>> inputTxidsByHash) async {
3005 - final allInputTxids = <String>{};
3006 - for (final txids in inputTxidsByHash.values) {
3007 - allInputTxids.addAll(txids);
3008 - }
3009 -
3010 - final inputTxIds = allInputTxids.toList(growable: false);
3011 -
3012 - final verboseTransactionByHash =
3013 - await _processChunksToMap<String, String, Map<String, dynamic>>(
3014 - items: inputTxIds,
3015 - chunkSize: inputTransactionChunkSize,
3016 - processChunk: _getTransactionVerboseBatch,
3017 - onChunkError: (chunk, error) {
3018 - if (error is electrum.RequestFailedTimeoutException) {
3019 - printV(
3020 - 'fetchInputTransactionVerboseBatch timeout for ${chunk.length} txs: ${error.method}',
3021 - );
3022 - } else {
3023 - printV(
3024 - 'fetchInputTransactionVerboseBatch failed for ${chunk.length} txs: $error,',
3025 - );
3026 - }
3027 - },
3028 - );
3029 -
3030 - final emptyHex = <String>[];
3031 - for (final txId in inputTxIds) {
3032 - final vTx = verboseTransactionByHash[txId];
3033 - if (vTx == null || vTx.isEmpty || vTx['hex'] == null) {
3034 - emptyHex.add(txId);
3035 - }
3036 - }
3037 -
3038 - final hexByHash = await _processChunksToMap<String, String, String?>(
3039 - items: emptyHex,
3040 - chunkSize: inputTransactionChunkSize,
3041 - processChunk: _getTransactionHexBatch,
3042 - );
3043 -
3044 - for (final txId in inputTxIds) {
3045 - final verbose = verboseTransactionByHash[txId] ?? <String, dynamic>{};
3046 - if ((verbose['hex'] as String?) == null) {
3047 - final hex = hexByHash[txId];
3048 - if (hex != null && hex.isNotEmpty) {
3049 - verboseTransactionByHash[txId] = {
3050 - ...verbose,
3051 - 'hex': hex,
3052 - };
3053 - }
3054 - }
3055 - }
3056 -
3057 - return verboseTransactionByHash;
3058 - }
3059 -
3060 -
3145 Future<Map<String, ElectrumTransactionBundle>> _buildTransactionBundlesBatch({
3146 required List<String> unique,
3147 required Map<String, int?>? heightsByHash,
@@ -3290,18 +3374,64 @@ abstract class ElectrumWalletBase
3374 }));
3375 }
3376
3377 + Future<List<Map<String, dynamic>>> fetchBalancesBatch(
3378 + List<BitcoinAddressRecord> addresses,
3379 + ) async {
3380 + final scriptHashes = addresses.map((address) => address.getScriptHash(network)).toList();
3381 +
3382 + if (scriptHashes.isEmpty) {
3383 + return <Map<String, dynamic>>[];
3384 + }
3385 +
3386 + try {
3387 + final balancesByScriptHash =
3388 + await _processChunksToMap<String, String, Map<String, dynamic>>(
3389 + items: scriptHashes,
3390 + chunkSize: addressHistoryChunkSize,
3391 + processChunk: _getBalanceBatch,
3392 + );
3393 +
3394 + final balances = scriptHashes
3395 + .map((scriptHash) => balancesByScriptHash[scriptHash] ?? <String, dynamic>{})
3396 + .toList();
3397 +
3398 + final hasMissingBalance = balances.any((balance) => balance['confirmed'] == null);
3399 + if (hasMissingBalance) {
3400 + printV('fetchBalancesBatch returned missing balances, falling back to regular flow');
3401 + return fetchBalancesRegular(addresses);
3402 + }
3403 +
3404 + return balances;
3405 + } catch (e) {
3406 + printV('fetchBalancesBatch failed, falling back to regular flow: $e');
3407 + return fetchBalancesRegular(addresses);
3408 + }
3409 + }
3410 +
3411 + Future<List<Map<String, dynamic>>> fetchBalancesRegular(
3412 + List<BitcoinAddressRecord> addresses,
3413 + ) async {
3414 + final balanceFutures = <Future<Map<String, dynamic>>>[];
3415 +
3416 + for (final address in addresses) {
3417 + final sh = address.getScriptHash(network);
3418 + balanceFutures.add(electrumClient.getBalance(sh));
3419 + }
3420 +
3421 + return Future.wait(balanceFutures);
3422 + }
3423 +
3424 Future<ElectrumBalance> fetchBalances() async {
3425 final addresses = walletAddresses.allAddresses
3426 .where((address) => address.address.isNotEmpty)
3427 .where((address) => RegexUtils.addressTypeFromStr(address.address, network) is! MwebAddress)
3428 .toList();
3298 - final balanceFutures = <Future<Map<String, dynamic>>>[];
3299 - for (var i = 0; i < addresses.length; i++) {
3300 - final addressRecord = addresses[i];
3301 - final sh = addressRecord.getScriptHash(network);
3302 - final balanceFuture = electrumClient.getBalance(sh);
3303 - balanceFutures.add(balanceFuture);
3304 - }
3429 +
3430 + final balances = shouldUseBatchFetching
3431 + ? await fetchBalancesBatch(addresses)
3432 + : await fetchBalancesRegular(addresses);
3433 +
3434 + printV('Fetched balances for ${addresses.length} addresses. Batch fetching: $shouldUseBatchFetching');
3435
3436 var totalFrozen = 0;
3437 var totalConfirmed = 0;
@@ -3337,8 +3467,6 @@ abstract class ElectrumWalletBase
3467 });
3468 });
3469
3340 - final balances = await Future.wait(balanceFutures);
3341 -
3470 if (balances.isNotEmpty && balances.first['confirmed'] == null) {
3471 // if we got null balance responses from the server, set our connection status to lost and return our last known balance:
3472 printV("got null balance responses from the server, setting connection status to lost");
@@ -3420,7 +3548,23 @@ abstract class ElectrumWalletBase
3548 return base64Encode(decodedSig);
3549 }
3550
3423 - Future<void> _checkIfBatchSupported() async {
3551 + void _applyLitecoinPegOutTag(ElectrumTransactionInfo tx) {
3552 + if (this is! LitecoinWallet) return;
3553 +
3554 + // if we have a peg out transaction with the same value
3555 + // that matches this received transaction, mark it as being from a peg out:
3556 + for (final tx2 in transactionHistory.transactions.values) {
3557 + final heightDiff = ((tx2.height ?? 0) - (tx.height ?? 0)).abs();
3558 + // this isn't a perfect matching algorithm since we don't have the right input/output information from these transaction models (the addresses are in different formats), but this should be more than good enough for now as it's extremely unlikely a user receives the EXACT same amount from 2 different sources and one of them is a peg out and the other isn't WITHIN 5 blocks of each other
3559 + if (tx2.additionalInfo["isPegOut"] == true &&
3560 + tx2.amount == tx.amount &&
3561 + heightDiff <= 5) {
3562 + tx.additionalInfo["fromPegOut"] = true;
3563 + }
3564 + }
3565 + }
3566 +
3567 + Future<void> checkIfBatchSupported() async {
3568
3569 if (_isBatchSupported != null) {
3570 printV('[BATCH_TEST] Already checked: $_isBatchSupported');
@@ -3440,12 +3584,23 @@ abstract class ElectrumWalletBase
3584
3585 printV('[BATCH_TEST] Start: hashes=${hashes.length}, timeout=${batchTestTimeoutMs}ms');
3586
3443 - await electrumClient.callBatchWithTimeout(
3587 + final result = await electrumClient.callBatchWithTimeout(
3588 method: 'blockchain.scripthash.get_history',
3589 paramsList: paramsList,
3590 timeout: batchTestTimeoutMs,
3591 );
3592
3593 + final hasError = result.any((item) =>
3594 + item is Map<String, dynamic> &&
3595 + item.containsKey('error') &&
3596 + item['error'] != null);
3597 +
3598 + if (hasError) {
3599 + _isBatchSupported = false;
3600 + printV('[BATCH_TEST] Result: supported=false (server returned error)');
3601 + return;
3602 + }
3603 +
3604 _isBatchSupported = true;
3605 printV('[BATCH_TEST] Result: supported=true');
3606 } on electrum.RequestFailedTimeoutException catch (e) {
cw_bitcoin/lib/electrum_wallet_addresses.dart
+13 -7
@@ -715,38 +715,42 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
715 }
716
717 @action
718 - Future<bool> discoverAddressesBatch(
718 + Future<List<BitcoinAddressRecord>> discoverAddressesBatch(
719 List<BitcoinAddressRecord> addressList,
720 bool isHidden,
721 Future<Set<String>> Function(List<BitcoinAddressRecord>) getUsedAddresses, {
722 BitcoinAddressType type = SegwitAddresType.p2wpkh,
723 + required bool isLegacyDerivation,
724 }) async {
725 final newAddresses = await _createNewAddresses(
726 gap,
727 startIndex: addressList.length,
728 isHidden: isHidden,
729 type: type,
730 + isLegacyDerivation: isLegacyDerivation,
731 );
732 addAddresses(newAddresses);
733
734 final usedAddresses = await getUsedAddresses(newAddresses);
733 - final isLastAddressUsed =
734 - newAddresses.isNotEmpty && usedAddresses.contains(newAddresses.last.address);
735
736 - if (!isLastAddressUsed) {
737 - return false;
736 + final hasUsedAddressInGap = newAddresses.any(
737 + (addressRecord) => usedAddresses.contains(addressRecord.address));
738 +
739 + if (!hasUsedAddressInGap) {
740 + return newAddresses;
741 }
742
743 final updatedAddressList = [...addressList, ...newAddresses];
744
742 - await discoverAddressesBatch(
745 + final moreNewAddresses = await discoverAddressesBatch(
746 updatedAddressList,
747 isHidden,
748 getUsedAddresses,
749 type: type,
750 + isLegacyDerivation: isLegacyDerivation,
751 );
752
749 - return true;
753 + return [...newAddresses, ...moreNewAddresses];
754 }
755
756
@@ -818,6 +822,8 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
822 this._addresses.clear();
823 this._addresses.addAll(addressesSet);
824 updateAddressesByMatch();
825 + updateReceiveAddresses();
826 + updateChangeAddresses();
827 }
828
829 @action
cw_bitcoin/lib/litecoin_wallet.dart
+1
@@ -381,6 +381,7 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
381 } catch (e) {
382 printV("failed to subscribe for updates: $e");
383 }
384 + await checkIfBatchSupported();
385 updateFeeRates();
386 _feeRatesTimer?.cancel();
387 _feeRatesTimer =