mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2024-11-19 09:54:30 +01:00
Add Lightning payout support (#2517)
* Add Lightning payout support * Adjust Greenfield API to allow other payment types for Payouts * Pull payment view: Improve payment method select * Pull payments view: Update JS * Pull payments view: Table improvements * Pull payment form: Remove duplicate name field * Cleanup Lightning branch after rebasing * Update swagger documnetation for Lightning support * Remove required requirement for amount in pull payments * Adapt Refund endpoint to support multiple playment methods * Support LNURL Pay for Pull Payments * Revert "Remove required requirement for amount in pull payments" This reverts commit 96cb78939d43b7be61ee2d257800ccd1cce45c4c. * Support Lightning address payout claims * Fix lightning claim handling and provide better error messages * Fix tests Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
This commit is contained in:
parent
5ac4135a13
commit
cf206e64a7
@ -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;
|
||||
|
@ -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)
|
||||
|
@ -54,6 +54,7 @@
|
||||
<PackageReference Include="Fido2" Version="2.0.1" />
|
||||
<PackageReference Include="Fido2.AspNet" Version="2.0.1" />
|
||||
<PackageReference Include="HtmlSanitizer" Version="5.0.372" />
|
||||
<PackageReference Include="LNURL" Version="0.0.7" />
|
||||
<PackageReference Include="McMaster.NETCore.Plugins.Mvc" Version="1.4.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Filter" Version="1.1.2" />
|
||||
<PackageReference Include="Microsoft.NetCore.Analyzers" Version="3.3.2">
|
||||
@ -63,7 +64,7 @@
|
||||
<PackageReference Include="QRCoder" Version="1.4.1" />
|
||||
<PackageReference Include="System.IO.Pipelines" Version="4.7.4" />
|
||||
<PackageReference Include="NBitpayClient" Version="1.0.0.39" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
<PackageReference Include="NicolasDorier.CommandLine" Version="1.0.0.2" />
|
||||
<PackageReference Include="NicolasDorier.CommandLine.Configuration" Version="1.0.0.3" />
|
||||
<PackageReference Include="NicolasDorier.RateLimits" Version="1.1.0" />
|
||||
|
@ -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<BTCPayNetwork>(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)");
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (paymentMethods.Any(p => _networkProvider.GetNetwork<BTCPayNetwork>(p.CryptoCode) is null))
|
||||
ModelState.AddModelError(nameof(request.PaymentMethods), "Invalid payment method");
|
||||
}
|
||||
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
|
||||
|
@ -164,7 +164,7 @@ namespace BTCPayServer.Controllers
|
||||
[HttpGet]
|
||||
[Route("invoices/{invoiceId}/refund")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Refund(string invoiceId, CancellationToken cancellationToken)
|
||||
public async Task<IActionResult> Refund([FromServices]IEnumerable<IPayoutHandler> 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<BTCPayNetwork>(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;
|
||||
|
@ -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<BTCPayNetwork>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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<BTCPayNetwork>(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<PayoutState>(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<BTCPayNetwork>(walletId.CryptoCode);
|
||||
List<string> bip21 = new List<string>();
|
||||
|
||||
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<List<PayoutData>> GetPayoutsForPaymentMethod(PaymentMethodId paymentMethodId,
|
||||
@ -375,12 +366,12 @@ namespace BTCPayServer.Controllers
|
||||
[HttpGet("{walletId}/payouts")]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
|
@ -18,5 +18,7 @@ namespace BTCPayServer.Data
|
||||
{
|
||||
return _bitcoinAddress.ToString();
|
||||
}
|
||||
|
||||
public decimal? Amount => null;
|
||||
}
|
||||
}
|
||||
|
@ -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<IClaimDestination> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination)
|
||||
public Task<(IClaimDestination destination, string error)> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination, bool validate)
|
||||
{
|
||||
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethodId.CryptoCode);
|
||||
destination = destination.Trim();
|
||||
@ -76,11 +76,12 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
|
||||
// return Task.FromResult<IClaimDestination>(new UriClaimDestination(new BitcoinUrlBuilder(destination, network.NBitcoinNetwork)));
|
||||
//}
|
||||
|
||||
return Task.FromResult<IClaimDestination>(new AddressClaimDestination(BitcoinAddress.Create(destination, network.NBitcoinNetwork)));
|
||||
return Task.FromResult<(IClaimDestination, string)>((new AddressClaimDestination(BitcoinAddress.Create(destination, network.NBitcoinNetwork)), null));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Task.FromResult<IClaimDestination>(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<PaymentMethodId> GetSupportedPaymentMethods()
|
||||
{
|
||||
Message = "Unknown action",
|
||||
Severity = StatusMessageModel.StatusSeverity.Error
|
||||
};;
|
||||
return _btcPayNetworkProvider.GetAll().OfType<BTCPayNetwork>()
|
||||
.Where(network => network.ReadonlyWallet is false)
|
||||
.Select(network => new PaymentMethodId(network.CryptoCode, BitcoinPaymentType.Instance));
|
||||
}
|
||||
|
||||
public async Task<IActionResult> 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<BTCPayNetwork>(paymentMethodId.CryptoCode);
|
||||
List<string> bip21 = new List<string>();
|
||||
foreach (var payout in payouts)
|
||||
{
|
||||
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()
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,10 @@
|
||||
using System;
|
||||
#nullable enable
|
||||
using NBitcoin;
|
||||
|
||||
namespace BTCPayServer.Data
|
||||
{
|
||||
public interface IClaimDestination
|
||||
{
|
||||
decimal? Amount { get; }
|
||||
}
|
||||
}
|
||||
|
@ -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<IClaimDestination> 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<Type[]> subscribe);
|
||||
@ -21,4 +22,6 @@ public interface IPayoutHandler
|
||||
Task<decimal> GetMinimumPayoutAmount(PaymentMethodId paymentMethod, IClaimDestination claimDestination);
|
||||
Dictionary<PayoutState, List<(string Action, string Text)>> GetPayoutSpecificActions();
|
||||
Task<StatusMessageModel> DoSpecificAction(string action, string[] payoutIds, string storeId);
|
||||
IEnumerable<PaymentMethodId> GetSupportedPaymentMethods();
|
||||
Task<IActionResult> InitiatePayment(PaymentMethodId paymentMethodId, string[] payoutIds);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
namespace BTCPayServer.Data.Payouts.LightningLike
|
||||
{
|
||||
public interface ILightningLikeLikeClaimDestination : IClaimDestination
|
||||
{
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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<ApplicationUser> _userManager;
|
||||
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
|
||||
private readonly IEnumerable<IPayoutHandler> _payoutHandlers;
|
||||
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
|
||||
private readonly LightningClientFactoryService _lightningClientFactoryService;
|
||||
private readonly IOptions<LightningNetworkOptions> _options;
|
||||
|
||||
public LightningLikePayoutController(ApplicationDbContextFactory applicationDbContextFactory,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings,
|
||||
IEnumerable<IPayoutHandler> payoutHandlers,
|
||||
BTCPayNetworkProvider btcPayNetworkProvider,
|
||||
LightningClientFactoryService lightningClientFactoryService,
|
||||
IOptions<LightningNetworkOptions> options)
|
||||
{
|
||||
_applicationDbContextFactory = applicationDbContextFactory;
|
||||
_userManager = userManager;
|
||||
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
|
||||
_payoutHandlers = payoutHandlers;
|
||||
_btcPayNetworkProvider = btcPayNetworkProvider;
|
||||
_lightningClientFactoryService = lightningClientFactoryService;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
private async Task<List<PayoutData>> GetPayouts(ApplicationDbContext dbContext, PaymentMethodId pmi,
|
||||
string[] payoutIds)
|
||||
{
|
||||
var userId = _userManager.GetUserId(User);
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
return new List<PayoutData>();
|
||||
}
|
||||
|
||||
var pmiStr = pmi.ToString();
|
||||
|
||||
var approvedStores = new Dictionary<string, bool>();
|
||||
|
||||
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<IActionResult> 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<IActionResult> 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<ResultVM>();
|
||||
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(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<LightningSupportedPaymentMethod>()
|
||||
.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; }
|
||||
}
|
||||
}
|
||||
}
|
@ -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<BTCPayNetwork>(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<BTCPayNetwork>(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<Type[]> subscribe)
|
||||
{
|
||||
}
|
||||
|
||||
public Task BackgroundCheck(object o)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<decimal> GetMinimumPayoutAmount(PaymentMethodId paymentMethodId, IClaimDestination claimDestination)
|
||||
{
|
||||
return Task.FromResult(Money.Satoshis(1).ToDecimal(MoneyUnit.BTC));
|
||||
}
|
||||
|
||||
public Dictionary<PayoutState, List<(string Action, string Text)>> GetPayoutSpecificActions()
|
||||
{
|
||||
return new Dictionary<PayoutState, List<(string Action, string Text)>>();
|
||||
}
|
||||
|
||||
public Task<StatusMessageModel> DoSpecificAction(string action, string[] payoutIds, string storeId)
|
||||
{
|
||||
return Task.FromResult<StatusMessageModel>(null);
|
||||
}
|
||||
|
||||
public IEnumerable<PaymentMethodId> GetSupportedPaymentMethods()
|
||||
{
|
||||
return _btcPayNetworkProvider.GetAll().OfType<BTCPayNetwork>().Where(network => network.SupportLightning)
|
||||
.Select(network => new PaymentMethodId(network.CryptoCode, LightningPaymentType.Instance));
|
||||
}
|
||||
|
||||
public Task<IActionResult> InitiatePayment(PaymentMethodId paymentMethodId, string[] payoutIds)
|
||||
{
|
||||
return Task.FromResult<IActionResult>(new RedirectToActionResult("ConfirmLightningPayout",
|
||||
"LightningLikePayout", new { cryptoCode = paymentMethodId.CryptoCode, payoutIds }));
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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<PaymentMethodId> GetSupportedPaymentMethods(
|
||||
this IEnumerable<IPayoutHandler> payoutHandlers, List<PaymentMethodId> paymentMethodIds = null)
|
||||
{
|
||||
return payoutHandlers.SelectMany(handler => handler.GetSupportedPaymentMethods())
|
||||
.Where(id => paymentMethodIds is null || paymentMethodIds.Contains(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ namespace BTCPayServer
|
||||
public static void AddModelError<TModel, TProperty>(this TModel source,
|
||||
Expression<Func<TModel, TProperty>> ex,
|
||||
string message,
|
||||
Controller controller)
|
||||
ControllerBase controller)
|
||||
{
|
||||
var provider = (ModelExpressionProvider)controller.HttpContext.RequestServices.GetService(typeof(ModelExpressionProvider));
|
||||
var key = provider.GetExpressionText(ex);
|
||||
|
@ -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;
|
||||
|
@ -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<IPayoutHandler, BitcoinLikePayoutHandler>();
|
||||
services.AddSingleton<IPayoutHandler, LightningLikePayoutHandler>();
|
||||
|
||||
services.AddHttpClient(LightningLikePayoutHandler.LightningLikePayoutHandlerOnionNamedClient)
|
||||
.ConfigureHttpClient(h => h.DefaultRequestHeaders.ConnectionClose = true)
|
||||
.ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>();
|
||||
services.AddSingleton<HostedServices.PullPaymentHostedService>();
|
||||
services.AddSingleton<IHostedService, HostedServices.PullPaymentHostedService>(o => o.GetRequiredService<PullPaymentHostedService>());
|
||||
|
||||
|
@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ namespace BTCPayServer.Models.WalletViewModels
|
||||
public string PullPaymentId { get; set; }
|
||||
public string Command { get; set; }
|
||||
public Dictionary<PayoutState, int> PayoutStateCount { get; set; }
|
||||
public PaymentMethodId PaymentMethodId { get; set; }
|
||||
public string PaymentMethodId { get; set; }
|
||||
|
||||
public List<PayoutModel> Payouts { get; set; }
|
||||
public PayoutState PayoutState { get; set; }
|
||||
|
@ -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<string> PaymentMethods { get; set; }
|
||||
public IEnumerable<SelectListItem> PaymentMethodItems { get; set; }
|
||||
}
|
||||
}
|
||||
|
26
BTCPayServer/Payments/Lightning/LightningExtensions.cs
Normal file
26
BTCPayServer/Payments/Lightning/LightningExtensions.cs
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -0,0 +1,52 @@
|
||||
@model System.Collections.Generic.List<BTCPayServer.Data.Payouts.LightningLike.LightningLikePayoutController.ConfirmVM>
|
||||
@{
|
||||
Layout = "../Shared/_Layout.cshtml";
|
||||
ViewData["Title"] = "Confirm Lightning Payout";
|
||||
var cryptoCode = Context.GetRouteValue("cryptoCode");
|
||||
}
|
||||
<section>
|
||||
<div class="container">
|
||||
|
||||
<h2 class="mb-4">@ViewData["Title"]</h2>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<ul class="list-group">
|
||||
@foreach (var item in Model)
|
||||
{
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div data-bs-toggle="tooltip" class="text-break" title="@item.Destination">@item.Destination</div>
|
||||
|
||||
<span class="text-capitalize badge bg-secondary">@item.Amount @cryptoCode</span>
|
||||
</li>
|
||||
|
||||
<form method="post" class="list-group-item justify-content-center" id="pay-invoices-form">
|
||||
<button type="submit" class="btn btn-primary xmx-2" style="min-width:25%;" id="Pay">Pay</button>
|
||||
<button type="button" class="btn btn-secondary mx-2" onclick="history.back(); return false;" style="min-width:25%;">Go back</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@section PageFootContent {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
$("#pay-invoices-form").on("submit", function() {
|
||||
$(this).find("input[type='submit']").prop('disabled', true);
|
||||
});
|
||||
|
||||
$("#pay-invoices-form input").on("input", function () {
|
||||
// Give it a timeout to make sure all form validation has completed by the time we run our callback
|
||||
setTimeout(function() {
|
||||
var validationErrors = $('.field-validation-error');
|
||||
if (validationErrors.length === 0) {
|
||||
$("input[type='submit']#Create").removeAttr('disabled');
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
}
|
||||
</section>
|
@ -0,0 +1,24 @@
|
||||
@using BTCPayServer.Lightning
|
||||
@model System.Collections.Generic.List<BTCPayServer.Data.Payouts.LightningLike.LightningLikePayoutController.ResultVM>
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
ViewData["Title"] = $"Lightning Payout Result";
|
||||
}
|
||||
<section>
|
||||
<div class="container">
|
||||
<h2 class="mb-4">@ViewData["Title"]</h2>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<ul class="list-group">
|
||||
@foreach (var item in Model)
|
||||
{
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center @(item.Result == PayResult.Ok ? "bg-success" : "bg-danger")">
|
||||
<div class="text-break" title="@item.Destination">@item.Destination</div>
|
||||
<span class="badge bg-secondary">@(item.Result == PayResult.Ok ? "Sent" : "Failed")</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
@ -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 @@
|
||||
<form asp-action="ClaimPullPayment" asp-route-pullPaymentId="@Model.Id" class="w-100">
|
||||
<div class="row align-items-center" style="width:calc(100% + 30px)">
|
||||
<div class="col-12 mb-3 col-lg-6 mb-lg-0">
|
||||
<input class="form-control form-control-lg font-monospace w-100" asp-for="Destination" placeholder="Enter destination address to claim funds …" required style="font-size:.9rem;height:42px;">
|
||||
<div class="input-group">
|
||||
<input class="form-control form-control-lg font-monospace" asp-for="Destination" placeholder="Enter destination to claim funds" required style="font-size:.9rem;height:42px;">
|
||||
<select class="form-select w-auto" asp-for="SelectedPaymentMethod" asp-items="Model.PaymentMethods.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString()))"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 mb-3 col-sm-6 mb-sm-0 col-lg-3">
|
||||
<div class="input-group">
|
||||
<input type="number" class="form-control form-control-lg text-end hide-number-spin" asp-for="ClaimedAmount" max="@Model.AmountDue" min="@Model.MinimumClaim" step="any" placeholder="Amount" required>
|
||||
@ -147,6 +151,7 @@
|
||||
<thead>
|
||||
<tr class="table-borderless">
|
||||
<th class="fw-normal text-secondary" scope="col">Destination</th>
|
||||
<th class="fw-normal text-secondary" scope="col">Method</th>
|
||||
<th class="fw-normal text-secondary text-end text-nowrap">Amount requested</th>
|
||||
<th class="fw-normal text-secondary text-end">Status</th>
|
||||
</tr>
|
||||
@ -158,15 +163,16 @@
|
||||
<td class="text-break">
|
||||
@invoice.Destination
|
||||
</td>
|
||||
<td class="text-end">@invoice.AmountFormatted</td>
|
||||
<td class="text-nowrap">@invoice.PaymentMethod.ToPrettyString()</td>
|
||||
<td class="text-end text-nowrap">@invoice.AmountFormatted</td>
|
||||
<td class="text-end text-nowrap">
|
||||
@if (!string.IsNullOrEmpty(invoice.Link))
|
||||
{
|
||||
<a class="transaction-link text-print-default @StatusTextClass(invoice.Status.ToString())" href="@invoice.Link" rel="noreferrer noopener">@invoice.Status.GetStateString()</a>
|
||||
<a class="transaction-link text-print-default badge @StatusTextClass(invoice.Status.ToString())" href="@invoice.Link" rel="noreferrer noopener">@invoice.Status.GetStateString()</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-print-default @StatusTextClass(invoice.Status.ToString())">@invoice.Status.GetStateString()</span>
|
||||
<span class="text-print-default badge @StatusTextClass(invoice.Status.ToString())">@invoice.Status.GetStateString()</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@ -190,6 +196,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<partial name="LayoutFoot" />
|
||||
<script src="~/js/copy-to-clipboard.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
document.getElementById("copyLink").addEventListener("click", window.copyUrlToClipboard);
|
||||
|
@ -72,6 +72,10 @@
|
||||
<span asp-validation-for="Currency" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="PaymentMethods"></label>
|
||||
<select asp-for="PaymentMethods" asp-items="Model.PaymentMethodItems" class="form-select" multiple></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="CustomCSSLink" class="form-label"></label>
|
||||
<a href="https://docs.btcpayserver.org/Development/Theme/#2-bootstrap-themes" target="_blank" rel="noreferrer noopener">
|
||||
|
@ -1,13 +1,16 @@
|
||||
@using BTCPayServer.Client.Models
|
||||
@using BTCPayServer.Payments
|
||||
@model PayoutsModel
|
||||
|
||||
@inject IEnumerable<IPayoutHandler> PayoutHandlers;
|
||||
@{
|
||||
Layout = "../Shared/_NavLayout.cshtml";
|
||||
ViewData.SetActivePageAndTitle(WalletsNavPages.Payouts, $"Manage {Model.PaymentMethodId.ToPrettyString()} payouts{(string.IsNullOrEmpty(Model.PullPaymentName) ? string.Empty : " for pull payment " + Model.PullPaymentName)}", Context.GetStoreData().StoreName);
|
||||
ViewData.SetActivePageAndTitle(WalletsNavPages.Payouts, $"Manage payouts{(string.IsNullOrEmpty(Model.PullPaymentName) ? string.Empty : " for pull payment " + Model.PullPaymentName)}", Context.GetStoreData().StoreName);
|
||||
|
||||
var paymentMethods = PayoutHandlers.GetSupportedPaymentMethods();
|
||||
|
||||
var stateActions = new List<(string Action, string Text)>();
|
||||
var payoutHandler = PayoutHandlers.First(handler => handler.CanHandle(Model.PaymentMethodId));
|
||||
var payoutHandler = PayoutHandlers.First(handler => handler.CanHandle(PaymentMethodId.Parse(Model.PaymentMethodId)));
|
||||
stateActions.AddRange(payoutHandler.GetPayoutSpecificActions().Where(pair => pair.Key == Model.PayoutState).SelectMany(pair => pair.Value));
|
||||
switch (Model.PayoutState)
|
||||
{
|
||||
@ -42,14 +45,37 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-auto">
|
||||
<ul class="nav nav-pills">
|
||||
@foreach (var state in Model.PayoutStateCount)
|
||||
<ul class="nav nav-pills bg-tile mb-2" style="border-radius:4px; border: 1px solid var(--btcpay-body-border-medium)">
|
||||
@foreach (var state in paymentMethods)
|
||||
{
|
||||
<li class="nav-item py-0">
|
||||
<a id="@state.Key-view" asp-action="Payouts" asp-route-walletId="@Context.GetRouteValue("walletId")" asp-route-payoutState="@state.Key" asp-route-pullPaymentId="@Model.PullPaymentId" class="nav-link me-1 @(state.Key == Model.PayoutState ? "active" : "")" role="tab">@state.Key.GetStateString() (@state.Value)</a>
|
||||
<a asp-action="Payouts" asp-route-walletId="@Context.GetRouteValue("walletId")"
|
||||
asp-route-payoutState="@Model.PayoutState"
|
||||
asp-route-paymentMethodId="@state.ToString()"
|
||||
asp-route-pullPaymentId="@Model.PullPaymentId"
|
||||
class="nav-link me-1 @(state.ToString() == Model.PaymentMethodId ? "active" : "")"
|
||||
id="@state.ToString()-view"
|
||||
role="tab">
|
||||
@state.ToPrettyString()
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<ul class="nav nav-pills bg-tile" style="border: 1px solid var(--btcpay-body-border-medium)">
|
||||
@foreach (var state in Model.PayoutStateCount)
|
||||
{
|
||||
<li class="nav-item py-0">
|
||||
<a id="@state.Key-view"
|
||||
asp-action="Payouts"
|
||||
asp-route-walletId="@Context.GetRouteValue("walletId")"
|
||||
asp-route-payoutState="@state.Key"
|
||||
asp-route-pullPaymentId="@Model.PullPaymentId"
|
||||
asp-route-paymentMethodId="@Model.PaymentMethodId"
|
||||
class="nav-link me-1 @(state.Key == Model.PayoutState ? "active" : "")" role="tab">@state.Key.GetStateString() (@state.Value)</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
@if (Model.Payouts.Any() && stateActions.Any())
|
||||
{
|
||||
@ -69,7 +95,6 @@
|
||||
|
||||
<div class="row">
|
||||
|
||||
<div>
|
||||
@if (Model.Payouts.Any())
|
||||
{
|
||||
<table class="table table-hover table-responsive-lg">
|
||||
@ -107,8 +132,8 @@
|
||||
<td class="mw-100">
|
||||
<span>@pp.PullPaymentName</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>@pp.Destination</span>
|
||||
<td title="@pp.Destination">
|
||||
<span class="text-break">@pp.Destination</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span>@pp.Amount</span>
|
||||
@ -131,7 +156,6 @@
|
||||
{
|
||||
<p class="mb-0 py-4" id="@Model.PayoutState-no-payouts">There are no payouts matching this criteria.</p>
|
||||
}
|
||||
</div>
|
||||
<vc:pager view-model="Model"/>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -72,7 +72,7 @@
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"example": "BTC",
|
||||
"description": "The currency of the amount. In this current release, this parameter must be set to a cryptoCode like (`BTC`)."
|
||||
"description": "The currency of the amount."
|
||||
},
|
||||
"period": {
|
||||
"type": "integer",
|
||||
@ -97,7 +97,7 @@
|
||||
},
|
||||
"paymentMethods": {
|
||||
"type": "array",
|
||||
"description": "The list of supported payment methods supported. In this current release, this must be set to an array with a single entry equals to `currency` (eg. `[ \"BTC\" ]`)",
|
||||
"description": "The list of supported payment methods supported by this pull payment. Available options can be queried from the `StorePaymentMethods_GetStorePaymentMethods` endpoint",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"example": "BTC"
|
||||
|
Loading…
Reference in New Issue
Block a user