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 =