Refactor payouts processing (#6314)

This commit is contained in:
Nicolas Dorier
2024-10-19 21:33:34 +09:00
committed by GitHub
parent 62d765125d
commit cc0ea0b3f8
17 changed files with 596 additions and 447 deletions

View File

@@ -679,6 +679,26 @@ namespace BTCPayServer.Tests
Assert.Equal(utxo54, utxos[53]); Assert.Equal(utxo54, utxos[53]);
} }
[Fact]
public void ResourceTrackerTest()
{
var tracker = new ResourceTracker<string>();
var t1 = tracker.StartTracking();
Assert.True(t1.TryTrack("1"));
Assert.False(t1.TryTrack("1"));
var t2 = tracker.StartTracking();
Assert.True(t2.TryTrack("2"));
Assert.False(t2.TryTrack("1"));
Assert.True(t1.Contains("1"));
Assert.True(t2.Contains("2"));
Assert.True(tracker.Contains("1"));
Assert.True(tracker.Contains("2"));
t1.Dispose();
Assert.False(tracker.Contains("1"));
Assert.True(tracker.Contains("2"));
Assert.True(t2.TryTrack("1"));
}
[Fact] [Fact]
public void CanAcceptInvoiceWithTolerance() public void CanAcceptInvoiceWithTolerance()
{ {

View File

@@ -4201,8 +4201,8 @@ namespace BTCPayServer.Tests
await TestUtils.EventuallyAsync(async () => await TestUtils.EventuallyAsync(async () =>
{ {
var payoutC = var payoutC =
(await adminClient.GetStorePayouts(admin.StoreId, false)).Single(data => data.Id == payout.Id); (await adminClient.GetStorePayouts(admin.StoreId, false)).SingleOrDefault(data => data.Id == payout.Id);
Assert.Equal(PayoutState.Completed, payoutC.State); Assert.Equal(PayoutState.Completed, payoutC?.State);
}); });
payout = await adminClient.CreatePayout(admin.StoreId, payout = await adminClient.CreatePayout(admin.StoreId,

View File

@@ -41,7 +41,7 @@
<PackageReference Include="YamlDotNet" Version="8.0.0" /> <PackageReference Include="YamlDotNet" Version="8.0.0" />
<PackageReference Include="BIP78.Sender" Version="0.2.2" /> <PackageReference Include="BIP78.Sender" Version="0.2.2" />
<PackageReference Include="BTCPayServer.Hwi" Version="2.0.2" /> <PackageReference Include="BTCPayServer.Hwi" Version="2.0.2" />
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.6.0" /> <PackageReference Include="BTCPayServer.Lightning.All" Version="1.6.1" />
<PackageReference Include="CsvHelper" Version="32.0.3" /> <PackageReference Include="CsvHelper" Version="32.0.3" />
<PackageReference Include="Dapper" Version="2.1.35" /> <PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Fido2" Version="2.0.2" /> <PackageReference Include="Fido2" Version="2.0.2" />

View File

@@ -407,23 +407,29 @@ namespace BTCPayServer.Controllers.Greenfield
return this.CreateValidationError(ModelState); return this.CreateValidationError(ModelState);
} }
var amtError = ClaimRequest.IsPayoutAmountOk(destination.destination, request.Amount, payoutHandler.Currency, pp.Currency); var amt = ClaimRequest.GetClaimedAmount(destination.destination, request.Amount, payoutHandler.Currency, pp.Currency);
if (amtError.error is not null) if (amt is ClaimRequest.ClaimedAmountResult.Error err)
{ {
ModelState.AddModelError(nameof(request.Amount), amtError.error ); ModelState.AddModelError(nameof(request.Amount), err.Message);
return this.CreateValidationError(ModelState); return this.CreateValidationError(ModelState);
} }
request.Amount = amtError.amount; else if (amt is ClaimRequest.ClaimedAmountResult.Success succ)
var result = await _pullPaymentService.Claim(new ClaimRequest()
{ {
Destination = destination.destination, request.Amount = succ.Amount;
PullPaymentId = pullPaymentId, var result = await _pullPaymentService.Claim(new ClaimRequest()
Value = request.Amount, {
PayoutMethodId = payoutMethodId, Destination = destination.destination,
StoreId = pp.StoreId PullPaymentId = pullPaymentId,
}); ClaimedAmount = request.Amount,
PayoutMethodId = payoutMethodId,
return HandleClaimResult(result); StoreId = pp.StoreId
});
return HandleClaimResult(result);
}
else
{
throw new NotSupportedException($"Should never happen {amt}");
}
} }
[HttpPost("~/api/v1/stores/{storeId}/payouts")] [HttpPost("~/api/v1/stores/{storeId}/payouts")]
@@ -456,6 +462,7 @@ namespace BTCPayServer.Controllers.Greenfield
PullPaymentBlob? ppBlob = null; PullPaymentBlob? ppBlob = null;
string? ppCurrency = null;
if (request?.PullPaymentId is not null) if (request?.PullPaymentId is not null)
{ {
@@ -464,6 +471,7 @@ namespace BTCPayServer.Controllers.Greenfield
if (pp is null) if (pp is null)
return PullPaymentNotFound(); return PullPaymentNotFound();
ppBlob = pp.GetBlob(); ppBlob = pp.GetBlob();
ppCurrency = pp.Currency;
} }
var destination = await payoutHandler.ParseAndValidateClaimDestination(request!.Destination, ppBlob, default); var destination = await payoutHandler.ParseAndValidateClaimDestination(request!.Destination, ppBlob, default);
if (destination.destination is null) if (destination.destination is null)
@@ -472,30 +480,37 @@ namespace BTCPayServer.Controllers.Greenfield
return this.CreateValidationError(ModelState); return this.CreateValidationError(ModelState);
} }
var amtError = ClaimRequest.IsPayoutAmountOk(destination.destination, request.Amount); var amt = ClaimRequest.GetClaimedAmount(destination.destination, request.Amount, payoutHandler.Currency, ppCurrency);
if (amtError.error is not null) if (amt is ClaimRequest.ClaimedAmountResult.Error err)
{ {
ModelState.AddModelError(nameof(request.Amount), amtError.error ); ModelState.AddModelError(nameof(request.Amount), err.Message);
return this.CreateValidationError(ModelState); return this.CreateValidationError(ModelState);
} }
request.Amount = amtError.amount; else if (amt is ClaimRequest.ClaimedAmountResult.Success succ)
if (request.Amount is { } v && (v < ppBlob?.MinimumClaim || v == 0.0m))
{ {
var minimumClaim = ppBlob?.MinimumClaim is decimal val ? val : 0.0m; request.Amount = succ.Amount;
ModelState.AddModelError(nameof(request.Amount), $"Amount too small (should be at least {minimumClaim})"); if (request.Amount is { } v && (v < ppBlob?.MinimumClaim || v == 0.0m))
return this.CreateValidationError(ModelState); {
var minimumClaim = ppBlob?.MinimumClaim is decimal val ? val : 0.0m;
ModelState.AddModelError(nameof(request.Amount), $"Amount too small (should be at least {minimumClaim})");
return this.CreateValidationError(ModelState);
}
var result = await _pullPaymentService.Claim(new ClaimRequest()
{
Destination = destination.destination,
PullPaymentId = request.PullPaymentId,
PreApprove = request.Approved,
ClaimedAmount = request.Amount,
PayoutMethodId = paymentMethodId,
StoreId = storeId,
Metadata = request.Metadata
});
return HandleClaimResult(result);
} }
var result = await _pullPaymentService.Claim(new ClaimRequest() else
{ {
Destination = destination.destination, throw new NotSupportedException($"Should never happen {amt}");
PullPaymentId = request.PullPaymentId, }
PreApprove = request.Approved,
Value = request.Amount,
PayoutMethodId = paymentMethodId,
StoreId = storeId,
Metadata = request.Metadata
});
return HandleClaimResult(result);
} }
private IActionResult HandleClaimResult(ClaimRequest.ClaimResponse result) private IActionResult HandleClaimResult(ClaimRequest.ClaimResponse result)

