Quality of life improvements to payout processors (#5135)

* Quality of life improvements to payout processors

* Allows more fleixble intervals for payout processing from 10-60 mins to 1min-24hours(requested by users)
* Cancel ln payotus that expired (bolt11)
* Allow cancelling of ln payotus that have failed to be paid after x attempts
* Allow conifguring a threshold for when to process on-chain payouts (reduces fees)

# Conflicts:
#	BTCPayServer.Tests/SeleniumTests.cs

* Simplify the code

* switch to concurrent dictionary

* Allow ProcessNewPayoutsInstantly

* refactor plugin hook service to have events available and change processor hooks to actions with better args

* add procesor extended tests

* Update BTCPayServer.Tests/GreenfieldAPITests.cs

* fix concurrency issue

* Update BTCPayServer/PayoutProcessors/BaseAutomatedPayoutProcessor.cs

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
Andrew Camilleri 2023-07-20 15:05:14 +02:00 committed by GitHub
parent b1c81b696f
commit 4063a5aaee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 441 additions and 107 deletions

View file

@ -1,3 +1,4 @@
using System;
using System.Threading.Tasks;
namespace BTCPayServer.Abstractions.Contracts
@ -6,5 +7,8 @@ namespace BTCPayServer.Abstractions.Contracts
{
Task ApplyAction(string hook, object args);
Task<object> ApplyFilter(string hook, object args);
event EventHandler<(string hook, object args)> ActionInvoked;
event EventHandler<(string hook, object args)> FilterInvoked;
}
}

View file

@ -10,4 +10,9 @@ public class LightningAutomatedPayoutSettings
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
public TimeSpan IntervalSeconds { get; set; }
public int? CancelPayoutAfterFailures { get; set; }
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
public bool ProcessNewPayoutsInstantly { get; set; }
}

View file

@ -12,4 +12,8 @@ public class OnChainAutomatedPayoutSettings
public TimeSpan IntervalSeconds { get; set; }
public int? FeeBlockTarget { get; set; }
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
public decimal Threshold { get; set; }
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
public bool ProcessNewPayoutsInstantly { get; set; }
}

View file

