clear a replacement transaction when a utxo it spends is frozen rather than silently removing the input
What changed, and why it matters
This change fixes a bug in Sparrow Wallet where freezing a coin (UTXO) that was being used in a replacement transaction could silently remove the input and accidentally break the transaction's privacy-related outputs. Now, if removing the frozen coin would be unsafe, the wallet clears the replacement transaction instead of silently altering it. This is a correctness and safety improvement rather than a typical remote-exploitable vulnerability.
Review and merge if not already included in a release; verify that clear(null) correctly resets UI state and does not leave stale fee/amount values. No urgent incident response appears required.
Security signals we found
Behavioral fix preventing silent mutation of in-flight replacement transactions
Defensive copying of mutable UTXO collections to avoid side effects
Guard condition tied to isSafeToAddInputsOrOutputs for replacement transactions
Potential privacy/correctness impact on silent payment outputs
Evidence from the diff
In SendController, when a UTXO’s status changed to FROZEN, the previous code rebuilt a PresetUtxoSelector by removing all frozen UTXOs from the preset list and then calling updateTransaction(true). For replacement transactions (RBF/CPFP), silently dropping an input can invalidate or corrupt silent-payment outputs because adding or removing inputs/outputs is not always safe. The patch detects when a frozen UTXO is among the preset inputs and the replaced transaction is present and not safe to modify; in that case it calls clear(null) to discard the replacement rather than rebuild it. It also defensively copies UTXO collections to avoid mutating shared state and only forces revalidation when no replacement transaction exists.
Changed components
src/main/java/com/sparrowwallet/sparrow/wallet/SendController.javaUTXO selection / PresetUtxoSelectorReplacement transaction handlingInspect captured patch +17 / −6
### src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java
@@ -1606,14 +1606,14 @@ public void excludeUtxo(ExcludeUtxoEvent event) {
}
UtxoSelector utxoSelector = utxoSelectorProperty.get();
if(utxoSelector instanceof MaxUtxoSelector) {
- Collection<BlockTransactionHashIndex> utxos = event.getWalletTransaction().getSelectedUtxos().keySet();
+ Collection<BlockTransactionHashIndex> utxos = new ArrayList<>(event.getWalletTransaction().getSelectedUtxos().keySet());
utxos.remove(event.getUtxo());
PresetUtxoSelector presetUtxoSelector = new PresetUtxoSelector(utxos);
presetUtxoSelector.getExcludedUtxos().add(event.getUtxo());
utxoSelectorProperty.set(presetUtxoSelector);
updateTransaction(true);
} else if(utxoSelector instanceof PresetUtxoSelector existingUtxoSelector) {
- PresetUtxoSelector presetUtxoSelector = new PresetUtxoSelector(existingUtxoSelector.getPresetUtxos(), existingUtxoSelector.getExcludedUtxos());
+ PresetUtxoSelector presetUtxoSelector = new PresetUtxoSelector(new ArrayList<>(existingUtxoSelector.getPresetUtxos()), new ArrayList<>(existingUtxoSelector.getExcludedUtxos()));
presetUtxoSelector.getPresetUtxos().remove(event.getUtxo());
presetUtxoSelector.getExcludedUtxos().add(event.getUtxo());
utxoSelectorProperty.set(presetUtxoSelector);
@@ -1645,11 +1645,22 @@ public void walletUtxoStatusChanged(WalletUtxoStatusChangedEvent event) {
UtxoSelector utxoSelector = utxoSelectorProperty.get();
if(utxoSelector instanceof MaxUtxoSelector) {
updateTransaction(true);
- } else if(utxoSelectorProperty().get() instanceof PresetUtxoSelector) {
- PresetUtxoSelector presetUtxoSelector = new PresetUtxoSelector(((PresetUtxoSelector)utxoSelector).getPresetUtxos());
- presetUtxoSelector.getPresetUtxos().removeAll(event.getUtxos());
+ } else if(utxoSelector instanceof PresetUtxoSelector existingUtxoSelector) {
+ List<BlockTransactionHashIndex> frozenUtxos = event.getUtxos().stream().filter(utxo -> utxo.getStatus() == Status.FROZEN).collect(Collectors.toList());
+ List<BlockTransactionHashIndex> frozenPresetUtxos = existingUtxoSelector.getPresetUtxos().stream()
+ .filter(utxo -> frozenUtxos.stream().anyMatch(frozen -> frozen.getHash().equals(utxo.getHash()) && frozen.getIndex() == utxo.getIndex()))
+ .collect(Collectors.toList());
+ BlockTransaction replacedTransaction = replacedTransactionProperty.get();
+ if(!frozenPresetUtxos.isEmpty() && replacedTransaction != null && !getWalletForm().getWallet().isSafeToAddInputsOrOutputs(replacedTransaction)) {
+ //Removing an input could break the silent payment outputs of the replaced transaction, so clear the replacement rather than rebuild it
+ clear(null);
+ return;
+ }
+
+ PresetUtxoSelector presetUtxoSelector = new PresetUtxoSelector(new ArrayList<>(existingUtxoSelector.getPresetUtxos()), new ArrayList<>(existingUtxoSelector.getExcludedUtxos()));
+ presetUtxoSelector.getPresetUtxos().removeAll(frozenPresetUtxos);
utxoSelectorProperty.set(presetUtxoSelector);
- updateTransaction(true);
+ updateTransaction(replacedTransaction == null);
} else {
updateTransaction();
}Why this scored 32/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.