View File

@@ -193,7 +193,7 @@ namespace BTCPayServer
PayoutMethodId = pmi, PayoutMethodId = pmi,
PullPaymentId = pullPaymentId, PullPaymentId = pullPaymentId,
StoreId = pp.StoreId, StoreId = pp.StoreId,
Value = result.MinimumAmount.ToDecimal(unit), ClaimedAmount = result.MinimumAmount.ToDecimal(unit),
}); });
if (claimResponse.Result != ClaimRequest.ClaimResult.Ok) if (claimResponse.Result != ClaimRequest.ClaimResult.Ok)

View File

@@ -196,10 +196,13 @@ namespace BTCPayServer.Controllers
} }
[AllowAnonymous] [AllowAnonymous]
[HttpPost("pull-payments/{pullPaymentId}/claim")] [HttpPost("pull-payments/{pullPaymentId}")]
public async Task<IActionResult> ClaimPullPayment(string pullPaymentId, ViewPullPaymentModel vm, CancellationToken cancellationToken) public async Task<IActionResult> ClaimPullPayment(string pullPaymentId, ViewPullPaymentModel vm, CancellationToken cancellationToken)
{ {
await using var ctx = _dbContextFactory.CreateContext(); if (vm.ClaimedAmount == 0)
vm.ClaimedAmount = null;
await using var ctx = _dbContextFactory.CreateContext();
var pp = await ctx.PullPayments.FindAsync(pullPaymentId); var pp = await ctx.PullPayments.FindAsync(pullPaymentId);
if (pp is null) if (pp is null)
{ {
@@ -251,14 +254,14 @@ namespace BTCPayServer.Controllers
ModelState.AddModelError(nameof(vm.Destination), error ?? StringLocalizer["Invalid destination or payment method"]); ModelState.AddModelError(nameof(vm.Destination), error ?? StringLocalizer["Invalid destination or payment method"]);
return await ViewPullPayment(pullPaymentId); return await ViewPullPayment(pullPaymentId);
} }
var amtError = ClaimRequest.IsPayoutAmountOk(destination, vm.ClaimedAmount == 0 ? null : vm.ClaimedAmount, payoutHandler.Currency, pp.Currency); var claimedAmount = ClaimRequest.GetClaimedAmount(destination, vm.ClaimedAmount, payoutHandler.Currency, pp.Currency);
if (amtError.error is not null) if (claimedAmount is ClaimRequest.ClaimedAmountResult.Error err2)
{ {
ModelState.AddModelError(nameof(vm.ClaimedAmount), amtError.error); ModelState.AddModelError(nameof(vm.ClaimedAmount), err2.Message);
} }
else if (amtError.amount is not null) else if (claimedAmount is ClaimRequest.ClaimedAmountResult.Success succ)
{ {
vm.ClaimedAmount = amtError.amount.Value; vm.ClaimedAmount = succ.Amount;
} }
if (!ModelState.IsValid) if (!ModelState.IsValid)
@@ -270,7 +273,7 @@ namespace BTCPayServer.Controllers
{ {
Destination = destination, Destination = destination,
PullPaymentId = pullPaymentId, PullPaymentId = pullPaymentId,
Value = vm.ClaimedAmount, ClaimedAmount = vm.ClaimedAmount,
PayoutMethodId = payoutMethodId, PayoutMethodId = payoutMethodId,
StoreId = pp.StoreId StoreId = pp.StoreId
}); });
@@ -283,11 +286,19 @@ namespace BTCPayServer.Controllers
return await ViewPullPayment(pullPaymentId); return await ViewPullPayment(pullPaymentId);
} }
TempData.SetStatusMessageModel(new StatusMessageModel
{ TempData.SetStatusMessageModel(new StatusMessageModel
Message = $"Your claim request of {_displayFormatter.Currency(vm.ClaimedAmount, pp.Currency, DisplayFormatter.CurrencyFormat.Symbol)} to {vm.Destination} has been submitted and is awaiting {(result.PayoutData.State == PayoutState.AwaitingApproval ? "approval" : "payment")}.", {
Severity = StatusMessageModel.StatusSeverity.Success Message = (vm.ClaimedAmount, result.PayoutData.State) switch
}); {
(null, PayoutState.AwaitingApproval) => $"Your claim request to {vm.Destination} has been submitted and is awaiting approval",
(null, PayoutState.AwaitingPayment) => $"Your claim request to {vm.Destination} has been submitted and is awaiting payment",
({ } a, PayoutState.AwaitingApproval) => $"Your claim request of {_displayFormatter.Currency(a, pp.Currency, DisplayFormatter.CurrencyFormat.Symbol)} to {vm.Destination} has been submitted and is awaiting approval",
({ } a, PayoutState.AwaitingPayment) => $"Your claim request of {_displayFormatter.Currency(a, pp.Currency, DisplayFormatter.CurrencyFormat.Symbol)} to {vm.Destination} has been submitted and is awaiting payment",
_ => $"Unexpected payout state ({result.PayoutData.State})"
},
Severity = StatusMessageModel.StatusSeverity.Success
});
return RedirectToAction(nameof(ViewPullPayment), new { pullPaymentId }); return RedirectToAction(nameof(ViewPullPayment), new { pullPaymentId });
} }

View File

@@ -757,7 +757,7 @@ namespace BTCPayServer.Controllers
{ {
Destination = new AddressClaimDestination( Destination = new AddressClaimDestination(
BitcoinAddress.Create(output.DestinationAddress, network.NBitcoinNetwork)), BitcoinAddress.Create(output.DestinationAddress, network.NBitcoinNetwork)),
Value = output.Amount, ClaimedAmount = output.Amount,
PayoutMethodId = pmi, PayoutMethodId = pmi,
StoreId = walletId.StoreId, StoreId = walletId.StoreId,
PreApprove = true, PreApprove = true,
@@ -777,7 +777,7 @@ namespace BTCPayServer.Controllers
message = "Payouts scheduled:<br/>"; message = "Payouts scheduled:<br/>";
} }
message += $"{claimRequest.Value} to {claimRequest.Destination.ToString()}<br/>"; message += $"{claimRequest.ClaimedAmount} to {claimRequest.Destination.ToString()}<br/>";
} }
else else
@@ -791,10 +791,10 @@ namespace BTCPayServer.Controllers
switch (response.Result) switch (response.Result)
{ {
case ClaimRequest.ClaimResult.Duplicate: case ClaimRequest.ClaimResult.Duplicate:
errorMessage += $"{claimRequest.Value} to {claimRequest.Destination.ToString()} - address reuse<br/>"; errorMessage += $"{claimRequest.ClaimedAmount} to {claimRequest.Destination.ToString()} - address reuse<br/>";
break; break;
case ClaimRequest.ClaimResult.AmountTooLow: case ClaimRequest.ClaimResult.AmountTooLow:
errorMessage += $"{claimRequest.Value} to {claimRequest.Destination.ToString()} - amount too low<br/>"; errorMessage += $"{claimRequest.ClaimedAmount} to {claimRequest.Destination.ToString()} - amount too low<br/>";
break; break;
} }
} }