@ -8,6 +8,7 @@ namespace BTCPayServer.Data;
public class AutomatedPayoutBlob
{
public TimeSpan Interval { get; set; } = TimeSpan.FromHours(1);
public bool ProcessNewPayoutsInstantly { get; set; }
}
public class PayoutProcessorData : IHasBlobUntyped
{

View file

@ -17,6 +17,7 @@ using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.PayoutProcessors;
using BTCPayServer.PayoutProcessors.OnChain;
using BTCPayServer.Plugins;
using BTCPayServer.Services;
using BTCPayServer.Services.Custodian.Client.MockCustodian;
using BTCPayServer.Services.Notifications;
@ -3669,9 +3670,12 @@ namespace BTCPayServer.Tests
Assert.Single(payouts.Where(data => data.State == PayoutState.InProgress));
});
var txid = await tester.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create((await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address,
tester.ExplorerClient.Network.NBitcoinNetwork), Money.Coins(0.01m) + fee);
await tester.WaitForEvent<NewOnChainTransactionEvent>(null, correctEvent: ev => ev.NewTransactionEvent.TransactionData.TransactionHash == txid);
uint256 txid = null;
await tester.WaitForEvent<NewOnChainTransactionEvent>(async () =>
{
txid = await tester.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create((await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address,
tester.ExplorerClient.Network.NBitcoinNetwork), Money.Coins(0.01m) + fee);
}, correctEvent: ev => ev.NewTransactionEvent.TransactionData.TransactionHash == txid);
await tester.PayTester.GetService<PayoutProcessorService>().Restart(new PayoutProcessorService.PayoutProcessorQuery(admin.StoreId, "BTC"));
await TestUtils.EventuallyAsync(async () =>
{
@ -3679,6 +3683,122 @@ namespace BTCPayServer.Tests
payouts = await adminClient.GetStorePayouts(admin.StoreId);
Assert.Empty(payouts.Where(data => data.State != PayoutState.InProgress));
});
// settings that were added later
var settings =
Assert.Single(await adminClient.GetStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC"));
Assert.False( settings.ProcessNewPayoutsInstantly);
Assert.Equal(0m, settings.Threshold);
//let's use the ProcessNewPayoutsInstantly so that it will trigger instantly
settings.IntervalSeconds = TimeSpan.FromDays(1);
settings.ProcessNewPayoutsInstantly = true;
await tester.WaitForEvent<NewOnChainTransactionEvent>(async () =>
{
txid = await tester.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create((await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address,
tester.ExplorerClient.Network.NBitcoinNetwork), Money.Coins(1m) + fee);
}, correctEvent: ev => ev.NewTransactionEvent.TransactionData.TransactionHash == txid);
await adminClient.UpdateStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC", settings);
settings =
Assert.Single(await adminClient.GetStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC"));
Assert.True( settings.ProcessNewPayoutsInstantly);
var pluginHookService = tester.PayTester.GetService<IPluginHookService>();
var beforeHookTcs = new TaskCompletionSource();
var afterHookTcs = new TaskCompletionSource();
pluginHookService.ActionInvoked += (sender, tuple) =>
{
switch (tuple.hook)
{
case "before-automated-payout-processing":
beforeHookTcs.TrySetResult();
break;
case "after-automated-payout-processing":
afterHookTcs.TrySetResult();
break;
}
};
var payoutThatShouldBeProcessedStraightAway = await adminClient.CreatePayout(admin.StoreId, new CreatePayoutThroughStoreRequest()
{
PullPaymentId = pullPayment.Id,
Amount = 0.5m,
Approved = true,
PaymentMethod = "BTC",
Destination = (await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address,
});
await beforeHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
await afterHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
payouts = await adminClient.GetStorePayouts(admin.StoreId);
Assert.Single(payouts.Where(data => data.State == PayoutState.InProgress && data.Id == payoutThatShouldBeProcessedStraightAway.Id));
beforeHookTcs = new TaskCompletionSource();
afterHookTcs = new TaskCompletionSource();
//let's test the threshold limiter
settings.Threshold = 0.5m;
await adminClient.UpdateStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC", settings);
//quick test: when updating processor, it processes instantly
await beforeHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
await afterHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
settings =
Assert.Single(await adminClient.GetStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC"));
Assert.Equal(0.5m, settings.Threshold);
//create a payout that should not be processed straight away due to threshold
beforeHookTcs = new TaskCompletionSource();
afterHookTcs = new TaskCompletionSource();
var payoutThatShouldNotBeProcessedStraightAway = await adminClient.CreatePayout(admin.StoreId, new CreatePayoutThroughStoreRequest()
{
Amount = 0.1m,
Approved = true,
PaymentMethod = "BTC",
Destination = (await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address,
});
await beforeHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
await afterHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
payouts = await adminClient.GetStorePayouts(admin.StoreId);
Assert.Single(payouts.Where(data => data.State == PayoutState.AwaitingPayment && data.Id == payoutThatShouldNotBeProcessedStraightAway.Id));
beforeHookTcs = new TaskCompletionSource();
afterHookTcs = new TaskCompletionSource();
var payoutThatShouldNotBeProcessedStraightAway2 = await adminClient.CreatePayout(admin.StoreId, new CreatePayoutThroughStoreRequest()
{
Amount = 0.3m,
Approved = true,
PaymentMethod = "BTC",
Destination = (await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address,
});
await beforeHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
await afterHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
payouts = await adminClient.GetStorePayouts(admin.StoreId);
Assert.Equal(2, payouts.Count(data => data.State == PayoutState.AwaitingPayment &&
(data.Id == payoutThatShouldNotBeProcessedStraightAway.Id || data.Id == payoutThatShouldNotBeProcessedStraightAway2.Id)));
beforeHookTcs = new TaskCompletionSource();
afterHookTcs = new TaskCompletionSource();
var payoutThatShouldNotBeProcessedStraightAway3 = await adminClient.CreatePayout(admin.StoreId, new CreatePayoutThroughStoreRequest()
{
Amount = 0.3m,
Approved = true,
PaymentMethod = "BTC",
Destination = (await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address,
});
await beforeHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
await afterHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
payouts = await adminClient.GetStorePayouts(admin.StoreId);
Assert.Empty(payouts.Where(data => data.State != PayoutState.InProgress));
}
[Fact(Timeout = 60 * 2 * 1000)]

View file

@ -53,16 +53,23 @@ namespace BTCPayServer.Controllers.Greenfield
private static LightningAutomatedPayoutSettings ToModel(PayoutProcessorData data)
{
var blob = data.HasTypedBlob<LightningAutomatedPayoutBlob>().GetBlob();
return new LightningAutomatedPayoutSettings()
{
PaymentMethod = data.PaymentMethod,
IntervalSeconds = data.HasTypedBlob<AutomatedPayoutBlob>().GetBlob()!.Interval
IntervalSeconds = blob.Interval,
CancelPayoutAfterFailures = blob.CancelPayoutAfterFailures,
ProcessNewPayoutsInstantly = blob.ProcessNewPayoutsInstantly
};
}
private static AutomatedPayoutBlob FromModel(LightningAutomatedPayoutSettings data)
private static LightningAutomatedPayoutBlob FromModel(LightningAutomatedPayoutSettings data)
{
return new AutomatedPayoutBlob() { Interval = data.IntervalSeconds };
return new LightningAutomatedPayoutBlob() {
Interval = data.IntervalSeconds,
CancelPayoutAfterFailures = data.CancelPayoutAfterFailures,
ProcessNewPayoutsInstantly = data.ProcessNewPayoutsInstantly
};
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
@ -84,7 +91,7 @@ namespace BTCPayServer.Controllers.Greenfield
}))
.FirstOrDefault();
activeProcessor ??= new PayoutProcessorData();
activeProcessor.HasTypedBlob<AutomatedPayoutBlob>().SetBlob(FromModel(request));
activeProcessor.HasTypedBlob<LightningAutomatedPayoutBlob>().SetBlob(FromModel(request));
activeProcessor.StoreId = storeId;
activeProcessor.PaymentMethod = paymentMethod;
activeProcessor.Processor = LightningAutomatedPayoutSenderFactory.ProcessorName;

View file

@ -59,7 +59,9 @@ namespace BTCPayServer.Controllers.Greenfield
{
FeeBlockTarget = blob.FeeTargetBlock,
PaymentMethod = data.PaymentMethod,
IntervalSeconds = blob.Interval
IntervalSeconds = blob.Interval,
Threshold = blob.Threshold,
ProcessNewPayoutsInstantly = blob.ProcessNewPayoutsInstantly
};
}
@ -68,7 +70,9 @@ namespace BTCPayServer.Controllers.Greenfield
return new OnChainAutomatedPayoutBlob()
{
FeeTargetBlock = data.FeeBlockTarget ?? 1,
Interval = data.IntervalSeconds
Interval = data.IntervalSeconds,
Threshold = data.Threshold,
ProcessNewPayoutsInstantly = data.ProcessNewPayoutsInstantly
};
}

View file

@ -277,6 +277,18 @@ namespace BTCPayServer.Data.Payouts.LightningLike
};
}
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
{

View file

@ -389,7 +389,7 @@ namespace BTCPayServer.HostedServices
{
try
{
using var ctx = _dbContextFactory.CreateContext();
await using var ctx = _dbContextFactory.CreateContext();
var payout = await ctx.Payouts.Include(p => p.PullPaymentData).Where(p => p.Id == req.PayoutId)
.FirstOrDefaultAsync();
if (payout is null)
@ -440,6 +440,7 @@ namespace BTCPayServer.HostedServices
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
await ctx.SaveChangesAsync();
_eventAggregator.Publish(new PayoutEvent(PayoutEvent.PayoutEventType.Approved, payout));
req.Completion.SetResult(new PayoutApproval.ApprovalResult(PayoutApproval.Result.Ok, payoutBlob.CryptoAmount));
}
catch (Exception ex)
@ -604,6 +605,8 @@ namespace BTCPayServer.HostedServices
{
await payoutHandler.TrackClaim(req.ClaimRequest, payout);
await ctx.SaveChangesAsync();
var response = new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Ok, payout);
_eventAggregator.Publish(new PayoutEvent(PayoutEvent.PayoutEventType.Created, payout));
if (req.ClaimRequest.PreApprove.GetValueOrDefault(ppBlob?.AutoApproveClaims is true))
{
payout.StoreData = await ctx.Stores.FindAsync(payout.StoreDataId);
@ -628,7 +631,7 @@ namespace BTCPayServer.HostedServices
}
}
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Ok, payout));
req.Completion.TrySetResult(response);
await _notificationSender.SendNotification(new StoreScope(payout.StoreDataId),
new PayoutNotification()
{
@ -888,4 +891,14 @@ namespace BTCPayServer.HostedServices
public string StoreId { get; set; }
public bool? PreApprove { get; set; }
}
public record PayoutEvent(PayoutEvent.PayoutEventType Type,PayoutData Payout)
{
public enum PayoutEventType
{
Created,
Approved
}
}
}

