using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; using NBitcoin; using NBitcoin.Payment; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using IHttpClientFactory = System.Net.Http.IHttpClientFactory; namespace BTCPayServer.Services { public static class PSBTExtensions { public static ScriptPubKeyType? GetInputsScriptPubKeyType(this PSBT psbt) { if (!psbt.IsAllFinalized()) throw new InvalidOperationException("The psbt should be finalized with witness information"); var coinsPerTypes = psbt.Inputs.Select(i => { return ((PSBTCoin)i, i.GetInputScriptPubKeyType()); }).GroupBy(o => o.Item2, o => o.Item1).ToArray(); if (coinsPerTypes.Length != 1) return default; return coinsPerTypes[0].Key; } public static ScriptPubKeyType? GetInputScriptPubKeyType(this PSBTInput i) { var scriptPubKey = i.GetTxOut().ScriptPubKey; if (scriptPubKey.IsScriptType(ScriptType.P2PKH)) return ScriptPubKeyType.Legacy; if (scriptPubKey.IsScriptType(ScriptType.P2WPKH)) return ScriptPubKeyType.Segwit; if (scriptPubKey.IsScriptType(ScriptType.P2SH) && i.FinalScriptWitness is WitScript && PayToWitPubKeyHashTemplate.Instance.ExtractWitScriptParameters(i.FinalScriptWitness) is { }) return ScriptPubKeyType.SegwitP2SH; if (scriptPubKey.IsScriptType(ScriptType.P2SH) && i.RedeemScript is Script && PayToWitPubKeyHashTemplate.Instance.CheckScriptPubKey(i.RedeemScript)) return ScriptPubKeyType.SegwitP2SH; return null; } } public class PayjoinClientParameters { public Money MaxAdditionalFeeContribution { get; set; } public FeeRate MinFeeRate { get; set; } public int? AdditionalFeeOutputIndex { get; set; } public bool? DisableOutputSubstitution { get; set; } public int Version { get; set; } = 1; } public class PayjoinClient { public const string PayjoinOnionNamedClient = "payjoin.onion"; public const string PayjoinClearnetNamedClient = "payjoin.clearnet"; public static readonly ScriptPubKeyType[] SupportedFormats = { ScriptPubKeyType.Segwit, ScriptPubKeyType.SegwitP2SH }; public const string BIP21EndpointKey = "pj"; private readonly ExplorerClientProvider _explorerClientProvider; private readonly IHttpClientFactory _httpClientFactory; public PayjoinClient(ExplorerClientProvider explorerClientProvider, IHttpClientFactory httpClientFactory) { if (httpClientFactory == null) throw new ArgumentNullException(nameof(httpClientFactory)); _explorerClientProvider = explorerClientProvider ?? throw new ArgumentNullException(nameof(explorerClientProvider)); _httpClientFactory = httpClientFactory; } public Money MaxFeeBumpContribution { get; set; } public FeeRate MinimumFeeRate { get; set; } public async Task RequestPayjoin(BitcoinUrlBuilder bip21, DerivationSchemeSettings derivationSchemeSettings, PSBT signedPSBT, CancellationToken cancellationToken) { if (bip21 == null) throw new ArgumentNullException(nameof(bip21)); if (!bip21.TryGetPayjoinEndpoint(out var endpoint)) throw new InvalidOperationException("This BIP21 does not support payjoin"); if (derivationSchemeSettings == null) throw new ArgumentNullException(nameof(derivationSchemeSettings)); if (signedPSBT == null) throw new ArgumentNullException(nameof(signedPSBT)); if (signedPSBT.IsAllFinalized()) throw new InvalidOperationException("The original PSBT should not be finalized."); var optionalParameters = new PayjoinClientParameters(); var inputScriptType = derivationSchemeSettings.AccountDerivation.ScriptPubKeyType(); var signingAccount = derivationSchemeSettings.GetSigningAccountKeySettings(); var paymentScriptPubKey = bip21.Address?.ScriptPubKey; var changeOutput = signedPSBT.Outputs.CoinsFor(derivationSchemeSettings.AccountDerivation, signingAccount.AccountKey, signingAccount.GetRootedKeyPath()) .Where(o => o.ScriptPubKey != paymentScriptPubKey) .FirstOrDefault(); if (changeOutput is PSBTOutput o) optionalParameters.AdditionalFeeOutputIndex = (int)o.Index; if (!signedPSBT.TryGetEstimatedFeeRate(out var originalFeeRate)) throw new ArgumentException("signedPSBT should have utxo information", nameof(signedPSBT)); var originalFee = signedPSBT.GetFee(); if (changeOutput is PSBTOutput) optionalParameters.MaxAdditionalFeeContribution = MaxFeeBumpContribution is null ? // By default, we want to keep same fee rate and a single additional input originalFeeRate.GetFee(GetVirtualSize(inputScriptType)) : MaxFeeBumpContribution; if (MinimumFeeRate is FeeRate v) optionalParameters.MinFeeRate = v; bool allowOutputSubstitution = !(optionalParameters.DisableOutputSubstitution is true); if (bip21.UnknowParameters.TryGetValue("pjos", out var pjos) && pjos == "0") allowOutputSubstitution = false; PSBT originalPSBT = CreateOriginalPSBT(signedPSBT); Transaction originalGlobalTx = signedPSBT.GetGlobalTransaction(); TxOut feeOutput = changeOutput == null ? null : originalGlobalTx.Outputs[changeOutput.Index]; var originalInputs = new Queue<(TxIn OriginalTxIn, PSBTInput SignedPSBTInput)>(); for (int i = 0; i < originalGlobalTx.Inputs.Count; i++) { originalInputs.Enqueue((originalGlobalTx.Inputs[i], signedPSBT.Inputs[i])); } var originalOutputs = new Queue<(TxOut OriginalTxOut, PSBTOutput SignedPSBTOutput)>(); for (int i = 0; i < originalGlobalTx.Outputs.Count; i++) { originalOutputs.Enqueue((originalGlobalTx.Outputs[i], signedPSBT.Outputs[i])); } endpoint = ApplyOptionalParameters(endpoint, optionalParameters); var proposal = await SendOriginalTransaction(endpoint, originalPSBT, cancellationToken); // Checking that the PSBT of the receiver is clean if (proposal.GlobalXPubs.Any()) { throw new PayjoinSenderException("GlobalXPubs should not be included in the receiver's PSBT"); } //////////// if (proposal.CheckSanity() is List errors && errors.Count > 0) throw new PayjoinSenderException($"The proposal PSBT is not sane ({errors[0]})"); var proposalGlobalTx = proposal.GetGlobalTransaction(); // Verify that the transaction version, and nLockTime are unchanged. if (proposalGlobalTx.Version != originalGlobalTx.Version) throw new PayjoinSenderException($"The proposal PSBT changed the transaction version"); if (proposalGlobalTx.LockTime != originalGlobalTx.LockTime) throw new PayjoinSenderException($"The proposal PSBT changed the nLocktime"); HashSet sequences = new HashSet(); // For each inputs in the proposal: foreach (var proposedPSBTInput in proposal.Inputs) { if (proposedPSBTInput.HDKeyPaths.Count != 0) throw new PayjoinSenderException("The receiver added keypaths to an input"); if (proposedPSBTInput.PartialSigs.Count != 0) throw new PayjoinSenderException("The receiver added partial signatures to an input"); var proposedTxIn = proposalGlobalTx.Inputs.FindIndexedInput(proposedPSBTInput.PrevOut).TxIn; bool isOurInput = originalInputs.Count > 0 && originalInputs.Peek().OriginalTxIn.PrevOut == proposedPSBTInput.PrevOut; // If it is one of our input if (isOurInput) { var input = originalInputs.Dequeue(); // Verify that sequence is unchanged. if (input.OriginalTxIn.Sequence != proposedTxIn.Sequence) throw new PayjoinSenderException("The proposedTxIn modified the sequence of one of our inputs"); // Verify the PSBT input is not finalized if (proposedPSBTInput.IsFinalized()) throw new PayjoinSenderException("The receiver finalized one of our inputs"); // Verify that non_witness_utxo and witness_utxo are not specified. if (proposedPSBTInput.NonWitnessUtxo != null || proposedPSBTInput.WitnessUtxo != null) throw new PayjoinSenderException("The receiver added non_witness_utxo or witness_utxo to one of our inputs"); sequences.Add(proposedTxIn.Sequence); // Fill up the info from the original PSBT input so we can sign and get fees. proposedPSBTInput.NonWitnessUtxo = input.SignedPSBTInput.NonWitnessUtxo; proposedPSBTInput.WitnessUtxo = input.SignedPSBTInput.WitnessUtxo; // We fill up information we had on the signed PSBT, so we can sign it. foreach (var hdKey in input.SignedPSBTInput.HDKeyPaths) proposedPSBTInput.HDKeyPaths.Add(hdKey.Key, hdKey.Value); proposedPSBTInput.RedeemScript = input.SignedPSBTInput.RedeemScript; } else { // Verify the PSBT input is finalized if (!proposedPSBTInput.IsFinalized()) throw new PayjoinSenderException("The receiver did not finalized one of their input"); // Verify that non_witness_utxo or witness_utxo are filled in. if (proposedPSBTInput.NonWitnessUtxo == null && proposedPSBTInput.WitnessUtxo == null) throw new PayjoinSenderException("The receiver did not specify non_witness_utxo or witness_utxo for one of their inputs"); sequences.Add(proposedTxIn.Sequence); // Verify that the payjoin proposal did not introduced mixed input's type. if (inputScriptType != proposedPSBTInput.GetInputScriptPubKeyType()) throw new PayjoinSenderException("Mixed input type detected in the proposal"); } } // Verify that all of sender's inputs from the original PSBT are in the proposal. if (originalInputs.Count != 0) throw new PayjoinSenderException("Some of our inputs are not included in the proposal"); // Verify that the payjoin proposal did not introduced mixed input's sequence. if (sequences.Count != 1) throw new PayjoinSenderException("Mixed sequence detected in the proposal"); if (!proposal.TryGetFee(out var newFee)) throw new PayjoinSenderException("The payjoin receiver did not included UTXO information to calculate fee correctly"); var additionalFee = newFee - originalFee; if (additionalFee < Money.Zero) throw new PayjoinSenderException("The receiver decreased absolute fee"); // For each outputs in the proposal: foreach (var proposedPSBTOutput in proposal.Outputs) { // Verify that no keypaths is in the PSBT output if (proposedPSBTOutput.HDKeyPaths.Count != 0) throw new PayjoinSenderException("The receiver added keypaths to an output"); bool isOriginalOutput = originalOutputs.Count > 0 && originalOutputs.Peek().OriginalTxOut.ScriptPubKey == proposedPSBTOutput.ScriptPubKey; if (isOriginalOutput) { var originalOutput = originalOutputs.Dequeue(); if (originalOutput.OriginalTxOut == feeOutput) { var actualContribution = feeOutput.Value - proposedPSBTOutput.Value; // The amount that was substracted from the output's value is less or equal to maxadditionalfeecontribution if (actualContribution > optionalParameters.MaxAdditionalFeeContribution) throw new PayjoinSenderException("The actual contribution is more than maxadditionalfeecontribution"); // Make sure the actual contribution is only paying fee if (actualContribution > additionalFee) throw new PayjoinSenderException("The actual contribution is not only paying fee"); // Make sure the actual contribution is only paying for fee incurred by additional inputs var additionalInputsCount = proposalGlobalTx.Inputs.Count - originalGlobalTx.Inputs.Count; if (actualContribution > originalFeeRate.GetFee(GetVirtualSize(inputScriptType)) * additionalInputsCount) throw new PayjoinSenderException("The actual contribution is not only paying for additional inputs"); } else if (allowOutputSubstitution && originalOutput.OriginalTxOut.ScriptPubKey == paymentScriptPubKey) { // That's the payment output, the receiver may have changed it. } else { if (originalOutput.OriginalTxOut.Value > proposedPSBTOutput.Value) throw new PayjoinSenderException("The receiver decreased the value of one of the outputs"); } // We fill up information we had on the signed PSBT, so we can sign it. foreach (var hdKey in originalOutput.SignedPSBTOutput.HDKeyPaths) proposedPSBTOutput.HDKeyPaths.Add(hdKey.Key, hdKey.Value); proposedPSBTOutput.RedeemScript = originalOutput.SignedPSBTOutput.RedeemScript; } } // Verify that all of sender's outputs from the original PSBT are in the proposal. if (originalOutputs.Count != 0) { if (!allowOutputSubstitution || originalOutputs.Count != 1 || originalOutputs.Dequeue().OriginalTxOut.ScriptPubKey != paymentScriptPubKey) { throw new PayjoinSenderException("Some of our outputs are not included in the proposal"); } } // If minfeerate was specified, check that the fee rate of the payjoin transaction is not less than this value. if (optionalParameters.MinFeeRate is FeeRate minFeeRate) { if (!proposal.TryGetEstimatedFeeRate(out var newFeeRate)) throw new PayjoinSenderException("The payjoin receiver did not included UTXO information to calculate fee correctly"); if (newFeeRate < minFeeRate) throw new PayjoinSenderException("The payjoin receiver created a payjoin with a too low fee rate"); } return proposal; } private int GetVirtualSize(ScriptPubKeyType? scriptPubKeyType) { switch (scriptPubKeyType) { case ScriptPubKeyType.Legacy: return 148; case ScriptPubKeyType.Segwit: return 68; case ScriptPubKeyType.SegwitP2SH: return 91; default: return 110; } } private static PSBT CreateOriginalPSBT(PSBT signedPSBT) { var original = signedPSBT.Clone(); original = original.Finalize(); foreach (var input in original.Inputs) { input.HDKeyPaths.Clear(); input.PartialSigs.Clear(); input.Unknown.Clear(); } foreach (var output in original.Outputs) { output.Unknown.Clear(); output.HDKeyPaths.Clear(); } original.GlobalXPubs.Clear(); return original; } private async Task SendOriginalTransaction(Uri endpoint, PSBT originalTx, CancellationToken cancellationToken) { using (HttpClient client = CreateHttpClient(endpoint)) { var bpuresponse = await client.PostAsync(endpoint, new StringContent(originalTx.ToBase64(), Encoding.UTF8, "text/plain"), cancellationToken); if (!bpuresponse.IsSuccessStatusCode) { var errorStr = await bpuresponse.Content.ReadAsStringAsync(); try { var error = JObject.Parse(errorStr); throw new PayjoinReceiverException(error["errorCode"].Value(), error["message"].Value()); } catch (JsonReaderException) { // will throw bpuresponse.EnsureSuccessStatusCode(); throw; } } var hex = await bpuresponse.Content.ReadAsStringAsync(); return PSBT.Parse(hex, originalTx.Network); } } private static Uri ApplyOptionalParameters(Uri endpoint, PayjoinClientParameters clientParameters) { var requestUri = endpoint.AbsoluteUri; if (requestUri.IndexOf('?', StringComparison.OrdinalIgnoreCase) is int i && i != -1) requestUri = requestUri.Substring(0, i); List parameters = new List(3); parameters.Add($"v={clientParameters.Version}"); if (clientParameters.AdditionalFeeOutputIndex is int additionalFeeOutputIndex) parameters.Add($"additionalfeeoutputindex={additionalFeeOutputIndex.ToString(CultureInfo.InvariantCulture)}"); if (clientParameters.DisableOutputSubstitution is bool disableoutputsubstitution) parameters.Add($"disableoutputsubstitution={disableoutputsubstitution}"); if (clientParameters.MaxAdditionalFeeContribution is Money maxAdditionalFeeContribution) parameters.Add($"maxadditionalfeecontribution={maxAdditionalFeeContribution.Satoshi.ToString(CultureInfo.InvariantCulture)}"); if (clientParameters.MinFeeRate is FeeRate minFeeRate) parameters.Add($"minfeerate={minFeeRate.SatoshiPerByte.ToString(CultureInfo.InvariantCulture)}"); endpoint = new Uri($"{requestUri}?{string.Join('&', parameters)}"); return endpoint; } private HttpClient CreateHttpClient(Uri uri) { if (uri.IsOnion()) return _httpClientFactory.CreateClient(PayjoinOnionNamedClient); else return _httpClientFactory.CreateClient(PayjoinClearnetNamedClient); } } public class PayjoinException : Exception { public PayjoinException(string message) : base(message) { } } public enum PayjoinReceiverWellknownErrors { Unavailable, NotEnoughMoney, VersionUnsupported, OriginalPSBTRejected } public class PayjoinReceiverHelper { static IEnumerable<(PayjoinReceiverWellknownErrors EnumValue, string ErrorCode, string Message)> Get() { yield return (PayjoinReceiverWellknownErrors.Unavailable, "unavailable", "The payjoin endpoint is not available for now."); yield return (PayjoinReceiverWellknownErrors.NotEnoughMoney, "not-enough-money", "The receiver added some inputs but could not bump the fee of the payjoin proposal."); yield return (PayjoinReceiverWellknownErrors.VersionUnsupported, "version-unsupported", "This version of payjoin is not supported."); yield return (PayjoinReceiverWellknownErrors.OriginalPSBTRejected, "original-psbt-rejected", "The receiver rejected the original PSBT."); } public static string GetErrorCode(PayjoinReceiverWellknownErrors err) { return Get().Single(o => o.EnumValue == err).ErrorCode; } public static PayjoinReceiverWellknownErrors? GetWellknownError(string errorCode) { var t = Get().FirstOrDefault(o => o.ErrorCode == errorCode); if (t == default) return null; return t.EnumValue; } static readonly string UnknownError = "Unknown error from the receiver"; public static string GetMessage(string errorCode) { return Get().FirstOrDefault(o => o.ErrorCode == errorCode).Message ?? UnknownError; } public static string GetMessage(PayjoinReceiverWellknownErrors err) { return Get().Single(o => o.EnumValue == err).Message; } } public class PayjoinReceiverException : PayjoinException { public PayjoinReceiverException(string errorCode, string receiverMessage) : base(FormatMessage(errorCode, receiverMessage)) { ErrorCode = errorCode; ReceiverMessage = receiverMessage; WellknownError = PayjoinReceiverHelper.GetWellknownError(errorCode); ErrorMessage = PayjoinReceiverHelper.GetMessage(errorCode); } public string ErrorCode { get; } public string ErrorMessage { get; } public string ReceiverMessage { get; } public PayjoinReceiverWellknownErrors? WellknownError { get; } private static string FormatMessage(string errorCode, string receiverMessage) { return $"{errorCode}: {PayjoinReceiverHelper.GetMessage(errorCode)}. (Receiver message: {receiverMessage})"; } } public class PayjoinSenderException : PayjoinException { public PayjoinSenderException(string message) : base(message) { } } }