handle errors if silent payments psbt validation fails
What changed, and why it matters
This commit adds error handling for a new type of PSBT (Partially Signed Bitcoin Transaction) validation failure related to silent payments. Previously, if silent payment proof validation failed during transaction extraction, broadcasting, saving, sweeping private keys, or payjoin, the application would likely crash or propagate an unhandled exception. Now it shows an error dialog instead. The commit also makes some related payjoin logic more robust, such as computing the additional fee contribution before serialization and fixing a change-output value comparison bug.
Review the drongo-side implementation of PSBTProofException to understand what silent payment proof failures it covers, and ensure all other `extractTransaction()` call sites in the codebase are similarly protected. Consider whether unhandled `PSBTProofException` elsewhere could lead to denial-of-service or inconsistent wallet state.
Security signals we found
New exception type PSBTProofException handled at PSBT.extractTransaction() call sites
Prevents unhandled runtime exceptions during silent payments transaction extraction
Payjoin additional fee contribution computed before PSBT serialization
Payjoin change output value comparison corrected to use originalOutput.getKey() instead of changeOutput variable
Error dialogs now inform users of invalid silent payments transactions
Evidence from the diff
The patch introduces handling for PSBTProofException (a new silent payments PSBT validation exception from the drongo library) at multiple call sites of PSBT.extractTransaction(): PrivateKeySweepDialog, HeadersController (extract/broadcast/save), and Payjoin. It also refactors Payjoin to store psbt.getForExport() internally, pre-computes additionalFeeContribution, and fixes a bug where changeOutput could have been compared against a stale reference. Several minor cleanups (e.g., isEmpty() checks, getFirst(), exception messages) are included. The security relevance is defensive: preventing unhandled exceptions from causing crashes or inconsistent UI state when invalid silent payment proofs are encountered.
Changed components
PrivateKeySweepDialog.javaPayjoin.javaHeadersController.javadrongo (PSBTProofException class, external to this diff)Inspect captured patch +51 / −31
diff --git a/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java
index fb4392d..ff713f3 100644
--- a/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java
+++ b/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java
@@ -13,6 +13,7 @@ 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.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppServices;
@@ -28,8 +29,6 @@ import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.event.ActionEvent;
import javafx.scene.control.*;
-import javafx.scene.image.Image;
-import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
@@ -461,7 +460,11 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
psbtInput.setFinalScriptWitness(finalizedTxInput.getWitness());
}
- setResult(psbt.extractTransaction());
+ try {
+ setResult(psbt.extractTransaction());
+ } catch(PSBTProofException e) {
+ AppServices.showErrorDialog("Invalid Silent Payments Transaction", e.getMessage());
+ }
}
public Glyph getGlyph(FontAwesome5.Glyph glyphEnum) {
diff --git a/src/main/java/com/sparrowwallet/sparrow/payjoin/Payjoin.java b/src/main/java/com/sparrowwallet/sparrow/payjoin/Payjoin.java
index 0c2e9bf..a1e752e 100644
--- a/src/main/java/com/sparrowwallet/sparrow/payjoin/Payjoin.java
+++ b/src/main/java/com/sparrowwallet/sparrow/payjoin/Payjoin.java
@@ -6,10 +6,7 @@ import com.sparrowwallet.drongo.protocol.Script;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionInput;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
-import com.sparrowwallet.drongo.psbt.PSBT;
-import com.sparrowwallet.drongo.psbt.PSBTInput;
-import com.sparrowwallet.drongo.psbt.PSBTOutput;
-import com.sparrowwallet.drongo.psbt.PSBTParseException;
+import com.sparrowwallet.drongo.psbt.*;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
@@ -38,7 +35,7 @@ public class Payjoin {
public Payjoin(BitcoinURI payjoinURI, Wallet wallet, PSBT psbt) {
this.payjoinURI = payjoinURI;
this.wallet = wallet;
- this.psbt = psbt;
+ this.psbt = psbt.getForExport();
if(payjoinURI.getAddress() == null) {
throw new IllegalArgumentException("Payjoin URI must have an address");
@@ -55,7 +52,7 @@ public class Payjoin {
}
}
- public PSBT requestPayjoinPSBT(boolean allowOutputSubstitution) throws PayjoinReceiverException {
+ public PSBT requestPayjoinPSBT(boolean allowOutputSubstitution) throws PayjoinReceiverException, PSBTProofException {
if(!payjoinURI.isPayjoinOutputSubstitutionAllowed()) {
allowOutputSubstitution = false;
}
@@ -66,15 +63,17 @@ public class Payjoin {
throw new PayjoinReceiverException("No payjoin URL provided");
}
+ long additionalFeeContribution = getAdditionalFeeContribution();
+
try {
- String base64Psbt = psbt.getForExport().getPublicCopy().toBase64String();
+ String base64Psbt = psbt.getPublicCopy().toBase64String();
String appendQuery = "v=1&minfeerate=" + AppServices.getMinimumRelayFeeRate();
int changeOutputIndex = getChangeOutputIndex();
long maxAdditionalFeeContribution = 0;
if(changeOutputIndex > -1) {
appendQuery += "&additionalfeeoutputindex=" + changeOutputIndex;
- maxAdditionalFeeContribution = getAdditionalFeeContribution();
+ maxAdditionalFeeContribution = additionalFeeContribution;
appendQuery += "&maxadditionalfeecontribution=" + maxAdditionalFeeContribution;
}
@@ -117,15 +116,16 @@ public class Payjoin {
}
}
- private void checkProposal(PSBT original, PSBT proposal, int changeOutputIndex, long maxAdditionalFeeContribution, boolean allowOutputSubstitution) throws PayjoinReceiverException {
+ private void checkProposal(PSBT original, PSBT proposal, int changeOutputIndex, long maxAdditionalFeeContribution, boolean allowOutputSubstitution) throws PayjoinReceiverException, PSBTProofException {
+ Transaction originalTx = original.getTransaction();
Queue<Map.Entry<TransactionInput, PSBTInput>> originalInputs = new ArrayDeque<>();
for(int i = 0; i < original.getPsbtInputs().size(); i++) {
- originalInputs.add(Map.entry(original.getTransaction().getInputs().get(i), original.getPsbtInputs().get(i)));
+ originalInputs.add(Map.entry(originalTx.getInputs().get(i), original.getPsbtInputs().get(i)));
}
Queue<Map.Entry<TransactionOutput, PSBTOutput>> originalOutputs = new ArrayDeque<>();
for(int i = 0; i < original.getPsbtOutputs().size(); i++) {
- originalOutputs.add(Map.entry(original.getTransaction().getOutputs().get(i), original.getPsbtOutputs().get(i)));
+ originalOutputs.add(Map.entry(originalTx.getOutputs().get(i), original.getPsbtOutputs().get(i)));
}
// Checking that the PSBT of the receiver is clean
@@ -133,7 +133,6 @@ public class Payjoin {
throw new PayjoinReceiverException("Global xpubs should not be included in the receiver's PSBT");
}
- Transaction originalTx = original.getTransaction();
Transaction proposalTx = proposal.getTransaction();
// Verify that the transaction version, and nLockTime are unchanged.
if(proposalTx.getVersion() != originalTx.getVersion()) {
@@ -154,7 +153,7 @@ public class Payjoin {
}
TransactionInput proposedTxIn = proposedPSBTInput.getInput();
- boolean isOriginalInput = originalInputs.size() > 0 && originalInputs.peek().getKey().getOutpoint().equals(proposedTxIn.getOutpoint());
+ boolean isOriginalInput = !originalInputs.isEmpty() && originalInputs.peek().getKey().getOutpoint().equals(proposedTxIn.getOutpoint());
if(isOriginalInput) {
Map.Entry<TransactionInput, PSBTInput> originalInput = originalInputs.remove();
TransactionInput originalTxIn = originalInput.getKey();
@@ -223,11 +222,11 @@ public class Payjoin {
}
TransactionOutput proposedTxOut = proposalTx.getOutputs().get(i);
- boolean isOriginalOutput = originalOutputs.size() > 0 && originalOutputs.peek().getKey().getScript().equals(proposedTxOut.getScript());
+ boolean isOriginalOutput = !originalOutputs.isEmpty() && originalOutputs.peek().getKey().getScript().equals(proposedTxOut.getScript());
if(isOriginalOutput) {
Map.Entry<TransactionOutput, PSBTOutput> originalOutput = originalOutputs.remove();
if(originalOutput.getKey() == changeOutput) {
- var actualContribution = changeOutput.getValue() - proposedTxOut.getValue();
+ var actualContribution = originalOutput.getKey().getValue() - proposedTxOut.getValue();
// The amount that was subtracted from the output's value is less than or equal to maxadditionalfeecontribution
if(actualContribution > maxAdditionalFeeContribution) {
throw new PayjoinReceiverException("The actual contribution is more than maxadditionalfeecontribution");
@@ -245,7 +244,7 @@ public class Payjoin {
// That's the payment output, the receiver may have changed it.
} else {
if(originalOutput.getKey().getValue() > proposedTxOut.getValue()) {
- throw new PayjoinReceiverException("The receiver decreased the value of one of the outputs");
+ throw new PayjoinReceiverException("The receiver decreased the value of one of the outputs from " + originalOutput.getKey().getValue() + " sats to " + proposedTxOut.getValue() + " sats");
}
}
@@ -282,17 +281,17 @@ public class Payjoin {
return -1;
}
- private long getAdditionalFeeContribution() {
+ private long getAdditionalFeeContribution() throws PSBTProofException {
return getSingleInputFee();
}
- private long getSingleInputFee() {
+ private long getSingleInputFee() throws PSBTProofException {
Transaction transaction = psbt.extractTransaction();
double feeRate = psbt.getFee().doubleValue() / transaction.getVirtualSize();
int vSize = 68;
- if(transaction.getInputs().size() > 0) {
- TransactionInput input = transaction.getInputs().get(0);
+ if(!transaction.getInputs().isEmpty()) {
+ TransactionInput input = transaction.getInputs().getFirst();
vSize = input.getLength() * Transaction.WITNESS_SCALE_FACTOR;
vSize += input.getWitness() != null ? input.getWitness().getLength() : 0;
vSize = (int)Math.ceil((double)vSize / Transaction.WITNESS_SCALE_FACTOR);
@@ -338,7 +337,7 @@ public class Payjoin {
@Override
protected Task<PSBT> createTask() {
return new Task<>() {
- protected PSBT call() throws PayjoinReceiverException {
+ protected PSBT call() throws PayjoinReceiverException, PSBTProofException {
return payjoin.requestPayjoinPSBT(allowOutputSubstitution);
}
};
diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
index e41d12a..618655d 100644
--- a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
@@ -8,6 +8,7 @@ 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.silentpayments.SilentPayment;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.uri.BitcoinURI;
@@ -1187,17 +1188,31 @@ public class HeadersController extends TransactionFormController implements Init
}
public void extractTransaction(ActionEvent event) {
+ extractTransaction();
+ }
+
+ public boolean extractTransaction() {
viewFinalButton.setDisable(true);
- Transaction finalTx = headersForm.getPsbt().extractTransaction();
- headersForm.setFinalTransaction(finalTx);
- EventManager.get().post(new TransactionExtractedEvent(headersForm.getPsbt(), finalTx));
+ try {
+ Transaction finalTx = headersForm.getPsbt().extractTransaction();
+ headersForm.setFinalTransaction(finalTx);
+ EventManager.get().post(new TransactionExtractedEvent(headersForm.getPsbt(), finalTx));
+ return true;
+ } catch(PSBTProofException e) {
+ AppServices.showErrorDialog("Invalid Silent Payments Transaction", e.getMessage());
+ viewFinalButton.setDisable(false);
+ return false;
+ }
}
public void broadcastTransaction(ActionEvent event) {
broadcastButton.setDisable(true);
if(headersForm.getPsbt() != null) {
- extractTransaction(event);
+ if(!extractTransaction()) {
+ broadcastButton.setDisable(false);
+ return;
+ }
}
if(fee.getValue() > 0) {
@@ -1363,10 +1378,12 @@ public class HeadersController extends TransactionFormController implements Init
File file = fileChooser.showSaveDialog(window);
if(file != null) {
try {
+ Transaction finalTx = headersForm.getPsbt().extractTransaction();
try(PrintWriter writer = new PrintWriter(file, StandardCharsets.UTF_8)) {
- Transaction finalTx = headersForm.getPsbt().extractTransaction();
writer.print(Utils.bytesToHex(finalTx.bitcoinSerialize()));
}
+ } catch(PSBTProofException e) {
+ AppServices.showErrorDialog("Invalid Silent Payments Transaction", e.getMessage());
} catch(IOException e) {
log.error("Error saving transaction", e);
AppServices.showErrorDialog("Error saving transaction", "Cannot write to " + file.getAbsolutePath());
@@ -1387,7 +1404,8 @@ public class HeadersController extends TransactionFormController implements Init
EventManager.get().post(new ViewPSBTEvent(payjoinButton.getScene().getWindow(), headersForm.getName() + " Payjoin", null, proposalPsbt));
});
requestPayjoinPSBTService.setOnFailed(failedEvent -> {
- AppServices.showErrorDialog("Error Requesting Payjoin Transaction", failedEvent.getSource().getException().getMessage());
+ Throwable exception = failedEvent.getSource().getException();
+ AppServices.showErrorDialog(exception instanceof PSBTProofException ? "Invalid Silent Payments Transaction" : "Error Requesting Payjoin Transaction", exception.getMessage());
});
requestPayjoinPSBTService.start();
}
Why this scored 43/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.