mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-17 05:41:29 +02:00
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:
+194
-26
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user