fix: checkBlockUTXO only checking the specific UTXO output
What changed, and why it matters
This commit fixes a bug in how Stack Wallet checks whether a Particl coin can be spent. Previously, the wallet looked at every output in the entire transaction to decide if a single coin (UTXO) was blocked. If any unrelated output in the same transaction was a blind/confidential/data/staking output, the wallet would wrongly mark the user's spendable coin as blocked too. Now it only checks the specific output that actually belongs to the coin in question. This is a correctness fix that could prevent coins from being incorrectly frozen or hidden from the user, but it does not appear to be a direct theft or remote-code-execution vulnerability.
Review whether the missing-output case (orElse returning null) is handled safely downstream, and consider adding a bounds/validation check for vout. Otherwise, this is a straightforward correctness fix that should be merged and tested against real Particl transactions containing mixed output types.
Security signals we found
Incorrect blocking logic could cause denial-of-spend for legitimate UTXOs
Previously scanned all transaction outputs instead of the UTXO-specific output
No input validation or bounds check added for vout against outputs length
No explicit security framing in commit message or diff comments
Evidence from the diff
In lib/wallets/wallet/impl/particl_wallet.dart, checkBlockUTXO previously iterated over all outputs in jsonTX[‘vout’] and set blocked/blockedReason/utxoLabel whenever any output had ct_fee, rangeproof, data_hex, or an OP_ISCOINSTAKE scriptPubKey. Because these variables were overwritten in the loop, the final iteration determined the result, and any matching output in the transaction could block the UTXO being evaluated. The patch extracts jsonUTXO[‘tx_pos’] as vout and uses firstWhere to inspect only the output whose ‘n’ index matches that vout. This narrows the check to the exact output the UTXO references. The change is logically a bug fix for output-tagging accuracy.
Changed components
lib/wallets/wallet/impl/particl_wallet.dartParticlWallet.checkBlockUTXOInspect captured patch +26 / −24
diff --git a/lib/wallets/wallet/impl/particl_wallet.dart b/lib/wallets/wallet/impl/particl_wallet.dart
index 6f2b976..6310bda 100644
--- a/lib/wallets/wallet/impl/particl_wallet.dart
+++ b/lib/wallets/wallet/impl/particl_wallet.dart
@@ -73,34 +73,36 @@ class ParticlWallet<T extends ElectrumXCurrencyInterface>
String? blockedReason;
String? utxoLabel;
+ // Only check the specific output this UTXO corresponds to, not all outputs.
+ final vout = jsonUTXO["tx_pos"] as int;
final outputs = jsonTX["vout"] as List? ?? [];
- for (final output in outputs) {
- if (output is Map) {
- if (output['ct_fee'] != null) {
- // Blind output, ignore for now.
- blocked = true;
- blockedReason = "Blind output.";
- utxoLabel = "Unsupported output type.";
- } else if (output['rangeproof'] != null) {
- // Private RingCT output, ignore for now.
- blocked = true;
- blockedReason = "Confidential output.";
- utxoLabel = "Unsupported output type.";
- } else if (output['data_hex'] != null) {
- // Data output, ignore for now.
+ final output = outputs.cast<Map<String, dynamic>?>().firstWhere(
+ (e) => e?["n"] == vout,
+ orElse: () => null,
+ );
+
+ if (output != null) {
+ if (output['ct_fee'] != null) {
+ blocked = true;
+ blockedReason = "Blind output.";
+ utxoLabel = "Unsupported output type.";
+ } else if (output['rangeproof'] != null) {
+ blocked = true;
+ blockedReason = "Confidential output.";
+ utxoLabel = "Unsupported output type.";
+ } else if (output['data_hex'] != null) {
+ blocked = true;
+ blockedReason = "Data output.";
+ utxoLabel = "Unsupported output type.";
+ } else if (output['scriptPubKey'] != null) {
+ if (output['scriptPubKey']?['asm'] is String &&
+ (output['scriptPubKey']['asm'] as String).contains(
+ "OP_ISCOINSTAKE",
+ )) {
blocked = true;
- blockedReason = "Data output.";
+ blockedReason = "Spending staking";
utxoLabel = "Unsupported output type.";
- } else if (output['scriptPubKey'] != null) {
- if (output['scriptPubKey']?['asm'] is String &&
- (output['scriptPubKey']['asm'] as String).contains(
- "OP_ISCOINSTAKE",
- )) {
- blocked = true;
- blockedReason = "Spending staking";
- utxoLabel = "Unsupported output type.";
- }
}
}
}
Why this scored 40/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.