diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index 53553ac1f..2452e2309 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -1074,6 +1074,22 @@ namespace BTCPayServer.Tests var lnrURLs = await unauthenticated.GetPullPaymentLNURL(test4.Id); Assert.IsType(lnrURLs.LNURLBech32); Assert.IsType(lnrURLs.LNURLUri); + Assert.Equal(12.303228134m, test4.Amount); + Assert.Equal("BTC", test4.Currency); + + // Test with SATS denomination values + var testSats = await client.CreatePullPayment(storeId, new Client.Models.CreatePullPaymentRequest() + { + Name = "Test SATS", + Amount = 21000, + Currency = "SATS", + PaymentMethods = new[] { "BTC", "BTC-LightningNetwork", "BTC_LightningLike" } + }); + lnrURLs = await unauthenticated.GetPullPaymentLNURL(testSats.Id); + Assert.IsType(lnrURLs.LNURLBech32); + Assert.IsType(lnrURLs.LNURLUri); + Assert.Equal(21000, testSats.Amount); + Assert.Equal("SATS", testSats.Currency); //permission test around auto approved pps and payouts var nonApproved = await acc.CreateClient(Policies.CanCreateNonApprovedPullPayments); diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index 31c1f01a9..02f26ab7b 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -1748,7 +1748,6 @@ namespace BTCPayServer.Tests Assert.Contains(labels, element => element.Text == "pull-payment"); }); - s.GoToStore(s.StoreId, StoreNavPages.Payouts); s.Driver.FindElement(By.Id($"{PayoutState.InProgress}-view")).Click(); ReadOnlyCollection txs; @@ -1932,8 +1931,7 @@ namespace BTCPayServer.Tests Assert.Contains(PayoutState.AwaitingPayment.GetStateString(), s.Driver.PageSource); - //lnurl-w support check - + // LNURL Withdraw support check with BTC denomination s.GoToStore(s.StoreId, StoreNavPages.PullPayments); s.Driver.FindElement(By.Id("NewPullPayment")).Click(); s.Driver.FindElement(By.Id("Name")).SendKeys("PP1"); @@ -2001,6 +1999,42 @@ namespace BTCPayServer.Tests Assert.Contains(PayoutState.AwaitingApproval.GetStateString(), s.Driver.PageSource); }); + + // LNURL Withdraw support check with SATS denomination + s.GoToStore(s.StoreId, StoreNavPages.PullPayments); + s.Driver.FindElement(By.Id("NewPullPayment")).Click(); + s.Driver.FindElement(By.Id("Name")).SendKeys("PP SATS"); + s.Driver.SetCheckbox(By.Id("AutoApproveClaims"), true); + s.Driver.FindElement(By.Id("Amount")).Clear(); + s.Driver.FindElement(By.Id("Amount")).SendKeys("21021"); + s.Driver.FindElement(By.Id("Currency")).Clear(); + s.Driver.FindElement(By.Id("Currency")).SendKeys("SATS" + Keys.Enter); + s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success); + s.Driver.FindElement(By.LinkText("View")).Click(); + s.Driver.FindElement(By.CssSelector("#lnurlwithdraw-button")).Click(); + lnurl = new Uri(LNURL.LNURL.Parse(s.Driver.FindElement(By.Id("qr-code-data-input")).GetAttribute("value"), out _).ToString().Replace("https", "http")); + s.Driver.FindElement(By.CssSelector("button[data-bs-dismiss='modal']")).Click(); + var amount = new LightMoney(21021, LightMoneyUnit.Satoshi); + info = Assert.IsType(await LNURL.LNURL.FetchInformation(lnurl, s.Server.PayTester.HttpClient)); + Assert.Equal(amount, info.MaxWithdrawable); + Assert.Equal(amount, info.CurrentBalance); + info = Assert.IsType(await LNURL.LNURL.FetchInformation(info.BalanceCheck, s.Server.PayTester.HttpClient)); + Assert.Equal(amount, info.MaxWithdrawable); + Assert.Equal(amount, info.CurrentBalance); + + bolt2 = (await s.Server.CustomerLightningD.CreateInvoice( + amount, + $"LNurl w payout test {DateTime.UtcNow.Ticks}", + TimeSpan.FromHours(1), CancellationToken.None)); + response = await info.SendRequest(bolt2.BOLT11, s.Server.PayTester.HttpClient); + await TestUtils.EventuallyAsync(async () => + { + s.Driver.Navigate().Refresh(); + Assert.Contains(bolt2.BOLT11, s.Driver.PageSource); + + Assert.Contains(PayoutState.Completed.GetStateString(), s.Driver.PageSource); + Assert.Equal(LightningInvoiceStatus.Paid, (await s.Server.CustomerLightningD.GetInvoice(bolt2.Id)).Status); + }); } [Fact] diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldPullPaymentController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldPullPaymentController.cs index d9db98351..d2d31593d 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldPullPaymentController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldPullPaymentController.cs @@ -255,16 +255,15 @@ namespace BTCPayServer.Controllers.Greenfield return PullPaymentNotFound(); var blob = pp.GetBlob(); - var pms = blob.SupportedPaymentMethods.FirstOrDefault(id => id.PaymentType == LightningPaymentType.Instance && _networkProvider.DefaultNetwork.CryptoCode == id.CryptoCode); - if (pms is not null && blob.Currency.Equals(pms.CryptoCode, StringComparison.InvariantCultureIgnoreCase)) + if (_pullPaymentService.SupportsLNURL(blob)) { var lnurlEndpoint = new Uri(Url.Action("GetLNURLForPullPayment", "UILNURL", new { cryptoCode = _networkProvider.DefaultNetwork.CryptoCode, - pullPaymentId = pullPaymentId + pullPaymentId }, Request.Scheme, Request.Host.ToString())!); - return base.Ok(new PullPaymentLNURL() + return base.Ok(new PullPaymentLNURL { LNURLBech32 = LNURL.LNURL.EncodeUri(lnurlEndpoint, "withdrawRequest", true).ToString(), LNURLUri = LNURL.LNURL.EncodeUri(lnurlEndpoint, "withdrawRequest", false).ToString() diff --git a/BTCPayServer/Controllers/UILNURLController.cs b/BTCPayServer/Controllers/UILNURLController.cs index 7a4559da4..c6f17639a 100644 --- a/BTCPayServer/Controllers/UILNURLController.cs +++ b/BTCPayServer/Controllers/UILNURLController.cs @@ -109,24 +109,24 @@ namespace BTCPayServer } var blob = pp.GetBlob(); - if (!blob.Currency.Equals(cryptoCode, StringComparison.InvariantCultureIgnoreCase)) + if (!_pullPaymentHostedService.SupportsLNURL(blob)) { return NotFound(); } + var unit = blob.Currency == "SATS" ? LightMoneyUnit.Satoshi : LightMoneyUnit.BTC; var progress = _pullPaymentHostedService.CalculatePullPaymentProgress(pp, DateTimeOffset.UtcNow); - var remaining = progress.Limit - progress.Completed - progress.Awaiting; var request = new LNURLWithdrawRequest { - MaxWithdrawable = LightMoney.FromUnit(remaining, LightMoneyUnit.BTC), + MaxWithdrawable = LightMoney.FromUnit(remaining, unit), K1 = pullPaymentId, BalanceCheck = new Uri(Request.GetCurrentUrl()), - CurrentBalance = LightMoney.FromUnit(remaining, LightMoneyUnit.BTC), + CurrentBalance = LightMoney.FromUnit(remaining, unit), MinWithdrawable = LightMoney.FromUnit( Math.Min(await _lightningLikePayoutHandler.GetMinimumPayoutAmount(pmi, null), remaining), - LightMoneyUnit.BTC), + unit), Tag = "withdrawRequest", Callback = new Uri(Request.GetCurrentUrl()), // It's not `pp.GetBlob().Description` because this would be HTML @@ -154,13 +154,13 @@ namespace BTCPayServer return NotFound(); } - var claimResponse = await _pullPaymentHostedService.Claim(new ClaimRequest() + var claimResponse = await _pullPaymentHostedService.Claim(new ClaimRequest { Destination = new BoltInvoiceClaimDestination(pr, result), PaymentMethodId = pmi, PullPaymentId = pullPaymentId, StoreId = pp.StoreId, - Value = result.MinimumAmount.ToDecimal(LightMoneyUnit.BTC) + Value = result.MinimumAmount.ToDecimal(unit) }); if (claimResponse.Result != ClaimRequest.ClaimResult.Ok) diff --git a/BTCPayServer/Controllers/UIPullPaymentController.cs b/BTCPayServer/Controllers/UIPullPaymentController.cs index e56db6b5c..f384040c8 100644 --- a/BTCPayServer/Controllers/UIPullPaymentController.cs +++ b/BTCPayServer/Controllers/UIPullPaymentController.cs @@ -29,6 +29,7 @@ namespace BTCPayServer.Controllers private readonly CurrencyNameTable _currencyNameTable; private readonly DisplayFormatter _displayFormatter; private readonly PullPaymentHostedService _pullPaymentHostedService; + private readonly BTCPayNetworkProvider _networkProvider; private readonly BTCPayNetworkJsonSerializerSettings _serializerSettings; private readonly IEnumerable _payoutHandlers; private readonly StoreRepository _storeRepository; @@ -37,6 +38,7 @@ namespace BTCPayServer.Controllers CurrencyNameTable currencyNameTable, DisplayFormatter displayFormatter, PullPaymentHostedService pullPaymentHostedService, + BTCPayNetworkProvider networkProvider, BTCPayNetworkJsonSerializerSettings serializerSettings, IEnumerable payoutHandlers, StoreRepository storeRepository) @@ -48,6 +50,7 @@ namespace BTCPayServer.Controllers _serializerSettings = serializerSettings; _payoutHandlers = payoutHandlers; _storeRepository = storeRepository; + _networkProvider = networkProvider; } [AllowAnonymous] @@ -102,6 +105,13 @@ namespace BTCPayServer.Controllers }).ToList() }; vm.IsPending &= vm.AmountDue > 0.0m; + + if (_pullPaymentHostedService.SupportsLNURL(blob)) + { + var url = Url.Action("GetLNURLForPullPayment", "UILNURL", new { cryptoCode = _networkProvider.DefaultNetwork.CryptoCode, pullPaymentId = vm.Id }, Request.Scheme, Request.Host.ToString()); + vm.LnurlEndpoint = url != null ? new Uri(url) : null; + } + return View(nameof(ViewPullPayment), vm); } diff --git a/BTCPayServer/HostedServices/PullPaymentHostedService.cs b/BTCPayServer/HostedServices/PullPaymentHostedService.cs index 06d21f3a4..3f94e4ad3 100644 --- a/BTCPayServer/HostedServices/PullPaymentHostedService.cs +++ b/BTCPayServer/HostedServices/PullPaymentHostedService.cs @@ -46,6 +46,8 @@ namespace BTCPayServer.HostedServices public class PullPaymentHostedService : BaseAsyncService { + private readonly string[] _lnurlSupportedCurrencies = { "BTC", "SATS" }; + public class CancelRequest { public CancelRequest(string pullPaymentId) @@ -337,6 +339,14 @@ namespace BTCPayServer.HostedServices } } + public bool SupportsLNURL(PullPaymentBlob blob) + { + var pms = blob.SupportedPaymentMethods.FirstOrDefault(id => + id.PaymentType == LightningPaymentType.Instance && + _networkProvider.DefaultNetwork.CryptoCode == id.CryptoCode); + return pms is not null && _lnurlSupportedCurrencies.Contains(blob.Currency); + } + public Task GetRate(PayoutData payout, string explicitRateRule, CancellationToken cancellationToken) { var ppBlob = payout.PullPaymentData?.GetBlob(); diff --git a/BTCPayServer/Models/ViewPullPaymentModel.cs b/BTCPayServer/Models/ViewPullPaymentModel.cs index 09a811d62..113c08b88 100644 --- a/BTCPayServer/Models/ViewPullPaymentModel.cs +++ b/BTCPayServer/Models/ViewPullPaymentModel.cs @@ -96,6 +96,7 @@ namespace BTCPayServer.Models public DateTimeOffset StartDate { get; set; } public DateTime LastRefreshed { get; set; } public CurrencyData CurrencyData { get; set; } + public Uri LnurlEndpoint { get; set; } public bool Archived { get; set; } public bool AutoApprove { get; set; } diff --git a/BTCPayServer/Views/UIPullPayment/ViewPullPayment.cshtml b/BTCPayServer/Views/UIPullPayment/ViewPullPayment.cshtml index 191e1b5ff..1623961ab 100644 --- a/BTCPayServer/Views/UIPullPayment/ViewPullPayment.cshtml +++ b/BTCPayServer/Views/UIPullPayment/ViewPullPayment.cshtml @@ -1,11 +1,9 @@ @using BTCPayServer.Client -@using BTCPayServer.Payments @using BTCPayServer.Services @using Microsoft.AspNetCore.Mvc.TagHelpers @using BTCPayServer.Abstractions.TagHelpers @inject BTCPayServer.Security.ContentSecurityPolicies Csp @inject BTCPayServerEnvironment Env -@inject BTCPayNetworkProvider BtcPayNetworkProvider @inject DisplayFormatter DisplayFormatter @model BTCPayServer.Models.ViewPullPaymentModel @{ @@ -25,22 +23,6 @@ return "bg-warning"; } } - - - string lnurl = null; - string lnurlUri = null; - - var pms = Model.PaymentMethods.FirstOrDefault(id => id.PaymentType == LightningPaymentType.Instance && BtcPayNetworkProvider.DefaultNetwork.CryptoCode == id.CryptoCode); - if (pms is not null && Model.Currency.Equals(pms.CryptoCode, StringComparison.InvariantCultureIgnoreCase)) - { - var lnurlEndpoint = new Uri(Url.Action("GetLNURLForPullPayment", "UILNURL", new - { - cryptoCode = pms.CryptoCode, - pullPaymentId = Model.Id - }, Context.Request.Scheme, Context.Request.Host.ToString())); - lnurl = LNURL.LNURL.EncodeUri(lnurlEndpoint, "withdrawRequest", true).ToString(); - lnurlUri = LNURL.LNURL.EncodeUri(lnurlEndpoint, "withdrawRequest", false).ToString(); - } } @@ -62,7 +44,7 @@
- @if (lnurl is not null) + @if (Model.LnurlEndpoint is not null) {
- @if (lnurl is not null) + @if (Model.LnurlEndpoint is not null) { + var lnurlUri = LNURL.LNURL.EncodeUri(Model.LnurlEndpoint, "withdrawRequest", false).ToString(); + var lnurlBech32 = LNURL.LNURL.EncodeUri(Model.LnurlEndpoint, "withdrawRequest", true).ToString(); var note = "You can scan or open this link with a LNURL-Withdraw enabled wallet."; if (!Model.AutoApprove) { @@ -237,7 +221,7 @@ document.addEventListener("DOMContentLoaded", () => { const modes = { uri: { title: "URI", fragments: [@Safe.Json(lnurlUri)], showData: true, href: @Safe.Json(lnurlUri) }, - bech32: { title: "Bech32", fragments: [@Safe.Json(lnurl)], showData: true, href: @Safe.Json(lnurl) } + bech32: { title: "Bech32", fragments: [@Safe.Json(lnurlBech32)], showData: true, href: @Safe.Json(lnurlBech32) } }; initQRShow({ title: "LNURL Withdraw", note: @Safe.Json(note), modes }) });