diff --git a/BTCPayServer.Tests/SeleniumTester.cs b/BTCPayServer.Tests/SeleniumTester.cs index fefbb99fa..fb0929af9 100644 --- a/BTCPayServer.Tests/SeleniumTester.cs +++ b/BTCPayServer.Tests/SeleniumTester.cs @@ -16,6 +16,7 @@ using BTCPayServer.Views.Wallets; using Microsoft.Extensions.Configuration; using NBitcoin; using BTCPayServer.BIP78.Sender; +using Microsoft.EntityFrameworkCore.Internal; using OpenQA.Selenium; using OpenQA.Selenium.Chrome; using OpenQA.Selenium.Support.Extensions; @@ -95,8 +96,12 @@ namespace BTCPayServer.Tests public Uri ServerUri; internal IWebElement FindAlertMessage(StatusMessageModel.StatusSeverity severity = StatusMessageModel.StatusSeverity.Success) { - var className = $"alert-{StatusMessageModel.ToString(severity)}"; - var el = Driver.FindElement(By.ClassName(className)) ?? Driver.WaitForElement(By.ClassName(className)); + return FindAlertMessage(new[] {severity}); + } + internal IWebElement FindAlertMessage(params StatusMessageModel.StatusSeverity[] severity) + { + var className = string.Join(", ", severity.Select(statusSeverity => $".alert-{StatusMessageModel.ToString(statusSeverity)}")); + var el = Driver.FindElement(By.CssSelector(className)) ?? Driver.WaitForElement(By.CssSelector(className)); if (el is null) throw new NoSuchElementException($"Unable to find {className}"); return el; diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index 00c895543..3417379f7 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -4,10 +4,15 @@ using System.Globalization; using System.Linq; using System.Text; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; using BTCPayServer.Abstractions.Models; using BTCPayServer.Client.Models; using BTCPayServer.Data; +using BTCPayServer.Lightning; +using BTCPayServer.Lightning.Charge; +using BTCPayServer.Lightning.LND; +using BTCPayServer.Payments; using BTCPayServer.Services; using BTCPayServer.Services.Wallets; using BTCPayServer.Tests.Logging; @@ -27,6 +32,7 @@ using OpenQA.Selenium.Support.UI; using Renci.SshNet.Security.Cryptography; using Xunit; using Xunit.Abstractions; +using CreateInvoiceRequest = BTCPayServer.Lightning.Charge.CreateInvoiceRequest; namespace BTCPayServer.Tests { @@ -977,9 +983,11 @@ namespace BTCPayServer.Tests [Fact] [Trait("Selenium", "Selenium")] + [Trait("Lightning", "Lightning")] public async Task CanUsePullPaymentsViaUI() { using var s = SeleniumTester.Create(); + s.Server.ActivateLightning(); await s.StartAsync(); s.RegisterNewUser(true); s.CreateNewStore(); @@ -1129,8 +1137,88 @@ namespace BTCPayServer.Tests s.Driver.FindElement(By.Id($"{PayoutState.AwaitingPayment}-mark-paid")).Click(); s.FindAlertMessage(); - s.Driver.FindElement(By.Id("InProgress-view")).Click(); + s.Driver.FindElement(By.Id($"{PayoutState.InProgress}-view")).Click(); Assert.Contains(tx.ToString(), s.Driver.PageSource); + + + //lightning tests + newStore = s.CreateNewStore(); + s.AddLightningNode("BTC"); + //Currently an onchain wallet is required to use the Lightning payouts feature.. + s.GenerateWallet("BTC", "", true, true); + newWalletId = new WalletId(newStore.storeId, "BTC"); + s.GoToWallet(newWalletId, WalletsNavPages.PullPayments); + + s.Driver.FindElement(By.Id("NewPullPayment")).Click(); + + var paymentMethodOptions = s.Driver.FindElements(By.CssSelector("#PaymentMethods option")); + Assert.Equal(2, paymentMethodOptions.Count); + + + s.Driver.FindElement(By.Id("Name")).SendKeys("Lightning Test"); + s.Driver.FindElement(By.Id("Amount")).Clear(); + s.Driver.FindElement(By.Id("Amount")).SendKeys("0.00001"); + s.Driver.FindElement(By.Id("Currency")).Clear(); + s.Driver.FindElement(By.Id("Currency")).SendKeys("BTC"); + s.Driver.FindElement(By.Id("Create")).Click(); + s.Driver.FindElement(By.LinkText("View")).Click(); + + var bolt = (await s.Server.MerchantLnd.Client.CreateInvoice( + LightMoney.FromUnit(0.00001m, LightMoneyUnit.BTC), + $"LN payout test {DateTime.Now.Ticks}", + TimeSpan.FromHours(1), CancellationToken.None)).BOLT11; + s.Driver.FindElement(By.Id("Destination")).SendKeys(bolt); + s.Driver.FindElement(By.Id("SelectedPaymentMethod")).Click(); + s.Driver.FindElement(By.CssSelector($"#SelectedPaymentMethod option[value={new PaymentMethodId("BTC", PaymentTypes.LightningLike )}]")).Click(); + + s.Driver.FindElement(By.Id("ClaimedAmount")).SendKeys(Keys.Enter); + //we do not allow short-life bolts. + s.FindAlertMessage(StatusMessageModel.StatusSeverity.Error); + + bolt = (await s.Server.MerchantLnd.Client.CreateInvoice( + LightMoney.FromUnit(0.00001m, LightMoneyUnit.BTC), + $"LN payout test {DateTime.Now.Ticks}", + TimeSpan.FromDays(31), CancellationToken.None)).BOLT11; + s.Driver.FindElement(By.Id("Destination")).Clear(); + s.Driver.FindElement(By.Id("Destination")).SendKeys(bolt); + s.Driver.FindElement(By.Id("SelectedPaymentMethod")).Click(); + s.Driver.FindElement(By.CssSelector($"#SelectedPaymentMethod option[value={new PaymentMethodId("BTC", PaymentTypes.LightningLike )}]")).Click(); + + s.Driver.FindElement(By.Id("ClaimedAmount")).SendKeys(Keys.Enter); + s.FindAlertMessage(); + + Assert.Contains(PayoutState.AwaitingApproval.GetStateString(), s.Driver.PageSource); + s.GoToWallet(newWalletId, WalletsNavPages.Payouts); + s.Driver.FindElement(By.Id($"{new PaymentMethodId("BTC", PaymentTypes.LightningLike )}-view")).Click(); + s.Driver.FindElement(By.Id($"{PayoutState.AwaitingApproval}-view")).Click(); + s.Driver.FindElement(By.Id($"{PayoutState.AwaitingApproval}-selectAllCheckbox")).Click(); + s.Driver.FindElement(By.Id($"{PayoutState.AwaitingApproval}-actions")).Click(); + s.Driver.FindElement(By.Id($"{PayoutState.AwaitingApproval}-approve-pay")).Click(); + Assert.Contains(bolt, s.Driver.PageSource); + Assert.Contains("0.00001 BTC", s.Driver.PageSource); + + s.Driver.FindElement(By.CssSelector("#pay-invoices-form")).Submit(); + //lightning config in tests is very unstable so we can just go ahead and handle it as both + s.FindAlertMessage(new []{StatusMessageModel.StatusSeverity.Error, StatusMessageModel.StatusSeverity.Success}); + s.GoToWallet(newWalletId, WalletsNavPages.Payouts); + s.Driver.FindElement(By.Id($"{new PaymentMethodId("BTC", PaymentTypes.LightningLike )}-view")).Click(); + + + s.Driver.FindElement(By.Id($"{PayoutState.Completed}-view")).Click(); + if (!s.Driver.PageSource.Contains(bolt)) + { + + s.Driver.FindElement(By.Id($"{PayoutState.AwaitingPayment}-view")).Click(); + Assert.Contains(bolt, s.Driver.PageSource); + + s.Driver.FindElement(By.Id($"{PayoutState.AwaitingPayment}-selectAllCheckbox")).Click(); + s.Driver.FindElement(By.Id($"{PayoutState.AwaitingPayment}-actions")).Click(); + s.Driver.FindElement(By.Id($"{PayoutState.AwaitingPayment}-mark-paid")).Click(); + s.Driver.FindElement(By.Id($"{new PaymentMethodId("BTC", PaymentTypes.LightningLike )}-view")).Click(); + + s.Driver.FindElement(By.Id($"{PayoutState.Completed}-view")).Click(); + Assert.Contains(bolt, s.Driver.PageSource); + } } private static void CanBrowseContent(SeleniumTester s) diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 0e62d490a..6bcef83fd 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -54,6 +54,7 @@ + @@ -63,7 +64,7 @@ - + diff --git a/BTCPayServer/Controllers/GreenField/PullPaymentController.cs b/BTCPayServer/Controllers/GreenField/PullPaymentController.cs index e559a077f..4912f6692 100644 --- a/BTCPayServer/Controllers/GreenField/PullPaymentController.cs +++ b/BTCPayServer/Controllers/GreenField/PullPaymentController.cs @@ -3,14 +3,12 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; -using BTCPayServer; using BTCPayServer.Abstractions.Constants; using BTCPayServer.Client; using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.HostedServices; using BTCPayServer.Payments; -using BTCPayServer.Security; using BTCPayServer.Services; using BTCPayServer.Services.Rates; using Microsoft.AspNetCore.Authorization; @@ -101,19 +99,21 @@ namespace BTCPayServer.Controllers.GreenField ModelState.AddModelError(nameof(request.Period), $"The period should be positive"); } PaymentMethodId[] paymentMethods = null; - if (request.PaymentMethods is string[] paymentMethodsStr) + if (request.PaymentMethods is { } paymentMethodsStr) { - paymentMethods = paymentMethodsStr.Select(p => new PaymentMethodId(p, PaymentTypes.BTCLike)).ToArray(); - foreach (var p in paymentMethods) + paymentMethods = paymentMethodsStr.Select(s => { - var n = _networkProvider.GetNetwork(p.CryptoCode); - if (n is null) - ModelState.AddModelError(nameof(request.PaymentMethods), "Invalid payment method"); - if (n.ReadonlyWallet) - ModelState.AddModelError(nameof(request.PaymentMethods), "Invalid payment method (We do not support the crypto currency for refund)"); - } - if (paymentMethods.Any(p => _networkProvider.GetNetwork(p.CryptoCode) is null)) - ModelState.AddModelError(nameof(request.PaymentMethods), "Invalid payment method"); + PaymentMethodId.TryParse(s, out var pmi); + return pmi; + }).ToArray(); + var supported = _payoutHandlers.GetSupportedPaymentMethods().ToArray(); + for (int i = 0; i < paymentMethods.Length; i++) + { + if (!supported.Contains(paymentMethods[i])) + { + request.AddModelError(paymentRequest => paymentRequest.PaymentMethods[i], "Invalid or unsupported payment method", this); + } + } } else { @@ -245,14 +245,23 @@ namespace BTCPayServer.Controllers.GreenField if (pp is null) return PullPaymentNotFound(); var ppBlob = pp.GetBlob(); - IClaimDestination destination = await payoutHandler.ParseClaimDestination(paymentMethodId,request.Destination); - if (destination is null) + var destination = await payoutHandler.ParseClaimDestination(paymentMethodId,request.Destination, true); + if (destination.destination is null) { - ModelState.AddModelError(nameof(request.Destination), "The destination must be an address or a BIP21 URI"); + ModelState.AddModelError(nameof(request.Destination), destination.error??"The destination is invalid for the payment specified"); return this.CreateValidationError(ModelState); } - if (request.Amount is decimal v && (v < ppBlob.MinimumClaim || v == 0.0m)) + if (request.Amount is null && destination.destination.Amount != null) + { + request.Amount = destination.destination.Amount; + } + else if (request.Amount != null && destination.destination.Amount != null && request.Amount != destination.destination.Amount) + { + ModelState.AddModelError(nameof(request.Amount), $"Amount is implied in destination ({destination.destination.Amount}) that does not match the payout amount provided {request.Amount})"); + return this.CreateValidationError(ModelState); + } + if (request.Amount is { } v && (v < ppBlob.MinimumClaim || v == 0.0m)) { ModelState.AddModelError(nameof(request.Amount), $"Amount too small (should be at least {ppBlob.MinimumClaim})"); return this.CreateValidationError(ModelState); @@ -260,7 +269,7 @@ namespace BTCPayServer.Controllers.GreenField var cd = _currencyNameTable.GetCurrencyData(pp.GetBlob().Currency, false); var result = await _pullPaymentService.Claim(new ClaimRequest() { - Destination = destination, + Destination = destination.destination, PullPaymentId = pullPaymentId, Value = request.Amount, PaymentMethodId = paymentMethodId diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index 560c2dbd1..c880e28c5 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -164,7 +164,7 @@ namespace BTCPayServer.Controllers [HttpGet] [Route("invoices/{invoiceId}/refund")] [AllowAnonymous] - public async Task Refund(string invoiceId, CancellationToken cancellationToken) + public async Task Refund([FromServices]IEnumerable payoutHandlers, string invoiceId, CancellationToken cancellationToken) { using var ctx = _dbContextFactory.CreateContext(); ctx.ChangeTracker.QueryTrackingBehavior = Microsoft.EntityFrameworkCore.QueryTrackingBehavior.NoTracking; @@ -189,22 +189,16 @@ namespace BTCPayServer.Controllers else { var paymentMethods = invoice.GetBlob(_NetworkProvider).GetPaymentMethods(); - var options = paymentMethods - .Select(o => o.GetId()) - .Select(o => o.CryptoCode) - .Where(o => _NetworkProvider.GetNetwork(o) is BTCPayNetwork n && !n.ReadonlyWallet) - .Distinct() - .OrderBy(o => o) - .Select(o => new PaymentMethodId(o, PaymentTypes.BTCLike)) - .ToList(); + var pmis = paymentMethods.Select(method => method.GetId()).ToList(); + var options = payoutHandlers.GetSupportedPaymentMethods(pmis); var defaultRefund = invoice.Payments .Select(p => p.GetBlob(_NetworkProvider)) .Select(p => p?.GetPaymentMethodId()) - .FirstOrDefault(p => p != null && p.PaymentType == BitcoinPaymentType.Instance); + .FirstOrDefault(p => p != null && options.Contains(p)); // TODO: What if no option? var refund = new RefundModel(); refund.Title = "Select a payment method"; - refund.AvailablePaymentMethods = new SelectList(options, nameof(PaymentMethodId.CryptoCode), nameof(PaymentMethodId.CryptoCode)); + refund.AvailablePaymentMethods = new SelectList(options.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString()))); refund.SelectedPaymentMethod = defaultRefund?.ToString() ?? options.Select(o => o.CryptoCode).First(); // Nothing to select, skip to next @@ -229,7 +223,7 @@ namespace BTCPayServer.Controllers return NotFound(); if (!CanRefund(invoice.GetInvoiceState())) return NotFound(); - var paymentMethodId = new PaymentMethodId(model.SelectedPaymentMethod, PaymentTypes.BTCLike); + var paymentMethodId = PaymentMethodId.Parse(model.SelectedPaymentMethod); var cdCurrency = _CurrencyNameTable.GetCurrencyData(invoice.Currency, true); var paymentMethodDivisibility = _CurrencyNameTable.GetCurrencyData(paymentMethodId.CryptoCode, false)?.Divisibility ?? 8; RateRules rules; diff --git a/BTCPayServer/Controllers/PullPaymentController.cs b/BTCPayServer/Controllers/PullPaymentController.cs index c78f1c0f8..7aa465729 100644 --- a/BTCPayServer/Controllers/PullPaymentController.cs +++ b/BTCPayServer/Controllers/PullPaymentController.cs @@ -85,6 +85,7 @@ namespace BTCPayServer.Controllers Currency = blob.Currency, Status = entity.Entity.State, Destination = entity.Blob.Destination, + PaymentMethod = PaymentMethodId.Parse(entity.Entity.PaymentMethodId), Link = entity.ProofBlob?.Link, TransactionId = entity.ProofBlob?.Id }).ToList() @@ -105,14 +106,31 @@ namespace BTCPayServer.Controllers } var ppBlob = pp.GetBlob(); - var network = _networkProvider.GetNetwork(ppBlob.SupportedPaymentMethods.Single().CryptoCode); - var paymentMethodId = ppBlob.SupportedPaymentMethods.Single(); - var payoutHandler = _payoutHandlers.FirstOrDefault(handler => handler.CanHandle(paymentMethodId)); - IClaimDestination destination = await payoutHandler?.ParseClaimDestination(paymentMethodId, vm.Destination); - if (destination is null) + var paymentMethodId = ppBlob.SupportedPaymentMethods.FirstOrDefault(id => vm.SelectedPaymentMethod == id.ToString()); + + var payoutHandler = paymentMethodId is null? null: _payoutHandlers.FirstOrDefault(handler => handler.CanHandle(paymentMethodId)); + if (payoutHandler is null) { - ModelState.AddModelError(nameof(vm.Destination), $"Invalid destination"); + ModelState.AddModelError(nameof(vm.SelectedPaymentMethod), $"Invalid destination with selected payment method"); + return await ViewPullPayment(pullPaymentId); + } + var destination = await payoutHandler?.ParseClaimDestination(paymentMethodId, vm.Destination, true); + if (destination.destination is null) + { + ModelState.AddModelError(nameof(vm.Destination), destination.error??"Invalid destination with selected payment method"); + return await ViewPullPayment(pullPaymentId); + } + + if (vm.ClaimedAmount == 0) + { + ModelState.AddModelError(nameof(vm.ClaimedAmount), + $"Amount is required"); + } + else if (vm.ClaimedAmount != 0 && destination.destination.Amount != null && vm.ClaimedAmount != destination.destination.Amount) + { + ModelState.AddModelError(nameof(vm.ClaimedAmount), + $"Amount is implied in destination ({destination.destination.Amount}) that does not match the payout amount provided {vm.ClaimedAmount})"); } if (!ModelState.IsValid) @@ -122,10 +140,10 @@ namespace BTCPayServer.Controllers var result = await _pullPaymentHostedService.Claim(new ClaimRequest() { - Destination = destination, + Destination = destination.destination, PullPaymentId = pullPaymentId, Value = vm.ClaimedAmount, - PaymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike) + PaymentMethodId = paymentMethodId }); if (result.Result != ClaimRequest.ClaimResult.Ok) @@ -150,12 +168,5 @@ namespace BTCPayServer.Controllers } return RedirectToAction(nameof(ViewPullPayment), new { pullPaymentId = pullPaymentId }); } - - string GetTransactionLink(BTCPayNetworkBase network, string txId) - { - if (txId is null) - return string.Empty; - return string.Format(CultureInfo.InvariantCulture, network.BlockExplorerLink, txId); - } } } diff --git a/BTCPayServer/Controllers/WalletsController.PullPayments.cs b/BTCPayServer/Controllers/WalletsController.PullPayments.cs index 971379329..ba733e7dc 100644 --- a/BTCPayServer/Controllers/WalletsController.PullPayments.cs +++ b/BTCPayServer/Controllers/WalletsController.PullPayments.cs @@ -17,6 +17,7 @@ using BTCPayServer.Payments; using BTCPayServer.Rating; using BTCPayServer.Views; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.EntityFrameworkCore; using NBitcoin; using PayoutData = BTCPayServer.Data.PayoutData; @@ -29,15 +30,18 @@ namespace BTCPayServer.Controllers public IActionResult NewPullPayment([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId) { + if (GetDerivationSchemeSettings(walletId) == null) return NotFound(); - + var storeMethods = CurrentStore.GetSupportedPaymentMethods(NetworkProvider).Select(method => method.PaymentId).ToList(); + var paymentMethodOptions = _payoutHandlers.GetSupportedPaymentMethods(storeMethods); return View(new NewPullPaymentModel { Name = "", Currency = "BTC", CustomCSSLink = "", EmbeddedCSS = "", + PaymentMethodItems = paymentMethodOptions.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString(), true)) }); } @@ -48,8 +52,16 @@ namespace BTCPayServer.Controllers if (GetDerivationSchemeSettings(walletId) == null) return NotFound(); + var storeMethods = CurrentStore.GetSupportedPaymentMethods(NetworkProvider).Select(method => method.PaymentId).ToList(); + var paymentMethodOptions = _payoutHandlers.GetSupportedPaymentMethods(storeMethods); + model.PaymentMethodItems = + paymentMethodOptions.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString(), true)); model.Name ??= string.Empty; model.Currency = model.Currency.ToUpperInvariant().Trim(); + if (!model.PaymentMethods.Any()) + { + ModelState.AddModelError(nameof(model.PaymentMethods), "You need at least one payment method"); + } if (_currencyTable.GetCurrencyData(model.Currency, false) is null) { ModelState.AddModelError(nameof(model.Currency), "Invalid currency"); @@ -62,10 +74,12 @@ namespace BTCPayServer.Controllers { ModelState.AddModelError(nameof(model.Name), "The name should be maximum 50 characters."); } - var paymentMethodId = walletId.GetPaymentMethodId(); - var n = this.NetworkProvider.GetNetwork(paymentMethodId.CryptoCode); - if (n is null || paymentMethodId.PaymentType != PaymentTypes.BTCLike || n.ReadonlyWallet) - ModelState.AddModelError(nameof(model.Name), "Pull payments are not supported with this wallet"); + + var selectedPaymentMethodIds = model.PaymentMethods.Select(PaymentMethodId.Parse).ToArray(); + if (!selectedPaymentMethodIds.All(id => selectedPaymentMethodIds.Contains(id))) + { + ModelState.AddModelError(nameof(model.Name), "Not all payment methods are supported"); + } if (!ModelState.IsValid) return View(model); await _pullPaymentService.CreatePullPayment(new HostedServices.CreatePullPayment() @@ -74,7 +88,7 @@ namespace BTCPayServer.Controllers Amount = model.Amount, Currency = model.Currency, StoreId = walletId.StoreId, - PaymentMethodIds = new[] { paymentMethodId }, + PaymentMethodIds = selectedPaymentMethodIds, EmbeddedCSS = model.EmbeddedCSS, CustomCSSLink = model.CustomCSSLink }); @@ -180,13 +194,14 @@ namespace BTCPayServer.Controllers return NotFound(); var storeId = walletId.StoreId; - var paymentMethodId = new PaymentMethodId(walletId.CryptoCode, PaymentTypes.BTCLike); - + var paymentMethodId = PaymentMethodId.Parse(vm.PaymentMethodId); + var handler = _payoutHandlers + .FirstOrDefault(handler => handler.CanHandle(paymentMethodId)); var commandState = Enum.Parse(vm.Command.Split("-").First()); var payoutIds = vm.GetSelectedPayouts(commandState); if (payoutIds.Length == 0) { - this.TempData.SetStatusMessageModel(new StatusMessageModel() + TempData.SetStatusMessageModel(new StatusMessageModel() { Message = "No payout selected", Severity = StatusMessageModel.StatusSeverity.Error @@ -194,12 +209,11 @@ namespace BTCPayServer.Controllers return RedirectToAction(nameof(Payouts), new { walletId = walletId.ToString(), - pullPaymentId = vm.PullPaymentId + pullPaymentId = vm.PullPaymentId, + paymentMethodId = paymentMethodId.ToString() }); } var command = vm.Command.Substring(vm.Command.IndexOf('-', StringComparison.InvariantCulture) + 1); - var handler = _payoutHandlers - .FirstOrDefault(handler => handler.CanHandle(paymentMethodId)); if (handler != null) { var result = await handler.DoSpecificAction(command, payoutIds, walletId.StoreId); @@ -215,8 +229,9 @@ namespace BTCPayServer.Controllers { await using var ctx = this._dbContextFactory.CreateContext(); ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; - var payouts = await GetPayoutsForPaymentMethod(walletId.GetPaymentMethodId(), ctx, payoutIds, storeId, cancellationToken); + var payouts = await GetPayoutsForPaymentMethod(paymentMethodId, ctx, payoutIds, storeId, cancellationToken); + var failed = false; for (int i = 0; i < payouts.Count; i++) { var payout = payouts[i]; @@ -230,11 +245,8 @@ namespace BTCPayServer.Controllers Message = $"Rate unavailable: {rateResult.EvaluatedRule}", Severity = StatusMessageModel.StatusSeverity.Error }); - return RedirectToAction(nameof(Payouts), new - { - walletId = walletId.ToString(), - pullPaymentId = vm.PullPaymentId - }); + failed = true; + break; } var approveResult = await _pullPaymentService.Approve(new HostedServices.PullPaymentHostedService.PayoutApproval() { @@ -242,26 +254,26 @@ namespace BTCPayServer.Controllers Revision = payout.GetBlob(_jsonSerializerSettings).Revision, Rate = rateResult.BidAsk.Ask }); - if (approveResult != HostedServices.PullPaymentHostedService.PayoutApproval.Result.Ok) + if (approveResult != PullPaymentHostedService.PayoutApproval.Result.Ok) { - this.TempData.SetStatusMessageModel(new StatusMessageModel() + TempData.SetStatusMessageModel(new StatusMessageModel() { Message = PullPaymentHostedService.PayoutApproval.GetErrorMessage(approveResult), Severity = StatusMessageModel.StatusSeverity.Error }); - return RedirectToAction(nameof(Payouts), new - { - walletId = walletId.ToString(), - pullPaymentId = vm.PullPaymentId - }); + failed = true; + break; } } + if (failed) + { + break; + } if (command == "approve-pay") { goto case "pay"; } - TempData.SetStatusMessageModel(new StatusMessageModel() { Message = "Payouts approved", Severity = StatusMessageModel.StatusSeverity.Success @@ -271,47 +283,19 @@ namespace BTCPayServer.Controllers case "pay": { - await using var ctx = this._dbContextFactory.CreateContext(); - ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; - var payouts = await GetPayoutsForPaymentMethod(walletId.GetPaymentMethodId(), ctx, payoutIds, storeId, cancellationToken); - - var walletSend = (WalletSendModel)((ViewResult)(await this.WalletSend(walletId))).Model; - walletSend.Outputs.Clear(); - var network = NetworkProvider.GetNetwork(walletId.CryptoCode); - List bip21 = new List(); - - foreach (var payout in payouts) - { - if (payout.Proof != null) - { - continue; - } - var blob = payout.GetBlob(_jsonSerializerSettings); - bip21.Add(network.GenerateBIP21(payout.Destination, new Money(blob.CryptoAmount.Value, MoneyUnit.BTC)).ToString()); - - } - if(bip21.Any()) - { - TempData.SetStatusMessageModel(null); - return RedirectToAction(nameof(WalletSend), new {walletId, bip21}); - } + if (handler is { }) return await handler?.InitiatePayment(paymentMethodId, payoutIds); TempData.SetStatusMessageModel(new StatusMessageModel() { - Severity = StatusMessageModel.StatusSeverity.Error, - Message = "There were no payouts eligible to pay from the selection. You may have selected payouts which have detected a transaction to the payout address with the payout amount that you need to accept or reject as the payout." - }); - return RedirectToAction(nameof(Payouts), new - { - walletId = walletId.ToString(), - pullPaymentId = vm.PullPaymentId + Message = "Paying via this payment method is not supported", Severity = StatusMessageModel.StatusSeverity.Error }); + break; } case "mark-paid": { await using var ctx = this._dbContextFactory.CreateContext(); ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; - var payouts = await GetPayoutsForPaymentMethod(walletId.GetPaymentMethodId(), ctx, payoutIds, storeId, cancellationToken); + var payouts = await GetPayoutsForPaymentMethod(paymentMethodId, ctx, payoutIds, storeId, cancellationToken); for (int i = 0; i < payouts.Count; i++) { var payout = payouts[i]; @@ -332,7 +316,8 @@ namespace BTCPayServer.Controllers return RedirectToAction(nameof(Payouts), new { walletId = walletId.ToString(), - pullPaymentId = vm.PullPaymentId + pullPaymentId = vm.PullPaymentId, + paymentMethodId = paymentMethodId.ToString() }); } } @@ -346,15 +331,21 @@ namespace BTCPayServer.Controllers case "cancel": await _pullPaymentService.Cancel( - new HostedServices.PullPaymentHostedService.CancelRequest(payoutIds)); - this.TempData.SetStatusMessageModel(new StatusMessageModel() + new PullPaymentHostedService.CancelRequest(payoutIds)); + TempData.SetStatusMessageModel(new StatusMessageModel() { Message = "Payouts archived", Severity = StatusMessageModel.StatusSeverity.Success }); break; } + return RedirectToAction(nameof(Payouts), - new {walletId = walletId.ToString(), pullPaymentId = vm.PullPaymentId}); + new + { + walletId = walletId.ToString(), + pullPaymentId = vm.PullPaymentId, + paymentMethodId = paymentMethodId.ToString() + }); } private static async Task> GetPayoutsForPaymentMethod(PaymentMethodId paymentMethodId, @@ -375,12 +366,12 @@ namespace BTCPayServer.Controllers [HttpGet("{walletId}/payouts")] public async Task Payouts( [ModelBinder(typeof(WalletIdModelBinder))] - WalletId walletId, string pullPaymentId, PayoutState payoutState, + WalletId walletId, string pullPaymentId, string paymentMethodId, PayoutState payoutState, int skip = 0, int count = 50) { var vm = this.ParseListQuery(new PayoutsModel { - PaymentMethodId = new PaymentMethodId(walletId.CryptoCode, PaymentTypes.BTCLike), + PaymentMethodId = paymentMethodId?? new PaymentMethodId(walletId.CryptoCode, PaymentTypes.BTCLike).ToString(), PullPaymentId = pullPaymentId, PayoutState = payoutState, Skip = skip, @@ -390,14 +381,14 @@ namespace BTCPayServer.Controllers await using var ctx = _dbContextFactory.CreateContext(); var storeId = walletId.StoreId; var payoutRequest = ctx.Payouts.Where(p => p.PullPaymentData.StoreId == storeId && !p.PullPaymentData.Archived); - if (vm.PullPaymentId != null) + if (pullPaymentId != null) { payoutRequest = payoutRequest.Where(p => p.PullPaymentDataId == vm.PullPaymentId); vm.PullPaymentName = (await ctx.PullPayments.FindAsync(pullPaymentId)).GetBlob().Name; } if (vm.PaymentMethodId != null) { - var pmiStr = vm.PaymentMethodId.ToString(); + var pmiStr = vm.PaymentMethodId; payoutRequest = payoutRequest.Where(p => p.PaymentMethodId == pmiStr); } diff --git a/BTCPayServer/Data/Payouts/BitcoinLike/AddressClaimDestination.cs b/BTCPayServer/Data/Payouts/BitcoinLike/AddressClaimDestination.cs index 86aab96bf..cf47300ba 100644 --- a/BTCPayServer/Data/Payouts/BitcoinLike/AddressClaimDestination.cs +++ b/BTCPayServer/Data/Payouts/BitcoinLike/AddressClaimDestination.cs @@ -18,5 +18,7 @@ namespace BTCPayServer.Data { return _bitcoinAddress.ToString(); } + + public decimal? Amount => null; } } diff --git a/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs b/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs index b7bc7f4bb..840afff92 100644 --- a/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs +++ b/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -11,13 +10,11 @@ using BTCPayServer.Data; using BTCPayServer.Events; using BTCPayServer.HostedServices; using BTCPayServer.Logging; -using BTCPayServer.Models; -using BTCPayServer.Models.WalletViewModels; using BTCPayServer.Payments; using BTCPayServer.Services; using BTCPayServer.Services.Notifications; using BTCPayServer.Services.Notifications.Blobs; -using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using NBitcoin; @@ -39,8 +36,11 @@ public class BitcoinLikePayoutHandler : IPayoutHandler private readonly NotificationSender _notificationSender; public BitcoinLikePayoutHandler(BTCPayNetworkProvider btcPayNetworkProvider, - ExplorerClientProvider explorerClientProvider, BTCPayNetworkJsonSerializerSettings jsonSerializerSettings, - ApplicationDbContextFactory dbContextFactory, EventAggregator eventAggregator, NotificationSender notificationSender) + ExplorerClientProvider explorerClientProvider, + BTCPayNetworkJsonSerializerSettings jsonSerializerSettings, + ApplicationDbContextFactory dbContextFactory, + EventAggregator eventAggregator, + NotificationSender notificationSender) { _btcPayNetworkProvider = btcPayNetworkProvider; _explorerClientProvider = explorerClientProvider; @@ -64,7 +64,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler await explorerClient.TrackAsync(TrackedSource.Create(bitcoinLikeClaimDestination.Address)); } - public Task ParseClaimDestination(PaymentMethodId paymentMethodId, string destination) + public Task<(IClaimDestination destination, string error)> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination, bool validate) { var network = _btcPayNetworkProvider.GetNetwork(paymentMethodId.CryptoCode); destination = destination.Trim(); @@ -76,11 +76,12 @@ public class BitcoinLikePayoutHandler : IPayoutHandler // return Task.FromResult(new UriClaimDestination(new BitcoinUrlBuilder(destination, network.NBitcoinNetwork))); //} - return Task.FromResult(new AddressClaimDestination(BitcoinAddress.Create(destination, network.NBitcoinNetwork))); + return Task.FromResult<(IClaimDestination, string)>((new AddressClaimDestination(BitcoinAddress.Create(destination, network.NBitcoinNetwork)), null)); } catch { - return Task.FromResult(null); + return Task.FromResult<(IClaimDestination, string)>( + (null, "A valid address was not provided")); } } @@ -175,6 +176,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler valueTuple.data.State = PayoutState.InProgress; SetProofBlob(valueTuple.data, valueTuple.Item2); } + await context.SaveChangesAsync(); } @@ -200,8 +202,10 @@ public class BitcoinLikePayoutHandler : IPayoutHandler valueTuple.Item2.TransactionId = null; SetProofBlob(valueTuple.data, valueTuple.Item2); } + await context.SaveChangesAsync(); } + return new StatusMessageModel() { Message = "Payout payments have been unmarked", @@ -209,11 +213,50 @@ public class BitcoinLikePayoutHandler : IPayoutHandler }; } - return new StatusMessageModel() + return null; + } + + public IEnumerable GetSupportedPaymentMethods() + { + return _btcPayNetworkProvider.GetAll().OfType() + .Where(network => network.ReadonlyWallet is false) + .Select(network => new PaymentMethodId(network.CryptoCode, BitcoinPaymentType.Instance)); + } + + public async Task InitiatePayment(PaymentMethodId paymentMethodId ,string[] payoutIds) + { + await using var ctx = this._dbContextFactory.CreateContext(); + ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + var pmi = paymentMethodId.ToString(); + + var payouts = await ctx.Payouts.Include(data => data.PullPaymentData) + .Where(data => payoutIds.Contains(data.Id) + && pmi == data.PaymentMethodId + && data.State == PayoutState.AwaitingPayment) + .ToListAsync(); + + var pullPaymentIds = payouts.Select(data => data.PullPaymentDataId).Distinct().ToArray(); + var storeId = payouts.First().PullPaymentData.StoreId; + var network = _btcPayNetworkProvider.GetNetwork(paymentMethodId.CryptoCode); + List bip21 = new List(); + foreach (var payout in payouts) { - Message = "Unknown action", - Severity = StatusMessageModel.StatusSeverity.Error - };; + if (payout.Proof != null) + { + continue; + } + var blob = payout.GetBlob(_jsonSerializerSettings); + if (payout.GetPaymentMethodId() != paymentMethodId) + continue; + bip21.Add(network.GenerateBIP21(payout.Destination, new Money(blob.CryptoAmount.Value, MoneyUnit.BTC)).ToString()); + } + if(bip21.Any()) + return new RedirectToActionResult("WalletSend", "Wallets", new {walletId = new WalletId(storeId, paymentMethodId.CryptoCode).ToString(), bip21}); + return new RedirectToActionResult("Payouts", "Wallets", new + { + walletId = new WalletId(storeId, paymentMethodId.CryptoCode).ToString(), + pullPaymentId = pullPaymentIds.Length == 1? pullPaymentIds.First(): null + }); } private async Task UpdatePayoutsInProgress() diff --git a/BTCPayServer/Data/Payouts/BitcoinLike/UriClaimDestination.cs b/BTCPayServer/Data/Payouts/BitcoinLike/UriClaimDestination.cs index 3a8e8d3b5..f78e1191e 100644 --- a/BTCPayServer/Data/Payouts/BitcoinLike/UriClaimDestination.cs +++ b/BTCPayServer/Data/Payouts/BitcoinLike/UriClaimDestination.cs @@ -1,3 +1,4 @@ +#nullable enable using System; using NBitcoin; using NBitcoin.Payment; @@ -23,5 +24,7 @@ namespace BTCPayServer.Data { return _bitcoinUrl.ToString(); } + + public decimal? Amount => _bitcoinUrl.Amount?.ToDecimal(MoneyUnit.BTC); } } diff --git a/BTCPayServer/Data/Payouts/IClaimDestination.cs b/BTCPayServer/Data/Payouts/IClaimDestination.cs index 756f4faac..118443a67 100644 --- a/BTCPayServer/Data/Payouts/IClaimDestination.cs +++ b/BTCPayServer/Data/Payouts/IClaimDestination.cs @@ -1,8 +1,10 @@ -using System; +#nullable enable +using NBitcoin; namespace BTCPayServer.Data { public interface IClaimDestination { + decimal? Amount { get; } } } diff --git a/BTCPayServer/Data/Payouts/IPayoutHandler.cs b/BTCPayServer/Data/Payouts/IPayoutHandler.cs index e3bbac820..9e8f0b213 100644 --- a/BTCPayServer/Data/Payouts/IPayoutHandler.cs +++ b/BTCPayServer/Data/Payouts/IPayoutHandler.cs @@ -6,13 +6,14 @@ using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Payments; using PayoutData = BTCPayServer.Data.PayoutData; +using Microsoft.AspNetCore.Mvc; public interface IPayoutHandler { public bool CanHandle(PaymentMethodId paymentMethod); public Task TrackClaim(PaymentMethodId paymentMethodId, IClaimDestination claimDestination); //Allows payout handler to parse payout destinations on its own - public Task ParseClaimDestination(PaymentMethodId paymentMethodId, string destination); + public Task<(IClaimDestination destination, string error)> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination, bool validate); public IPayoutProof ParseProof(PayoutData payout); //Allows you to subscribe the main pull payment hosted service to events and prepare the handler void StartBackgroundCheck(Action subscribe); @@ -21,4 +22,6 @@ public interface IPayoutHandler Task GetMinimumPayoutAmount(PaymentMethodId paymentMethod, IClaimDestination claimDestination); Dictionary> GetPayoutSpecificActions(); Task DoSpecificAction(string action, string[] payoutIds, string storeId); + IEnumerable GetSupportedPaymentMethods(); + Task InitiatePayment(PaymentMethodId paymentMethodId, string[] payoutIds); } diff --git a/BTCPayServer/Data/Payouts/LightningLike/BoltInvoiceClaimDestination.cs b/BTCPayServer/Data/Payouts/LightningLike/BoltInvoiceClaimDestination.cs new file mode 100644 index 000000000..60efede8e --- /dev/null +++ b/BTCPayServer/Data/Payouts/LightningLike/BoltInvoiceClaimDestination.cs @@ -0,0 +1,31 @@ +using System; +using BTCPayServer.Lightning; +using NBitcoin; + +namespace BTCPayServer.Data.Payouts.LightningLike +{ + public class BoltInvoiceClaimDestination : ILightningLikeLikeClaimDestination + { + private readonly string _bolt11; + private readonly decimal _amount; + + public BoltInvoiceClaimDestination(string bolt11, Network network) + { + _bolt11 = bolt11 ?? throw new ArgumentNullException(nameof(bolt11)); + _amount = BOLT11PaymentRequest.Parse(bolt11, network).MinimumAmount.ToDecimal(LightMoneyUnit.BTC); + } + + public BoltInvoiceClaimDestination(string bolt11, BOLT11PaymentRequest invoice) + { + _bolt11 = bolt11 ?? throw new ArgumentNullException(nameof(bolt11)); + _amount = invoice?.MinimumAmount.ToDecimal(LightMoneyUnit.BTC) ?? throw new ArgumentNullException(nameof(invoice)); + } + + public override string ToString() + { + return _bolt11; + } + + public decimal? Amount => _amount; + } +} diff --git a/BTCPayServer/Data/Payouts/LightningLike/ILightningLikeLikeClaimDestination.cs b/BTCPayServer/Data/Payouts/LightningLike/ILightningLikeLikeClaimDestination.cs new file mode 100644 index 000000000..13fc57de9 --- /dev/null +++ b/BTCPayServer/Data/Payouts/LightningLike/ILightningLikeLikeClaimDestination.cs @@ -0,0 +1,6 @@ +namespace BTCPayServer.Data.Payouts.LightningLike +{ + public interface ILightningLikeLikeClaimDestination : IClaimDestination + { + } +} diff --git a/BTCPayServer/Data/Payouts/LightningLike/LNURLPayClaimDestinaton.cs b/BTCPayServer/Data/Payouts/LightningLike/LNURLPayClaimDestinaton.cs new file mode 100644 index 000000000..819b8d16f --- /dev/null +++ b/BTCPayServer/Data/Payouts/LightningLike/LNURLPayClaimDestinaton.cs @@ -0,0 +1,18 @@ +namespace BTCPayServer.Data.Payouts.LightningLike +{ + public class LNURLPayClaimDestinaton: ILightningLikeLikeClaimDestination + { + public LNURLPayClaimDestinaton(string lnurl) + { + LNURL = lnurl; + } + + public decimal? Amount { get; } = null; + public string LNURL { get; set; } + + public override string ToString() + { + return LNURL; + } + } +} diff --git a/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutController.cs b/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutController.cs new file mode 100644 index 000000000..9155733f6 --- /dev/null +++ b/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutController.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Client.Models; +using BTCPayServer.Configuration; +using BTCPayServer.Lightning; +using BTCPayServer.Payments; +using BTCPayServer.Payments.Lightning; +using BTCPayServer.Services; +using LNURL; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace BTCPayServer.Data.Payouts.LightningLike +{ + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] + [AutoValidateAntiforgeryToken] + public class LightningLikePayoutController : Controller + { + private readonly ApplicationDbContextFactory _applicationDbContextFactory; + private readonly UserManager _userManager; + private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings; + private readonly IEnumerable _payoutHandlers; + private readonly BTCPayNetworkProvider _btcPayNetworkProvider; + private readonly LightningClientFactoryService _lightningClientFactoryService; + private readonly IOptions _options; + + public LightningLikePayoutController(ApplicationDbContextFactory applicationDbContextFactory, + UserManager userManager, + BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings, + IEnumerable payoutHandlers, + BTCPayNetworkProvider btcPayNetworkProvider, + LightningClientFactoryService lightningClientFactoryService, + IOptions options) + { + _applicationDbContextFactory = applicationDbContextFactory; + _userManager = userManager; + _btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings; + _payoutHandlers = payoutHandlers; + _btcPayNetworkProvider = btcPayNetworkProvider; + _lightningClientFactoryService = lightningClientFactoryService; + _options = options; + } + + private async Task> GetPayouts(ApplicationDbContext dbContext, PaymentMethodId pmi, + string[] payoutIds) + { + var userId = _userManager.GetUserId(User); + if (string.IsNullOrEmpty(userId)) + { + return new List(); + } + + var pmiStr = pmi.ToString(); + + var approvedStores = new Dictionary(); + + return (await dbContext.Payouts + .Include(data => data.PullPaymentData) + .ThenInclude(data => data.StoreData) + .ThenInclude(data => data.UserStores) + .Where(data => + payoutIds.Contains(data.Id) && + data.State == PayoutState.AwaitingPayment && + data.PaymentMethodId == pmiStr) + .ToListAsync()) + .Where(payout => + { + if (approvedStores.TryGetValue(payout.PullPaymentData.StoreId, out var value)) return value; + value = payout.PullPaymentData.StoreData.UserStores + .Any(store => store.Role == StoreRoles.Owner && store.ApplicationUserId == userId); + approvedStores.Add(payout.PullPaymentData.StoreId, value); + return value; + }).ToList(); + } + + [HttpGet("pull-payments/payouts/lightning/{cryptoCode}")] + public async Task ConfirmLightningPayout(string cryptoCode, string[] payoutIds) + { + var pmi = new PaymentMethodId(cryptoCode, PaymentTypes.LightningLike); + + await using var ctx = _applicationDbContextFactory.CreateContext(); + var payouts = await GetPayouts(ctx, pmi, payoutIds); + + var vm = payouts.Select(payoutData => + { + var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings); + + return new ConfirmVM() + { + Amount = blob.CryptoAmount.Value, Destination = blob.Destination, PayoutId = payoutData.Id + }; + }).ToList(); + return View(vm); + } + + [HttpPost("pull-payments/payouts/lightning/{cryptoCode}")] + public async Task ProcessLightningPayout(string cryptoCode, string[] payoutIds) + { + var pmi = new PaymentMethodId(cryptoCode, PaymentTypes.LightningLike); + var payoutHandler = _payoutHandlers.FirstOrDefault(handler => handler.CanHandle(pmi)); + + await using var ctx = _applicationDbContextFactory.CreateContext(); + + var payouts = (await GetPayouts(ctx, pmi, payoutIds)).GroupBy(data => data.PullPaymentData.StoreId); + var results = new List(); + var network = _btcPayNetworkProvider.GetNetwork(pmi.CryptoCode); + + //we group per store and init the transfers by each + async Task TrypayBolt(ILightningClient lightningClient, PayoutBlob payoutBlob, PayoutData payoutData, + string destination) + { + var result = await lightningClient.Pay(destination); + if (result.Result == PayResult.Ok) + { + results.Add(new ResultVM() + { + PayoutId = payoutData.Id, Result = result.Result, Destination = payoutBlob.Destination + }); + payoutData.State = PayoutState.Completed; + } + else + { + results.Add(new ResultVM() + { + PayoutId = payoutData.Id, Result = result.Result, Destination = payoutBlob.Destination + }); + } + } + + foreach (var payoutDatas in payouts) + { + var store = payoutDatas.First().PullPaymentData.StoreData; + var lightningSupportedPaymentMethod = store.GetSupportedPaymentMethods(_btcPayNetworkProvider) + .OfType() + .FirstOrDefault(method => method.PaymentId == pmi); + var client = + lightningSupportedPaymentMethod.CreateLightningClient(network, _options.Value, + _lightningClientFactoryService); + foreach (var payoutData in payoutDatas) + { + var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings); + var claim = await payoutHandler.ParseClaimDestination(pmi, blob.Destination, false); + try + { + switch (claim.destination) + { + case LNURLPayClaimDestinaton lnurlPayClaimDestinaton: + var endpoint = LNURL.LNURL.Parse(lnurlPayClaimDestinaton.LNURL, out var tag); + var lightningPayoutHandler = (LightningLikePayoutHandler)payoutHandler; + var httpClient = lightningPayoutHandler.CreateClient(endpoint); + var lnurlInfo = + (LNURLPayRequest)await LNURL.LNURL.FetchInformation(endpoint, "payRequest", + httpClient); + var lm = new LightMoney(blob.CryptoAmount.Value, LightMoneyUnit.BTC); + if (lm > lnurlInfo.MaxSendable || lm < lnurlInfo.MinSendable) + { + results.Add(new ResultVM() + { + PayoutId = payoutData.Id, + Result = PayResult.Error, + Destination = blob.Destination, + Message = + $"The LNURL provided would not generate an invoice of {lm.MilliSatoshi}msats" + }); + } + else + { + try + { + var lnurlPayRequestCallbackResponse = + await lnurlInfo.SendRequest(lm, network.NBitcoinNetwork, httpClient); + + + await TrypayBolt(client, blob, payoutData, lnurlPayRequestCallbackResponse.Pr); + } + catch (LNUrlException e) + { + results.Add(new ResultVM() + { + PayoutId = payoutData.Id, + Result = PayResult.Error, + Destination = blob.Destination, + Message = e.Message + }); + } + } + + break; + + case BoltInvoiceClaimDestination item1: + await TrypayBolt(client, blob, payoutData, payoutData.Destination); + + break; + default: + results.Add(new ResultVM() + { + PayoutId = payoutData.Id, + Result = PayResult.Error, + Destination = blob.Destination, + Message = claim.error + }); + break; + } + } + catch (Exception) + { + results.Add(new ResultVM() + { + PayoutId = payoutData.Id, Result = PayResult.Error, Destination = blob.Destination + }); + } + } + } + + await ctx.SaveChangesAsync(); + return View("LightningPayoutResult", results); + } + + public class ResultVM + { + public string PayoutId { get; set; } + public string Destination { get; set; } + public PayResult Result { get; set; } + public string Message { get; set; } + } + + public class ConfirmVM + { + public string PayoutId { get; set; } + public string Destination { get; set; } + public decimal Amount { get; set; } + } + } +} diff --git a/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutHandler.cs b/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutHandler.cs new file mode 100644 index 000000000..a725e73bb --- /dev/null +++ b/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutHandler.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using BTCPayServer.Abstractions.Models; +using BTCPayServer.Client.Models; +using BTCPayServer.Lightning; +using BTCPayServer.Payments; +using BTCPayServer.Validation; +using LNURL; +using Microsoft.AspNetCore.Mvc; +using NBitcoin; + +namespace BTCPayServer.Data.Payouts.LightningLike +{ + public class LightningLikePayoutHandler : IPayoutHandler + { + public const string LightningLikePayoutHandlerOnionNamedClient = + nameof(LightningLikePayoutHandlerOnionNamedClient); + + public const string LightningLikePayoutHandlerClearnetNamedClient = + nameof(LightningLikePayoutHandlerClearnetNamedClient); + + private readonly BTCPayNetworkProvider _btcPayNetworkProvider; + private readonly IHttpClientFactory _httpClientFactory; + + public LightningLikePayoutHandler(BTCPayNetworkProvider btcPayNetworkProvider, + IHttpClientFactory httpClientFactory) + { + _btcPayNetworkProvider = btcPayNetworkProvider; + _httpClientFactory = httpClientFactory; + } + + public bool CanHandle(PaymentMethodId paymentMethod) + { + return paymentMethod.PaymentType == LightningPaymentType.Instance && + _btcPayNetworkProvider.GetNetwork(paymentMethod.CryptoCode)?.SupportLightning is true; + } + + public Task TrackClaim(PaymentMethodId paymentMethodId, IClaimDestination claimDestination) + { + return Task.CompletedTask; + } + + public HttpClient CreateClient(Uri uri) + { + return _httpClientFactory.CreateClient(uri.IsOnion() + ? LightningLikePayoutHandlerOnionNamedClient + : LightningLikePayoutHandlerClearnetNamedClient); + } + + public async Task<(IClaimDestination destination, string error)> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination, bool validate) + { + destination = destination.Trim(); + var network = _btcPayNetworkProvider.GetNetwork(paymentMethodId.CryptoCode); + try + { + string lnurlTag = null; + var lnurl = EmailValidator.IsEmail(destination) + ? LNURL.LNURL.ExtractUriFromInternetIdentifier(destination) + : LNURL.LNURL.Parse(destination, out lnurlTag); + + if (lnurlTag is null) + { + var info = (LNURLPayRequest)(await LNURL.LNURL.FetchInformation(lnurl, CreateClient(lnurl))); + lnurlTag = info.Tag; + } + + if (lnurlTag.Equals("payRequest", StringComparison.InvariantCultureIgnoreCase)) + { + return (new LNURLPayClaimDestinaton(destination), null); + } + } + catch (FormatException) + { + } + catch (HttpRequestException) + { + return (null, "The LNURL / Lightning Address provided was not online."); + } + + var result = + BOLT11PaymentRequest.TryParse(destination, out var invoice, network.NBitcoinNetwork) + ? new BoltInvoiceClaimDestination(destination, invoice) + : null; + + if (result == null) return (null, "A valid BOLT11 invoice (with 30+ day expiry) or LNURL Pay or Lightning address was not provided."); + if (validate && (invoice.ExpiryDate.UtcDateTime - DateTime.UtcNow).Days < 30) + { + return (null, + $"The BOLT11 invoice must have an expiry date of at least 30 days from submission (Provided was only {(invoice.ExpiryDate.UtcDateTime - DateTime.UtcNow).Days})."); + } + if (invoice.ExpiryDate.UtcDateTime < DateTime.UtcNow) + { + return (null, + "The BOLT11 invoice submitted has expired."); + } + + return (result, null); + } + + public IPayoutProof ParseProof(PayoutData payout) + { + return null; + } + + public void StartBackgroundCheck(Action subscribe) + { + } + + public Task BackgroundCheck(object o) + { + return Task.CompletedTask; + } + + public Task GetMinimumPayoutAmount(PaymentMethodId paymentMethodId, IClaimDestination claimDestination) + { + return Task.FromResult(Money.Satoshis(1).ToDecimal(MoneyUnit.BTC)); + } + + public Dictionary> GetPayoutSpecificActions() + { + return new Dictionary>(); + } + + public Task DoSpecificAction(string action, string[] payoutIds, string storeId) + { + return Task.FromResult(null); + } + + public IEnumerable GetSupportedPaymentMethods() + { + return _btcPayNetworkProvider.GetAll().OfType().Where(network => network.SupportLightning) + .Select(network => new PaymentMethodId(network.CryptoCode, LightningPaymentType.Instance)); + } + + public Task InitiatePayment(PaymentMethodId paymentMethodId, string[] payoutIds) + { + return Task.FromResult(new RedirectToActionResult("ConfirmLightningPayout", + "LightningLikePayout", new { cryptoCode = paymentMethodId.CryptoCode, payoutIds })); + } + } +} diff --git a/BTCPayServer/Data/Payouts/LightningLike/PayoutLightningBlob.cs b/BTCPayServer/Data/Payouts/LightningLike/PayoutLightningBlob.cs new file mode 100644 index 000000000..f0fc5294a --- /dev/null +++ b/BTCPayServer/Data/Payouts/LightningLike/PayoutLightningBlob.cs @@ -0,0 +1,13 @@ +namespace BTCPayServer.Data.Payouts.LightningLike +{ + public class PayoutLightningBlob: IPayoutProof + { + public string Bolt11Invoice { get; set; } + public string Preimage { get; set; } + public string PaymentHash { get; set; } + + public string ProofType { get; } + public string Link { get; } = null; + public string Id => PaymentHash; + } +} diff --git a/BTCPayServer/Data/Payouts/PayoutExtensions.cs b/BTCPayServer/Data/Payouts/PayoutExtensions.cs index 0a5a02b85..120fdaf27 100644 --- a/BTCPayServer/Data/Payouts/PayoutExtensions.cs +++ b/BTCPayServer/Data/Payouts/PayoutExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -50,5 +51,12 @@ namespace BTCPayServer.Data data.Proof = bytes; } } + + public static IEnumerable GetSupportedPaymentMethods( + this IEnumerable payoutHandlers, List paymentMethodIds = null) + { + return payoutHandlers.SelectMany(handler => handler.GetSupportedPaymentMethods()) + .Where(id => paymentMethodIds is null || paymentMethodIds.Contains(id)); + } } } diff --git a/BTCPayServer/Extensions/ModelStateExtensions.cs b/BTCPayServer/Extensions/ModelStateExtensions.cs index 5e6502316..80215e0e0 100644 --- a/BTCPayServer/Extensions/ModelStateExtensions.cs +++ b/BTCPayServer/Extensions/ModelStateExtensions.cs @@ -11,7 +11,7 @@ namespace BTCPayServer public static void AddModelError(this TModel source, Expression> ex, string message, - Controller controller) + ControllerBase controller) { var provider = (ModelExpressionProvider)controller.HttpContext.RequestServices.GetService(typeof(ModelExpressionProvider)); var key = provider.GetExpressionText(ex); diff --git a/BTCPayServer/HostedServices/PullPaymentHostedService.cs b/BTCPayServer/HostedServices/PullPaymentHostedService.cs index 2aa8b74d1..f3e298539 100644 --- a/BTCPayServer/HostedServices/PullPaymentHostedService.cs +++ b/BTCPayServer/HostedServices/PullPaymentHostedService.cs @@ -297,9 +297,8 @@ namespace BTCPayServer.HostedServices req.Rate = 1.0m; var cryptoAmount = payoutBlob.Amount / req.Rate; var payoutHandler = _payoutHandlers.First(handler => handler.CanHandle(paymentMethod)); - var dest = await payoutHandler.ParseClaimDestination(paymentMethod, payoutBlob.Destination); - - decimal minimumCryptoAmount = await payoutHandler.GetMinimumPayoutAmount(paymentMethod, dest); + var dest = await payoutHandler.ParseClaimDestination(paymentMethod, payoutBlob.Destination, false); + decimal minimumCryptoAmount = await payoutHandler.GetMinimumPayoutAmount(paymentMethod, dest.destination); if (cryptoAmount < minimumCryptoAmount) { req.Completion.TrySetResult(PayoutApproval.Result.TooLowAmount); @@ -384,7 +383,6 @@ namespace BTCPayServer.HostedServices Entity = o, Blob = o.GetBlob(_jsonSerializerSettings) }); - var cd = _currencyNameTable.GetCurrencyData(pp.GetBlob().Currency, false); var limit = ppBlob.Limit; var totalPayout = payouts.Select(p => p.Blob.Amount).Sum(); var claimed = req.ClaimRequest.Value is decimal v ? v : limit - totalPayout; diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index f7cac9707..141fac9d9 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -11,6 +11,7 @@ using BTCPayServer.Configuration; using BTCPayServer.Controllers; using BTCPayServer.Controllers.GreenField; using BTCPayServer.Data; +using BTCPayServer.Data.Payouts.LightningLike; using BTCPayServer.HostedServices; using BTCPayServer.Lightning; using BTCPayServer.Logging; @@ -314,6 +315,11 @@ namespace BTCPayServer.Hosting services.AddSingleton(); + services.AddSingleton(); + + services.AddHttpClient(LightningLikePayoutHandler.LightningLikePayoutHandlerOnionNamedClient) + .ConfigureHttpClient(h => h.DefaultRequestHeaders.ConnectionClose = true) + .ConfigurePrimaryHttpMessageHandler(); services.AddSingleton(); services.AddSingleton(o => o.GetRequiredService()); diff --git a/BTCPayServer/Models/ViewPullPaymentModel.cs b/BTCPayServer/Models/ViewPullPaymentModel.cs index b30b38ccf..7610c7cd2 100644 --- a/BTCPayServer/Models/ViewPullPaymentModel.cs +++ b/BTCPayServer/Models/ViewPullPaymentModel.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; using BTCPayServer.Abstractions.Extensions; +using System.Linq; using BTCPayServer.Client.Models; using BTCPayServer.Data; +using BTCPayServer.Payments; using BTCPayServer.Services.Rates; using PullPaymentData = BTCPayServer.Data.PullPaymentData; @@ -18,6 +20,8 @@ namespace BTCPayServer.Models { Id = data.Id; var blob = data.GetBlob(); + PaymentMethods = blob.SupportedPaymentMethods; + SelectedPaymentMethod = PaymentMethods.First().ToString(); Archived = data.Archived; Title = blob.View.Title; Amount = blob.Limit; @@ -58,6 +62,11 @@ namespace BTCPayServer.Models ResetIn = resetIn.TimeString(); } } + + public string SelectedPaymentMethod { get; set; } + + public PaymentMethodId[] PaymentMethods { get; set; } + public string HubPath { get; set; } public string ResetIn { get; set; } public string Email { get; set; } @@ -95,6 +104,7 @@ namespace BTCPayServer.Models public string Currency { get; set; } public string Link { get; set; } public string TransactionId { get; set; } + public PaymentMethodId PaymentMethod { get; set; } } } } diff --git a/BTCPayServer/Models/WalletViewModels/PayoutsModel.cs b/BTCPayServer/Models/WalletViewModels/PayoutsModel.cs index f09934eb1..4bb96c130 100644 --- a/BTCPayServer/Models/WalletViewModels/PayoutsModel.cs +++ b/BTCPayServer/Models/WalletViewModels/PayoutsModel.cs @@ -11,7 +11,7 @@ namespace BTCPayServer.Models.WalletViewModels public string PullPaymentId { get; set; } public string Command { get; set; } public Dictionary PayoutStateCount { get; set; } - public PaymentMethodId PaymentMethodId { get; set; } + public string PaymentMethodId { get; set; } public List Payouts { get; set; } public PayoutState PayoutState { get; set; } diff --git a/BTCPayServer/Models/WalletViewModels/PullPaymentsModel.cs b/BTCPayServer/Models/WalletViewModels/PullPaymentsModel.cs index cb3c45d19..7b722c1f9 100644 --- a/BTCPayServer/Models/WalletViewModels/PullPaymentsModel.cs +++ b/BTCPayServer/Models/WalletViewModels/PullPaymentsModel.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc.Rendering; namespace BTCPayServer.Models.WalletViewModels { @@ -49,5 +50,8 @@ namespace BTCPayServer.Models.WalletViewModels public string CustomCSSLink { get; set; } [Display(Name = "Custom CSS Code")] public string EmbeddedCSS { get; set; } + + public IEnumerable PaymentMethods { get; set; } + public IEnumerable PaymentMethodItems { get; set; } } } diff --git a/BTCPayServer/Payments/Lightning/LightningExtensions.cs b/BTCPayServer/Payments/Lightning/LightningExtensions.cs new file mode 100644 index 000000000..bbdcc9389 --- /dev/null +++ b/BTCPayServer/Payments/Lightning/LightningExtensions.cs @@ -0,0 +1,26 @@ +using BTCPayServer.Configuration; +using BTCPayServer.Lightning; +using BTCPayServer.Services; + +namespace BTCPayServer.Payments.Lightning +{ + public static class LightningExtensions + { + + + public static ILightningClient CreateLightningClient(this LightningSupportedPaymentMethod supportedPaymentMethod, BTCPayNetwork network, LightningNetworkOptions options, LightningClientFactoryService lightningClientFactory) + { + var external = supportedPaymentMethod.GetExternalLightningUrl(); + if (external != null) + { + return lightningClientFactory.Create(external, network); + } + else + { + if (!options.InternalLightningByCryptoCode.TryGetValue(network.CryptoCode, out var connectionString)) + throw new PaymentMethodUnavailableException("No internal node configured"); + return lightningClientFactory.Create(connectionString, network); + } + } + } +} diff --git a/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs b/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs index 578efb0a8..c790deabf 100644 --- a/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs +++ b/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs @@ -80,7 +80,7 @@ namespace BTCPayServer.Payments.Lightning { // ignored } - var client = CreateLightningClient(supportedPaymentMethod, network); + var client = supportedPaymentMethod.CreateLightningClient(network, Options.Value, _lightningClientFactory); var expiry = invoice.ExpirationTime - DateTimeOffset.UtcNow; if (expiry < TimeSpan.Zero) expiry = TimeSpan.FromSeconds(1); @@ -127,7 +127,7 @@ namespace BTCPayServer.Payments.Lightning using (var cts = new CancellationTokenSource(LIGHTNING_TIMEOUT)) { - var client = CreateLightningClient(supportedPaymentMethod, network); + var client = supportedPaymentMethod.CreateLightningClient(network, Options.Value, _lightningClientFactory); LightningNodeInformation info; try { @@ -162,21 +162,6 @@ namespace BTCPayServer.Payments.Lightning } } - private ILightningClient CreateLightningClient(LightningSupportedPaymentMethod supportedPaymentMethod, BTCPayNetwork network) - { - var external = supportedPaymentMethod.GetExternalLightningUrl(); - if (external != null) - { - return _lightningClientFactory.Create(external, network); - } - else - { - if (!Options.Value.InternalLightningByCryptoCode.TryGetValue(network.CryptoCode, out var connectionString)) - throw new PaymentMethodUnavailableException("No internal node configured"); - return _lightningClientFactory.Create(connectionString, network); - } - } - public async Task TestConnection(NodeInfo nodeInfo, CancellationToken cancellation) { try diff --git a/BTCPayServer/Views/LightningLikePayout/ConfirmLightningPayout.cshtml b/BTCPayServer/Views/LightningLikePayout/ConfirmLightningPayout.cshtml new file mode 100644 index 000000000..5d4b2a11f --- /dev/null +++ b/BTCPayServer/Views/LightningLikePayout/ConfirmLightningPayout.cshtml @@ -0,0 +1,52 @@ +@model System.Collections.Generic.List +@{ + Layout = "../Shared/_Layout.cshtml"; + ViewData["Title"] = "Confirm Lightning Payout"; + var cryptoCode = Context.GetRouteValue("cryptoCode"); +} +
+
+ +

@ViewData["Title"]

+
+
+
    + @foreach (var item in Model) + { +
  • +
    @item.Destination
    + + @item.Amount @cryptoCode +
  • + +
    + + +
    + } + +
+
+
+
+@section PageFootContent { + + +} +
diff --git a/BTCPayServer/Views/LightningLikePayout/LightningPayoutResult.cshtml b/BTCPayServer/Views/LightningLikePayout/LightningPayoutResult.cshtml new file mode 100644 index 000000000..53cacadda --- /dev/null +++ b/BTCPayServer/Views/LightningLikePayout/LightningPayoutResult.cshtml @@ -0,0 +1,24 @@ +@using BTCPayServer.Lightning +@model System.Collections.Generic.List +@{ + Layout = "_Layout"; + ViewData["Title"] = $"Lightning Payout Result"; +} +
+
+

@ViewData["Title"]

+
+
+
    + @foreach (var item in Model) + { +
  • +
    @item.Destination
    + @(item.Result == PayResult.Ok ? "Sent" : "Failed") +
  • + } +
+
+
+
+
diff --git a/BTCPayServer/Views/PullPayment/ViewPullPayment.cshtml b/BTCPayServer/Views/PullPayment/ViewPullPayment.cshtml index 3fa45d1ae..d53aba700 100644 --- a/BTCPayServer/Views/PullPayment/ViewPullPayment.cshtml +++ b/BTCPayServer/Views/PullPayment/ViewPullPayment.cshtml @@ -15,11 +15,11 @@ { case "Completed": case "In Progress": - return "text-success"; + return "bg-success"; case "Cancelled": - return "text-danger"; + return "bg-danger"; default: - return "text-warning"; + return "bg-warning"; } } } @@ -55,8 +55,12 @@
- +
+ + +
+
@@ -147,6 +151,7 @@ Destination + Method Amount requested Status @@ -158,15 +163,16 @@ @invoice.Destination - @invoice.AmountFormatted + @invoice.PaymentMethod.ToPrettyString() + @invoice.AmountFormatted @if (!string.IsNullOrEmpty(invoice.Link)) { - @invoice.Status.GetStateString() + @invoice.Status.GetStateString() } else { - @invoice.Status.GetStateString() + @invoice.Status.GetStateString() } @@ -190,6 +196,7 @@
+