View File

@@ -6,6 +6,5 @@ namespace BTCPayServer.Data
{ {
public string? Id { get; } public string? Id { get; }
decimal? Amount { get; } decimal? Amount { get; }
bool IsExplicitAmountMinimum => false;
} }
} }

View File

@@ -23,6 +23,5 @@ namespace BTCPayServer.Data.Payouts.LightningLike
public uint256 PaymentHash { get; } public uint256 PaymentHash { get; }
public string Id => PaymentHash.ToString(); public string Id => PaymentHash.ToString();
public decimal? Amount { get; } public decimal? Amount { get; }
public bool IsExplicitAmountMinimum => true; };
}
} }

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
@@ -208,5 +209,6 @@ namespace BTCPayServer.Data.Payouts.LightningLike
"UILightningLikePayout", new { cryptoCode, payoutIds })); "UILightningLikePayout", new { cryptoCode, payoutIds }));
} }
public ResourceTracker<string> PayoutsPaymentProcessing { get; } = new ResourceTracker<string>();
} }
} }

View File

@@ -12,6 +12,7 @@ using BTCPayServer.Lightning;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Lightning; using BTCPayServer.Payments.Lightning;
using BTCPayServer.PayoutProcessors.Lightning;
using BTCPayServer.Payouts; using BTCPayServer.Payouts;
using BTCPayServer.Security; using BTCPayServer.Security;
using BTCPayServer.Services; using BTCPayServer.Services;
@@ -32,6 +33,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
public class UILightningLikePayoutController : Controller public class UILightningLikePayoutController : Controller
{ {
private readonly ApplicationDbContextFactory _applicationDbContextFactory; private readonly ApplicationDbContextFactory _applicationDbContextFactory;
private readonly LightningAutomatedPayoutSenderFactory _lightningAutomatedPayoutSenderFactory;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings; private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
private readonly PayoutMethodHandlerDictionary _payoutHandlers; private readonly PayoutMethodHandlerDictionary _payoutHandlers;
@@ -43,17 +45,19 @@ namespace BTCPayServer.Data.Payouts.LightningLike
private readonly StoreRepository _storeRepository; private readonly StoreRepository _storeRepository;
public UILightningLikePayoutController(ApplicationDbContextFactory applicationDbContextFactory, public UILightningLikePayoutController(ApplicationDbContextFactory applicationDbContextFactory,
LightningAutomatedPayoutSenderFactory lightningAutomatedPayoutSenderFactory,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings, BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings,
PayoutMethodHandlerDictionary payoutHandlers, PayoutMethodHandlerDictionary payoutHandlers,
PaymentMethodHandlerDictionary handlers, PaymentMethodHandlerDictionary handlers,
StoreRepository storeRepository, StoreRepository storeRepository,
LightningClientFactoryService lightningClientFactoryService, LightningClientFactoryService lightningClientFactoryService,
IOptions<LightningNetworkOptions> options, IOptions<LightningNetworkOptions> options,
IAuthorizationService authorizationService, IAuthorizationService authorizationService,
EventAggregator eventAggregator) EventAggregator eventAggregator)
{ {
_applicationDbContextFactory = applicationDbContextFactory; _applicationDbContextFactory = applicationDbContextFactory;
_lightningAutomatedPayoutSenderFactory = lightningAutomatedPayoutSenderFactory;
_userManager = userManager; _userManager = userManager;
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings; _btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
_payoutHandlers = payoutHandlers; _payoutHandlers = payoutHandlers;
@@ -132,248 +136,66 @@ namespace BTCPayServer.Data.Payouts.LightningLike
var paymentMethodId = PaymentTypes.LN.GetPaymentMethodId(cryptoCode); var paymentMethodId = PaymentTypes.LN.GetPaymentMethodId(cryptoCode);
var payoutHandler = (LightningLikePayoutHandler)_payoutHandlers.TryGet(pmi); var payoutHandler = (LightningLikePayoutHandler)_payoutHandlers.TryGet(pmi);
await using var ctx = _applicationDbContextFactory.CreateContext(); IEnumerable<IGrouping<string, PayoutData>> payouts;
using (var ctx = _applicationDbContextFactory.CreateContext())
var payouts = (await GetPayouts(ctx, pmi, payoutIds)).GroupBy(data => data.StoreDataId); {
payouts = (await GetPayouts(ctx, pmi, payoutIds)).GroupBy(data => data.StoreDataId);
}
var results = new List<ResultVM>(); var results = new List<ResultVM>();
//we group per store and init the transfers by each //we group per store and init the transfers by each
var authorizedForInternalNode = (await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.CanModifyServerSettings))).Succeeded; var authorizedForInternalNode = (await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.CanModifyServerSettings))).Succeeded;
foreach (var payoutDatas in payouts) foreach (var payoutDatas in payouts)
{ {
var store = payoutDatas.First().StoreData; var store = payoutDatas.First().StoreData;
var authorized = await _authorizationService.AuthorizeAsync(User, store, new PolicyRequirement(Policies.CanUseLightningNodeInStore));
if (!authorized.Succeeded)
{
results.AddRange(FailAll(payoutDatas, "You need the 'btcpay.store.canuselightningnode' permission for this action"));
continue;
}
var lightningSupportedPaymentMethod = store.GetPaymentMethodConfig<LightningPaymentMethodConfig>(paymentMethodId, _handlers); var lightningSupportedPaymentMethod = store.GetPaymentMethodConfig<LightningPaymentMethodConfig>(paymentMethodId, _handlers);
if (lightningSupportedPaymentMethod.IsInternalNode && !authorizedForInternalNode) if (lightningSupportedPaymentMethod.IsInternalNode && !authorizedForInternalNode)
{ {
foreach (PayoutData payoutData in payoutDatas) results.AddRange(FailAll(payoutDatas, "You are currently using the internal Lightning node for this payout's store but you are not a server admin."));
{
var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings);
results.Add(new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Error,
Destination = blob.Destination,
Message = "You are currently using the internal Lightning node for this payout's store but you are not a server admin."
});
}
continue; continue;
} }
var processor = _lightningAutomatedPayoutSenderFactory.ConstructProcessor(new PayoutProcessorData()
{
Store = store,
StoreId = store.Id,
PayoutMethodId = pmi.ToString(),
Processor = LightningAutomatedPayoutSenderFactory.ProcessorName,
Id = Guid.NewGuid().ToString()
});
var client = var client =
lightningSupportedPaymentMethod.CreateLightningClient(payoutHandler.Network, _options.Value, lightningSupportedPaymentMethod.CreateLightningClient(payoutHandler.Network, _options.Value,
_lightningClientFactoryService); _lightningClientFactoryService);
foreach (var payoutData in payoutDatas)
foreach (var payout in payoutDatas)
{ {
ResultVM result; results.Add(await processor.HandlePayout(payout, client, cancellationToken));
var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings);
var claim = await payoutHandler.ParseClaimDestination(blob.Destination, cancellationToken);
try
{
switch (claim.destination)
{
case LNURLPayClaimDestinaton lnurlPayClaimDestinaton:
var lnurlResult = await GetInvoiceFromLNURL(payoutData, payoutHandler, blob,
lnurlPayClaimDestinaton, payoutHandler.Network.NBitcoinNetwork, cancellationToken);
if (lnurlResult.Item2 is not null)
{
result = lnurlResult.Item2;
}
else
{
result = await TrypayBolt(client, blob, payoutData, lnurlResult.Item1, cancellationToken);
}
break;
case BoltInvoiceClaimDestination item1:
result = await TrypayBolt(client, blob, payoutData, item1.PaymentRequest, cancellationToken);
break;
default:
result = new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Error,
Destination = blob.Destination,
Message = claim.error
};
break;
}
}
catch (Exception exception)
{
result = new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Error,
Destination = blob.Destination,
Message = exception.Message
};
}
results.Add(result);
}
}
await ctx.SaveChangesAsync();
foreach (var payoutG in payouts)
{
foreach (PayoutData payout in payoutG)
{
if (payout.State != PayoutState.AwaitingPayment)
{
_eventAggregator.Publish(new PayoutEvent(PayoutEvent.PayoutEventType.Updated, payout));
}
} }
} }
return View("LightningPayoutResult", results); return View("LightningPayoutResult", results);
} }
public static async Task<(BOLT11PaymentRequest, ResultVM)> GetInvoiceFromLNURL(PayoutData payoutData,
LightningLikePayoutHandler handler, PayoutBlob blob, LNURLPayClaimDestinaton lnurlPayClaimDestinaton, Network network, CancellationToken cancellationToken) private ResultVM[] FailAll(IEnumerable<PayoutData> payouts, string message)
{ {
var endpoint = lnurlPayClaimDestinaton.LNURL.IsValidEmail() return payouts.Select(p => Fail(p, message)).ToArray();
? LNURL.LNURL.ExtractUriFromInternetIdentifier(lnurlPayClaimDestinaton.LNURL)
: LNURL.LNURL.Parse(lnurlPayClaimDestinaton.LNURL, out _);
var httpClient = handler.CreateClient(endpoint);
var lnurlInfo =
(LNURLPayRequest)await LNURL.LNURL.FetchInformation(endpoint, "payRequest",
httpClient, cancellationToken);
var lm = new LightMoney(payoutData.Amount.Value, LightMoneyUnit.BTC);
if (lm > lnurlInfo.MaxSendable || lm < lnurlInfo.MinSendable)
{
payoutData.State = PayoutState.Cancelled;
return (null, new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Error,
Destination = blob.Destination,
Message =
$"The LNURL provided would not generate an invoice of {lm.ToDecimal(LightMoneyUnit.Satoshi)} sats"
});
}
try
{
var lnurlPayRequestCallbackResponse =
await lnurlInfo.SendRequest(lm, network, httpClient, cancellationToken: cancellationToken);
return (lnurlPayRequestCallbackResponse.GetPaymentRequest(network), null);
}
catch (LNUrlException e)
{
return (null,
new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Error,
Destination = blob.Destination,
Message = e.Message
});
}
} }
private ResultVM Fail(PayoutData payoutData, string message)
public static async Task<ResultVM> TrypayBolt(
ILightningClient lightningClient, PayoutBlob payoutBlob, PayoutData payoutData, BOLT11PaymentRequest bolt11PaymentRequest, CancellationToken cancellationToken)
{ {
var boltAmount = bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC); var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings);
if (boltAmount > payoutData.Amount) return new ResultVM
{ {
payoutData.State = PayoutState.Cancelled; PayoutId = payoutData.Id,
return new ResultVM Result = PayResult.Error,
{ Destination = blob.Destination,
PayoutId = payoutData.Id, Message = message
Result = PayResult.Error, };
Message = $"The BOLT11 invoice amount ({boltAmount} {payoutData.Currency}) did not match the payout's amount ({payoutData.Amount.GetValueOrDefault()} {payoutData.Currency})",
Destination = payoutBlob.Destination
};
}
if (bolt11PaymentRequest.ExpiryDate < DateTimeOffset.Now)
{
payoutData.State = PayoutState.Cancelled;
return new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Error,
Message = $"The BOLT11 invoice expiry date ({bolt11PaymentRequest.ExpiryDate}) has expired",
Destination = payoutBlob.Destination
};
}
var proofBlob = new PayoutLightningBlob { PaymentHash = bolt11PaymentRequest.PaymentHash.ToString() };
try
{
var result = await lightningClient.Pay(bolt11PaymentRequest.ToString(),
new PayInvoiceParams()
{
// CLN does not support explicit amount param if it is the same as the invoice amount
Amount = payoutData.Amount == bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC)? null: new LightMoney((decimal)payoutData.Amount, LightMoneyUnit.BTC)
}, cancellationToken);
if (result == null) throw new NoPaymentResultException();
string message = null;
if (result.Result == PayResult.Ok)
{
payoutData.State = result.Details?.Status switch
{
LightningPaymentStatus.Pending => PayoutState.InProgress,
_ => PayoutState.Completed,
};
if (payoutData.State == PayoutState.Completed)
{
message = result.Details?.TotalAmount != null
? $"Paid out {result.Details.TotalAmount.ToDecimal(LightMoneyUnit.BTC)}"
: null;
try
{
var payment = await lightningClient.GetPayment(bolt11PaymentRequest.PaymentHash.ToString(),
cancellationToken);
proofBlob.Preimage = payment.Preimage;
}
catch (Exception)
{
// ignored
}
}
}
else if (result.Result == PayResult.Unknown)
{
payoutData.State = PayoutState.InProgress;
}
if (payoutData.State == PayoutState.InProgress)
{
message = "The payment has been initiated but is still in-flight.";
}
payoutData.SetProofBlob(proofBlob, null);
return new ResultVM
{
PayoutId = payoutData.Id,
Result = result.Result,
Destination = payoutBlob.Destination,
Message = message
};
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException or NoPaymentResultException)
{
// Timeout, potentially caused by hold invoices
// Payment will be saved as pending, the LightningPendingPayoutListener will handle settling/cancelling
payoutData.State = PayoutState.InProgress;
payoutData.SetProofBlob(proofBlob, null);
return new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Ok,
Destination = payoutBlob.Destination,
Message = "The payment timed out. We will verify if it completed later."
};
}
} }
private async Task SetStoreContext() private async Task SetStoreContext()
@@ -405,8 +227,4 @@ namespace BTCPayServer.Data.Payouts.LightningLike
public decimal Amount { get; set; } public decimal Amount { get; set; }
} }
} }
public class NoPaymentResultException : Exception
{
}
} }

