diff --git a/BTCPayServer.Tests/Checkoutv2Tests.cs b/BTCPayServer.Tests/Checkoutv2Tests.cs index 23be45b1c..6ff6c7293 100644 --- a/BTCPayServer.Tests/Checkoutv2Tests.cs +++ b/BTCPayServer.Tests/Checkoutv2Tests.cs @@ -61,14 +61,14 @@ namespace BTCPayServer.Tests Assert.Contains("LNURL", s.Driver.FindElement(By.CssSelector(".payment-method:nth-child(2)")).Text); var payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href"); Assert.StartsWith("bitcoin:", payUrl); - Assert.DoesNotContain("&lightning=", payUrl); + Assert.DoesNotContain("lightning=", payUrl); // Switch to LNURL s.Driver.FindElement(By.CssSelector(".payment-method:nth-child(2)")).Click(); TestUtils.Eventually(() => { payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href"); - Assert.StartsWith("lightning:", payUrl); + Assert.StartsWith("lightning:lnurl", payUrl); }); // Default payment method @@ -78,9 +78,9 @@ namespace BTCPayServer.Tests Assert.Equal(2, s.Driver.FindElements(By.CssSelector(".payment-method")).Count); Assert.Contains("Lightning", s.Driver.WaitForElement(By.CssSelector(".payment-method.active")).Text); - Assert.DoesNotContain("LNURL", s.Driver.PageSource); + Assert.Contains("Bitcoin", s.Driver.WaitForElement(By.CssSelector(".payment-method")).Text); payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href"); - Assert.StartsWith("lightning:", payUrl); + Assert.StartsWith("lightning:lnbcrt", payUrl); // Lightning amount in Sats Assert.Contains("BTC", s.Driver.FindElement(By.Id("AmountDue")).Text); @@ -188,17 +188,16 @@ namespace BTCPayServer.Tests 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=", payUrl); + Assert.Contains("&lightning=lnbcrt", payUrl); // BIP21 with topup invoice s.GoToHome(); invoiceId = s.CreateInvoice(amount: null); s.GoToInvoiceCheckout(invoiceId); - 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); + Assert.Empty(s.Driver.FindElements(By.CssSelector(".payment-method"))); payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href"); - Assert.DoesNotContain("&lightning=", payUrl); + Assert.StartsWith("bitcoin:", payUrl); + Assert.DoesNotContain("&lightning=lnurl", payUrl); // Expiry message should not show amount for topup invoice expirySeconds = s.Driver.FindElement(By.Id("ExpirySeconds")); diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs index 52ef6f907..0315df24e 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs @@ -649,12 +649,13 @@ namespace BTCPayServer.Controllers var storeBlob = store.GetStoreBlob(); var btcId = PaymentMethodId.Parse("BTC"); var lnId = PaymentMethodId.Parse("BTC_LightningLike"); + var lnurlId = PaymentMethodId.Parse("BTC_LNURLPAY"); if (paymentMethodId is null) { var enabledPaymentIds = store.GetEnabledPaymentIds(_NetworkProvider) - // Exclude LNURL for Checkout v2 + non-top up invoices - .Where(pmId => storeBlob.CheckoutType == CheckoutType.V1 || - pmId.PaymentType is not LNURLPayPaymentType || invoice.IsUnsetTopUp()) + .Where(pmId => storeBlob.CheckoutType == CheckoutType.V1 || + // Exclude LNURL for Checkout v2 + non-top up invoices + (pmId.PaymentType is not LNURLPayPaymentType || invoice.IsUnsetTopUp())) .ToArray(); // Exclude Lightning if OnChainWithLnInvoiceFallback is active and we have both payment methods @@ -802,11 +803,9 @@ namespace BTCPayServer.Controllers IsMultiCurrency = invoice.GetPayments(false).Select(p => p.GetPaymentMethodId()).Concat(new[] { paymentMethod.GetId() }).Distinct().Count() > 1, StoreId = store.Id, AvailableCryptos = invoice.GetPaymentMethods() - .Where(i => i.Network != null && + .Where(i => i.Network != null && storeBlob.CheckoutType == CheckoutType.V1 || // Exclude LNURL for Checkout v2 + non-top up invoices - (storeBlob.CheckoutType == CheckoutType.V1 || - i.GetId().PaymentType is not LNURLPayPaymentType || - invoice.IsUnsetTopUp())) + i.GetId().PaymentType is not LNURLPayPaymentType || invoice.IsUnsetTopUp()) .Select(kv => { var availableCryptoPaymentMethodId = kv.GetId(); @@ -836,10 +835,15 @@ namespace BTCPayServer.Controllers { var onchainPM = model.AvailableCryptos.Find(c => c.PaymentMethodId == btcId.ToString()); var lightningPM = model.AvailableCryptos.Find(c => c.PaymentMethodId == lnId.ToString()); + var lnurlPM = model.AvailableCryptos.Find(c => c.PaymentMethodId == lnurlId.ToString()); if (onchainPM != null && lightningPM != null) { model.AvailableCryptos.Remove(lightningPM); } + if (onchainPM != null && lnurlPM != null) + { + model.AvailableCryptos.Remove(lnurlPM); + } } paymentMethodHandler.PreparePaymentModel(model, dto, storeBlob, paymentMethod); diff --git a/BTCPayServer/Controllers/UIStoresController.cs b/BTCPayServer/Controllers/UIStoresController.cs index 72096605c..ec9e4dca5 100644 --- a/BTCPayServer/Controllers/UIStoresController.cs +++ b/BTCPayServer/Controllers/UIStoresController.cs @@ -504,11 +504,8 @@ namespace BTCPayServer.Controllers } blob.CheckoutType = model.UseNewCheckout ? Client.Models.CheckoutType.V2 : Client.Models.CheckoutType.V1; - if (blob.CheckoutType == Client.Models.CheckoutType.V2) - { - blob.OnChainWithLnInvoiceFallback = model.OnChainWithLnInvoiceFallback; - } + blob.OnChainWithLnInvoiceFallback = model.OnChainWithLnInvoiceFallback; blob.RequiresRefundEmail = model.RequiresRefundEmail; blob.LazyPaymentMethods = model.LazyPaymentMethods; blob.RedirectAutomatically = model.RedirectAutomatically; diff --git a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs index e8669541a..94ad08578 100644 --- a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs +++ b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentHandler.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using BTCPayServer.Client.Models; -using BTCPayServer.Common; using BTCPayServer.Data; using BTCPayServer.HostedServices; using BTCPayServer.Logging; @@ -11,6 +10,7 @@ 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; @@ -69,13 +69,24 @@ namespace BTCPayServer.Payments.Bitcoin { var lightningInfo = invoiceResponse.CryptoInfo.FirstOrDefault(a => a.GetpaymentMethodId() == new PaymentMethodId(model.CryptoCode, PaymentTypes.LightningLike)); - - - // Turn the colon into an equal sign to trun the whole into the lightning part of the query string - - // lightningInfo?.PaymentUrls?.BOLT11: lightning:lnbcrt440070n1p3ua9np... - lightningFallback = lightningInfo?.PaymentUrls?.BOLT11.Replace("lightning:", "lightning=", StringComparison.OrdinalIgnoreCase); - // lightningFallback: lightning=lnbcrt440070n1p3ua9np... + if (lightningInfo is not null && !string.IsNullOrEmpty(lightningInfo.PaymentUrls?.BOLT11)) + { + lightningFallback = lightningInfo.PaymentUrls.BOLT11; + } + else + { + var lnurl = invoiceResponse.CryptoInfo.FirstOrDefault(a => + a.GetpaymentMethodId() == new PaymentMethodId(model.CryptoCode, PaymentTypes.LNURLPay)); + if (lnurl is not null) + { + lightningFallback = LNURL.LNURL.EncodeUri(new Uri(lnurl.Url), "payRequest", true).ToString(); + } + } + if (!string.IsNullOrEmpty(lightningFallback)) + { + lightningFallback = lightningFallback + .Replace("lightning:", "lightning=", StringComparison.OrdinalIgnoreCase); + } } if (model.Activated) diff --git a/BTCPayServer/Plugins/NFC/NFCController.cs b/BTCPayServer/Plugins/NFC/NFCController.cs new file mode 100644 index 000000000..7bfd98f7a --- /dev/null +++ b/BTCPayServer/Plugins/NFC/NFCController.cs @@ -0,0 +1,183 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; +using BTCPayServer.Data.Payouts.LightningLike; +using BTCPayServer.Payments; +using BTCPayServer.Payments.Lightning; +using BTCPayServer.Services; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Stores; +using LNURL; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using NBitcoin; + +namespace BTCPayServer.Plugins.NFC +{ + [Route("plugins/NFC")] + public class NFCController : Controller + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly InvoiceRepository _invoiceRepository; + private readonly UILNURLController _uilnurlController; + private readonly InvoiceActivator _invoiceActivator; + private readonly StoreRepository _storeRepository; + + public NFCController(IHttpClientFactory httpClientFactory, + InvoiceRepository invoiceRepository, + UILNURLController uilnurlController, + InvoiceActivator invoiceActivator, + StoreRepository storeRepository) + { + _httpClientFactory = httpClientFactory; + _invoiceRepository = invoiceRepository; + _uilnurlController = uilnurlController; + _invoiceActivator = invoiceActivator; + _storeRepository = storeRepository; + } + + public class SubmitRequest + { + public string Lnurl { get; set; } + public string InvoiceId { get; set; } + public long? Amount { get; set; } + } + + [AllowAnonymous] + public async Task SubmitLNURLWithdrawForInvoice([FromBody] SubmitRequest request) + { + var invoice = await _invoiceRepository.GetInvoice(request.InvoiceId); + if (invoice?.Status is not InvoiceStatusLegacy.New) + { + return NotFound(); + } + + var methods = invoice.GetPaymentMethods(); + PaymentMethod lnPaymentMethod = null; + if (!methods.TryGetValue(new PaymentMethodId("BTC", PaymentTypes.LNURLPay), out var lnurlPaymentMethod) && + !methods.TryGetValue(new PaymentMethodId("BTC", PaymentTypes.LightningLike), out lnPaymentMethod)) + { + return BadRequest("destination for lnurlw was not specified"); + } + + Uri uri; + string tag; + try + { + uri = LNURL.LNURL.Parse(request.Lnurl, out tag); + if (uri is null) + { + return BadRequest("lnurl was malformed"); + } + } + catch (Exception e) + { + return BadRequest(e.Message); + } + + + if (!string.IsNullOrEmpty(tag) && !tag.Equals("withdrawRequest")) + { + return BadRequest("lnurl was not lnurl-withdraw"); + } + + var httpClient = _httpClientFactory.CreateClient(uri.IsOnion() + ? LightningLikePayoutHandler.LightningLikePayoutHandlerOnionNamedClient + : LightningLikePayoutHandler.LightningLikePayoutHandlerClearnetNamedClient); + var info = (await + LNURL.LNURL.FetchInformation(uri, "withdrawRequest", httpClient)) as LNURLWithdrawRequest; + if (info?.Callback is null) + { + return BadRequest("Could not fetch info from lnurl-withdraw "); + } + + httpClient = _httpClientFactory.CreateClient(info.Callback.IsOnion() + ? LightningLikePayoutHandler.LightningLikePayoutHandlerOnionNamedClient + : LightningLikePayoutHandler.LightningLikePayoutHandlerClearnetNamedClient); + + string bolt11 = null; + + if (lnPaymentMethod is not null) + { + if (lnPaymentMethod.GetPaymentMethodDetails() is LightningLikePaymentMethodDetails + { + Activated: false + } lnPMD) + { + var store = await _storeRepository.FindStore(invoice.StoreId); + await _invoiceActivator.ActivateInvoicePaymentMethod(lnPaymentMethod.GetId(), invoice, store); + } + + lnPMD = lnPaymentMethod.GetPaymentMethodDetails() as LightningLikePaymentMethodDetails; + Money due; + if (invoice.Type == InvoiceType.TopUp && request.Amount is not null) + { + due = new Money(request.Amount.Value, MoneyUnit.Satoshi); + }else if (invoice.Type == InvoiceType.TopUp) + { + return BadRequest("This is a topup invoice and you need to provide the amount in sats to pay."); + } + else + { + due = lnPaymentMethod.Calculate().Due; + } + if (info.MinWithdrawable > due || due > info.MaxWithdrawable) + { + return BadRequest("invoice amount is not payable with the lnurl allowed amounts."); + } + + if (lnPMD?.Activated is true) + { + bolt11 = lnPMD.BOLT11; + } + } + + if (lnurlPaymentMethod is not null) + { + Money due; + if (invoice.Type == InvoiceType.TopUp && request.Amount is not null) + { + due = new Money(request.Amount.Value, MoneyUnit.Satoshi); + }else if (invoice.Type == InvoiceType.TopUp) + { + return BadRequest("This is a topup invoice and you need to provide the amount in sats to pay."); + } + else + { + due = lnurlPaymentMethod.Calculate().Due; + } + + var response = await _uilnurlController.GetLNURLForInvoice(request.InvoiceId, "BTC", + due.Satoshi); + + if (response is ObjectResult objectResult) + { + switch (objectResult.Value) + { + case LNURLPayRequest.LNURLPayRequestCallbackResponse lnurlPayRequestCallbackResponse: + bolt11 = lnurlPayRequestCallbackResponse.Pr; + break; + case LNUrlStatusResponse lnUrlStatusResponse: + + return BadRequest( + $"Could not fetch bolt11 invoice to pay to: {lnUrlStatusResponse.Reason}"); + } + } + } + + if (bolt11 is null) + { + return BadRequest("Could not fetch bolt11 invoice to pay to."); + } + + var result = await info.SendRequest(bolt11, httpClient); + if (result.Status.Equals("ok", StringComparison.InvariantCultureIgnoreCase)) + { + return Ok(result.Reason); + } + + return BadRequest(result.Reason); + } + } +} diff --git a/BTCPayServer/Plugins/NFC/NFCPlugin.cs b/BTCPayServer/Plugins/NFC/NFCPlugin.cs new file mode 100644 index 000000000..22a7c8788 --- /dev/null +++ b/BTCPayServer/Plugins/NFC/NFCPlugin.cs @@ -0,0 +1,31 @@ +using BTCPayServer.Abstractions.Contracts; +using BTCPayServer.Abstractions.Models; +using BTCPayServer.Abstractions.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace BTCPayServer.Plugins.NFC +{ + public class NFCPlugin : BaseBTCPayServerPlugin + { + + public override string Identifier => "BTCPayServer.Plugins.NFC"; + public override string Name => "NFC"; + public override string Description => "Allows you to support contactless card payments over NFC and LNURL Withdraw!"; + + + public override void Execute(IServiceCollection applicationBuilder) + { + applicationBuilder.AddSingleton(new UIExtension("NFC/CheckoutEnd", + "checkout-end")); + applicationBuilder.AddSingleton(new UIExtension("NFC/LNURLNFCPostContent", + "checkout-lightning-post-content")); + applicationBuilder.AddSingleton(new UIExtension("NFC/CheckoutEnd", + "checkout-v2-end")); + applicationBuilder.AddSingleton(new UIExtension("NFC/LNURLNFCPostContent", + "checkout-v2-lightning-post-content")); + applicationBuilder.AddSingleton(new UIExtension("NFC/LNURLNFCPostContent", + "checkout-v2-bitcoin-post-content")); + base.Execute(applicationBuilder); + } + } +} diff --git a/BTCPayServer/Plugins/PluginManager.cs b/BTCPayServer/Plugins/PluginManager.cs index 5c52054a0..935704b7b 100644 --- a/BTCPayServer/Plugins/PluginManager.cs +++ b/BTCPayServer/Plugins/PluginManager.cs @@ -120,6 +120,12 @@ namespace BTCPayServer.Plugins foreach (var toLoad in pluginsToLoad) { + // This used to be a standalone plugin but due to popular demand has been made as part of core. If we detect an install, we remove the redundant plugin. + if (toLoad.PluginIdentifier == "BTCPayServer.Plugins.NFC") + { + QueueCommands(pluginsFolder, ("delete", toLoad.PluginIdentifier)); + continue; + } if (!loadedPluginIdentifiers.Add(toLoad.PluginIdentifier)) continue; try diff --git a/BTCPayServer/Views/Shared/Bitcoin/BitcoinLikeMethodCheckout-v2.cshtml b/BTCPayServer/Views/Shared/Bitcoin/BitcoinLikeMethodCheckout-v2.cshtml index 54825430b..879b3aece 100644 --- a/BTCPayServer/Views/Shared/Bitcoin/BitcoinLikeMethodCheckout-v2.cshtml +++ b/BTCPayServer/Views/Shared/Bitcoin/BitcoinLikeMethodCheckout-v2.cshtml @@ -2,6 +2,7 @@ @model BTCPayServer.Models.InvoicingModels.PaymentModel @@ -44,8 +46,8 @@ hasPayjoin () { return this.model.invoiceBitcoinUrl.indexOf('@PayjoinClient.BIP21EndpointKey=') !== -1; }, - BOLT11 () { - const match = this.model.invoiceBitcoinUrl.match(/&lightning=(.*)&?/i); + lightning () { + const match = this.model.invoiceBitcoinUrl.match(/[&?]lightning=(.*)&?/i); return match ? match[1].toLowerCase() : null; } } diff --git a/BTCPayServer/Views/Shared/Lightning/LightningLikeMethodCheckout-v2.cshtml b/BTCPayServer/Views/Shared/Lightning/LightningLikeMethodCheckout-v2.cshtml index 8a962dbf1..a558c6cec 100644 --- a/BTCPayServer/Views/Shared/Lightning/LightningLikeMethodCheckout-v2.cshtml +++ b/BTCPayServer/Views/Shared/Lightning/LightningLikeMethodCheckout-v2.cshtml @@ -2,6 +2,7 @@ diff --git a/BTCPayServer/Views/Shared/NFC/CheckoutEnd.cshtml b/BTCPayServer/Views/Shared/NFC/CheckoutEnd.cshtml new file mode 100644 index 000000000..4920bcadb --- /dev/null +++ b/BTCPayServer/Views/Shared/NFC/CheckoutEnd.cshtml @@ -0,0 +1,133 @@ +@using BTCPayServer.Abstractions.Extensions +@using Microsoft.AspNetCore.Mvc.TagHelpers +@using BTCPayServer.Abstractions.TagHelpers + + diff --git a/BTCPayServer/Views/Shared/NFC/LNURLNFCPostContent.cshtml b/BTCPayServer/Views/Shared/NFC/LNURLNFCPostContent.cshtml new file mode 100644 index 000000000..ebbd23bc7 --- /dev/null +++ b/BTCPayServer/Views/Shared/NFC/LNURLNFCPostContent.cshtml @@ -0,0 +1,3 @@ + + + diff --git a/BTCPayServer/Views/UIInvoice/CheckoutV2.cshtml b/BTCPayServer/Views/UIInvoice/CheckoutV2.cshtml index 4c8f2127f..6416fc6dc 100644 --- a/BTCPayServer/Views/UIInvoice/CheckoutV2.cshtml +++ b/BTCPayServer/Views/UIInvoice/CheckoutV2.cshtml @@ -47,7 +47,7 @@
@Model.ItemDesc
@if (Model.IsUnsetTopUp) { -

+

} else { @@ -237,5 +237,6 @@ } @await Component.InvokeAsync("UiExtensionPoint", new { location = "checkout-payment", model = Model }) + @await Component.InvokeAsync("UiExtensionPoint", new { location = "checkout-v2-end", model = Model }) diff --git a/BTCPayServer/Views/UIStores/CheckoutAppearance.cshtml b/BTCPayServer/Views/UIStores/CheckoutAppearance.cshtml index d6e356f75..b339ed235 100644 --- a/BTCPayServer/Views/UIStores/CheckoutAppearance.cshtml +++ b/BTCPayServer/Views/UIStores/CheckoutAppearance.cshtml @@ -10,7 +10,7 @@ } @section PageFootContent { - +