handle errors if silent payments psbt validation fails

This commit is contained in:
Craig Raw
2025-11-19 16:06:55 +02:00
parent 21543de007
commit c16997eacc
4 changed files with 51 additions and 31 deletions
@@ -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) {
@@ -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);
}
};
@@ -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();
}