View File

@@ -8,6 +8,7 @@ using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Events; using BTCPayServer.Events;
using BTCPayServer.Lightning;
using BTCPayServer.Logging; using BTCPayServer.Logging;
using BTCPayServer.Models.WalletViewModels; using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Payments; using BTCPayServer.Payments;
@@ -18,11 +19,13 @@ using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Notifications; using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs; using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
using Dapper;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NBitcoin; using NBitcoin;
using NBitcoin.DataEncoders; using NBitcoin.DataEncoders;
using NBXplorer; using NBXplorer;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using PayoutData = BTCPayServer.Data.PayoutData; using PayoutData = BTCPayServer.Data.PayoutData;
using PullPaymentData = BTCPayServer.Data.PullPaymentData; using PullPaymentData = BTCPayServer.Data.PullPaymentData;
@@ -534,6 +537,8 @@ namespace BTCPayServer.HostedServices
if (cryptoAmount < minimumCryptoAmount) if (cryptoAmount < minimumCryptoAmount)
{ {
req.Completion.TrySetResult(new PayoutApproval.ApprovalResult(PayoutApproval.Result.TooLowAmount, null)); req.Completion.TrySetResult(new PayoutApproval.ApprovalResult(PayoutApproval.Result.TooLowAmount, null));
payout.State = PayoutState.Cancelled;
await ctx.SaveChangesAsync();
return; return;
} }
@@ -583,6 +588,8 @@ namespace BTCPayServer.HostedServices
break; break;
} }
payout.State = req.Request.State; payout.State = req.Request.State;
if (req.Request.Blob is { } b)
payout.SetBlob(b, _jsonSerializerSettings);
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
_eventAggregator.Publish(new PayoutEvent(PayoutEvent.PayoutEventType.Updated, payout)); _eventAggregator.Publish(new PayoutEvent(PayoutEvent.PayoutEventType.Updated, payout));
req.Completion.SetResult(MarkPayoutRequest.PayoutPaidResult.Ok); req.Completion.SetResult(MarkPayoutRequest.PayoutPaidResult.Ok);
@@ -657,13 +664,6 @@ namespace BTCPayServer.HostedServices
} }
} }
if (req.ClaimRequest.Value <
await payoutHandler.GetMinimumPayoutAmount(req.ClaimRequest.Destination))
{
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.AmountTooLow));
return;
}
var payoutsRaw = withoutPullPayment var payoutsRaw = withoutPullPayment
? null ? null
: await ctx.Payouts.Where(p => p.PullPaymentDataId == pp.Id) : await ctx.Payouts.Where(p => p.PullPaymentDataId == pp.Id)
@@ -672,7 +672,7 @@ namespace BTCPayServer.HostedServices
var payouts = payoutsRaw?.Select(o => new { Entity = o, Blob = o.GetBlob(_jsonSerializerSettings) }); var payouts = payoutsRaw?.Select(o => new { Entity = o, Blob = o.GetBlob(_jsonSerializerSettings) });
var limit = pp?.Limit ?? 0; var limit = pp?.Limit ?? 0;
var totalPayout = payouts?.Select(p => p.Entity.OriginalAmount)?.Sum(); var totalPayout = payouts?.Select(p => p.Entity.OriginalAmount)?.Sum();
var claimed = req.ClaimRequest.Value is decimal v ? v : limit - (totalPayout ?? 0); var claimed = req.ClaimRequest.ClaimedAmount is decimal v ? v : limit - (totalPayout ?? 0);
if (totalPayout is not null && totalPayout + claimed > limit) if (totalPayout is not null && totalPayout + claimed > limit)
{ {
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Overdraft)); req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Overdraft));
@@ -731,6 +731,22 @@ namespace BTCPayServer.HostedServices
payout.State = PayoutState.AwaitingPayment; payout.State = PayoutState.AwaitingPayment;
payout.Amount = approveResult.CryptoAmount; payout.Amount = approveResult.CryptoAmount;
} }
else if (approveResult.Result == PayoutApproval.Result.TooLowAmount)
{
payout.State = PayoutState.Cancelled;
await ctx.SaveChangesAsync();
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.AmountTooLow));
return;
}
else
{
payout.State = PayoutState.Cancelled;
await ctx.SaveChangesAsync();
// We returns Ok even if the approval failed. This is expected.
// Because the claim worked, what didn't is the approval
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Ok));
return;
}
} }
} }
@@ -923,6 +939,7 @@ namespace BTCPayServer.HostedServices
public string PayoutId { get; set; } public string PayoutId { get; set; }
public JObject Proof { get; set; } public JObject Proof { get; set; }
public PayoutState State { get; set; } = PayoutState.Completed; public PayoutState State { get; set; } = PayoutState.Completed;
public PayoutBlob Blob { get; internal set; }
public static string GetErrorMessage(PayoutPaidResult result) public static string GetErrorMessage(PayoutPaidResult result)
{ {
@@ -942,28 +959,40 @@ namespace BTCPayServer.HostedServices
public class ClaimRequest public class ClaimRequest
{ {
public static (string error, decimal? amount) IsPayoutAmountOk(IClaimDestination destination, decimal? amount, string payoutCurrency = null, string ppCurrency = null) public record ClaimedAmountResult
{ {
return amount switch public record Error(string Message) : ClaimedAmountResult;
public record Success(decimal? Amount) : ClaimedAmountResult;
}
public static ClaimedAmountResult GetClaimedAmount(IClaimDestination destination, decimal? amount, string payoutCurrency, string ppCurrency)
{
var amountsComparable = false;
var destinationAmount = destination.Amount;
if (destinationAmount is not null &&
payoutCurrency == "BTC" &&
ppCurrency == "SATS")
{ {
null when destination.Amount is null && ppCurrency is null => ("Amount is not specified in destination or payout request", null), destinationAmount = new LightMoney(destinationAmount.Value, LightMoneyUnit.BTC).ToUnit(LightMoneyUnit.Satoshi);
null when destination.Amount is null => (null, null), amountsComparable = true;
null when destination.Amount != null => (null, destination.Amount), }
not null when destination.Amount is null => (null, amount), if (destinationAmount is not null && payoutCurrency == ppCurrency)
not null when destination.Amount != null && amount != destination.Amount && {
destination.IsExplicitAmountMinimum && amountsComparable = true;
payoutCurrency == "BTC" && ppCurrency == "SATS" && }
new Money(amount.Value, MoneyUnit.Satoshi).ToUnit(MoneyUnit.BTC) < destination.Amount => return (destinationAmount, amount) switch
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount", null), {
not null when destination.Amount != null && amount != destination.Amount && (null, null) when ppCurrency is null => new ClaimedAmountResult.Error("Amount is not specified in destination or payout request"),
destination.IsExplicitAmountMinimum && ({ } a, null) when ppCurrency is null => new ClaimedAmountResult.Success(a),
!(payoutCurrency == "BTC" && ppCurrency == "SATS") && (null, null) => new ClaimedAmountResult.Success(null),
amount < destination.Amount => ({ } a, null) when amountsComparable => new ClaimedAmountResult.Success(a),
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount", null), (null, { } b) => new ClaimedAmountResult.Success(b),
not null when destination.Amount != null && amount != destination.Amount && ({ } a, { } b) when amountsComparable && a == b => new ClaimedAmountResult.Success(a),
!destination.IsExplicitAmountMinimum => ({ } a, { } b) when amountsComparable && a > b => new ClaimedAmountResult.Error($"The destination's amount ({a} {ppCurrency}) is more than the claimed amount ({b} {ppCurrency})."),
($"Amount is implied in destination ({destination.Amount}) that does not match the payout amount provided {amount})", null), ({ } a, { } b) when amountsComparable && a < b => new ClaimedAmountResult.Success(a),
_ => (null, amount) ({ } a, { } b) when !amountsComparable => new ClaimedAmountResult.Success(b),
_ => new ClaimedAmountResult.Success(amount)
}; };
} }
@@ -1020,7 +1049,7 @@ namespace BTCPayServer.HostedServices
public PayoutMethodId PayoutMethodId { get; set; } public PayoutMethodId PayoutMethodId { get; set; }
public string PullPaymentId { get; set; } public string PullPaymentId { get; set; }
public decimal? Value { get; set; } public decimal? ClaimedAmount { get; set; }
public IClaimDestination Destination { get; set; } public IClaimDestination Destination { get; set; }
public string StoreId { get; set; } public string StoreId { get; set; }
public bool? PreApprove { get; set; } public bool? PreApprove { get; set; }

View File

@@ -78,7 +78,7 @@ namespace BTCPayServer.Models
public bool IsPending { get; set; } public bool IsPending { get; set; }
public decimal AmountCollected { get; set; } public decimal AmountCollected { get; set; }
public decimal AmountDue { get; set; } public decimal AmountDue { get; set; }
public decimal ClaimedAmount { get; set; } public decimal? ClaimedAmount { get; set; }
public decimal MinimumClaim { get; set; } public decimal MinimumClaim { get; set; }
public string Destination { get; set; } public string Destination { get; set; }
public decimal Amount { get; set; } public decimal Amount { get; set; }

View File

@@ -73,17 +73,7 @@ public class LightningPendingPayoutListener : BaseAsyncService
foreach (IGrouping<string, PayoutData> payoutByStore in payouts.GroupBy(data => data.StoreDataId)) foreach (IGrouping<string, PayoutData> payoutByStore in payouts.GroupBy(data => data.StoreDataId))
{ {
//this should never happen var store = stores[payoutByStore.Key];
if (!stores.TryGetValue(payoutByStore.Key, out var store))
{
foreach (PayoutData payoutData in payoutByStore)
{
payoutData.State = PayoutState.Cancelled;
}
continue;
}
foreach (IGrouping<string, PayoutData> payoutByStoreByPaymentMethod in payoutByStore.GroupBy(data => foreach (IGrouping<string, PayoutData> payoutByStoreByPaymentMethod in payoutByStore.GroupBy(data =>
data.PayoutMethodId)) data.PayoutMethodId))
{ {
@@ -101,40 +91,37 @@ public class LightningPendingPayoutListener : BaseAsyncService
pm.CreateLightningClient(networks[pmi], _options.Value, _lightningClientFactoryService); pm.CreateLightningClient(networks[pmi], _options.Value, _lightningClientFactoryService);
foreach (PayoutData payoutData in payoutByStoreByPaymentMethod) foreach (PayoutData payoutData in payoutByStoreByPaymentMethod)
{ {
var handler = _payoutHandlers.TryGet(payoutData.GetPayoutMethodId()); var handler = _payoutHandlers.TryGet(payoutData.GetPayoutMethodId()) as LightningLikePayoutHandler;
var proof = handler is null ? null : handler.ParseProof(payoutData); if (handler is null || handler.PayoutsPaymentProcessing.Contains(payoutData.Id))
switch (proof) continue;
{ var proof = handler.ParseProof(payoutData) as PayoutLightningBlob;
case null:
break;
case PayoutLightningBlob payoutLightningBlob:
{
LightningPayment payment = null;
try
{
payment = await client.GetPayment(payoutLightningBlob.Id, CancellationToken);
}
catch
{
}
if (payment is null)
continue;
switch (payment.Status)
{
case LightningPaymentStatus.Complete:
payoutData.State = PayoutState.Completed;
payoutLightningBlob.Preimage = payment.Preimage;
payoutData.SetProofBlob(payoutLightningBlob, null);
break;
case LightningPaymentStatus.Failed:
payoutData.State = PayoutState.Cancelled;
break;
}
break; LightningPayment payment = null;
} try
} {
} if (proof is not null)
payment = await client.GetPayment(proof.Id, CancellationToken);
}
catch
{
}
if (payment is null)
{
payoutData.State = PayoutState.Cancelled;
continue;
}
switch (payment.Status)
{
case LightningPaymentStatus.Complete:
payoutData.State = PayoutState.Completed;
proof.Preimage = payment.Preimage;
payoutData.SetProofBlob(proof, null);
break;
case LightningPaymentStatus.Failed:
payoutData.State = PayoutState.Cancelled;
break;
}
}
} }
} }

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client; using BTCPayServer.Client;
@@ -18,10 +19,13 @@ using BTCPayServer.Payouts;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using LNURL;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NBitcoin; using NBitcoin;
using Newtonsoft.Json.Linq;
using static BTCPayServer.Data.Payouts.LightningLike.UILightningLikePayoutController;
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest; using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
using PayoutData = BTCPayServer.Data.PayoutData; using PayoutData = BTCPayServer.Data.PayoutData;
using PayoutProcessorData = BTCPayServer.Data.PayoutProcessorData; using PayoutProcessorData = BTCPayServer.Data.PayoutProcessorData;
@@ -30,117 +34,341 @@ namespace BTCPayServer.PayoutProcessors.Lightning;
public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<LightningAutomatedPayoutBlob> public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<LightningAutomatedPayoutBlob>
{ {
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings; private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
private readonly LightningClientFactoryService _lightningClientFactoryService; private readonly LightningClientFactoryService _lightningClientFactoryService;
private readonly UserService _userService; private readonly UserService _userService;
private readonly IOptions<LightningNetworkOptions> _options; private readonly IOptions<LightningNetworkOptions> _options;
private readonly PullPaymentHostedService _pullPaymentHostedService; private readonly PullPaymentHostedService _pullPaymentHostedService;
private readonly LightningLikePayoutHandler _payoutHandler; private readonly LightningLikePayoutHandler _payoutHandler;
public BTCPayNetwork Network => _payoutHandler.Network; public BTCPayNetwork Network => _payoutHandler.Network;
private readonly PaymentMethodHandlerDictionary _handlers; private readonly PaymentMethodHandlerDictionary _handlers;
public LightningAutomatedPayoutProcessor( public LightningAutomatedPayoutProcessor(
PayoutMethodId payoutMethodId, PayoutMethodId payoutMethodId,
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings, BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings,
LightningClientFactoryService lightningClientFactoryService, LightningClientFactoryService lightningClientFactoryService,
PayoutMethodHandlerDictionary payoutHandlers, PayoutMethodHandlerDictionary payoutHandlers,
UserService userService, UserService userService,
ILoggerFactory logger, IOptions<LightningNetworkOptions> options, ILoggerFactory logger, IOptions<LightningNetworkOptions> options,
StoreRepository storeRepository, PayoutProcessorData payoutProcessorSettings, StoreRepository storeRepository, PayoutProcessorData payoutProcessorSettings,
ApplicationDbContextFactory applicationDbContextFactory, ApplicationDbContextFactory applicationDbContextFactory,
PaymentMethodHandlerDictionary handlers, PaymentMethodHandlerDictionary handlers,
IPluginHookService pluginHookService, IPluginHookService pluginHookService,
EventAggregator eventAggregator, EventAggregator eventAggregator,
PullPaymentHostedService pullPaymentHostedService) : PullPaymentHostedService pullPaymentHostedService) :
base(PaymentTypes.LN.GetPaymentMethodId(GetPayoutHandler(payoutHandlers, payoutMethodId).Network.CryptoCode), logger, storeRepository, payoutProcessorSettings, applicationDbContextFactory, base(PaymentTypes.LN.GetPaymentMethodId(GetPayoutHandler(payoutHandlers, payoutMethodId).Network.CryptoCode), logger, storeRepository, payoutProcessorSettings, applicationDbContextFactory,
handlers, pluginHookService, eventAggregator) handlers, pluginHookService, eventAggregator)
{ {
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings; _btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
_lightningClientFactoryService = lightningClientFactoryService; _lightningClientFactoryService = lightningClientFactoryService;
_userService = userService; _userService = userService;
_options = options; _options = options;
_pullPaymentHostedService = pullPaymentHostedService; _pullPaymentHostedService = pullPaymentHostedService;
_payoutHandler = GetPayoutHandler(payoutHandlers, payoutMethodId); _payoutHandler = GetPayoutHandler(payoutHandlers, payoutMethodId);
_handlers = handlers; _handlers = handlers;
} }
private static LightningLikePayoutHandler GetPayoutHandler(PayoutMethodHandlerDictionary payoutHandlers, PayoutMethodId payoutMethodId) private static LightningLikePayoutHandler GetPayoutHandler(PayoutMethodHandlerDictionary payoutHandlers, PayoutMethodId payoutMethodId)
{ {
return (LightningLikePayoutHandler)payoutHandlers[payoutMethodId]; return (LightningLikePayoutHandler)payoutHandlers[payoutMethodId];
} }
private async Task HandlePayout(PayoutData payoutData, ILightningClient lightningClient) public async Task<ResultVM> HandlePayout(PayoutData payoutData, ILightningClient lightningClient, CancellationToken cancellationToken)
{ {
if (payoutData.State != PayoutState.AwaitingPayment) using var scope = _payoutHandler.PayoutsPaymentProcessing.StartTracking();
return; if (payoutData.State != PayoutState.AwaitingPayment || !scope.TryTrack(payoutData.Id))
var res = await _pullPaymentHostedService.MarkPaid(new MarkPayoutRequest() return InvalidState(payoutData.Id);
var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings);
var res = await _pullPaymentHostedService.MarkPaid(new MarkPayoutRequest()
{
State = PayoutState.InProgress,
PayoutId = payoutData.Id,
Proof = null
});
if (res != MarkPayoutRequest.PayoutPaidResult.Ok)
return InvalidState(payoutData.Id);
ResultVM result;
var claim = await _payoutHandler.ParseClaimDestination(blob.Destination, cancellationToken);
switch (claim.destination)
{ {
State = PayoutState.InProgress, PayoutId = payoutData.Id, Proof = null case LNURLPayClaimDestinaton lnurlPayClaimDestinaton:
}); var lnurlResult = await GetInvoiceFromLNURL(payoutData, _payoutHandler, blob,
if (res != MarkPayoutRequest.PayoutPaidResult.Ok) lnurlPayClaimDestinaton, cancellationToken);
{ if (lnurlResult.Item2 is not null)
return; {
result = lnurlResult.Item2;
}
else
{
result = await TrypayBolt(lightningClient, blob, payoutData, lnurlResult.Item1, cancellationToken);
}
break;
case BoltInvoiceClaimDestination item1:
result = await TrypayBolt(lightningClient, blob, payoutData, item1.PaymentRequest, cancellationToken);
break;
default:
result = new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Error,
Destination = blob.Destination,
Message = claim.error
};
break;
}
bool updateBlob = false;
if (result.Result is PayResult.Error or PayResult.CouldNotFindRoute && payoutData.State == PayoutState.AwaitingPayment)
{
var errorCount = IncrementErrorCount(blob);
updateBlob = true;
if (errorCount >= 10)
payoutData.State = PayoutState.Cancelled;
}
if (payoutData.State != PayoutState.InProgress || payoutData.Proof is not null)
{
await _pullPaymentHostedService.MarkPaid(new MarkPayoutRequest()
{
State = payoutData.State,
PayoutId = payoutData.Id,
Proof = payoutData.GetProofBlobJson(),
Blob = updateBlob ? blob : null
});
}
return result;
}
private ResultVM InvalidState(string payoutId) =>
new ResultVM
{
PayoutId = payoutId,
Result = PayResult.Error,
Message = "The payout isn't in a valid state"
};
private int IncrementErrorCount(PayoutBlob blob)
{
int count;
if (blob.AdditionalData.TryGetValue("ErrorCount", out var v) && v.Type == JTokenType.Integer)
{
count = v.Value<int>() + 1;
blob.AdditionalData["ErrorCount"] = count;
}
else
{
count = 1;
blob.AdditionalData.Add("ErrorCount", count);
}
return count;
}
async Task<(BOLT11PaymentRequest, ResultVM)> GetInvoiceFromLNURL(PayoutData payoutData,
LightningLikePayoutHandler handler, PayoutBlob blob, LNURLPayClaimDestinaton lnurlPayClaimDestinaton, CancellationToken cancellationToken)
{
var endpoint = lnurlPayClaimDestinaton.LNURL.IsValidEmail()
? LNURL.LNURL.ExtractUriFromInternetIdentifier(lnurlPayClaimDestinaton.LNURL)
: LNURL.LNURL.Parse(lnurlPayClaimDestinaton.LNURL, out _);
var httpClient = handler.CreateClient(endpoint);
var lnurlInfo =
(LNURLPayRequest)await LNURL.LNURL.FetchInformation(endpoint, "payRequest",
httpClient, cancellationToken);
var lm = new LightMoney(payoutData.Amount.Value, LightMoneyUnit.BTC);
if (lm > lnurlInfo.MaxSendable || lm < lnurlInfo.MinSendable)
{
payoutData.State = PayoutState.Cancelled;
return (null, new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Error,
Destination = blob.Destination,
Message =
$"The LNURL provided would not generate an invoice of {lm.ToDecimal(LightMoneyUnit.Satoshi)} sats"
});
} }
var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings);
try try
{ {
var claim = await _payoutHandler.ParseClaimDestination(blob.Destination, CancellationToken); var lnurlPayRequestCallbackResponse =
switch (claim.destination) await lnurlInfo.SendRequest(lm, this.Network.NBitcoinNetwork, httpClient, cancellationToken: cancellationToken);
{
case LNURLPayClaimDestinaton lnurlPayClaimDestinaton: return (lnurlPayRequestCallbackResponse.GetPaymentRequest(this.Network.NBitcoinNetwork), null);
var lnurlResult = await UILightningLikePayoutController.GetInvoiceFromLNURL(payoutData,
_payoutHandler, blob,
lnurlPayClaimDestinaton, Network.NBitcoinNetwork, CancellationToken);
if (lnurlResult.Item2 is null)
{
await TrypayBolt(lightningClient, blob, payoutData,
lnurlResult.Item1);
}
break;
case BoltInvoiceClaimDestination item1:
await TrypayBolt(lightningClient, blob, payoutData, item1.PaymentRequest);
break;
}
} }
catch (Exception e) catch (LNUrlException e)
{ {
Logs.PayServer.LogError(e, $"Could not process payout {payoutData.Id}"); return (null,
new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Error,
Destination = blob.Destination,
Message = e.Message
});
}
}
async Task<ResultVM> TrypayBolt(
ILightningClient lightningClient, PayoutBlob payoutBlob, PayoutData payoutData, BOLT11PaymentRequest bolt11PaymentRequest, CancellationToken cancellationToken)
{
var boltAmount = bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC);
// BoltAmount == 0: Any amount is OK.
// While we could allow paying more than the minimum amount from the boltAmount,
// Core-Lightning do not support it! It would just refuse to pay more than the boltAmount.
if (boltAmount != payoutData.Amount.Value && boltAmount != 0.0m)
{
payoutData.State = PayoutState.Cancelled;
return new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Error,
Message = $"The BOLT11 invoice amount ({boltAmount} {payoutData.Currency}) did not match the payout's amount ({payoutData.Amount.GetValueOrDefault()} {payoutData.Currency})",
Destination = payoutBlob.Destination
};
} }
if (payoutData.State != PayoutState.InProgress || payoutData.Proof is not null) if (bolt11PaymentRequest.ExpiryDate < DateTimeOffset.Now)
{ {
await _pullPaymentHostedService.MarkPaid(new MarkPayoutRequest() payoutData.State = PayoutState.Cancelled;
return new ResultVM
{ {
State = payoutData.State, PayoutId = payoutData.Id, Proof = payoutData.GetProofBlobJson() PayoutId = payoutData.Id,
}); Result = PayResult.Error,
Message = $"The BOLT11 invoice expiry date ({bolt11PaymentRequest.ExpiryDate}) has expired",
Destination = payoutBlob.Destination
};
}
var proofBlob = new PayoutLightningBlob { PaymentHash = bolt11PaymentRequest.PaymentHash.ToString() };
PayResponse pay = null;
try
{
Exception exception = null;
try
{
pay = await lightningClient.Pay(bolt11PaymentRequest.ToString(),
new PayInvoiceParams()
{
Amount = new LightMoney((decimal)payoutData.Amount, LightMoneyUnit.BTC)
}, cancellationToken);
if (pay?.Result is PayResult.CouldNotFindRoute)
{
// Payment failed for sure... we can try again later!
payoutData.State = PayoutState.AwaitingPayment;
return new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.CouldNotFindRoute,
Message = $"Unable to find a route for the payment, check your channel liquidity",
Destination = payoutBlob.Destination
};
}
}
catch (Exception ex)
{
exception = ex;
}
LightningPayment payment = null;
try
{
payment = await lightningClient.GetPayment(bolt11PaymentRequest.PaymentHash.ToString(), cancellationToken);
}
catch (Exception ex)
{
exception = ex;
}
if (payment is null)
{
payoutData.State = PayoutState.Cancelled;
var exceptionMessage = "";
if (exception is not null)
exceptionMessage = $" ({exception.Message})";
if (exceptionMessage == "")
exceptionMessage = $" ({pay?.ErrorDetail})";
return new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Error,
Message = $"Unable to confirm the payment of the invoice" + exceptionMessage,
Destination = payoutBlob.Destination
};
}
if (payment.Preimage is not null)
proofBlob.Preimage = payment.Preimage;
if (payment.Status == LightningPaymentStatus.Complete)
{
payoutData.State = PayoutState.Completed;
payoutData.SetProofBlob(proofBlob, null);
return new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Ok,
Destination = payoutBlob.Destination,
Message = payment.AmountSent != null
? $"Paid out {payment.AmountSent.ToDecimal(LightMoneyUnit.BTC)} {payoutData.Currency}"
: "Paid out"
};
}
else if (payment.Status == LightningPaymentStatus.Failed)
{
payoutData.State = PayoutState.AwaitingPayment;
string reason = "";
if (pay?.ErrorDetail is string err)
reason = $" ({err})";
return new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Error,
Destination = payoutBlob.Destination,
Message = $"The payment failed{reason}"
};
}
else
{
payoutData.State = PayoutState.InProgress;
return new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Unknown,
Destination = payoutBlob.Destination,
Message = "The payment has been initiated but is still in-flight."
};
}
}
catch (OperationCanceledException)
{
// Timeout, potentially caused by hold invoices
// Payment will be saved as pending, the LightningPendingPayoutListener will handle settling/cancelling
payoutData.State = PayoutState.InProgress;
payoutData.SetProofBlob(proofBlob, null);
return new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Ok,
Destination = payoutBlob.Destination,
Message = "The payment timed out. We will verify if it completed later."
};
} }
} }
protected override async Task<bool> ProcessShouldSave(object paymentMethodConfig, List<PayoutData> payouts) protected override async Task<bool> ProcessShouldSave(object paymentMethodConfig, List<PayoutData> payouts)
{ {
var processorBlob = GetBlob(PayoutProcessorSettings); var lightningSupportedPaymentMethod = (LightningPaymentMethodConfig)paymentMethodConfig;
var lightningSupportedPaymentMethod = (LightningPaymentMethodConfig)paymentMethodConfig; if (lightningSupportedPaymentMethod.IsInternalNode &&
if (lightningSupportedPaymentMethod.IsInternalNode && !await _storeRepository.InternalNodePayoutAuthorized(PayoutProcessorSettings.StoreId))
!await _storeRepository.InternalNodePayoutAuthorized(PayoutProcessorSettings.StoreId)) {
{ return false;
return false; }
}
var client = var client =
lightningSupportedPaymentMethod.CreateLightningClient(Network, _options.Value, lightningSupportedPaymentMethod.CreateLightningClient(Network, _options.Value,
_lightningClientFactoryService); _lightningClientFactoryService);
await Task.WhenAll(payouts.Select(data => HandlePayout(data, client))); await Task.WhenAll(payouts.Select(data => HandlePayout(data, client, CancellationToken)));
//we return false because this processor handles db updates on its own //we return false because this processor handles db updates on its own
return false; return false;
} }
//we group per store and init the transfers by each
async Task<bool> TrypayBolt(ILightningClient lightningClient, PayoutBlob payoutBlob, PayoutData payoutData,
BOLT11PaymentRequest bolt11PaymentRequest)
{
return (await UILightningLikePayoutController.TrypayBolt(lightningClient, payoutBlob, payoutData,
bolt11PaymentRequest,
CancellationToken)).Result is PayResult.Ok ;
}
} }

