mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-03-12 10:30:47 +01:00
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:
parent
b1c81b696f
commit
4063a5aaee
26 changed files with 441 additions and 107 deletions
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
@ -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)]
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 =>
|
||||||
|
|
7
BTCPayServer/PayoutProcessors/AfterPayoutActionData.cs
Normal file
7
BTCPayServer/PayoutProcessors/AfterPayoutActionData.cs
Normal 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);
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
6
BTCPayServer/PayoutProcessors/BeforePayoutActionData.cs
Normal file
6
BTCPayServer/PayoutProcessors/BeforePayoutActionData.cs
Normal 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);
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
|
||||||
|
namespace BTCPayServer.PayoutProcessors.Lightning;
|
||||||
|
|
||||||
|
public class LightningAutomatedPayoutBlob : AutomatedPayoutBlob
|
||||||
|
{
|
||||||
|
public int? CancelPayoutAfterFailures { get; set; } = null;
|
||||||
|
}
|
|
@ -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 _);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue