diff --git a/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs index 8fd5337fb..2947c56d9 100644 --- a/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs +++ b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs @@ -120,7 +120,7 @@ namespace BTCPayServer.Payments.PayJoin var network = _btcPayNetworkProvider.GetNetwork(cryptoCode); if (network == null) { - return BadRequest(CreatePayjoinError(400, "invalid-network", "Incorrect network")); + return BadRequest(CreatePayjoinError("invalid-network", "Incorrect network")); } var explorer = _explorerClientProvider.GetExplorerClient(network); @@ -128,12 +128,12 @@ namespace BTCPayServer.Payments.PayJoin { if (length > 1_000_000) return this.StatusCode(413, - CreatePayjoinError(413, "payload-too-large", "The transaction is too big to be processed")); + CreatePayjoinError("payload-too-large", "The transaction is too big to be processed")); } else { return StatusCode(411, - CreatePayjoinError(411, "missing-content-length", + CreatePayjoinError("missing-content-length", "The http header Content-Length should be filled")); } @@ -150,7 +150,7 @@ namespace BTCPayServer.Payments.PayJoin { psbtFormat = false; if (!Transaction.TryParse(rawBody, network.NBitcoinNetwork, out var tx)) - return BadRequest(CreatePayjoinError(400, "invalid-format", "invalid transaction or psbt")); + return BadRequest(CreatePayjoinError("invalid-format", "invalid transaction or psbt")); originalTx = tx; psbt = PSBT.FromTransaction(tx, network.NBitcoinNetwork); psbt = (await explorer.UpdatePSBTAsync(new UpdatePSBTRequest() {PSBT = psbt})).PSBT; @@ -163,7 +163,7 @@ namespace BTCPayServer.Payments.PayJoin else { if (!psbt.IsAllFinalized()) - return BadRequest(CreatePayjoinError(400, "psbt-not-finalized", "The PSBT should be finalized")); + return BadRequest(CreatePayjoinError("psbt-not-finalized", "The PSBT should be finalized")); originalTx = psbt.ExtractTransaction(); } @@ -174,14 +174,14 @@ namespace BTCPayServer.Payments.PayJoin var sendersInputType = psbt.GetInputsScriptPubKeyType(); if (sendersInputType is null) - return BadRequest(CreatePayjoinError(400, "unsupported-inputs", "Payjoin only support segwit inputs (of the same type)")); + return BadRequest(CreatePayjoinError("unsupported-inputs", "Payjoin only support segwit inputs (of the same type)")); if (psbt.CheckSanity() is var errors && errors.Count != 0) { - return BadRequest(CreatePayjoinError(400, "insane-psbt", $"This PSBT is insane ({errors[0]})")); + return BadRequest(CreatePayjoinError("insane-psbt", $"This PSBT is insane ({errors[0]})")); } if (!psbt.TryGetEstimatedFeeRate(out originalFeeRate)) { - return BadRequest(CreatePayjoinError(400, "need-utxo-information", + return BadRequest(CreatePayjoinError("need-utxo-information", "You need to provide Witness UTXO information to the PSBT.")); } @@ -189,26 +189,26 @@ namespace BTCPayServer.Payments.PayJoin // to leak global xpubs if (psbt.GlobalXPubs.Any()) { - return BadRequest(CreatePayjoinError(400, "leaking-data", + return BadRequest(CreatePayjoinError("leaking-data", "GlobalXPubs should not be included in the PSBT")); } if (psbt.Outputs.Any(o => o.HDKeyPaths.Count != 0) || psbt.Inputs.Any(o => o.HDKeyPaths.Count != 0)) { - return BadRequest(CreatePayjoinError(400, "leaking-data", + return BadRequest(CreatePayjoinError("leaking-data", "Keypath information should not be included in the PSBT")); } if (psbt.Inputs.Any(o => !o.IsFinalized())) { - return BadRequest(CreatePayjoinError(400, "psbt-not-finalized", "The PSBT Should be finalized")); + return BadRequest(CreatePayjoinError("psbt-not-finalized", "The PSBT Should be finalized")); } //////////// var mempool = await explorer.BroadcastAsync(originalTx, true); if (!mempool.Success) { - return BadRequest(CreatePayjoinError(400, "invalid-transaction", + return BadRequest(CreatePayjoinError("invalid-transaction", $"Provided transaction isn't mempool eligible {mempool.RPCCodeMessage}")); } @@ -240,12 +240,12 @@ namespace BTCPayServer.Payments.PayJoin if (!PayjoinClient.SupportedFormats.Contains(receiverInputsType)) { //this should never happen, unless the store owner changed the wallet mid way through an invoice - return StatusCode(500, CreatePayjoinError(500, "unavailable", $"This service is unavailable for now")); + return StatusCode(500, CreatePayjoinError("unavailable", $"This service is unavailable for now")); } if (sendersInputType != receiverInputsType) { return StatusCode(503, - CreatePayjoinError(503, "out-of-utxos", + CreatePayjoinError("out-of-utxos", "We do not have any UTXO available for making a payjoin with the sender's inputs type")); } var paymentMethod = invoice.GetPaymentMethod(paymentMethodId); @@ -255,7 +255,7 @@ namespace BTCPayServer.Payments.PayJoin continue; if (invoice.GetAllBitcoinPaymentData().Any()) { - return UnprocessableEntity(CreatePayjoinError(422, "already-paid", + return UnprocessableEntity(CreatePayjoinError("already-paid", $"The invoice this PSBT is paying has already been partially or completely paid")); } @@ -268,7 +268,7 @@ namespace BTCPayServer.Payments.PayJoin if (!await _payJoinRepository.TryLockInputs(originalTx.Inputs.Select(i => i.PrevOut).ToArray())) { - return BadRequest(CreatePayjoinError(400, "inputs-already-used", + return BadRequest(CreatePayjoinError("inputs-already-used", "Some of those inputs have already been used to make payjoin transaction")); } @@ -293,13 +293,13 @@ namespace BTCPayServer.Payments.PayJoin if (!paidSomething) { - return BadRequest(CreatePayjoinError(400, "invoice-not-found", + return BadRequest(CreatePayjoinError("invoice-not-found", "This transaction does not pay any invoice with payjoin")); } if (due is null || due > Money.Zero) { - return BadRequest(CreatePayjoinError(400, "invoice-not-fully-paid", + return BadRequest(CreatePayjoinError("invoice-not-fully-paid", "The transaction must pay the whole invoice")); } @@ -307,7 +307,7 @@ namespace BTCPayServer.Payments.PayJoin { await BroadcastNow(); return StatusCode(503, - CreatePayjoinError(503, "out-of-utxos", + CreatePayjoinError("out-of-utxos", "We do not have any UTXO available for making a payjoin for now")); } @@ -323,7 +323,7 @@ namespace BTCPayServer.Payments.PayJoin // This should not happen, as we check the existance of private key before creating invoice with payjoin await UnlockUTXOs(); await BroadcastNow(); - return StatusCode(500, CreatePayjoinError(500, "unavailable", $"This service is unavailable for now")); + return StatusCode(500, CreatePayjoinError("unavailable", $"This service is unavailable for now")); } Money contributedAmount = Money.Zero; @@ -438,7 +438,7 @@ namespace BTCPayServer.Payments.PayJoin { await UnlockUTXOs(); await BroadcastNow(); - return UnprocessableEntity(CreatePayjoinError(422, "not-enough-money", + return UnprocessableEntity(CreatePayjoinError("not-enough-money", "Not enough money is sent to pay for the additional payjoin inputs")); } } @@ -476,7 +476,7 @@ namespace BTCPayServer.Payments.PayJoin { await UnlockUTXOs(); await BroadcastNow(); - return UnprocessableEntity(CreatePayjoinError(422, "already-paid", + return UnprocessableEntity(CreatePayjoinError("already-paid", $"The original transaction has already been accounted")); } await _btcPayWalletProvider.GetWallet(network).SaveOffchainTransactionAsync(originalTx); @@ -514,10 +514,9 @@ namespace BTCPayServer.Payments.PayJoin return hash; } - private JObject CreatePayjoinError(int httpCode, string errorCode, string friendlyMessage) + private JObject CreatePayjoinError(string errorCode, string friendlyMessage) { var o = new JObject(); - o.Add(new JProperty("httpCode", httpCode)); o.Add(new JProperty("errorCode", errorCode)); o.Add(new JProperty("message", friendlyMessage)); return o; diff --git a/BTCPayServer/Services/PayjoinClient.cs b/BTCPayServer/Services/PayjoinClient.cs index 964d91a80..67cc2298c 100644 --- a/BTCPayServer/Services/PayjoinClient.cs +++ b/BTCPayServer/Services/PayjoinClient.cs @@ -1,11 +1,14 @@ using System; using System.Collections.Generic; +using System.Configuration; 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 Microsoft.WindowsAzure.Storage.Queue.Protocol; using NBitcoin; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -34,7 +37,7 @@ namespace BTCPayServer.Services if (i.WitnessUtxo.ScriptPubKey.IsScriptType(ScriptType.P2WPKH)) return ScriptPubKeyType.Segwit; if (i.WitnessUtxo.ScriptPubKey.IsScriptType(ScriptType.P2SH) && - PayToWitPubKeyHashTemplate.Instance.ExtractWitScriptParameters(i.FinalScriptWitness) is {}) + PayToWitPubKeyHashTemplate.Instance.ExtractWitScriptParameters(i.FinalScriptWitness) is { }) return ScriptPubKeyType.SegwitP2SH; return null; } @@ -56,18 +59,22 @@ namespace BTCPayServer.Services public PayjoinClient(ExplorerClientProvider explorerClientProvider, IHttpClientFactory httpClientFactory) { - if (httpClientFactory == null) throw new ArgumentNullException(nameof(httpClientFactory)); + if (httpClientFactory == null) + throw new ArgumentNullException(nameof(httpClientFactory)); _explorerClientProvider = explorerClientProvider ?? throw new ArgumentNullException(nameof(explorerClientProvider)); - _httpClientFactory = httpClientFactory; + _httpClientFactory = httpClientFactory; } 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 (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."); @@ -111,7 +118,7 @@ namespace BTCPayServer.Services try { var error = JObject.Parse(errorStr); - throw new PayjoinReceiverException((int)bpuresponse.StatusCode, error["errorCode"].Value(), + throw new PayjoinReceiverException(error["errorCode"].Value(), error["message"].Value()); } catch (JsonReaderException) @@ -151,7 +158,7 @@ namespace BTCPayServer.Services foreach (var output in newPSBT.Outputs) { output.HDKeyPaths.Clear(); - foreach (var originalOutput in originalTx.Outputs) + foreach (var originalOutput in originalTx.Outputs) { if (output.ScriptPubKey == originalOutput.ScriptPubKey) output.UpdateFrom(originalOutput); @@ -212,10 +219,10 @@ namespace BTCPayServer.Services if (sentAfter > sentBefore) { var overPaying = sentAfter - sentBefore; - + 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"); - + var additionalFee = newPSBT.GetFee() - originalFee; if (overPaying > additionalFee) throw new PayjoinSenderException("The payjoin receiver is sending more money to himself"); @@ -250,23 +257,61 @@ namespace BTCPayServer.Services } } + public enum PayjoinReceiverWellknownErrors + { + LeakingData, + PSBTNotFinalized, + Unavailable, + OutOfUTXOS, + NotEnoughMoney, + InsanePSBT, + VersionUnsupported + } public class PayjoinReceiverException : PayjoinException { - public PayjoinReceiverException(int httpCode, string errorCode, string message) : base(FormatMessage(httpCode, - errorCode, message)) + public PayjoinReceiverException(string errorCode, string receiverDebugMessage) : base(FormatMessage(errorCode, receiverDebugMessage)) { - HttpCode = httpCode; ErrorCode = errorCode; - ErrorMessage = message; + ReceiverDebugMessage = receiverDebugMessage; + WellknownError = errorCode switch + { + "leaking-data" => PayjoinReceiverWellknownErrors.LeakingData, + "psbt-not-finalized" => PayjoinReceiverWellknownErrors.PSBTNotFinalized, + "unavailable" => PayjoinReceiverWellknownErrors.Unavailable, + "out-of-utxos" => PayjoinReceiverWellknownErrors.OutOfUTXOS, + "not-enough-money" => PayjoinReceiverWellknownErrors.NotEnoughMoney, + "insane-psbt" => PayjoinReceiverWellknownErrors.InsanePSBT, + "version-unsupported" => PayjoinReceiverWellknownErrors.VersionUnsupported, + _ => null + }; } - - public int HttpCode { get; } public string ErrorCode { get; } public string ErrorMessage { get; } + public string ReceiverDebugMessage { get; } - private static string FormatMessage(in int httpCode, string errorCode, string message) + public PayjoinReceiverWellknownErrors? WellknownError { - return $"{errorCode}: {message} (HTTP: {httpCode})"; + get; + } + + private static string FormatMessage(string errorCode, string receiverDebugMessage) + { + return $"{errorCode}: {GetMessage(errorCode)}"; + } + + private static string GetMessage(string errorCode) + { + return errorCode switch + { + "leaking-data" => "Key path information or GlobalXPubs should not be included in the original PSBT.", + "psbt-not-finalized" => "The original PSBT must be finalized.", + "unavailable" => "The payjoin endpoint is not available for now.", + "out-of-utxos" => "The receiver does not have any UTXO to contribute in a payjoin proposal.", + "not-enough-money" => "The receiver added some inputs but could not bump the fee of the payjoin proposal.", + "insane-psbt" => "Some consistency check on the PSBT failed.", + "version-unsupported" => "This version of payjoin is not supported.", + _ => "Unknown error" + }; } }