Skip to content

Commit 3485af7

Browse files
committed
Add reference implementation
1 parent ea7562f commit 3485af7

File tree

1 file changed

+232
-0
lines changed

1 file changed

+232
-0
lines changed

bip-0078.mediawiki

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,238 @@ Without payjoin, the maximum amount of money that could be lost by a compromised
381381

382382
With payjoin, the maximum amount of money that can be lost is equal to two payments.
383383

384+
==Reference sender's implementation==
385+
386+
Here is pseudo code of a sender implementation.
387+
<code>RequestPayjoin</code> takes the bip21 URI of the payment, the wallet and the <code>signedPSBT</code>.
388+
389+
The <code>signedPSBT</code> represents a PSBT which has been fully signed, but not yet finalized.
390+
We then prepare <code>originalPSBT</code> from the <code>signedPSBT</code> via the <code>CreateOriginalPSBT</code> function and get back the <code>proposal</code>.
391+
392+
While we verify the <code>proposal</code>, we also import into it informations about our own inputs and outputs from the <code>signedPSBT</code>.
393+
At the end of this <code>RequestPayjoin</code>, the proposal is verified and ready to be signed.
394+
395+
We logged the different PSBT involved, and show the result in our [[#test-vectors|test vectors]].
396+
<pre>
397+
public async Task<PSBT> RequestPayjoin(
398+
BIP21Uri bip21,
399+
Wallet wallet,
400+
PSBT signedPSBT,
401+
PayjoinClientParameters optionalParameters)
402+
{
403+
Log("signed PSBT" + signedPSBT);
404+
var endpoint = bip21.ExtractPayjointEndpoint();
405+
if (signedPSBT.IsAllFinalized())
406+
throw new InvalidOperationException("The original PSBT should not be finalized.");
407+
ScriptPubKeyType inputScriptType = wallet.ScriptPubKeyType();
408+
PSBTOutput feePSBTOutput = null;
409+
if (optionalParameters.AdditionalFeeOutputIndex != null && optionalParameters.MaxAdditionalFeeContribution != null)
410+
feePSBTOutput = signedPSBT.Outputs[optionalParameters.AdditionalFeeOutputIndex];
411+
decimal originalFee = signedPSBT.GetFee();
412+
PSBT originalPSBT = CreateOriginalPSBT(signedPSBT);
413+
Transaction originalGlobalTx = signedPSBT.GetGlobalTransaction();
414+
TxOut feeOutput = feePSBTOutput == null ? null : originalGlobalTx.Outputs[feePSBTOutput.Index];
415+
var ourInputs = new Queue<(TxIn OriginalTxIn, PSBTInput SignedPSBTInput)>();
416+
for (int i = 0; i < originalGlobalTx.Inputs.Count; i++)
417+
{
418+
ourInputs.Enqueue((originalGlobalTx.Inputs[i], signedPSBT.Inputs[i]));
419+
}
420+
var ourOutputs = new Queue<(TxOut OriginalTxOut, PSBTOutput SignedPSBTOutput)>();
421+
for (int i = 0; i < originalGlobalTx.Outputs.Count; i++)
422+
{
423+
if (signedPSBT.Outputs[i].ScriptPubKey != bip21.Address.ScriptPubKey)
424+
ourOutputs.Enqueue((originalGlobalTx.Outputs[i], signedPSBT.Outputs[i]));
425+
}
426+
endpoint = ApplyOptionalParameters(endpoint, optionalParameters);
427+
Log("original PSBT" + originalPSBT);
428+
PSBT proposal = await SendOriginalTransaction(endpoint, originalPSBT, cancellationToken);
429+
Log("payjoin proposal" + proposal);
430+
// Checking that the PSBT of the receiver is clean
431+
if (proposal.GlobalXPubs.Any())
432+
{
433+
throw new PayjoinSenderException("GlobalXPubs should not be included in the receiver's PSBT");
434+
}
435+
////////////
436+
437+
if (proposal.CheckSanity() is List<PSBTError> errors && errors.Count > 0)
438+
throw new PayjoinSenderException($"The proposal PSBT is not insance ({errors[0]})");
439+
440+
var proposalGlobalTx = proposal.GetGlobalTransaction();
441+
// Verify that the transaction version, and nLockTime are unchanged.
442+
if (proposalGlobalTx.Version != originalGlobalTx.Version)
443+
throw new PayjoinSenderException($"The proposal PSBT changed the transaction version");
444+
if (proposalGlobalTx.LockTime != originalGlobalTx.LockTime)
445+
throw new PayjoinSenderException($"The proposal PSBT changed the nLocktime");
446+
447+
HashSet<Sequence> sequences = new HashSet<Sequence>();
448+
// For each inputs in the proposal:
449+
foreach (PSBTInput proposedPSBTInput in proposal.Inputs)
450+
{
451+
if (proposedPSBTInput.HDKeyPaths.Count != 0)
452+
throw new PayjoinSenderException("The receiver added keypaths to an input");
453+
if (proposedPSBTInput.PartialSigs.Count != 0)
454+
throw new PayjoinSenderException("The receiver added partial signatures to an input");
455+
PSBTInput proposedTxIn = proposalGlobalTx.Inputs.FindIndexedInput(proposedPSBTInput.PrevOut).TxIn;
456+
bool isOurInput = ourInputs.Count > 0 && ourInputs.Peek().OriginalTxIn.PrevOut == proposedPSBTInput.PrevOut;
457+
// If it is one of our input
458+
if (isOurInput)
459+
{
460+
OutPoint inputPrevout = ourPrevouts.Dequeue();
461+
TxIn originalTxin = originalGlobalTx.Inputs.FromOutpoint(inputPrevout);
462+
PSBTInput originalPSBTInput = originalPSBT.Inputs.FromOutpoint(inputPrevout);
463+
// Verify that sequence is unchanged.
464+
if (input.OriginalTxIn.Sequence != proposedTxIn.Sequence)
465+
throw new PayjoinSenderException("The proposedTxIn modified the sequence of one of our inputs")
466+
// Verify the PSBT input is not finalized
467+
if (proposedPSBTInput.IsFinalized())
468+
throw new PayjoinSenderException("The receiver finalized one of our inputs");
469+
// Verify that <code>non_witness_utxo</code> and <code>witness_utxo</code> are not specified.
470+
if (proposedPSBTInput.NonWitnessUtxo != null || proposedPSBTInput.WitnessUtxo != null)
471+
throw new PayjoinSenderException("The receiver added non_witness_utxo or witness_utxo to one of our inputs");
472+
sequences.Add(proposedTxIn.Sequence);
473+
474+
// Fill up the info from the original PSBT input so we can sign and get fees.
475+
proposedPSBTInput.NonWitnessUtxo = input.SignedPSBTInput.NonWitnessUtxo;
476+
proposedPSBTInput.WitnessUtxo = input.SignedPSBTInput.WitnessUtxo;
477+
// We fill up information we had on the signed PSBT, so we can sign it.
478+
foreach (var hdKey in input.SignedPSBTInput.HDKeyPaths)
479+
proposedPSBTInput.HDKeyPaths.Add(hdKey.Key, hdKey.Value);
480+
proposedPSBTInput.RedeemScript = signedPSBTInput.RedeemScript;
481+
proposedPSBTInput.RedeemScript = input.SignedPSBTInput.RedeemScript;
482+
}
483+
else
484+
{
485+
// Verify the PSBT input is finalized
486+
if (!proposedPSBTInput.IsFinalized())
487+
throw new PayjoinSenderException("The receiver did not finalized one of their input");
488+
// Verify that non_witness_utxo or witness_utxo are filled in.
489+
if (proposedPSBTInput.NonWitnessUtxo == null && proposedPSBTInput.WitnessUtxo == null)
490+
throw new PayjoinSenderException("The receiver did not specify non_witness_utxo or witness_utxo for one of their inputs");
491+
sequences.Add(proposedTxIn.Sequence);
492+
// Verify that the payjoin proposal did not introduced mixed input's type.
493+
if (inputScriptType != proposedPSBTInput.GetInputScriptPubKeyType())
494+
throw new PayjoinSenderException("Mixed input type detected in the proposal");
495+
}
496+
}
497+
498+
// Verify that all of sender's inputs from the original PSBT are in the proposal.
499+
if (ourInputs.Count != 0)
500+
throw new PayjoinSenderException("Some of our inputs are not included in the proposal");
501+
502+
// Verify that the payjoin proposal did not introduced mixed input's sequence.
503+
if (sequences.Count != 1)
504+
throw new PayjoinSenderException("Mixed sequence detected in the proposal");
505+
506+
// For each outputs in the proposal:
507+
foreach (PSBTOutput proposedPSBTOutput in proposal.Outputs)
508+
{
509+
// Verify that no keypaths is in the PSBT output
510+
if (proposedPSBTOutput.HDKeyPaths.Count != 0)
511+
throw new PayjoinSenderException("The receiver added keypaths to an output");
512+
bool isOurOutput = ourOutputs.Count > 0 && ourOutputs.Peek().OriginalTxOut.ScriptPubKey == proposedPSBTOutput.ScriptPubKey;
513+
if (isOurOutput)
514+
{
515+
var output = ourOutputs.Dequeue();
516+
if (output.OriginalTxOut == feeOutput)
517+
{
518+
var actualContribution = feeOutput.Value - proposedPSBTOutput.Value;
519+
// The amount that was substracted from the output's value is less or equal to maxadditionalfeecontribution
520+
if (actualContribution > optionalParameters.MaxAdditionalFeeContribution)
521+
throw new PayjoinSenderException("The actual contribution is more than maxadditionalfeecontribution");
522+
decimal newFee = proposal.GetFee();
523+
decimal additionalFee = newFee - originalFee;
524+
// Make sure the actual contribution is only paying fee
525+
if (actualContribution > additionalFee)
526+
throw new PayjoinSenderException("The actual contribution is not only paying fee");
527+
// Make sure the actual contribution is only paying for fee incurred by additional inputs
528+
int additionalInputsCount = proposalGlobalTx.Inputs.Count - originalGlobalTx.Inputs.Count;
529+
if (actualContribution > originalFeeRate * GetVirtualSize(inputScriptType) * additionalInputsCount)
530+
throw new PayjoinSenderException("The actual contribution is not only paying for additional inputs");
531+
}
532+
else
533+
{
534+
if (output.OriginalTxOut.Value != proposedPSBTOutput.Value)
535+
throw new PayjoinSenderException("The receiver changed one of our outputs");
536+
}
537+
// We fill up information we had on the signed PSBT, so we can sign it.
538+
foreach (var hdKey in output.SignedPSBTOutput.HDKeyPaths)
539+
proposedPSBTOutput.HDKeyPaths.Add(hdKey.Key, hdKey.Value);
540+
proposedPSBTOutput.RedeemScript = output.SignedPSBTOutput.RedeemScript;
541+
}
542+
}
543+
// Verify that all of sender's outputs from the original PSBT are in the proposal.
544+
if (ourOutputs.Count != 0)
545+
throw new PayjoinSenderException("Some of our outputs are not included in the proposal");
546+
547+
// After signing this proposal, we should check if minfeerate is respected.
548+
Log("payjoin proposal filled with sender's information" + proposal);
549+
return proposal;
550+
}
551+
552+
int GetVirtualSize(ScriptPubKeyType? scriptPubKeyType)
553+
{
554+
switch (scriptPubKeyType)
555+
{
556+
case ScriptPubKeyType.Legacy:
557+
return 148;
558+
case ScriptPubKeyType.Segwit:
559+
return 68;
560+
case ScriptPubKeyType.SegwitP2SH:
561+
return 91;
562+
default:
563+
return 110;
564+
}
565+
}
566+
567+
// Finalized the signedPSBT and remove confidential information
568+
PSBT CreateOriginalPSBT(PSBT signedPSBT)
569+
{
570+
var original = signedPSBT.Clone();
571+
original = original.Finalize();
572+
foreach (var input in original.Inputs)
573+
{
574+
input.HDKeyPaths.Clear();
575+
input.PartialSigs.Clear();
576+
input.Unknown.Clear();
577+
}
578+
foreach (var output in original.Outputs)
579+
{
580+
output.Unknown.Clear();
581+
output.HDKeyPaths.Clear();
582+
}
583+
original.GlobalXPubs.Clear();
584+
return original;
585+
}
586+
</pre>
587+
588+
==<span id="test-vectors"></span>Test vectors==
589+
590+
A successful exchange with:
591+
592+
{| class="wikitable"
593+
!InputScriptType
594+
!Orginal PSBT Fee rate
595+
!maxadditionalfeecontribution
596+
!additionalfeeoutputindex
597+
|-
598+
|P2SH-P2WSH
599+
|2 sat/vbyte
600+
|0.00000182
601+
|0
602+
|}
603+
604+
<code>signed PSBT</code>
605+
<pre>cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQQWABTHikVyU1WCjVZYB03VJg1fy2mFMCICAxWawBqg1YdUxLTYt9NJ7R7fzws2K09rVRBnI6KFj4UWRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIgYDFZrAGqDVh1TEtNi300ntHt/PCzYrT2tVEGcjooWPhRYYSFzWUDEAAIABAACAAAAAgAEAAAAAAAAAAAEAFgAURvYaK7pzgo7lhbSl/DeUan2MxRQiAgLKC8FYHmmul/HrXLUcMDCjfuRg/dhEkG8CO26cEC6vfBhIXNZQMQAAgAEAAIAAAACAAQAAAAEAAAAAAA==</pre>
606+
607+
<code>original PSBT</code>
608+
<pre>cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA=</pre>
609+
610+
<code>payjoin proposal</code>
611+
<pre>cHNidP8BAJwCAAAAAo8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////jye60aAl3JgZdaIERvjkeh72VYZuTGH/ps2I4l0IO4MBAAAAAP7///8CJpW4BQAAAAAXqRQd6EnwadJ0FQ46/q6NcutaawlEMIcACT0AAAAAABepFHdAltvPSGdDwi9DR+m0af6+i2d6h9MAAAAAAAEBIICEHgAAAAAAF6kUyPLL+cphRyyI5GTUazV0hF2R2NWHAQcXFgAUX4BmVeWSTJIEwtUb5TlPS/ntohABCGsCRzBEAiBnu3tA3yWlT0WBClsXXS9j69Bt+waCs9JcjWtNjtv7VgIge2VYAaBeLPDB6HGFlpqOENXMldsJezF9Gs5amvDQRDQBIQJl1jz1tBt8hNx2owTm+4Du4isx0pmdKNMNIjjaMHFfrQAAAA==</pre>
612+
613+
<code>payjoin proposal filled with sender's information</code>
614+
<pre>cHNidP8BAJwCAAAAAo8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////jye60aAl3JgZdaIERvjkeh72VYZuTGH/ps2I4l0IO4MBAAAAAP7///8CJpW4BQAAAAAXqRQd6EnwadJ0FQ46/q6NcutaawlEMIcACT0AAAAAABepFHdAltvPSGdDwi9DR+m0af6+i2d6h9MAAAAAAQEgqBvXBQAAAAAXqRTeTh6QYcpZE1sDWtXm1HmQRUNU0IcBBBYAFMeKRXJTVYKNVlgHTdUmDV/LaYUwIgYDFZrAGqDVh1TEtNi300ntHt/PCzYrT2tVEGcjooWPhRYYSFzWUDEAAIABAACAAAAAgAEAAAAAAAAAAAEBIICEHgAAAAAAF6kUyPLL+cphRyyI5GTUazV0hF2R2NWHAQcXFgAUX4BmVeWSTJIEwtUb5TlPS/ntohABCGsCRzBEAiBnu3tA3yWlT0WBClsXXS9j69Bt+waCs9JcjWtNjtv7VgIge2VYAaBeLPDB6HGFlpqOENXMldsJezF9Gs5amvDQRDQBIQJl1jz1tBt8hNx2owTm+4Du4isx0pmdKNMNIjjaMHFfrQABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUIgICygvBWB5prpfx61y1HDAwo37kYP3YRJBvAjtunBAur3wYSFzWUDEAAIABAACAAAAAgAEAAAABAAAAAAA=</pre>
615+
384616
==Implementations==
385617

386618
* [[https://github.com/BlueWallet/BlueWallet|BlueWallet]] is in the process of implementing the protocol.

0 commit comments

Comments
 (0)