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:
Andrew Camilleri 2021-10-18 05:37:59 +02:00 committed by GitHub
parent 5ac4135a13
commit cf206e64a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 925 additions and 172 deletions

View File

@ -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;

View File

@ -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)

View File

@ -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" />

View File

@ -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

View File

@ -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;

View File

@ -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);
}
}
}

View File

@ -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);
}

View File

@ -18,5 +18,7 @@ namespace BTCPayServer.Data
{
return _bitcoinAddress.ToString();
}
public decimal? Amount => null;
}
}

View File

@ -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()

View File

@ -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);
}
}

View File

@ -1,8 +1,10 @@
using System;
#nullable enable
using NBitcoin;
namespace BTCPayServer.Data
{
public interface IClaimDestination
{
decimal? Amount { get; }
}
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -0,0 +1,6 @@
namespace BTCPayServer.Data.Payouts.LightningLike
{
public interface ILightningLikeLikeClaimDestination : IClaimDestination
{
}
}

View File

@ -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;
}
}
}

View File

@ -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; }
}
}
}

View File

@ -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 }));
}
}
}

View File

@ -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;
}
}

View File

@ -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));
}
}
}

View File

@ -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);

View File

@ -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;

View File

@ -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>());

View File

@ -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; }
}
}
}

View File

@ -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; }

View File

@ -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; }
}
}

View 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);
}
}
}
}

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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);

View File

@ -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">

View File

@ -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>

View File

@ -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"