View file

@ -278,7 +278,8 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<AppService>();
services.AddTransient<PluginService>();
services.AddSingleton<IPluginHookService, PluginHookService>();
services.AddSingleton<PluginHookService>();
services.AddSingleton<IPluginHookService, PluginHookService>(provider => provider.GetService<PluginHookService>());
services.TryAddTransient<Safe>();
services.TryAddTransient<DisplayFormatter>();
services.TryAddSingleton<Ganss.XSS.HtmlSanitizer>(o =>

View file

@ -0,0 +1,7 @@
using System.Collections.Generic;
using BTCPayServer.Data;
namespace BTCPayServer.PayoutProcessors;
public record AfterPayoutActionData(StoreData Store, PayoutProcessorData ProcessorData,
IEnumerable<PayoutData> Payouts);

View file

@ -1,19 +0,0 @@
using System.Collections.Generic;
using BTCPayServer.Data;
using BTCPayServer.Payments;
namespace BTCPayServer.PayoutProcessors;
public class AfterPayoutFilterData
{
private readonly StoreData _store;
private readonly ISupportedPaymentMethod _paymentMethod;
private readonly List<PayoutData> _payoutDatas;
public AfterPayoutFilterData(StoreData store, ISupportedPaymentMethod paymentMethod, List<PayoutData> payoutDatas)
{
_store = store;
_paymentMethod = paymentMethod;
_payoutDatas = payoutDatas;
}
}

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client.Models;
@ -20,89 +21,109 @@ namespace BTCPayServer.PayoutProcessors;
public class AutomatedPayoutConstants
{
public const double MinIntervalMinutes = 10.0;
public const double MaxIntervalMinutes = 60.0;
public const double MinIntervalMinutes = 1.0;
public const double MaxIntervalMinutes = 24 * 60; //1 day
public static void ValidateInterval(ModelStateDictionary modelState, TimeSpan timeSpan, string parameterName)
{
if (timeSpan < TimeSpan.FromMinutes(AutomatedPayoutConstants.MinIntervalMinutes))
{
modelState.AddModelError(parameterName, $"The minimum interval is {AutomatedPayoutConstants.MinIntervalMinutes * 60} seconds");
modelState.AddModelError(parameterName, $"The minimum interval is {MinIntervalMinutes * 60} seconds");
}
if (timeSpan > TimeSpan.FromMinutes(AutomatedPayoutConstants.MaxIntervalMinutes))
{
modelState.AddModelError(parameterName, $"The maximum interval is {AutomatedPayoutConstants.MaxIntervalMinutes * 60} seconds");
modelState.AddModelError(parameterName, $"The maximum interval is {MaxIntervalMinutes * 60} seconds");
}
}
}
public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T : AutomatedPayoutBlob
{
protected readonly StoreRepository _storeRepository;
protected readonly PayoutProcessorData _PayoutProcesserSettings;
protected readonly PayoutProcessorData PayoutProcessorSettings;
protected readonly ApplicationDbContextFactory _applicationDbContextFactory;
private readonly PullPaymentHostedService _pullPaymentHostedService;
protected readonly BTCPayNetworkProvider _btcPayNetworkProvider;
protected readonly PaymentMethodId PaymentMethodId;
private readonly IPluginHookService _pluginHookService;
private readonly EventAggregator _eventAggregator;
protected BaseAutomatedPayoutProcessor(
ILoggerFactory logger,
StoreRepository storeRepository,
PayoutProcessorData payoutProcesserSettings,
PayoutProcessorData payoutProcessorSettings,
ApplicationDbContextFactory applicationDbContextFactory,
PullPaymentHostedService pullPaymentHostedService,
BTCPayNetworkProvider btcPayNetworkProvider,
IPluginHookService pluginHookService) : base(logger.CreateLogger($"{payoutProcesserSettings.Processor}:{payoutProcesserSettings.StoreId}:{payoutProcesserSettings.PaymentMethod}"))
IPluginHookService pluginHookService,
EventAggregator eventAggregator) : base(logger.CreateLogger($"{payoutProcessorSettings.Processor}:{payoutProcessorSettings.StoreId}:{payoutProcessorSettings.PaymentMethod}"))
{
_storeRepository = storeRepository;
_PayoutProcesserSettings = payoutProcesserSettings;
PaymentMethodId = _PayoutProcesserSettings.GetPaymentMethodId();
PayoutProcessorSettings = payoutProcessorSettings;
PaymentMethodId = PayoutProcessorSettings.GetPaymentMethodId();
_applicationDbContextFactory = applicationDbContextFactory;
_pullPaymentHostedService = pullPaymentHostedService;
_btcPayNetworkProvider = btcPayNetworkProvider;
_pluginHookService = pluginHookService;
_eventAggregator = eventAggregator;
this.NoLogsOnExit = true;
}
internal override Task[] InitializeTasks()
{
_subscription = _eventAggregator.SubscribeAsync<PayoutEvent>(OnPayoutEvent);
return new[] { CreateLoopTask(Act) };
}
public override Task StopAsync(CancellationToken cancellationToken)
{
_subscription.Dispose();
return base.StopAsync(cancellationToken);
}
private Task OnPayoutEvent(PayoutEvent arg)
{
if (arg.Type == PayoutEvent.PayoutEventType.Approved &&
PayoutProcessorSettings.StoreId == arg.Payout.StoreDataId &&
arg.Payout.GetPaymentMethodId() == PaymentMethodId &&
GetBlob(PayoutProcessorSettings).ProcessNewPayoutsInstantly)
{
SkipInterval();
}
return Task.CompletedTask;
}
protected abstract Task Process(ISupportedPaymentMethod paymentMethod, List<PayoutData> payouts);
private async Task Act()
{
var store = await _storeRepository.FindStore(_PayoutProcesserSettings.StoreId);
_timerCTs = null;
var store = await _storeRepository.FindStore(PayoutProcessorSettings.StoreId);
var paymentMethod = store?.GetEnabledPaymentMethods(_btcPayNetworkProvider)?.FirstOrDefault(
method =>
method.PaymentId == PaymentMethodId);
var blob = GetBlob(_PayoutProcesserSettings);
var blob = GetBlob(PayoutProcessorSettings);
if (paymentMethod is not null)
{
// Allow plugins to do something before the automatic payouts are executed
await _pluginHookService.ApplyFilter("before-automated-payout-processing",
new BeforePayoutFilterData(store, paymentMethod));
await using var context = _applicationDbContextFactory.CreateContext();
var payouts = await PullPaymentHostedService.GetPayouts(
new PullPaymentHostedService.PayoutQuery()
{
States = new[] { PayoutState.AwaitingPayment },
PaymentMethods = new[] { _PayoutProcesserSettings.PaymentMethod },
Stores = new[] { _PayoutProcesserSettings.StoreId }
PaymentMethods = new[] { PayoutProcessorSettings.PaymentMethod },
Stores = new[] {PayoutProcessorSettings.StoreId}
}, context, CancellationToken);
await _pluginHookService.ApplyAction("before-automated-payout-processing",
new BeforePayoutActionData(store, PayoutProcessorSettings, payouts));
if (payouts.Any())
{
Logs.PayServer.LogInformation($"{payouts.Count} found to process. Starting (and after will sleep for {blob.Interval})");
Logs.PayServer.LogInformation(
$"{payouts.Count} found to process. Starting (and after will sleep for {blob.Interval})");
await Process(paymentMethod, payouts);
await context.SaveChangesAsync();
// Allow plugins do to something after automatic payout processing
await _pluginHookService.ApplyFilter("after-automated-payout-processing",
new AfterPayoutFilterData(store, paymentMethod, payouts));
}
// Allow plugins do to something after automatic payout processing
await _pluginHookService.ApplyAction("after-automated-payout-processing",
new AfterPayoutActionData(store, PayoutProcessorSettings, payouts));
}
// Clip interval
@ -110,9 +131,34 @@ public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T
blob.Interval = TimeSpan.FromMinutes(AutomatedPayoutConstants.MinIntervalMinutes);
if (blob.Interval > TimeSpan.FromMinutes(AutomatedPayoutConstants.MaxIntervalMinutes))
blob.Interval = TimeSpan.FromMinutes(AutomatedPayoutConstants.MaxIntervalMinutes);
await Task.Delay(blob.Interval, CancellationToken);
_timerCTs??= new CancellationTokenSource();
try
{
var cts= CancellationTokenSource.CreateLinkedTokenSource(CancellationToken, _timerCTs.Token);
await Task.Delay(blob.Interval, cts.Token);
cts.Dispose();
}
catch (TaskCanceledException)
{
}
}
private CancellationTokenSource _timerCTs;
private IEventAggregatorSubscription _subscription;
private readonly object _intervalLock = new object();
public void SkipInterval()
{
lock (_intervalLock)
{
_timerCTs ??= new CancellationTokenSource();
_timerCTs?.Cancel();
}
}
public static T GetBlob(PayoutProcessorData payoutProcesserSettings)
{
return payoutProcesserSettings.HasTypedBlob<T>().GetBlob();

View file

@ -0,0 +1,6 @@
using System.Collections.Generic;
using BTCPayServer.Data;
namespace BTCPayServer.PayoutProcessors;
public record BeforePayoutActionData(StoreData Store, PayoutProcessorData ProcessorData, IEnumerable<PayoutData> Payouts);

View file

@ -1,16 +0,0 @@
using BTCPayServer.Data;
using BTCPayServer.Payments;
namespace BTCPayServer.PayoutProcessors;
public class BeforePayoutFilterData
{
private readonly StoreData _store;
private readonly ISupportedPaymentMethod _paymentMethod;
public BeforePayoutFilterData(StoreData store, ISupportedPaymentMethod paymentMethod)
{
_store = store;
_paymentMethod = paymentMethod;
}
}

View file

@ -0,0 +1,8 @@
using BTCPayServer.Data;
namespace BTCPayServer.PayoutProcessors.Lightning;
public class LightningAutomatedPayoutBlob : AutomatedPayoutBlob
{
public int? CancelPayoutAfterFailures { get; set; } = null;
}

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
@ -14,16 +15,15 @@ using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Services;
using BTCPayServer.Services.Stores;
using LNURL;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NBitcoin;
using PayoutData = BTCPayServer.Data.PayoutData;
using PayoutProcessorData = BTCPayServer.Data.PayoutProcessorData;
namespace BTCPayServer.PayoutProcessors.Lightning;
public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<AutomatedPayoutBlob>
public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<LightningAutomatedPayoutBlob>
{
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
private readonly LightningClientFactoryService _lightningClientFactoryService;
@ -31,6 +31,7 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Au
private readonly IOptions<LightningNetworkOptions> _options;
private readonly LightningLikePayoutHandler _payoutHandler;
private readonly BTCPayNetwork _network;
private readonly ConcurrentDictionary<string, int> _failedPayoutCounter = new();
public LightningAutomatedPayoutProcessor(
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings,
@ -38,11 +39,13 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Au
IEnumerable<IPayoutHandler> payoutHandlers,
UserService userService,
ILoggerFactory logger, IOptions<LightningNetworkOptions> options,
StoreRepository storeRepository, PayoutProcessorData payoutProcesserSettings,
ApplicationDbContextFactory applicationDbContextFactory, PullPaymentHostedService pullPaymentHostedService, BTCPayNetworkProvider btcPayNetworkProvider,
IPluginHookService pluginHookService) :
base(logger, storeRepository, payoutProcesserSettings, applicationDbContextFactory, pullPaymentHostedService,
btcPayNetworkProvider, pluginHookService)
StoreRepository storeRepository, PayoutProcessorData payoutProcessorSettings,
ApplicationDbContextFactory applicationDbContextFactory,
BTCPayNetworkProvider btcPayNetworkProvider,
IPluginHookService pluginHookService,
EventAggregator eventAggregator) :
base(logger, storeRepository, payoutProcessorSettings, applicationDbContextFactory,
btcPayNetworkProvider, pluginHookService, eventAggregator)
{
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
_lightningClientFactoryService = lightningClientFactoryService;
@ -50,15 +53,16 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Au
_options = options;
_payoutHandler = (LightningLikePayoutHandler)payoutHandlers.FindPayoutHandler(PaymentMethodId);
_network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(_PayoutProcesserSettings.GetPaymentMethodId().CryptoCode);
_network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(PayoutProcessorSettings.GetPaymentMethodId().CryptoCode);
}
protected override async Task Process(ISupportedPaymentMethod paymentMethod, List<PayoutData> payouts)
{
var processorBlob = GetBlob(PayoutProcessorSettings);
var lightningSupportedPaymentMethod = (LightningSupportedPaymentMethod)paymentMethod;
if (lightningSupportedPaymentMethod.IsInternalNode &&
!(await Task.WhenAll((await _storeRepository.GetStoreUsers(_PayoutProcesserSettings.StoreId))
.Where(user => user.StoreRole.ToPermissionSet( _PayoutProcesserSettings.StoreId).Contains(Policies.CanModifyStoreSettings, _PayoutProcesserSettings.StoreId)).Select(user => user.Id)
!(await Task.WhenAll((await _storeRepository.GetStoreUsers(PayoutProcessorSettings.StoreId))
.Where(user => user.StoreRole.ToPermissionSet( PayoutProcessorSettings.StoreId).Contains(Policies.CanModifyStoreSettings, PayoutProcessorSettings.StoreId)).Select(user => user.Id)
.Select(s => _userService.IsAdminUser(s)))).Any(b => b))
{
return;
@ -70,6 +74,7 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Au
foreach (var payoutData in payouts)
{
var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings);
var failed = false;
var claim = await _payoutHandler.ParseClaimDestination(PaymentMethodId, blob.Destination, CancellationToken);
try
{
@ -83,17 +88,40 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Au
{
continue;
}
await TrypayBolt(client, blob, payoutData,
failed = await TrypayBolt(client, blob, payoutData,
lnurlResult.Item1);
break;
case BoltInvoiceClaimDestination item1:
await TrypayBolt(client, blob, payoutData, item1.PaymentRequest);
failed = await TrypayBolt(client, blob, payoutData, item1.PaymentRequest);
break;
}
}
catch (Exception e)
{
Logs.PayServer.LogError(e, $"Could not process payout {payoutData.Id}");
failed = true;
}
if (failed && processorBlob.CancelPayoutAfterFailures is not null)
{
if (!_failedPayoutCounter.TryGetValue(payoutData.Id, out int counter))
{
counter = 0;
}
counter++;
if(counter >= processorBlob.CancelPayoutAfterFailures)
{
payoutData.State = PayoutState.Cancelled;
Logs.PayServer.LogError($"Payout {payoutData.Id} has failed {counter} times, cancelling it");
}
else
{
_failedPayoutCounter.AddOrReplace(payoutData.Id, counter);
}
}
if (payoutData.State == PayoutState.Cancelled)
{
_failedPayoutCounter.TryRemove(payoutData.Id, out _);
}
}
}

View file

@ -17,6 +17,7 @@ public class LightningAutomatedPayoutSenderFactory : IPayoutProcessorFactory
private readonly IServiceProvider _serviceProvider;
private readonly LinkGenerator _linkGenerator;
public LightningAutomatedPayoutSenderFactory(BTCPayNetworkProvider btcPayNetworkProvider, IServiceProvider serviceProvider, LinkGenerator linkGenerator)
{
_btcPayNetworkProvider = btcPayNetworkProvider;

View file

@ -59,7 +59,7 @@ public class UILightningAutomatedPayoutProcessorsController : Controller
}))
.FirstOrDefault();
return View(new LightningTransferViewModel(activeProcessor is null ? new AutomatedPayoutBlob() : OnChainAutomatedPayoutProcessor.GetBlob(activeProcessor)));
return View(new LightningTransferViewModel(activeProcessor is null ? new LightningAutomatedPayoutBlob() : LightningAutomatedPayoutProcessor.GetBlob(activeProcessor)));
}
[HttpPost("~/stores/{storeId}/payout-processors/lightning-automated/{cryptocode}")]
@ -92,7 +92,7 @@ public class UILightningAutomatedPayoutProcessorsController : Controller
}))
.FirstOrDefault();
activeProcessor ??= new PayoutProcessorData();
activeProcessor.HasTypedBlob<AutomatedPayoutBlob>().SetBlob(automatedTransferBlob.ToBlob());
activeProcessor.HasTypedBlob<LightningAutomatedPayoutBlob>().SetBlob(automatedTransferBlob.ToBlob());
activeProcessor.StoreId = storeId;
activeProcessor.PaymentMethod = new PaymentMethodId(cryptoCode, LightningPaymentType.Instance).ToString();
activeProcessor.Processor = _lightningAutomatedPayoutSenderFactory.Processor;
@ -119,16 +119,26 @@ public class UILightningAutomatedPayoutProcessorsController : Controller
}
public LightningTransferViewModel(AutomatedPayoutBlob blob)
public LightningTransferViewModel(LightningAutomatedPayoutBlob blob)
{
IntervalMinutes = blob.Interval.TotalMinutes;
CancelPayoutAfterFailures = blob.CancelPayoutAfterFailures;
ProcessNewPayoutsInstantly = blob.ProcessNewPayoutsInstantly;
}
public bool ProcessNewPayoutsInstantly { get; set; }
public int? CancelPayoutAfterFailures { get; set; }
[Range(AutomatedPayoutConstants.MinIntervalMinutes, AutomatedPayoutConstants.MaxIntervalMinutes)]
public double IntervalMinutes { get; set; }
public AutomatedPayoutBlob ToBlob()
public LightningAutomatedPayoutBlob ToBlob()
{
return new AutomatedPayoutBlob { Interval = TimeSpan.FromMinutes(IntervalMinutes) };
return new LightningAutomatedPayoutBlob {
ProcessNewPayoutsInstantly = ProcessNewPayoutsInstantly,
Interval = TimeSpan.FromMinutes(IntervalMinutes),
CancelPayoutAfterFailures = CancelPayoutAfterFailures};
}
}
}

