Compare commits

..

6 Commits

Author SHA1 Message Date
f530fb3241 Update changelog 2023-04-07 17:50:57 +09:00
5d39bb7466 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>
2023-04-07 17:48:58 +09:00
041cba72b6 Made Fake Custodian config field non-required (#4861) 2023-04-07 15:59:44 +09:00
892b3e273f Improve Labeling further (#4849)
* If loading addresses into the send wallet page using bip21 or address,  (or clicking on "Send selected payouts"  from the payotus page), existing labels will be pre-populated.
*  Add the payout label to the address when the payoutis created instead of to the transaction when it is paid.
*  Add the label attachments when adding labels from an address to the transaction.
2023-04-07 15:58:41 +09:00
91faf5756d With core lightning, getting payment by paymenthash wouldn't return the successful payment if the first one failed. 2023-04-07 15:54:56 +09:00
e239390ebf Allow any bolt11 invoice for pullpayments/payouts (#4857)
closes #4830

If users want to deal with expired payout destinations, then they should be able to conifugre it that way. Some wallets simply do not allow customizing the bolt11 expiry and the defaults are much less than a day. I think we should merge #3857 if we introduce this as an automated payotu processor for lightning running every few minutes would work together with this and solve it.
2023-04-06 15:54:19 +09:00
21 changed files with 432 additions and 253 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

@ -47,7 +47,7 @@
<ItemGroup>
<PackageReference Include="BIP78.Sender" Version="0.2.2" />
<PackageReference Include="BTCPayServer.Hwi" Version="2.0.2" />
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.4.21" />
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.4.22" />
<PackageReference Include="CsvHelper" Version="15.0.5" />
<PackageReference Include="Dapper" Version="2.0.123" />
<PackageReference Include="Fido2" Version="2.0.2" />

View File

@ -115,7 +115,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
ModelState.AddModelError(nameof(request.Period), $"The period should be positive");
}
if (request.BOLT11Expiration <= TimeSpan.Zero)
if (request.BOLT11Expiration < TimeSpan.Zero)
{
ModelState.AddModelError(nameof(request.BOLT11Expiration), $"The BOLT11 expiration should be positive");
}
@ -142,7 +142,7 @@ namespace BTCPayServer.Controllers.Greenfield
}
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
var ppId = await _pullPaymentService.CreatePullPayment(new HostedServices.CreatePullPayment()
var ppId = await _pullPaymentService.CreatePullPayment(new CreatePullPayment()
{
StartsAt = request.StartsAt,
ExpiresAt = request.ExpiresAt,

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

@ -459,14 +459,17 @@ namespace BTCPayServer.Controllers
}
}
if (ix is null) continue;
var labels = _labelService.CreateTransactionTagModels(ix, Request);
var input = vm.Inputs.First(model => model.Index == inputToObject.Key);
input.Labels = ix.LabelColors;
input.Labels = labels;
}
foreach (var outputToObject in outputToObjects)
{
if (!labelInfo.TryGetValue(outputToObject.Value.Id, out var ix)) continue;
var labels = _labelService.CreateTransactionTagModels(ix, Request);
var destination = vm.Destinations.First(model => model.Destination == outputToObject.Key);
destination.Labels = ix.LabelColors;
destination.Labels = labels;
}
}

View File

