diff --git a/BTCPayServer.Tests/PayJoinTests.cs b/BTCPayServer.Tests/PayJoinTests.cs index 3a360d757..29c221ce8 100644 --- a/BTCPayServer.Tests/PayJoinTests.cs +++ b/BTCPayServer.Tests/PayJoinTests.cs @@ -32,6 +32,7 @@ using NBXplorer.DerivationStrategy; using NBXplorer.Models; using Newtonsoft.Json.Linq; using OpenQA.Selenium; +using TwentyTwenty.Storage; using Xunit; using Xunit.Abstractions; @@ -193,12 +194,11 @@ namespace BTCPayServer.Tests await receiverUser.EnablePayJoin(); var receiverCoin = await receiverUser.ReceiveUTXO(Money.Satoshis(810), network); - var clientShouldError = unsupportedFormats.Contains(senderAddressType); string errorCode = receiverAddressType == senderAddressType ? null : "unavailable|any UTXO available"; var invoice = receiverUser.BitPay.CreateInvoice(new Invoice() { Price = 50000, Currency = "sats", FullNotifications = true }); if (unsupportedFormats.Contains(receiverAddressType)) { - Assert.Null(TestAccount.GetPayjoinEndpoint(invoice, cashCow.Network)); + Assert.Null(TestAccount.GetPayjoinBitcoinUrl(invoice, cashCow.Network)); continue; } var invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network); @@ -210,7 +210,7 @@ namespace BTCPayServer.Tests txBuilder.SendEstimatedFees(new FeeRate(50m)); var psbt = txBuilder.BuildPSBT(false); psbt = await senderUser.Sign(psbt); - var pj = await senderUser.SubmitPayjoin(invoice, psbt, errorCode, clientShouldError); + var pj = await senderUser.SubmitPayjoin(invoice, psbt, errorCode, false); } } @@ -263,7 +263,7 @@ namespace BTCPayServer.Tests s.Driver.FindElement(By.Id("bip21parse")).Click(); s.Driver.SwitchTo().Alert().SendKeys(bip21); s.Driver.SwitchTo().Alert().Accept(); - Assert.False(string.IsNullOrEmpty(s.Driver.FindElement(By.Id("PayJoinEndpointUrl")) + Assert.False(string.IsNullOrEmpty(s.Driver.FindElement(By.Id("PayJoinBIP21")) .GetAttribute("value"))); s.Driver.ScrollTo(By.Id("SendMenu")); s.Driver.FindElement(By.Id("SendMenu")).ForceClick(); @@ -298,10 +298,10 @@ namespace BTCPayServer.Tests s.Driver.FindElement(By.Id("bip21parse")).Click(); s.Driver.SwitchTo().Alert().SendKeys(bip21); s.Driver.SwitchTo().Alert().Accept(); - Assert.False(string.IsNullOrEmpty(s.Driver.FindElement(By.Id("PayJoinEndpointUrl")) + Assert.False(string.IsNullOrEmpty(s.Driver.FindElement(By.Id("PayJoinBIP21")) .GetAttribute("value"))); s.Driver.FindElement(By.Id("FeeSatoshiPerByte")).Clear(); - s.Driver.FindElement(By.Id("FeeSatoshiPerByte")).SendKeys("1"); + s.Driver.FindElement(By.Id("FeeSatoshiPerByte")).SendKeys("2"); s.Driver.ScrollTo(By.Id("SendMenu")); s.Driver.FindElement(By.Id("SendMenu")).ForceClick(); s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click(); @@ -372,8 +372,11 @@ namespace BTCPayServer.Tests await alice.RegisterDerivationSchemeAsync("BTC", ScriptPubKeyType.Segwit, true); await notifications.ListenDerivationSchemesAsync(new[] { alice.DerivationScheme }); var address = (await nbx.GetUnusedAsync(alice.DerivationScheme, DerivationFeature.Deposit)).Address; + await tester.ExplorerNode.GenerateAsync(1); tester.ExplorerNode.SendToAddress(address, Money.Coins(1.0m)); await notifications.NextEventAsync(); + var paymentAddress = new Key().PubKey.GetAddress(ScriptPubKeyType.Legacy, Network.RegTest); + var otherAddress = new Key().PubKey.GetAddress(ScriptPubKeyType.Legacy, Network.RegTest); var psbt = (await nbx.CreatePSBTAsync(alice.DerivationScheme, new CreatePSBTRequest() { Destinations = @@ -381,7 +384,12 @@ namespace BTCPayServer.Tests new CreatePSBTDestination() { Amount = Money.Coins(0.5m), - Destination = new Key().PubKey.GetAddress(ScriptPubKeyType.Legacy, Network.RegTest) + Destination = paymentAddress + }, + new CreatePSBTDestination() + { + Amount = Money.Coins(0.1m), + Destination = otherAddress } }, FeePreference = new FeePreference() @@ -389,62 +397,110 @@ namespace BTCPayServer.Tests ExplicitFee = Money.Satoshis(3000) } })).PSBT; + int paymentIndex = 0; + int changeIndex = 0; + int otherIndex = 0; + for (int i = 0; i < psbt.Outputs.Count; i++) + { + if (psbt.Outputs[i].Value == Money.Coins(0.5m)) + paymentIndex = i; + else if (psbt.Outputs[i].Value == Money.Coins(0.1m)) + otherIndex = i; + else + changeIndex = i; + } + var derivationSchemeSettings = alice.GetController().GetDerivationSchemeSettings(new WalletId(alice.StoreId, "BTC")); var signingAccount = derivationSchemeSettings.GetSigningAccountKeySettings(); psbt.SignAll(derivationSchemeSettings.AccountDerivation, alice.GenerateWalletResponseV.AccountHDKey, signingAccount.GetRootedKeyPath()); - var changeIndex = Array.FindIndex(psbt.Outputs.ToArray(), (PSBTOutput o) => o.ScriptPubKey.IsScriptType(ScriptType.P2WPKH)); using var fakeServer = new FakeServer(); await fakeServer.Start(); - var requesting = pjClient.RequestPayjoin(fakeServer.ServerUri, derivationSchemeSettings, psbt, default); + var bip21 = new BitcoinUrlBuilder($"bitcoin:{paymentAddress}?pj={fakeServer.ServerUri}", Network.RegTest); + var requesting = pjClient.RequestPayjoin(bip21, derivationSchemeSettings, psbt, default); var request = await fakeServer.GetNextRequest(); Assert.Equal("1", request.Request.Query["v"][0]); Assert.Equal(changeIndex.ToString(), request.Request.Query["additionalfeeoutputindex"][0]); - Assert.Equal("3000", request.Request.Query["maxadditionalfeecontribution"][0]); - + Assert.Equal("1146", request.Request.Query["maxadditionalfeecontribution"][0]); Logs.Tester.LogInformation("The payjoin receiver tries to make us pay lots of fee"); var originalPSBT = await ParsePSBT(request); var proposalTx = originalPSBT.GetGlobalTransaction(); - proposalTx.Outputs[changeIndex].Value -= Money.Satoshis(3001); + proposalTx.Outputs[changeIndex].Value -= Money.Satoshis(1147); await request.Response.WriteAsync(PSBT.FromTransaction(proposalTx, Network.RegTest).ToBase64(), Encoding.UTF8); fakeServer.Done(); var ex = await Assert.ThrowsAsync(async () => await requesting); - Assert.Contains("too much fee", ex.Message); + Assert.Contains("contribution is more than maxadditionalfeecontribution", ex.Message); - Logs.Tester.LogInformation("The payjoin receiver tries to send money to himself"); - requesting = pjClient.RequestPayjoin(fakeServer.ServerUri, derivationSchemeSettings, psbt, default); + Logs.Tester.LogInformation("The payjoin receiver tries to change one of our output"); + requesting = pjClient.RequestPayjoin(bip21, derivationSchemeSettings, psbt, default); request = await fakeServer.GetNextRequest(); originalPSBT = await ParsePSBT(request); proposalTx = originalPSBT.GetGlobalTransaction(); - proposalTx.Outputs.Where((o, i) => i != changeIndex).First().Value += Money.Satoshis(1); + proposalTx.Outputs[otherIndex].Value -= Money.Satoshis(1); + await request.Response.WriteAsync(PSBT.FromTransaction(proposalTx, Network.RegTest).ToBase64(), Encoding.UTF8); + fakeServer.Done(); + ex = await Assert.ThrowsAsync(async () => await requesting); + Assert.Contains("The receiver decreased the value of one", ex.Message); + + Logs.Tester.LogInformation("The payjoin receiver tries to pocket the fee"); + requesting = pjClient.RequestPayjoin(bip21, derivationSchemeSettings, psbt, default); + request = await fakeServer.GetNextRequest(); + originalPSBT = await ParsePSBT(request); + proposalTx = originalPSBT.GetGlobalTransaction(); + proposalTx.Outputs[paymentIndex].Value += Money.Satoshis(1); + await request.Response.WriteAsync(PSBT.FromTransaction(proposalTx, Network.RegTest).ToBase64(), Encoding.UTF8); + fakeServer.Done(); + ex = await Assert.ThrowsAsync(async () => await requesting); + Assert.Contains("The receiver decreased absolute fee", ex.Message); + + Logs.Tester.LogInformation("The payjoin receiver tries to remove one of our output"); + requesting = pjClient.RequestPayjoin(bip21, derivationSchemeSettings, psbt, default); + request = await fakeServer.GetNextRequest(); + originalPSBT = await ParsePSBT(request); + proposalTx = originalPSBT.GetGlobalTransaction(); + var removedOutput = proposalTx.Outputs.First(o => o.ScriptPubKey == otherAddress.ScriptPubKey); + proposalTx.Outputs.Remove(removedOutput); + await request.Response.WriteAsync(PSBT.FromTransaction(proposalTx, Network.RegTest).ToBase64(), Encoding.UTF8); + fakeServer.Done(); + ex = await Assert.ThrowsAsync(async () => await requesting); + Assert.Contains("Some of our outputs are not included in the proposal", ex.Message); + + Logs.Tester.LogInformation("The payjoin receiver tries to change their own output"); + requesting = pjClient.RequestPayjoin(bip21, derivationSchemeSettings, psbt, default); + request = await fakeServer.GetNextRequest(); + originalPSBT = await ParsePSBT(request); + proposalTx = originalPSBT.GetGlobalTransaction(); + proposalTx.Outputs.First(o => o.ScriptPubKey == paymentAddress.ScriptPubKey).Value -= Money.Satoshis(1); + await request.Response.WriteAsync(PSBT.FromTransaction(proposalTx, Network.RegTest).ToBase64(), Encoding.UTF8); + fakeServer.Done(); + await requesting; + + + Logs.Tester.LogInformation("The payjoin receiver tries to send money to himself"); + pjClient.MaxFeeBumpContribution = Money.Satoshis(1); + requesting = pjClient.RequestPayjoin(bip21, derivationSchemeSettings, psbt, default); + request = await fakeServer.GetNextRequest(); + originalPSBT = await ParsePSBT(request); + proposalTx = originalPSBT.GetGlobalTransaction(); + proposalTx.Outputs[paymentIndex].Value += Money.Satoshis(1); proposalTx.Outputs[changeIndex].Value -= Money.Satoshis(1); await request.Response.WriteAsync(PSBT.FromTransaction(proposalTx, Network.RegTest).ToBase64(), Encoding.UTF8); fakeServer.Done(); ex = await Assert.ThrowsAsync(async () => await requesting); - Assert.Contains("money to himself", ex.Message); + Assert.Contains("is not only paying fee", ex.Message); + pjClient.MaxFeeBumpContribution = null; - Logs.Tester.LogInformation("The payjoin receiver can't increase the fee rate too much"); - requesting = pjClient.RequestPayjoin(fakeServer.ServerUri, derivationSchemeSettings, psbt, default); - request = await fakeServer.GetNextRequest(); - originalPSBT = await ParsePSBT(request); - proposalTx = originalPSBT.GetGlobalTransaction(); - proposalTx.Outputs[changeIndex].Value -= Money.Satoshis(3000); - await request.Response.WriteAsync(PSBT.FromTransaction(proposalTx, Network.RegTest).ToBase64(), Encoding.UTF8); - fakeServer.Done(); - ex = await Assert.ThrowsAsync(async () => await requesting); - Assert.Contains("increased the fee rate", ex.Message); - - Logs.Tester.LogInformation("The payjoin receiver can't decrease the fee rate too much"); + Logs.Tester.LogInformation("The payjoin receiver can't use additional fee without adding inputs"); pjClient.MinimumFeeRate = new FeeRate(50m); - requesting = pjClient.RequestPayjoin(fakeServer.ServerUri, derivationSchemeSettings, psbt, default); + requesting = pjClient.RequestPayjoin(bip21, derivationSchemeSettings, psbt, default); request = await fakeServer.GetNextRequest(); originalPSBT = await ParsePSBT(request); proposalTx = originalPSBT.GetGlobalTransaction(); - proposalTx.Outputs[changeIndex].Value -= Money.Satoshis(3000); + proposalTx.Outputs[changeIndex].Value -= Money.Satoshis(1146); await request.Response.WriteAsync(PSBT.FromTransaction(proposalTx, Network.RegTest).ToBase64(), Encoding.UTF8); fakeServer.Done(); ex = await Assert.ThrowsAsync(async () => await requesting); - Assert.Contains("a too low fee rate", ex.Message); + Assert.Contains("is not only paying for additional inputs", ex.Message); pjClient.MinimumFeeRate = null; Logs.Tester.LogInformation("Make sure the receiver implementation do not take more fee than allowed"); @@ -476,7 +532,7 @@ namespace BTCPayServer.Tests } })).PSBT; psbt.SignAll(derivationSchemeSettings.AccountDerivation, alice.GenerateWalletResponseV.AccountHDKey, signingAccount.GetRootedKeyPath()); - var endpoint = TestAccount.GetPayjoinEndpoint(invoice, Network.RegTest); + var endpoint = TestAccount.GetPayjoinBitcoinUrl(invoice, Network.RegTest); pjClient.MaxFeeBumpContribution = Money.Satoshis(50); var proposal = await pjClient.RequestPayjoin(endpoint, derivationSchemeSettings, psbt, default); Assert.True(proposal.TryGetFee(out var newFee)); @@ -507,7 +563,7 @@ namespace BTCPayServer.Tests } })).PSBT; psbt.SignAll(derivationSchemeSettings.AccountDerivation, alice.GenerateWalletResponseV.AccountHDKey, signingAccount.GetRootedKeyPath()); - endpoint = TestAccount.GetPayjoinEndpoint(invoice, Network.RegTest); + endpoint = TestAccount.GetPayjoinBitcoinUrl(invoice, Network.RegTest); pjClient.MinimumFeeRate = new FeeRate(100_000_000.2m); var ex2 = await Assert.ThrowsAsync(async () => await pjClient.RequestPayjoin(endpoint, derivationSchemeSettings, psbt, default)); Assert.Equal(PayjoinReceiverWellknownErrors.NotEnoughMoney, ex2.WellknownError); @@ -732,7 +788,7 @@ namespace BTCPayServer.Tests var invoice = senderUser.BitPay.CreateInvoice( new Invoice() { Price = 100, Currency = "USD", FullNotifications = true }); //payjoin is not enabled by default. - Assert.DoesNotContain($"{PayjoinClient.BIP21EndpointKey}", invoice.CryptoInfo.First().PaymentUrls.BIP21); + Assert.DoesNotContain($"{PayjoinClient.BIP21EndpointKey}=", invoice.CryptoInfo.First().PaymentUrls.BIP21); cashCow.SendToAddress(BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network), Money.Coins(0.06m)); @@ -767,7 +823,8 @@ namespace BTCPayServer.Tests invoice = receiverUser.BitPay.CreateInvoice( new Invoice() { Price = 0.02m, Currency = "BTC", FullNotifications = true }); // Bad version should throw incorrect version - var endpoint = TestAccount.GetPayjoinEndpoint(invoice, btcPayNetwork.NBitcoinNetwork); + var bip21 = TestAccount.GetPayjoinBitcoinUrl(invoice, btcPayNetwork.NBitcoinNetwork); + bip21.TryGetPayjoinEndpoint(out var endpoint); var response = await tester.PayTester.HttpClient.PostAsync(endpoint.AbsoluteUri + "?v=2", new StringContent("", Encoding.UTF8, "text/plain")); Assert.False(response.IsSuccessStatusCode); diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index e0b953541..7188f30c7 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -344,7 +344,7 @@ namespace BTCPayServer.Tests public async Task SubmitPayjoin(Invoice invoice, PSBT psbt, string expectedError = null, bool senderError= false) { - var endpoint = GetPayjoinEndpoint(invoice, psbt.Network); + var endpoint = GetPayjoinBitcoinUrl(invoice, psbt.Network); if (endpoint == null) { throw new InvalidOperationException("No payjoin endpoint for the invoice"); @@ -394,7 +394,8 @@ namespace BTCPayServer.Tests async Task SubmitPayjoinCore(string content, Invoice invoice, Network network, string expectedError) { - var endpoint = GetPayjoinEndpoint(invoice, network); + var bip21 = GetPayjoinBitcoinUrl(invoice, network); + bip21.TryGetPayjoinEndpoint(out var endpoint); var response = await parent.PayTester.HttpClient.PostAsync(endpoint, new StringContent(content, Encoding.UTF8, "text/plain")); if (expectedError != null) @@ -421,12 +422,14 @@ namespace BTCPayServer.Tests return response; } - public static Uri GetPayjoinEndpoint(Invoice invoice, Network network) + public static BitcoinUrlBuilder GetPayjoinBitcoinUrl(Invoice invoice, Network network) { var parsedBip21 = new BitcoinUrlBuilder( invoice.CryptoInfo.First(c => c.CryptoCode == network.NetworkSet.CryptoCode).PaymentUrls.BIP21, network); - return parsedBip21.UnknowParameters.TryGetValue($"{PayjoinClient.BIP21EndpointKey}", out var uri) ? new Uri(uri, UriKind.Absolute) : null; + if (!parsedBip21.TryGetPayjoinEndpoint(out var endpoint)) + return null; + return parsedBip21; } } } diff --git a/BTCPayServer/Controllers/WalletsController.PSBT.cs b/BTCPayServer/Controllers/WalletsController.PSBT.cs index ea1e77bf5..7dcf82235 100644 --- a/BTCPayServer/Controllers/WalletsController.PSBT.cs +++ b/BTCPayServer/Controllers/WalletsController.PSBT.cs @@ -12,6 +12,7 @@ using BTCPayServer.Models.WalletViewModels; using BTCPayServer.Services; using Microsoft.AspNetCore.Mvc; using NBitcoin; +using NBitcoin.Payment; using NBXplorer; using NBXplorer.Models; @@ -153,14 +154,12 @@ namespace BTCPayServer.Controllers } } - private async Task GetPayjoinProposedTX(string bpu, PSBT psbt, DerivationSchemeSettings derivationSchemeSettings, BTCPayNetwork btcPayNetwork, CancellationToken cancellationToken) + private async Task GetPayjoinProposedTX(BitcoinUrlBuilder bip21, PSBT psbt, DerivationSchemeSettings derivationSchemeSettings, BTCPayNetwork btcPayNetwork, CancellationToken cancellationToken) { - if (string.IsNullOrEmpty(bpu) || !Uri.TryCreate(bpu, UriKind.Absolute, out var endpoint)) - throw new InvalidOperationException("No payjoin url available"); var cloned = psbt.Clone(); cloned = cloned.Finalize(); await _broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2.0), cloned.ExtractTransaction(), btcPayNetwork); - return await _payjoinClient.RequestPayjoin(endpoint, derivationSchemeSettings, psbt, cancellationToken); + return await _payjoinClient.RequestPayjoin(bip21, derivationSchemeSettings, psbt, cancellationToken); } [HttpGet] @@ -317,7 +316,7 @@ namespace BTCPayServer.Controllers string error = null; try { - var proposedPayjoin = await GetPayjoinProposedTX(vm.SigningContext.PayJoinEndpointUrl, psbt, + var proposedPayjoin = await GetPayjoinProposedTX(new BitcoinUrlBuilder(vm.SigningContext.PayJoinBIP21, network.NBitcoinNetwork), psbt, derivationSchemeSettings, network, cancellationToken); try { diff --git a/BTCPayServer/Controllers/WalletsController.cs b/BTCPayServer/Controllers/WalletsController.cs index 53a6c0f8f..513440cd7 100644 --- a/BTCPayServer/Controllers/WalletsController.cs +++ b/BTCPayServer/Controllers/WalletsController.cs @@ -658,7 +658,7 @@ namespace BTCPayServer.Controllers var signingContext = new SigningContextModel() { - PayJoinEndpointUrl = vm.PayJoinEndpointUrl, + PayJoinBIP21 = vm.PayJoinBIP21, EnforceLowR = psbt.Suggestions?.ShouldEnforceLowR, ChangeAddress = psbt.ChangeAddress?.ToString() }; @@ -713,8 +713,9 @@ namespace BTCPayServer.Controllers $"Payment {(string.IsNullOrEmpty(uriBuilder.Label) ? string.Empty : $" to {uriBuilder.Label}")} {(string.IsNullOrEmpty(uriBuilder.Message) ? string.Empty : $" for {uriBuilder.Message}")}" }); } - uriBuilder.UnknowParameters.TryGetValue(PayjoinClient.BIP21EndpointKey, out var vmPayJoinEndpointUrl); - vm.PayJoinEndpointUrl = vmPayJoinEndpointUrl; + + if (uriBuilder.TryGetPayjoinEndpoint(out _)) + vm.PayJoinBIP21 = uriBuilder.ToString(); } catch { @@ -783,7 +784,7 @@ namespace BTCPayServer.Controllers return; redirectVm.Parameters.Add(new KeyValuePair("SigningContext.PSBT", signingContext.PSBT)); redirectVm.Parameters.Add(new KeyValuePair("SigningContext.OriginalPSBT", signingContext.OriginalPSBT)); - redirectVm.Parameters.Add(new KeyValuePair("SigningContext.PayJoinEndpointUrl", signingContext.PayJoinEndpointUrl)); + redirectVm.Parameters.Add(new KeyValuePair("SigningContext.PayJoinBIP21", signingContext.PayJoinBIP21)); redirectVm.Parameters.Add(new KeyValuePair("SigningContext.EnforceLowR", signingContext.EnforceLowR?.ToString(CultureInfo.InvariantCulture))); redirectVm.Parameters.Add(new KeyValuePair("SigningContext.ChangeAddress", signingContext.ChangeAddress)); } diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index ca7ac82ed..b81463e1d 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -38,11 +38,17 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Newtonsoft.Json.Linq; using BTCPayServer.Payments.Bitcoin; +using NBitcoin.Payment; namespace BTCPayServer { public static class Extensions { + public static bool TryGetPayjoinEndpoint(this BitcoinUrlBuilder bip21, out Uri endpoint) + { + endpoint = bip21.UnknowParameters.TryGetValue($"{PayjoinClient.BIP21EndpointKey}", out var uri) ? new Uri(uri, UriKind.Absolute) : null; + return endpoint != null; + } public static bool IsInternalNode(this LightningConnectionString connectionString, LightningConnectionString internalLightning) { var internalDomain = internalLightning?.BaseUri?.DnsSafeHost; diff --git a/BTCPayServer/Models/WalletViewModels/SigningContextModel.cs b/BTCPayServer/Models/WalletViewModels/SigningContextModel.cs index bf7cf9fbb..2b4fc70e8 100644 --- a/BTCPayServer/Models/WalletViewModels/SigningContextModel.cs +++ b/BTCPayServer/Models/WalletViewModels/SigningContextModel.cs @@ -18,7 +18,7 @@ namespace BTCPayServer.Models.WalletViewModels } public string PSBT { get; set; } public string OriginalPSBT { get; set; } - public string PayJoinEndpointUrl { get; set; } + public string PayJoinBIP21 { get; set; } public bool? EnforceLowR { get; set; } public string ChangeAddress { get; set; } } diff --git a/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs b/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs index f0bbfb7a3..7253a6b2a 100644 --- a/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs +++ b/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs @@ -57,8 +57,8 @@ namespace BTCPayServer.Models.WalletViewModels public ThreeStateBool AllowFeeBump { get; set; } public bool NBXSeedAvailable { get; set; } - [Display(Name = "PayJoin Endpoint Url")] - public string PayJoinEndpointUrl { get; set; } + [Display(Name = "PayJoin BIP21")] + public string PayJoinBIP21 { get; set; } public bool InputSelection { get; set; } public InputSelectionOption[] InputsAvailable { get; set; } diff --git a/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs index 322bab20c..577c3531b 100644 --- a/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs +++ b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs @@ -122,9 +122,10 @@ namespace BTCPayServer.Payments.PayJoin [MediaTypeConstraint("text/plain")] [RateLimitsFilter(ZoneLimits.PayJoin, Scope = RateLimitsScope.RemoteAddress)] public async Task Submit(string cryptoCode, - long maxadditionalfeecontribution = -1, - int additionalfeeoutputindex = -1, + long? maxadditionalfeecontribution, + int? additionalfeeoutputindex, decimal minfeerate = -1.0m, + bool disableoutputsubstitution = false, int v = 1) { var network = _btcPayNetworkProvider.GetNetwork(cryptoCode); @@ -192,9 +193,8 @@ namespace BTCPayServer.Payments.PayJoin } } - bool spareChangeCase = psbt.Outputs.Count == 1; - FeeRate senderMinFeeRate = !spareChangeCase && minfeerate >= 0.0m ? new FeeRate(minfeerate) : null; - Money allowedSenderFeeContribution = Money.Satoshis(!spareChangeCase && maxadditionalfeecontribution >= 0 ? maxadditionalfeecontribution : long.MaxValue); + FeeRate senderMinFeeRate = minfeerate >= 0.0m ? new FeeRate(minfeerate) : null; + Money allowedSenderFeeContribution = Money.Satoshis(maxadditionalfeecontribution is long t && t >= 0 ? t : 0); var sendersInputType = psbt.GetInputsScriptPubKeyType(); if (psbt.CheckSanity() is var errors && errors.Count != 0) @@ -260,7 +260,7 @@ namespace BTCPayServer.Payments.PayJoin //this should never happen, unless the store owner changed the wallet mid way through an invoice return CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "Our wallet does not support payjoin"); } - if (sendersInputType is ScriptPubKeyType t && t != receiverInputsType) + if (sendersInputType is ScriptPubKeyType t1 && t1 != receiverInputsType) { return CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "We do not have any UTXO available for making a payjoin with the sender's inputs type"); } @@ -341,10 +341,14 @@ namespace BTCPayServer.Payments.PayJoin var ourNewOutput = newTx.Outputs[originalPaymentOutput.Index]; HashSet isOurOutput = new HashSet(); isOurOutput.Add(ourNewOutput); - TxOut preferredFeeBumpOutput = additionalfeeoutputindex >= 0 - && additionalfeeoutputindex < newTx.Outputs.Count - && !isOurOutput.Contains(newTx.Outputs[additionalfeeoutputindex]) - ? newTx.Outputs[additionalfeeoutputindex] : null; + TxOut feeOutput = + additionalfeeoutputindex is int feeOutputIndex && + maxadditionalfeecontribution is long v3 && + v3 >= 0 && + feeOutputIndex >= 0 + && feeOutputIndex < newTx.Outputs.Count + && !isOurOutput.Contains(newTx.Outputs[feeOutputIndex]) + ? newTx.Outputs[feeOutputIndex] : null; var rand = new Random(); int senderInputCount = newTx.Inputs.Count; foreach (var selectedUTXO in selectedUTXOs.Select(o => o.Value)) @@ -356,49 +360,6 @@ namespace BTCPayServer.Payments.PayJoin ourNewOutput.Value += contributedAmount; var minRelayTxFee = this._dashboard.Get(network.CryptoCode).Status.BitcoinStatus?.MinRelayTxFee ?? new FeeRate(1.0m); - // Probably receiving some spare change, let's add an output to make - // it looks more like a normal transaction - if (spareChangeCase) - { - ctx.Logs.Write($"The payjoin receiver sent only a single output"); - if (RandomUtils.GetUInt64() % 2 == 0) - { - var change = await explorer.GetUnusedAsync(derivationSchemeSettings.AccountDerivation, DerivationFeature.Change); - var randomChangeAmount = RandomUtils.GetUInt64() % (ulong)contributedAmount.Satoshi; - - // Randomly round the amount to make the payment output look like a change output - var roundMultiple = (ulong)Math.Pow(10, (ulong)Math.Log10(randomChangeAmount)); - while (roundMultiple > 1_000UL) - { - if (RandomUtils.GetUInt32() % 2 == 0) - { - roundMultiple = roundMultiple / 10; - } - else - { - randomChangeAmount = (randomChangeAmount / roundMultiple) * roundMultiple; - break; - } - } - - var fakeChange = newTx.Outputs.CreateNewTxOut(randomChangeAmount, change.ScriptPubKey); - if (fakeChange.IsDust(minRelayTxFee)) - { - randomChangeAmount = fakeChange.GetDustThreshold(minRelayTxFee); - fakeChange.Value = randomChangeAmount; - } - if (randomChangeAmount < contributedAmount) - { - ourNewOutput.Value -= fakeChange.Value; - newTx.Outputs.Add(fakeChange); - isOurOutput.Add(fakeChange); - ctx.Logs.Write($"Added a fake change output of {fakeChange.Value} {network.CryptoCode} in the payjoin proposal"); - } - } - } - - Utils.Shuffle(newTx.Inputs, rand); - Utils.Shuffle(newTx.Outputs, rand); // Remove old signatures as they are not valid anymore foreach (var input in newTx.Inputs) @@ -421,6 +382,8 @@ namespace BTCPayServer.Payments.PayJoin // If the user overpaid, taking fee on our output (useful if sender dump a full UTXO for privacy) for (int i = 0; i < newTx.Outputs.Count && additionalFee > Money.Zero && due < Money.Zero; i++) { + if (disableoutputsubstitution) + break; if (isOurOutput.Contains(newTx.Outputs[i])) { var outputContribution = Money.Min(additionalFee, -due); @@ -434,21 +397,15 @@ namespace BTCPayServer.Payments.PayJoin } // The rest, we take from user's change - for (int i = 0; i < newTx.Outputs.Count && additionalFee > Money.Zero && allowedSenderFeeContribution > Money.Zero; i++) + if (feeOutput != null) { - if (preferredFeeBumpOutput is TxOut && - preferredFeeBumpOutput != newTx.Outputs[i]) - continue; - if (!isOurOutput.Contains(newTx.Outputs[i])) - { - var outputContribution = Money.Min(additionalFee, newTx.Outputs[i].Value); - outputContribution = Money.Min(outputContribution, - newTx.Outputs[i].Value - newTx.Outputs[i].GetDustThreshold(minRelayTxFee)); - outputContribution = Money.Min(outputContribution, allowedSenderFeeContribution); - newTx.Outputs[i].Value -= outputContribution; - additionalFee -= outputContribution; - allowedSenderFeeContribution -= outputContribution; - } + var outputContribution = Money.Min(additionalFee, feeOutput.Value); + outputContribution = Money.Min(outputContribution, + feeOutput.Value - feeOutput.GetDustThreshold(minRelayTxFee)); + outputContribution = Money.Min(outputContribution, allowedSenderFeeContribution); + feeOutput.Value -= outputContribution; + additionalFee -= outputContribution; + allowedSenderFeeContribution -= outputContribution; } if (additionalFee > Money.Zero) diff --git a/BTCPayServer/Services/PayjoinClient.cs b/BTCPayServer/Services/PayjoinClient.cs index eb3e13572..b15a433a4 100644 --- a/BTCPayServer/Services/PayjoinClient.cs +++ b/BTCPayServer/Services/PayjoinClient.cs @@ -7,11 +7,16 @@ using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; +using BTCPayServer.Logging; using BTCPayServer.Payments.Changelly.Models; using Google.Apis.Http; +using Microsoft.Extensions.Logging; using NBitcoin; +using NBitcoin.Payment; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using NUglify.Helpers; +using TwentyTwenty.Storage; using IHttpClientFactory = System.Net.Http.IHttpClientFactory; namespace BTCPayServer.Services @@ -21,7 +26,7 @@ namespace BTCPayServer.Services { public static ScriptPubKeyType? GetInputsScriptPubKeyType(this PSBT psbt) { - if (!psbt.IsAllFinalized() || psbt.Inputs.Any(i => i.WitnessUtxo == null)) + if (!psbt.IsAllFinalized()) throw new InvalidOperationException("The psbt should be finalized with witness information"); var coinsPerTypes = psbt.Inputs.Select(i => { @@ -34,11 +39,19 @@ namespace BTCPayServer.Services public static ScriptPubKeyType? GetInputScriptPubKeyType(this PSBTInput i) { - if (i.WitnessUtxo.ScriptPubKey.IsScriptType(ScriptType.P2WPKH)) + var scriptPubKey = i.GetTxOut().ScriptPubKey; + if (scriptPubKey.IsScriptType(ScriptType.P2PKH)) + return ScriptPubKeyType.Legacy; + if (scriptPubKey.IsScriptType(ScriptType.P2WPKH)) return ScriptPubKeyType.Segwit; - if (i.WitnessUtxo.ScriptPubKey.IsScriptType(ScriptType.P2SH) && + 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; } } @@ -48,6 +61,7 @@ namespace BTCPayServer.Services 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; } @@ -77,185 +91,257 @@ namespace BTCPayServer.Services public Money MaxFeeBumpContribution { get; set; } public FeeRate MinimumFeeRate { get; set; } - public async Task RequestPayjoin(Uri endpoint, DerivationSchemeSettings derivationSchemeSettings, - PSBT originalTx, CancellationToken cancellationToken) + public async Task RequestPayjoin(BitcoinUrlBuilder bip21, DerivationSchemeSettings derivationSchemeSettings, + PSBT signedPSBT, CancellationToken cancellationToken) { - if (endpoint == null) - throw new ArgumentNullException(nameof(endpoint)); + 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 (originalTx == null) - throw new ArgumentNullException(nameof(originalTx)); - if (originalTx.IsAllFinalized()) + if (signedPSBT == null) + throw new ArgumentNullException(nameof(signedPSBT)); + if (signedPSBT.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 optionalParameters = new PayjoinClientParameters(); + var inputScriptType = derivationSchemeSettings.AccountDerivation.ScriptPubKeyType(); var signingAccount = derivationSchemeSettings.GetSigningAccountKeySettings(); - var changeOutput = originalTx.Outputs.CoinsFor(derivationSchemeSettings.AccountDerivation, signingAccount.AccountKey, signingAccount.GetRootedKeyPath()) + 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) - 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; + 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(); + 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) - clientParameters.MinFeeRate = v; - var cloned = originalTx.Clone(); - cloned.Finalize(); + optionalParameters.MinFeeRate = v; - // We make sure we don't send unnecessary information to the receiver - foreach (var finalized in cloned.Inputs.Where(i => i.IsFinalized())) + 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++) { - finalized.ClearForFinalize(); + originalInputs.Enqueue((originalGlobalTx.Inputs[i], signedPSBT.Inputs[i])); } - - foreach (var output in cloned.Outputs) + var originalOutputs = new Queue<(TxOut OriginalTxOut, PSBTOutput SignedPSBTOutput)>(); + for (int i = 0; i < originalGlobalTx.Outputs.Count; i++) { - output.HDKeyPaths.Clear(); + originalOutputs.Enqueue((originalGlobalTx.Outputs[i], signedPSBT.Outputs[i])); } - - 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); - + endpoint = ApplyOptionalParameters(endpoint, optionalParameters); + var proposal = await SendOriginalTransaction(endpoint, originalPSBT, cancellationToken); // Checking that the PSBT of the receiver is clean - if (newPSBT.GlobalXPubs.Any()) + if (proposal.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); - } - } + if (proposal.CheckSanity() is List errors && errors.Count > 0) + throw new PayjoinSenderException($"The proposal PSBT is not sane ({errors[0]})"); - // 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())) + 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 (oldGlobalTx.Inputs.FindIndexedInput(input.PrevOut) is IndexedTxIn ourInput) + 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) { - 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"); + 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 { - throw new PayjoinSenderException( - "The payjoin receiver added some of our own inputs in the proposal"); + // 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"); } } - 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"); - } - } - } + // 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"); - if (ourInputCount < originalTx.Inputs.Count) - throw new PayjoinSenderException("The payjoin receiver removed some of our inputs"); + // 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 (!newPSBT.TryGetEstimatedFeeRate(out var newFeeRate) || !newPSBT.TryGetVirtualSize(out var newVirtualSize)) + 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"); - if (clientParameters.MinFeeRate is FeeRate minFeeRate) + // 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; + } - var sentAfter = -newPSBT.GetBalance(derivationSchemeSettings.AccountDerivation, - signingAccount.AccountKey, - signingAccount.GetRootedKeyPath()); - if (sentAfter > sentBefore) + private int GetVirtualSize(ScriptPubKeyType? scriptPubKeyType) + { + switch (scriptPubKeyType) { - 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"); + case ScriptPubKeyType.Legacy: + return 148; + case ScriptPubKeyType.Segwit: + return 68; + case ScriptPubKeyType.SegwitP2SH: + return 91; + default: + return 110; } + } - return newPSBT; + 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.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(); + return PSBT.Parse(hex, originalTx.Network); + } } private static Uri ApplyOptionalParameters(Uri endpoint, PayjoinClientParameters clientParameters) @@ -267,6 +353,8 @@ namespace BTCPayServer.Services 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) @@ -330,7 +418,7 @@ namespace BTCPayServer.Services } public class PayjoinReceiverException : PayjoinException { - public PayjoinReceiverException(string errorCode, string receiverMessage) : base(FormatMessage(errorCode)) + public PayjoinReceiverException(string errorCode, string receiverMessage) : base(FormatMessage(errorCode, receiverMessage)) { ErrorCode = errorCode; ReceiverMessage = receiverMessage; @@ -346,9 +434,9 @@ namespace BTCPayServer.Services get; } - private static string FormatMessage(string errorCode) + private static string FormatMessage(string errorCode, string receiverMessage) { - return $"{errorCode}: {PayjoinReceiverHelper.GetMessage(errorCode)}"; + return $"{errorCode}: {PayjoinReceiverHelper.GetMessage(errorCode)}. (Receiver message: {receiverMessage})"; } } diff --git a/BTCPayServer/Views/Wallets/SigningContext.cshtml b/BTCPayServer/Views/Wallets/SigningContext.cshtml index 4c689271d..6d69ee6d1 100644 --- a/BTCPayServer/Views/Wallets/SigningContext.cshtml +++ b/BTCPayServer/Views/Wallets/SigningContext.cshtml @@ -4,7 +4,7 @@ { - + } diff --git a/BTCPayServer/Views/Wallets/WalletPSBTReady.cshtml b/BTCPayServer/Views/Wallets/WalletPSBTReady.cshtml index 6dfa75c20..0a83faa3f 100644 --- a/BTCPayServer/Views/Wallets/WalletPSBTReady.cshtml +++ b/BTCPayServer/Views/Wallets/WalletPSBTReady.cshtml @@ -142,7 +142,7 @@ @if (!Model.HasErrors) { - @if (!string.IsNullOrEmpty(Model.SigningContext?.PayJoinEndpointUrl)) + @if (!string.IsNullOrEmpty(Model.SigningContext?.PayJoinBIP21)) { or diff --git a/BTCPayServer/Views/Wallets/WalletSend.cshtml b/BTCPayServer/Views/Wallets/WalletSend.cshtml index 3304d1dfa..c36922253 100644 --- a/BTCPayServer/Views/Wallets/WalletSend.cshtml +++ b/BTCPayServer/Views/Wallets/WalletSend.cshtml @@ -203,12 +203,12 @@ } - @if (!string.IsNullOrEmpty(Model.PayJoinEndpointUrl)) + @if (!string.IsNullOrEmpty(Model.PayJoinBIP21)) {
- - - + + +
}