verify scanned or loaded transactions match the originating psbt
What changed, and why it matters
This commit adds a safety check in the Sparrow Wallet desktop app to make sure a transaction loaded from a file or scanned from a QR code actually matches the PSBT (a partially-signed Bitcoin transaction) that was already open. Before this change, the app would accept any transaction or PSBT from a file/scan and display it, which could mislead a user into thinking a different transaction was the one they intended to sign. The fix shows an error dialog when the loaded or scanned data does not match the original PSBT, and includes a special message for silent-payment transactions that cannot be verified from a final transaction alone.
Review the PSBT.matches() and possibleUnverifiableSilentPaymentsTransaction() implementations in drongo to confirm they compare all security-relevant fields (inputs, outputs, locktime, version, witness data) and cannot be bypassed by malleable fields. Also verify that the context PSBT cannot be null in security-critical flows and that the error dialogs block further action on the mismatched data.
Security signals we found
New integrity/matching validation between an originating PSBT and subsequently loaded or scanned transactions/PSBTs
User-facing error dialogs for mismatched transactions and silent-payment transactions
Prevention of displaying a substituted transaction as if it were the intended one
Special handling for silent payments where the final transaction cannot be verified against the PSBT
Evidence from the diff
The patch threads a context PSBT through transaction-open request events and file-loading methods. In AppController, openTransactionFromFile, openTransactionFile, and the addTransactionTab overloads now accept an optional PSBT. When a PSBT or raw transaction is loaded, the code calls contextPsbt.matches(psbt) or contextPsbt.matches(transaction). If they do not match, an error dialog is shown and the tab is not opened/replaced. HeadersController applies the same matching logic to QR-scan results and passes headersForm.getPsbt() when requesting a file open. A special case calls possibleUnverifiableSilentPaymentsTransaction(transaction) to warn when a silent-payment output prevents verification from a final transaction.
Changed components
com.sparrowwallet.sparrow.AppControllercom.sparrowwallet.sparrow.event.RequestTransactionOpenEventcom.sparrowwallet.sparrow.transaction.HeadersControllerFile/QR transaction loading flowPSBT matching logic in drongo (referenced but not shown in diff)Inspect captured patch +64 / −28
diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java
index 534e4bf..ab15c02 100644
--- a/src/main/java/com/sparrowwallet/sparrow/AppController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java
@@ -614,6 +614,10 @@ public class AppController implements Initializable {
}
public void openTransactionFromFile(ActionEvent event) {
+ openTransactionFromFile(event, null);
+ }
+
+ private void openTransactionFromFile(ActionEvent event, PSBT contextPsbt) {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
@@ -628,19 +632,21 @@ public class AppController implements Initializable {
List<File> files = fileChooser.showOpenMultipleDialog(window);
if(files != null) {
for(File file : files) {
- openTransactionFile(file);
+ openTransactionFile(file, contextPsbt);
}
}
}
- private void openTransactionFile(File file) {
- for(Tab tab : tabs.getTabs()) {
- TabData tabData = (TabData)tab.getUserData();
- if(tabData instanceof TransactionTabData) {
- TransactionTabData transactionTabData = (TransactionTabData)tabData;
- if(file.equals(transactionTabData.getFile())) {
- tabs.getSelectionModel().select(tab);
- return;
+ private void openTransactionFile(File file, PSBT contextPsbt) {
+ if(contextPsbt == null) {
+ for(Tab tab : tabs.getTabs()) {
+ TabData tabData = (TabData)tab.getUserData();
+ if(tabData instanceof TransactionTabData) {
+ TransactionTabData transactionTabData = (TransactionTabData)tabData;
+ if(file.equals(transactionTabData.getFile())) {
+ tabs.getSelectionModel().select(tab);
+ return;
+ }
}
}
}
@@ -651,9 +657,9 @@ public class AppController implements Initializable {
String name = file.getName();
if(Utils.isHex(bytes) || Utils.isBase64(bytes)) {
- addTransactionTab(name, file, new String(bytes, StandardCharsets.UTF_8).trim());
+ addTransactionTab(name, file, new String(bytes, StandardCharsets.UTF_8).trim(), contextPsbt);
} else {
- addTransactionTab(name, file, bytes);
+ addTransactionTab(name, file, bytes, contextPsbt);
}
} catch(IOException e) {
showErrorDialog("Error opening file", e.getMessage());
@@ -675,7 +681,7 @@ public class AppController implements Initializable {
Optional<String> text = dialog.showAndWait();
if(text.isPresent() && !text.get().isEmpty()) {
try {
- addTransactionTab(null, null, text.get().trim());
+ addTransactionTab(null, null, text.get().trim(), null);
} catch(PSBTParseException e) {
showErrorDialog("Invalid PSBT", e.getMessage());
} catch(TransactionParseException e) {
@@ -1091,7 +1097,7 @@ public class AppController implements Initializable {
verifyOpened = true;
}
} else {
- openTransactionFile(file);
+ openTransactionFile(file, null);
}
}
}
@@ -1931,25 +1937,35 @@ public class AppController implements Initializable {
return Collections.emptyList();
}
- private void addTransactionTab(String name, File file, String string) throws ParseException, PSBTParseException, TransactionParseException {
+ private void addTransactionTab(String name, File file, String string, PSBT contextPsbt) throws ParseException, PSBTParseException, TransactionParseException {
if(Utils.isBase64(string) && !Utils.isHex(string)) {
- addTransactionTab(name, file, Base64.getDecoder().decode(string));
+ addTransactionTab(name, file, Base64.getDecoder().decode(string), contextPsbt);
} else if(Utils.isHex(string)) {
- addTransactionTab(name, file, Utils.hexToBytes(string));
+ addTransactionTab(name, file, Utils.hexToBytes(string), contextPsbt);
} else {
throw new ParseException("Input is not base64 or hex", 0);
}
}
- private void addTransactionTab(String name, File file, byte[] bytes) throws PSBTParseException, ParseException, TransactionParseException {
+ private void addTransactionTab(String name, File file, byte[] bytes, PSBT contextPsbt) throws PSBTParseException, ParseException, TransactionParseException {
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);
- addTransactionTab(name, file, psbt);
+ if(contextPsbt == null || contextPsbt.matches(psbt)) {
+ 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);
- addTransactionTab(name, file, transaction);
+ if(contextPsbt == null || contextPsbt.matches(transaction)) {
+ 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());
}
@@ -3290,9 +3306,9 @@ public class AppController implements Initializable {
public void requestTransactionOpen(RequestTransactionOpenEvent event) {
if(tabs.getScene().getWindow().equals(event.getWindow())) {
if(event.getFile() != null) {
- openTransactionFile(event.getFile());
+ openTransactionFile(event.getFile(), event.getContextPsbt());
} else {
- openTransactionFromFile(null);
+ openTransactionFromFile(null, event.getContextPsbt());
}
}
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/event/RequestTransactionOpenEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/RequestTransactionOpenEvent.java
index bb16c0d..57b4038 100644
--- a/src/main/java/com/sparrowwallet/sparrow/event/RequestTransactionOpenEvent.java
+++ b/src/main/java/com/sparrowwallet/sparrow/event/RequestTransactionOpenEvent.java
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.event;
+import com.sparrowwallet.drongo.psbt.PSBT;
import javafx.stage.Window;
import java.io.File;
@@ -10,15 +11,20 @@ import java.io.File;
public class RequestTransactionOpenEvent {
private final Window window;
private final File file;
+ private final PSBT contextPsbt;
public RequestTransactionOpenEvent(Window window) {
- this.window = window;
- this.file = null;
+ this(window, null, null);
}
public RequestTransactionOpenEvent(Window window, File file) {
+ this(window, file, null);
+ }
+
+ public RequestTransactionOpenEvent(Window window, File file, PSBT contextPsbt) {
this.window = window;
this.file = file;
+ this.contextPsbt = contextPsbt;
}
public Window getWindow() {
@@ -28,4 +34,8 @@ public class RequestTransactionOpenEvent {
public File getFile() {
return file;
}
+
+ public PSBT getContextPsbt() {
+ return contextPsbt;
+ }
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
index 28c8b83..77a452f 100644
--- a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
@@ -1037,9 +1037,19 @@ public class HeadersController extends TransactionFormController implements Init
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
if(result.transaction != null) {
- EventManager.get().post(new ViewTransactionEvent(toggleButton.getScene().getWindow(), result.transaction));
+ 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.");
+ }
} else if(result.psbt != null) {
- EventManager.get().post(new ViewPSBTEvent(toggleButton.getScene().getWindow(), null, null, result.psbt));
+ 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.");
+ }
} else if(result.seed != null) {
signFromSeed(result.seed);
} else if(result.exception != null) {
@@ -1110,7 +1120,7 @@ public class HeadersController extends TransactionFormController implements Init
ToggleButton toggleButton = (ToggleButton)event.getSource();
toggleButton.setSelected(false);
- EventManager.get().post(new RequestTransactionOpenEvent(toggleButton.getScene().getWindow()));
+ EventManager.get().post(new RequestTransactionOpenEvent(toggleButton.getScene().getWindow(), null, headersForm.getPsbt()));
}
public void signPSBT(ActionEvent event) {
@@ -1882,4 +1892,4 @@ public class HeadersController extends TransactionFormController implements Init
return wallet.getKeystores().stream().map(keystore -> sourceOrder.indexOf(keystore.getSource())).mapToInt(v -> v).max().orElse(0);
}
}
-}
\ No newline at end of file
+}
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.