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
This commit is contained in:
Serhii
2026-04-13 19:22:05 +02:00
committed by GitHub
parent 0bd69009cf
commit 7b2c9d5130
3 changed files with 952 additions and 29 deletions
+194 -26
View File
@@ -144,7 +144,7 @@ class ElectrumClient {
void _parseResponse(String message) {
try {
final response = json.decode(message) as Map<String, dynamic>;
final response = json.decode(message);
_handleResponse(response);
} on FormatException catch (e) {
final msg = e.message.toLowerCase();
@@ -159,7 +159,7 @@ class ElectrumClient {
}
if (isJSONStringCorrect(unterminatedString)) {
final response = json.decode(unterminatedString) as Map<String, dynamic>;
final response = json.decode(unterminatedString);
_handleResponse(response);
unterminatedString = '';
}
@@ -172,8 +172,7 @@ class ElectrumClient {
unterminatedString += message;
if (isJSONStringCorrect(unterminatedString)) {
final response = json.decode(unterminatedString) as Map<String, dynamic>;
_handleResponse(response);
final response = json.decode(unterminatedString);
// unterminatedString = null;
unterminatedString = '';
}
@@ -303,6 +302,137 @@ class ElectrumClient {
return '';
});
Future<Map<String, List<Map<String, dynamic>>>> getBatchHistory(
List<String> scriptHashes, {
int timeout = 10000,
}) async {
final paramsList = scriptHashes.map((h) => <Object>[h]).toList(growable: false);
final batchResults = await callBatchWithTimeout(
method: 'blockchain.scripthash.get_history',
paramsList: paramsList,
timeout: timeout,
);
final historyMap = <String, List<Map<String, dynamic>>>{};
for (int i = 0; i < scriptHashes.length; i++) {
final sh = scriptHashes[i];
if (i >= batchResults.length) {
historyMap[sh] = const [];
continue;
}
final result = batchResults[i];
if (result is List) {
historyMap[sh] = result
.whereType<Map<dynamic, dynamic>>()
.map((m) => m.map((k, v) => MapEntry(k.toString(), v)))
.cast<Map<String, dynamic>>()
.toList();
} else {
historyMap[sh] = const [];
}
}
return historyMap;
}
Future<Map<String, Map<String, dynamic>>> getBatchTransactionVerbose(
List<String> hashes, {
int timeout = 10000,
}) async {
final result = <String, Map<String, dynamic>>{};
if (hashes.isEmpty) return result;
final paramsList = hashes.map((h) => <Object>[h, true]).toList(growable: false);
final batchResults = await callBatchWithTimeout(
method: 'blockchain.transaction.get',
paramsList: paramsList,
timeout: timeout,
);
for (var i = 0; i < hashes.length; i++) {
final txid = hashes[i];
final r = (i < batchResults.length) ? batchResults[i] : null;
if (r is Map<String, dynamic>) {
result[txid] = r;
} else {
result[txid] = <String, dynamic>{};
}
}
return result;
}
Future<Map<String, String?>> getBatchTransactionHex(
List<String> hashes, {
int timeout = 10000,
}) async {
final result = <String, String?>{};
if (hashes.isEmpty) return result;
final paramsList = hashes.map((h) => <Object>[h]).toList(growable: false);
final batchResults = await callBatchWithTimeout(
method: 'blockchain.transaction.get',
paramsList: paramsList,
timeout: timeout,
);
for (var i = 0; i < hashes.length; i++) {
final txid = hashes[i];
final r = (i < batchResults.length) ? batchResults[i] : null;
if (r is String && r.isNotEmpty) {
result[txid] = r;
} else {
result[txid] = null;
}
}
return result;
}
Future<List<dynamic>> callBatchWithTimeout({
required String method,
required List<List<Object>> paramsList,
int timeout = 10000,
}) async {
if (!isConnected) return [];
final completer = Completer<List<dynamic>>();
final int batchBaseId = _id += 1;
final String internalBatchKey = "batch_$batchBaseId";
// Build the Batch Array
final List<Map<String, dynamic>> batchPayload = [];
for (int i = 0; i < paramsList.length; i++) {
batchPayload.add({
"jsonrpc": "2.0",
"method": method,
"params": paramsList[i],
"id": "$batchBaseId-$i"
});
}
// Register the task
_tasks[internalBatchKey] = SocketTask(completer: completer, isSubscription: false);
// Write to socket
socket!.write(json.encode(batchPayload) + "\n");
// Timeout Logic
Timer(Duration(milliseconds: timeout), () {
if (!completer.isCompleted) {
_tasks.remove(internalBatchKey);
completer.completeError(RequestFailedTimeoutException("BATCH_$method", batchBaseId));
}
});
return completer.future;
}
Future<String> broadcastTransaction(
{required String transactionRaw,
BasedUtxoNetwork? network,
@@ -569,35 +699,73 @@ class ElectrumClient {
}
}
void _handleResponse(Map<String, dynamic> response) {
final method = response['method'];
final id = response['id'] as String?;
final result = response['result'];
void _handleResponse(dynamic response) {
try {
final error = response['error'] as Map<String, dynamic>?;
if (error != null) {
final errorMessage = error['message'] as String?;
if (errorMessage != null) {
_errors[id!] = errorMessage;
// Handle batch response
if (response is List) {
if (response.isEmpty) return;
// Sort responses by ID to ensure correct order for batch processing
response.sort((a, b) {
try {
final idA = int.parse(a['id'].toString().split('-').last);
final idB = int.parse(b['id'].toString().split('-').last);
return idA.compareTo(idB);
} catch (_) {
return 0;
}
}
} catch (_) {}
});
try {
final error = response['error'] as String?;
if (error != null) {
_errors[id!] = error;
}
} catch (_) {}
final firstItem = response.first as Map<String, dynamic>;
final String firstIdAttr = firstItem['id'].toString();
if (method is String) {
_methodHandler(method: method, request: response);
final String batchKey = firstIdAttr.contains('-')
? "batch_${firstIdAttr.split('-')[0].replaceAll('batch_', '')}"
: firstIdAttr;
// Extract the results from each item in the batch
final results = response.map((item) {
if (item is Map) {
return item['result'] ?? item['error'];
}
return null;
}).toList();
_finish(batchKey, results);
return;
}
if (id != null) {
_finish(id, result);
// Handle single response
if (response is Map<String, dynamic>) {
final method = response['method'];
final id = response['id'] as String?;
final result = response['result'];
try {
final error = response['error'] as Map<String, dynamic>?;
if (error != null) {
final errorMessage = error['message'] as String?;
if (errorMessage != null) {
_errors[id!] = errorMessage;
}
}
} catch (_) {}
try {
final error = response['error'] as String?;
if (error != null) {
_errors[id!] = error;
}
} catch (_) {}
if (method is String) {
_methodHandler(method: method, request: response);
return;
}
if (id != null) {
_finish(id, result);
}
}
}
+721 -2
View File
@@ -279,6 +279,18 @@ abstract class ElectrumWalletBase
seedBytes, network != null ? getKeyNetVersion(network, hardwareWalletType) : null);
}
static const int addressHistoryChunkSize = 150;
static const int transactionChunkSize = 150;
static const int inputTransactionChunkSize = 150;
static const int discoveryHistoryChunkSize = 20;
static const int transactionBatchTimeoutMs = 15000;
static const int batchTestTimeoutMs = 4000;
static const int batchTestHashesCount = 2;
static const bool useBatchForHistory = true;
@observable
bool? alwaysScan;
@@ -360,6 +372,8 @@ abstract class ElectrumWalletBase
bool silentPaymentsScanningActive = false;
bool _isTryingToConnect = false;
bool? _isBatchSupported;
DateTime? _syncBenchmarkStartTime;
Completer<SharedPreferences> sharedPrefs = Completer();
@@ -639,6 +653,11 @@ abstract class ElectrumWalletBase
return;
}
if (_syncBenchmarkStartTime == null) {
_syncBenchmarkStartTime = DateTime.now();
printV('[ELECTRUM_WALLET SYNC] Starting: ${_syncBenchmarkStartTime!}');
}
syncStatus = SyncronizingSyncStatus();
if (hasSilentPaymentsScanning) {
@@ -671,6 +690,7 @@ abstract class ElectrumWalletBase
}
await subscribeForUpdates();
await _checkIfBatchSupported();
await updateTransactions();
await updateAllUnspents();
@@ -686,11 +706,28 @@ abstract class ElectrumWalletBase
if (syncStatus is LostConnectionSyncStatus) {
return;
}
final syncEnd = DateTime.now();
final totalMs = _syncBenchmarkStartTime != null
? syncEnd.difference(_syncBenchmarkStartTime!).inMilliseconds
: 0;
printV('[ELECTRUM_WALLET SYNC] Finished: $syncEnd, took ${totalMs} ms');
_syncBenchmarkStartTime = null;
syncStatus = SyncedSyncStatus();
}
} catch (e, stacktrace) {
final syncEnd = DateTime.now();
final totalMs = _syncBenchmarkStartTime != null
? syncEnd.difference(_syncBenchmarkStartTime!).inMilliseconds
: 0;
printV(stacktrace);
printV("startSync $e");
printV('[ELECTRUM_WALLET SYNC] Finished: $syncEnd, took ${totalMs} ms');
_syncBenchmarkStartTime = null;
syncStatus = FailedSyncStatus();
}
}
@@ -786,6 +823,7 @@ abstract class ElectrumWalletBase
@override
Future<void> connectToNode({required Node node}) async {
this.node = node;
_isBatchSupported = null;
if (syncStatus is ConnectingSyncStatus) return;
@@ -794,6 +832,7 @@ abstract class ElectrumWalletBase
await _receiveStream?.cancel();
await electrumClient.close();
_isBatchSupported = null;
electrumClient.onConnectionStatusChange = _onConnectionStatusChange;
@@ -1711,6 +1750,7 @@ abstract class ElectrumWalletBase
try {
await _receiveStream?.cancel();
await electrumClient.close();
_isBatchSupported = null;
} catch (_) {}
_autoSaveTimer?.cancel();
_updateFeeRateTimer?.cancel();
@@ -2337,10 +2377,14 @@ abstract class ElectrumWalletBase
Future<Map<String, ElectrumTransactionInfo>> fetchTransactions() async {
try {
final Map<String, ElectrumTransactionInfo> historiesWithDetails = {};
final shouldUseBatchForHistory = useBatchForHistory && _isBatchSupported == true;
printV('[BATCH_TEST] Fetching transactions with batch: $shouldUseBatchForHistory');
if (type == WalletType.bitcoin) {
await Future.wait(BITCOIN_ADDRESS_TYPES
.map((type) => fetchTransactionsForAddressType(historiesWithDetails, type)));
await Future.wait(BITCOIN_ADDRESS_TYPES.map((type) => shouldUseBatchForHistory
? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type)
: fetchTransactionsForAddressType(historiesWithDetails, type)));
} else if (type == WalletType.bitcoinCash) {
await Future.wait(BITCOIN_CASH_ADDRESS_TYPES
.map((type) => fetchTransactionsForAddressType(historiesWithDetails, type)));
@@ -2508,6 +2552,644 @@ abstract class ElectrumWalletBase
}
}
Future<void> fetchTransactionsForAddressTypeBatch(
Map<String, ElectrumTransactionInfo> historiesWithDetails, BitcoinAddressType type) async {
final addressesByType =
walletAddresses.allAddresses.where((addr) => addr.type == type).toList();
final hiddenAddresses = addressesByType.where((addr) => addr.isHidden).toList();
final receiveAddresses = addressesByType.where((addr) => !addr.isHidden).toList();
walletAddresses.hiddenAddresses.addAll(hiddenAddresses.map((e) => e.address));
await walletAddresses.saveAddressesInBox();
final tip = await getCurrentChainTip();
final addressHistory = await _processChunksToMap<BitcoinAddressRecord, String, ElectrumTransactionInfo>(
items: addressesByType,
chunkSize: addressHistoryChunkSize,
processChunk: (chunk) => _fetchBatchAddressHistory(chunk, tip, addressHistoryChunkSize),
);
if (addressHistory.isNotEmpty) historiesWithDetails.addAll(addressHistory);
for (final addressRecord in addressesByType) {
final matchedAddresses = addressRecord.isHidden ? hiddenAddresses : receiveAddresses;
final isUsedAddressUnderGap =
matchedAddresses.indexOf(addressRecord) >=
matchedAddresses.length - ElectrumWalletAddressesBase.gap;
if (isUsedAddressUnderGap && addressRecord.isUsed) {
final prevLength = walletAddresses.allAddresses.length;
await walletAddresses.discoverAddressesBatch(
matchedAddresses,
addressRecord.isHidden,
(newAddresses) async {
await _fetchBatchAddressHistory(
newAddresses,
tip,
discoveryHistoryChunkSize,
);
return newAddresses
.where((addressRecord) => addressRecord.isUsed)
.map((addressRecord) => addressRecord.address)
.toSet();
},
type: type,
);
final newLength = walletAddresses.allAddresses.length;
if (newLength > prevLength) {
await fetchTransactionsForAddressTypeBatch(
historiesWithDetails,
type);
return;
}
}
}
}
Future<Map<String, ElectrumTransactionInfo>> _fetchBatchAddressHistory(
List<BitcoinAddressRecord> addressRecords,
int? currentHeight,
int historyChunkSize) async {
String lastTxId = '';
bool didUpdateHistory = false;
try {
final Map<String, ElectrumTransactionInfo> historiesWithDetails = {};
// List of script hashes for the given address records
final scriptHashes = addressRecords.map((a) => a.getScriptHash(network)).toList();
final historyByScriptHash =
await _processChunksToMap<String, String, List<Map<String, dynamic>>>(
items: scriptHashes,
chunkSize: historyChunkSize,
processChunk: _getHistoryBatch
);
// Map scriptHash -> addressRecord
final byScriptHash = <String, BitcoinAddressRecord>{};
for (final a in addressRecords) {
byScriptHash[a.getScriptHash(network)] = a;
}
// Split into already-known txs vs missing txs
final missingHistoryItems = <Map<String, dynamic>>[];
for (final entry in historyByScriptHash.entries) {
final sh = entry.key;
final addressRecord = byScriptHash[sh];
if (addressRecord == null) continue;
final history = entry.value;
if (history.isEmpty) continue;
addressRecord.setAsUsed();
walletAddresses.clearLockIfMatches(addressRecord.type, addressRecord.address);
//removes transactions no longer returned by the api, presumed replaced/invalid.
if (this is BitcoinWallet) {
final beforeLen = transactionHistory.transactions.length;
transactionHistory.transactions.removeWhere((hash, tx) {
return tx.outputAddresses != null &&
tx.outputAddresses!.contains(addressRecord.address) &&
!history.any((h) => h['tx_hash'] == hash);
});
if (transactionHistory.transactions.length != beforeLen) {
didUpdateHistory = true;
}
}
// For each transaction in the history, check if we already have it in our transaction history. If we do, update its details if necessary. If we don't, add it to the list of missing history items to fetch later.
for (final item in history) {
final txid = item['tx_hash'] as String?;
final height = item['height'] as int? ?? 0;
if (txid == null || txid.isEmpty) continue;
lastTxId = txid;
final storedTx = transactionHistory.transactions[txid];
if (storedTx != null) {
if (height > 0) {
final oldHeight = storedTx.height;
final oldConfs = storedTx.confirmations;
final oldPending = storedTx.isPending;
storedTx.height = height;
if ((currentHeight ?? 0) > 0) {
storedTx.confirmations = currentHeight! - height + 1;
}
storedTx.isPending = storedTx.confirmations == 0;
if (storedTx.height != oldHeight ||
storedTx.confirmations != oldConfs ||
storedTx.isPending != oldPending) {
transactionHistory.addOne(storedTx);
didUpdateHistory = true;
}
}
historiesWithDetails[txid] = storedTx;
} else {
missingHistoryItems.add({
'tx_hash': txid,
'height': height,
'script_hash': sh,
'address': addressRecord.address,
});
}
}
}
// Batch fetch missing tx verbose details
if (missingHistoryItems.isEmpty) {
if (didUpdateHistory) await transactionHistory.save();
return historiesWithDetails;
}
for (var i = 0; i < missingHistoryItems.length; i += historyChunkSize) {
final end = (i + historyChunkSize < missingHistoryItems.length)
? i + historyChunkSize
: missingHistoryItems.length;
final chunkHistory = missingHistoryItems.sublist(i, end);
final hashes = chunkHistory
.map((e) => (e['tx_hash'] as String).trim())
.where((h) => h.isNotEmpty)
.toList(growable: false);
final heightsByHash = <String, int?>{
for (final e in chunkHistory)
(e['tx_hash'] as String): (e['height'] as int?),
};
final infosByHash = await fetchTransactionInfoBatch(
hashes: hashes,
heightsByHash: heightsByHash,
retryOnFailure: true,
retryDelay: const Duration(seconds: 1),
);
for (final txid in hashes) {
final tx = infosByHash[txid];
if (tx == null) continue;
historiesWithDetails[tx.id] = tx;
// Litecoin peg-out tagging
if (this is LitecoinWallet) {
for (final tx2 in transactionHistory.transactions.values) {
final heightDiff = ((tx2.height ?? 0) - (tx.height ?? 0)).abs();
if (tx2.additionalInfo["isPegOut"] == true &&
tx2.amount == tx.amount &&
heightDiff <= 5) {
tx.additionalInfo["fromPegOut"] = true;
}
}
}
transactionHistory.addOne(tx);
didUpdateHistory = true;
}
}
if (didUpdateHistory) {
await transactionHistory.save();
}
return historiesWithDetails;
} catch (e, stacktrace) {
final prefix = lastTxId.isNotEmpty ? '$lastTxId - ' : '';
_onError?.call(FlutterErrorDetails(
exception: '$prefix$e',
stack: stacktrace,
library: runtimeType.toString(),
));
return {};
}
}
Future<Map<String, Map<String, dynamic>>> _getTransactionVerboseBatch(
List<String> hashes) {
return electrumClient.getBatchTransactionVerbose(
hashes,
timeout: transactionBatchTimeoutMs,
);
}
Future<Map<String, String?>> _getTransactionHexBatch(
List<String> hashes) {
return electrumClient.getBatchTransactionHex(
hashes,
timeout: transactionBatchTimeoutMs,
);
}
Future<Map<String, List<Map<String, dynamic>>>> _getHistoryBatch(
List<String> scriptHashes) {
return electrumClient.getBatchHistory(
scriptHashes,
timeout: transactionBatchTimeoutMs,
);
}
Future<Map<String, ElectrumTransactionInfo?>> fetchTransactionInfoBatch({
required List<String> hashes,
Map<String, int?>? heightsByHash,
bool retryOnFailure = false,
Duration retryDelay = const Duration(seconds: 2),
}) async {
final result = <String, ElectrumTransactionInfo?>{};
final uniqueHashes =
hashes.map((h) => h.trim()).where((h) => h.isNotEmpty).toSet().toList();
if (uniqueHashes.isEmpty) return result;
await _processTransactionInfoBatch(
txIds: uniqueHashes,
result: result,
heightsByHash: heightsByHash,
);
if (retryOnFailure) {
final failedHashes = uniqueHashes.where((txId) => result[txId] == null).toList();
if (failedHashes.isNotEmpty) {
await Future.delayed(retryDelay);
await _processTransactionInfoBatch(
txIds: failedHashes,
result: result,
heightsByHash: heightsByHash,
);
}
}
return result;
}
Future<void> _processTransactionInfoBatch({
required List<String> txIds,
required Map<String, ElectrumTransactionInfo?> result,
required Map<String, int?>? heightsByHash,
}) async {
for (var i = 0; i < txIds.length; i += transactionChunkSize) {
final end = (i + transactionChunkSize < txIds.length)
? i + transactionChunkSize
: txIds.length;
final chunk = txIds.sublist(i, end);
final bundlesByHash = await getTransactionExpandedBatch(
hashes: chunk,
heightsByHash: heightsByHash,
);
for (final txId in chunk) {
try {
final bundle = bundlesByHash[txId];
if (bundle == null) {
result[txId] = null;
continue;
}
final info = ElectrumTransactionInfo.fromElectrumBundle(
bundle,
walletInfo.type,
network,
addresses: addressesSet,
height: heightsByHash?[txId],
);
info.id = txId;
result[txId] = info;
} catch (_) {
result[txId] = null;
}
}
}
}
Future<Map<String, ElectrumTransactionBundle>> getTransactionExpandedBatch({
required List<String> hashes,
Map<String, int?>? heightsByHash}) async {
final bundles = <String, ElectrumTransactionBundle>{};
if (hashes.isEmpty) return bundles;
final verboseByHash = await _fetchTransactionVerboseBatch(hashes);
final originalByHash = _parseTransactions(verboseByHash);
final inputTxIdsByHash = _collectInputTxIdsByHash(originalByHash);
final inputVerboseByTxId = await _fetchInputTransactionVerboseBatch(
inputTxIdsByHash);
final parsedInputTxById = _parseTransactions(inputVerboseByTxId);
return _buildTransactionBundlesBatch(
unique: hashes,
heightsByHash: heightsByHash,
tip: await getUpdatedChainTip(),
originalByHash: originalByHash,
verboseByHash: verboseByHash,
inputTxidsByHash: inputTxIdsByHash,
parsedInputTxById: parsedInputTxById,
);
}
Future<Map<String, Map<String, dynamic>>> _fetchTransactionVerboseBatch(
List<String> txIds) async {
final verboseTransactionByHash = await _processChunksToMap<String, String, Map<String, dynamic>>(
items: txIds,
chunkSize: transactionChunkSize,
processChunk: _getTransactionVerboseBatch,
);
final emptyHex = <String>[];
for (final txId in txIds) {
final vTx = verboseTransactionByHash[txId];
if (vTx == null || vTx.isEmpty || vTx['hex'] == null) {
emptyHex.add(txId);
}
}
final hexByHash = await _processChunksToMap<String, String, String?>(
items: emptyHex,
chunkSize: transactionChunkSize,
processChunk: _getTransactionHexBatch,
);
for (final txId in txIds) {
final verbose = verboseTransactionByHash[txId] ?? <String, dynamic>{};
if ((verbose['hex'] as String?) == null) {
final hex = hexByHash[txId];
if (hex != null && hex.isNotEmpty) {
verboseTransactionByHash[txId] = {
...verbose,
'hex': hex,
};
}
}
}
return verboseTransactionByHash;
}
Map<String, BtcTransaction> _parseTransactions(
Map<String, Map<String, dynamic>> verboseByHash,
) {
final result = <String, BtcTransaction>{};
for (final entry in verboseByHash.entries) {
final hex = entry.value['hex'] as String?;
if (hex == null || hex.isEmpty) continue;
try {
result[entry.key] = BtcTransaction.fromRaw(hex);
} catch (_) {}
}
return result;
}
Map<String, List<String>> _collectInputTxIdsByHash(
Map<String, BtcTransaction> originalByHash,
) {
final inputTxIdsByHash = <String, List<String>>{};
for (final entry in originalByHash.entries) {
final txId = entry.key;
final original = entry.value;
final inputTxIds = <String>[];
for (final vin in original.inputs) {
inputTxIds.add(vin.txId);
}
inputTxIdsByHash[txId] = inputTxIds;
}
return inputTxIdsByHash;
}
Future<Map<String, Map<String, dynamic>>> _fetchInputTransactionVerboseBatch(
Map<String, List<String>> inputTxidsByHash) async {
final allInputTxids = <String>{};
for (final txids in inputTxidsByHash.values) {
allInputTxids.addAll(txids);
}
final inputTxIds = allInputTxids.toList(growable: false);
final verboseTransactionByHash =
await _processChunksToMap<String, String, Map<String, dynamic>>(
items: inputTxIds,
chunkSize: inputTransactionChunkSize,
processChunk: _getTransactionVerboseBatch,
onChunkError: (chunk, error) {
if (error is electrum.RequestFailedTimeoutException) {
printV(
'fetchInputTransactionVerboseBatch timeout for ${chunk.length} txs: ${error.method}',
);
} else {
printV(
'fetchInputTransactionVerboseBatch failed for ${chunk.length} txs: $error,',
);
}
},
);
final emptyHex = <String>[];
for (final txId in inputTxIds) {
final vTx = verboseTransactionByHash[txId];
if (vTx == null || vTx.isEmpty || vTx['hex'] == null) {
emptyHex.add(txId);
}
}
final hexByHash = await _processChunksToMap<String, String, String?>(
items: emptyHex,
chunkSize: inputTransactionChunkSize,
processChunk: _getTransactionHexBatch,
);
for (final txId in inputTxIds) {
final verbose = verboseTransactionByHash[txId] ?? <String, dynamic>{};
if ((verbose['hex'] as String?) == null) {
final hex = hexByHash[txId];
if (hex != null && hex.isNotEmpty) {
verboseTransactionByHash[txId] = {
...verbose,
'hex': hex,
};
}
}
}
return verboseTransactionByHash;
}
Future<Map<String, ElectrumTransactionBundle>> _buildTransactionBundlesBatch({
required List<String> unique,
required Map<String, int?>? heightsByHash,
required int tip,
required Map<String, BtcTransaction> originalByHash,
required Map<String, Map<String, dynamic>> verboseByHash,
required Map<String, List<String>> inputTxidsByHash,
required Map<String, BtcTransaction> parsedInputTxById,
}) async {
final bundles = <String, ElectrumTransactionBundle>{};
// Identify heights that need mempool timestamp lookup
final heightsNeedingTime = <int>{};
for (final txid in originalByHash.keys) {
final verbose = verboseByHash[txid] ?? const <String, dynamic>{};
final time = verbose['time'] as int?;
final h = heightsByHash?[txid];
if (time == null && h != null && h > 0) {
heightsNeedingTime.add(h);
}
}
final mempoolTimes = await _fetchBlockTimestampsFromMempoolByHeights(heightsNeedingTime);
for (final txid in unique) {
final original = originalByHash[txid];
if (original == null) continue;
final verbose = verboseByHash[txid] ?? const <String, dynamic>{};
int? time = verbose['time'] as int?;
int? confirmations = verbose['confirmations'] as int?;
final h = heightsByHash?[txid];
if (h != null) {
if (time == null && h > 0) {
final mp = mempoolTimes[h];
time = mp ?? (getDateByBitcoinHeight(h).millisecondsSinceEpoch / 1000).round();
}
if (confirmations == null && tip > 0 && h > 0) {
confirmations = tip - h + 1;
if (confirmations < 0) confirmations = 0;
}
}
final ins = <BtcTransaction>[];
final inputTxids = inputTxidsByHash[txid] ?? const <String>[];
bool allInputsPresent = true;
for (final inputTxid in inputTxids) {
final inTx = parsedInputTxById[inputTxid];
if (inTx == null) {
allInputsPresent = false;
break;
}
ins.add(inTx);
}
if (!allInputsPresent || ins.length != original.inputs.length) {
continue;
}
bundles[txid] = ElectrumTransactionBundle(
original,
ins: ins,
time: time,
confirmations: confirmations ?? 0,
);
}
return bundles;
}
Future<Map<int, int>> _fetchBlockTimestampsFromMempoolByHeights(
Set<int> heights,
) async {
final out = <int, int>{};
if (heights.isEmpty) return out;
if (!(await checkIfMempoolAPIIsEnabled())) return out;
// Best-effort: if any call fails, we just skip that height.
await Future.wait(heights.map((h) async {
try {
final blockHashResp = await ProxyWrapper()
.get(
clearnetUri: Uri.parse(
'https://mempool.cakewallet.com/api/v1/block-height/$h',
),
)
.timeout(const Duration(seconds: 15));
if (blockHashResp.statusCode != 200 || blockHashResp.body.isEmpty) return;
final blockHash = blockHashResp.body.trim();
if (blockHash.isEmpty) return;
final blockResp = await ProxyWrapper()
.get(
clearnetUri: Uri.parse(
'https://mempool.cakewallet.com/api/v1/block/$blockHash',
),
)
.timeout(const Duration(seconds: 15));
if (blockResp.statusCode != 200 || blockResp.body.isEmpty) return;
final decoded = jsonDecode(blockResp.body);
final ts = decoded is Map<String, dynamic> ? decoded['timestamp'] : null;
if (ts == null) return;
final parsed = int.tryParse(ts.toString());
if (parsed == null) return;
out[h] = parsed;
} catch (_) {
// ignore
}
}));
return out;
}
Future<Map<K, V>> _processChunksToMap<T, K, V>({
required List<T> items,
required int chunkSize,
required Future<Map<K, V>> Function(List<T> chunk) processChunk,
void Function(List<T> chunk, Object error)? onChunkError,
}) async {
final result = <K, V>{};
for (var i = 0; i < items.length; i += chunkSize) {
final end = (i + chunkSize < items.length) ? i + chunkSize : items.length;
final chunk = items.sublist(i, end);
try {
final chunkResult = await processChunk(chunk);
result.addAll(chunkResult);
} on electrum.RequestFailedTimeoutException catch (e) {
onChunkError?.call(chunk, e);
continue;
} catch (e) {
onChunkError?.call(chunk, e);
continue;
}
}
return result;
}
Future<void> updateTransactions() async {
printV("updateTransactions() called!");
try {
@@ -2719,6 +3401,43 @@ abstract class ElectrumWalletBase
return base64Encode(decodedSig);
}
Future<void> _checkIfBatchSupported() async {
if (_isBatchSupported != null) {
printV('[BATCH_TEST] Already checked: $_isBatchSupported');
return;
}
final hashes = publicScriptHashes.take(batchTestHashesCount).toList();
if (hashes.length < batchTestHashesCount) {
_isBatchSupported = false;
printV('[BATCH_TEST] Failed: not enough script hashes');
return;
}
try {
final paramsList = hashes.map((hash) => <Object>[hash]).toList();
printV('[BATCH_TEST] Start: hashes=${hashes.length}, timeout=${batchTestTimeoutMs}ms');
await electrumClient.callBatchWithTimeout(
method: 'blockchain.scripthash.get_history',
paramsList: paramsList,
timeout: batchTestTimeoutMs,
);
_isBatchSupported = true;
printV('[BATCH_TEST] Result: supported=true');
} on electrum.RequestFailedTimeoutException catch (e) {
_isBatchSupported = false;
printV('[BATCH_TEST] Timeout: $e');
} catch (e) {
_isBatchSupported = false;
printV('[BATCH_TEST] Exception: $e');
}
}
@override
Future<bool> verifyMessage(String message, String signature, {String? address = null}) async {
if (address == null) {
+37 -1
View File
@@ -704,7 +704,43 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
}
}
Future<void> _generateInitialAddresses(
@action
Future<bool> discoverAddressesBatch(
List<BitcoinAddressRecord> addressList,
bool isHidden,
Future<Set<String>> Function(List<BitcoinAddressRecord>) getUsedAddresses, {
BitcoinAddressType type = SegwitAddresType.p2wpkh,
}) async {
final newAddresses = await _createNewAddresses(
gap,
startIndex: addressList.length,
isHidden: isHidden,
type: type,
);
addAddresses(newAddresses);
final usedAddresses = await getUsedAddresses(newAddresses);
final isLastAddressUsed =
newAddresses.isNotEmpty && usedAddresses.contains(newAddresses.last.address);
if (!isLastAddressUsed) {
return false;
}
final updatedAddressList = [...addressList, ...newAddresses];
await discoverAddressesBatch(
updatedAddressList,
isHidden,
getUsedAddresses,
type: type,
);
return true;
}
Future<void> _generateInitialAddresses(
{BitcoinAddressType type = SegwitAddresType.p2wpkh,
bool isLegacyDerivation = false }) async {