@@ -381,6 +381,238 @@ Without payjoin, the maximum amount of money that could be lost by a compromised
381
381
382
382
With payjoin, the maximum amount of money that can be lost is equal to two payments.
383
383
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
+
384
616
==Implementations ==
385
617
386
618
* [[https ://github.com/BlueWallet/BlueWallet |BlueWallet ]] is in the process of implementing the protocol.
0 commit comments