diff --git a/BTCPayServer.Client/Models/InvoiceData.cs b/BTCPayServer.Client/Models/InvoiceData.cs index 9e81621720..7710700185 100644 --- a/BTCPayServer.Client/Models/InvoiceData.cs +++ b/BTCPayServer.Client/Models/InvoiceData.cs @@ -87,6 +87,7 @@ public class CheckoutOptions public string DefaultLanguage { get; set; } public CheckoutType? CheckoutType { get; set; } public bool? LazyPaymentMethods { get; set; } + public string ExplicitRateScript { get; set; } } } public class InvoiceData : InvoiceDataBase diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index b0ce5df902..d33d8b460f 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -2361,11 +2361,11 @@ public async Task CanUseLNAddress() var lnaddress1 = Guid.NewGuid().ToString(); s.Driver.FindElement(By.Id("Add_Username")).SendKeys(lnaddress1); s.Driver.FindElement(By.CssSelector("button[value='add']")).Click(); - s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success); + s.FindAlertMessage(); s.Driver.ToggleCollapse("AddAddress"); - var lnaddress2 = "EUR" + Guid.NewGuid().ToString(); + var lnaddress2 = "EUR" + Guid.NewGuid(); s.Driver.FindElement(By.Id("Add_Username")).SendKeys(lnaddress2); lnaddress2 = lnaddress2.ToLowerInvariant(); @@ -2375,20 +2375,22 @@ public async Task CanUseLNAddress() 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); + s.FindAlertMessage(); var addresses = s.Driver.FindElements(By.ClassName("lightning-address-value")); + var emailSuffix = $"@{s.Server.PayTester.HostName}:{s.Server.PayTester.Port}"; Assert.Equal(2, addresses.Count); + LNURLPayRequest.LNURLPayRequestCallbackResponse lnAddressOneResponse = null; + LNURLPayRequest.LNURLPayRequestCallbackResponse lnAddressTwoResponse = null; foreach (IWebElement webElement in addresses) { var value = webElement.GetAttribute("value"); //cannot test this directly as https is not supported on our e2e tests // var request = await LNURL.LNURL.FetchPayRequestViaInternetIdentifier(value, new HttpClient()); - var lnurl = new Uri(LNURL.LNURL.ExtractUriFromInternetIdentifier(value).ToString() - .Replace("https", "http")); - var request = (LNURL.LNURLPayRequest)await LNURL.LNURL.FetchInformation(lnurl, new HttpClient()); + var lnurl = new Uri(LNURL.LNURL.ExtractUriFromInternetIdentifier(value).ToString().Replace("https", "http")); + var request = (LNURLPayRequest)await LNURL.LNURL.FetchInformation(lnurl, new HttpClient()); var m = request.ParsedMetadata.ToDictionary(o => o.Key, o => o.Value); switch (value) { @@ -2397,6 +2399,8 @@ public async Task CanUseLNAddress() lnaddress2 = m["text/identifier"]; Assert.Equal(2, request.MinSendable.ToDecimal(LightMoneyUnit.Satoshi)); Assert.Equal(10, request.MaxSendable.ToDecimal(LightMoneyUnit.Satoshi)); + lnAddressTwoResponse = await request.SendRequest(request.MinSendable, ((BTCPayNetwork)s.Server.DefaultNetwork).NBitcoinNetwork, + new HttpClient()); break; case { } v when v.StartsWith(lnaddress1): @@ -2404,34 +2408,29 @@ public async Task CanUseLNAddress() lnaddress1 = m["text/identifier"]; Assert.Equal(1, request.MinSendable.ToDecimal(LightMoneyUnit.Satoshi)); Assert.Equal(6.12m, request.MaxSendable.ToDecimal(LightMoneyUnit.BTC)); + lnAddressOneResponse = await request.SendRequest(request.MinSendable, ((BTCPayNetwork)s.Server.DefaultNetwork).NBitcoinNetwork, + new HttpClient()); break; + + case not null when value.Equals($"{lnaddress2}{emailSuffix}"): + lnaddress2 = m["text/identifier"]; + Assert.Equal(2, request.MinSendable.ToDecimal(LightMoneyUnit.Satoshi)); + Assert.Equal(10, request.MaxSendable.ToDecimal(LightMoneyUnit.Satoshi)); + break; + default: - Assert.False(true, "Should have matched"); + Assert.False(true, "Should have matched one of the Lightning addresses"); break; } } + + // Check that no BTCPay invoice got generated on initial LNURL request 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 invoices = await repo.GetInvoices(new InvoiceQuery { StoreId = new[] { s.StoreId } }); + Assert.Empty(invoices); var lnUsername = lnaddress1.Split('@')[0]; - LNURLPayRequest req; using (var resp = await s.Server.PayTester.HttpClient.GetAsync($"/.well-known/lnurlp/{lnUsername}")) { @@ -2484,6 +2483,25 @@ public async Task CanUseLNAddress() Assert.NotNull(succ.Pr); Assert.Equal(new LightMoney(2001), BOLT11PaymentRequest.Parse(succ.Pr, Network.RegTest).MinimumAmount); } + + // Again, check the invoices + invoices = await repo.GetInvoices(new InvoiceQuery { StoreId = new[] { s.StoreId } }); + Assert.Equal(2, invoices.Length); + + 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()); + } + } } [Fact] diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs index 5b6521dc16..0f4bfd0077 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs @@ -41,6 +41,7 @@ public class GreenfieldInvoiceController : Controller private readonly RateFetcher _rateProvider; private readonly InvoiceActivator _invoiceActivator; private readonly ApplicationDbContextFactory _dbContextFactory; + private readonly IAuthorizationService _authorizationService; public LanguageService LanguageService { get; } @@ -48,7 +49,7 @@ public GreenfieldInvoiceController(UIInvoiceController invoiceController, Invoic LinkGenerator linkGenerator, LanguageService languageService, BTCPayNetworkProvider btcPayNetworkProvider, CurrencyNameTable currencyNameTable, RateFetcher rateProvider, InvoiceActivator invoiceActivator, - PullPaymentHostedService pullPaymentService, ApplicationDbContextFactory dbContextFactory) + PullPaymentHostedService pullPaymentService, ApplicationDbContextFactory dbContextFactory, IAuthorizationService authorizationService) { _invoiceController = invoiceController; _invoiceRepository = invoiceRepository; @@ -59,6 +60,7 @@ public GreenfieldInvoiceController(UIInvoiceController invoiceController, Invoic _invoiceActivator = invoiceActivator; _pullPaymentService = pullPaymentService; _dbContextFactory = dbContextFactory; + _authorizationService = authorizationService; LanguageService = languageService; } @@ -188,6 +190,12 @@ public async Task CreateInvoice(string storeId, CreateInvoiceRequ { ModelState.AddModelError(nameof(request.Amount), $"The amount should less than {GreenfieldConstants.MaxAmount}."); } + if (!string.IsNullOrEmpty(request.Checkout.ExplicitRateScript) && + !(await _authorizationService.AuthorizeAsync(User, Policies.CanModifyInvoices)).Succeeded) + { + request.AddModelError(invoiceRequest => invoiceRequest.Checkout.ExplicitRateScript, + $"You are not authorized to use explicit rate script (missing {Policies.CanModifyInvoices} permission)", this); + } request.Checkout ??= new CreateInvoiceRequest.CheckoutOptions(); if (request.Checkout.PaymentMethods?.Any() is true) { diff --git a/BTCPayServer/Controllers/UIInvoiceController.cs b/BTCPayServer/Controllers/UIInvoiceController.cs index 21eb946314..153399e3ea 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.cs @@ -238,6 +238,10 @@ public async Task CreateInvoiceCoreRaw(CreateInvoiceRequest invoi { var storeBlob = store.GetStoreBlob(); var entity = _InvoiceRepository.CreateNewInvoice(); + if (!string.IsNullOrEmpty(invoice.Checkout.ExplicitRateScript) && RateRules.TryParse(invoice.Checkout.ExplicitRateScript, out var explicitRateRule) && explicitRateRule is not null) + { + entity.ExplicitRateRules = explicitRateRule; + } entity.ServerUrl = serverUrl; entity.ExpirationTime = entity.InvoiceTime + (invoice.Checkout.Expiration ?? storeBlob.InvoiceExpiration); entity.MonitoringExpiration = entity.ExpirationTime + (invoice.Checkout.Monitoring ?? storeBlob.MonitoringExpiration); @@ -315,7 +319,6 @@ internal async Task CreateInvoiceCoreRaw(InvoiceEntity entity, St } entity.Status = InvoiceStatusLegacy.New; HashSet currencyPairsToFetch = new HashSet(); - var rules = storeBlob.GetRateRules(_NetworkProvider); var excludeFilter = storeBlob.GetExcludedPaymentMethods(); // Here we can compose filters from other origin with PaymentFilter.Any() if (invoicePaymentMethodFilter != null) { @@ -337,7 +340,7 @@ internal async Task CreateInvoiceCoreRaw(InvoiceEntity entity, St } } - var rateRules = storeBlob.GetRateRules(_NetworkProvider); + var rateRules = entity.ExplicitRateRules?? storeBlob.GetRateRules(_NetworkProvider); var fetchingByCurrencyPair = _RateProvider.FetchRates(currencyPairsToFetch, rateRules, cancellationToken); var fetchingAll = WhenAllFetched(logs, fetchingByCurrencyPair); diff --git a/BTCPayServer/Controllers/UILNURLController.cs b/BTCPayServer/Controllers/UILNURLController.cs index c6f17639a1..e873a67d2b 100644 --- a/BTCPayServer/Controllers/UILNURLController.cs +++ b/BTCPayServer/Controllers/UILNURLController.cs @@ -23,6 +23,7 @@ using BTCPayServer.Plugins.Crowdfund; using BTCPayServer.Plugins.PointOfSale; using BTCPayServer.Plugins.PointOfSale.Models; +using BTCPayServer.Rating; using BTCPayServer.Services; using BTCPayServer.Services.Apps; using BTCPayServer.Services.Invoices; @@ -34,11 +35,13 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Caching.Memory; using NBitcoin; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using LightningAddressData = BTCPayServer.Data.LightningAddressData; using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest; +using StoreData = BTCPayServer.Data.StoreData; namespace BTCPayServer { @@ -60,6 +63,8 @@ public class UILNURLController : Controller private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings; private readonly IPluginHookService _pluginHookService; private readonly InvoiceActivator _invoiceActivator; + private readonly IMemoryCache _memoryCache; + private readonly RateFetcher _rateFetcher; public UILNURLController(InvoiceRepository invoiceRepository, EventAggregator eventAggregator, @@ -74,7 +79,9 @@ public UILNURLController(InvoiceRepository invoiceRepository, PullPaymentHostedService pullPaymentHostedService, BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings, IPluginHookService pluginHookService, - InvoiceActivator invoiceActivator) + InvoiceActivator invoiceActivator, + IMemoryCache memoryCache, + RateFetcher rateFetcher) { _invoiceRepository = invoiceRepository; _eventAggregator = eventAggregator; @@ -90,6 +97,8 @@ public UILNURLController(InvoiceRepository invoiceRepository, _btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings; _pluginHookService = pluginHookService; _invoiceActivator = invoiceActivator; + _memoryCache = memoryCache; + _rateFetcher = rateFetcher; } [HttpGet("withdraw/pp/{pullPaymentId}")] @@ -226,12 +235,8 @@ await _pullPaymentHostedService.Cancel( [HttpGet("pay/app/{appId}/{itemCode}")] public async Task GetLNURLForApp(string cryptoCode, string appId, string itemCode = null) { - var network = _btcPayNetworkProvider.GetNetwork(cryptoCode); - if (network is null || !network.SupportLightning) - { - return NotFound(); - } - + if (!NetworkSupportsLightning(cryptoCode, out _)) + return null; var app = await _appService.GetApp(appId, null, true); if (app is null) { @@ -248,6 +253,10 @@ public async Task GetLNURLForApp(string cryptoCode, string appId, { return NotFound(); } + + var pmi = GetLNUrlPaymentMethodId(cryptoCode, store, out var lnurlPM); + if (pmi is null) + return NotFound("LNUrl or LN is disabled"); ViewPointOfSaleViewModel.Item[] items; string currencyCode; @@ -272,9 +281,6 @@ public async Task GetLNURLForApp(string cryptoCode, string appId, 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) || @@ -318,13 +324,12 @@ public async Task GetLNURLForApp(string cryptoCode, string appId, createInvoice.Metadata = invoiceMetadata.ToJObject(); - return await GetLNURLRequest( - cryptoCode, - store, - store.GetStoreBlob(), + return await CreateLNURLRequestWithoutInvoice( new LNURLRequestParams( + store.Id, + pmi, createInvoice, additionalTags: new List { AppService.GetAppInternalTag(appId) }, - allowOverpay: false); + allowOverpay: false), store, store.GetStoreBlob(), lnurlPM); } public class EditLightningAddressVM @@ -376,29 +381,23 @@ public async Task ResolveLightningAddress(string username) if (store is null) return NotFound("Unknown username"); + var pmi = GetLNUrlPaymentMethodId("BTC", store, out var lnurlPaymentMethod); + if (pmi is null) + return NotFound("LNUrl or LN is disabled"); + var blob = lightningAddressSettings.GetBlob(); - - 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}" } - }); + return await CreateLNURLRequestWithoutInvoice(new LNURLRequestParams( + store.Id, + pmi, + 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}"}}), store,store.GetStoreBlob(), lnurlPaymentMethod); } - [HttpGet("pay")] [EnableCors(CorsPolicies.All)] [IgnoreAntiforgeryToken] @@ -411,49 +410,124 @@ public async Task GetLNUrlForStore( if (store is null) return NotFound(); + var pmi = GetLNUrlPaymentMethodId("BTC", store, out var lnurlPaymentMethod); + if (pmi is null) + return NotFound("LNUrl or LN is disabled"); var blob = store.GetStoreBlob(); if (!blob.AnyoneCanInvoice) return NotFound("'Anyone can invoice' is turned off"); - return await GetLNURLRequest( - cryptoCode, - store, - blob, + return await CreateLNURLRequestWithoutInvoice(new LNURLRequestParams( + storeId, + pmi, new CreateInvoiceRequest { Currency = currencyCode - }); + }), store, blob, lnurlPaymentMethod); } - - 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) + class LNURLRequestParams { - var pmi = GetLNUrlPaymentMethodId(cryptoCode, store, out _); - if (pmi is null) - return NotFound("LNUrl or LN is disabled"); + public LNURLRequestParams( + string storeId, + PaymentMethodId paymentMethodId, + CreateInvoiceRequest createInvoice, + LNURLPayRequest lnurlRequest = null, + Dictionary lnUrlMetadata = null, + List additionalTags = null, + bool allowOverpay = true) + { + StoreId = storeId; + PaymentMethodId = paymentMethodId; + CreateInvoice = createInvoice; + LNURLRequest = lnurlRequest; + LNURLMetadata = lnUrlMetadata; + AdditionalTags = additionalTags; + AllowOverpay = allowOverpay; + } - InvoiceEntity i; - try - { - createInvoice.Checkout ??= new InvoiceDataBase.CheckoutOptions(); - createInvoice.Checkout.LazyPaymentMethods = false; - createInvoice.Checkout.PaymentMethods = new[] { pmi.ToStringNormalized() }; - i = await _invoiceController.CreateInvoiceCoreRaw(createInvoice, store, Request.GetAbsoluteRoot(), additionalTags); + public string StoreId { get; } + public PaymentMethodId PaymentMethodId { get; set; } + public CreateInvoiceRequest CreateInvoice { get; set; } + public LNURLPayRequest LNURLRequest { get; set; } + public Dictionary LNURLMetadata { get; set; } + public List AdditionalTags { get; set; } + public bool AllowOverpay { get; set; } + } + private async Task CreateLNURLRequestWithoutInvoice(LNURLRequestParams requestParams, + StoreData store, StoreBlob storeBlob, LNURLPaySupportedPaymentMethod lnurlPaySupportedPaymentMethod) + { + var k = Guid.NewGuid(); + + requestParams.LNURLRequest ??= new LNURLPayRequest(); + requestParams.LNURLMetadata ??= new Dictionary(); + requestParams.CreateInvoice.Currency ??= storeBlob.DefaultCurrency; + // Set the callback endpoint to trigger invoice generation + requestParams.LNURLRequest.Tag = "payRequest"; + requestParams.LNURLRequest.Callback = new Uri(_linkGenerator.GetUriByAction( + action: nameof(LNURLCallback), + controller: "UILNURL", + values: new {requestParams.PaymentMethodId.CryptoCode, k }, + Request.Scheme, Request.Host, Request.PathBase)); + + + if (!requestParams.LNURLMetadata.ContainsKey("text/plain")) + { + var invMetadata = InvoiceMetadata.FromJObject(requestParams.CreateInvoice.Metadata?? new JObject()); + var invoiceDescription = storeBlob.LightningDescriptionTemplate + .Replace("{StoreName}", store.StoreName ?? "", StringComparison.OrdinalIgnoreCase) + .Replace("{ItemDescription}", invMetadata.ItemDesc ?? "", StringComparison.OrdinalIgnoreCase) + .Replace("{OrderId}", invMetadata.OrderId ?? "", StringComparison.OrdinalIgnoreCase); + requestParams.LNURLMetadata.Add("text/plain", invoiceDescription); } - catch (Exception e) + + requestParams.LNURLRequest.CommentAllowed = lnurlPaySupportedPaymentMethod.LUD12Enabled ? 2000 : 0; + requestParams.LNURLRequest.Metadata = JsonConvert.SerializeObject(requestParams.LNURLMetadata.Select(kv => new[] { kv.Key, kv.Value })); + // We don't think BTCPay handle well 0 sats payments, just in case make it minimum one sat. + if (requestParams.LNURLRequest.MinSendable is null || requestParams.LNURLRequest.MinSendable < LightMoney.Satoshis(1.0m)) + requestParams.LNURLRequest.MinSendable = LightMoney.Satoshis(1.0m); + + if (requestParams.LNURLRequest.MaxSendable is null) + requestParams.LNURLRequest.MaxSendable = LightMoney.FromUnit(6.12m, LightMoneyUnit.BTC); + + if (requestParams.CreateInvoice.Type != InvoiceType.TopUp && requestParams.CreateInvoice.Amount is not null) { - return this.CreateAPIError(null, e.Message); + LightMoney cAmount; + if (requestParams.CreateInvoice.Currency != requestParams.PaymentMethodId.CryptoCode) + { + var rr = storeBlob.GetRateRules(_btcPayNetworkProvider); + var rate = _rateFetcher.FetchRates( + new HashSet() + { + new CurrencyPair( requestParams.PaymentMethodId.CryptoCode, requestParams.CreateInvoice.Currency) + }, rr, CancellationToken.None).First(); + var rateResult = await rate.Value; + cAmount = LightMoney.FromUnit(rateResult.BidAsk.Bid, LightMoneyUnit.BTC); + requestParams.CreateInvoice.Checkout.ExplicitRateScript = + $"{ requestParams.PaymentMethodId.CryptoCode}_{requestParams.CreateInvoice.Currency}={rateResult.BidAsk.Bid}"; + } + else + { + cAmount = LightMoney.FromUnit(requestParams.CreateInvoice.Amount.Value!, LightMoneyUnit.BTC); + + } + requestParams.LNURLRequest.MinSendable = cAmount; + + if (!requestParams.AllowOverpay) + requestParams.LNURLRequest.MaxSendable = requestParams.LNURLRequest.MinSendable; } - lnurlRequest = await CreateLNUrlRequestFromInvoice(cryptoCode, i, store, blob, lnurlRequest, lnUrlMetadata, allowOverpay); - return lnurlRequest is null ? NotFound() : Ok(lnurlRequest); - } + + requestParams.LNURLRequest = await _pluginHookService.ApplyFilter("modify-lnurlp-request", requestParams.LNURLRequest) as LNURLPayRequest; + + var invoiceParamsCacheEntryOptions = new MemoryCacheEntryOptions() + .SetAbsoluteExpiration(TimeSpan.FromMinutes(5)); // Set an appropriate expiration time + + // Store the invoice parameters in the cache + _memoryCache.Set($"{nameof(UILNURLController)}:{k}", requestParams, invoiceParamsCacheEntryOptions); + + return Ok(requestParams.LNURLRequest); + } + + private async Task CreateLNUrlRequestFromInvoice( string cryptoCode, InvoiceEntity i, @@ -474,7 +548,7 @@ private async Task CreateLNUrlRequestFromInvoice( return null; var paymentMethodDetails = (LNURLPayPaymentMethodDetails)pm.GetPaymentMethodDetails(); bool updatePaymentMethodDetails = false; - if (lnUrlMetadata?.TryGetValue("text/identifier", out var lnAddress) is true && lnAddress is not null) + if (lnUrlMetadata.TryGetValue("text/identifier", out var lnAddress) && lnAddress is not null) { paymentMethodDetails.ConsumedLightningAddress = lnAddress; updatePaymentMethodDetails = true; @@ -520,6 +594,7 @@ private async Task CreateLNUrlRequestFromInvoice( { pm.SetPaymentMethodDetails(paymentMethodDetails); await _invoiceRepository.UpdateInvoicePaymentMethod(i.Id, pm); + i.SetPaymentMethod(pm); } return lnurlRequest; } @@ -527,8 +602,7 @@ private async Task CreateLNUrlRequestFromInvoice( PaymentMethodId GetLNUrlPaymentMethodId(string cryptoCode, Data.StoreData store, out LNURLPaySupportedPaymentMethod lnUrlSettings) { lnUrlSettings = null; - var network = _btcPayNetworkProvider.GetNetwork(cryptoCode); - if (network is null || !network.SupportLightning) + if (!NetworkSupportsLightning(cryptoCode, out _)) return null; var pmi = new PaymentMethodId(cryptoCode, PaymentTypes.LNURLPay); var lnpmi = new PaymentMethodId(cryptoCode, PaymentTypes.LightningLike); @@ -545,19 +619,58 @@ PaymentMethodId GetLNUrlPaymentMethodId(string cryptoCode, Data.StoreData store, return pmi; } + + [HttpGet("pay/{k}")] + public async Task LNURLCallback(string k, long? amount = null, string comment = null) + { + if (!_memoryCache.TryGetValue($"{nameof(UILNURLController)}:{k}", out var lnurlReq) || lnurlReq is null) + return NotFound(); + if (amount is null) + { + return Ok(lnurlReq.LNURLRequest); + } + + var lnurlPayRequest = lnurlReq.LNURLRequest; + var amt = new LightMoney(amount.Value); + if (amt < lnurlPayRequest.MinSendable || amt > lnurlPayRequest.MaxSendable) + return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Amount is out of bounds." }); + + InvoiceEntity i; + try + { + var store = await _storeRepository.FindStore(lnurlReq.StoreId); + + lnurlReq.CreateInvoice.Checkout ??= new InvoiceDataBase.CheckoutOptions(); + lnurlReq.CreateInvoice.Checkout.LazyPaymentMethods = false; + lnurlReq.CreateInvoice.Checkout.PaymentMethods = new[] { lnurlReq.PaymentMethodId.ToStringNormalized() }; + i = await _invoiceController.CreateInvoiceCoreRaw(lnurlReq.CreateInvoice, store, Request.GetAbsoluteRoot(), lnurlReq.AdditionalTags); + await CreateLNUrlRequestFromInvoice(lnurlReq.PaymentMethodId.CryptoCode,i, store,store.GetStoreBlob(),lnurlReq.LNURLRequest, lnurlReq.LNURLMetadata, lnurlReq.AllowOverpay); + return await GetLNURLForInvoice(i, lnurlReq.PaymentMethodId.CryptoCode, amount, comment); + } + catch (Exception e) + { + return this.CreateAPIError(null, e.Message); + } + } + [HttpGet("pay/i/{invoiceId}")] [EnableCors(CorsPolicies.All)] [IgnoreAntiforgeryToken] public async Task GetLNURLForInvoice(string invoiceId, string cryptoCode, [FromQuery] long? amount = null, string comment = null) { - var network = _btcPayNetworkProvider.GetNetwork(cryptoCode); - if (network is null || !network.SupportLightning) - { - return NotFound(); - } - + var i = await _invoiceRepository.GetInvoice(invoiceId, true); + return await GetLNURLForInvoice(i, cryptoCode, amount, comment); + } + + [NonAction] + private async Task GetLNURLForInvoice(InvoiceEntity i, string cryptoCode, + [FromQuery] long? amount = null, string comment = null) + { + if (!NetworkSupportsLightning(cryptoCode, out var network)) + return null; + if (i is null) return NotFound(); @@ -578,7 +691,7 @@ public async Task GetLNURLForInvoice(string invoiceId, string cry { if (!await _invoiceActivator.ActivateInvoicePaymentMethod(pmi, i, store)) return NotFound(); - i = await _invoiceRepository.GetInvoice(invoiceId, true); + i = await _invoiceRepository.GetInvoice(i.Id, true); lightningPaymentMethod = i.GetPaymentMethod(pmi); paymentMethodDetails = lightningPaymentMethod.GetPaymentMethodDetails() as LNURLPayPaymentMethodDetails; } @@ -599,7 +712,7 @@ public async Task GetLNURLForInvoice(string invoiceId, string cry return Ok(lnurlPayRequest); var amt = new LightMoney(amount.Value); - if (amt < lnurlPayRequest.MinSendable || amount > lnurlPayRequest.MaxSendable) + if (amt < lnurlPayRequest.MinSendable || amt > lnurlPayRequest.MaxSendable) return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Amount is out of bounds." }); LNURLPayRequest.LNURLPayRequestCallbackResponse.ILNURLPayRequestSuccessAction successAction = null; @@ -613,7 +726,7 @@ public async Task GetLNURLForInvoice(string invoiceId, string cry Url = _linkGenerator.GetUriByAction( nameof(UIInvoiceController.InvoiceReceipt), "UIInvoice", - new { invoiceId }, + new { i.Id }, Request.Scheme, Request.Host, Request.PathBase) @@ -681,7 +794,7 @@ public async Task GetLNURLForInvoice(string invoiceId, string cry string.IsNullOrEmpty(ex.Message) ? "" : $": {ex.Message}") }); } - + paymentMethodDetails.BOLT11 = invoice.BOLT11; paymentMethodDetails.PaymentHash = string.IsNullOrEmpty(invoice.PaymentHash) ? null : uint256.Parse(invoice.PaymentHash); paymentMethodDetails.Preimage = string.IsNullOrEmpty(invoice.Preimage) ? null : uint256.Parse(invoice.Preimage); @@ -693,8 +806,8 @@ public async Task GetLNURLForInvoice(string invoiceId, string cry if (updatePaymentMethod) { lightningPaymentMethod.SetPaymentMethodDetails(paymentMethodDetails); - await _invoiceRepository.UpdateInvoicePaymentMethod(invoiceId, lightningPaymentMethod); - _eventAggregator.Publish(new InvoiceNewPaymentDetailsEvent(invoiceId, paymentMethodDetails, pmi)); + await _invoiceRepository.UpdateInvoicePaymentMethod(i.Id, lightningPaymentMethod); + _eventAggregator.Publish(new InvoiceNewPaymentDetailsEvent(i.Id, paymentMethodDetails, pmi)); } return Ok(new LNURLPayRequest.LNURLPayRequestCallbackResponse @@ -713,6 +826,12 @@ public async Task GetLNURLForInvoice(string invoiceId, string cry }); } + private bool NetworkSupportsLightning(string cryptoCode, out BTCPayNetwork network) + { + network = _btcPayNetworkProvider.GetNetwork(cryptoCode); + return !(network is null || !network.SupportLightning); + } + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [HttpGet("~/stores/{storeId}/plugins/lightning-address")] diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index 1a32a52f1b..fdd990aea6 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -10,6 +10,7 @@ using BTCPayServer.Payments; using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.Lightning; +using BTCPayServer.Rating; using NBitcoin; using NBitcoin.DataEncoders; using NBitpayClient; @@ -465,6 +466,7 @@ private Uri FillPlaceholdersUri(string v) [JsonConverter(typeof(StringEnumConverter))] public CheckoutType? CheckoutType { get; set; } public bool LazyPaymentMethods { get; set; } + public RateRules ExplicitRateRules { get; set; } public bool IsExpired() { @@ -490,13 +492,13 @@ public InvoiceResponse EntityToDTO() Currency = Currency, PaymentSubtotals = new Dictionary(), PaymentTotals = new Dictionary(), - SupportedTransactionCurrencies = new Dictionary(), + SupportedTransactionCurrencies = new Dictionary(), Addresses = new Dictionary(), PaymentCodes = new Dictionary(), ExchangeRates = new Dictionary>() }; - dto.Url = ServerUrl.WithTrailingSlash() + $"invoice?id=" + Id; + dto.Url = ServerUrl.WithTrailingSlash() + $"invoice?id={Id}"; dto.CryptoInfo = new List(); dto.MinerFees = new Dictionary(); foreach (var info in this.GetPaymentMethods())