View File

@@ -47,14 +47,17 @@ public class LightningAutomatedPayoutSenderFactory : IPayoutProcessorFactory
public static string ProcessorName => nameof(LightningAutomatedPayoutSenderFactory); public static string ProcessorName => nameof(LightningAutomatedPayoutSenderFactory);
public IEnumerable<PayoutMethodId> GetSupportedPayoutMethods() => _supportedPayoutMethods; public IEnumerable<PayoutMethodId> GetSupportedPayoutMethods() => _supportedPayoutMethods;
public Task<IHostedService> ConstructProcessor(PayoutProcessorData settings) public LightningAutomatedPayoutProcessor ConstructProcessor(PayoutProcessorData settings)
{ {
if (settings.Processor != Processor) if (settings.Processor != Processor)
{ {
throw new NotSupportedException("This processor cannot handle the provided requirements"); throw new NotSupportedException("This processor cannot handle the provided requirements");
} }
var payoutMethodId = settings.GetPayoutMethodId(); var payoutMethodId = settings.GetPayoutMethodId();
return Task.FromResult<IHostedService>(ActivatorUtilities.CreateInstance<LightningAutomatedPayoutProcessor>(_serviceProvider, settings, payoutMethodId)); return ActivatorUtilities.CreateInstance<LightningAutomatedPayoutProcessor>(_serviceProvider, settings, payoutMethodId);
}
Task<IHostedService> IPayoutProcessorFactory.ConstructProcessor(PayoutProcessorData settings)
{
return Task.FromResult<IHostedService>(ConstructProcessor(settings));
} }
} }

View File

@@ -0,0 +1,38 @@
#nullable enable
using System;
using System.Collections.Concurrent;
namespace BTCPayServer
{
public class ResourceTracker<T> where T: notnull
{
public class ScopedResourceTracker : IDisposable
{
private ResourceTracker<T> _parent;
public ScopedResourceTracker(ResourceTracker<T> resourceTracker)
{
_parent = resourceTracker;
}
ConcurrentDictionary<T, string> _Scoped = new();
public bool TryTrack(T resource)
{
if (!_parent._TrackedResources.TryAdd(resource, string.Empty))
return false;
_Scoped.TryAdd(resource, string.Empty);
return true;
}
public bool Contains(T resource) => _Scoped.ContainsKey(resource);
public void Dispose()
{
foreach (var d in _Scoped)
_parent._TrackedResources.TryRemove(d.Key, out _);
}
}
internal ConcurrentDictionary<T, string> _TrackedResources = new();
public ScopedResourceTracker StartTracking() => new ScopedResourceTracker(this);
public bool Contains(T resource) => _TrackedResources.ContainsKey(resource);
}
}