diff --git a/BTCPayServer.Data/Data/LightingAddressData.cs b/BTCPayServer.Data/Data/LightingAddressData.cs index f5115c02d..7704119be 100644 --- a/BTCPayServer.Data/Data/LightingAddressData.cs +++ b/BTCPayServer.Data/Data/LightingAddressData.cs @@ -43,7 +43,7 @@ public class LightningAddressDataBlob public decimal? Max { get; set; } public JObject InvoiceMetadata { get; set; } - + public string PullPaymentId { get; set; } [JsonExtensionData] public Dictionary AdditionalData { get; set; } } diff --git a/BTCPayServer.Tests/SeleniumTester.cs b/BTCPayServer.Tests/SeleniumTester.cs index 09662aab9..ee42a368e 100644 --- a/BTCPayServer.Tests/SeleniumTester.cs +++ b/BTCPayServer.Tests/SeleniumTester.cs @@ -18,6 +18,7 @@ using NBitcoin; using NBitcoin.RPC; using OpenQA.Selenium; using OpenQA.Selenium.Chrome; +using OpenQA.Selenium.Support.Extensions; using OpenQA.Selenium.Support.UI; using Xunit; diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index 8175aa20a..675a262cf 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -16,6 +16,7 @@ using BTCPayServer.Client; using BTCPayServer.Client.Models; using BTCPayServer.Controllers; using BTCPayServer.Data; +using BTCPayServer.HostedServices; using BTCPayServer.Lightning; using BTCPayServer.Models.InvoicingModels; using BTCPayServer.NTag424; @@ -2118,7 +2119,6 @@ namespace BTCPayServer.Tests }); s.GoToHome(); //offline/external payout test - var newStore = s.CreateNewStore(); s.GenerateWallet("BTC", "", true, true); s.GoToStore(s.StoreId, StoreNavPages.PullPayments); @@ -2322,6 +2322,23 @@ namespace BTCPayServer.Tests // p and c should work so long as no bolt11 has been submitted info = (LNURLWithdrawRequest)await LNURL.LNURL.FetchInformation(boltcardUrl, s.Server.PayTester.HttpClient); info = (LNURLWithdrawRequest)await LNURL.LNURL.FetchInformation(boltcardUrl, s.Server.PayTester.HttpClient); + Assert.NotNull(info.PayLink); + Assert.StartsWith("lnurlp://", info.PayLink.AbsoluteUri); + // Ignore certs issue + info.PayLink = new Uri(info.PayLink.AbsoluteUri.Replace("lnurlp://", "http://"), UriKind.Absolute); + var payReq = (LNURLPayRequest)await LNURL.LNURL.FetchInformation(info.PayLink, s.Server.PayTester.HttpClient); + var callback = await payReq.SendRequest(LightMoney.Satoshis(100), Network.RegTest, s.Server.PayTester.HttpClient); + Assert.NotNull(callback.Pr); + var res = await s.Server.CustomerLightningD.Pay(callback.Pr); + Assert.Equal(PayResult.Ok, res.Result); + var ppService = s.Server.PayTester.GetService(); + var serializer = s.Server.PayTester.GetService(); + await TestUtils.EventuallyAsync(async () => + { + var pp = await ppService.GetPullPayment(ppid, true); + Assert.Contains(pp.Payouts.Select(p => p.GetBlob(serializer)), p => p.CryptoAmount == -LightMoney.Satoshis(100).ToUnit(LightMoneyUnit.BTC)); + }); + var fakeBoltcardUrl = new Uri(Regex.Replace(boltcardUrl.AbsoluteUri, "p=([A-F0-9]{32})", $"p={RandomBytes(16)}")); await Assert.ThrowsAsync(() => LNURL.LNURL.FetchInformation(fakeBoltcardUrl, s.Server.PayTester.HttpClient)); fakeBoltcardUrl = new Uri(Regex.Replace(boltcardUrl.AbsoluteUri, "c=([A-F0-9]{16})", $"c={RandomBytes(8)}")); diff --git a/BTCPayServer/Controllers/UIBoltcardController.cs b/BTCPayServer/Controllers/UIBoltcardController.cs index fd81ab004..d1a881bd4 100644 --- a/BTCPayServer/Controllers/UIBoltcardController.cs +++ b/BTCPayServer/Controllers/UIBoltcardController.cs @@ -14,11 +14,20 @@ using System.Threading; using System; using NBitcoin.DataEncoders; using System.Text.Json.Serialization; +using BTCPayServer.HostedServices; +using BTCPayServer.Services.Stores; +using System.Collections.Generic; +using BTCPayServer.Client.Models; +using BTCPayServer.Lightning; +using System.Reflection.Metadata; namespace BTCPayServer.Controllers; public class UIBoltcardController : Controller { + private readonly PullPaymentHostedService _ppService; + private readonly StoreRepository _storeRepository; + public class BoltcardSettings { [JsonConverter(typeof(NBitcoin.JsonConverters.HexJsonConverter))] @@ -28,11 +37,15 @@ public class UIBoltcardController : Controller UILNURLController lnUrlController, SettingsRepository settingsRepository, ApplicationDbContextFactory contextFactory, + PullPaymentHostedService ppService, + StoreRepository storeRepository, BTCPayServerEnvironment env) { LNURLController = lnUrlController; SettingsRepository = settingsRepository; ContextFactory = contextFactory; + _ppService = ppService; + _storeRepository = storeRepository; Env = env; } @@ -41,6 +54,50 @@ public class UIBoltcardController : Controller public ApplicationDbContextFactory ContextFactory { get; } public BTCPayServerEnvironment Env { get; } + [AllowAnonymous] + [HttpGet("~/boltcard/pay")] + public async Task GetPayRequest([FromQuery] string? p, [FromQuery] long? amount = null) + { + var issuerKey = await SettingsRepository.GetIssuerKey(Env); + var piccData = issuerKey.TryDecrypt(p); + if (piccData is null) + return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Invalid PICCData" }); + + piccData = new BoltcardPICCData(piccData.Uid, int.MaxValue - 10); // do not check the counter + var registration = await ContextFactory.GetBoltcardRegistration(issuerKey, piccData, false); + var pp = await _ppService.GetPullPayment(registration!.PullPaymentId, false); + var store = await _storeRepository.FindStore(pp.StoreId); + var payRequest = new LNURLPayRequest + { + Tag = "payRequest", + MinSendable = LightMoney.Satoshis(1.0m), + MaxSendable = LightMoney.FromUnit(6.12m, LightMoneyUnit.BTC), + Callback = new Uri(GetPayLink(p, Request.Scheme), UriKind.Absolute), + CommentAllowed = 0 + }; + if (amount is null) + return Ok(payRequest); + + var cryptoCode = "BTC"; + LNURLController.ControllerContext.HttpContext = HttpContext; + var result = await LNURLController.GetLNURLRequest( + cryptoCode, + store, + store.GetStoreBlob(), + new CreateInvoiceRequest() + { + Currency = "BTC", + Amount = LightMoney.FromUnit(amount.Value, LightMoneyUnit.MilliSatoshi).ToUnit(LightMoneyUnit.BTC) + }, + payRequest, + null, + [PullPaymentHostedService.GetInternalTag(pp.Id)]); + if (result is not OkObjectResult ok || ok.Value is not LNURLPayRequest payRequest2) + return result; + payRequest = payRequest2; + var invoiceId = payRequest.Callback.AbsoluteUri.Split('/').Last(); + return await LNURLController.GetLNURLForInvoice(invoiceId, cryptoCode, amount.Value, null); + } [AllowAnonymous] [HttpGet("~/boltcard")] public async Task GetWithdrawRequest([FromQuery] string? p, [FromQuery] string? c, [FromQuery] string? pr, [FromQuery] string? k1, CancellationToken cancellationToken) @@ -65,6 +122,16 @@ public class UIBoltcardController : Controller if (!cardKey.CheckSunMac(c, piccData)) return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Replayed or expired query" }); LNURLController.ControllerContext.HttpContext = HttpContext; - return await LNURLController.GetLNURLForPullPayment("BTC", registration.PullPaymentId, pr, $"{p}-{c}", cancellationToken); + var res = await LNURLController.GetLNURLForPullPayment("BTC", registration.PullPaymentId, pr, $"{p}-{c}", cancellationToken); + if (res is not OkObjectResult ok || ok.Value is not LNURLWithdrawRequest withdrawRequest) + return res; + var paylink = GetPayLink(p, "lnurlp"); + withdrawRequest.PayLink = new Uri(paylink, UriKind.Absolute); + return res; + } + + private string GetPayLink(string? p, string scheme) + { + return Url.Action(nameof(GetPayRequest), "UIBoltcard", new { p }, scheme)!; } } diff --git a/BTCPayServer/Controllers/UILNURLController.cs b/BTCPayServer/Controllers/UILNURLController.cs index 7534deed5..af832fdb3 100644 --- a/BTCPayServer/Controllers/UILNURLController.cs +++ b/BTCPayServer/Controllers/UILNURLController.cs @@ -26,6 +26,7 @@ using BTCPayServer.Plugins.PointOfSale.Models; using BTCPayServer.Services; using BTCPayServer.Services.Apps; using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.PaymentRequests; using BTCPayServer.Services.Rates; using BTCPayServer.Services.Stores; using LNURL; @@ -436,6 +437,13 @@ namespace BTCPayServer var store = await _storeRepository.FindStore(lightningAddressSettings.StoreDataId); if (store is null) return NotFound("Unknown username"); + List additionalTags = new List(); + if (blob?.PullPaymentId is not null) + { + var pp = await _pullPaymentHostedService.GetPullPayment(blob.PullPaymentId, false); + if (pp != null) + additionalTags.Add(PullPaymentHostedService.GetInternalTag(blob.PullPaymentId)); + } var result = await GetLNURLRequest( cryptoCode, store, @@ -453,7 +461,7 @@ namespace BTCPayServer new Dictionary { { "text/identifier", $"{username}@{Request.Host}" } - }); + }, additionalTags); if (result is not OkObjectResult ok || ok.Value is not LNURLPayRequest payRequest) return result; var invoiceId = payRequest.Callback.AbsoluteUri.Split('/').Last(); @@ -495,7 +503,7 @@ namespace BTCPayServer }); } - private async Task GetLNURLRequest( + internal async Task GetLNURLRequest( string cryptoCode, Data.StoreData store, Data.StoreBlob blob, diff --git a/BTCPayServer/Controllers/UIPullPaymentController.cs b/BTCPayServer/Controllers/UIPullPaymentController.cs index f48defaa6..66f551400 100644 --- a/BTCPayServer/Controllers/UIPullPaymentController.cs +++ b/BTCPayServer/Controllers/UIPullPaymentController.cs @@ -276,7 +276,8 @@ namespace BTCPayServer.Controllers Destination = destination, PullPaymentId = pullPaymentId, Value = vm.ClaimedAmount, - PaymentMethodId = paymentMethodId + PaymentMethodId = paymentMethodId, + StoreId = pp.StoreId }); if (result.Result != ClaimRequest.ClaimResult.Ok) diff --git a/BTCPayServer/HostedServices/PullPaymentHostedService.cs b/BTCPayServer/HostedServices/PullPaymentHostedService.cs index 95470994b..643595588 100644 --- a/BTCPayServer/HostedServices/PullPaymentHostedService.cs +++ b/BTCPayServer/HostedServices/PullPaymentHostedService.cs @@ -7,11 +7,13 @@ using System.Threading.Tasks; using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Client.Models; using BTCPayServer.Data; +using BTCPayServer.Events; using BTCPayServer.Logging; using BTCPayServer.Models.WalletViewModels; using BTCPayServer.Payments; using BTCPayServer.Rating; using BTCPayServer.Services; +using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Notifications; using BTCPayServer.Services.Notifications.Blobs; using BTCPayServer.Services.Rates; @@ -47,7 +49,7 @@ namespace BTCPayServer.HostedServices public class PullPaymentHostedService : BaseAsyncService { private readonly string[] _lnurlSupportedCurrencies = { "BTC", "SATS" }; - + public class CancelRequest { public CancelRequest(string pullPaymentId) @@ -282,7 +284,7 @@ namespace BTCPayServer.HostedServices return await query.FirstOrDefaultAsync(data => data.Id == pullPaymentId); } - + record TopUpRequest(string PullPaymentId, InvoiceEntity InvoiceEntity); class PayoutRequest { public PayoutRequest(TaskCompletionSource completionSource, @@ -292,6 +294,8 @@ namespace BTCPayServer.HostedServices ArgumentNullException.ThrowIfNull(completionSource); Completion = completionSource; ClaimRequest = request; + if (request.StoreId is null) + throw new ArgumentNullException(nameof(request.StoreId)); } public TaskCompletionSource Completion { get; set; } @@ -342,10 +346,20 @@ namespace BTCPayServer.HostedServices { payoutHandler.StartBackgroundCheck(Subscribe); } - + _eventAggregator.Subscribe(TopUpInvoice); return new[] { Loop() }; } + private void TopUpInvoice(InvoiceEvent evt) + { + if (evt.EventCode == InvoiceEventCode.Completed) + { + foreach (var pullPaymentId in evt.Invoice.GetInternalTags("PULLPAY#")) + { + _Channel.Writer.TryWrite(new TopUpRequest(pullPaymentId, evt.Invoice)); + } + } + } private void Subscribe(params Type[] events) { foreach (Type @event in events) @@ -358,6 +372,10 @@ namespace BTCPayServer.HostedServices { await foreach (var o in _Channel.Reader.ReadAllAsync()) { + if (o is TopUpRequest topUp) + { + await HandleTopUp(topUp); + } if (o is PayoutRequest req) { await HandleCreatePayout(req); @@ -392,10 +410,40 @@ namespace BTCPayServer.HostedServices } } + private async Task HandleTopUp(TopUpRequest topUp) + { + var pp = await this.GetPullPayment(topUp.PullPaymentId, false); + using var ctx = _dbContextFactory.CreateContext(); + + var payout = new Data.PayoutData() + { + Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)), + Date = DateTimeOffset.UtcNow, + State = PayoutState.Completed, + PullPaymentDataId = pp.Id, + PaymentMethodId = new PaymentMethodId("BTC", PaymentTypes.LightningLike).ToString(), + Destination = null, + StoreDataId = pp.StoreId + }; + var rate = topUp.InvoiceEntity.Rates["BTC"]; + var cryptoAmount = Math.Round(topUp.InvoiceEntity.PaidAmount.Net / rate, 11); + + var payoutBlob = new PayoutBlob() + { + CryptoAmount = -cryptoAmount, + Amount = -topUp.InvoiceEntity.PaidAmount.Net, + Destination = null, + Metadata = new JObject(), + }; + payout.SetBlob(payoutBlob, _jsonSerializerSettings); + await ctx.Payouts.AddAsync(payout); + await ctx.SaveChangesAsync(); + } + public bool SupportsLNURL(PullPaymentBlob blob) { - var pms = blob.SupportedPaymentMethods.FirstOrDefault(id => - id.PaymentType == LightningPaymentType.Instance && + var pms = blob.SupportedPaymentMethods.FirstOrDefault(id => + id.PaymentType == LightningPaymentType.Instance && _networkProvider.DefaultNetwork.CryptoCode == id.CryptoCode); return pms is not null && _lnurlSupportedCurrencies.Contains(blob.Currency); } @@ -652,7 +700,7 @@ namespace BTCPayServer.HostedServices { Amount = claimed, Destination = req.ClaimRequest.Destination.ToString(), - Metadata = req.ClaimRequest.Metadata?? new JObject(), + Metadata = req.ClaimRequest.Metadata ?? new JObject(), }; payout.SetBlob(payoutBlob, _jsonSerializerSettings); await ctx.Payouts.AddAsync(payout); @@ -845,6 +893,10 @@ namespace BTCPayServer.HostedServices return time; } + public static string GetInternalTag(string id) + { + return $"PULLPAY#{id}"; + } class InternalPayoutPaidRequest { @@ -899,25 +951,25 @@ namespace BTCPayServer.HostedServices { null when destination.Amount is null && ppCurrency is null => ("Amount is not specified in destination or payout request", null), null when destination.Amount is null => (null, null), - null when destination.Amount != null => (null,destination.Amount), - not null when destination.Amount is null => (null,amount), + null when destination.Amount != null => (null, destination.Amount), + not null when destination.Amount is null => (null, amount), not null when destination.Amount != null && amount != destination.Amount && destination.IsExplicitAmountMinimum && payoutCurrency == "BTC" && ppCurrency == "SATS" && new Money(amount.Value, MoneyUnit.Satoshi).ToUnit(MoneyUnit.BTC) < destination.Amount => - ($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount",null), + ($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount", null), not null when destination.Amount != null && amount != destination.Amount && destination.IsExplicitAmountMinimum && !(payoutCurrency == "BTC" && ppCurrency == "SATS") && amount < destination.Amount => - ($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount",null), + ($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount", null), not null when destination.Amount != null && amount != destination.Amount && !destination.IsExplicitAmountMinimum => ($"Amount is implied in destination ({destination.Amount}) that does not match the payout amount provided {amount})", null), _ => (null, amount) }; } - + public static string GetErrorMessage(ClaimResult result) { switch (result) diff --git a/BTCPayServer/Plugins/BoltcardTopUp/Controllers/UIBoltcardTopUpController.cs b/BTCPayServer/Plugins/BoltcardTopUp/Controllers/UIBoltcardTopUpController.cs index 9c3a26f37..5b6cbad83 100644 --- a/BTCPayServer/Plugins/BoltcardTopUp/Controllers/UIBoltcardTopUpController.cs +++ b/BTCPayServer/Plugins/BoltcardTopUp/Controllers/UIBoltcardTopUpController.cs @@ -184,7 +184,7 @@ namespace BTCPayServer.Plugins.BoltcardTopUp.Controllers { Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)), Date = DateTimeOffset.UtcNow, - State = PayoutState.AwaitingApproval, + State = PayoutState.Completed, PullPaymentDataId = registration.PullPaymentId, PaymentMethodId = new PaymentMethodId("BTC", PaymentTypes.LightningLike).ToString(), Destination = null, diff --git a/BTCPayServer/Plugins/BoltcardTopUp/Views/NavExtension.cshtml b/BTCPayServer/Plugins/BoltcardTopUp/Views/NavExtension.cshtml index 55c38330a..3a2359a69 100644 --- a/BTCPayServer/Plugins/BoltcardTopUp/Views/NavExtension.cshtml +++ b/BTCPayServer/Plugins/BoltcardTopUp/Views/NavExtension.cshtml @@ -7,12 +7,14 @@ @using BTCPayServer @{ - var storeId = Context.GetStoreData().Id; + var storeId = Context.GetStoreData()?.Id; +} +@if (storeId != null) +{ + } - - diff --git a/BTCPayServer/Services/WalletRepository.cs b/BTCPayServer/Services/WalletRepository.cs index 1ca22c155..8f6b9ca4e 100644 --- a/BTCPayServer/Services/WalletRepository.cs +++ b/BTCPayServer/Services/WalletRepository.cs @@ -487,6 +487,7 @@ namespace BTCPayServer.Services public static WalletObjectData NewWalletObjectData(WalletObjectId id, JObject? data = null) { + return new WalletObjectData() { WalletId = id.WalletId.ToString(),