From e1acaa8a78fc27a45f7a4825fa2dc233be2495e4 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Tue, 3 Nov 2020 13:54:18 +0200 Subject: [PATCH] add payjoin (bip78) support --- drongo | 2 +- .../sparrowwallet/sparrow/AppController.java | 15 + .../sparrow/glyphfont/FontAwesome5.java | 1 + .../sparrow/net/TcpTransport.java | 2 +- .../sparrow/payjoin/Payjoin.java | 304 ++++++++++++++++++ .../payjoin/PayjoinReceiverException.java | 19 ++ .../transaction/HeadersController.java | 56 +++- .../sparrow/transaction/TransactionForm.java | 4 + .../sparrow/wallet/PaymentController.java | 3 + src/main/java/module-info.java | 1 + .../sparrow/transaction/headers.fxml | 5 + 11 files changed, 404 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/sparrow/payjoin/Payjoin.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/payjoin/PayjoinReceiverException.java diff --git a/drongo b/drongo index 67c76c3b..9c983614 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit 67c76c3b28158e38dbf6a5a3eb49f4a03b01b7b1 +Subproject commit 9c9836147ab77b28fed9b6bdc8eb1e14fd1e1217 diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index fe97d129..11a2c1b7 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -7,6 +7,7 @@ import com.sparrowwallet.drongo.BitcoinUnit; import com.sparrowwallet.drongo.Network; import com.sparrowwallet.drongo.SecureString; import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.crypto.ECKey; import com.sparrowwallet.drongo.crypto.EncryptionType; import com.sparrowwallet.drongo.crypto.InvalidPasswordException; @@ -17,6 +18,7 @@ import com.sparrowwallet.drongo.protocol.Sha256Hash; import com.sparrowwallet.drongo.protocol.Transaction; import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.psbt.PSBTParseException; +import com.sparrowwallet.drongo.uri.BitcoinURI; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.control.*; import com.sparrowwallet.sparrow.event.*; @@ -146,6 +148,8 @@ public class AppController implements Initializable { private static List devices; + private static Map payjoinURIs = new HashMap<>(); + @Override public void initialize(URL location, ResourceBundle resources) { EventManager.get().register(this); @@ -657,6 +661,17 @@ public class AppController implements Initializable { return devices == null ? new ArrayList<>() : devices; } + public static BitcoinURI getPayjoinURI(Address address) { + return payjoinURIs.get(address); + } + + public static void addPayjoinURI(BitcoinURI bitcoinURI) { + if(bitcoinURI.getPayjoinUrl() == null) { + throw new IllegalArgumentException("Not a payjoin URI"); + } + payjoinURIs.put(bitcoinURI.getAddress(), bitcoinURI); + } + public Map getOpenWallets() { Map openWallets = new LinkedHashMap<>(); diff --git a/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java b/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java index f8682b68..94b81c02 100644 --- a/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java +++ b/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java @@ -38,6 +38,7 @@ public class FontAwesome5 extends GlyphFont { PLUS('\uf067'), QRCODE('\uf029'), QUESTION_CIRCLE('\uf059'), + RANDOM('\uf074'), REPLY_ALL('\uf122'), SATELLITE_DISH('\uf7c0'), SD_CARD('\uf7c2'), diff --git a/src/main/java/com/sparrowwallet/sparrow/net/TcpTransport.java b/src/main/java/com/sparrowwallet/sparrow/net/TcpTransport.java index ae9b38bd..e1401297 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/TcpTransport.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/TcpTransport.java @@ -133,7 +133,7 @@ public class TcpTransport implements Transport, Closeable { while(running) { try { String received = readInputStream(); - if(received.contains("method")) { + if(received.contains("method") && !received.contains("error")) { //Handle subscription notification jsonRpcServer.handle(received, subscriptionService); } else { diff --git a/src/main/java/com/sparrowwallet/sparrow/payjoin/Payjoin.java b/src/main/java/com/sparrowwallet/sparrow/payjoin/Payjoin.java new file mode 100644 index 00000000..8ca318d6 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/payjoin/Payjoin.java @@ -0,0 +1,304 @@ +package com.sparrowwallet.sparrow.payjoin; + +import com.google.common.collect.ImmutableMap; +import com.google.gson.Gson; +import com.sparrowwallet.drongo.KeyPurpose; +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.uri.BitcoinURI; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.drongo.wallet.WalletNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.*; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.*; + +public class Payjoin { + private static final Logger log = LoggerFactory.getLogger(Payjoin.class); + + private final BitcoinURI payjoinURI; + private final Wallet wallet; + private final PSBT psbt; + + public Payjoin(BitcoinURI payjoinURI, Wallet wallet, PSBT psbt) { + this.payjoinURI = payjoinURI; + this.wallet = wallet; + this.psbt = psbt.getPublicCopy(); + } + + public PSBT requestPayjoinPSBT(boolean allowOutputSubstitution) throws PayjoinReceiverException { + if(!payjoinURI.isPayjoinOutputSubstitutionAllowed()) { + allowOutputSubstitution = false; + } + + URI uri = payjoinURI.getPayjoinUrl(); + if(uri == null) { + log.error("No payjoin URL provided"); + throw new PayjoinReceiverException("No payjoin URL provided"); + } + + try { + String base64Psbt = psbt.toBase64String(); + + String appendQuery = "v=1"; + int changeOutputIndex = getChangeOutputIndex(); + long maxAdditionalFeeContribution = 0; + if(changeOutputIndex > -1) { + appendQuery += "&additionalfeeoutputindex=" + changeOutputIndex; + maxAdditionalFeeContribution = getAdditionalFeeContribution(psbt.getTransaction()); + appendQuery += "&maxadditionalfeecontribution=" + maxAdditionalFeeContribution; + } + + URI finalUri = new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), uri.getQuery() == null ? appendQuery : uri.getQuery() + "&" + appendQuery, uri.getFragment()); + log.info("Sending PSBT to " + finalUri.toURL()); + + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = HttpRequest.newBuilder() + .uri(finalUri) + .header("Content-Type", "text/plain") + .POST(HttpRequest.BodyPublishers.ofString(base64Psbt)) + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if(response.statusCode() != 200) { + Gson gson = new Gson(); + PayjoinReceiverError payjoinReceiverError = gson.fromJson(response.body(), PayjoinReceiverError.class); + log.warn("Payjoin receiver returned an error of " + payjoinReceiverError.getErrorCode() + " (" + payjoinReceiverError.getMessage() + ")"); + throw new PayjoinReceiverException(payjoinReceiverError.getSafeMessage()); + } + + PSBT proposalPsbt = PSBT.fromString(response.body()); + checkProposal(psbt, proposalPsbt, changeOutputIndex, maxAdditionalFeeContribution, allowOutputSubstitution); + + return proposalPsbt; + } catch(URISyntaxException e) { + log.error("Invalid payjoin receiver URI", e); + throw new PayjoinReceiverException("Invalid payjoin receiver URI", e); + } catch(IOException | InterruptedException e) { + log.error("Payjoin receiver error", e); + throw new PayjoinReceiverException("Payjoin receiver error", e); + } catch(PSBTParseException e) { + log.error("Error parsing received PSBT", e); + throw new PayjoinReceiverException("Payjoin receiver returned invalid PSBT", e); + } + } + + private void checkProposal(PSBT original, PSBT proposal, int changeOutputIndex, long maxAdditionalFeeContribution, boolean allowOutputSubstitution) throws PayjoinReceiverException { + Queue> 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))); + } + + Queue> 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))); + } + + // Checking that the PSBT of the receiver is clean + if(!proposal.getExtendedPublicKeys().isEmpty()) { + 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()) { + throw new PayjoinReceiverException("The proposal PSBT changed the transaction version"); + } + if(proposalTx.getLocktime() != originalTx.getLocktime()) { + throw new PayjoinReceiverException("The proposal PSBT changed the nLocktime"); + } + + Set sequences = new HashSet<>(); + // For each inputs in the proposal: + for(PSBTInput proposedPSBTInput : proposal.getPsbtInputs()) { + if(!proposedPSBTInput.getDerivedPublicKeys().isEmpty()) { + throw new PayjoinReceiverException("The receiver added keypaths to an input"); + } + if(!proposedPSBTInput.getPartialSignatures().isEmpty()) { + throw new PayjoinReceiverException("The receiver added partial signatures to an input"); + } + + TransactionInput proposedTxIn = proposedPSBTInput.getInput(); + boolean isOriginalInput = originalInputs.size() > 0 && originalInputs.peek().getKey().getOutpoint().equals(proposedTxIn.getOutpoint()); + if(isOriginalInput) { + Map.Entry originalInput = originalInputs.remove(); + TransactionInput originalTxIn = originalInput.getKey(); + + // Verify that sequence is unchanged. + if(originalTxIn.getSequenceNumber() != proposedTxIn.getSequenceNumber()) { + throw new PayjoinReceiverException("The proposed transaction input modified the sequence of one of the original inputs"); + } + // Verify the PSBT input is not finalized + if(proposedPSBTInput.isFinalized()) { + throw new PayjoinReceiverException("The receiver finalized one of the original inputs"); + } + // Verify that non_witness_utxo and witness_utxo are not specified. + if(proposedPSBTInput.getNonWitnessUtxo() != null || proposedPSBTInput.getWitnessUtxo() != null) { + throw new PayjoinReceiverException("The receiver added non_witness_utxo or witness_utxo to one of the original inputs"); + } + sequences.add(proposedTxIn.getSequenceNumber()); + + PSBTInput originalPSBTInput = originalInput.getValue(); + // Fill up the info from the original PSBT input so we can sign and get fees. + proposedPSBTInput.setNonWitnessUtxo(originalPSBTInput.getNonWitnessUtxo()); + proposedPSBTInput.setWitnessUtxo(originalPSBTInput.getWitnessUtxo()); + // We fill up information we had on the signed PSBT, so we can sign it. + proposedPSBTInput.getDerivedPublicKeys().putAll(originalPSBTInput.getDerivedPublicKeys()); + proposedPSBTInput.setRedeemScript(originalPSBTInput.getRedeemScript()); + proposedPSBTInput.setWitnessScript(originalPSBTInput.getWitnessScript()); + proposedPSBTInput.setSigHash(originalPSBTInput.getSigHash()); + } else { + // Verify the PSBT input is finalized + if(!proposedPSBTInput.isFinalized()) { + throw new PayjoinReceiverException("The receiver did not finalize one of their inputs"); + } + // Verify that non_witness_utxo or witness_utxo are filled in. + if(proposedPSBTInput.getNonWitnessUtxo() == null && proposedPSBTInput.getWitnessUtxo() == null) { + throw new PayjoinReceiverException("The receiver did not specify non_witness_utxo or witness_utxo for one of their inputs"); + } + sequences.add(proposedTxIn.getSequenceNumber()); + // Verify that the payjoin proposal did not introduced mixed inputs' type. + if(wallet.getScriptType() != proposedPSBTInput.getScriptType()) { + throw new PayjoinReceiverException("Proposal script type of " + proposedPSBTInput.getScriptType() + " did not match wallet script type of " + wallet.getScriptType()); + } + } + } + + // Verify that all of sender's inputs from the original PSBT are in the proposal. + if(!originalInputs.isEmpty()) { + throw new PayjoinReceiverException("Some of the original inputs are not included in the proposal"); + } + + // Verify that the payjoin proposal did not introduced mixed inputs' sequence. + if(sequences.size() != 1) { + throw new PayjoinReceiverException("Mixed sequences detected in the proposal"); + } + + Long newFee = proposal.getFee(); + long additionalFee = newFee - original.getFee(); + if(additionalFee < 0) { + throw new PayjoinReceiverException("The receiver decreased absolute fee"); + } + + TransactionOutput changeOutput = (changeOutputIndex > -1 ? originalTx.getOutputs().get(changeOutputIndex) : null); + + // For each outputs in the proposal: + for(int i = 0; i < proposal.getPsbtOutputs().size(); i++) { + PSBTOutput proposedPSBTOutput = proposal.getPsbtOutputs().get(i); + // Verify that no keypaths is in the PSBT output + if(!proposedPSBTOutput.getDerivedPublicKeys().isEmpty()) { + throw new PayjoinReceiverException("The receiver added keypaths to an output"); + } + + TransactionOutput proposedTxOut = proposalTx.getOutputs().get(i); + boolean isOriginalOutput = originalOutputs.size() > 0 && originalOutputs.peek().getKey().getScript().equals(proposedTxOut.getScript()); + if(isOriginalOutput) { + Map.Entry originalOutput = originalOutputs.remove(); + if(originalOutput.getKey() == changeOutput) { + var actualContribution = changeOutput.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"); + } + // Make sure the actual contribution is only paying fee + if(actualContribution > additionalFee) { + throw new PayjoinReceiverException("The actual contribution is not only paying fee"); + } + // Make sure the actual contribution is only paying for fee incurred by additional inputs + int additionalInputsCount = proposalTx.getInputs().size() - originalTx.getInputs().size(); + if(actualContribution > getSingleInputFee(originalTx) * additionalInputsCount) { + throw new PayjoinReceiverException("The actual contribution is not only paying for additional inputs"); + } + } else if(allowOutputSubstitution && originalOutput.getKey().getScript().equals(payjoinURI.getAddress().getOutputScript())) { + // 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"); + } + } + + PSBTOutput originalPSBTOutput = originalOutput.getValue(); + // We fill up information we had on the signed PSBT, so we can sign it. + proposedPSBTOutput.getDerivedPublicKeys().putAll(originalPSBTOutput.getDerivedPublicKeys()); + proposedPSBTOutput.setRedeemScript(originalPSBTOutput.getRedeemScript()); + proposedPSBTOutput.setWitnessScript(originalPSBTOutput.getWitnessScript()); + } + } + + // Verify that all of sender's outputs from the original PSBT are in the proposal. + if(!originalOutputs.isEmpty()) { + // The payment output may have been substituted + if(!allowOutputSubstitution || originalOutputs.size() != 1 || !originalOutputs.remove().getKey().getScript().equals(payjoinURI.getAddress().getOutputScript())) { + throw new PayjoinReceiverException("Some of our outputs are not included in the proposal"); + } + } + } + + private int getChangeOutputIndex() { + Map changeScriptNodes = wallet.getWalletOutputScripts(KeyPurpose.CHANGE); + for(int i = 0; i < psbt.getTransaction().getOutputs().size(); i++) { + if(changeScriptNodes.containsKey(psbt.getTransaction().getOutputs().get(i).getScript())) { + return i; + } + } + + return -1; + } + + private long getAdditionalFeeContribution(Transaction transaction) { + return getSingleInputFee(transaction); + } + + private long getSingleInputFee(Transaction transaction) { + double feeRate = psbt.getFee().doubleValue() / transaction.getVirtualSize(); + int vSize = 68; + + if(transaction.getInputs().size() > 0) { + TransactionInput input = transaction.getInputs().get(0); + 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); + } + + return (long) (vSize * feeRate); + } + + private static class PayjoinReceiverError { + Map knownErrors = ImmutableMap.of( + "unavailable", "The payjoin endpoint is not available for now.", + "not-enough-money", "The receiver added some inputs but could not bump the fee of the payjoin proposal.", + "version-unsupported", "This version of payjoin is not supported.", + "original-psbt-rejected", "The receiver rejected the original PSBT." + ); + + public String errorCode; + public String message; + + public String getErrorCode() { + return errorCode; + } + + public String getMessage() { + return message; + } + + public String getSafeMessage() { + String message = knownErrors.get(errorCode); + return (message == null ? "Unknown Error" : message); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/payjoin/PayjoinReceiverException.java b/src/main/java/com/sparrowwallet/sparrow/payjoin/PayjoinReceiverException.java new file mode 100644 index 00000000..4c14c216 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/payjoin/PayjoinReceiverException.java @@ -0,0 +1,19 @@ +package com.sparrowwallet.sparrow.payjoin; + +public class PayjoinReceiverException extends Exception { + public PayjoinReceiverException() { + super(); + } + + public PayjoinReceiverException(String msg) { + super(msg); + } + + public PayjoinReceiverException(Throwable cause) { + super(cause); + } + + public PayjoinReceiverException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java index fb37c9dd..13b4bd29 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java @@ -3,9 +3,11 @@ package com.sparrowwallet.sparrow.transaction; import com.sparrowwallet.drongo.KeyPurpose; import com.sparrowwallet.drongo.SecureString; import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.protocol.*; import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.psbt.PSBTInput; +import com.sparrowwallet.drongo.uri.BitcoinURI; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.hummingbird.UR; import com.sparrowwallet.sparrow.AppController; @@ -16,6 +18,8 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands; import com.sparrowwallet.sparrow.io.Device; import com.sparrowwallet.sparrow.net.ElectrumServer; import com.sparrowwallet.sparrow.io.Storage; +import com.sparrowwallet.sparrow.payjoin.Payjoin; +import com.sparrowwallet.sparrow.payjoin.PayjoinReceiverException; import com.sparrowwallet.sparrow.wallet.HashIndexEntry; import com.sparrowwallet.sparrow.wallet.TransactionEntry; import javafx.application.Platform; @@ -40,10 +44,8 @@ import tornadofx.control.Fieldset; import com.google.common.eventbus.Subscribe; import tornadofx.control.Form; -import java.io.File; -import java.io.IOException; -import java.io.PrintWriter; -import java.net.URL; +import java.io.*; +import java.net.*; import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.time.*; @@ -196,6 +198,9 @@ public class HeadersController extends TransactionFormController implements Init @FXML private Button saveFinalButton; + @FXML + private Button payjoinButton; + @Override public void initialize(URL location, ResourceBundle resources) { EventManager.get().register(this); @@ -356,6 +361,12 @@ public class HeadersController extends TransactionFormController implements Init saveFinalButton.visibleProperty().bind(broadcastButton.visibleProperty().not()); broadcastButton.visibleProperty().bind(AppController.onlineProperty()); + BitcoinURI payjoinURI = getPayjoinURI(); + boolean isPayjoinOriginalTx = payjoinURI != null && headersForm.getPsbt() != null && headersForm.getPsbt().getPsbtInputs().stream().noneMatch(PSBTInput::isFinalized); + payjoinButton.managedProperty().bind(payjoinButton.visibleProperty()); + payjoinButton.visibleProperty().set(isPayjoinOriginalTx); + broadcastButton.setDefaultButton(!isPayjoinOriginalTx); + blockchainForm.setVisible(false); signingWalletForm.setVisible(false); sigHashForm.setVisible(false); @@ -541,6 +552,24 @@ public class HeadersController extends TransactionFormController implements Init } } + private BitcoinURI getPayjoinURI() { + if(headersForm.getPsbt() != null) { + for(TransactionOutput txOutput : headersForm.getPsbt().getTransaction().getOutputs()) { + try { + Address address = txOutput.getScript().getToAddresses()[0]; + BitcoinURI bitcoinURI = AppController.getPayjoinURI(address); + if(bitcoinURI != null) { + return bitcoinURI; + } + } catch(Exception e) { + //ignore + } + } + } + + return null; + } + private static class BlockHeightContextMenu extends ContextMenu { public BlockHeightContextMenu(Sha256Hash blockHash) { MenuItem copyBlockHash = new MenuItem("Copy Block Hash"); @@ -556,7 +585,7 @@ public class HeadersController extends TransactionFormController implements Init private void updateTxId() { id.setText(headersForm.getTransaction().calculateTxId(false).toString()); - if(headersForm.getPsbt() != null && !(headersForm.getTransaction().hasScriptSigs() || headersForm.getTransaction().hasWitnesses())) { + if(!headersForm.isTransactionFinalized()) { if(!id.getStyleClass().contains(UNFINALIZED_TXID_CLASS)) { id.getStyleClass().add(UNFINALIZED_TXID_CLASS); } @@ -788,6 +817,21 @@ public class HeadersController extends TransactionFormController implements Init } } + public void getPayjoinTransaction(ActionEvent event) { + BitcoinURI payjoinURI = getPayjoinURI(); + if(payjoinURI == null) { + throw new IllegalStateException("No valid Payjoin URI"); + } + + try { + Payjoin payjoin = new Payjoin(payjoinURI, headersForm.getSigningWallet(), headersForm.getPsbt()); + PSBT proposalPsbt = payjoin.requestPayjoinPSBT(true); + EventManager.get().post(new ViewPSBTEvent(headersForm.getName() + " Payjoin", proposalPsbt)); + } catch(PayjoinReceiverException e) { + AppController.showErrorDialog("Invalid Payjoin Transaction", e.getMessage()); + } + } + @Override public void update() { BlockTransaction blockTransaction = headersForm.getBlockTransaction(); @@ -961,7 +1005,7 @@ public class HeadersController extends TransactionFormController implements Init @Subscribe public void walletNodeHistoryChanged(WalletNodeHistoryChangedEvent event) { - if(headersForm.getSigningWallet() != null && event.getWalletNode(headersForm.getSigningWallet()) != null) { + if(headersForm.getSigningWallet() != null && event.getWalletNode(headersForm.getSigningWallet()) != null && headersForm.isTransactionFinalized()) { Sha256Hash txid = headersForm.getTransaction().getTxId(); ElectrumServer.TransactionReferenceService transactionReferenceService = new ElectrumServer.TransactionReferenceService(Set.of(txid), event.getScriptHash()); transactionReferenceService.setOnSucceeded(successEvent -> { diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionForm.java b/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionForm.java index c7c24f78..76426b0e 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionForm.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionForm.java @@ -107,6 +107,10 @@ public abstract class TransactionForm { return true; } + public boolean isTransactionFinalized() { + return getPsbt() == null || getTransaction().hasScriptSigs() || getTransaction().hasWitnesses(); + } + public abstract Node getContents() throws IOException; public abstract TransactionView getView(); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java index 99732f57..0754da6b 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java @@ -315,6 +315,9 @@ public class PaymentController extends WalletFormController implements Initializ if(bitcoinURI.getAmount() != null) { setRecipientValueSats(bitcoinURI.getAmount()); } + if(bitcoinURI.getPayjoinUrl() != null) { + AppController.addPayjoinURI(bitcoinURI); + } sendController.updateTransaction(); } diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 6fc2b8c9..61ab145f 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -1,5 +1,6 @@ open module com.sparrowwallet.sparrow { requires java.desktop; + requires java.net.http; requires javafx.controls; requires javafx.fxml; requires javafx.graphics; diff --git a/src/main/resources/com/sparrowwallet/sparrow/transaction/headers.fxml b/src/main/resources/com/sparrowwallet/sparrow/transaction/headers.fxml index 74395247..7e034f0f 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/transaction/headers.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/transaction/headers.fxml @@ -248,6 +248,11 @@ +