diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index b0ce5df90..3215744c8 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -1,5 +1,6 @@ using System; using System.Buffers; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; using System.Linq; @@ -2379,7 +2380,7 @@ namespace BTCPayServer.Tests var addresses = s.Driver.FindElements(By.ClassName("lightning-address-value")); Assert.Equal(2, addresses.Count); - + var callbacks = new List(); foreach (IWebElement webElement in addresses) { var value = webElement.GetAttribute("value"); @@ -2397,6 +2398,7 @@ namespace BTCPayServer.Tests lnaddress2 = m["text/identifier"]; Assert.Equal(2, request.MinSendable.ToDecimal(LightMoneyUnit.Satoshi)); Assert.Equal(10, request.MaxSendable.ToDecimal(LightMoneyUnit.Satoshi)); + callbacks.Add(request.Callback); break; case { } v when v.StartsWith(lnaddress1): @@ -2404,6 +2406,7 @@ namespace BTCPayServer.Tests lnaddress1 = m["text/identifier"]; Assert.Equal(1, request.MinSendable.ToDecimal(LightMoneyUnit.Satoshi)); Assert.Equal(6.12m, request.MaxSendable.ToDecimal(LightMoneyUnit.BTC)); + callbacks.Add(request.Callback); break; default: Assert.False(true, "Should have matched"); @@ -2411,7 +2414,19 @@ namespace BTCPayServer.Tests } } var repo = s.Server.PayTester.GetService(); + var invoices = await repo.GetInvoices(new InvoiceQuery() { StoreId = new[] { s.StoreId } }); + // Resolving a ln address shouldn't create any btcpay invoice. + // This must be done because some NOST clients resolve ln addresses preemptively without user interaction + Assert.Empty(invoices); + + // Calling the callbacks should create the invoices + foreach (var callback in callbacks) + { + using var r = await s.Server.PayTester.HttpClient.GetAsync(callback); + await r.Content.ReadAsStringAsync(); + } + 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) diff --git a/BTCPayServer/Controllers/UILNURLController.cs b/BTCPayServer/Controllers/UILNURLController.cs index c6f17639a..1739a79c3 100644 --- a/BTCPayServer/Controllers/UILNURLController.cs +++ b/BTCPayServer/Controllers/UILNURLController.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Abstractions.Constants; @@ -373,13 +374,52 @@ namespace BTCPayServer return NotFound("Unknown username"); var store = await _storeRepository.FindStore(lightningAddressSettings.StoreDataId); + var cryptoCode = "BTC"; if (store is null) return NotFound("Unknown username"); + if (GetLNUrlPaymentMethodId(cryptoCode, store, out var lnUrlMethod) is null) + return NotFound("LNUrl not available for store"); var blob = lightningAddressSettings.GetBlob(); - return await GetLNURLRequest( - "BTC", + var lnurlRequest = new LNURLPayRequest() + { + Tag = "payRequest", + MinSendable = blob?.Min is decimal min ? new LightMoney(min, LightMoneyUnit.Satoshi) : null, + MaxSendable = blob?.Max is decimal max ? new LightMoney(max, LightMoneyUnit.Satoshi) : null, + CommentAllowed = lnUrlMethod.LUD12Enabled ? 2000 : 0 + }; + NormalizeSendable(lnurlRequest); + + var lnUrlMetadata = new Dictionary() + { + ["text/identifier"] = $"{username}@{Request.Host}" + }; + SetLNUrlDescriptionMetadata(lnUrlMetadata, store, store.GetStoreBlob(), null); + lnurlRequest.Metadata = + JsonConvert.SerializeObject(lnUrlMetadata.Select(kv => new[] { kv.Key, kv.Value })); + + lnurlRequest.Callback = new Uri(_linkGenerator.GetUriByAction( + action: nameof(GetLNURLForLightningAddress), + controller: "UILNURL", + values: new { cryptoCode, username }, Request.Scheme, Request.Host, Request.PathBase)); + + lnurlRequest = await _pluginHookService.ApplyFilter("modify-lnurlp-request", lnurlRequest) as LNURLPayRequest; + return Ok(lnurlRequest); + } + + [HttpGet("pay/lnaddress/{username}")] + [EnableCors(CorsPolicies.All)] + [IgnoreAntiforgeryToken] + public async Task GetLNURLForLightningAddress(string cryptoCode, string username, [FromQuery] long? amount = null, string comment = null) + { + var lightningAddressSettings = await _lightningAddressService.ResolveByAddress(username); + if (lightningAddressSettings is null || username is null) + return NotFound("Unknown username"); + var blob = lightningAddressSettings.GetBlob(); + var store = await _storeRepository.FindStore(lightningAddressSettings.StoreDataId); + var result = await GetLNURLRequest( + cryptoCode, store, store.GetStoreBlob(), new CreateInvoiceRequest() @@ -396,6 +436,10 @@ namespace BTCPayServer { { "text/identifier", $"{username}@{Request.Host}" } }); + if (result is not OkObjectResult ok || ok.Value is not LNURLPayRequest payRequest) + return result; + var invoiceId = payRequest.Callback.AbsoluteUri.Split('/').Last(); + return await GetLNURLForInvoice(invoiceId, cryptoCode, amount, comment); } @@ -482,11 +526,7 @@ namespace BTCPayServer if (!lnUrlMetadata.ContainsKey("text/plain")) { - 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); + SetLNUrlDescriptionMetadata(lnUrlMetadata, store, blob, i.Metadata); } lnurlRequest.Tag = "payRequest"; @@ -503,12 +543,7 @@ namespace BTCPayServer lnurlRequest.MaxSendable = lnurlRequest.MinSendable; } - // 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); + NormalizeSendable(lnurlRequest); lnurlRequest = await _pluginHookService.ApplyFilter("modify-lnurlp-request", lnurlRequest) as LNURLPayRequest; if (paymentMethodDetails.PayRequest is null) @@ -524,6 +559,25 @@ namespace BTCPayServer return lnurlRequest; } + private void SetLNUrlDescriptionMetadata(Dictionary lnUrlMetadata, Data.StoreData store, StoreBlob blob, InvoiceMetadata invoiceMetadata) + { + var invoiceDescription = blob.LightningDescriptionTemplate + .Replace("{StoreName}", store.StoreName ?? "", StringComparison.OrdinalIgnoreCase) + .Replace("{ItemDescription}", invoiceMetadata?.ItemDesc ?? "", StringComparison.OrdinalIgnoreCase) + .Replace("{OrderId}", invoiceMetadata?.OrderId ?? "", StringComparison.OrdinalIgnoreCase); + lnUrlMetadata.Add("text/plain", invoiceDescription); + } + + private static void NormalizeSendable(LNURLPayRequest lnurlRequest) + { + // 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); + } + PaymentMethodId GetLNUrlPaymentMethodId(string cryptoCode, Data.StoreData store, out LNURLPaySupportedPaymentMethod lnUrlSettings) { lnUrlSettings = null;