Allow LN Address to customize invoice metadata, and various bug fixes on LNUrl (#4855)

* Allow LN Address to customize invoice metadata

solves https://github.com/OpenSats/website/issues/8

* Refactor GetLNUrl

* Fix lightningAddresssettings.Max being ignored

* Fix: The payRequest generated by the callback wasn't the same as the original

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
Andrew Camilleri 2023-04-07 10:48:58 +02:00 committed by GitHub
parent 041cba72b6
commit 5d39bb7466
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 329 additions and 207 deletions

View file

@ -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; }
}

View file

@ -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<InvoiceRepository>();
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<string>());
}
}
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<LNURLPayRequest>(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<LNURLPayRequest>(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<LNURLPayRequest>(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<LNUrlStatusResponse>(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<LNURLPayRequest.LNURLPayRequestCallbackResponse>(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<LNURLPayRequest.LNURLPayRequestCallbackResponse>(str);
Assert.NotNull(succ.Pr);
Assert.Equal(new LightMoney(2001), BOLT11PaymentRequest.Parse(succ.Pr, Network.RegTest).MinimumAmount);
}
}
[Fact]

View file

@ -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<string> { 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<PointOfSaleSettings>().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<string> { 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<string, LightningAddressItem> Items { get; } = new ();
@ -344,111 +370,103 @@ namespace BTCPayServer
public async Task<IActionResult> 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<string, string>()
{
{ "text/identifier", $"{username}@{Request.Host}" }
});
}
[HttpGet("pay")]
public async Task<IActionResult> GetLNURL(string cryptoCode, string storeId, string currencyCode = null,
decimal? min = null, decimal? max = null,
Func<(string username, AppData app, ViewPointOfSaleViewModel.Item item, List<string> additionalTags, decimal? invoiceAmount, bool? anyoneCanInvoice)>
internalDetails = null)
[EnableCors(CorsPolicies.All)]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> GetLNUrlForStore(
string cryptoCode,
string storeId,
string currencyCode = null)
{
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(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<string> 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<string[]> lnurlMetadata = new();
var redirectUrl = app?.AppType switch
{
PointOfSaleAppType.AppType => app.GetSettings<PointOfSaleSettings>().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<IActionResult> GetLNURLRequest(
string cryptoCode,
Data.StoreData store,
Data.StoreBlob blob,
CreateInvoiceRequest createInvoice,
LNURLPayRequest lnurlRequest = null,
Dictionary<string, string> lnUrlMetadata = null,
List<string> 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<LNURLPayRequest> CreateLNUrlRequestFromInvoice(
string cryptoCode,
InvoiceEntity i,
Data.StoreData store,
StoreBlob blob,
LNURLPayRequest lnurlRequest = null,
Dictionary<string, string> 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<string, string>();
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<BTCPayNetwork>(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<LNURLPaySupportedPaymentMethod>(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<string[]> 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<LNURLPayRequest>();
}
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<string>(),
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<string>(),
Pr = paymentMethodDetails.BOLT11,
SuccessAction = successAction
});
lightningPaymentMethod.SetPaymentMethodDetails(paymentMethodDetails);
await _invoiceRepository.UpdateInvoicePaymentMethod(invoiceId, lightningPaymentMethod);
}
return Ok(new LNURLPayRequest.LNURLPayRequestCallbackResponse
{
Disposable = true,
Routes = Array.Empty<string>(),
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

View file

@ -67,26 +67,33 @@
<div id="AdvancedSettings" class="collapse @(showAdvancedOptions ? "show" : "")">
<div class="row">
<div class="col-12 col-sm-auto">
<div class="form-group">
<div class="form-group" title="The currency to generate the invoice in when generated through this lightning address ">
<label asp-for="Add.CurrencyCode" class="form-label"></label>
<input asp-for="Add.CurrencyCode" class="form-control w-auto" currency-selection style="max-width:16ch;"/>
<span asp-validation-for="Add.CurrencyCode" class="text-danger"></span>
</div>
</div>
<div class="col-12 col-sm-auto">
<div class="form-group">
<div class="form-group" title="Minimum amount of sats to allow to be sent to this ln address">
<label asp-for="Add.Min" class="form-label"></label>
<input asp-for="Add.Min" class="form-control" type="number" inputmode="numeric" min="1" style="max-width:16ch;"/>
<span asp-validation-for="Add.Min" class="text-danger"></span>
</div>
</div>
<div class="col-12 col-sm-auto">
<div class="form-group">
<div class="form-group" title="Maximum amount of sats to allow to be sent to this ln address">
<label asp-for="Add.Max" class="form-label"></label>
<input asp-for="Add.Max" class="form-control" type="number" inputmode="numeric" min="1" max="@int.MaxValue" style="max-width:16ch;"/>
<span asp-validation-for="Add.Max" class="text-danger"></span>
</div>
</div>
<div class="col-12 col-sm-auto">
<div class="form-group" title="Metadata (in JSON) to add to the invoice when created through this lightning address.">
<label asp-for="Add.InvoiceMetadata" class="form-label"></label>
<textarea asp-for="Add.InvoiceMetadata" class="form-control" ></textarea>
<span asp-validation-for="Add.InvoiceMetadata" class="text-danger"></span>
</div>
</div>
</div>
</div>
@ -114,6 +121,7 @@
<input asp-for="Items[index].Min" type="hidden"/>
<input asp-for="Items[index].Max" type="hidden"/>
<input asp-for="Items[index].Username" type="hidden"/>
<input asp-for="Items[index].InvoiceMetadata" type="hidden"/>
var address = $"{Model.Items[index].Username}@{Context.Request.Host.ToUriComponent()}";
<tr>
<td>
@ -138,6 +146,10 @@
{
<span>tracked in @Model.Items[index].CurrencyCode</span>
}
@if (!string.IsNullOrEmpty(Model.Items[index].InvoiceMetadata))
{
<span>with invoice metadata @Model.Items[index].InvoiceMetadata</span>
}
</td>
<td class="text-end">
<button type="submit" title="Remove" name="command" value="@($"remove:{Model.Items[index].Username}")"