improve psbt/tx matching and ensure incoming psbt signatures are always verified
What changed, and why it matters
This commit changes how Sparrow Wallet matches incoming PSBTs (Partially Signed Bitcoin Transactions) against already-open transaction tabs, and adds verification of signatures before combining them. Previously, the app matched transactions by exact byte-for-byte equality and then merged PSBTs without checking that signatures in the incoming PSBT were valid. Now it matches by transaction ID and witness data, warns when two transactions share the same ID but have different witnesses, verifies signatures before merging, and warns users about silent-payments transactions whose recipient addresses cannot be verified. The change reduces the risk that a malicious or malformed PSBT could silently overwrite or merge with an existing transaction.
Review the updated drongo and lark submodule commits to confirm the implementation of PSBT.matches(), verifyCombinedSignatures(), possibleUnverifiableSilentPaymentsTransaction(), and copyFinalizedFields(). Test that invalid signatures in an incoming PSBT are rejected, that same-txid/different-witness transactions trigger the warning, and that silent-payments unverifiable transactions require explicit user confirmation. Consider whether the warning dialogs are sufficient to prevent social-engineering attacks where users are tricked into clicking through.
Security signals we found
PSBT signature verification added before combine() in AppController
PSBT signature verification added before combine() in HeadersController
Transaction matching changed from byte-level equality to txid/wtxid/PSBT.matches() comparison
Warning dialog added for same-txid/different-witness transactions
Warning dialog added for unverifiable silent-payments outputs
Submodule updates to drongo and lark likely contain PSBT verification and silent-payments helpers
Evidence from the diff
The patch refactors AppController.addTransactionTab to extract transaction/PSBT matching and merging into helper methods. Matching now uses PSBT.matches() and txid/wtxid equality rather than full bitcoinSerialize() equality. A new isExistingTransaction() path warns when two transactions share a txid but differ in witnesses (same-txid/different-witness malleability). handleTransactionMerge() now calls currentPsbt.verifyCombinedSignatures(psbt) before currentPsbt.combine(psbt), catching PSBTSignatureException and showing an error dialog. HeadersController’s signed-PSBT import path also adds verifyCombinedSignatures() before combine(). A new possibleUnverifiableSilentPaymentsTransaction() check prompts the user before opening a transaction that may contain an unverifiable silent-payments output. The drongo/lark submodules are updated, indicating the underlying PSBT verification and silent-payments logic lives there. The commit title explicitly frames the change as improving PSBT/transaction matching and ensuring incoming PSBT signatures are always verified.
Changed components
AppController.javaHeadersController.javaPaymentController.javadrongo submodulelark submoduleInspect captured patch +80 / −40
diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java
index edc302c..27d1486 100644
--- a/src/main/java/com/sparrowwallet/sparrow/AppController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java
@@ -1986,39 +1986,13 @@ public class AppController implements Initializable {
private void addTransactionTab(String name, File file, Transaction transaction, PSBT psbt, BlockTransaction blockTransaction, TransactionView initialView, Integer initialIndex) {
for(Tab tab : tabs.getTabs()) {
TabData tabData = (TabData)tab.getUserData();
- if(tabData instanceof TransactionTabData) {
- TransactionTabData transactionTabData = (TransactionTabData)tabData;
-
- //If an exact match bytewise of an existing tab, return that tab
- if(Arrays.equals(transactionTabData.getTransaction().bitcoinSerialize(), transaction.bitcoinSerialize())) {
- if(transactionTabData.getPsbt() != null && psbt != null && !transactionTabData.getPsbt().isFinalized()) {
- if(!psbt.isFinalized()) {
- //As per BIP174, combine PSBTs with matching transactions so long as they are not yet finalized
- transactionTabData.getPsbt().combine(psbt);
- if(name != null && !name.isEmpty()) {
- ((Label)tab.getGraphic()).setText(name);
- }
-
- EventManager.get().post(new PSBTCombinedEvent(transactionTabData.getPsbt()));
- } else {
- //If the new PSBT is finalized, copy the finalized fields to the existing unfinalized PSBT
- for(int i = 0; i < transactionTabData.getPsbt().getPsbtInputs().size(); i++) {
- PSBTInput existingInput = transactionTabData.getPsbt().getPsbtInputs().get(i);
- PSBTInput finalizedInput = psbt.getPsbtInputs().get(i);
- existingInput.setFinalScriptSig(finalizedInput.getFinalScriptSig());
- existingInput.setFinalScriptWitness(finalizedInput.getFinalScriptWitness());
- existingInput.clearNonFinalFields();
- }
-
- if(name != null && !name.isEmpty()) {
- ((Label)tab.getGraphic()).setText(name);
- }
-
- EventManager.get().post(new PSBTFinalizedEvent(transactionTabData.getPsbt()));
- }
- }
+ if(tabData instanceof TransactionTabData transactionTabData) {
+ if(isExistingTransaction(transactionTabData, transaction, psbt, getTabName(tab))) {
+ handleTransactionMerge(transactionTabData, psbt, name, tab);
+ return;
+ }
- tabs.getSelectionModel().select(tab);
+ if(transactionTabData.getPsbt() != null && transactionTabData.getPsbt().possibleUnverifiableSilentPaymentsTransaction(transaction) && !openUnverifiableTransaction(getTabName(tab))) {
return;
}
}
@@ -2085,6 +2059,69 @@ public class AppController implements Initializable {
}
}
+ private boolean isExistingTransaction(TransactionTabData transactionTabData, Transaction transaction, PSBT psbt, String tabName) {
+ PSBT currentPsbt = transactionTabData.getPsbt();
+ Transaction currentTransaction = transactionTabData.getTransaction();
+
+ if(currentPsbt != null && psbt != null && currentPsbt.matches(psbt)) {
+ return true;
+ } else if(currentTransaction.getTxId().equals(transaction.getTxId())) {
+ if(currentTransaction.getWTxId().equals(transaction.getWTxId())) {
+ return true;
+ } else if(currentPsbt == null) {
+ AppServices.showWarningDialog("Suspicious Transaction",
+ "This transaction has the same txid as the transaction in tab " + tabName + ", but contains different witnesses. It will be opened in a separate tab.");
+ }
+ }
+
+ return false;
+ }
+
+ private void handleTransactionMerge(TransactionTabData transactionTabData, PSBT psbt, String name, Tab tab) {
+ PSBT currentPsbt = transactionTabData.getPsbt();
+
+ if(currentPsbt != null && psbt != null && !currentPsbt.isFinalized()) {
+ if(!psbt.isFinalized()) {
+ //As per BIP174, combine PSBTs with matching transactions so long as they are not yet finalized
+ try {
+ currentPsbt.verifyCombinedSignatures(psbt);
+ currentPsbt.combine(psbt);
+ setTabName(tab, name);
+ EventManager.get().post(new PSBTCombinedEvent(currentPsbt));
+ } catch(PSBTSignatureException e) {
+ AppServices.showErrorDialog("Invalid PSBT", e.getMessage());
+ }
+ } else {
+ //If the new PSBT is finalized, copy the finalized fields to the existing unfinalized PSBT
+ currentPsbt.copyFinalizedFields(psbt);
+ setTabName(tab, name);
+ EventManager.get().post(new PSBTFinalizedEvent(currentPsbt));
+ }
+ }
+
+ tabs.getSelectionModel().select(tab);
+ }
+
+ private boolean openUnverifiableTransaction(String tabName) {
+ Optional<ButtonType> result = AppServices.showWarningDialog(
+ "Unverifiable Silent Payments Transaction",
+ "This transaction contains an unverifiable silent payments output.\n\n" +
+ "The tab " + tabName + " contains a similar transaction spending to a silent payments address, " +
+ "but this transaction does not contain enough information to determine if the recipient address is correct.\n\n" +
+ "Open the transaction in another tab?", ButtonType.YES, ButtonType.NO);
+ return result.isPresent() && result.get() == ButtonType.YES;
+ }
+
+ private String getTabName(Tab tab) {
+ return ((Label)tab.getGraphic()).getText();
+ }
+
+ private void setTabName(Tab tab, String name){
+ if(name != null && !name.isEmpty()) {
+ ((Label)tab.getGraphic()).setText(name);
+ }
+ }
+
private ContextMenu getTabContextMenu(Tab tab) {
ContextMenu contextMenu = new ContextMenu();
diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
index 618655d..678bf29 100644
--- a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
@@ -6,9 +6,7 @@ import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.*;
-import com.sparrowwallet.drongo.psbt.PSBT;
-import com.sparrowwallet.drongo.psbt.PSBTInput;
-import com.sparrowwallet.drongo.psbt.PSBTProofException;
+import com.sparrowwallet.drongo.psbt.*;
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.uri.BitcoinURI;
@@ -1161,8 +1159,13 @@ public class HeadersController extends TransactionFormController implements Init
Optional<PSBT> optionalSignedPsbt = dlg.showAndWait();
if(optionalSignedPsbt.isPresent()) {
PSBT signedPsbt = optionalSignedPsbt.get();
- headersForm.getPsbt().combine(signedPsbt);
- EventManager.get().post(new PSBTCombinedEvent(headersForm.getPsbt()));
+ try {
+ headersForm.getPsbt().verifyCombinedSignatures(signedPsbt);
+ headersForm.getPsbt().combine(signedPsbt);
+ EventManager.get().post(new PSBTCombinedEvent(headersForm.getPsbt()));
+ } catch(PSBTSignatureException e) {
+ AppServices.showErrorDialog("Invalid PSBT", e.getMessage());
+ }
}
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java
index cff12eb..8494bdc 100644
--- a/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java
@@ -467,7 +467,7 @@ public class PaymentController extends WalletFormController implements Initializ
private void setSilentPaymentAddress(SilentPaymentAddress silentPaymentAddress) {
if(!sendController.getWalletForm().getWallet().canSendSilentPayments()) {
- Platform.runLater(() -> AppServices.showErrorDialog("Silent Payments Unsupported", "This wallet does not support sending silent payments. Use a single signature software wallet."));
+ Platform.runLater(() -> AppServices.showErrorDialog("Silent Payments Unsupported", "This wallet does not support sending silent payments. Use a single signature wallet."));
return;
}
Why this scored 58/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.