View file

@ -5,4 +5,5 @@ namespace BTCPayServer.PayoutProcessors.OnChain;
public class OnChainAutomatedPayoutBlob : AutomatedPayoutBlob
{
public int FeeTargetBlock { get; set; } = 1;
public decimal Threshold { get; set; } = 0;
}

View file

@ -12,7 +12,6 @@ using BTCPayServer.Payments;
using BTCPayServer.Services;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBXplorer;
@ -45,8 +44,8 @@ namespace BTCPayServer.PayoutProcessors.OnChain
BTCPayNetworkProvider btcPayNetworkProvider,
IPluginHookService pluginHookService,
IFeeProviderFactory feeProviderFactory) :
base(logger, storeRepository, payoutProcesserSettings, applicationDbContextFactory, pullPaymentHostedService,
btcPayNetworkProvider, pluginHookService)
base(logger, storeRepository, payoutProcesserSettings, applicationDbContextFactory,
btcPayNetworkProvider, pluginHookService, eventAggregator)
{
_explorerClientProvider = explorerClientProvider;
_btcPayWalletProvider = btcPayWalletProvider;
@ -97,13 +96,19 @@ namespace BTCPayServer.PayoutProcessors.OnChain
var changeAddress = await explorerClient.GetUnusedAsync(
storePaymentMethod.AccountDerivation, DerivationFeature.Change, 0, true);
var processorBlob = GetBlob(_PayoutProcesserSettings);
var processorBlob = GetBlob(PayoutProcessorSettings);
var payoutToBlobs = payouts.ToDictionary(data => data, data => data.GetBlob(_btcPayNetworkJsonSerializerSettings));
if (payoutToBlobs.Sum(pair => pair.Value.CryptoAmount) < processorBlob.Threshold)
{
return;
}
var feeRate = await FeeProvider.GetFeeRateAsync(Math.Max(processorBlob.FeeTargetBlock, 1));
var transfersProcessing = new List<PayoutData>();
foreach (var transferRequest in payouts)
var transfersProcessing = new List<KeyValuePair<PayoutData, PayoutBlob>>();
foreach (var transferRequest in payoutToBlobs)
{
var blob = transferRequest.GetBlob(_btcPayNetworkJsonSerializerSettings);
var blob = transferRequest.Value;
if (failedAmount.HasValue && blob.CryptoAmount >= failedAmount)
{
continue;
@ -153,10 +158,10 @@ namespace BTCPayServer.PayoutProcessors.OnChain
try
{
var txHash = workingTx.GetHash();
foreach (PayoutData payoutData in transfersProcessing)
foreach (var payoutData in transfersProcessing)
{
payoutData.State = PayoutState.InProgress;
_bitcoinLikePayoutHandler.SetProofBlob(payoutData,
payoutData.Key.State = PayoutState.InProgress;
_bitcoinLikePayoutHandler.SetProofBlob(payoutData.Key,
new PayoutTransactionOnChainBlob()
{
Accounted = true,
@ -175,12 +180,12 @@ namespace BTCPayServer.PayoutProcessors.OnChain
{
tcs.SetResult(false);
}
var walletId = new WalletId(_PayoutProcesserSettings.StoreId, PaymentMethodId.CryptoCode);
foreach (PayoutData payoutData in transfersProcessing)
var walletId = new WalletId(PayoutProcessorSettings.StoreId, PaymentMethodId.CryptoCode);
foreach (var payoutData in transfersProcessing)
{
await WalletRepository.AddWalletTransactionAttachment(walletId,
txHash,
Attachment.Payout(payoutData.PullPaymentDataId, payoutData.Id));
Attachment.Payout(payoutData.Key.PullPaymentDataId, payoutData.Key.Id));
}
await Task.WhenAny(tcs.Task, task);
}

View file

@ -134,12 +134,17 @@ public class UIOnChainAutomatedPayoutProcessorsController : Controller
public OnChainTransferViewModel(OnChainAutomatedPayoutBlob blob)
{
ProcessNewPayoutsInstantly = blob.ProcessNewPayoutsInstantly;
IntervalMinutes = blob.Interval.TotalMinutes;
FeeTargetBlock = blob.FeeTargetBlock;
Threshold = blob.Threshold;
}
public bool ProcessNewPayoutsInstantly { get; set; }
[Range(1, 1000)]
public int FeeTargetBlock { get; set; }
public decimal Threshold { get; set; }
[Range(AutomatedPayoutConstants.MinIntervalMinutes, AutomatedPayoutConstants.MaxIntervalMinutes)]
public double IntervalMinutes { get; set; }
@ -148,8 +153,10 @@ public class UIOnChainAutomatedPayoutProcessorsController : Controller
{
return new OnChainAutomatedPayoutBlob
{
ProcessNewPayoutsInstantly = ProcessNewPayoutsInstantly,
FeeTargetBlock = FeeTargetBlock,
Interval = TimeSpan.FromMinutes(IntervalMinutes)
Interval = TimeSpan.FromMinutes(IntervalMinutes),
Threshold = Threshold
};
}
}

View file

@ -24,6 +24,7 @@ namespace BTCPayServer.Plugins
// Trigger simple action hook for registered plugins
public async Task ApplyAction(string hook, object args)
{
ActionInvoked?.Invoke(this, (hook, args));
var filters = _actions
.Where(filter => filter.Hook.Equals(hook, StringComparison.InvariantCultureIgnoreCase)).ToList();
foreach (IPluginHookAction pluginHookFilter in filters)
@ -42,6 +43,7 @@ namespace BTCPayServer.Plugins
// Trigger hook on which registered plugins can optionally return modified args or new object back
public async Task<object> ApplyFilter(string hook, object args)
{
FilterInvoked?.Invoke(this, (hook, args));
var filters = _filters
.Where(filter => filter.Hook.Equals(hook, StringComparison.InvariantCultureIgnoreCase)).ToList();
foreach (IPluginHookFilter pluginHookFilter in filters)
@ -58,5 +60,8 @@ namespace BTCPayServer.Plugins
return args;
}
public event EventHandler<(string hook, object args)> ActionInvoked;
public event EventHandler<(string hook, object args)> FilterInvoked;
}
}

View file

@ -18,6 +18,11 @@
<div asp-validation-summary="All" class="text-danger"></div>
}
<form method="post">
<div class="form-check">
<input asp-for="ProcessNewPayoutsInstantly" type="checkbox" class="form-check-input" />
<label asp-for="ProcessNewPayoutsInstantly" class="form-check-label">Process approved payouts instantly</label>
<span asp-validation-for="ProcessNewPayoutsInstantly" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="IntervalMinutes" class="form-label" data-required>Interval</label>
<div class="input-group">
@ -26,6 +31,16 @@
<span asp-validation-for="IntervalMinutes" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label asp-for="CancelPayoutAfterFailures" class="form-label">Max Payout Failure Attempts</label>
<div class="input-group">
<input asp-for="CancelPayoutAfterFailures" min="1" class="form-control" inputmode="numeric" style="max-width:10ch;">
<span class="input-group-text">attempts</span>
<span asp-validation-for="IntervalMinutes" class="text-danger"></span>
</div>
<div class="form-text">If a payout fails this many times, it will be cancelled.</div>
</div>
<button name="command" type="submit" class="btn btn-primary mt-2" value="Save" id="Save">Save</button>
</form>
</div>

View file

@ -5,6 +5,7 @@
ViewData["NavPartialName"] = "../UIStores/_Nav";
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage(StoreNavPages.PayoutProcessors, "On-Chain Payout Processor", Context.GetStoreData().Id);
var cryptoCode = Context.GetRouteValue("cryptocode")?.ToString();
}
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
@ -17,6 +18,11 @@
<div asp-validation-summary="All" class="text-danger"></div>
}
<form method="post">
<div class="form-check">
<input asp-for="ProcessNewPayoutsInstantly" type="checkbox" class="form-check-input" />
<label asp-for="ProcessNewPayoutsInstantly" class="form-check-label">Process approved payouts instantly</label>
<span asp-validation-for="ProcessNewPayoutsInstantly" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="IntervalMinutes" class="form-label" data-required>Interval</label>
<div class="input-group">
@ -24,7 +30,7 @@
<span class="input-group-text">minutes</span>
<span asp-validation-for="IntervalMinutes" class="text-danger"></span>
</div>
</div>
</div>
<div class="form-group">
<label asp-for="FeeTargetBlock" class="form-label" data-required>Fee block target</label>
<div class="input-group">
@ -33,6 +39,15 @@
<span asp-validation-for="FeeTargetBlock" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label asp-for="Threshold" class="form-label" data-required>Threshold</label>
<div class="input-group">
<input asp-for="Threshold" class="form-control" min="0" inputmode="numeric" style="max-width:10ch;">
<span class="input-group-text">@cryptoCode</span>
<span asp-validation-for="FeeTargetBlock" class="text-danger"></span>
</div>
<div class="form-text">Only process payouts when this payout sum is reached.</div>
</div>
<button name="command" type="submit" class="btn btn-primary mt-2" value="Save" id="Save">Save</button>
</form>
</div>

View file

@ -582,6 +582,16 @@
"$ref": "#/components/schemas/TimeSpanSeconds"
}
]
},
"cancelPayoutAfterFailures": {
"description": "How many failures should the processor tolerate before cancelling the payout",
"type": "number",
"nullable": true
},
"processNewPayoutsInstantly": {
"description": "Skip the interval when ane eligible payout has been approved (or created with pre-approval)",
"type": "boolean",
"default": false
}
}
},
@ -600,6 +610,16 @@
"$ref": "#/components/schemas/TimeSpanSeconds"
}
]
},
"cancelPayoutAfterFailures": {
"description": "How many failures should the processor tolerate before cancelling the payout",
"type": "number",
"nullable": true
},
"processNewPayoutsInstantly": {
"description": "Skip the interval when ane eligible payout has been approved (or created with pre-approval)",
"type": "boolean",
"default": false
}
}
},
@ -619,6 +639,18 @@
"$ref": "#/components/schemas/TimeSpanSeconds"
}
]
},
"threshold": {
"type": "string",
"format": "decimal",
"minimum": 0,
"description": "Only process payouts when this payout sum is reached.",
"example": "0.1"
},
"processNewPayoutsInstantly": {
"description": "Skip the interval when ane eligible payout has been approved (or created with pre-approval)",
"type": "boolean",
"default": false
}
}
},
@ -641,6 +673,18 @@
"$ref": "#/components/schemas/TimeSpanSeconds"
}
]
},
"threshold": {
"type": "string",
"format": "decimal",
"minimum": 0,
"description": "Only process payouts when this payout sum is reached.",
"example": "0.1"
},
"processNewPayoutsInstantly": {
"description": "Skip the interval when ane eligible payout has been approved (or created with pre-approval)",
"type": "boolean",
"default": false
}
}
}