diff --git a/BTCPayServer.Tests/Checkoutv2Tests.cs b/BTCPayServer.Tests/Checkoutv2Tests.cs index 6ff6c7293..6533cfb3f 100644 --- a/BTCPayServer.Tests/Checkoutv2Tests.cs +++ b/BTCPayServer.Tests/Checkoutv2Tests.cs @@ -33,7 +33,8 @@ namespace BTCPayServer.Tests s.CreateNewStore(); s.EnableCheckoutV2(); s.AddLightningNode(); - s.AddDerivationScheme(); + // Use non-legacy derivation scheme + s.AddDerivationScheme("BTC", "tpubDD79XF4pzhmPSJ9AyUay9YbXAeD1c6nkUqC32pnKARJH6Ja5hGUfGc76V82ahXpsKqN6UcSGXMkzR34aZq4W23C6DAdZFaVrzWqzj24F8BC"); // Configure store url var storeUrl = "https://satoshisteaks.com/"; @@ -59,9 +60,16 @@ namespace BTCPayServer.Tests Assert.Equal(2, s.Driver.FindElements(By.CssSelector(".payment-method")).Count); Assert.Contains("Bitcoin", s.Driver.FindElement(By.CssSelector(".payment-method.active")).Text); Assert.Contains("LNURL", s.Driver.FindElement(By.CssSelector(".payment-method:nth-child(2)")).Text); + var qrValue = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-qr-value"); + var address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard"); var payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href"); - Assert.StartsWith("bitcoin:", payUrl); + var copyAddress = s.Driver.FindElement(By.Id("Address_BTC")).GetAttribute("value"); + Assert.Equal($"bitcoin:{address}", payUrl); + Assert.StartsWith("bcrt", s.Driver.FindElement(By.Id("Address_BTC")).GetAttribute("value")); Assert.DoesNotContain("lightning=", payUrl); + Assert.Equal(address, copyAddress); + Assert.Equal($"bitcoin:{address.ToUpperInvariant()}", qrValue); + s.Driver.ElementDoesNotExist(By.Id("Lightning_BTC")); // Switch to LNURL s.Driver.FindElement(By.CssSelector(".payment-method:nth-child(2)")).Click(); @@ -69,18 +77,26 @@ namespace BTCPayServer.Tests { payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href"); Assert.StartsWith("lightning:lnurl", payUrl); + Assert.StartsWith("lnurl", s.Driver.WaitForElement(By.Id("Lightning_BTC")).GetAttribute("value")); + s.Driver.ElementDoesNotExist(By.Id("Address_BTC")); }); // Default payment method s.GoToHome(); invoiceId = s.CreateInvoice(defaultPaymentMethod: "BTC_LightningLike"); s.GoToInvoiceCheckout(invoiceId); - + s.Driver.WaitUntilAvailable(By.Id("Checkout-v2")); Assert.Equal(2, s.Driver.FindElements(By.CssSelector(".payment-method")).Count); Assert.Contains("Lightning", s.Driver.WaitForElement(By.CssSelector(".payment-method.active")).Text); Assert.Contains("Bitcoin", s.Driver.WaitForElement(By.CssSelector(".payment-method")).Text); + qrValue = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-qr-value"); + address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard"); payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href"); - Assert.StartsWith("lightning:lnbcrt", payUrl); + copyAddress = s.Driver.FindElement(By.Id("Lightning_BTC_LightningLike")).GetAttribute("value"); + Assert.Equal($"lightning:{address}", payUrl); + Assert.Equal(address, copyAddress); + Assert.Equal($"lightning:{address.ToUpperInvariant()}", qrValue); + s.Driver.ElementDoesNotExist(By.Id("Address_BTC")); // Lightning amount in Sats Assert.Contains("BTC", s.Driver.FindElement(By.Id("AmountDue")).Text); @@ -90,6 +106,7 @@ namespace BTCPayServer.Tests s.Driver.FindElement(By.Id("save")).Click(); Assert.Contains("BTC Lightning settings successfully updated", s.FindAlertMessage().Text); s.GoToInvoiceCheckout(invoiceId); + s.Driver.WaitUntilAvailable(By.Id("Checkout-v2")); Assert.Contains("Sats", s.Driver.FindElement(By.Id("AmountDue")).Text); // Expire @@ -114,6 +131,7 @@ namespace BTCPayServer.Tests s.GoToHome(); invoiceId = s.CreateInvoice(); s.GoToInvoiceCheckout(invoiceId); + s.Driver.WaitUntilAvailable(By.Id("Checkout-v2")); // Details s.Driver.ToggleCollapse("PaymentDetails"); @@ -126,7 +144,7 @@ namespace BTCPayServer.Tests // Pay partial amount await Task.Delay(200); - var address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard"); + address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard"); var amountFraction = "0.00001"; await s.Server.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create(address, Network.RegTest), Money.Parse(amountFraction)); @@ -176,28 +194,55 @@ namespace BTCPayServer.Tests invoiceId = s.CreateInvoice(); s.GoToInvoiceCheckout(invoiceId); + s.Driver.WaitUntilAvailable(By.Id("Checkout-v2")); Assert.Empty(s.Driver.FindElements(By.CssSelector(".payment-method"))); + qrValue = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-qr-value"); + address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard"); payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href"); - Assert.StartsWith("bitcoin:", payUrl); + var copyAddressOnchain = s.Driver.FindElement(By.Id("Address_BTC")).GetAttribute("value"); + var copyAddressLightning = s.Driver.FindElement(By.Id("Lightning_BTC")).GetAttribute("value"); + Assert.StartsWith($"bitcoin:{address}?amount=", payUrl); + Assert.Contains("?amount=", payUrl); Assert.Contains("&lightning=", payUrl); + Assert.StartsWith("bcrt", copyAddressOnchain); + Assert.Equal(address, copyAddressOnchain); + Assert.StartsWith("lnbcrt", copyAddressLightning); + Assert.StartsWith($"bitcoin:{address.ToUpperInvariant()}?amount=", qrValue); + Assert.Contains("&lightning=LNBCRT", qrValue); // BIP21 with LN as default payment method s.GoToHome(); invoiceId = s.CreateInvoice(defaultPaymentMethod: "BTC_LightningLike"); s.GoToInvoiceCheckout(invoiceId); + s.Driver.WaitUntilAvailable(By.Id("Checkout-v2")); Assert.Empty(s.Driver.FindElements(By.CssSelector(".payment-method"))); payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href"); Assert.StartsWith("bitcoin:", payUrl); Assert.Contains("&lightning=lnbcrt", payUrl); - // BIP21 with topup invoice + // Ensure LNURL is enabled s.GoToHome(); + s.GoToLightningSettings(); + Assert.True(s.Driver.FindElement(By.Id("LNURLEnabled")).Selected); + Assert.True(s.Driver.FindElement(By.Id("LNURLStandardInvoiceEnabled")).Selected); + + // BIP21 with topup invoice invoiceId = s.CreateInvoice(amount: null); s.GoToInvoiceCheckout(invoiceId); + s.Driver.WaitUntilAvailable(By.Id("Checkout-v2")); Assert.Empty(s.Driver.FindElements(By.CssSelector(".payment-method"))); + qrValue = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-qr-value"); + address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard"); payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href"); - Assert.StartsWith("bitcoin:", payUrl); - Assert.DoesNotContain("&lightning=lnurl", payUrl); + copyAddressOnchain = s.Driver.FindElement(By.Id("Address_BTC")).GetAttribute("value"); + copyAddressLightning = s.Driver.FindElement(By.Id("Lightning_BTC")).GetAttribute("value"); + Assert.StartsWith($"bitcoin:{address}", payUrl); + Assert.Contains("?lightning=lnurl", payUrl); + Assert.DoesNotContain("amount=", payUrl); + Assert.StartsWith("bcrt", copyAddressOnchain); + Assert.Equal(address, copyAddressOnchain); + Assert.StartsWith("lnurl", copyAddressLightning); + Assert.StartsWith($"bitcoin:{address.ToUpperInvariant()}?lightning=LNURL", qrValue); // Expiry message should not show amount for topup invoice expirySeconds = s.Driver.FindElement(By.Id("ExpirySeconds")); @@ -223,6 +268,7 @@ namespace BTCPayServer.Tests Assert.Contains("Store successfully updated", s.FindAlertMessage().Text); s.GoToInvoiceCheckout(invoiceId); + s.Driver.WaitUntilAvailable(By.Id("Checkout-v2")); paymentInfo = s.Driver.FindElement(By.Id("PaymentInfo")); Assert.False(paymentInfo.Displayed); Assert.DoesNotContain("This invoice will expire in", paymentInfo.Text); diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index fece3f174..19fe0135d 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -1613,10 +1613,10 @@ namespace BTCPayServer.Tests var paymentMethodUnified = Assert.IsType( Assert.IsType(res).Model ); - Assert.StartsWith("bitcoin:", paymentMethodUnified.InvoiceBitcoinUrl); - Assert.StartsWith("bitcoin:", paymentMethodUnified.InvoiceBitcoinUrlQR); - Assert.Contains("&lightning=", paymentMethodUnified.InvoiceBitcoinUrl); - Assert.Contains("&lightning=", paymentMethodUnified.InvoiceBitcoinUrlQR); + Assert.StartsWith("bitcoin:bcrt", paymentMethodUnified.InvoiceBitcoinUrl); + Assert.StartsWith("bitcoin:BCRT", paymentMethodUnified.InvoiceBitcoinUrlQR); + Assert.Contains("&lightning=lnbcrt", paymentMethodUnified.InvoiceBitcoinUrl); + Assert.Contains("&lightning=LNBCRT", paymentMethodUnified.InvoiceBitcoinUrlQR); // Check correct casing: Addresses in payment URI need to be … // - lowercase in link version diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs index 0315df24e..11dabd3b3 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs @@ -655,7 +655,7 @@ namespace BTCPayServer.Controllers var enabledPaymentIds = store.GetEnabledPaymentIds(_NetworkProvider) .Where(pmId => storeBlob.CheckoutType == CheckoutType.V1 || // Exclude LNURL for Checkout v2 + non-top up invoices - (pmId.PaymentType is not LNURLPayPaymentType || invoice.IsUnsetTopUp())) + pmId != lnurlId || invoice.IsUnsetTopUp()) .ToArray(); // Exclude Lightning if OnChainWithLnInvoiceFallback is active and we have both payment methods @@ -805,7 +805,7 @@ namespace BTCPayServer.Controllers AvailableCryptos = invoice.GetPaymentMethods() .Where(i => i.Network != null && storeBlob.CheckoutType == CheckoutType.V1 || // Exclude LNURL for Checkout v2 + non-top up invoices - i.GetId().PaymentType is not LNURLPayPaymentType || invoice.IsUnsetTopUp()) + i.GetId() != lnurlId || invoice.IsUnsetTopUp()) .Select(kv => { var availableCryptoPaymentMethodId = kv.GetId(); diff --git a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs index 94ad08578..431cc983a 100644 --- a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs +++ b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs @@ -10,7 +10,6 @@ using BTCPayServer.Models; using BTCPayServer.Models.InvoicingModels; using BTCPayServer.Services; using BTCPayServer.Services.Invoices; -using Microsoft.AspNetCore.Http; using NBitcoin; using NBitcoin.DataEncoders; using NBXplorer.Models; @@ -44,7 +43,6 @@ namespace BTCPayServer.Payments.Bitcoin network => Encoders.ASCII.EncodeData( network.NBitcoinNetwork.GetBech32Encoder(Bech32Type.WITNESS_PUBKEY_ADDRESS, false) .HumanReadablePart)); - } class Prepare @@ -58,10 +56,11 @@ namespace BTCPayServer.Payments.Bitcoin StoreBlob storeBlob, IPaymentMethod paymentMethod) { var paymentMethodId = paymentMethod.GetId(); + var paymentMethodDetails = (BitcoinLikeOnChainPaymentMethod)paymentMethod.GetPaymentMethodDetails(); var cryptoInfo = invoiceResponse.CryptoInfo.First(o => o.GetpaymentMethodId() == paymentMethodId); var network = _networkProvider.GetNetwork(model.CryptoCode); model.ShowRecommendedFee = storeBlob.ShowRecommendedFee; - model.FeeRate = ((BitcoinLikeOnChainPaymentMethod)paymentMethod.GetPaymentMethodDetails()).GetFeeRate(); + model.FeeRate = paymentMethodDetails.GetFeeRate(); model.PaymentMethodName = GetPaymentMethodName(network); string lightningFallback = null; @@ -75,11 +74,20 @@ namespace BTCPayServer.Payments.Bitcoin } else { - var lnurl = invoiceResponse.CryptoInfo.FirstOrDefault(a => + var lnurlInfo = invoiceResponse.CryptoInfo.FirstOrDefault(a => a.GetpaymentMethodId() == new PaymentMethodId(model.CryptoCode, PaymentTypes.LNURLPay)); - if (lnurl is not null) + if (lnurlInfo is not null) { - lightningFallback = LNURL.LNURL.EncodeUri(new Uri(lnurl.Url), "payRequest", true).ToString(); + lightningFallback = lnurlInfo.PaymentUrls?.AdditionalData["LNURLP"].ToObject(); + + // This seems to be an edge case in the Selenium tests, in which the LNURLP isn't populated. + // I have come across it only in the tests and this is supposed to make them happy. + if (string.IsNullOrEmpty(lightningFallback)) + { + var serverUrl = new Uri(lnurlInfo.Url[..lnurlInfo.Url.IndexOf("/i/", StringComparison.InvariantCultureIgnoreCase)]); + var uri = new Uri($"{serverUrl}{network.CryptoCode}/lnurl/pay/i/{invoiceResponse.Id}"); + lightningFallback = LNURL.LNURL.EncodeUri(uri, "payRequest", true).ToString(); + } } } if (!string.IsNullOrEmpty(lightningFallback)) diff --git a/BTCPayServer/Payments/LNURLPay/LNURLPayPaymentHandler.cs b/BTCPayServer/Payments/LNURLPay/LNURLPayPaymentHandler.cs index 3f7c22748..abbfe1e07 100644 --- a/BTCPayServer/Payments/LNURLPay/LNURLPayPaymentHandler.cs +++ b/BTCPayServer/Payments/LNURLPay/LNURLPayPaymentHandler.cs @@ -41,6 +41,8 @@ namespace BTCPayServer.Payments.Lightning public override PaymentType PaymentType => PaymentTypes.LightningLike; + private const string UriScheme = "lightning:"; + public IOptions Options { get; } public override async Task CreatePaymentMethodDetails( @@ -107,12 +109,13 @@ namespace BTCPayServer.Payments.Lightning StoreBlob storeBlob, IPaymentMethod paymentMethod) { var paymentMethodId = paymentMethod.GetId(); - var cryptoInfo = invoiceResponse.CryptoInfo.First(o => o.GetpaymentMethodId() == paymentMethodId); var network = _networkProvider.GetNetwork(model.CryptoCode); + var cryptoInfo = invoiceResponse.CryptoInfo.First(o => o.GetpaymentMethodId() == paymentMethodId); + var lnurl = cryptoInfo.PaymentUrls?.AdditionalData["LNURLP"].ToObject(); model.PaymentMethodName = GetPaymentMethodName(network); - model.InvoiceBitcoinUrl = cryptoInfo.PaymentUrls?.AdditionalData["LNURLP"].ToObject(); - model.InvoiceBitcoinUrlQR = model.InvoiceBitcoinUrl; - model.BtcAddress = model.InvoiceBitcoinUrl; + model.BtcAddress = lnurl?.Replace(UriScheme, ""); + model.InvoiceBitcoinUrl = lnurl; + model.InvoiceBitcoinUrlQR = lnurl?.ToUpperInvariant().Replace(UriScheme.ToUpperInvariant(), UriScheme); model.PeerInfo = ((LNURLPayPaymentMethodDetails)paymentMethod.GetPaymentMethodDetails()).NodeInfo; if (storeBlob.LightningAmountInSatoshi && model.CryptoCode == "BTC") { diff --git a/BTCPayServer/Views/Shared/Bitcoin/BitcoinLikeMethodCheckout-v2.cshtml b/BTCPayServer/Views/Shared/Bitcoin/BitcoinLikeMethodCheckout-v2.cshtml index 879b3aece..7339dbb22 100644 --- a/BTCPayServer/Views/Shared/Bitcoin/BitcoinLikeMethodCheckout-v2.cshtml +++ b/BTCPayServer/Views/Shared/Bitcoin/BitcoinLikeMethodCheckout-v2.cshtml @@ -4,7 +4,7 @@