mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-03-04 09:58:13 +01:00
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:
parent
041cba72b6
commit
5d39bb7466
4 changed files with 329 additions and 207 deletions
|
@ -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; }
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}")"
|
||||
|
|
Loading…
Add table
Reference in a new issue