@ -453,7 +453,7 @@ namespace BTCPayServer.Controllers
{
if (!string.IsNullOrEmpty(link))
{
LoadFromBIP21(model, link, network);
await LoadFromBIP21(walletId, model, link, network);
}
}
}
@ -568,7 +568,7 @@ namespace BTCPayServer.Controllers
if (!string.IsNullOrEmpty(bip21))
{
vm.Outputs?.Clear();
LoadFromBIP21(vm, bip21, network);
await LoadFromBIP21(walletId, vm, bip21, network);
}
decimal transactionAmountSum = 0;
@ -586,7 +586,7 @@ namespace BTCPayServer.Controllers
.GetUnspentCoins(schemeSettings.AccountDerivation, false, cancellation);
var walletTransactionsInfoAsync = await this.WalletRepository.GetWalletTransactionsInfo(walletId,
utxos.SelectMany(u => GetWalletObjectsQuery.Get(u)).Distinct().ToArray());
utxos.SelectMany(GetWalletObjectsQuery.Get).Distinct().ToArray());
vm.InputsAvailable = utxos.Select(coin =>
{
walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.Hash.ToString(), out var info1);
@ -863,8 +863,10 @@ namespace BTCPayServer.Controllers
}
private void LoadFromBIP21(WalletSendModel vm, string bip21, BTCPayNetwork network)
private async Task LoadFromBIP21(WalletId walletId, WalletSendModel vm, string bip21,
BTCPayNetwork network)
{
BitcoinAddress? address = null;
vm.Outputs ??= new();
try
{
@ -879,6 +881,7 @@ namespace BTCPayServer.Controllers
? uriBuilder.UnknownParameters["payout"]
: null
});
address = uriBuilder.Address;
if (!string.IsNullOrEmpty(uriBuilder.Label) || !string.IsNullOrEmpty(uriBuilder.Message))
{
TempData.SetStatusMessageModel(new StatusMessageModel()
@ -896,9 +899,10 @@ namespace BTCPayServer.Controllers
{
try
{
address = BitcoinAddress.Create(bip21, network.NBitcoinNetwork);
vm.Outputs.Add(new WalletSendModel.TransactionOutput()
{
DestinationAddress = BitcoinAddress.Create(bip21, network.NBitcoinNetwork).ToString()
DestinationAddress = address.ToString()
}
);
}
@ -913,6 +917,11 @@ namespace BTCPayServer.Controllers
}
ModelState.Clear();
if (address is not null)
{
var addressLabels = await WalletRepository.GetWalletLabels(new WalletObjectId(walletId, WalletObjectData.Types.Address, address.ToString()));
vm.Outputs.Last().Labels = addressLabels.Select(tuple => tuple.Label).ToArray();
}
}
private IActionResult ViewVault(WalletId walletId, WalletPSBTViewModel vm)

View File

@ -64,12 +64,19 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
_btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethod.CryptoCode)?.ReadonlyWallet is false;
}
public async Task TrackClaim(PaymentMethodId paymentMethodId, IClaimDestination claimDestination)
public async Task TrackClaim(ClaimRequest claimRequest, PayoutData payoutData)
{
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethodId.CryptoCode);
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(claimRequest.PaymentMethodId.CryptoCode);
var explorerClient = _explorerClientProvider.GetExplorerClient(network);
if (claimDestination is IBitcoinLikeClaimDestination bitcoinLikeClaimDestination)
if (claimRequest.Destination is IBitcoinLikeClaimDestination bitcoinLikeClaimDestination)
{
await explorerClient.TrackAsync(TrackedSource.Create(bitcoinLikeClaimDestination.Address));
await WalletRepository.AddWalletTransactionAttachment(
new WalletId(claimRequest.StoreId, claimRequest.PaymentMethodId.CryptoCode),
bitcoinLikeClaimDestination.Address.ToString(),
Attachment.Payout(payoutData.PullPaymentDataId, payoutData.Id), WalletObjectData.Types.Address);
}
}
public Task<(IClaimDestination destination, string error)> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination, CancellationToken cancellationToken)

View File

@ -6,6 +6,7 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Payments;
using Microsoft.AspNetCore.Mvc;
using PayoutData = BTCPayServer.Data.PayoutData;
@ -14,7 +15,7 @@ using StoreData = BTCPayServer.Data.StoreData;
public interface IPayoutHandler
{
public bool CanHandle(PaymentMethodId paymentMethod);
public Task TrackClaim(PaymentMethodId paymentMethodId, IClaimDestination claimDestination);
public Task TrackClaim(ClaimRequest claimRequest, PayoutData payoutData);
//Allows payout handler to parse payout destinations on its own
public Task<(IClaimDestination destination, string error)> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination, CancellationToken cancellationToken);
public (bool valid, string? error) ValidateClaimDestination(IClaimDestination claimDestination, PullPaymentBlob? pullPaymentBlob);

View File

@ -6,6 +6,7 @@ using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client.Models;
using BTCPayServer.HostedServices;
using BTCPayServer.Lightning;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
@ -45,7 +46,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
_btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethod.CryptoCode)?.SupportLightning is true;
}
public Task TrackClaim(PaymentMethodId paymentMethodId, IClaimDestination claimDestination)
public Task TrackClaim(ClaimRequest claimRequest, PayoutData payoutData)
{
return Task.CompletedTask;
}

View File

