mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2024-11-04 21:36:45 +00:00
add payjoin (bip78) support
This commit is contained in:
parent
13a486597c
commit
e1acaa8a78
11 changed files with 404 additions and 8 deletions
2
drongo
2
drongo
|
@ -1 +1 @@
|
||||||
Subproject commit 67c76c3b28158e38dbf6a5a3eb49f4a03b01b7b1
|
Subproject commit 9c9836147ab77b28fed9b6bdc8eb1e14fd1e1217
|
|
@ -7,6 +7,7 @@ import com.sparrowwallet.drongo.BitcoinUnit;
|
||||||
import com.sparrowwallet.drongo.Network;
|
import com.sparrowwallet.drongo.Network;
|
||||||
import com.sparrowwallet.drongo.SecureString;
|
import com.sparrowwallet.drongo.SecureString;
|
||||||
import com.sparrowwallet.drongo.Utils;
|
import com.sparrowwallet.drongo.Utils;
|
||||||
|
import com.sparrowwallet.drongo.address.Address;
|
||||||
import com.sparrowwallet.drongo.crypto.ECKey;
|
import com.sparrowwallet.drongo.crypto.ECKey;
|
||||||
import com.sparrowwallet.drongo.crypto.EncryptionType;
|
import com.sparrowwallet.drongo.crypto.EncryptionType;
|
||||||
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
|
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.protocol.Transaction;
|
||||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||||
import com.sparrowwallet.drongo.psbt.PSBTParseException;
|
import com.sparrowwallet.drongo.psbt.PSBTParseException;
|
||||||
|
import com.sparrowwallet.drongo.uri.BitcoinURI;
|
||||||
import com.sparrowwallet.drongo.wallet.*;
|
import com.sparrowwallet.drongo.wallet.*;
|
||||||
import com.sparrowwallet.sparrow.control.*;
|
import com.sparrowwallet.sparrow.control.*;
|
||||||
import com.sparrowwallet.sparrow.event.*;
|
import com.sparrowwallet.sparrow.event.*;
|
||||||
|
@ -146,6 +148,8 @@ public class AppController implements Initializable {
|
||||||
|
|
||||||
private static List<Device> devices;
|
private static List<Device> devices;
|
||||||
|
|
||||||
|
private static Map<Address, BitcoinURI> payjoinURIs = new HashMap<>();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void initialize(URL location, ResourceBundle resources) {
|
public void initialize(URL location, ResourceBundle resources) {
|
||||||
EventManager.get().register(this);
|
EventManager.get().register(this);
|
||||||
|
@ -657,6 +661,17 @@ public class AppController implements Initializable {
|
||||||
return devices == null ? new ArrayList<>() : devices;
|
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<Wallet, Storage> getOpenWallets() {
|
public Map<Wallet, Storage> getOpenWallets() {
|
||||||
Map<Wallet, Storage> openWallets = new LinkedHashMap<>();
|
Map<Wallet, Storage> openWallets = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
|
|
@ -38,6 +38,7 @@ public class FontAwesome5 extends GlyphFont {
|
||||||
PLUS('\uf067'),
|
PLUS('\uf067'),
|
||||||
QRCODE('\uf029'),
|
QRCODE('\uf029'),
|
||||||
QUESTION_CIRCLE('\uf059'),
|
QUESTION_CIRCLE('\uf059'),
|
||||||
|
RANDOM('\uf074'),
|
||||||
REPLY_ALL('\uf122'),
|
REPLY_ALL('\uf122'),
|
||||||
SATELLITE_DISH('\uf7c0'),
|
SATELLITE_DISH('\uf7c0'),
|
||||||
SD_CARD('\uf7c2'),
|
SD_CARD('\uf7c2'),
|
||||||
|
|
|
@ -133,7 +133,7 @@ public class TcpTransport implements Transport, Closeable {
|
||||||
while(running) {
|
while(running) {
|
||||||
try {
|
try {
|
||||||
String received = readInputStream();
|
String received = readInputStream();
|
||||||
if(received.contains("method")) {
|
if(received.contains("method") && !received.contains("error")) {
|
||||||
//Handle subscription notification
|
//Handle subscription notification
|
||||||
jsonRpcServer.handle(received, subscriptionService);
|
jsonRpcServer.handle(received, subscriptionService);
|
||||||
} else {
|
} else {
|
||||||
|
|
304
src/main/java/com/sparrowwallet/sparrow/payjoin/Payjoin.java
Normal file
304
src/main/java/com/sparrowwallet/sparrow/payjoin/Payjoin.java
Normal file
|
@ -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<String> 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<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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<Long> 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<TransactionInput, PSBTInput> 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<TransactionOutput, PSBTOutput> 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<Script, WalletNode> 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<String, String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,9 +3,11 @@ package com.sparrowwallet.sparrow.transaction;
|
||||||
import com.sparrowwallet.drongo.KeyPurpose;
|
import com.sparrowwallet.drongo.KeyPurpose;
|
||||||
import com.sparrowwallet.drongo.SecureString;
|
import com.sparrowwallet.drongo.SecureString;
|
||||||
import com.sparrowwallet.drongo.Utils;
|
import com.sparrowwallet.drongo.Utils;
|
||||||
|
import com.sparrowwallet.drongo.address.Address;
|
||||||
import com.sparrowwallet.drongo.protocol.*;
|
import com.sparrowwallet.drongo.protocol.*;
|
||||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||||
import com.sparrowwallet.drongo.psbt.PSBTInput;
|
import com.sparrowwallet.drongo.psbt.PSBTInput;
|
||||||
|
import com.sparrowwallet.drongo.uri.BitcoinURI;
|
||||||
import com.sparrowwallet.drongo.wallet.*;
|
import com.sparrowwallet.drongo.wallet.*;
|
||||||
import com.sparrowwallet.hummingbird.UR;
|
import com.sparrowwallet.hummingbird.UR;
|
||||||
import com.sparrowwallet.sparrow.AppController;
|
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.io.Device;
|
||||||
import com.sparrowwallet.sparrow.net.ElectrumServer;
|
import com.sparrowwallet.sparrow.net.ElectrumServer;
|
||||||
import com.sparrowwallet.sparrow.io.Storage;
|
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.HashIndexEntry;
|
||||||
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
|
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
|
@ -40,10 +44,8 @@ import tornadofx.control.Fieldset;
|
||||||
import com.google.common.eventbus.Subscribe;
|
import com.google.common.eventbus.Subscribe;
|
||||||
import tornadofx.control.Form;
|
import tornadofx.control.Form;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.*;
|
||||||
import java.io.IOException;
|
import java.net.*;
|
||||||
import java.io.PrintWriter;
|
|
||||||
import java.net.URL;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.text.SimpleDateFormat;
|
import java.text.SimpleDateFormat;
|
||||||
import java.time.*;
|
import java.time.*;
|
||||||
|
@ -196,6 +198,9 @@ public class HeadersController extends TransactionFormController implements Init
|
||||||
@FXML
|
@FXML
|
||||||
private Button saveFinalButton;
|
private Button saveFinalButton;
|
||||||
|
|
||||||
|
@FXML
|
||||||
|
private Button payjoinButton;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void initialize(URL location, ResourceBundle resources) {
|
public void initialize(URL location, ResourceBundle resources) {
|
||||||
EventManager.get().register(this);
|
EventManager.get().register(this);
|
||||||
|
@ -356,6 +361,12 @@ public class HeadersController extends TransactionFormController implements Init
|
||||||
saveFinalButton.visibleProperty().bind(broadcastButton.visibleProperty().not());
|
saveFinalButton.visibleProperty().bind(broadcastButton.visibleProperty().not());
|
||||||
broadcastButton.visibleProperty().bind(AppController.onlineProperty());
|
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);
|
blockchainForm.setVisible(false);
|
||||||
signingWalletForm.setVisible(false);
|
signingWalletForm.setVisible(false);
|
||||||
sigHashForm.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 {
|
private static class BlockHeightContextMenu extends ContextMenu {
|
||||||
public BlockHeightContextMenu(Sha256Hash blockHash) {
|
public BlockHeightContextMenu(Sha256Hash blockHash) {
|
||||||
MenuItem copyBlockHash = new MenuItem("Copy Block Hash");
|
MenuItem copyBlockHash = new MenuItem("Copy Block Hash");
|
||||||
|
@ -556,7 +585,7 @@ public class HeadersController extends TransactionFormController implements Init
|
||||||
|
|
||||||
private void updateTxId() {
|
private void updateTxId() {
|
||||||
id.setText(headersForm.getTransaction().calculateTxId(false).toString());
|
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)) {
|
if(!id.getStyleClass().contains(UNFINALIZED_TXID_CLASS)) {
|
||||||
id.getStyleClass().add(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
|
@Override
|
||||||
public void update() {
|
public void update() {
|
||||||
BlockTransaction blockTransaction = headersForm.getBlockTransaction();
|
BlockTransaction blockTransaction = headersForm.getBlockTransaction();
|
||||||
|
@ -961,7 +1005,7 @@ public class HeadersController extends TransactionFormController implements Init
|
||||||
|
|
||||||
@Subscribe
|
@Subscribe
|
||||||
public void walletNodeHistoryChanged(WalletNodeHistoryChangedEvent event) {
|
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();
|
Sha256Hash txid = headersForm.getTransaction().getTxId();
|
||||||
ElectrumServer.TransactionReferenceService transactionReferenceService = new ElectrumServer.TransactionReferenceService(Set.of(txid), event.getScriptHash());
|
ElectrumServer.TransactionReferenceService transactionReferenceService = new ElectrumServer.TransactionReferenceService(Set.of(txid), event.getScriptHash());
|
||||||
transactionReferenceService.setOnSucceeded(successEvent -> {
|
transactionReferenceService.setOnSucceeded(successEvent -> {
|
||||||
|
|
|
@ -107,6 +107,10 @@ public abstract class TransactionForm {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isTransactionFinalized() {
|
||||||
|
return getPsbt() == null || getTransaction().hasScriptSigs() || getTransaction().hasWitnesses();
|
||||||
|
}
|
||||||
|
|
||||||
public abstract Node getContents() throws IOException;
|
public abstract Node getContents() throws IOException;
|
||||||
|
|
||||||
public abstract TransactionView getView();
|
public abstract TransactionView getView();
|
||||||
|
|
|
@ -315,6 +315,9 @@ public class PaymentController extends WalletFormController implements Initializ
|
||||||
if(bitcoinURI.getAmount() != null) {
|
if(bitcoinURI.getAmount() != null) {
|
||||||
setRecipientValueSats(bitcoinURI.getAmount());
|
setRecipientValueSats(bitcoinURI.getAmount());
|
||||||
}
|
}
|
||||||
|
if(bitcoinURI.getPayjoinUrl() != null) {
|
||||||
|
AppController.addPayjoinURI(bitcoinURI);
|
||||||
|
}
|
||||||
sendController.updateTransaction();
|
sendController.updateTransaction();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
open module com.sparrowwallet.sparrow {
|
open module com.sparrowwallet.sparrow {
|
||||||
requires java.desktop;
|
requires java.desktop;
|
||||||
|
requires java.net.http;
|
||||||
requires javafx.controls;
|
requires javafx.controls;
|
||||||
requires javafx.fxml;
|
requires javafx.fxml;
|
||||||
requires javafx.graphics;
|
requires javafx.graphics;
|
||||||
|
|
|
@ -248,6 +248,11 @@
|
||||||
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="ARROW_DOWN" />
|
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="ARROW_DOWN" />
|
||||||
</graphic>
|
</graphic>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button fx:id="payjoinButton" defaultButton="true" HBox.hgrow="ALWAYS" text="Get Payjoin Transaction" contentDisplay="TOP" wrapText="true" textAlignment="CENTER" onAction="#getPayjoinTransaction">
|
||||||
|
<graphic>
|
||||||
|
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="RANDOM" />
|
||||||
|
</graphic>
|
||||||
|
</Button>
|
||||||
</HBox>
|
</HBox>
|
||||||
</VBox>
|
</VBox>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
|
Loading…
Reference in a new issue