Craig Raw
4 years ago
11 changed files with 404 additions and 8 deletions
@ -1 +1 @@ |
|||||
Subproject commit 67c76c3b28158e38dbf6a5a3eb49f4a03b01b7b1 |
Subproject commit 9c9836147ab77b28fed9b6bdc8eb1e14fd1e1217 |
@ -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); |
||||
|
} |
||||
|
} |
Loading…
Reference in new issue