@ -120,6 +120,7 @@ namespace BTCPayServer.HostedServices
o.Period = create.Period is TimeSpan period ? (long?)period.TotalSeconds : null;
o.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20));
o.StoreId = create.StoreId;
o.SetBlob(new PullPaymentBlob()
{
Name = create.Name ?? string.Empty,
@ -591,7 +592,7 @@ namespace BTCPayServer.HostedServices
await ctx.Payouts.AddAsync(payout);
try
{
await payoutHandler.TrackClaim(req.ClaimRequest.PaymentMethodId, req.ClaimRequest.Destination);
await payoutHandler.TrackClaim(req.ClaimRequest, payout);
await ctx.SaveChangesAsync();
if (req.ClaimRequest.PreApprove.GetValueOrDefault(ppBlob?.AutoApproveClaims is true))
{

View File

@ -80,12 +80,18 @@ namespace BTCPayServer.HostedServices
//if the object is an address, we also link the labels to the tx
if(walletObjectData.Value.Type == WalletObjectData.Types.Address)
{
var labels = walletObjectData.Value.GetNeighbours()
var neighbours = walletObjectData.Value.GetNeighbours().ToArray();
var labels = neighbours
.Where(data => data.Type == WalletObjectData.Types.Label).Select(data =>
new WalletObjectId(walletObjectDatas.Key, data.Type, data.Id));
foreach (var label in labels)
{
await _walletRepository.EnsureWalletObjectLink(label, txWalletObject);
var attachments = neighbours.Where(data => data.Type == label.Id);
foreach (var attachment in attachments)
{
await _walletRepository.EnsureWalletObjectLink(new WalletObjectId(walletObjectDatas.Key, attachment.Type, attachment.Id), txWalletObject);
}
}
}
}
@ -103,14 +109,8 @@ namespace BTCPayServer.HostedServices
{
Attachment.Invoice(invoiceEvent.Invoice.Id)
};
foreach (var paymentId in PaymentRequestRepository.GetPaymentIdsFromInternalTags(invoiceEvent.Invoice))
{
labels.Add(Attachment.PaymentRequest(paymentId));
}
foreach (var appId in AppService.GetAppInternalTags(invoiceEvent.Invoice))
{
labels.Add(Attachment.App(appId));
}
labels.AddRange(PaymentRequestRepository.GetPaymentIdsFromInternalTags(invoiceEvent.Invoice).Select(Attachment.PaymentRequest));
labels.AddRange(AppService.GetAppInternalTags(invoiceEvent.Invoice).Select(Attachment.App));
await _walletRepository.AddWalletTransactionAttachment(walletId, transactionId, labels);
break;

View File

@ -54,7 +54,7 @@ namespace BTCPayServer.Models.StoreViewModels
public string DefaultCurrency { get; set; }
[Display(Name = "Minimum acceptable expiration time for BOLT11 for refunds")]
[Range(1, 365 * 10)]
[Range(0, 365 * 10)]
public long BOLT11Expiration { get; set; }
}
}

View File

@ -26,7 +26,7 @@ namespace BTCPayServer.Models.StoreViewModels
public string DefaultCurrency { get; set; }
[Display(Name = "Minimum acceptable expiration time for BOLT11 for refunds")]
[Range(1, 365 * 10)]
[Range(0, 365 * 10)]
public long BOLT11Expiration { get; set; }
}
}

View File

@ -66,7 +66,7 @@ namespace BTCPayServer.Models.WalletViewModels
public IEnumerable<string> PaymentMethods { get; set; }
public IEnumerable<SelectListItem> PaymentMethodItems { get; set; }
[Display(Name = "Minimum acceptable expiration time for BOLT11 for refunds")]
[Range(1, 365 * 10)]
[Range(0, 365 * 10)]
public long BOLT11Expiration { get; set; } = 30;
[Display(Name = "Automatically approve claims")]
public bool AutoApproveClaims { get; set; } = false;

View File

@ -16,7 +16,7 @@ namespace BTCPayServer.Models.WalletViewModels
public bool Positive { get; set; }
public string Destination { get; set; }
public string Balance { get; set; }
public Dictionary<string, string> Labels { get; set; } = new();
public IEnumerable<TransactionTagModel> Labels { get; set; } = new List<TransactionTagModel>();
}
public class InputViewModel
@ -25,7 +25,7 @@ namespace BTCPayServer.Models.WalletViewModels
public string Error { get; set; }
public bool Positive { get; set; }
public string BalanceChange { get; set; }
public Dictionary<string, string> Labels { get; set; } = new();
public IEnumerable<TransactionTagModel> Labels { get; set; } = new List<TransactionTagModel>();
}
public bool HasErrors => Inputs.Count == 0 || Inputs.Any(i => !string.IsNullOrEmpty(i.Error));
public string BalanceChange { get; set; }

View File

@ -61,13 +61,13 @@ public class FakeCustodian : ICustodian, ICanDeposit, ICanWithdraw, ICanTrade
var generalFieldset = Field.CreateFieldset();
generalFieldset.Label = "General";
// TODO we cannot validate the custodian account ID because we have no access to the correct value. This is fine given this is a development tool and won't be needed by actual custodians.
var accountIdField = Field.Create("Custodian Account ID", "CustodianAccountId", null, true,
"Enter the ID of this custodian account. This is needed as a workaround which only applies to the Fake Custodian.");
var accountIdField = Field.Create("Custodian Account ID", "CustodianAccountId", null, false,
"Enter the ID of this custodian account. This is needed as a workaround which only applies to the Fake Custodian. Fill out correctly to make trading and withdrawing work.");
generalFieldset.Fields.Add(accountIdField);
// TODO we cannot validate the store ID because we have no access to the correct value. This is fine given this is a development tool and won't be needed by actual custodians.
var storeIdField = Field.Create("Store ID", "StoreId", null, true,
"Enter the ID of this store. This is needed as a workaround which only applies to the Fake Custodian.");
"Enter the ID of this store. This is needed as a workaround which only applies to the Fake Custodian. Fill out correctly to make trading and withdrawing work.");
generalFieldset.Fields.Add(storeIdField);
form.Fields.Add(generalFieldset);

