using System; using System.Collections.Generic; using System.Configuration; using System.Globalization; using System.Linq; using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Payments.Changelly.Models; using Google.Apis.Http; using NBitcoin; 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() || psbt.Inputs.Any(i => i.WitnessUtxo == null)) 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) { if (i.WitnessUtxo.ScriptPubKey.IsScriptType(ScriptType.P2WPKH)) return ScriptPubKeyType.Segwit; if (i.WitnessUtxo.ScriptPubKey.IsScriptType(ScriptType.P2SH) && PayToWitPubKeyHashTemplate.Instance.ExtractWitScriptParameters(i.FinalScriptWitness) is { }) return ScriptPubKeyType.SegwitP2SH; return null; } } public class PayjoinClientParameters { public Money MaxAdditionalFeeContribution { get; set; } public FeeRate MinFeeRate { get; set; } public int? AdditionalFeeOutputIndex { 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 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(Uri endpoint, DerivationSchemeSettings derivationSchemeSettings, PSBT originalTx, CancellationToken cancellationToken) { if (endpoint == null) throw new ArgumentNullException(nameof(endpoint)); if (derivationSchemeSettings == null) throw new ArgumentNullException(nameof(derivationSchemeSettings)); if (originalTx == null) throw new ArgumentNullException(nameof(originalTx)); if (originalTx.IsAllFinalized()) throw new InvalidOperationException("The original PSBT should not be finalized."); var clientParameters = new PayjoinClientParameters(); var type = derivationSchemeSettings.AccountDerivation.ScriptPubKeyType(); if (!SupportedFormats.Contains(type)) { throw new PayjoinSenderException($"The wallet does not support payjoin"); } var signingAccount = derivationSchemeSettings.GetSigningAccountKeySettings(); var changeOutput = originalTx.Outputs.CoinsFor(derivationSchemeSettings.AccountDerivation, signingAccount.AccountKey, signingAccount.GetRootedKeyPath()) .FirstOrDefault(); if (changeOutput is PSBTOutput o) clientParameters.AdditionalFeeOutputIndex = (int)o.Index; var sentBefore = -originalTx.GetBalance(derivationSchemeSettings.AccountDerivation, signingAccount.AccountKey, signingAccount.GetRootedKeyPath()); var oldGlobalTx = originalTx.GetGlobalTransaction(); if (!originalTx.TryGetEstimatedFeeRate(out var originalFeeRate) || !originalTx.TryGetVirtualSize(out var oldVirtualSize)) throw new ArgumentException("originalTx should have utxo information", nameof(originalTx)); var originalFee = originalTx.GetFee(); clientParameters.MaxAdditionalFeeContribution = MaxFeeBumpContribution is null ? originalFee : MaxFeeBumpContribution; if (MinimumFeeRate is FeeRate v) clientParameters.MinFeeRate = v; var cloned = originalTx.Clone(); cloned.Finalize(); // We make sure we don't send unnecessary information to the receiver foreach (var finalized in cloned.Inputs.Where(i => i.IsFinalized())) { finalized.ClearForFinalize(); } foreach (var output in cloned.Outputs) { output.HDKeyPaths.Clear(); } cloned.GlobalXPubs.Clear(); endpoint = ApplyOptionalParameters(endpoint, clientParameters); using HttpClient client = CreateHttpClient(endpoint); var bpuresponse = await client.PostAsync(endpoint, new StringContent(cloned.ToHex(), 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(); var newPSBT = PSBT.Parse(hex, originalTx.Network); // Checking that the PSBT of the receiver is clean if (newPSBT.GlobalXPubs.Any()) { throw new PayjoinSenderException("GlobalXPubs should not be included in the receiver's PSBT"); } if (newPSBT.Outputs.Any(o => o.HDKeyPaths.Count != 0) || newPSBT.Inputs.Any(o => o.HDKeyPaths.Count != 0)) { throw new PayjoinSenderException("Keypath information should not be included in the receiver's PSBT"); } //////////// newPSBT = await _explorerClientProvider.UpdatePSBT(derivationSchemeSettings, newPSBT); if (newPSBT.CheckSanity() is IList errors2 && errors2.Count != 0) { throw new PayjoinSenderException($"The PSBT of the receiver is insane ({errors2[0]})"); } // We make sure we don't sign things what should not be signed foreach (var finalized in newPSBT.Inputs.Where(i => i.IsFinalized())) { finalized.ClearForFinalize(); } // Make sure only the only our output have any information foreach (var output in newPSBT.Outputs) { output.HDKeyPaths.Clear(); foreach (var originalOutput in originalTx.Outputs) { if (output.ScriptPubKey == originalOutput.ScriptPubKey) output.UpdateFrom(originalOutput); } } // Making sure that our inputs are finalized, and that some of our inputs have not been added var newGlobalTx = newPSBT.GetGlobalTransaction(); int ourInputCount = 0; if (newGlobalTx.Version != oldGlobalTx.Version) throw new PayjoinSenderException("The version field of the transaction has been modified"); if (newGlobalTx.LockTime != oldGlobalTx.LockTime) throw new PayjoinSenderException("The LockTime field of the transaction has been modified"); foreach (var input in newPSBT.Inputs.CoinsFor(derivationSchemeSettings.AccountDerivation, signingAccount.AccountKey, signingAccount.GetRootedKeyPath())) { if (oldGlobalTx.Inputs.FindIndexedInput(input.PrevOut) is IndexedTxIn ourInput) { ourInputCount++; if (input.IsFinalized()) throw new PayjoinSenderException("A PSBT input from us should not be finalized"); if (newGlobalTx.Inputs[input.Index].Sequence != ourInput.TxIn.Sequence) throw new PayjoinSenderException("The sequence of one of our input has been modified"); } else { throw new PayjoinSenderException( "The payjoin receiver added some of our own inputs in the proposal"); } } foreach (var input in newPSBT.Inputs) { if (originalTx.Inputs.FindIndexedInput(input.PrevOut) is null) { if (!input.IsFinalized()) throw new PayjoinSenderException("The payjoin receiver included a non finalized input"); // Making sure that the receiver's inputs are finalized and match format var payjoinInputType = input.GetInputScriptPubKeyType(); if (payjoinInputType is null || payjoinInputType.Value != type) { throw new PayjoinSenderException("The payjoin receiver included an input that is not the same segwit input type"); } } } if (ourInputCount < originalTx.Inputs.Count) throw new PayjoinSenderException("The payjoin receiver removed some of our inputs"); if (!newPSBT.TryGetEstimatedFeeRate(out var newFeeRate) || !newPSBT.TryGetVirtualSize(out var newVirtualSize)) throw new PayjoinSenderException("The payjoin receiver did not included UTXO information to calculate fee correctly"); if (clientParameters.MinFeeRate is FeeRate minFeeRate) { if (newFeeRate < minFeeRate) throw new PayjoinSenderException("The payjoin receiver created a payjoin with a too low fee rate"); } var sentAfter = -newPSBT.GetBalance(derivationSchemeSettings.AccountDerivation, signingAccount.AccountKey, signingAccount.GetRootedKeyPath()); if (sentAfter > sentBefore) { var overPaying = sentAfter - sentBefore; var additionalFee = newPSBT.GetFee() - originalFee; if (overPaying > additionalFee) throw new PayjoinSenderException("The payjoin receiver is sending more money to himself"); if (overPaying > clientParameters.MaxAdditionalFeeContribution) throw new PayjoinSenderException("The payjoin receiver is making us pay too much fee"); // Let's check the difference is only for the fee and that feerate // did not changed that much var expectedFee = originalFeeRate.GetFee(newVirtualSize); // Signing precisely is hard science, give some breathing room for error. expectedFee += originalFeeRate.GetFee(newPSBT.Inputs.Count * 2); if (overPaying > (expectedFee - originalFee)) throw new PayjoinSenderException("The payjoin receiver increased the fee rate we are paying too much"); } return newPSBT; } 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.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 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)) { 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) { return $"{errorCode}: {PayjoinReceiverHelper.GetMessage(errorCode)}"; } } public class PayjoinSenderException : PayjoinException { public PayjoinSenderException(string message) : base(message) { } } }