Refactor payouts processing (#6314)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ using BTCPayServer.Lightning;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Lightning; using BTCPayServer.Payments.Lightning;
using BTCPayServer.PayoutProcessors.Lightning;
using BTCPayServer.Payouts; using BTCPayServer.Payouts;
using BTCPayServer.Security; using BTCPayServer.Security;
using BTCPayServer.Services; using BTCPayServer.Services;
@@ -32,6 +33,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
public class UILightningLikePayoutController : Controller public class UILightningLikePayoutController : Controller
{ {
private readonly ApplicationDbContextFactory _applicationDbContextFactory; private readonly ApplicationDbContextFactory _applicationDbContextFactory;
private readonly LightningAutomatedPayoutSenderFactory _lightningAutomatedPayoutSenderFactory;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings; private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
private readonly PayoutMethodHandlerDictionary _payoutHandlers; private readonly PayoutMethodHandlerDictionary _payoutHandlers;
@@ -43,6 +45,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
private readonly StoreRepository _storeRepository; private readonly StoreRepository _storeRepository;
public UILightningLikePayoutController(ApplicationDbContextFactory applicationDbContextFactory, public UILightningLikePayoutController(ApplicationDbContextFactory applicationDbContextFactory,
LightningAutomatedPayoutSenderFactory lightningAutomatedPayoutSenderFactory,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings, BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings,
PayoutMethodHandlerDictionary payoutHandlers, PayoutMethodHandlerDictionary payoutHandlers,
@@ -54,6 +57,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
EventAggregator eventAggregator) EventAggregator eventAggregator)
{ {
_applicationDbContextFactory = applicationDbContextFactory; _applicationDbContextFactory = applicationDbContextFactory;
_lightningAutomatedPayoutSenderFactory = lightningAutomatedPayoutSenderFactory;
_userManager = userManager; _userManager = userManager;
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings; _btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
_payoutHandlers = payoutHandlers; _payoutHandlers = payoutHandlers;
@@ -132,249 +136,67 @@ namespace BTCPayServer.Data.Payouts.LightningLike
var paymentMethodId = PaymentTypes.LN.GetPaymentMethodId(cryptoCode); var paymentMethodId = PaymentTypes.LN.GetPaymentMethodId(cryptoCode);
var payoutHandler = (LightningLikePayoutHandler)_payoutHandlers.TryGet(pmi); var payoutHandler = (LightningLikePayoutHandler)_payoutHandlers.TryGet(pmi);
await using var ctx = _applicationDbContextFactory.CreateContext(); IEnumerable<IGrouping<string, PayoutData>> payouts;
using (var ctx = _applicationDbContextFactory.CreateContext())
var payouts = (await GetPayouts(ctx, pmi, payoutIds)).GroupBy(data => data.StoreDataId); {
payouts = (await GetPayouts(ctx, pmi, payoutIds)).GroupBy(data => data.StoreDataId);
}
var results = new List<ResultVM>(); var results = new List<ResultVM>();
//we group per store and init the transfers by each //we group per store and init the transfers by each
var authorizedForInternalNode = (await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.CanModifyServerSettings))).Succeeded; var authorizedForInternalNode = (await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.CanModifyServerSettings))).Succeeded;
foreach (var payoutDatas in payouts) foreach (var payoutDatas in payouts)
{ {
var store = payoutDatas.First().StoreData; var store = payoutDatas.First().StoreData;
var authorized = await _authorizationService.AuthorizeAsync(User, store, new PolicyRequirement(Policies.CanUseLightningNodeInStore));
if (!authorized.Succeeded)
{
results.AddRange(FailAll(payoutDatas, "You need the 'btcpay.store.canuselightningnode' permission for this action"));
continue;
}
var lightningSupportedPaymentMethod = store.GetPaymentMethodConfig<LightningPaymentMethodConfig>(paymentMethodId, _handlers); var lightningSupportedPaymentMethod = store.GetPaymentMethodConfig<LightningPaymentMethodConfig>(paymentMethodId, _handlers);
if (lightningSupportedPaymentMethod.IsInternalNode && !authorizedForInternalNode) if (lightningSupportedPaymentMethod.IsInternalNode && !authorizedForInternalNode)
{ {
foreach (PayoutData payoutData in payoutDatas) results.AddRange(FailAll(payoutDatas, "You are currently using the internal Lightning node for this payout's store but you are not a server admin."));
{
var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings);
results.Add(new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Error,
Destination = blob.Destination,
Message = "You are currently using the internal Lightning node for this payout's store but you are not a server admin."
});
}
continue; continue;
} }
var processor = _lightningAutomatedPayoutSenderFactory.ConstructProcessor(new PayoutProcessorData()
{
Store = store,
StoreId = store.Id,
PayoutMethodId = pmi.ToString(),
Processor = LightningAutomatedPayoutSenderFactory.ProcessorName,
Id = Guid.NewGuid().ToString()
});
var client = var client =
lightningSupportedPaymentMethod.CreateLightningClient(payoutHandler.Network, _options.Value, lightningSupportedPaymentMethod.CreateLightningClient(payoutHandler.Network, _options.Value,
_lightningClientFactoryService); _lightningClientFactoryService);
foreach (var payoutData in payoutDatas)
{
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; foreach (var payout in payoutDatas)
case BoltInvoiceClaimDestination item1:
result = await TrypayBolt(client, blob, payoutData, item1.PaymentRequest, cancellationToken);
break;
default:
result = new ResultVM
{ {
PayoutId = payoutData.Id, results.Add(await processor.HandlePayout(payout, client, cancellationToken));
Result = PayResult.Error,
Destination = blob.Destination,
Message = claim.error
};
break;
}
}
catch (Exception exception)
{
result = new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Error,
Destination = blob.Destination,
Message = exception.Message
};
}
results.Add(result);
}
}
await ctx.SaveChangesAsync();
foreach (var payoutG in payouts)
{
foreach (PayoutData payout in payoutG)
{
if (payout.State != PayoutState.AwaitingPayment)
{
_eventAggregator.Publish(new PayoutEvent(PayoutEvent.PayoutEventType.Updated, payout));
}
} }
} }
return View("LightningPayoutResult", results); return View("LightningPayoutResult", results);
} }
public static async Task<(BOLT11PaymentRequest, ResultVM)> GetInvoiceFromLNURL(PayoutData payoutData,
LightningLikePayoutHandler handler, PayoutBlob blob, LNURLPayClaimDestinaton lnurlPayClaimDestinaton, Network network, CancellationToken cancellationToken)
{
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; private ResultVM[] FailAll(IEnumerable<PayoutData> payouts, string message)
return (null, new ResultVM {
return payouts.Select(p => Fail(p, message)).ToArray();
}
private ResultVM Fail(PayoutData payoutData, string message)
{
var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings);
return new ResultVM
{ {
PayoutId = payoutData.Id, PayoutId = payoutData.Id,
Result = PayResult.Error, Result = PayResult.Error,
Destination = blob.Destination, 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
});
}
}
public static async Task<ResultVM> TrypayBolt(
ILightningClient lightningClient, PayoutBlob payoutBlob, PayoutData payoutData, BOLT11PaymentRequest bolt11PaymentRequest, CancellationToken cancellationToken)
{
var boltAmount = bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC);
if (boltAmount > payoutData.Amount)
{
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 Message = message
}; };
} }
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException or NoPaymentResultException)
{
// Timeout, potentially caused by hold invoices
// Payment will be saved as pending, the LightningPendingPayoutListener will handle settling/cancelling
payoutData.State = PayoutState.InProgress;
payoutData.SetProofBlob(proofBlob, null);
return new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Ok,
Destination = payoutBlob.Destination,
Message = "The payment timed out. We will verify if it completed later."
};
}
}
private async Task SetStoreContext() private async Task SetStoreContext()
{ {
@@ -405,8 +227,4 @@ namespace BTCPayServer.Data.Payouts.LightningLike
public decimal Amount { get; set; } public decimal Amount { get; set; }
} }
} }
public class NoPaymentResultException : Exception
{
}
} }

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client; using BTCPayServer.Client;
@@ -18,10 +19,13 @@ using BTCPayServer.Payouts;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using LNURL;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NBitcoin; using NBitcoin;
using Newtonsoft.Json.Linq;
using static BTCPayServer.Data.Payouts.LightningLike.UILightningLikePayoutController;
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest; using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
using PayoutData = BTCPayServer.Data.PayoutData; using PayoutData = BTCPayServer.Data.PayoutData;
using PayoutProcessorData = BTCPayServer.Data.PayoutProcessorData; using PayoutProcessorData = BTCPayServer.Data.PayoutProcessorData;
@@ -68,57 +72,290 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Li
return (LightningLikePayoutHandler)payoutHandlers[payoutMethodId]; return (LightningLikePayoutHandler)payoutHandlers[payoutMethodId];
} }
private async Task HandlePayout(PayoutData payoutData, ILightningClient lightningClient) public async Task<ResultVM> HandlePayout(PayoutData payoutData, ILightningClient lightningClient, CancellationToken cancellationToken)
{ {
if (payoutData.State != PayoutState.AwaitingPayment) using var scope = _payoutHandler.PayoutsPaymentProcessing.StartTracking();
return; if (payoutData.State != PayoutState.AwaitingPayment || !scope.TryTrack(payoutData.Id))
return InvalidState(payoutData.Id);
var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings);
var res = await _pullPaymentHostedService.MarkPaid(new MarkPayoutRequest() var res = await _pullPaymentHostedService.MarkPaid(new MarkPayoutRequest()
{ {
State = PayoutState.InProgress, PayoutId = payoutData.Id, Proof = null State = PayoutState.InProgress,
PayoutId = payoutData.Id,
Proof = null
}); });
if (res != MarkPayoutRequest.PayoutPaidResult.Ok) if (res != MarkPayoutRequest.PayoutPaidResult.Ok)
{ return InvalidState(payoutData.Id);
return; ResultVM result;
} var claim = await _payoutHandler.ParseClaimDestination(blob.Destination, cancellationToken);
var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings);
try
{
var claim = await _payoutHandler.ParseClaimDestination(blob.Destination, CancellationToken);
switch (claim.destination) switch (claim.destination)
{ {
case LNURLPayClaimDestinaton lnurlPayClaimDestinaton: case LNURLPayClaimDestinaton lnurlPayClaimDestinaton:
var lnurlResult = await UILightningLikePayoutController.GetInvoiceFromLNURL(payoutData, var lnurlResult = await GetInvoiceFromLNURL(payoutData, _payoutHandler, blob,
_payoutHandler, blob, lnurlPayClaimDestinaton, cancellationToken);
lnurlPayClaimDestinaton, Network.NBitcoinNetwork, CancellationToken); if (lnurlResult.Item2 is not null)
if (lnurlResult.Item2 is null)
{ {
await TrypayBolt(lightningClient, blob, payoutData, result = lnurlResult.Item2;
lnurlResult.Item1); }
else
{
result = await TrypayBolt(lightningClient, blob, payoutData, lnurlResult.Item1, cancellationToken);
} }
break; break;
case BoltInvoiceClaimDestination item1: case BoltInvoiceClaimDestination item1:
await TrypayBolt(lightningClient, blob, payoutData, item1.PaymentRequest); result = await TrypayBolt(lightningClient, blob, payoutData, item1.PaymentRequest, cancellationToken);
break; break;
} default:
} result = new ResultVM
catch (Exception e)
{ {
Logs.PayServer.LogError(e, $"Could not process payout {payoutData.Id}"); 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) if (payoutData.State != PayoutState.InProgress || payoutData.Proof is not null)
{ {
await _pullPaymentHostedService.MarkPaid(new MarkPayoutRequest() await _pullPaymentHostedService.MarkPaid(new MarkPayoutRequest()
{ {
State = payoutData.State, PayoutId = payoutData.Id, Proof = payoutData.GetProofBlobJson() 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"
});
}
try
{
var lnurlPayRequestCallbackResponse =
await lnurlInfo.SendRequest(lm, this.Network.NBitcoinNetwork, httpClient, cancellationToken: cancellationToken);
return (lnurlPayRequestCallbackResponse.GetPaymentRequest(this.Network.NBitcoinNetwork), null);
}
catch (LNUrlException e)
{
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 (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() };
PayResponse pay = null;
try
{
Exception exception = null;
try
{
pay = await lightningClient.Pay(bolt11PaymentRequest.ToString(),
new PayInvoiceParams()
{
Amount = new LightMoney((decimal)payoutData.Amount, LightMoneyUnit.BTC)
}, cancellationToken);
if (pay?.Result is PayResult.CouldNotFindRoute)
{
// Payment failed for sure... we can try again later!
payoutData.State = PayoutState.AwaitingPayment;
return new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.CouldNotFindRoute,
Message = $"Unable to find a route for the payment, check your channel liquidity",
Destination = payoutBlob.Destination
};
}
}
catch (Exception ex)
{
exception = ex;
}
LightningPayment payment = null;
try
{
payment = await lightningClient.GetPayment(bolt11PaymentRequest.PaymentHash.ToString(), cancellationToken);
}
catch (Exception ex)
{
exception = ex;
}
if (payment is null)
{
payoutData.State = PayoutState.Cancelled;
var exceptionMessage = "";
if (exception is not null)
exceptionMessage = $" ({exception.Message})";
if (exceptionMessage == "")
exceptionMessage = $" ({pay?.ErrorDetail})";
return new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Error,
Message = $"Unable to confirm the payment of the invoice" + exceptionMessage,
Destination = payoutBlob.Destination
};
}
if (payment.Preimage is not null)
proofBlob.Preimage = payment.Preimage;
if (payment.Status == LightningPaymentStatus.Complete)
{
payoutData.State = PayoutState.Completed;
payoutData.SetProofBlob(proofBlob, null);
return new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Ok,
Destination = payoutBlob.Destination,
Message = payment.AmountSent != null
? $"Paid out {payment.AmountSent.ToDecimal(LightMoneyUnit.BTC)} {payoutData.Currency}"
: "Paid out"
};
}
else if (payment.Status == LightningPaymentStatus.Failed)
{
payoutData.State = PayoutState.AwaitingPayment;
string reason = "";
if (pay?.ErrorDetail is string err)
reason = $" ({err})";
return new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Error,
Destination = payoutBlob.Destination,
Message = $"The payment failed{reason}"
};
}
else
{
payoutData.State = PayoutState.InProgress;
return new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Unknown,
Destination = payoutBlob.Destination,
Message = "The payment has been initiated but is still in-flight."
};
}
}
catch (OperationCanceledException)
{
// Timeout, potentially caused by hold invoices
// Payment will be saved as pending, the LightningPendingPayoutListener will handle settling/cancelling
payoutData.State = PayoutState.InProgress;
payoutData.SetProofBlob(proofBlob, null);
return new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Ok,
Destination = payoutBlob.Destination,
Message = "The payment timed out. We will verify if it completed later."
};
}
} }
protected override async Task<bool> ProcessShouldSave(object paymentMethodConfig, List<PayoutData> payouts) protected override async Task<bool> ProcessShouldSave(object paymentMethodConfig, List<PayoutData> payouts)
{ {
var processorBlob = GetBlob(PayoutProcessorSettings);
var lightningSupportedPaymentMethod = (LightningPaymentMethodConfig)paymentMethodConfig; var lightningSupportedPaymentMethod = (LightningPaymentMethodConfig)paymentMethodConfig;
if (lightningSupportedPaymentMethod.IsInternalNode && if (lightningSupportedPaymentMethod.IsInternalNode &&
!await _storeRepository.InternalNodePayoutAuthorized(PayoutProcessorSettings.StoreId)) !await _storeRepository.InternalNodePayoutAuthorized(PayoutProcessorSettings.StoreId))
@@ -129,18 +366,9 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Li
var client = var client =
lightningSupportedPaymentMethod.CreateLightningClient(Network, _options.Value, lightningSupportedPaymentMethod.CreateLightningClient(Network, _options.Value,
_lightningClientFactoryService); _lightningClientFactoryService);
await Task.WhenAll(payouts.Select(data => HandlePayout(data, client))); await Task.WhenAll(payouts.Select(data => HandlePayout(data, client, CancellationToken)));
//we return false because this processor handles db updates on its own //we return false because this processor handles db updates on its own
return false; return false;
} }
//we group per store and init the transfers by each
async Task<bool> TrypayBolt(ILightningClient lightningClient, PayoutBlob payoutBlob, PayoutData payoutData,
BOLT11PaymentRequest bolt11PaymentRequest)
{
return (await UILightningLikePayoutController.TrypayBolt(lightningClient, payoutBlob, payoutData,
bolt11PaymentRequest,
CancellationToken)).Result is PayResult.Ok ;
}
} }

View File

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

View File

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