View File

@ -318,10 +318,10 @@ namespace BTCPayServer.Services
public async Task<(string Label, string Color)[]> GetWalletLabels(WalletObjectId objectId)
{
return await GetWalletLabels(w =>
w.WalletId == objectId.WalletId.ToString() &&
w.Type == objectId.Type &&
w.Id == objectId.Id);
await using var ctx = _ContextFactory.CreateContext();
var obj = await GetWalletObject(objectId, true);
return obj is null ? Array.Empty<(string Label, string Color)>() : obj.GetNeighbours().Where(data => data.Type == WalletObjectData.Types.Label).Select(FormatToLabel).ToArray();
}
private async Task<(string Label, string Color)[]> GetWalletLabels(Expression<Func<WalletObjectData, bool>> predicate)

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}")"

View File

@ -35,10 +35,25 @@
{
<td>@input.Index</td>
}<td>
@foreach (var label in input.Labels)
{
<div class="transaction-label" style="--label-bg:@label.Value;--label-fg:@ColorPalette.Default.TextColor(label.Value)">@label.Key</div>
}
@if (input.Labels.Any())
{
<div class="d-flex flex-wrap gap-2 align-items-center">
@foreach (var label in input.Labels)
{
<div class="transaction-label" style="--label-bg:@label.Color;--label-fg:@label.TextColor">
<span>@label.Text</span>
@if (!string.IsNullOrEmpty(label.Link))
{
<a class="transaction-label-info transaction-details-icon" href="@label.Link"
target="_blank" rel="noreferrer noopener" title="@label.Tooltip" data-bs-html="true"
data-bs-toggle="tooltip" data-bs-custom-class="transaction-label-tooltip">
<vc:icon symbol="info" />
</a>
}
</div>
}
</div>
}
</td>
<td class="text-end text-@(input.Positive ? "success" : "danger")">@input.BalanceChange</td>
@ -64,11 +79,25 @@
<tr>
<td class="text-break">@destination.Destination</td>
<td>
@foreach (var label in destination.Labels)
{
<div class="transaction-label" style="--label-bg:@label.Value;--label-fg:@ColorPalette.Default.TextColor(label.Value)">@label.Key</div>
}
@if (destination.Labels.Any())
{
<div class="d-flex flex-wrap gap-2 align-items-center">
@foreach (var label in destination.Labels)
{
<div class="transaction-label" style="--label-bg:@label.Color;--label-fg:@label.TextColor">
<span>@label.Text</span>
@if (!string.IsNullOrEmpty(label.Link))
{
<a class="transaction-label-info transaction-details-icon" href="@label.Link"
target="_blank" rel="noreferrer noopener" title="@label.Tooltip" data-bs-html="true"
data-bs-toggle="tooltip" data-bs-custom-class="transaction-label-tooltip">
<vc:icon symbol="info" />
</a>
}
</div>
}
</div>
}
</td>
<td class="text-end text-@(destination.Positive ? "success" : "danger")">@destination.Balance</td>

View File

@ -10,6 +10,8 @@ We introduce another flag, `--deprecated`, which allows you to start with SQLite
### New features
* Can customize invoice's metadata for payments received through LN Address. (#4855) @Kukks
* The payRequest of an invoice from LNUrl are now saved inside the invoice's metadata (#4855) @NicolasDorier
* NFC: If browser permission is already granted, do not require the merchant to click on the "Pay by NFC" button. (#4807 #4819) @dennisreimann
* Point of Sales bought items will now appear on the receipt (#4851) @Kukks
* Add payment proof to the receipt, such as transaction ID or Lightning preimage (#4782) @dennisreimann
@ -28,6 +30,10 @@ We introduce another flag, `--deprecated`, which allows you to start with SQLite
### Bug fixes
* LN Address's Max sats payment was ignored. @NicolasDorier
* The preferred currency of a Point of Sale's App was ignored when paying through LNURL. @NicolasDorier
* The payRequest generated by LNAddress wasn't the same as the one generated by the callback (losing information about Min/Max spendable)
* With core lightning, getting payment by paymenthash wouldn't return the successful payment if the first one failed.
* Do not propose Lightning payment if the LN Node is dead (#4795 #3541) @Kukks
* Point of Sale: Fix escaped HTML entities in item title (#4798) @dennisreimann
* Fix: Labels added by payouts to transactions shouldn't show HTML markups (#4790) @dennisreimann