check all open tabs when verifying a scanned or loaded transaction matches the originating psbt
What changed, and why it matters
This commit tightens how Sparrow Wallet checks that a scanned QR code or loaded file matches the transaction the user is currently working on. Previously, the app only compared the new transaction/PSBT against the single currently open tab. Now it checks all open transaction tabs. This reduces the chance that a user accidentally accepts a wrong or maliciously substituted transaction because they had the wrong tab active. It is a defensive hardening fix, not a clear-cut remote exploit.
Treat as a defensive fix worth including in the next release. Review whether matchesOpenTransactionTab correctly handles tabs with no PSBT (transaction-only comparison by txid) and ensure the new error dialog text is localized. No immediate incident response is indicated absent a disclosed exploit.
Security signals we found
UI workflow hardening: cross-tab verification prevents acceptance of mismatched transactions/PSBTs
Previously, verification was scoped to a single tab's context PSBT, which could be bypassed by tab confusion or user error
Silent Payments edge case preserved: possibleUnverifiableSilentPaymentsTransaction still triggers a dedicated warning
No cryptographic changes; purely validation/UX logic
No CVE, advisory, or vendor security statement supplied
Evidence from the diff
The patch refactors transaction/PSBT verification in AppController. A new verifyTransactionContext() method and matchesOpenTransactionTab() helper compare an incoming Transaction or PSBT against every open TransactionTabData, not just contextPsbt of the current tab. ViewTransactionEvent and ViewPSBTEvent now carry an optional context PSBT, and HeadersController posts these events with the originating PSBT instead of performing its own single-tab match. The old per-tab checks are removed. Error messages now say ‘this or any other open tab’.
Changed components
com.sparrowwallet.sparrow.AppControllercom.sparrowwallet.sparrow.event.ViewPSBTEventcom.sparrowwallet.sparrow.event.ViewTransactionEventcom.sparrowwallet.sparrow.transaction.HeadersControllerInspect captured patch +66 / −23
diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java
index ab15c02..1ccf222 100644
--- a/src/main/java/com/sparrowwallet/sparrow/AppController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java
@@ -1951,20 +1951,14 @@ public class AppController implements Initializable {
if(PSBT.isPSBT(bytes)) {
//Don't verify signatures here - provided PSBT may omit UTXO data that can be found when combining with an existing PSBT
PSBT psbt = new PSBT(bytes, false);
- if(contextPsbt == null || contextPsbt.matches(psbt)) {
+ if(verifyTransactionContext(contextPsbt, null, psbt, "loaded")) {
addTransactionTab(name, file, psbt);
- } else {
- AppServices.showErrorDialog("Mismatched Transaction", "The loaded transaction does not match the transaction in this tab.\n\nCheck that the correct transaction was signed and exported from the signing device.");
}
} else if(Transaction.isTransaction(bytes)) {
try {
Transaction transaction = new Transaction(bytes);
- if(contextPsbt == null || contextPsbt.matches(transaction)) {
+ if(verifyTransactionContext(contextPsbt, transaction, null, "loaded")) {
addTransactionTab(name, file, transaction);
- } else if(contextPsbt.possibleUnverifiableSilentPaymentsTransaction(transaction)) {
- AppServices.showErrorDialog("Silent Payments Transaction", "This transaction pays a silent payment address.\n\nThe signing device must return the PSBT rather than the final transaction, so the silent payment outputs can be verified.");
- } else {
- AppServices.showErrorDialog("Mismatched Transaction", "The loaded transaction does not match the transaction in this tab.\n\nCheck that the correct transaction was signed and exported from the signing device.");
}
} catch(Exception e) {
throw new TransactionParseException(e.getMessage());
@@ -2198,6 +2192,37 @@ public class AppController implements Initializable {
tabs.getSelectionModel().select(tab);
}
+ private boolean verifyTransactionContext(PSBT contextPsbt, Transaction transaction, PSBT psbt, String source) {
+ if(contextPsbt == null || matchesOpenTransactionTab(transaction, psbt)) {
+ return true;
+ }
+
+ if(psbt == null && contextPsbt.possibleUnverifiableSilentPaymentsTransaction(transaction)) {
+ AppServices.showErrorDialog("Silent Payments Transaction", "This transaction pays a silent payment address.\n\nThe signing device must return the PSBT rather than the final transaction, so the silent payment outputs can be verified.");
+ } else {
+ AppServices.showErrorDialog("Mismatched Transaction", "The " + source + " transaction does not match the transaction in this or any other open tab.\n\nCheck that the correct transaction was signed and exported from the signing device.");
+ }
+
+ return false;
+ }
+
+ private boolean matchesOpenTransactionTab(Transaction transaction, PSBT psbt) {
+ for(Tab tab : tabs.getTabs()) {
+ TabData tabData = (TabData)tab.getUserData();
+ if(tabData instanceof TransactionTabData transactionTabData) {
+ if(transactionTabData.getPsbt() != null) {
+ if(psbt != null ? transactionTabData.getPsbt().matches(psbt) : transactionTabData.getPsbt().matches(transaction)) {
+ return true;
+ }
+ } else if(transactionTabData.getTransaction().calculateTxId(false).equals(psbt != null ? psbt.getTransaction().getTxId() : transaction.getTxId())) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
private boolean openUnverifiableTransaction(String tabName) {
Optional<ButtonType> result = AppServices.showWarningDialog(
"Unverifiable Silent Payments Transaction",
@@ -3236,7 +3261,7 @@ public class AppController implements Initializable {
if(tabs.getScene().getWindow().equals(event.getWindow())) {
if(event.getBlockTransaction() != null) {
addTransactionTab(event.getBlockTransaction(), event.getInitialView(), event.getInitialIndex());
- } else {
+ } else if(verifyTransactionContext(event.getContextPsbt(), event.getTransaction(), null, "scanned")) {
addTransactionTab(event.getTransaction(), event.getInitialView(), event.getInitialIndex());
}
}
@@ -3245,7 +3270,9 @@ public class AppController implements Initializable {
@Subscribe
public void viewPSBT(ViewPSBTEvent event) {
if(tabs.getScene().getWindow().equals(event.getWindow())) {
- addTransactionTab(event.getLabel(), event.getFile(), event.getPsbt());
+ if(verifyTransactionContext(event.getContextPsbt(), null, event.getPsbt(), "scanned")) {
+ addTransactionTab(event.getLabel(), event.getFile(), event.getPsbt());
+ }
}
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/event/ViewPSBTEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/ViewPSBTEvent.java
index 87d9ca4..d36c6bb 100644
--- a/src/main/java/com/sparrowwallet/sparrow/event/ViewPSBTEvent.java
+++ b/src/main/java/com/sparrowwallet/sparrow/event/ViewPSBTEvent.java
@@ -11,18 +11,28 @@ public class ViewPSBTEvent {
private final String label;
private final File file;
private final PSBT psbt;
+ private final PSBT contextPsbt;
private final TransactionView initialView;
private final Integer initialIndex;
public ViewPSBTEvent(Window window, String label, File file, PSBT psbt) {
- this(window, label, file, psbt, TransactionView.HEADERS, null);
+ this(window, label, file, psbt, null, TransactionView.HEADERS, null);
+ }
+
+ public ViewPSBTEvent(Window window, String label, File file, PSBT psbt, PSBT contextPsbt) {
+ this(window, label, file, psbt, contextPsbt, TransactionView.HEADERS, null);
}
public ViewPSBTEvent(Window window, String label, File file, PSBT psbt, TransactionView initialView, Integer initialIndex) {
+ this(window, label, file, psbt, null, initialView, initialIndex);
+ }
+
+ public ViewPSBTEvent(Window window, String label, File file, PSBT psbt, PSBT contextPsbt, TransactionView initialView, Integer initialIndex) {
this.window = window;
this.label = label;
this.file = file;
this.psbt = psbt;
+ this.contextPsbt = contextPsbt;
this.initialView = initialView;
this.initialIndex = initialIndex;
}
@@ -43,6 +53,10 @@ public class ViewPSBTEvent {
return psbt;
}
+ public PSBT getContextPsbt() {
+ return contextPsbt;
+ }
+
public TransactionView getInitialView() {
return initialView;
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/event/ViewTransactionEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/ViewTransactionEvent.java
index 4e749ce..91cf53b 100644
--- a/src/main/java/com/sparrowwallet/sparrow/event/ViewTransactionEvent.java
+++ b/src/main/java/com/sparrowwallet/sparrow/event/ViewTransactionEvent.java
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.protocol.Transaction;
+import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.sparrow.transaction.TransactionView;
import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
@@ -9,13 +10,19 @@ import javafx.stage.Window;
public class ViewTransactionEvent {
private final Window window;
private final Transaction transaction;
+ private final PSBT contextPsbt;
private final BlockTransaction blockTransaction;
private final TransactionView initialView;
private final Integer initialIndex;
public ViewTransactionEvent(Window window, Transaction transaction) {
+ this(window, transaction, null);
+ }
+
+ public ViewTransactionEvent(Window window, Transaction transaction, PSBT contextPsbt) {
this.window = window;
this.transaction = transaction;
+ this.contextPsbt = contextPsbt;
this.blockTransaction = null;
this.initialView = TransactionView.HEADERS;
this.initialIndex = null;
@@ -32,6 +39,7 @@ public class ViewTransactionEvent {
public ViewTransactionEvent(Window window, BlockTransaction blockTransaction, TransactionView initialView, Integer initialIndex) {
this.window = window;
this.transaction = blockTransaction.getTransaction();
+ this.contextPsbt = null;
this.blockTransaction = blockTransaction;
this.initialView = initialView;
this.initialIndex = initialIndex;
@@ -45,6 +53,10 @@ public class ViewTransactionEvent {
return transaction;
}
+ public PSBT getContextPsbt() {
+ return contextPsbt;
+ }
+
public BlockTransaction getBlockTransaction() {
return blockTransaction;
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
index 77a452f..dbf3757 100644
--- a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
@@ -1037,19 +1037,9 @@ public class HeadersController extends TransactionFormController implements Init
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
if(result.transaction != null) {
- if(headersForm.getPsbt().matches(result.transaction)) {
- EventManager.get().post(new ViewTransactionEvent(toggleButton.getScene().getWindow(), result.transaction));
- } else if(headersForm.getPsbt().possibleUnverifiableSilentPaymentsTransaction(result.transaction)) {
- AppServices.showErrorDialog("Silent Payments Transaction", "This transaction pays a silent payment address.\n\nThe signing device must return the PSBT rather than the final transaction, so the silent payment outputs can be verified.");
- } else {
- AppServices.showErrorDialog("Mismatched Transaction", "The scanned transaction does not match the transaction in this tab.\n\nCheck that the correct transaction was signed and exported from the signing device.");
- }
+ EventManager.get().post(new ViewTransactionEvent(toggleButton.getScene().getWindow(), result.transaction, headersForm.getPsbt()));
} else if(result.psbt != null) {
- if(headersForm.getPsbt().matches(result.psbt)) {
- EventManager.get().post(new ViewPSBTEvent(toggleButton.getScene().getWindow(), null, null, result.psbt));
- } else {
- AppServices.showErrorDialog("Mismatched Transaction", "The scanned transaction does not match the transaction in this tab.\n\nCheck that the correct transaction was signed and exported from the signing device.");
- }
+ EventManager.get().post(new ViewPSBTEvent(toggleButton.getScene().getWindow(), null, null, result.psbt, headersForm.getPsbt()));
} else if(result.seed != null) {
signFromSeed(result.seed);
} else if(result.exception != null) {
Why this scored 59/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.