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; using System.Threading.Tasks;
namespace BTCPayServer.Abstractions.Contracts namespace BTCPayServer.Abstractions.Contracts
@ -6,5 +7,8 @@ namespace BTCPayServer.Abstractions.Contracts
{ {
Task ApplyAction(string hook, object args); Task ApplyAction(string hook, object args);
Task<object> ApplyFilter(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))] [JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
public TimeSpan IntervalSeconds { get; set; } 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 TimeSpan IntervalSeconds { get; set; }
public int? FeeBlockTarget { 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 class AutomatedPayoutBlob
{ {
public TimeSpan Interval { get; set; } = TimeSpan.FromHours(1); public TimeSpan Interval { get; set; } = TimeSpan.FromHours(1);
public bool ProcessNewPayoutsInstantly { get; set; }
} }
public class PayoutProcessorData : IHasBlobUntyped public class PayoutProcessorData : IHasBlobUntyped
{ {

View file

@ -17,6 +17,7 @@ using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning; using BTCPayServer.Payments.Lightning;
using BTCPayServer.PayoutProcessors; using BTCPayServer.PayoutProcessors;
using BTCPayServer.PayoutProcessors.OnChain; using BTCPayServer.PayoutProcessors.OnChain;
using BTCPayServer.Plugins;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Custodian.Client.MockCustodian; using BTCPayServer.Services.Custodian.Client.MockCustodian;
using BTCPayServer.Services.Notifications; using BTCPayServer.Services.Notifications;
@ -3669,9 +3670,12 @@ namespace BTCPayServer.Tests
Assert.Single(payouts.Where(data => data.State == PayoutState.InProgress)); 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, 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); tester.ExplorerClient.Network.NBitcoinNetwork), Money.Coins(0.01m) + fee);
await tester.WaitForEvent<NewOnChainTransactionEvent>(null, correctEvent: ev => ev.NewTransactionEvent.TransactionData.TransactionHash == txid); }, correctEvent: ev => ev.NewTransactionEvent.TransactionData.TransactionHash == txid);
await tester.PayTester.GetService<PayoutProcessorService>().Restart(new PayoutProcessorService.PayoutProcessorQuery(admin.StoreId, "BTC")); await tester.PayTester.GetService<PayoutProcessorService>().Restart(new PayoutProcessorService.PayoutProcessorQuery(admin.StoreId, "BTC"));
await TestUtils.EventuallyAsync(async () => await TestUtils.EventuallyAsync(async () =>
{ {
@ -3679,6 +3683,122 @@ namespace BTCPayServer.Tests
payouts = await adminClient.GetStorePayouts(admin.StoreId); payouts = await adminClient.GetStorePayouts(admin.StoreId);
Assert.Empty(payouts.Where(data => data.State != PayoutState.InProgress)); 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)] [Fact(Timeout = 60 * 2 * 1000)]

View file

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

View file

@ -59,7 +59,9 @@ namespace BTCPayServer.Controllers.Greenfield
{ {
FeeBlockTarget = blob.FeeTargetBlock, FeeBlockTarget = blob.FeeTargetBlock,
PaymentMethod = data.PaymentMethod, 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() return new OnChainAutomatedPayoutBlob()
{ {
FeeTargetBlock = data.FeeBlockTarget ?? 1, 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() }; var proofBlob = new PayoutLightningBlob() { PaymentHash = bolt11PaymentRequest.PaymentHash.ToString() };
try try
{ {

View file

@ -389,7 +389,7 @@ namespace BTCPayServer.HostedServices
{ {
try 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) var payout = await ctx.Payouts.Include(p => p.PullPaymentData).Where(p => p.Id == req.PayoutId)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (payout is null) if (payout is null)
@ -440,6 +440,7 @@ namespace BTCPayServer.HostedServices
payout.SetBlob(payoutBlob, _jsonSerializerSettings); payout.SetBlob(payoutBlob, _jsonSerializerSettings);
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
_eventAggregator.Publish(new PayoutEvent(PayoutEvent.PayoutEventType.Approved, payout));
req.Completion.SetResult(new PayoutApproval.ApprovalResult(PayoutApproval.Result.Ok, payoutBlob.CryptoAmount)); req.Completion.SetResult(new PayoutApproval.ApprovalResult(PayoutApproval.Result.Ok, payoutBlob.CryptoAmount));
} }
catch (Exception ex) catch (Exception ex)
@ -604,6 +605,8 @@ namespace BTCPayServer.HostedServices
{ {
await payoutHandler.TrackClaim(req.ClaimRequest, payout); await payoutHandler.TrackClaim(req.ClaimRequest, payout);
await ctx.SaveChangesAsync(); 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)) if (req.ClaimRequest.PreApprove.GetValueOrDefault(ppBlob?.AutoApproveClaims is true))
{ {
payout.StoreData = await ctx.Stores.FindAsync(payout.StoreDataId); 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), await _notificationSender.SendNotification(new StoreScope(payout.StoreDataId),
new PayoutNotification() new PayoutNotification()
{ {
@ -888,4 +891,14 @@ namespace BTCPayServer.HostedServices
public string StoreId { get; set; } public string StoreId { get; set; }
public bool? PreApprove { 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.TryAddSingleton<AppService>();
services.AddTransient<PluginService>(); services.AddTransient<PluginService>();
services.AddSingleton<IPluginHookService, PluginHookService>(); services.AddSingleton<PluginHookService>();
services.AddSingleton<IPluginHookService, PluginHookService>(provider => provider.GetService<PluginHookService>());
services.TryAddTransient<Safe>(); services.TryAddTransient<Safe>();
services.TryAddTransient<DisplayFormatter>(); services.TryAddTransient<DisplayFormatter>();
services.TryAddSingleton<Ganss.XSS.HtmlSanitizer>(o => 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;
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.Models; using BTCPayServer.Client.Models;
@ -20,89 +21,109 @@ namespace BTCPayServer.PayoutProcessors;
public class AutomatedPayoutConstants public class AutomatedPayoutConstants
{ {
public const double MinIntervalMinutes = 10.0; public const double MinIntervalMinutes = 1.0;
public const double MaxIntervalMinutes = 60.0; public const double MaxIntervalMinutes = 24 * 60; //1 day
public static void ValidateInterval(ModelStateDictionary modelState, TimeSpan timeSpan, string parameterName) public static void ValidateInterval(ModelStateDictionary modelState, TimeSpan timeSpan, string parameterName)
{ {
if (timeSpan < TimeSpan.FromMinutes(AutomatedPayoutConstants.MinIntervalMinutes)) 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)) 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 public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T : AutomatedPayoutBlob
{ {
protected readonly StoreRepository _storeRepository; protected readonly StoreRepository _storeRepository;
protected readonly PayoutProcessorData _PayoutProcesserSettings; protected readonly PayoutProcessorData PayoutProcessorSettings;
protected readonly ApplicationDbContextFactory _applicationDbContextFactory; protected readonly ApplicationDbContextFactory _applicationDbContextFactory;
private readonly PullPaymentHostedService _pullPaymentHostedService;
protected readonly BTCPayNetworkProvider _btcPayNetworkProvider; protected readonly BTCPayNetworkProvider _btcPayNetworkProvider;
protected readonly PaymentMethodId PaymentMethodId; protected readonly PaymentMethodId PaymentMethodId;
private readonly IPluginHookService _pluginHookService; private readonly IPluginHookService _pluginHookService;
private readonly EventAggregator _eventAggregator;
protected BaseAutomatedPayoutProcessor( protected BaseAutomatedPayoutProcessor(
ILoggerFactory logger, ILoggerFactory logger,
StoreRepository storeRepository, StoreRepository storeRepository,
PayoutProcessorData payoutProcesserSettings, PayoutProcessorData payoutProcessorSettings,
ApplicationDbContextFactory applicationDbContextFactory, ApplicationDbContextFactory applicationDbContextFactory,
PullPaymentHostedService pullPaymentHostedService,
BTCPayNetworkProvider btcPayNetworkProvider, 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; _storeRepository = storeRepository;
_PayoutProcesserSettings = payoutProcesserSettings; PayoutProcessorSettings = payoutProcessorSettings;
PaymentMethodId = _PayoutProcesserSettings.GetPaymentMethodId(); PaymentMethodId = PayoutProcessorSettings.GetPaymentMethodId();
_applicationDbContextFactory = applicationDbContextFactory; _applicationDbContextFactory = applicationDbContextFactory;
_pullPaymentHostedService = pullPaymentHostedService;
_btcPayNetworkProvider = btcPayNetworkProvider; _btcPayNetworkProvider = btcPayNetworkProvider;
_pluginHookService = pluginHookService; _pluginHookService = pluginHookService;
_eventAggregator = eventAggregator;
this.NoLogsOnExit = true; this.NoLogsOnExit = true;
} }
internal override Task[] InitializeTasks() internal override Task[] InitializeTasks()
{ {
_subscription = _eventAggregator.SubscribeAsync<PayoutEvent>(OnPayoutEvent);
return new[] { CreateLoopTask(Act) }; 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); protected abstract Task Process(ISupportedPaymentMethod paymentMethod, List<PayoutData> payouts);
private async Task Act() 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( var paymentMethod = store?.GetEnabledPaymentMethods(_btcPayNetworkProvider)?.FirstOrDefault(
method => method =>
method.PaymentId == PaymentMethodId); method.PaymentId == PaymentMethodId);
var blob = GetBlob(_PayoutProcesserSettings); var blob = GetBlob(PayoutProcessorSettings);
if (paymentMethod is not null) 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(); await using var context = _applicationDbContextFactory.CreateContext();
var payouts = await PullPaymentHostedService.GetPayouts( var payouts = await PullPaymentHostedService.GetPayouts(
new PullPaymentHostedService.PayoutQuery() new PullPaymentHostedService.PayoutQuery()
{ {
States = new[] { PayoutState.AwaitingPayment }, States = new[] { PayoutState.AwaitingPayment },
PaymentMethods = new[] { _PayoutProcesserSettings.PaymentMethod }, PaymentMethods = new[] { PayoutProcessorSettings.PaymentMethod },
Stores = new[] { _PayoutProcesserSettings.StoreId } Stores = new[] {PayoutProcessorSettings.StoreId}
}, context, CancellationToken); }, context, CancellationToken);
await _pluginHookService.ApplyAction("before-automated-payout-processing",
new BeforePayoutActionData(store, PayoutProcessorSettings, payouts));
if (payouts.Any()) 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 Process(paymentMethod, payouts);
await context.SaveChangesAsync(); await context.SaveChangesAsync();
}
// Allow plugins do to something after automatic payout processing // Allow plugins do to something after automatic payout processing
await _pluginHookService.ApplyFilter("after-automated-payout-processing", await _pluginHookService.ApplyAction("after-automated-payout-processing",
new AfterPayoutFilterData(store, paymentMethod, payouts)); new AfterPayoutActionData(store, PayoutProcessorSettings, payouts));
}
} }
// Clip interval // Clip interval
@ -110,8 +131,33 @@ public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T
blob.Interval = TimeSpan.FromMinutes(AutomatedPayoutConstants.MinIntervalMinutes); blob.Interval = TimeSpan.FromMinutes(AutomatedPayoutConstants.MinIntervalMinutes);
if (blob.Interval > TimeSpan.FromMinutes(AutomatedPayoutConstants.MaxIntervalMinutes)) if (blob.Interval > TimeSpan.FromMinutes(AutomatedPayoutConstants.MaxIntervalMinutes))
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) public static T GetBlob(PayoutProcessorData payoutProcesserSettings)
{ {

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

View file

@ -59,7 +59,7 @@ public class UILightningAutomatedPayoutProcessorsController : Controller
})) }))
.FirstOrDefault(); .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}")] [HttpPost("~/stores/{storeId}/payout-processors/lightning-automated/{cryptocode}")]
@ -92,7 +92,7 @@ public class UILightningAutomatedPayoutProcessorsController : Controller
})) }))
.FirstOrDefault(); .FirstOrDefault();
activeProcessor ??= new PayoutProcessorData(); activeProcessor ??= new PayoutProcessorData();
activeProcessor.HasTypedBlob<AutomatedPayoutBlob>().SetBlob(automatedTransferBlob.ToBlob()); activeProcessor.HasTypedBlob<LightningAutomatedPayoutBlob>().SetBlob(automatedTransferBlob.ToBlob());
activeProcessor.StoreId = storeId; activeProcessor.StoreId = storeId;
activeProcessor.PaymentMethod = new PaymentMethodId(cryptoCode, LightningPaymentType.Instance).ToString(); activeProcessor.PaymentMethod = new PaymentMethodId(cryptoCode, LightningPaymentType.Instance).ToString();
activeProcessor.Processor = _lightningAutomatedPayoutSenderFactory.Processor; activeProcessor.Processor = _lightningAutomatedPayoutSenderFactory.Processor;
@ -119,16 +119,26 @@ public class UILightningAutomatedPayoutProcessorsController : Controller
} }
public LightningTransferViewModel(AutomatedPayoutBlob blob) public LightningTransferViewModel(LightningAutomatedPayoutBlob blob)
{ {
IntervalMinutes = blob.Interval.TotalMinutes; 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)] [Range(AutomatedPayoutConstants.MinIntervalMinutes, AutomatedPayoutConstants.MaxIntervalMinutes)]
public double IntervalMinutes { get; set; } 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 class OnChainAutomatedPayoutBlob : AutomatedPayoutBlob
{ {
public int FeeTargetBlock { get; set; } = 1; 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;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets; using BTCPayServer.Services.Wallets;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NBitcoin; using NBitcoin;
using NBXplorer; using NBXplorer;
@ -45,8 +44,8 @@ namespace BTCPayServer.PayoutProcessors.OnChain
BTCPayNetworkProvider btcPayNetworkProvider, BTCPayNetworkProvider btcPayNetworkProvider,
IPluginHookService pluginHookService, IPluginHookService pluginHookService,
IFeeProviderFactory feeProviderFactory) : IFeeProviderFactory feeProviderFactory) :
base(logger, storeRepository, payoutProcesserSettings, applicationDbContextFactory, pullPaymentHostedService, base(logger, storeRepository, payoutProcesserSettings, applicationDbContextFactory,
btcPayNetworkProvider, pluginHookService) btcPayNetworkProvider, pluginHookService, eventAggregator)
{ {
_explorerClientProvider = explorerClientProvider; _explorerClientProvider = explorerClientProvider;
_btcPayWalletProvider = btcPayWalletProvider; _btcPayWalletProvider = btcPayWalletProvider;
@ -97,13 +96,19 @@ namespace BTCPayServer.PayoutProcessors.OnChain
var changeAddress = await explorerClient.GetUnusedAsync( var changeAddress = await explorerClient.GetUnusedAsync(
storePaymentMethod.AccountDerivation, DerivationFeature.Change, 0, true); 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 feeRate = await FeeProvider.GetFeeRateAsync(Math.Max(processorBlob.FeeTargetBlock, 1));
var transfersProcessing = new List<PayoutData>(); var transfersProcessing = new List<KeyValuePair<PayoutData, PayoutBlob>>();
foreach (var transferRequest in payouts) foreach (var transferRequest in payoutToBlobs)
{ {
var blob = transferRequest.GetBlob(_btcPayNetworkJsonSerializerSettings); var blob = transferRequest.Value;
if (failedAmount.HasValue && blob.CryptoAmount >= failedAmount) if (failedAmount.HasValue && blob.CryptoAmount >= failedAmount)
{ {
continue; continue;
@ -153,10 +158,10 @@ namespace BTCPayServer.PayoutProcessors.OnChain
try try
{ {
var txHash = workingTx.GetHash(); var txHash = workingTx.GetHash();
foreach (PayoutData payoutData in transfersProcessing) foreach (var payoutData in transfersProcessing)
{ {
payoutData.State = PayoutState.InProgress; payoutData.Key.State = PayoutState.InProgress;
_bitcoinLikePayoutHandler.SetProofBlob(payoutData, _bitcoinLikePayoutHandler.SetProofBlob(payoutData.Key,
new PayoutTransactionOnChainBlob() new PayoutTransactionOnChainBlob()
{ {
Accounted = true, Accounted = true,
@ -175,12 +180,12 @@ namespace BTCPayServer.PayoutProcessors.OnChain
{ {
tcs.SetResult(false); tcs.SetResult(false);
} }
var walletId = new WalletId(_PayoutProcesserSettings.StoreId, PaymentMethodId.CryptoCode); var walletId = new WalletId(PayoutProcessorSettings.StoreId, PaymentMethodId.CryptoCode);
foreach (PayoutData payoutData in transfersProcessing) foreach (var payoutData in transfersProcessing)
{ {
await WalletRepository.AddWalletTransactionAttachment(walletId, await WalletRepository.AddWalletTransactionAttachment(walletId,
txHash, txHash,
Attachment.Payout(payoutData.PullPaymentDataId, payoutData.Id)); Attachment.Payout(payoutData.Key.PullPaymentDataId, payoutData.Key.Id));
} }
await Task.WhenAny(tcs.Task, task); await Task.WhenAny(tcs.Task, task);
} }

View file

@ -134,12 +134,17 @@ public class UIOnChainAutomatedPayoutProcessorsController : Controller
public OnChainTransferViewModel(OnChainAutomatedPayoutBlob blob) public OnChainTransferViewModel(OnChainAutomatedPayoutBlob blob)
{ {
ProcessNewPayoutsInstantly = blob.ProcessNewPayoutsInstantly;
IntervalMinutes = blob.Interval.TotalMinutes; IntervalMinutes = blob.Interval.TotalMinutes;
FeeTargetBlock = blob.FeeTargetBlock; FeeTargetBlock = blob.FeeTargetBlock;
Threshold = blob.Threshold;
} }
public bool ProcessNewPayoutsInstantly { get; set; }
[Range(1, 1000)] [Range(1, 1000)]
public int FeeTargetBlock { get; set; } public int FeeTargetBlock { get; set; }
public decimal Threshold { get; set; }
[Range(AutomatedPayoutConstants.MinIntervalMinutes, AutomatedPayoutConstants.MaxIntervalMinutes)] [Range(AutomatedPayoutConstants.MinIntervalMinutes, AutomatedPayoutConstants.MaxIntervalMinutes)]
public double IntervalMinutes { get; set; } public double IntervalMinutes { get; set; }
@ -148,8 +153,10 @@ public class UIOnChainAutomatedPayoutProcessorsController : Controller
{ {
return new OnChainAutomatedPayoutBlob return new OnChainAutomatedPayoutBlob
{ {
ProcessNewPayoutsInstantly = ProcessNewPayoutsInstantly,
FeeTargetBlock = FeeTargetBlock, 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 // Trigger simple action hook for registered plugins
public async Task ApplyAction(string hook, object args) public async Task ApplyAction(string hook, object args)
{ {
ActionInvoked?.Invoke(this, (hook, args));
var filters = _actions var filters = _actions
.Where(filter => filter.Hook.Equals(hook, StringComparison.InvariantCultureIgnoreCase)).ToList(); .Where(filter => filter.Hook.Equals(hook, StringComparison.InvariantCultureIgnoreCase)).ToList();
foreach (IPluginHookAction pluginHookFilter in filters) 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 // Trigger hook on which registered plugins can optionally return modified args or new object back
public async Task<object> ApplyFilter(string hook, object args) public async Task<object> ApplyFilter(string hook, object args)
{ {
FilterInvoked?.Invoke(this, (hook, args));
var filters = _filters var filters = _filters
.Where(filter => filter.Hook.Equals(hook, StringComparison.InvariantCultureIgnoreCase)).ToList(); .Where(filter => filter.Hook.Equals(hook, StringComparison.InvariantCultureIgnoreCase)).ToList();
foreach (IPluginHookFilter pluginHookFilter in filters) foreach (IPluginHookFilter pluginHookFilter in filters)
@ -58,5 +60,8 @@ namespace BTCPayServer.Plugins
return args; 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> <div asp-validation-summary="All" class="text-danger"></div>
} }
<form method="post"> <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"> <div class="form-group">
<label asp-for="IntervalMinutes" class="form-label" data-required>Interval</label> <label asp-for="IntervalMinutes" class="form-label" data-required>Interval</label>
<div class="input-group"> <div class="input-group">
@ -26,6 +31,16 @@
<span asp-validation-for="IntervalMinutes" class="text-danger"></span> <span asp-validation-for="IntervalMinutes" class="text-danger"></span>
</div> </div>
</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> <button name="command" type="submit" class="btn btn-primary mt-2" value="Save" id="Save">Save</button>
</form> </form>
</div> </div>

View file

@ -5,6 +5,7 @@
ViewData["NavPartialName"] = "../UIStores/_Nav"; ViewData["NavPartialName"] = "../UIStores/_Nav";
Layout = "../Shared/_NavLayout.cshtml"; Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage(StoreNavPages.PayoutProcessors, "On-Chain Payout Processor", Context.GetStoreData().Id); ViewData.SetActivePage(StoreNavPages.PayoutProcessors, "On-Chain Payout Processor", Context.GetStoreData().Id);
var cryptoCode = Context.GetRouteValue("cryptocode")?.ToString();
} }
<div class="row"> <div class="row">
<div class="col-xl-8 col-xxl-constrain"> <div class="col-xl-8 col-xxl-constrain">
@ -17,6 +18,11 @@
<div asp-validation-summary="All" class="text-danger"></div> <div asp-validation-summary="All" class="text-danger"></div>
} }
<form method="post"> <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"> <div class="form-group">
<label asp-for="IntervalMinutes" class="form-label" data-required>Interval</label> <label asp-for="IntervalMinutes" class="form-label" data-required>Interval</label>
<div class="input-group"> <div class="input-group">
@ -33,6 +39,15 @@
<span asp-validation-for="FeeTargetBlock" class="text-danger"></span> <span asp-validation-for="FeeTargetBlock" class="text-danger"></span>
</div> </div>
</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> <button name="command" type="submit" class="btn btn-primary mt-2" value="Save" id="Save">Save</button>
</form> </form>
</div> </div>

View file

@ -582,6 +582,16 @@
"$ref": "#/components/schemas/TimeSpanSeconds" "$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" "$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" "$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" "$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
} }
} }
} }