What changed, and why it matters
This commit refactors how Cake Wallet's Bitcoin-family wallets (Bitcoin, Litecoin, Bitcoin Cash, Dogecoin) talk to Electrum servers. It adds batch RPC calls for balances, unspent outputs, and transaction history, and fixes some address-discovery edge cases. There is no direct evidence in the commit that this fixes an active security vulnerability; it appears to be a performance and reliability improvement. However, any bug in wallet balance or transaction discovery logic can affect whether users see correct funds, so it has indirect financial-relevance.
Treat as a regular code-quality/performance refactor. Review the batch fallback paths and ensure that a partially failed batch cannot silently produce zero balances or missing transactions. If this commit is being evaluated for a security advisory, request the vendor's issue tracker entry or release notes to confirm whether it addresses a reported vulnerability.
Security signals we found
Batch Electrum RPC support added with fallback to single requests
Batch capability check now inspects server error responses, not just timeouts
Address gap discovery logic changed from 'last address used' to 'any address used in gap'
Legacy derivation flag added to address generation flows
Balance/unspent/transaction fetching paths refactored
Evidence from the diff
The change introduces getBatchUnspent/getBatchBalance in ElectrumClient and wires them into ElectrumWalletBase via shouldUseBatchFetching. It refactors fetchBalances/updateAllUnspents/fetchTransactions to use batch Electrum RPCs when the server supports them, with fallback to single calls. It also refactors address discovery (discoverAddressesBatch now returns new addresses and checks for any used address in the gap, not just the last), adds isLegacyDerivation flag flows, and extracts Litecoin peg-out tagging into _applyLitecoinPegOutTag. A small fix makes checkIfBatchSupported actually inspect batch responses for errors rather than only catching timeouts. The diff is large (+395/-158) and mostly structural; no explicit security bug or CVE is mentioned.
Changed components
cw_bitcoin/lib/electrum.dartcw_bitcoin/lib/electrum_wallet.dartcw_bitcoin/lib/electrum_wallet_addresses.dartcw_bitcoin/lib/litecoin_wallet.dartInspect captured patch +395 / −158
diff --git a/cw_bitcoin/lib/electrum.dart b/cw_bitcoin/lib/electrum.dart
index 09518342..052d18ef 100644
--- a/cw_bitcoin/lib/electrum.dart
+++ b/cw_bitcoin/lib/electrum.dart
@@ -173,6 +173,7 @@ class ElectrumClient {
if (isJSONStringCorrect(unterminatedString)) {
final response = json.decode(unterminatedString);
+ _handleResponse(response);
// unterminatedString = null;
unterminatedString = '';
}
@@ -340,6 +341,80 @@ class ElectrumClient {
return historyMap;
}
+ Future<Map<String, List<Map<String, dynamic>>>> getBatchUnspent(
+ List<String> scriptHashes, {
+ int timeout = 10000,
+ }) async {
+ final paramsList = scriptHashes.map((h) => <Object>[h]).toList(growable: false);
+
+ final batchResults = await callBatchWithTimeout(
+ method: 'blockchain.scripthash.listunspent',
+ paramsList: paramsList,
+ timeout: timeout,
+ );
+
+ final unspentMap = <String, List<Map<String, dynamic>>>{};
+
+ for (int i = 0; i < scriptHashes.length; i++) {
+ final sh = scriptHashes[i];
+
+ if (i >= batchResults.length) {
+ unspentMap[sh] = const [];
+ continue;
+ }
+
+ final result = batchResults[i];
+
+ if (result is List) {
+ unspentMap[sh] = result
+ .whereType<Map<dynamic, dynamic>>()
+ .map((m) => m.map((k, v) => MapEntry(k.toString(), v)))
+ .cast<Map<String, dynamic>>()
+ .toList();
+ } else {
+ unspentMap[sh] = const [];
+ }
+ }
+
+ return unspentMap;
+ }
+
+ Future<Map<String, Map<String, dynamic>>> getBatchBalance(
+ List<String> scriptHashes, {
+ int timeout = 10000,
+ }) async {
+ final paramsList = scriptHashes.map((h) => <Object>[h]).toList(growable: false);
+
+ final batchResults = await callBatchWithTimeout(
+ method: 'blockchain.scripthash.get_balance',
+ paramsList: paramsList,
+ timeout: timeout,
+ );
+
+ final balanceMap = <String, Map<String, dynamic>>{};
+
+ for (int i = 0; i < scriptHashes.length; i++) {
+ final sh = scriptHashes[i];
+
+ if (i >= batchResults.length) {
+ balanceMap[sh] = <String, dynamic>{};
+ continue;
+ }
+
+ final result = batchResults[i];
+
+ if (result is Map<String, dynamic>) {
+ balanceMap[sh] = result;
+ } else if (result is Map) {
+ balanceMap[sh] = Map<String, dynamic>.from(result);
+ } else {
+ balanceMap[sh] = <String, dynamic>{};
+ }
+ }
+
+ return balanceMap;
+ }
+
Future<Map<String, Map<String, dynamic>>> getBatchTransactionVerbose(
List<String> hashes, {
int timeout = 10000,
diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart
index 842fe7e3..1b10235e 100644
--- a/cw_bitcoin/lib/electrum_wallet.dart
+++ b/cw_bitcoin/lib/electrum_wallet.dart
@@ -367,6 +367,8 @@ abstract class ElectrumWalletBase
String get xpub => accountHD.publicKey.toExtended;
+ bool get shouldUseBatchFetching => useBatchForHistory && _isBatchSupported == true;
+
@override
String? get seed => _mnemonic;
@@ -708,7 +710,7 @@ abstract class ElectrumWalletBase
}
await subscribeForUpdates();
- await _checkIfBatchSupported();
+ await checkIfBatchSupported();
await updateTransactions();
await updateAllUnspents();
@@ -1793,18 +1795,21 @@ abstract class ElectrumWalletBase
}
// Set the balance of all non-silent payment and non-mweb addresses to 0 before updating
- walletAddresses.allAddresses
- .where((element) => element.type != SegwitAddresType.mweb)
- .forEach((addr) {
- if (addr is! BitcoinSilentPaymentAddressRecord) addr.balance = 0;
- });
- final addressFutures = walletAddresses.allAddresses
+ final targetAddresses = walletAddresses.allAddresses
.where((element) => element.type != SegwitAddresType.mweb)
- .map((address) => fetchUnspent(address))
.toList();
- final results = await Future.wait(addressFutures);
+ for (final addr in targetAddresses) {
+ if (addr is! BitcoinSilentPaymentAddressRecord) {
+ addr.balance = 0;
+ }
+ }
+
+ final results = shouldUseBatchFetching
+ ? await _fetchUnspentsBatch(targetAddresses)
+ : await _fetchUnspentsRegular(targetAddresses);
+
final failedCount = results.where((result) => result == null).length;
if (failedCount == 0) {
@@ -1836,6 +1841,74 @@ abstract class ElectrumWalletBase
await _refreshUnspentCoinsInfo();
}
+ Future<List<List<BitcoinUnspent>?>> _fetchUnspentsRegular(
+ List<BitcoinAddressRecord> addresses,
+ ) async {
+ final addressFutures = addresses.map((address) => fetchUnspent(address)).toList();
+ return Future.wait(addressFutures);
+ }
+
+
+ Future<List<List<BitcoinUnspent>?>> _fetchUnspentsBatch(
+ List<BitcoinAddressRecord> addresses,
+ ) async {
+ final byScriptHash = <String, BitcoinAddressRecord>{
+ for (final address in addresses) address.getScriptHash(network): address,
+ };
+
+ final scriptHashes = byScriptHash.keys.toList();
+
+ try {
+ final unspentByScriptHash =
+ await _processChunksToMap<String, String, List<Map<String, dynamic>>>(
+ items: scriptHashes,
+ chunkSize: addressHistoryChunkSize,
+ processChunk: _getListUnspentBatch,
+ );
+
+ final txHashes = <String>{};
+ final coinsByScriptHash = <String, List<BitcoinUnspent>>{};
+
+ for (final entry in unspentByScriptHash.entries) {
+ final addressRecord = byScriptHash[entry.key];
+ if (addressRecord == null) continue;
+
+ final coins = <BitcoinUnspent>[];
+
+ for (final unspent in entry.value) {
+ final coin = BitcoinUnspent.fromJSON(addressRecord, unspent);
+ coin.isChange = addressRecord.isHidden;
+ coins.add(coin);
+ txHashes.add(coin.hash);
+ }
+
+ coinsByScriptHash[entry.key] = coins;
+ }
+
+ final txInfoByHash = await fetchTransactionInfoBatch(
+ hashes: txHashes.toList(),
+ retryOnFailure: true,
+ retryDelay: const Duration(seconds: 1),
+ );
+
+ for (final coins in coinsByScriptHash.values) {
+ for (final coin in coins) {
+ final tx = txInfoByHash[coin.hash];
+ coin.confirmations = tx?.confirmations;
+ coin.isPegOut = tx?.isHogEx;
+ }
+ }
+
+ return addresses.map((address) {
+ final scriptHash = address.getScriptHash(network);
+ return coinsByScriptHash[scriptHash] ?? <BitcoinUnspent>[];
+ }).toList();
+ } catch (e) {
+ printV('fetchUnspentsBatch failed: $e');
+ return List<List<BitcoinUnspent>?>.filled(addresses.length, null);
+ }
+ }
+
List<BitcoinUnspent> handleFailedUtxoFetch({
required int failedCount,
required List<BitcoinUnspent> previousUnspentCoins,
@@ -2395,25 +2468,30 @@ abstract class ElectrumWalletBase
@override
Future<Map<String, ElectrumTransactionInfo>> fetchTransactions() async {
try {
- final Map<String, ElectrumTransactionInfo> historiesWithDetails = {};
- final shouldUseBatchForHistory = useBatchForHistory && _isBatchSupported == true;
+ final Map<String, ElectrumTransactionInfo> historiesWithDetails = {};;
- printV('[BATCH_TEST] Fetching transactions with batch: $shouldUseBatchForHistory');
+ printV('[BATCH_TEST] Fetching transactions with batch: $shouldUseBatchFetching');
if (type == WalletType.bitcoin) {
- await Future.wait(BITCOIN_ADDRESS_TYPES.map((type) => shouldUseBatchForHistory
+ await Future.wait(BITCOIN_ADDRESS_TYPES.map((type) => shouldUseBatchFetching
? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type)
: fetchTransactionsForAddressType(historiesWithDetails, type)));
} else if (type == WalletType.bitcoinCash) {
await Future.wait(BITCOIN_CASH_ADDRESS_TYPES
- .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type)));
+ .map((type) => shouldUseBatchFetching
+ ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type)
+ : fetchTransactionsForAddressType(historiesWithDetails, type)));
} else if (type == WalletType.litecoin) {
await Future.wait(LITECOIN_ADDRESS_TYPES
.where((type) => type != SegwitAddresType.mweb)
- .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type)));
+ .map((type) => shouldUseBatchFetching
+ ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type)
+ : fetchTransactionsForAddressType(historiesWithDetails, type)));
} else if (type == WalletType.dogecoin) {
await Future.wait(DOGECOIN_ADDRESS_TYPES
- .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type)));
+ .map((type) => shouldUseBatchFetching
+ ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type)
+ : fetchTransactionsForAddressType(historiesWithDetails, type)));
}
transactionHistory.transactions.values.forEach((tx) async {
@@ -2457,13 +2535,13 @@ abstract class ElectrumWalletBase
historiesWithDetails.addAll(history);
final matchedAddresses = addressRecord.isHidden ? hiddenAddresses : receiveAddresses;
- final isUsedAddressUnderGap = matchedAddresses.toList().indexOf(addressRecord) >=
+ final isUsedAddressAboveGap = matchedAddresses.toList().indexOf(addressRecord) >=
matchedAddresses.length -
(addressRecord.isHidden
? ElectrumWalletAddressesBase.defaultChangeAddressesCount
: ElectrumWalletAddressesBase.defaultReceiveAddressesCount);
- if (isUsedAddressUnderGap) {
+ if (isUsedAddressAboveGap) {
final prevLength = walletAddresses.allAddresses.length;
// Discover new addresses for the same address type until the gap limit is respected
@@ -2538,19 +2616,7 @@ abstract class ElectrumWalletBase
// Got a new transaction fetched, add it to the transaction history
// instead of waiting all to finish, and next time it will be faster
- if (this is LitecoinWallet) {
- // if we have a peg out transaction with the same value
- // that matches this received transaction, mark it as being from a peg out:
- for (final tx2 in transactionHistory.transactions.values) {
- final heightDiff = ((tx2.height ?? 0) - (tx.height ?? 0)).abs();
- // 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
- if (tx2.additionalInfo["isPegOut"] == true &&
- tx2.amount == tx.amount &&
- heightDiff <= 5) {
- tx.additionalInfo["fromPegOut"] = true;
- }
- }
- }
+ _applyLitecoinPegOutTag(tx);
transactionHistory.addOne(tx);
await transactionHistory.save();
}
@@ -2572,65 +2638,128 @@ abstract class ElectrumWalletBase
}
Future<void> fetchTransactionsForAddressTypeBatch(
- Map<String, ElectrumTransactionInfo> historiesWithDetails, BitcoinAddressType type) async {
+ Map<String, ElectrumTransactionInfo> historiesWithDetails,
+ BitcoinAddressType type) async {
final addressesByType =
- walletAddresses.allAddresses.where((addr) => addr.type == type).toList();
- final hiddenAddresses = addressesByType.where((addr) => addr.isHidden).toList();
+ walletAddresses.allAddresses.where((addr) => addr.type == type).toList();
final receiveAddresses = addressesByType.where((addr) => !addr.isHidden).toList();
+ final hiddenAddresses = addressesByType.where((addr) => addr.isHidden).toList();
+
walletAddresses.hiddenAddresses.addAll(hiddenAddresses.map((e) => e.address));
await walletAddresses.saveAddressesInBox();
+ await fetchTransactionsForAddressesBranchBatch(
+ historiesWithDetails,
+ type,
+ receiveAddresses,
+ isHidden: false,
+ isLegacyDerivation: false,
+ );
+
+ await fetchTransactionsForAddressesBranchBatch(
+ historiesWithDetails,
+ type,
+ hiddenAddresses,
+ isHidden: true,
+ isLegacyDerivation: false,
+ );
+
+ await fetchTransactionsForAddressesBranchBatch(
+ historiesWithDetails,
+ type,
+ receiveAddresses,
+ isHidden: false,
+ isLegacyDerivation: true,
+ );
+
+ await fetchTransactionsForAddressesBranchBatch(
+ historiesWithDetails,
+ type,
+ hiddenAddresses,
+ isHidden: true,
+ isLegacyDerivation: true,
+ );
+ }
+
+
+ Future<void> fetchTransactionsForAddressesBranchBatch(
+ Map<String, ElectrumTransactionInfo> historiesWithDetails,
+ BitcoinAddressType type,
+ List<BitcoinAddressRecord> branchAddresses, {
+ required bool isHidden,
+ required bool isLegacyDerivation,
+ }) async {
+ if (branchAddresses.isEmpty) return;
+
final tip = await getCurrentChainTip();
+ final currentBranch = [...branchAddresses];
- final addressHistory = await _processChunksToMap<BitcoinAddressRecord, String, ElectrumTransactionInfo>(
- items: addressesByType,
+ final initialHistory =
+ await _processChunksToMap<BitcoinAddressRecord, String, ElectrumTransactionInfo>(
+ items: currentBranch,
chunkSize: addressHistoryChunkSize,
- processChunk: (chunk) => _fetchBatchAddressHistory(chunk, tip, addressHistoryChunkSize),
+ processChunk: (chunk) => _fetchBatchAddressHistory(
+ chunk,
+ tip,
+ addressHistoryChunkSize,
+ ),
);
- if (addressHistory.isNotEmpty) historiesWithDetails.addAll(addressHistory);
-
- for (final addressRecord in addressesByType) {
- final matchedAddresses = addressRecord.isHidden ? hiddenAddresses : receiveAddresses;
+ if (initialHistory.isNotEmpty) {
+ historiesWithDetails.addAll(initialHistory);
+ }
- final isUsedAddressUnderGap =
- matchedAddresses.indexOf(addressRecord) >=
- matchedAddresses.length - ElectrumWalletAddressesBase.gap;
+ final gapLimit = isHidden
+ ? ElectrumWalletAddressesBase.defaultChangeAddressesCount
+ : ElectrumWalletAddressesBase.defaultReceiveAddressesCount;
- if (isUsedAddressUnderGap && addressRecord.isUsed) {
- final prevLength = walletAddresses.allAddresses.length;
+ final highestUsedIndex = _highestUsedIndex(currentBranch);
+ final shouldDiscover =
+ highestUsedIndex >= 0 && highestUsedIndex >= currentBranch.length - gapLimit;
+ if (!shouldDiscover) return;
- await walletAddresses.discoverAddressesBatch(
- matchedAddresses,
- addressRecord.isHidden,
- (newAddresses) async {
- await _fetchBatchAddressHistory(
- newAddresses,
- tip,
- discoveryHistoryChunkSize,
- );
- return newAddresses
- .where((addressRecord) => addressRecord.isUsed)
- .map((addressRecord) => addressRecord.address)
- .toSet();
- },
- type: type,
+ final newAddresses = await walletAddresses.discoverAddressesBatch(
+ currentBranch,
+ isHidden,
+ (newAddresses) async {
+ final newHistory = await _fetchBatchAddressHistory(
+ newAddresses,
+ tip,
+ discoveryHistoryChunkSize,
);
- final newLength = walletAddresses.allAddresses.length;
-
- if (newLength > prevLength) {
- await fetchTransactionsForAddressTypeBatch(
- historiesWithDetails,
- type);
- return;
+ if (newHistory.isNotEmpty) {
+ historiesWithDetails.addAll(newHistory);
}
+
+ return newAddresses
+ .where((addressRecord) => addressRecord.isUsed)
+ .map((addressRecord) => addressRecord.address)
+ .toSet();
+ },
+ type: type,
+ isLegacyDerivation: isLegacyDerivation,
+ );
+
+ if (newAddresses.isNotEmpty) {
+ currentBranch.addAll(newAddresses);
+
+ if (isHidden) {
+ walletAddresses.hiddenAddresses.addAll(newAddresses.map((e) => e.address));
+ await walletAddresses.saveAddressesInBox();
}
}
}
+ int _highestUsedIndex(List<BitcoinAddressRecord> addresses) {
+ for (int i = addresses.length - 1; i >= 0; i--) {
+ if (addresses[i].isUsed) return i;
+ }
+ return -1;
+ }
+
Future<Map<String, ElectrumTransactionInfo>> _fetchBatchAddressHistory(
List<BitcoinAddressRecord> addressRecords,
int? currentHeight,
@@ -2762,17 +2891,7 @@ abstract class ElectrumWalletBase
historiesWithDetails[tx.id] = tx;
- // Litecoin peg-out tagging
- if (this is LitecoinWallet) {
- for (final tx2 in transactionHistory.transactions.values) {
- final heightDiff = ((tx2.height ?? 0) - (tx.height ?? 0)).abs();
- if (tx2.additionalInfo["isPegOut"] == true &&
- tx2.amount == tx.amount &&
- heightDiff <= 5) {
- tx.additionalInfo["fromPegOut"] = true;
- }
- }
- }
+ _applyLitecoinPegOutTag(tx);
transactionHistory.addOne(tx);
didUpdateHistory = true;
@@ -2819,6 +2938,22 @@ abstract class ElectrumWalletBase
);
}
+ Future<Map<String, List<Map<String, dynamic>>>> _getListUnspentBatch(
+ List<String> scriptHashes) {
+ return electrumClient.getBatchUnspent(
+ scriptHashes,
+ timeout: transactionBatchTimeoutMs,
+ );
+ }
+
+ Future<Map<String, Map<String, dynamic>>> _getBalanceBatch(
+ List<String> scriptHashes) {
+ return electrumClient.getBatchBalance(
+ scriptHashes,
+ timeout: transactionBatchTimeoutMs,
+ );
+ }
+
Future<Map<String, ElectrumTransactionInfo?>> fetchTransactionInfoBatch({
required List<String> hashes,
Map<String, int?>? heightsByHash,
@@ -2906,8 +3041,16 @@ abstract class ElectrumWalletBase
final inputTxIdsByHash = _collectInputTxIdsByHash(originalByHash);
- final inputVerboseByTxId = await _fetchInputTransactionVerboseBatch(
- inputTxIdsByHash);
+ final allInputTxids = <String>{};
+ for (final txids in inputTxIdsByHash.values) {
+ allInputTxids.addAll(txids);
+ }
+
+ final inputTxIds = allInputTxids.toList(growable: false);
+
+ final inputVerboseByTxId = inputTxIds.isEmpty
+ ? <String, Map<String, dynamic>>{}
+ : await _fetchTransactionVerboseBatch(inputTxIds);
final parsedInputTxById = _parseTransactions(inputVerboseByTxId);
@@ -2999,65 +3142,6 @@ abstract class ElectrumWalletBase
return inputTxIdsByHash;
}
-
- Future<Map<String, Map<String, dynamic>>> _fetchInputTransactionVerboseBatch(
- Map<String, List<String>> inputTxidsByHash) async {
- final allInputTxids = <String>{};
- for (final txids in inputTxidsByHash.values) {
- allInputTxids.addAll(txids);
- }
-
- final inputTxIds = allInputTxids.toList(growable: false);
-
- final verboseTransactionByHash =
- await _processChunksToMap<String, String, Map<String, dynamic>>(
- items: inputTxIds,
- chunkSize: inputTransactionChunkSize,
- processChunk: _getTransactionVerboseBatch,
- onChunkError: (chunk, error) {
- if (error is electrum.RequestFailedTimeoutException) {
- printV(
- 'fetchInputTransactionVerboseBatch timeout for ${chunk.length} txs: ${error.method}',
- );
- } else {
- printV(
- 'fetchInputTransactionVerboseBatch failed for ${chunk.length} txs: $error,',
- );
- }
- },
- );
-
- final emptyHex = <String>[];
- for (final txId in inputTxIds) {
- final vTx = verboseTransactionByHash[txId];
- if (vTx == null || vTx.isEmpty || vTx['hex'] == null) {
- emptyHex.add(txId);
- }
- }
-
- final hexByHash = await _processChunksToMap<String, String, String?>(
- items: emptyHex,
- chunkSize: inputTransactionChunkSize,
- processChunk: _getTransactionHexBatch,
- );
-
- for (final txId in inputTxIds) {
- final verbose = verboseTransactionByHash[txId] ?? <String, dynamic>{};
- if ((verbose['hex'] as String?) == null) {
- final hex = hexByHash[txId];
- if (hex != null && hex.isNotEmpty) {
- verboseTransactionByHash[txId] = {
- ...verbose,
- 'hex': hex,
- };
- }
- }
- }
-
- return verboseTransactionByHash;
- }
-
-
Future<Map<String, ElectrumTransactionBundle>> _buildTransactionBundlesBatch({
required List<String> unique,
required Map<String, int?>? heightsByHash,
@@ -3290,18 +3374,64 @@ abstract class ElectrumWalletBase
}));
}
+ Future<List<Map<String, dynamic>>> fetchBalancesBatch(
+ List<BitcoinAddressRecord> addresses,
+ ) async {
+ final scriptHashes = addresses.map((address) => address.getScriptHash(network)).toList();
+
+ if (scriptHashes.isEmpty) {
+ return <Map<String, dynamic>>[];
+ }
+
+ try {
+ final balancesByScriptHash =
+ await _processChunksToMap<String, String, Map<String, dynamic>>(
+ items: scriptHashes,
+ chunkSize: addressHistoryChunkSize,
+ processChunk: _getBalanceBatch,
+ );
+
+ final balances = scriptHashes
+ .map((scriptHash) => balancesByScriptHash[scriptHash] ?? <String, dynamic>{})
+ .toList();
+
+ final hasMissingBalance = balances.any((balance) => balance['confirmed'] == null);
+ if (hasMissingBalance) {
+ printV('fetchBalancesBatch returned missing balances, falling back to regular flow');
+ return fetchBalancesRegular(addresses);
+ }
+
+ return balances;
+ } catch (e) {
+ printV('fetchBalancesBatch failed, falling back to regular flow: $e');
+ return fetchBalancesRegular(addresses);
+ }
+ }
+
+ Future<List<Map<String, dynamic>>> fetchBalancesRegular(
+ List<BitcoinAddressRecord> addresses,
+ ) async {
+ final balanceFutures = <Future<Map<String, dynamic>>>[];
+
+ for (final address in addresses) {
+ final sh = address.getScriptHash(network);
+ balanceFutures.add(electrumClient.getBalance(sh));
+ }
+
+ return Future.wait(balanceFutures);
+ }
+
Future<ElectrumBalance> fetchBalances() async {
final addresses = walletAddresses.allAddresses
.where((address) => address.address.isNotEmpty)
.where((address) => RegexUtils.addressTypeFromStr(address.address, network) is! MwebAddress)
.toList();
- final balanceFutures = <Future<Map<String, dynamic>>>[];
- for (var i = 0; i < addresses.length; i++) {
- final addressRecord = addresses[i];
- final sh = addressRecord.getScriptHash(network);
- final balanceFuture = electrumClient.getBalance(sh);
- balanceFutures.add(balanceFuture);
- }
+
+ final balances = shouldUseBatchFetching
+ ? await fetchBalancesBatch(addresses)
+ : await fetchBalancesRegular(addresses);
+
+ printV('Fetched balances for ${addresses.length} addresses. Batch fetching: $shouldUseBatchFetching');
var totalFrozen = 0;
var totalConfirmed = 0;
@@ -3337,8 +3467,6 @@ abstract class ElectrumWalletBase
});
});
- final balances = await Future.wait(balanceFutures);
-
if (balances.isNotEmpty && balances.first['confirmed'] == null) {
// if we got null balance responses from the server, set our connection status to lost and return our last known balance:
printV("got null balance responses from the server, setting connection status to lost");
@@ -3420,7 +3548,23 @@ abstract class ElectrumWalletBase
return base64Encode(decodedSig);
}
- Future<void> _checkIfBatchSupported() async {
+ void _applyLitecoinPegOutTag(ElectrumTransactionInfo tx) {
+ if (this is! LitecoinWallet) return;
+
+ // if we have a peg out transaction with the same value
+ // that matches this received transaction, mark it as being from a peg out:
+ for (final tx2 in transactionHistory.transactions.values) {
+ final heightDiff = ((tx2.height ?? 0) - (tx.height ?? 0)).abs();
+ // 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
+ if (tx2.additionalInfo["isPegOut"] == true &&
+ tx2.amount == tx.amount &&
+ heightDiff <= 5) {
+ tx.additionalInfo["fromPegOut"] = true;
+ }
+ }
+ }
+
+ Future<void> checkIfBatchSupported() async {
if (_isBatchSupported != null) {
printV('[BATCH_TEST] Already checked: $_isBatchSupported');
@@ -3440,12 +3584,23 @@ abstract class ElectrumWalletBase
printV('[BATCH_TEST] Start: hashes=${hashes.length}, timeout=${batchTestTimeoutMs}ms');
- await electrumClient.callBatchWithTimeout(
+ final result = await electrumClient.callBatchWithTimeout(
method: 'blockchain.scripthash.get_history',
paramsList: paramsList,
timeout: batchTestTimeoutMs,
);
+ final hasError = result.any((item) =>
+ item is Map<String, dynamic> &&
+ item.containsKey('error') &&
+ item['error'] != null);
+
+ if (hasError) {
+ _isBatchSupported = false;
+ printV('[BATCH_TEST] Result: supported=false (server returned error)');
+ return;
+ }
+
_isBatchSupported = true;
printV('[BATCH_TEST] Result: supported=true');
} on electrum.RequestFailedTimeoutException catch (e) {
diff --git a/cw_bitcoin/lib/electrum_wallet_addresses.dart b/cw_bitcoin/lib/electrum_wallet_addresses.dart
index d3763386..e692816b 100644
--- a/cw_bitcoin/lib/electrum_wallet_addresses.dart
+++ b/cw_bitcoin/lib/electrum_wallet_addresses.dart
@@ -715,38 +715,42 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
}
@action
- Future<bool> discoverAddressesBatch(
+ Future<List<BitcoinAddressRecord>> discoverAddressesBatch(
List<BitcoinAddressRecord> addressList,
bool isHidden,
Future<Set<String>> Function(List<BitcoinAddressRecord>) getUsedAddresses, {
BitcoinAddressType type = SegwitAddresType.p2wpkh,
+ required bool isLegacyDerivation,
}) async {
final newAddresses = await _createNewAddresses(
gap,
startIndex: addressList.length,
isHidden: isHidden,
type: type,
+ isLegacyDerivation: isLegacyDerivation,
);
addAddresses(newAddresses);
final usedAddresses = await getUsedAddresses(newAddresses);
- final isLastAddressUsed =
- newAddresses.isNotEmpty && usedAddresses.contains(newAddresses.last.address);
- if (!isLastAddressUsed) {
- return false;
+ final hasUsedAddressInGap = newAddresses.any(
+ (addressRecord) => usedAddresses.contains(addressRecord.address));
+
+ if (!hasUsedAddressInGap) {
+ return newAddresses;
}
final updatedAddressList = [...addressList, ...newAddresses];
- await discoverAddressesBatch(
+ final moreNewAddresses = await discoverAddressesBatch(
updatedAddressList,
isHidden,
getUsedAddresses,
type: type,
+ isLegacyDerivation: isLegacyDerivation,
);
- return true;
+ return [...newAddresses, ...moreNewAddresses];
}
@@ -818,6 +822,8 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
this._addresses.clear();
this._addresses.addAll(addressesSet);
updateAddressesByMatch();
+ updateReceiveAddresses();
+ updateChangeAddresses();
}
@action
diff --git a/cw_bitcoin/lib/litecoin_wallet.dart b/cw_bitcoin/lib/litecoin_wallet.dart
index 3cee6c9d..3a363aa4 100644
--- a/cw_bitcoin/lib/litecoin_wallet.dart
+++ b/cw_bitcoin/lib/litecoin_wallet.dart
@@ -381,6 +381,7 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
} catch (e) {
printV("failed to subscribe for updates: $e");
}
+ await checkIfBatchSupported();
updateFeeRates();
_feeRatesTimer?.cancel();
_feeRatesTimer =
Why this scored 34/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.