firo restore and refresh optimizations
What changed, and why it matters
This commit is a performance optimization for the Firo wallet in Stack Wallet. It replaces many individual ElectrumX server lookups with batched requests when restoring or refreshing a wallet, and it removes some redundant confirmation checks. The changes are mostly about speed and efficiency, not about fixing a security vulnerability. There is one small behavior change: the code now assumes any transaction already recorded with a block height is confirmed, instead of double-checking it every refresh. That is a reasonable optimization for Firo but could theoretically miss a rare blockchain reorganization. No exploit or backdoor is visible in the diff.
Treat as a routine optimization commit. Reviewers may want to confirm that the batched RPC responses are validated (e.g., missing txids do not trigger unhandled exceptions) and that the new 'confirmed if height present' assumption is acceptable given Firo's reorg risk. No security patch or incident response is indicated by the diff.
Security signals we found
New batched RPC path added (blockchain.transaction.get batch) — standard ElectrumX feature, no auth bypass
Assumption introduced: transactions with non-null height > 1 in local DB are treated as confirmed and skipped on refresh
Null-assertion operator (!) used on batched lookup maps (someInputTxns[txid]!, coinsToCheckTransactions[coin.txHash]!) — safe only if server returns all requested txids
Removed per-refresh re-verification of confirmed transactions, reducing resilience to chain reorganizations
No input sanitization changes for txids or RPC responses
Evidence from the diff
The patch adds getBatchTransactions() to ElectrumXClient (a JSON-RPC batch call to blockchain.transaction.get) and a caching wrapper in CachedElectrumXClient. FiroWallet.updateTransactions() and SparkInterface.refreshSparkData() are refactored to: collect all txids into a map (allHistory), skip txids already confirmed in the local Isar DB, fetch remaining txids in batches of 100, and fetch input prevout transactions in batches. Individual getTransaction() calls are replaced with map lookups (someInputTxns[txid]!, coinsToCheckTransactions[coin.txHash]!). A TODO about verifying 1000 FIRO masternode outputs is replaced with a comment about falling back to locked if the network call fails. No input validation, cryptographic, or authorization changes are introduced.
Changed components
lib/electrumx_rpc/electrumx_client.dartlib/electrumx_rpc/cached_electrumx_client.dartlib/wallets/wallet/impl/firo_wallet.dartlib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dartInspect captured patch +245 / −82
diff --git a/lib/electrumx_rpc/cached_electrumx_client.dart b/lib/electrumx_rpc/cached_electrumx_client.dart
index 7c23af4..e1b2235 100644
--- a/lib/electrumx_rpc/cached_electrumx_client.dart
+++ b/lib/electrumx_rpc/cached_electrumx_client.dart
@@ -26,15 +26,13 @@ class CachedElectrumXClient {
required ElectrumXClient electrumXClient,
}) => CachedElectrumXClient(electrumXClient: electrumXClient);
- String base64ToHex(String source) =>
- base64Decode(
- LineSplitter.split(source).join(),
- ).map((e) => e.toRadixString(16).padLeft(2, '0')).join();
+ String base64ToHex(String source) => base64Decode(
+ LineSplitter.split(source).join(),
+ ).map((e) => e.toRadixString(16).padLeft(2, '0')).join();
- String base64ToReverseHex(String source) =>
- base64Decode(
- LineSplitter.split(source).join(),
- ).reversed.map((e) => e.toRadixString(16).padLeft(2, '0')).join();
+ String base64ToReverseHex(String source) => base64Decode(
+ LineSplitter.split(source).join(),
+ ).reversed.map((e) => e.toRadixString(16).padLeft(2, '0')).join();
/// Call electrumx getTransaction on a per coin basis, storing the result in local db if not already there.
///
@@ -77,6 +75,55 @@ class CachedElectrumXClient {
}
}
+ Future<List<Map<String, dynamic>>> getBatchTransactions({
+ required List<String> txHashes,
+ required CryptoCurrency cryptoCurrency,
+ }) async {
+ try {
+ final box = await DB.instance.getTxCacheBox(currency: cryptoCurrency);
+
+ final List<Map<String, dynamic>> result = [];
+ final List<String> needsFetching = [];
+
+ for (final txHash in txHashes) {
+ final cachedTx = box.get(txHash) as Map?;
+ if (cachedTx == null) {
+ needsFetching.add(txHash);
+ } else {
+ result.add(Map<String, dynamic>.from(cachedTx));
+ }
+ }
+
+ if (needsFetching.isNotEmpty) {
+ final txns = await electrumXClient.getBatchTransactions(
+ txHashes: needsFetching,
+ );
+
+ for (final tx in txns) {
+ tx.remove("hex");
+ tx.remove("lelantusData");
+ tx.remove("sparkData");
+
+ if (tx["confirmations"] != null &&
+ tx["confirmations"] as int > minCacheConfirms) {
+ await box.put(tx["txid"] as String, tx);
+ }
+
+ result.add(tx);
+ }
+ }
+
+ return result;
+ } catch (e, s) {
+ Logging.instance.e(
+ "Failed to process CachedElectrumX.getTransaction(): ",
+ error: e,
+ stackTrace: s,
+ );
+ rethrow;
+ }
+ }
+
/// Clear all cached transactions for the specified coin
Future<void> clearSharedTransactionCache({
required CryptoCurrency cryptoCurrency,
diff --git a/lib/electrumx_rpc/electrumx_client.dart b/lib/electrumx_rpc/electrumx_client.dart
index 2ef2791..e38e73a 100644
--- a/lib/electrumx_rpc/electrumx_client.dart
+++ b/lib/electrumx_rpc/electrumx_client.dart
@@ -828,6 +828,28 @@ class ElectrumXClient {
return Map<String, dynamic>.from(response as Map);
}
+ Future<List<Map<String, dynamic>>> getBatchTransactions({
+ required List<String> txHashes,
+ String? requestID,
+ }) async {
+ Logging.instance.d(
+ "attempting to fetch BATCHED blockchain.transaction.get...",
+ );
+
+ final response = await batchRequest(
+ command: 'blockchain.transaction.get',
+ args: txHashes.map((e) => [e, true]).toList(),
+ );
+ final List<Map<String, dynamic>> result = [];
+ for (int i = 0; i < response.length; i++) {
+ result.add(Map<String, dynamic>.from(response[i] as Map));
+ }
+
+ Logging.instance.d("Fetching blockchain.transaction.get BATCHED finished");
+
+ return result;
+ }
+
/// Returns the whole Lelantus anonymity set for denomination in the groupId.
///
/// ex:
diff --git a/lib/wallets/wallet/impl/firo_wallet.dart b/lib/wallets/wallet/impl/firo_wallet.dart
index bd2b3f7..701f4a4 100644
--- a/lib/wallets/wallet/impl/firo_wallet.dart
+++ b/lib/wallets/wallet/impl/firo_wallet.dart
@@ -93,10 +93,28 @@ class FiroWallet<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
final allAddressesSet = {...receivingAddresses, ...changeAddresses};
- final List<Map<String, dynamic>> allTxHashes = await fetchHistory(
+ Logging.instance.d(
+ "firo_wallet.dart updateTransactions() allAddressesSet.length: "
+ "${allAddressesSet.length}",
+ );
+
+ final List<Map<String, dynamic>> allTxHashes1 = await fetchHistory(
allAddressesSet,
);
+ Logging.instance.d(
+ "firo_wallet.dart updateTransactions() allTxHashes.length: "
+ "${allTxHashes1.length}",
+ );
+
+ final Map<String, Map<String, dynamic>> allHistory = {};
+
+ for (final item in allTxHashes1) {
+ final txid = item["tx_hash"] as String;
+ allHistory[txid] ??= {};
+ allHistory[txid]!["height"] ??= item["height"] as int?;
+ }
+
final sparkCoins = await mainDB.isar.sparkCoins
.where()
.walletIdEqualToAnyLTagHash(walletId)
@@ -111,85 +129,118 @@ class FiroWallet<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
.walletIdEqualTo(walletId)
.filter()
.heightIsNull()
+ .txidProperty()
.findAll();
- for (final tx in unconfirmedTransactions) {
- final txn = await electrumXCachedClient.getTransaction(
- txHash: tx.txid,
- verbose: true,
- cryptoCurrency: info.coin,
- );
- final height = txn["height"] as int?;
-
- if (height != null) {
- // tx was mined
- // add to allTxHashes
- final info = {"tx_hash": tx.txid, "height": height};
- allTxHashes.add(info);
+ for (final txid in unconfirmedTransactions) {
+ if (allHistory[txid] == null) {
+ allHistory[txid] = {};
}
}
final Set<String> sparkTxids = {};
for (final coin in sparkCoins) {
sparkTxids.add(coin.txHash);
- // check for duplicates before adding to list
- if (allTxHashes.indexWhere((e) => e["tx_hash"] == coin.txHash) == -1) {
- final info = {"tx_hash": coin.txHash, "height": coin.height};
- allTxHashes.add(info);
+ if (allHistory[coin.txHash] == null) {
+ allHistory[coin.txHash] = {"height": coin.height};
}
}
final missing = await getSparkSpendTransactionIds();
for (final txid in missing.map((e) => e.txid).toSet()) {
- // check for duplicates before adding to list
- if (allTxHashes.indexWhere((e) => e["tx_hash"] == txid) == -1) {
- final info = {"tx_hash": txid};
- allTxHashes.add(info);
+ if (allHistory[txid] == null) {
+ allHistory[txid] = {};
}
}
- final currentHeight = await chainHeight;
-
- for (final txHash in allTxHashes) {
- final storedTx = await mainDB.isar.transactionV2s
- .where()
- .walletIdEqualTo(walletId)
- .filter()
- .txidEqualTo(txHash["tx_hash"] as String)
- .findFirst();
-
- if (storedTx?.isConfirmed(
- currentHeight,
- cryptoCurrency.minConfirms,
- cryptoCurrency.minCoinbaseConfirms,
- ) ==
- true) {
- // tx already confirmed, no need to process it again
- continue;
- }
+ final confirmedTxidsInIsar = await mainDB.isar.transactionV2s
+ .where()
+ .walletIdEqualTo(walletId)
+ .filter()
+ .heightIsNotNull()
+ .and()
+ .heightGreaterThan(1)
+ .txidProperty()
+ .findAll();
- // firod/electrumx seem to take forever to process spark txns so we'll
- // just ignore null errors and check again on next refresh.
- // This could also be a bug in the custom electrumx rpc code
- final Map<String, dynamic> tx;
- try {
- tx = await electrumXCachedClient.getTransaction(
- txHash: txHash["tx_hash"] as String,
- verbose: true,
- cryptoCurrency: info.coin,
- );
- } catch (_) {
- continue;
- }
+ Logging.instance.d(
+ "firo_wallet.dart updateTransactions() confirmedTxidsInIsar.length: "
+ "${confirmedTxidsInIsar.length}",
+ );
+
+ // assume every tx that has a height is confirmed and remove them from the
+ // list of transactions to fetch and check. This should be fine in firo.
+ confirmedTxidsInIsar.forEach(allHistory.remove);
+
+ final allTxids = allHistory.keys.toList(growable: false);
- // check for duplicates before adding to list
- if (allTransactions.indexWhere(
- (e) => e["txid"] == tx["txid"] as String,
- ) ==
- -1) {
- tx["height"] ??= txHash["height"];
+ const batchSize = 100;
+ final remainder = allTxids.length % batchSize;
+ final batchCount = allTxids.length ~/ batchSize;
+
+ for (int i = 0; i < batchCount; i++) {
+ final start = i * batchSize;
+ final end = start + batchSize;
+ Logging.instance.i("[allTxids]: Fetching batch #$i");
+ final txns = await electrumXCachedClient.getBatchTransactions(
+ txHashes: allTxids.sublist(start, end),
+ cryptoCurrency: cryptoCurrency,
+ );
+ for (final tx in txns) {
+ tx["height"] ??= allHistory[tx["txid"]]!["height"];
allTransactions.add(tx);
}
}
+ // handle remainder
+ if (remainder > 0) {
+ final txns = await electrumXCachedClient.getBatchTransactions(
+ txHashes: allTxids.sublist(allTxids.length - remainder),
+ cryptoCurrency: cryptoCurrency,
+ );
+ for (final tx in txns) {
+ tx["height"] ??= allHistory[tx["txid"]]!["height"];
+ allTransactions.add(tx);
+ }
+ }
+
+ final Set<String> txInputTxidsSet = {};
+ for (final txData in allTransactions) {
+ for (final jsonInput in txData["vin"] as List) {
+ final map = Map<String, dynamic>.from(jsonInput as Map);
+ final coinbase = map["coinbase"] as String?;
+
+ final txid = map["txid"] as String?;
+ final vout = map["vout"] as int?;
+ if (coinbase == null && txid != null && vout != null) {
+ txInputTxidsSet.add(txid);
+ }
+ }
+ }
+ final txInputTxids = txInputTxidsSet.toList(growable: false);
+
+ final Map<String, Map<String, dynamic>> someInputTxns = {};
+ final remainder2 = txInputTxids.length % batchSize;
+ for (int i = 0; i < txInputTxids.length ~/ batchSize; i++) {
+ final start = i * batchSize;
+ final end = start + batchSize;
+ Logging.instance.i("[txInputTxids]: Fetching batch #$i");
+ final txns = await electrumXCachedClient.getBatchTransactions(
+ txHashes: txInputTxids.sublist(start, end),
+ cryptoCurrency: cryptoCurrency,
+ );
+ for (final tx in txns) {
+ someInputTxns[tx["txid"] as String] = tx;
+ }
+ }
+ // handle remainder
+ if (remainder2 > 0) {
+ final txns = await electrumXCachedClient.getBatchTransactions(
+ txHashes: txInputTxids.sublist(txInputTxids.length - remainder2),
+ cryptoCurrency: cryptoCurrency,
+ );
+ for (final tx in txns) {
+ someInputTxns[tx["txid"] as String] = tx;
+ }
+ }
final List<TransactionV2> txns = [];
@@ -225,14 +276,16 @@ class FiroWallet<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
if (isMySpark && sparkCoinsInvolvedReceived.isEmpty && !isMySpentSpark) {
Logging.instance.e(
- "sparkCoinsInvolvedReceived is empty and should not be! (ignoring tx parsing)",
+ "sparkCoinsInvolvedReceived is empty and should not be!"
+ " (ignoring tx parsing)",
);
continue;
}
if (isMySpentSpark && sparkCoinsInvolvedSpent.isEmpty && !isMySpark) {
Logging.instance.e(
- "sparkCoinsInvolvedSpent is empty and should not be! (ignoring tx parsing)",
+ "sparkCoinsInvolvedSpent is empty and should not be!"
+ " (ignoring tx parsing)",
);
continue;
}
@@ -250,7 +303,8 @@ class FiroWallet<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
isMint = true;
} else {
Logging.instance.d(
- "Unknown mint op code found for lelantusmint tx: ${txData["txid"]}",
+ "Unknown mint op code found for lelantusmint tx: "
+ "${txData["txid"]}",
);
}
} else {
@@ -268,7 +322,8 @@ class FiroWallet<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
isSparkMint = true;
} else {
Logging.instance.d(
- "Unknown mint op code found for sparkmint tx: ${txData["txid"]}",
+ "Unknown mint op code found for sparkmint tx: "
+ "${txData["txid"]}",
);
}
} else {
@@ -431,10 +486,8 @@ class FiroWallet<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
anonFees = anonFees! + fees;
}
} else if (coinbase == null && txid != null && vout != null) {
- final inputTx = await electrumXCachedClient.getTransaction(
- txHash: txid,
- cryptoCurrency: cryptoCurrency,
- );
+ // fetched earlier so ! unwrap should be ok
+ final inputTx = someInputTxns[txid]!;
final prevOutJson = Map<String, dynamic>.from(
(inputTx["vout"] as List).firstWhere((e) => e["n"] == vout) as Map,
@@ -623,8 +676,8 @@ class FiroWallet<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
String? label;
if (jsonUTXO["value"] is int) {
- // TODO: [prio=high] use special electrumx call to verify the 1000 Firo output is masternode
- // electrumx call should exist now. Unsure if it works though
+ // verify the 1000 Firo output is masternode
+ // Fall back to locked in case network call fails
blocked =
Amount.fromDecimal(
Decimal.fromInt(
diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart
index 5a2a025..d759107 100644
--- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart
+++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart
@@ -1209,16 +1209,57 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
);
}
+ Logging.instance.d(
+ "refreshSparkData() coinsToCheck.length: "
+ "${coinsToCheck.length}",
+ );
+
+ // prepare data for next step
+ final coinsToCheckTxids = coinsToCheck
+ .where((e) => e.height == null)
+ .map((e) => e.txHash)
+ .toList(growable: false);
+
+ final Map<String, Map<String, dynamic>> coinsToCheckTransactions = {};
+ if (coinsToCheckTxids.isNotEmpty) {
+ const batchSize = 100;
+ final remainder = coinsToCheckTxids.length % batchSize;
+ final batchCount = coinsToCheckTxids.length ~/ batchSize;
+
+ for (int i = 0; i < batchCount; i++) {
+ final start = i * batchSize;
+ final end = start + batchSize;
+ Logging.instance.i("[coinsToCheck]: Fetching batch #$i");
+ final txns = await electrumXCachedClient.getBatchTransactions(
+ txHashes: coinsToCheckTxids.sublist(start, end),
+ cryptoCurrency: cryptoCurrency,
+ );
+ for (final tx in txns) {
+ coinsToCheckTransactions[tx["txid"] as String] = tx;
+ }
+ }
+ // handle remainder
+ if (remainder > 0) {
+ final txns = await electrumXCachedClient.getBatchTransactions(
+ txHashes: coinsToCheckTxids.sublist(
+ coinsToCheckTxids.length - remainder,
+ ),
+ cryptoCurrency: cryptoCurrency,
+ );
+ for (final tx in txns) {
+ coinsToCheckTransactions[tx["txid"] as String] = tx;
+ }
+ }
+ }
+
// check and update coins if required
final List<SparkCoin> checkedCoins = [];
for (final coin in coinsToCheck) {
final SparkCoin checked;
if (coin.height == null) {
- final tx = await electrumXCachedClient.getTransaction(
- txHash: coin.txHash,
- cryptoCurrency: info.coin,
- );
+ final tx = coinsToCheckTransactions[coin.txHash]!;
+
if (tx["height"] is int) {
checked = coin.copyWith(
height: tx["height"] as int,
Why this scored 17/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.