mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2024-11-19 01:43:50 +01:00
Refactor payouts processing (#6314)
This commit is contained in:
parent
62d765125d
commit
cc0ea0b3f8
@ -679,6 +679,26 @@ namespace BTCPayServer.Tests
|
||||
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]
|
||||
public void CanAcceptInvoiceWithTolerance()
|
||||
{
|
||||
|
@ -4201,8 +4201,8 @@ namespace BTCPayServer.Tests
|
||||
await TestUtils.EventuallyAsync(async () =>
|
||||
{
|
||||
var payoutC =
|
||||
(await adminClient.GetStorePayouts(admin.StoreId, false)).Single(data => data.Id == payout.Id);
|
||||
Assert.Equal(PayoutState.Completed, payoutC.State);
|
||||
(await adminClient.GetStorePayouts(admin.StoreId, false)).SingleOrDefault(data => data.Id == payout.Id);
|
||||
Assert.Equal(PayoutState.Completed, payoutC?.State);
|
||||
});
|
||||
|
||||
payout = await adminClient.CreatePayout(admin.StoreId,
|
||||
|
@ -41,7 +41,7 @@
|
||||
<PackageReference Include="YamlDotNet" Version="8.0.0" />
|
||||
<PackageReference Include="BIP78.Sender" Version="0.2.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="Dapper" Version="2.1.35" />
|
||||
<PackageReference Include="Fido2" Version="2.0.2" />
|
||||
|
@ -407,23 +407,29 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
return this.CreateValidationError(ModelState);
|
||||
}
|
||||
|
||||
var amtError = ClaimRequest.IsPayoutAmountOk(destination.destination, request.Amount, payoutHandler.Currency, pp.Currency);
|
||||
if (amtError.error is not null)
|
||||
var amt = ClaimRequest.GetClaimedAmount(destination.destination, request.Amount, payoutHandler.Currency, pp.Currency);
|
||||
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);
|
||||
}
|
||||
request.Amount = amtError.amount;
|
||||
var result = await _pullPaymentService.Claim(new ClaimRequest()
|
||||
else if (amt is ClaimRequest.ClaimedAmountResult.Success succ)
|
||||
{
|
||||
Destination = destination.destination,
|
||||
PullPaymentId = pullPaymentId,
|
||||
Value = request.Amount,
|
||||
PayoutMethodId = payoutMethodId,
|
||||
StoreId = pp.StoreId
|
||||
});
|
||||
|
||||
return HandleClaimResult(result);
|
||||
request.Amount = succ.Amount;
|
||||
var result = await _pullPaymentService.Claim(new ClaimRequest()
|
||||
{
|
||||
Destination = destination.destination,
|
||||
PullPaymentId = pullPaymentId,
|
||||
ClaimedAmount = request.Amount,
|
||||
PayoutMethodId = payoutMethodId,
|
||||
StoreId = pp.StoreId
|
||||
});
|
||||
return HandleClaimResult(result);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new NotSupportedException($"Should never happen {amt}");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("~/api/v1/stores/{storeId}/payouts")]
|
||||
@ -456,6 +462,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
|
||||
|
||||
PullPaymentBlob? ppBlob = null;
|
||||
string? ppCurrency = null;
|
||||
if (request?.PullPaymentId is not null)
|
||||
{
|
||||
|
||||
@ -464,6 +471,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
if (pp is null)
|
||||
return PullPaymentNotFound();
|
||||
ppBlob = pp.GetBlob();
|
||||
ppCurrency = pp.Currency;
|
||||
}
|
||||
var destination = await payoutHandler.ParseAndValidateClaimDestination(request!.Destination, ppBlob, default);
|
||||
if (destination.destination is null)
|
||||
@ -472,30 +480,37 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
return this.CreateValidationError(ModelState);
|
||||
}
|
||||
|
||||
var amtError = ClaimRequest.IsPayoutAmountOk(destination.destination, request.Amount);
|
||||
if (amtError.error is not null)
|
||||
var amt = ClaimRequest.GetClaimedAmount(destination.destination, request.Amount, payoutHandler.Currency, ppCurrency);
|
||||
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);
|
||||
}
|
||||
request.Amount = amtError.amount;
|
||||
if (request.Amount is { } v && (v < ppBlob?.MinimumClaim || v == 0.0m))
|
||||
else if (amt is ClaimRequest.ClaimedAmountResult.Success succ)
|
||||
{
|
||||
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);
|
||||
request.Amount = succ.Amount;
|
||||
if (request.Amount is { } v && (v < ppBlob?.MinimumClaim || v == 0.0m))
|
||||
{
|
||||
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,
|
||||
PullPaymentId = request.PullPaymentId,
|
||||
PreApprove = request.Approved,
|
||||
Value = request.Amount,
|
||||
PayoutMethodId = paymentMethodId,
|
||||
StoreId = storeId,
|
||||
Metadata = request.Metadata
|
||||
});
|
||||
return HandleClaimResult(result);
|
||||
throw new NotSupportedException($"Should never happen {amt}");
|
||||
}
|
||||
}
|
||||
|
||||
private IActionResult HandleClaimResult(ClaimRequest.ClaimResponse result)
|
||||
|
@ -193,7 +193,7 @@ namespace BTCPayServer
|
||||
PayoutMethodId = pmi,
|
||||
PullPaymentId = pullPaymentId,
|
||||
StoreId = pp.StoreId,
|
||||
Value = result.MinimumAmount.ToDecimal(unit),
|
||||
ClaimedAmount = result.MinimumAmount.ToDecimal(unit),
|
||||
});
|
||||
|
||||
if (claimResponse.Result != ClaimRequest.ClaimResult.Ok)
|
||||
|
@ -196,10 +196,13 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("pull-payments/{pullPaymentId}/claim")]
|
||||
[HttpPost("pull-payments/{pullPaymentId}")]
|
||||
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);
|
||||
if (pp is null)
|
||||
{
|
||||
@ -251,14 +254,14 @@ namespace BTCPayServer.Controllers
|
||||
ModelState.AddModelError(nameof(vm.Destination), error ?? StringLocalizer["Invalid destination or payment method"]);
|
||||
return await ViewPullPayment(pullPaymentId);
|
||||
}
|
||||
var amtError = ClaimRequest.IsPayoutAmountOk(destination, vm.ClaimedAmount == 0 ? null : vm.ClaimedAmount, payoutHandler.Currency, pp.Currency);
|
||||
if (amtError.error is not null)
|
||||
var claimedAmount = ClaimRequest.GetClaimedAmount(destination, vm.ClaimedAmount, payoutHandler.Currency, pp.Currency);
|
||||
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)
|
||||
@ -270,7 +273,7 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
Destination = destination,
|
||||
PullPaymentId = pullPaymentId,
|
||||
Value = vm.ClaimedAmount,
|
||||
ClaimedAmount = vm.ClaimedAmount,
|
||||
PayoutMethodId = payoutMethodId,
|
||||
StoreId = pp.StoreId
|
||||
});
|
||||
@ -283,11 +286,19 @@ namespace BTCPayServer.Controllers
|
||||
return await ViewPullPayment(pullPaymentId);
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
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 });
|
||||
}
|
||||
|
@ -757,7 +757,7 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
Destination = new AddressClaimDestination(
|
||||
BitcoinAddress.Create(output.DestinationAddress, network.NBitcoinNetwork)),
|
||||
Value = output.Amount,
|
||||
ClaimedAmount = output.Amount,
|
||||
PayoutMethodId = pmi,
|
||||
StoreId = walletId.StoreId,
|
||||
PreApprove = true,
|
||||
@ -777,7 +777,7 @@ namespace BTCPayServer.Controllers
|
||||
message = "Payouts scheduled:<br/>";
|
||||
}
|
||||
|
||||
message += $"{claimRequest.Value} to {claimRequest.Destination.ToString()}<br/>";
|
||||
message += $"{claimRequest.ClaimedAmount} to {claimRequest.Destination.ToString()}<br/>";
|
||||
|
||||
}
|
||||
else
|
||||
@ -791,10 +791,10 @@ namespace BTCPayServer.Controllers
|
||||
switch (response.Result)
|
||||
{
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,5 @@ namespace BTCPayServer.Data
|
||||
{
|
||||
public string? Id { get; }
|
||||
decimal? Amount { get; }
|
||||
bool IsExplicitAmountMinimum => false;
|
||||
}
|
||||
}
|
||||
|
@ -23,6 +23,5 @@ namespace BTCPayServer.Data.Payouts.LightningLike
|
||||
public uint256 PaymentHash { get; }
|
||||
public string Id => PaymentHash.ToString();
|
||||
public decimal? Amount { get; }
|
||||
public bool IsExplicitAmountMinimum => true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
@ -208,5 +209,6 @@ namespace BTCPayServer.Data.Payouts.LightningLike
|
||||
"UILightningLikePayout", new { cryptoCode, payoutIds }));
|
||||
}
|
||||
|
||||
public ResourceTracker<string> PayoutsPaymentProcessing { get; } = new ResourceTracker<string>();
|
||||
}
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ using BTCPayServer.Lightning;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Payments.Bitcoin;
|
||||
using BTCPayServer.Payments.Lightning;
|
||||
using BTCPayServer.PayoutProcessors.Lightning;
|
||||
using BTCPayServer.Payouts;
|
||||
using BTCPayServer.Security;
|
||||
using BTCPayServer.Services;
|
||||
@ -32,6 +33,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
|
||||
public class UILightningLikePayoutController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContextFactory _applicationDbContextFactory;
|
||||
private readonly LightningAutomatedPayoutSenderFactory _lightningAutomatedPayoutSenderFactory;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
|
||||
private readonly PayoutMethodHandlerDictionary _payoutHandlers;
|
||||
@ -43,17 +45,19 @@ namespace BTCPayServer.Data.Payouts.LightningLike
|
||||
private readonly StoreRepository _storeRepository;
|
||||
|
||||
public UILightningLikePayoutController(ApplicationDbContextFactory applicationDbContextFactory,
|
||||
LightningAutomatedPayoutSenderFactory lightningAutomatedPayoutSenderFactory,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings,
|
||||
PayoutMethodHandlerDictionary payoutHandlers,
|
||||
PaymentMethodHandlerDictionary handlers,
|
||||
StoreRepository storeRepository,
|
||||
LightningClientFactoryService lightningClientFactoryService,
|
||||
IOptions<LightningNetworkOptions> options,
|
||||
IOptions<LightningNetworkOptions> options,
|
||||
IAuthorizationService authorizationService,
|
||||
EventAggregator eventAggregator)
|
||||
{
|
||||
_applicationDbContextFactory = applicationDbContextFactory;
|
||||
_lightningAutomatedPayoutSenderFactory = lightningAutomatedPayoutSenderFactory;
|
||||
_userManager = userManager;
|
||||
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
|
||||
_payoutHandlers = payoutHandlers;
|
||||
@ -132,248 +136,66 @@ namespace BTCPayServer.Data.Payouts.LightningLike
|
||||
var paymentMethodId = PaymentTypes.LN.GetPaymentMethodId(cryptoCode);
|
||||
var payoutHandler = (LightningLikePayoutHandler)_payoutHandlers.TryGet(pmi);
|
||||
|
||||
await using var ctx = _applicationDbContextFactory.CreateContext();
|
||||
|
||||
var payouts = (await GetPayouts(ctx, pmi, payoutIds)).GroupBy(data => data.StoreDataId);
|
||||
IEnumerable<IGrouping<string, PayoutData>> payouts;
|
||||
using (var ctx = _applicationDbContextFactory.CreateContext())
|
||||
{
|
||||
payouts = (await GetPayouts(ctx, pmi, payoutIds)).GroupBy(data => data.StoreDataId);
|
||||
}
|
||||
var results = new List<ResultVM>();
|
||||
|
||||
//we group per store and init the transfers by each
|
||||
|
||||
var authorizedForInternalNode = (await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.CanModifyServerSettings))).Succeeded;
|
||||
foreach (var payoutDatas in payouts)
|
||||
{
|
||||
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);
|
||||
|
||||
if (lightningSupportedPaymentMethod.IsInternalNode && !authorizedForInternalNode)
|
||||
{
|
||||
foreach (PayoutData payoutData in payoutDatas)
|
||||
{
|
||||
|
||||
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."
|
||||
});
|
||||
}
|
||||
|
||||
results.AddRange(FailAll(payoutDatas, "You are currently using the internal Lightning node for this payout's store but you are not a server admin."));
|
||||
continue;
|
||||
}
|
||||
var processor = _lightningAutomatedPayoutSenderFactory.ConstructProcessor(new PayoutProcessorData()
|
||||
{
|
||||
Store = store,
|
||||
StoreId = store.Id,
|
||||
PayoutMethodId = pmi.ToString(),
|
||||
Processor = LightningAutomatedPayoutSenderFactory.ProcessorName,
|
||||
Id = Guid.NewGuid().ToString()
|
||||
});
|
||||
|
||||
var client =
|
||||
lightningSupportedPaymentMethod.CreateLightningClient(payoutHandler.Network, _options.Value,
|
||||
_lightningClientFactoryService);
|
||||
foreach (var payoutData in payoutDatas)
|
||||
|
||||
foreach (var payout in payoutDatas)
|
||||
{
|
||||
ResultVM result;
|
||||
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));
|
||||
}
|
||||
results.Add(await processor.HandlePayout(payout, client, cancellationToken));
|
||||
}
|
||||
}
|
||||
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()
|
||||
? 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
|
||||
});
|
||||
}
|
||||
return payouts.Select(p => Fail(p, message)).ToArray();
|
||||
}
|
||||
|
||||
public static async Task<ResultVM> TrypayBolt(
|
||||
ILightningClient lightningClient, PayoutBlob payoutBlob, PayoutData payoutData, BOLT11PaymentRequest bolt11PaymentRequest, CancellationToken cancellationToken)
|
||||
private ResultVM Fail(PayoutData payoutData, string message)
|
||||
{
|
||||
var boltAmount = bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC);
|
||||
if (boltAmount > payoutData.Amount)
|
||||
var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings);
|
||||
return new ResultVM
|
||||
{
|
||||
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 (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."
|
||||
};
|
||||
}
|
||||
PayoutId = payoutData.Id,
|
||||
Result = PayResult.Error,
|
||||
Destination = blob.Destination,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
|
||||
private async Task SetStoreContext()
|
||||
@ -405,8 +227,4 @@ namespace BTCPayServer.Data.Payouts.LightningLike
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
public class NoPaymentResultException : Exception
|
||||
{
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Lightning;
|
||||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.Models.WalletViewModels;
|
||||
using BTCPayServer.Payments;
|
||||
@ -18,11 +19,13 @@ using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Notifications;
|
||||
using BTCPayServer.Services.Notifications.Blobs;
|
||||
using BTCPayServer.Services.Rates;
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NBitcoin;
|
||||
using NBitcoin.DataEncoders;
|
||||
using NBXplorer;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using PayoutData = BTCPayServer.Data.PayoutData;
|
||||
using PullPaymentData = BTCPayServer.Data.PullPaymentData;
|
||||
@ -534,6 +537,8 @@ namespace BTCPayServer.HostedServices
|
||||
if (cryptoAmount < minimumCryptoAmount)
|
||||
{
|
||||
req.Completion.TrySetResult(new PayoutApproval.ApprovalResult(PayoutApproval.Result.TooLowAmount, null));
|
||||
payout.State = PayoutState.Cancelled;
|
||||
await ctx.SaveChangesAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -583,6 +588,8 @@ namespace BTCPayServer.HostedServices
|
||||
break;
|
||||
}
|
||||
payout.State = req.Request.State;
|
||||
if (req.Request.Blob is { } b)
|
||||
payout.SetBlob(b, _jsonSerializerSettings);
|
||||
await ctx.SaveChangesAsync();
|
||||
_eventAggregator.Publish(new PayoutEvent(PayoutEvent.PayoutEventType.Updated, payout));
|
||||
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
|
||||
? null
|
||||
: 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 limit = pp?.Limit ?? 0;
|
||||
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)
|
||||
{
|
||||
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Overdraft));
|
||||
@ -731,6 +731,22 @@ namespace BTCPayServer.HostedServices
|
||||
payout.State = PayoutState.AwaitingPayment;
|
||||
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 JObject Proof { get; set; }
|
||||
public PayoutState State { get; set; } = PayoutState.Completed;
|
||||
public PayoutBlob Blob { get; internal set; }
|
||||
|
||||
public static string GetErrorMessage(PayoutPaidResult result)
|
||||
{
|
||||
@ -942,28 +959,40 @@ namespace BTCPayServer.HostedServices
|
||||
|
||||
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),
|
||||
null when destination.Amount is null => (null, null),
|
||||
null when destination.Amount != null => (null, destination.Amount),
|
||||
not null when destination.Amount is null => (null, amount),
|
||||
not null when destination.Amount != null && amount != destination.Amount &&
|
||||
destination.IsExplicitAmountMinimum &&
|
||||
payoutCurrency == "BTC" && ppCurrency == "SATS" &&
|
||||
new Money(amount.Value, MoneyUnit.Satoshi).ToUnit(MoneyUnit.BTC) < destination.Amount =>
|
||||
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount", null),
|
||||
not null when destination.Amount != null && amount != destination.Amount &&
|
||||
destination.IsExplicitAmountMinimum &&
|
||||
!(payoutCurrency == "BTC" && ppCurrency == "SATS") &&
|
||||
amount < destination.Amount =>
|
||||
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount", null),
|
||||
not null when destination.Amount != null && amount != destination.Amount &&
|
||||
!destination.IsExplicitAmountMinimum =>
|
||||
($"Amount is implied in destination ({destination.Amount}) that does not match the payout amount provided {amount})", null),
|
||||
_ => (null, amount)
|
||||
destinationAmount = new LightMoney(destinationAmount.Value, LightMoneyUnit.BTC).ToUnit(LightMoneyUnit.Satoshi);
|
||||
amountsComparable = true;
|
||||
}
|
||||
if (destinationAmount is not null && payoutCurrency == ppCurrency)
|
||||
{
|
||||
amountsComparable = true;
|
||||
}
|
||||
return (destinationAmount, amount) switch
|
||||
{
|
||||
(null, null) when ppCurrency is null => new ClaimedAmountResult.Error("Amount is not specified in destination or payout request"),
|
||||
({ } a, null) when ppCurrency is null => new ClaimedAmountResult.Success(a),
|
||||
(null, null) => new ClaimedAmountResult.Success(null),
|
||||
({ } a, null) when amountsComparable => new ClaimedAmountResult.Success(a),
|
||||
(null, { } b) => new ClaimedAmountResult.Success(b),
|
||||
({ } a, { } b) when amountsComparable && a == b => new ClaimedAmountResult.Success(a),
|
||||
({ } a, { } b) when amountsComparable && a > b => new ClaimedAmountResult.Error($"The destination's amount ({a} {ppCurrency}) is more than the claimed amount ({b} {ppCurrency})."),
|
||||
({ } a, { } b) when amountsComparable && a < b => new ClaimedAmountResult.Success(a),
|
||||
({ } 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 string PullPaymentId { get; set; }
|
||||
public decimal? Value { get; set; }
|
||||
public decimal? ClaimedAmount { get; set; }
|
||||
public IClaimDestination Destination { get; set; }
|
||||
public string StoreId { get; set; }
|
||||
public bool? PreApprove { get; set; }
|
||||
|
@ -78,7 +78,7 @@ namespace BTCPayServer.Models
|
||||
public bool IsPending { get; set; }
|
||||
public decimal AmountCollected { get; set; }
|
||||
public decimal AmountDue { get; set; }
|
||||
public decimal ClaimedAmount { get; set; }
|
||||
public decimal? ClaimedAmount { get; set; }
|
||||
public decimal MinimumClaim { get; set; }
|
||||
public string Destination { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
|
@ -73,17 +73,7 @@ public class LightningPendingPayoutListener : BaseAsyncService
|
||||
|
||||
foreach (IGrouping<string, PayoutData> payoutByStore in payouts.GroupBy(data => data.StoreDataId))
|
||||
{
|
||||
//this should never happen
|
||||
if (!stores.TryGetValue(payoutByStore.Key, out var store))
|
||||
{
|
||||
foreach (PayoutData payoutData in payoutByStore)
|
||||
{
|
||||
payoutData.State = PayoutState.Cancelled;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var store = stores[payoutByStore.Key];
|
||||
foreach (IGrouping<string, PayoutData> payoutByStoreByPaymentMethod in payoutByStore.GroupBy(data =>
|
||||
data.PayoutMethodId))
|
||||
{
|
||||
@ -101,40 +91,37 @@ public class LightningPendingPayoutListener : BaseAsyncService
|
||||
pm.CreateLightningClient(networks[pmi], _options.Value, _lightningClientFactoryService);
|
||||
foreach (PayoutData payoutData in payoutByStoreByPaymentMethod)
|
||||
{
|
||||
var handler = _payoutHandlers.TryGet(payoutData.GetPayoutMethodId());
|
||||
var proof = handler is null ? null : handler.ParseProof(payoutData);
|
||||
switch (proof)
|
||||
{
|
||||
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;
|
||||
}
|
||||
var handler = _payoutHandlers.TryGet(payoutData.GetPayoutMethodId()) as LightningLikePayoutHandler;
|
||||
if (handler is null || handler.PayoutsPaymentProcessing.Contains(payoutData.Id))
|
||||
continue;
|
||||
var proof = handler.ParseProof(payoutData) as PayoutLightningBlob;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using BTCPayServer.Client;
|
||||
@ -18,10 +19,13 @@ using BTCPayServer.Payouts;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using LNURL;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NBitcoin;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using static BTCPayServer.Data.Payouts.LightningLike.UILightningLikePayoutController;
|
||||
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
|
||||
using PayoutData = BTCPayServer.Data.PayoutData;
|
||||
using PayoutProcessorData = BTCPayServer.Data.PayoutProcessorData;
|
||||
@ -30,117 +34,341 @@ namespace BTCPayServer.PayoutProcessors.Lightning;
|
||||
|
||||
public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<LightningAutomatedPayoutBlob>
|
||||
{
|
||||
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
|
||||
private readonly LightningClientFactoryService _lightningClientFactoryService;
|
||||
private readonly UserService _userService;
|
||||
private readonly IOptions<LightningNetworkOptions> _options;
|
||||
private readonly PullPaymentHostedService _pullPaymentHostedService;
|
||||
private readonly LightningLikePayoutHandler _payoutHandler;
|
||||
public BTCPayNetwork Network => _payoutHandler.Network;
|
||||
private readonly PaymentMethodHandlerDictionary _handlers;
|
||||
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
|
||||
private readonly LightningClientFactoryService _lightningClientFactoryService;
|
||||
private readonly UserService _userService;
|
||||
private readonly IOptions<LightningNetworkOptions> _options;
|
||||
private readonly PullPaymentHostedService _pullPaymentHostedService;
|
||||
private readonly LightningLikePayoutHandler _payoutHandler;
|
||||
public BTCPayNetwork Network => _payoutHandler.Network;
|
||||
private readonly PaymentMethodHandlerDictionary _handlers;
|
||||
|
||||
public LightningAutomatedPayoutProcessor(
|
||||
PayoutMethodId payoutMethodId,
|
||||
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings,
|
||||
LightningClientFactoryService lightningClientFactoryService,
|
||||
PayoutMethodHandlerDictionary payoutHandlers,
|
||||
UserService userService,
|
||||
ILoggerFactory logger, IOptions<LightningNetworkOptions> options,
|
||||
StoreRepository storeRepository, PayoutProcessorData payoutProcessorSettings,
|
||||
ApplicationDbContextFactory applicationDbContextFactory,
|
||||
PaymentMethodHandlerDictionary handlers,
|
||||
IPluginHookService pluginHookService,
|
||||
EventAggregator eventAggregator,
|
||||
PullPaymentHostedService pullPaymentHostedService) :
|
||||
base(PaymentTypes.LN.GetPaymentMethodId(GetPayoutHandler(payoutHandlers, payoutMethodId).Network.CryptoCode), logger, storeRepository, payoutProcessorSettings, applicationDbContextFactory,
|
||||
handlers, pluginHookService, eventAggregator)
|
||||
{
|
||||
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
|
||||
_lightningClientFactoryService = lightningClientFactoryService;
|
||||
_userService = userService;
|
||||
_options = options;
|
||||
_pullPaymentHostedService = pullPaymentHostedService;
|
||||
_payoutHandler = GetPayoutHandler(payoutHandlers, payoutMethodId);
|
||||
_handlers = handlers;
|
||||
}
|
||||
private static LightningLikePayoutHandler GetPayoutHandler(PayoutMethodHandlerDictionary payoutHandlers, PayoutMethodId payoutMethodId)
|
||||
{
|
||||
return (LightningLikePayoutHandler)payoutHandlers[payoutMethodId];
|
||||
}
|
||||
public LightningAutomatedPayoutProcessor(
|
||||
PayoutMethodId payoutMethodId,
|
||||
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings,
|
||||
LightningClientFactoryService lightningClientFactoryService,
|
||||
PayoutMethodHandlerDictionary payoutHandlers,
|
||||
UserService userService,
|
||||
ILoggerFactory logger, IOptions<LightningNetworkOptions> options,
|
||||
StoreRepository storeRepository, PayoutProcessorData payoutProcessorSettings,
|
||||
ApplicationDbContextFactory applicationDbContextFactory,
|
||||
PaymentMethodHandlerDictionary handlers,
|
||||
IPluginHookService pluginHookService,
|
||||
EventAggregator eventAggregator,
|
||||
PullPaymentHostedService pullPaymentHostedService) :
|
||||
base(PaymentTypes.LN.GetPaymentMethodId(GetPayoutHandler(payoutHandlers, payoutMethodId).Network.CryptoCode), logger, storeRepository, payoutProcessorSettings, applicationDbContextFactory,
|
||||
handlers, pluginHookService, eventAggregator)
|
||||
{
|
||||
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
|
||||
_lightningClientFactoryService = lightningClientFactoryService;
|
||||
_userService = userService;
|
||||
_options = options;
|
||||
_pullPaymentHostedService = pullPaymentHostedService;
|
||||
_payoutHandler = GetPayoutHandler(payoutHandlers, payoutMethodId);
|
||||
_handlers = handlers;
|
||||
}
|
||||
private static LightningLikePayoutHandler GetPayoutHandler(PayoutMethodHandlerDictionary payoutHandlers, PayoutMethodId payoutMethodId)
|
||||
{
|
||||
return (LightningLikePayoutHandler)payoutHandlers[payoutMethodId];
|
||||
}
|
||||
|
||||
private async Task HandlePayout(PayoutData payoutData, ILightningClient lightningClient)
|
||||
{
|
||||
if (payoutData.State != PayoutState.AwaitingPayment)
|
||||
return;
|
||||
var res = await _pullPaymentHostedService.MarkPaid(new MarkPayoutRequest()
|
||||
public async Task<ResultVM> HandlePayout(PayoutData payoutData, ILightningClient lightningClient, CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _payoutHandler.PayoutsPaymentProcessing.StartTracking();
|
||||
if (payoutData.State != PayoutState.AwaitingPayment || !scope.TryTrack(payoutData.Id))
|
||||
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
|
||||
});
|
||||
if (res != MarkPayoutRequest.PayoutPaidResult.Ok)
|
||||
{
|
||||
return;
|
||||
case LNURLPayClaimDestinaton lnurlPayClaimDestinaton:
|
||||
var lnurlResult = await GetInvoiceFromLNURL(payoutData, _payoutHandler, blob,
|
||||
lnurlPayClaimDestinaton, cancellationToken);
|
||||
if (lnurlResult.Item2 is not null)
|
||||
{
|
||||
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
|
||||
{
|
||||
var claim = await _payoutHandler.ParseClaimDestination(blob.Destination, CancellationToken);
|
||||
switch (claim.destination)
|
||||
{
|
||||
case LNURLPayClaimDestinaton lnurlPayClaimDestinaton:
|
||||
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;
|
||||
}
|
||||
var lnurlPayRequestCallbackResponse =
|
||||
await lnurlInfo.SendRequest(lm, this.Network.NBitcoinNetwork, httpClient, cancellationToken: cancellationToken);
|
||||
|
||||
return (lnurlPayRequestCallbackResponse.GetPaymentRequest(this.Network.NBitcoinNetwork), null);
|
||||
}
|
||||
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)
|
||||
{
|
||||
var processorBlob = GetBlob(PayoutProcessorSettings);
|
||||
var lightningSupportedPaymentMethod = (LightningPaymentMethodConfig)paymentMethodConfig;
|
||||
if (lightningSupportedPaymentMethod.IsInternalNode &&
|
||||
!await _storeRepository.InternalNodePayoutAuthorized(PayoutProcessorSettings.StoreId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
{
|
||||
var lightningSupportedPaymentMethod = (LightningPaymentMethodConfig)paymentMethodConfig;
|
||||
if (lightningSupportedPaymentMethod.IsInternalNode &&
|
||||
!await _storeRepository.InternalNodePayoutAuthorized(PayoutProcessorSettings.StoreId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var client =
|
||||
lightningSupportedPaymentMethod.CreateLightningClient(Network, _options.Value,
|
||||
_lightningClientFactoryService);
|
||||
await Task.WhenAll(payouts.Select(data => HandlePayout(data, client)));
|
||||
var client =
|
||||
lightningSupportedPaymentMethod.CreateLightningClient(Network, _options.Value,
|
||||
_lightningClientFactoryService);
|
||||
await Task.WhenAll(payouts.Select(data => HandlePayout(data, client, CancellationToken)));
|
||||
|
||||
//we return false because this processor handles db updates on its own
|
||||
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 ;
|
||||
}
|
||||
//we return false because this processor handles db updates on its own
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -47,14 +47,17 @@ public class LightningAutomatedPayoutSenderFactory : IPayoutProcessorFactory
|
||||
public static string ProcessorName => nameof(LightningAutomatedPayoutSenderFactory);
|
||||
public IEnumerable<PayoutMethodId> GetSupportedPayoutMethods() => _supportedPayoutMethods;
|
||||
|
||||
public Task<IHostedService> ConstructProcessor(PayoutProcessorData settings)
|
||||
public LightningAutomatedPayoutProcessor ConstructProcessor(PayoutProcessorData settings)
|
||||
{
|
||||
if (settings.Processor != Processor)
|
||||
{
|
||||
throw new NotSupportedException("This processor cannot handle the provided requirements");
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
38
BTCPayServer/ResourceTracker.cs
Normal file
38
BTCPayServer/ResourceTracker.cs
Normal 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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user