diff --git a/BTCPayServer.Data/Data/LightingAddressData.cs b/BTCPayServer.Data/Data/LightingAddressData.cs index 6ee9c5d56..400d64be5 100644 --- a/BTCPayServer.Data/Data/LightingAddressData.cs +++ b/BTCPayServer.Data/Data/LightingAddressData.cs @@ -1,6 +1,7 @@ using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; +using Newtonsoft.Json.Linq; namespace BTCPayServer.Data; @@ -38,4 +39,6 @@ public class LightningAddressDataBlob public string CurrencyCode { get; set; } public decimal? Min { get; set; } public decimal? Max { get; set; } + + public JObject InvoiceMetadata { get; set; } } diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index 024446183..51f41c996 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -4,7 +4,6 @@ using System.Globalization; using System.Linq; using System.Net; using System.Net.Http; -using System.Reflection.Metadata; using System.Text; using System.Text.RegularExpressions; using System.Threading; @@ -17,7 +16,6 @@ using BTCPayServer.Lightning; using BTCPayServer.Payments; using BTCPayServer.Services; using BTCPayServer.Services.Invoices; -using BTCPayServer.Services.Rates; using BTCPayServer.Services.Wallets; using BTCPayServer.Views.Manage; using BTCPayServer.Views.Server; @@ -29,6 +27,7 @@ using Microsoft.EntityFrameworkCore; using NBitcoin; using NBitcoin.DataEncoders; using NBitcoin.Payment; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using OpenQA.Selenium; using OpenQA.Selenium.Support.Extensions; @@ -2256,13 +2255,16 @@ namespace BTCPayServer.Tests s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success); s.Driver.ToggleCollapse("AddAddress"); + var lnaddress2 = "EUR" + Guid.NewGuid().ToString(); s.Driver.FindElement(By.Id("Add_Username")).SendKeys(lnaddress2); + lnaddress2 = lnaddress2.ToLowerInvariant(); s.Driver.ToggleCollapse("AdvancedSettings"); s.Driver.FindElement(By.Id("Add_CurrencyCode")).SendKeys("EUR"); s.Driver.FindElement(By.Id("Add_Min")).SendKeys("2"); s.Driver.FindElement(By.Id("Add_Max")).SendKeys("10"); + s.Driver.FindElement(By.Id("Add_InvoiceMetadata")).SendKeys("{\"test\":\"lol\"}"); s.Driver.FindElement(By.CssSelector("button[value='add']")).Click(); s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success); @@ -2278,20 +2280,100 @@ namespace BTCPayServer.Tests var lnurl = new Uri(LNURL.LNURL.ExtractUriFromInternetIdentifier(value).ToString() .Replace("https", "http")); var request = (LNURL.LNURLPayRequest)await LNURL.LNURL.FetchInformation(lnurl, new HttpClient()); - + var m = request.ParsedMetadata.ToDictionary(o => o.Key, o => o.Value); switch (value) { case { } v when v.StartsWith(lnaddress2): + Assert.StartsWith(lnaddress2 + "@", m["text/identifier"]); + lnaddress2 = m["text/identifier"]; Assert.Equal(2, request.MinSendable.ToDecimal(LightMoneyUnit.Satoshi)); Assert.Equal(10, request.MaxSendable.ToDecimal(LightMoneyUnit.Satoshi)); break; case { } v when v.StartsWith(lnaddress1): + Assert.StartsWith(lnaddress1 + "@", m["text/identifier"]); + lnaddress1 = m["text/identifier"]; Assert.Equal(1, request.MinSendable.ToDecimal(LightMoneyUnit.Satoshi)); Assert.Equal(6.12m, request.MaxSendable.ToDecimal(LightMoneyUnit.BTC)); break; + default: + Assert.False(true, "Should have matched"); + break; } } + var repo = s.Server.PayTester.GetService(); + var invoices = await repo.GetInvoices(new InvoiceQuery() { StoreId = new[] { s.StoreId } }); + Assert.Equal(2, invoices.Length); + var emailSuffix = $"@{s.Server.PayTester.HostName}:{s.Server.PayTester.Port}"; + + foreach (var i in invoices) + { + var lightningPaymentMethod = i.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.LNURLPay)); + var paymentMethodDetails = + lightningPaymentMethod.GetPaymentMethodDetails() as LNURLPayPaymentMethodDetails; + Assert.Contains( + paymentMethodDetails.ConsumedLightningAddress, + new[] { lnaddress1, lnaddress2 }); + + if (paymentMethodDetails.ConsumedLightningAddress == lnaddress2) + { + Assert.Equal("lol", i.Metadata.AdditionalData["test"].Value()); + } + } + + var lnUsername = lnaddress1.Split('@')[0]; + LNURLPayRequest req; + using (var resp = await s.Server.PayTester.HttpClient.GetAsync($"/.well-known/lnurlp/{lnUsername}")) + { + var str = await resp.Content.ReadAsStringAsync(); + req = JsonConvert.DeserializeObject(str); + Assert.Contains(req.ParsedMetadata, m => m.Key == "text/identifier" && m.Value == lnaddress1); + Assert.Contains(req.ParsedMetadata, m => m.Key == "text/plain" && m.Value.StartsWith("Paid to")); + Assert.NotNull(req.Callback); + Assert.Equal(new LightMoney(1000), req.MinSendable); + Assert.Equal(LightMoney.FromUnit(6.12m, LightMoneyUnit.BTC), req.MaxSendable); + } + lnUsername = lnaddress2.Split('@')[0]; + using (var resp = await s.Server.PayTester.HttpClient.GetAsync($"/.well-known/lnurlp/{lnUsername}")) + { + var str = await resp.Content.ReadAsStringAsync(); + req = JsonConvert.DeserializeObject(str); + Assert.Equal(new LightMoney(2000), req.MinSendable); + Assert.Equal(new LightMoney(10_000), req.MaxSendable); + } + // Check if we can get the same payrequest through the callback + using (var resp = await s.Server.PayTester.HttpClient.GetAsync(req.Callback)) + { + var str = await resp.Content.ReadAsStringAsync(); + req = JsonConvert.DeserializeObject(str); + Assert.Equal(new LightMoney(2000), req.MinSendable); + Assert.Equal(new LightMoney(10_000), req.MaxSendable); + } + + // Can we ask for invoice? (Should fail, below minSpendable) + using (var resp = await s.Server.PayTester.HttpClient.GetAsync(req.Callback + "?amount=1999")) + { + var str = await resp.Content.ReadAsStringAsync(); + var err = JsonConvert.DeserializeObject(str); + Assert.Equal("Amount is out of bounds.", err.Reason); + } + // Can we ask for invoice? + using (var resp = await s.Server.PayTester.HttpClient.GetAsync(req.Callback + "?amount=2000")) + { + var str = await resp.Content.ReadAsStringAsync(); + var succ = JsonConvert.DeserializeObject(str); + Assert.NotNull(succ.Pr); + Assert.Equal(new LightMoney(2000), BOLT11PaymentRequest.Parse(succ.Pr, Network.RegTest).MinimumAmount); + } + + // Can we change comment? + using (var resp = await s.Server.PayTester.HttpClient.GetAsync(req.Callback + "?amount=2001")) + { + var str = await resp.Content.ReadAsStringAsync(); + var succ = JsonConvert.DeserializeObject(str); + Assert.NotNull(succ.Pr); + Assert.Equal(new LightMoney(2001), BOLT11PaymentRequest.Parse(succ.Pr, Network.RegTest).MinimumAmount); + } } [Fact] diff --git a/BTCPayServer/Controllers/UILNURLController.cs b/BTCPayServer/Controllers/UILNURLController.cs index d6673a785..d5cd33c1c 100644 --- a/BTCPayServer/Controllers/UILNURLController.cs +++ b/BTCPayServer/Controllers/UILNURLController.cs @@ -35,6 +35,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using NBitcoin; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using LightningAddressData = BTCPayServer.Data.LightningAddressData; using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest; @@ -244,17 +245,6 @@ namespace BTCPayServer return NotFound(); } - var pmi = new PaymentMethodId(cryptoCode, PaymentTypes.LNURLPay); - var lnpmi = new PaymentMethodId(cryptoCode, PaymentTypes.LightningLike); - var methods = store.GetSupportedPaymentMethods(_btcPayNetworkProvider); - var lnUrlMethod = - methods.FirstOrDefault(method => method.PaymentId == pmi) as LNURLPaySupportedPaymentMethod; - var lnMethod = methods.FirstOrDefault(method => method.PaymentId == lnpmi); - if (lnUrlMethod is null || lnMethod is null) - { - return NotFound(); - } - ViewPointOfSaleViewModel.Item[] items; string currencyCode; PointOfSaleSettings posS = null; @@ -278,6 +268,9 @@ namespace BTCPayServer ViewPointOfSaleViewModel.Item item = null; if (!string.IsNullOrEmpty(itemCode)) { + var pmi = GetLNUrlPaymentMethodId(cryptoCode, store, out _); + if (pmi is null) + return NotFound("LNUrl or LN is disabled"); var escapedItemId = Extensions.UnescapeBackSlashUriString(itemCode); item = items.FirstOrDefault(item1 => item1.Id.Equals(itemCode, StringComparison.InvariantCultureIgnoreCase) || @@ -295,9 +288,39 @@ namespace BTCPayServer { return NotFound(); } - - return await GetLNURL(cryptoCode, app.StoreDataId, currencyCode, null, null, - () => (null, app, item, new List { AppService.GetAppInternalTag(appId) }, item?.Price.Value, true)); + + var createInvoice = new CreateInvoiceRequest() + { + Amount = item?.Price.Value, + Currency = currencyCode, + Checkout = new InvoiceDataBase.CheckoutOptions() + { + RedirectURL = app.AppType switch + { + PointOfSaleAppType.AppType => app.GetSettings().RedirectUrl ?? + HttpContext.Request.GetAbsoluteUri($"/apps/{app.Id}/pos"), + _ => null + } + } + }; + + var invoiceMetadata = new InvoiceMetadata(); + invoiceMetadata.OrderId =AppService.GetAppOrderId(app); + if (item != null) + { + invoiceMetadata.ItemCode = item.Id; + invoiceMetadata.ItemDesc = item.Description; + } + createInvoice.Metadata = invoiceMetadata.ToJObject(); + + + return await GetLNURLRequest( + cryptoCode, + store, + store.GetStoreBlob(), + createInvoice, + additionalTags: new List { AppService.GetAppInternalTag(appId) }, + allowOverpay: false); } public class EditLightningAddressVM @@ -327,6 +350,9 @@ namespace BTCPayServer [Display(Name = "Max sats")] [Range(1, double.PositiveInfinity)] public decimal? Max { get; set; } + + [Display(Name = "Invoice metadata")] + public string InvoiceMetadata { get; set; } } public ConcurrentDictionary Items { get; } = new (); @@ -344,111 +370,103 @@ namespace BTCPayServer public async Task ResolveLightningAddress(string username) { var lightningAddressSettings = await _lightningAddressService.ResolveByAddress(username); - if (lightningAddressSettings is null) - { + if (lightningAddressSettings is null || username is null) + return NotFound("Unknown username"); + + var store = await _storeRepository.FindStore(lightningAddressSettings.StoreDataId); + if (store is null) return NotFound("Unknown username"); - } var blob = lightningAddressSettings.GetBlob(); - return await GetLNURL("BTC", lightningAddressSettings.StoreDataId, blob.CurrencyCode, blob.Min, blob.Max, - () => (username, null, null, null, null, true)); + + return await GetLNURLRequest( + "BTC", + store, + store.GetStoreBlob(), + new CreateInvoiceRequest() + { + Currency = blob?.CurrencyCode, + Metadata = blob?.InvoiceMetadata + }, + new LNURLPayRequest() + { + MinSendable = blob?.Min is decimal min ? new LightMoney(min, LightMoneyUnit.Satoshi) : null, + MaxSendable = blob?.Max is decimal max ? new LightMoney(max, LightMoneyUnit.Satoshi) : null, + }, + new Dictionary() + { + { "text/identifier", $"{username}@{Request.Host}" } + }); } + [HttpGet("pay")] - public async Task GetLNURL(string cryptoCode, string storeId, string currencyCode = null, - decimal? min = null, decimal? max = null, - Func<(string username, AppData app, ViewPointOfSaleViewModel.Item item, List additionalTags, decimal? invoiceAmount, bool? anyoneCanInvoice)> - internalDetails = null) + [EnableCors(CorsPolicies.All)] + [IgnoreAntiforgeryToken] + public async Task GetLNUrlForStore( + string cryptoCode, + string storeId, + string currencyCode = null) { - var network = _btcPayNetworkProvider.GetNetwork(cryptoCode); - if (network is null || !network.SupportLightning) - { - return NotFound("This network does not support Lightning"); - } - - var store = await _storeRepository.FindStore(storeId); + var store = this.HttpContext.GetStoreData(); if (store is null) - { - return NotFound("Store not found"); - } - - var storeBlob = store.GetStoreBlob(); - currencyCode ??= storeBlob.DefaultCurrency ?? cryptoCode; - var pmi = new PaymentMethodId(cryptoCode, PaymentTypes.LNURLPay); - var lnpmi = new PaymentMethodId(cryptoCode, PaymentTypes.LightningLike); - var methods = store.GetSupportedPaymentMethods(_btcPayNetworkProvider); - var lnUrlMethod = - methods.FirstOrDefault(method => method.PaymentId == pmi) as LNURLPaySupportedPaymentMethod; - var lnMethod = methods.FirstOrDefault(method => method.PaymentId == lnpmi); - if (lnUrlMethod is null || lnMethod is null) - { - return NotFound("LNURL or Lightning payment method not found"); - } + return NotFound(); var blob = store.GetStoreBlob(); - if (blob.GetExcludedPaymentMethods().Match(pmi) || blob.GetExcludedPaymentMethods().Match(lnpmi)) - { - return NotFound("LNURL or Lightning payment method disabled"); - } - - (string username, AppData app, ViewPointOfSaleViewModel.Item item, List additionalTags, decimal? invoiceAmount, bool? anyoneCanInvoice) = - (internalDetails ?? (() => (null, null, null, null, null, null)))(); - - if ((anyoneCanInvoice ?? blob.AnyoneCanInvoice) is false) - { - return NotFound(); - } - - var lnAddress = username is null ? null : $"{username}@{Request.Host}"; - List lnurlMetadata = new(); - - var redirectUrl = app?.AppType switch - { - PointOfSaleAppType.AppType => app.GetSettings().RedirectUrl ?? - HttpContext.Request.GetAbsoluteUri($"/apps/{app.Id}/pos"), - _ => null - }; - var invoiceRequest = new CreateInvoiceRequest - { - Amount = invoiceAmount, - Checkout = new InvoiceDataBase.CheckoutOptions + if (!blob.AnyoneCanInvoice) + return NotFound("'Anyone can invoice' is turned off"); + return await GetLNURLRequest( + cryptoCode, + store, + blob, + new CreateInvoiceRequest { - PaymentMethods = new[] { pmi.ToStringNormalized() }, - Expiration = blob.InvoiceExpiration < TimeSpan.FromMinutes(2) - ? blob.InvoiceExpiration - : TimeSpan.FromMinutes(2), - RedirectURL = redirectUrl - }, - Currency = currencyCode, - Type = invoiceAmount is null ? InvoiceType.TopUp : InvoiceType.Standard, - }; + Currency = currencyCode + }); + } + + private async Task GetLNURLRequest( + string cryptoCode, + Data.StoreData store, + Data.StoreBlob blob, + CreateInvoiceRequest createInvoice, + LNURLPayRequest lnurlRequest = null, + Dictionary lnUrlMetadata = null, + List additionalTags = null, + bool allowOverpay = true) + { + if (GetLNUrlPaymentMethodId(cryptoCode, store, out _) is null) + return NotFound("LNUrl or LN is disabled"); - if (item != null) - { - invoiceRequest.Metadata = - new InvoiceMetadata - { - ItemCode = item.Id, - ItemDesc = item.Description, - OrderId = AppService.GetAppOrderId(app) - }.ToJObject(); - } InvoiceEntity i; try { - i = await _invoiceController.CreateInvoiceCoreRaw(invoiceRequest, store, Request.GetAbsoluteRoot(), additionalTags); + i = await _invoiceController.CreateInvoiceCoreRaw(createInvoice, store, Request.GetAbsoluteRoot(), additionalTags); } catch (Exception e) { return this.CreateAPIError(null, e.Message); } - if (i.Type != InvoiceType.TopUp) - { - min = i.GetPaymentMethod(pmi).Calculate().Due.ToDecimal(MoneyUnit.Satoshi); - max = item?.Price?.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Minimum ? null : min; - } + lnurlRequest = await CreateLNUrlRequestFromInvoice(cryptoCode, i, store, blob, lnurlRequest, lnUrlMetadata, allowOverpay); + return lnurlRequest is null ? NotFound() : Ok(lnurlRequest); + } - if (!string.IsNullOrEmpty(username)) + private async Task CreateLNUrlRequestFromInvoice( + string cryptoCode, + InvoiceEntity i, + Data.StoreData store, + StoreBlob blob, + LNURLPayRequest lnurlRequest = null, + Dictionary lnUrlMetadata = null, + bool allowOverpay = true) + { + var pmi = GetLNUrlPaymentMethodId(cryptoCode, store, out var lnUrlMethod); + if (pmi is null) + return null; + lnurlRequest ??= new LNURLPayRequest(); + lnUrlMetadata ??= new Dictionary(); + + if (lnUrlMetadata?.TryGetValue("text/identifier", out var lnAddress) is true && lnAddress is string) { var pm = i.GetPaymentMethod(pmi); var paymentMethodDetails = (LNURLPayPaymentMethodDetails)pm.GetPaymentMethodDetails(); @@ -457,36 +475,68 @@ namespace BTCPayServer await _invoiceRepository.UpdateInvoicePaymentMethod(i.Id, pm); } - var invoiceDescription = blob.LightningDescriptionTemplate - .Replace("{StoreName}", store.StoreName ?? "", StringComparison.OrdinalIgnoreCase) - .Replace("{ItemDescription}", i.Metadata.ItemDesc ?? "", StringComparison.OrdinalIgnoreCase) - .Replace("{OrderId}", i.Metadata.OrderId ?? "", StringComparison.OrdinalIgnoreCase); - - lnurlMetadata.Add(new[] { "text/plain", invoiceDescription }); - if (!string.IsNullOrEmpty(username)) + if (!lnUrlMetadata.ContainsKey("text/plain")) { - lnurlMetadata.Add(new[] { "text/identifier", lnAddress }); + var invoiceDescription = blob.LightningDescriptionTemplate + .Replace("{StoreName}", store.StoreName ?? "", StringComparison.OrdinalIgnoreCase) + .Replace("{ItemDescription}", i.Metadata.ItemDesc ?? "", StringComparison.OrdinalIgnoreCase) + .Replace("{OrderId}", i.Metadata.OrderId ?? "", StringComparison.OrdinalIgnoreCase); + lnUrlMetadata.Add("text/plain", invoiceDescription); } - if (await _pluginHookService.ApplyFilter("modify-lnurlp-request", new LNURLPayRequest - { - Tag = "payRequest", - MinSendable = new LightMoney(min ?? 1m, LightMoneyUnit.Satoshi), - MaxSendable = - max is null - ? LightMoney.FromUnit(6.12m, LightMoneyUnit.BTC) - : new LightMoney(max.Value, LightMoneyUnit.Satoshi), - CommentAllowed = lnUrlMethod.LUD12Enabled ? 2000 : 0, - Metadata = JsonConvert.SerializeObject(lnurlMetadata), - Callback = new Uri(_linkGenerator.GetUriByAction( + lnurlRequest.Tag = "payRequest"; + lnurlRequest.CommentAllowed = lnUrlMethod.LUD12Enabled ? 2000 : 0; + lnurlRequest.Callback = new Uri(_linkGenerator.GetUriByAction( action: nameof(GetLNURLForInvoice), controller: "UILNURL", - values: new {cryptoCode, invoiceId = i.Id}, Request.Scheme, Request.Host, Request.PathBase)) - }) is not LNURLPayRequest lnurlp) + values: new { pmi.CryptoCode, invoiceId = i.Id }, Request.Scheme, Request.Host, Request.PathBase)); + lnurlRequest.Metadata = JsonConvert.SerializeObject(lnUrlMetadata.Select(kv => new[] { kv.Key, kv.Value })); + if (i.Type != InvoiceType.TopUp) { - return NotFound(); + lnurlRequest.MinSendable = new LightMoney(i.GetPaymentMethod(pmi).Calculate().Due.ToDecimal(MoneyUnit.Satoshi), LightMoneyUnit.Satoshi); + if (!allowOverpay) + lnurlRequest.MaxSendable = lnurlRequest.MinSendable; } - return Ok(lnurlp); + + // We don't think BTCPay handle well 0 sats payments, just in case make it minimum one sat. + if (lnurlRequest.MinSendable is null || lnurlRequest.MinSendable < LightMoney.Satoshis(1.0m)) + lnurlRequest.MinSendable = LightMoney.Satoshis(1.0m); + + if (lnurlRequest.MaxSendable is null) + lnurlRequest.MaxSendable = LightMoney.FromUnit(6.12m, LightMoneyUnit.BTC); + + lnurlRequest = await _pluginHookService.ApplyFilter("modify-lnurlp-request", lnurlRequest) as LNURLPayRequest; + + i.Metadata ??= new InvoiceMetadata(); + var metadata = i.Metadata.ToJObject(); + if (metadata.Property("payRequest") is null) + { + metadata.Add("payRequest", JToken.FromObject(lnurlRequest)); + await _invoiceRepository.UpdateInvoiceMetadata(i.Id, i.StoreId, metadata); + } + + return lnurlRequest; + } + + PaymentMethodId GetLNUrlPaymentMethodId(string cryptoCode, Data.StoreData store, out LNURLPaySupportedPaymentMethod lnUrlSettings) + { + lnUrlSettings = null; + var network = _btcPayNetworkProvider.GetNetwork(cryptoCode); + if (network is null || !network.SupportLightning) + return null; + var pmi = new PaymentMethodId(cryptoCode, PaymentTypes.LNURLPay); + var lnpmi = new PaymentMethodId(cryptoCode, PaymentTypes.LightningLike); + var methods = store.GetSupportedPaymentMethods(_btcPayNetworkProvider); + var lnUrlMethod = + methods.FirstOrDefault(method => method.PaymentId == pmi) as LNURLPaySupportedPaymentMethod; + var lnMethod = methods.FirstOrDefault(method => method.PaymentId == lnpmi); + if (lnUrlMethod is null || lnMethod is null) + return null; + var blob = store.GetStoreBlob(); + if (blob.GetExcludedPaymentMethods().Match(pmi) || blob.GetExcludedPaymentMethods().Match(lnpmi)) + return null; + lnUrlSettings = lnUrlMethod; + return pmi; } [HttpGet("pay/i/{invoiceId}")] @@ -501,61 +551,46 @@ namespace BTCPayServer return NotFound(); } - if (comment is not null) - comment = comment.Truncate(2000); - - var pmi = new PaymentMethodId(cryptoCode, PaymentTypes.LNURLPay); var i = await _invoiceRepository.GetInvoice(invoiceId, true); + if (i is null) + return NotFound(); var store = await _storeRepository.FindStore(i.StoreId); if (store is null) - { return NotFound(); - } if (i.Status == InvoiceStatusLegacy.New) { - var isTopup = i.IsUnsetTopUp(); - var lnurlSupportedPaymentMethod = - i.GetSupportedPaymentMethod(pmi).FirstOrDefault(); - if (lnurlSupportedPaymentMethod is null) - { + var pmi = GetLNUrlPaymentMethodId(cryptoCode, store, out var lnurlSupportedPaymentMethod); + if (pmi is null) return NotFound(); - } var lightningPaymentMethod = i.GetPaymentMethod(pmi); - var accounting = lightningPaymentMethod.Calculate(); var paymentMethodDetails = lightningPaymentMethod.GetPaymentMethodDetails() as LNURLPayPaymentMethodDetails; if (paymentMethodDetails.LightningSupportedPaymentMethod is null) - { return NotFound(); - } - - var amt = amount.HasValue ? new LightMoney(amount.Value) : null; - var min = new LightMoney(isTopup ? 1m : accounting.Due.ToUnit(MoneyUnit.Satoshi), LightMoneyUnit.Satoshi); - var max = isTopup ? LightMoney.FromUnit(6.12m, LightMoneyUnit.BTC) : min; - - List lnurlMetadata = new(); + LNURLPayRequest lnurlPayRequest; var blob = store.GetStoreBlob(); - var invoiceDescription = blob.LightningDescriptionTemplate - .Replace("{StoreName}", store.StoreName ?? "", StringComparison.OrdinalIgnoreCase) - .Replace("{ItemDescription}", i.Metadata.ItemDesc ?? "", StringComparison.OrdinalIgnoreCase) - .Replace("{OrderId}", i.Metadata.OrderId ?? "", StringComparison.OrdinalIgnoreCase); - - lnurlMetadata.Add(new[] { "text/plain", invoiceDescription }); - if (!string.IsNullOrEmpty(paymentMethodDetails.ConsumedLightningAddress)) + if (i.Metadata.AdditionalData.TryGetValue("payRequest", out var t) && t is JObject jo) { - lnurlMetadata.Add(new[] { "text/identifier", paymentMethodDetails.ConsumedLightningAddress }); + lnurlPayRequest = jo.ToObject(); + } + else + { + lnurlPayRequest = await CreateLNUrlRequestFromInvoice(cryptoCode, i, store, blob, allowOverpay: false); + if (lnurlPayRequest is null) + return NotFound(); } - var metadata = JsonConvert.SerializeObject(lnurlMetadata); - if (amt != null && (amt < min || amount > max)) - { + if (amount is null) + return Ok(lnurlPayRequest); + + var amt = new LightMoney(amount.Value); + if (amt < lnurlPayRequest.MinSendable || amount > lnurlPayRequest.MaxSendable) return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Amount is out of bounds." }); - } - + LNURLPayRequest.LNURLPayRequestCallbackResponse.ILNURLPayRequestSuccessAction successAction = null; if ((i.ReceiptOptions?.Enabled ?? blob.ReceiptOptions.Enabled) is true) { @@ -565,7 +600,7 @@ namespace BTCPayServer Tag = "url", Description = "Thank you for your purchase. Here is your receipt", Url = _linkGenerator.GetUriByAction( - nameof(UIInvoiceController.InvoiceReceipt), + nameof(UIInvoiceController.InvoiceReceipt), "UIInvoice", new { invoiceId }, Request.Scheme, @@ -574,22 +609,15 @@ namespace BTCPayServer }; } - if (amt is null) + bool updatePaymentMethod = false; + if (lnurlSupportedPaymentMethod.LUD12Enabled) { - if (await _pluginHookService.ApplyFilter("modify-lnurlp-request", new LNURLPayRequest - { - Tag = "payRequest", - MinSendable = min, - MaxSendable = max, - CommentAllowed = lnurlSupportedPaymentMethod.LUD12Enabled ? 2000 : 0, - Metadata = metadata, - Callback = new Uri(Request.GetCurrentUrl()) - }) is not LNURLPayRequest lnurlp) + comment = comment?.Truncate(2000); + if (paymentMethodDetails.ProvidedComment != comment) { - return NotFound(); + paymentMethodDetails.ProvidedComment = comment; + updatePaymentMethod = true; } - - return Ok(lnurlp); } if (string.IsNullOrEmpty(paymentMethodDetails.BOLT11) || paymentMethodDetails.GeneratedBoltAmount != amt) @@ -613,11 +641,11 @@ namespace BTCPayServer try { var expiry = i.ExpirationTime.ToUniversalTime() - DateTimeOffset.UtcNow; + var metadata = JsonConvert.SerializeObject(lnurlPayRequest.Metadata); var description = (await _pluginHookService.ApplyFilter("modify-lnurlp-description", metadata)) as string; if (description is null) - { return NotFound(); - } + var param = new CreateInvoiceParams(amt, description, expiry) { PrivateRouteHints = blob.LightningPrivateRouteHints, @@ -649,42 +677,25 @@ namespace BTCPayServer paymentMethodDetails.Preimage = string.IsNullOrEmpty(invoice.Preimage) ? null : uint256.Parse(invoice.Preimage); paymentMethodDetails.InvoiceId = invoice.Id; paymentMethodDetails.GeneratedBoltAmount = amt; - if (lnurlSupportedPaymentMethod.LUD12Enabled) - { - paymentMethodDetails.ProvidedComment = comment; - } - - lightningPaymentMethod.SetPaymentMethodDetails(paymentMethodDetails); - await _invoiceRepository.UpdateInvoicePaymentMethod(invoiceId, lightningPaymentMethod); + updatePaymentMethod = true; _eventAggregator.Publish(new InvoiceNewPaymentDetailsEvent(invoiceId, paymentMethodDetails, pmi)); - return Ok(new LNURLPayRequest.LNURLPayRequestCallbackResponse - { - Disposable = true, - Routes = Array.Empty(), - Pr = paymentMethodDetails.BOLT11, - SuccessAction = successAction - }); } - if (paymentMethodDetails.GeneratedBoltAmount == amt) + if (updatePaymentMethod) { - if (lnurlSupportedPaymentMethod.LUD12Enabled && paymentMethodDetails.ProvidedComment != comment) - { - paymentMethodDetails.ProvidedComment = comment; - lightningPaymentMethod.SetPaymentMethodDetails(paymentMethodDetails); - await _invoiceRepository.UpdateInvoicePaymentMethod(invoiceId, lightningPaymentMethod); - } - - return Ok(new LNURLPayRequest.LNURLPayRequestCallbackResponse - { - Disposable = true, - Routes = Array.Empty(), - Pr = paymentMethodDetails.BOLT11, - SuccessAction = successAction - }); + lightningPaymentMethod.SetPaymentMethodDetails(paymentMethodDetails); + await _invoiceRepository.UpdateInvoicePaymentMethod(invoiceId, lightningPaymentMethod); } + + return Ok(new LNURLPayRequest.LNURLPayRequestCallbackResponse + { + Disposable = true, + Routes = Array.Empty(), + Pr = paymentMethodDetails.BOLT11, + SuccessAction = successAction + }); } return BadRequest(new LNUrlStatusResponse @@ -725,6 +736,7 @@ namespace BTCPayServer CurrencyCode = blob.CurrencyCode, StoreId = storeId, Username = s.Username, + InvoiceMetadata = blob.InvoiceMetadata?.ToString(Formatting.Indented) }; } ).ToList() @@ -746,6 +758,18 @@ namespace BTCPayServer vm.AddModelError(addressVm => addressVm.Add.CurrencyCode, "Currency is invalid", this); } + JObject metadata = null; + if (!string.IsNullOrEmpty(vm.Add.InvoiceMetadata) ) + { + try + { + metadata = JObject.Parse(vm.Add.InvoiceMetadata); + } + catch (Exception e) + { + vm.AddModelError(addressVm => addressVm.Add.InvoiceMetadata, "Metadata must be a valid json object", this); + } + } if (!ModelState.IsValid) { return View(vm); @@ -760,7 +784,8 @@ namespace BTCPayServer { Max = vm.Add.Max, Min = vm.Add.Min, - CurrencyCode = vm.Add.CurrencyCode + CurrencyCode = vm.Add.CurrencyCode, + InvoiceMetadata = metadata }))) { TempData.SetStatusMessageModel(new StatusMessageModel diff --git a/BTCPayServer/Views/UILNURL/EditLightningAddress.cshtml b/BTCPayServer/Views/UILNURL/EditLightningAddress.cshtml index 4c8fc2ed7..ed997f098 100644 --- a/BTCPayServer/Views/UILNURL/EditLightningAddress.cshtml +++ b/BTCPayServer/Views/UILNURL/EditLightningAddress.cshtml @@ -67,26 +67,33 @@
-
+
-
+
-
+
+
+
+ + + +
+
@@ -114,6 +121,7 @@ + var address = $"{Model.Items[index].Username}@{Context.Request.Host.ToUriComponent()}"; @@ -138,6 +146,10 @@ { tracked in @Model.Items[index].CurrencyCode } + @if (!string.IsNullOrEmpty(Model.Items[index].InvoiceMetadata)) + { + with invoice metadata @Model.Items[index].InvoiceMetadata + }