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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 596 additions and 447 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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