Refactor Payouts (#4032)

Co-authored-by: d11n <mail@dennisreimann.de>
This commit is contained in:
Andrew Camilleri 2022-08-17 09:45:51 +02:00 committed by GitHub
parent d6ae34929e
commit d0b26e9f69
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 435 additions and 106 deletions

View file

@ -21,4 +21,9 @@ public class PayoutProcessorData
.HasOne(o => o.Store)
.WithMany(data => data.PayoutProcessors).OnDelete(DeleteBehavior.Cascade);
}
public override string ToString()
{
return $"{Processor} {PaymentMethod} {StoreId}";
}
}

View file

@ -14,6 +14,7 @@ using BTCPayServer.Events;
using BTCPayServer.Lightning;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Services.Custodian.Client.MockCustodian;
using BTCPayServer.Services;
using BTCPayServer.Services.Notifications;
@ -2455,6 +2456,50 @@ namespace BTCPayServer.Tests
await newUserBasicClient.GetCurrentUser();
}
[Fact(Timeout = 60 * 2 * 1000)]
[Trait("Integration", "Integration")]
[Trait("Lightning", "Lightning")]
public async Task CanUseLNPayoutProcessor()
{
LightningPendingPayoutListener.SecondsDelay = 0;
using var tester = CreateServerTester();
tester.ActivateLightning();
await tester.StartAsync();
await tester.EnsureChannelsSetup();
var admin = tester.NewAccount();
await admin.GrantAccessAsync(true);
var adminClient = await admin.CreateClient(Policies.Unrestricted);
admin.RegisterLightningNode("BTC", LightningConnectionType.LndREST);
var payoutAmount = LightMoney.Satoshis(1000);
var inv = await tester.MerchantLnd.Client.CreateInvoice(payoutAmount, "Donation to merchant", TimeSpan.FromHours(1), default);
var resp = await tester.CustomerLightningD.Pay(inv.BOLT11);
Assert.Equal(PayResult.Ok, resp.Result);
var customerInvoice = await tester.CustomerLightningD.CreateInvoice(LightMoney.FromUnit(10, LightMoneyUnit.Satoshi),
Guid.NewGuid().ToString(), TimeSpan.FromDays(40));
var payout = await adminClient.CreatePayout(admin.StoreId,
new CreatePayoutThroughStoreRequest()
{
Approved = true, PaymentMethod = "BTC_LightningNetwork", Destination = customerInvoice.BOLT11
});
Assert.Empty(await adminClient.GetStoreLightningAutomatedPayoutProcessors(admin.StoreId, "BTC_LightningNetwork"));
await adminClient.UpdateStoreLightningAutomatedPayoutProcessors(admin.StoreId, "BTC_LightningNetwork",
new LightningAutomatedPayoutSettings() {IntervalSeconds = TimeSpan.FromSeconds(2)});
Assert.Equal(2, Assert.Single( await adminClient.GetStoreLightningAutomatedPayoutProcessors(admin.StoreId, "BTC_LightningNetwork")).IntervalSeconds.TotalSeconds);
await TestUtils.EventuallyAsync(async () =>
{
var payoutC =
(await adminClient.GetStorePayouts(admin.StoreId, false)).Single(data => data.Id == payout.Id);
Assert.Equal(PayoutState.Completed , payoutC.State);
});
}
[Fact(Timeout = 60 * 2 * 1000)]
[Trait("Integration", "Integration")]
public async Task CanUsePayoutProcessorsThroughAPI()

View file

@ -0,0 +1,16 @@
#!/bin/bash
PREIMAGE=$(cat /dev/urandom | tr -dc 'a-f0-9' | fold -w 64 | head -n 1)
HASH=`node -e "console.log(require('crypto').createHash('sha256').update(Buffer.from('$PREIMAGE', 'hex')).digest('hex'))"`
PAYREQ=$(./docker-customer-lncli.sh addholdinvoice $HASH $@ | jq -r ".payment_request")
echo "HASH: $HASH"
echo "PREIMAGE: $PREIMAGE"
echo "PAY REQ: $PAYREQ"
echo ""
echo "SETTLE: ./docker-customer-lncli.sh settleinvoice $PREIMAGE"
echo "CANCEL: ./docker-customer-lncli.sh cancelinvoice $HASH"
echo "LOOKUP: ./docker-customer-lncli.sh lookupinvoice $HASH"
echo ""
echo "TRACK: ./docker-merchant-lncli.sh trackpayment $HASH"
echo "PAY: ./docker-merchant-lncli.sh payinvoice $PAYREQ"

View file

@ -180,6 +180,15 @@ namespace BTCPayServer.Controllers.Greenfield
return Ok(CreatePullPaymentData(pp));
}
private PayoutState[]? GetStateFilter(bool includeCancelled) =>
includeCancelled
? null
: new[]
{
PayoutState.Completed, PayoutState.AwaitingApproval, PayoutState.AwaitingPayment,
PayoutState.InProgress
};
[HttpGet("~/api/v1/pull-payments/{pullPaymentId}/payouts")]
[AllowAnonymous]
public async Task<IActionResult> GetPayouts(string pullPaymentId, bool includeCancelled = false)
@ -189,7 +198,12 @@ namespace BTCPayServer.Controllers.Greenfield
var pp = await _pullPaymentService.GetPullPayment(pullPaymentId, true);
if (pp is null)
return PullPaymentNotFound();
var payouts = pp.Payouts.Where(p => p.State != PayoutState.Cancelled || includeCancelled).ToList();
var payouts =await _pullPaymentService.GetPayouts(new PullPaymentHostedService.PayoutQuery()
{
PullPayments = new[] {pullPaymentId},
States = GetStateFilter(includeCancelled)
});
return base.Ok(payouts
.Select(ToModel).ToList());
}
@ -201,10 +215,13 @@ namespace BTCPayServer.Controllers.Greenfield
if (payoutId is null)
return PayoutNotFound();
await using var ctx = _dbContextFactory.CreateContext();
var pp = await _pullPaymentService.GetPullPayment(pullPaymentId, true);
if (pp is null)
return PullPaymentNotFound();
var payout = pp.Payouts.FirstOrDefault(p => p.Id == payoutId);
var payout = (await _pullPaymentService.GetPayouts(new PullPaymentHostedService.PayoutQuery()
{
PullPayments = new[] {pullPaymentId}, PayoutIds = new[] {payoutId}
})).FirstOrDefault();
if (payout is null)
return PayoutNotFound();
return base.Ok(ToModel(payout));
@ -392,10 +409,13 @@ namespace BTCPayServer.Controllers.Greenfield
[Authorize(Policy = Policies.CanManagePullPayments, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetStorePayouts(string storeId, bool includeCancelled = false)
{
await using var ctx = _dbContextFactory.CreateContext();
var payouts = await ctx.Payouts
.Where(p => p.StoreDataId == storeId && (p.State != PayoutState.Cancelled || includeCancelled))
.ToListAsync();
var payouts = await _pullPaymentService.GetPayouts(new PullPaymentHostedService.PayoutQuery()
{
Stores = new[] {storeId},
States = GetStateFilter(includeCancelled)
});
return base.Ok(payouts
.Select(ToModel).ToList());
}

View file

@ -5,6 +5,7 @@ using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data.Data;
using BTCPayServer.Payments;
using BTCPayServer.PayoutProcessors;
using BTCPayServer.PayoutProcessors.Lightning;
using BTCPayServer.PayoutProcessors.Settings;
@ -36,6 +37,7 @@ namespace BTCPayServer.Controllers.Greenfield
public async Task<IActionResult> GetStoreLightningAutomatedPayoutProcessors(
string storeId, string? paymentMethod)
{
paymentMethod = !string.IsNullOrEmpty(paymentMethod) ? PaymentMethodId.Parse(paymentMethod).ToString() : null;
var configured =
await _payoutProcessorService.GetProcessors(
new PayoutProcessorService.PayoutProcessorQuery()
@ -68,6 +70,7 @@ namespace BTCPayServer.Controllers.Greenfield
public async Task<IActionResult> UpdateStoreLightningAutomatedPayoutProcessor(
string storeId, string paymentMethod, LightningAutomatedPayoutSettings request)
{
paymentMethod = PaymentMethodId.Parse(paymentMethod).ToString();
var activeProcessor =
(await _payoutProcessorService.GetProcessors(
new PayoutProcessorService.PayoutProcessorQuery()

View file

@ -5,6 +5,7 @@ using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data.Data;
using BTCPayServer.Payments;
using BTCPayServer.PayoutProcessors;
using BTCPayServer.PayoutProcessors.OnChain;
using BTCPayServer.PayoutProcessors.Settings;
@ -36,6 +37,7 @@ namespace BTCPayServer.Controllers.Greenfield
public async Task<IActionResult> GetStoreOnChainAutomatedPayoutProcessors(
string storeId, string? paymentMethod)
{
paymentMethod = !string.IsNullOrEmpty(paymentMethod) ? PaymentMethodId.Parse(paymentMethod).ToString() : null;
var configured =
await _payoutProcessorService.GetProcessors(
new PayoutProcessorService.PayoutProcessorQuery()
@ -68,6 +70,7 @@ namespace BTCPayServer.Controllers.Greenfield
public async Task<IActionResult> UpdateStoreOnchainAutomatedPayoutProcessor(
string storeId, string paymentMethod, OnChainAutomatedPayoutSettings request)
{
paymentMethod = PaymentMethodId.Parse(paymentMethod).ToString();
var activeProcessor =
(await _payoutProcessorService.GetProcessors(
new PayoutProcessorService.PayoutProcessorQuery()

View file

@ -103,9 +103,9 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
{
return null;
}
var raw = JObject.Parse(Encoding.UTF8.GetString(payout.Proof));
if (raw.TryGetValue("proofType", StringComparison.InvariantCultureIgnoreCase, out var proofType) &&
proofType.Value<string>() == ManualPayoutProof.Type)
ParseProofType(payout.Proof, out var raw, out var proofType);
if (proofType == ManualPayoutProof.Type)
{
return raw.ToObject<ManualPayoutProof>();
}
@ -118,6 +118,22 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
return res;
}
public static void ParseProofType(byte[] proof, out JObject obj, out string type)
{
type = null;
if (proof is null)
{
obj = null;
return;
}
obj = JObject.Parse(Encoding.UTF8.GetString(proof));
if (obj.TryGetValue("proofType", StringComparison.InvariantCultureIgnoreCase, out var proofType))
{
type = proofType.Value<string>();
}
}
public void StartBackgroundCheck(Action<Type[]> subscribe)
{
subscribe(new[] { typeof(NewOnChainTransactionEvent), typeof(NewBlockEvent) });
@ -443,11 +459,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
public void SetProofBlob(PayoutData data, PayoutTransactionOnChainBlob blob)
{
var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob, _jsonSerializerSettings.GetSerializer(data.GetPaymentMethodId().CryptoCode)));
// We only update the property if the bytes actually changed, this prevent from hammering the DB too much
if (data.Proof is null || bytes.Length != data.Proof.Length || !bytes.SequenceEqual(data.Proof))
{
data.Proof = bytes;
}
data.SetProofBlob(blob, _jsonSerializerSettings.GetSerializer(data.GetPaymentMethodId().CryptoCode));
}
}

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client.Models;
@ -13,6 +14,8 @@ using LNURL;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Data.Payouts.LightningLike
{
@ -117,7 +120,17 @@ namespace BTCPayServer.Data.Payouts.LightningLike
public IPayoutProof ParseProof(PayoutData payout)
{
return null;
BitcoinLikePayoutHandler.ParseProofType(payout.Proof, out var raw, out var proofType);
if (proofType is null)
{
return null;
}
if (proofType == ManualPayoutProof.Type)
{
return raw.ToObject<ManualPayoutProof>();
}
return raw.ToObject<PayoutLightningBlob>();
}
public void StartBackgroundCheck(Action<Type[]> subscribe)
@ -174,5 +187,6 @@ namespace BTCPayServer.Data.Payouts.LightningLike
return Task.FromResult<IActionResult>(new RedirectToActionResult("ConfirmLightningPayout",
"UILightningLikePayout", new { cryptoCode = paymentMethodId.CryptoCode, payoutIds }));
}
}
}

View file

@ -2,12 +2,11 @@ namespace BTCPayServer.Data.Payouts.LightningLike
{
public class PayoutLightningBlob : IPayoutProof
{
public string Bolt11Invoice { get; set; }
public string Preimage { get; set; }
public string PaymentHash { get; set; }
public string ProofType { get; }
public string ProofType { get; } = "PayoutLightningBlob";
public string Link { get; } = null;
public string Id => PaymentHash;
public string Preimage { get; set; }
}
}

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
@ -258,11 +259,16 @@ namespace BTCPayServer.Data.Payouts.LightningLike
}
public static async Task<ResultVM> TrypayBolt(ILightningClient lightningClient, PayoutBlob payoutBlob, PayoutData payoutData, BOLT11PaymentRequest bolt11PaymentRequest, PaymentMethodId pmi)
public static readonly TimeSpan SendTimeout = TimeSpan.FromSeconds(20);
public static async Task<ResultVM> TrypayBolt(
ILightningClient lightningClient, PayoutBlob payoutBlob, PayoutData payoutData, BOLT11PaymentRequest bolt11PaymentRequest,
PaymentMethodId pmi)
{
var boltAmount = bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC);
if (boltAmount != payoutBlob.CryptoAmount)
{
payoutData.State = PayoutState.Cancelled;
return new ResultVM
{
PayoutId = payoutData.Id,
@ -271,13 +277,36 @@ namespace BTCPayServer.Data.Payouts.LightningLike
Destination = payoutBlob.Destination
};
}
var result = await lightningClient.Pay(bolt11PaymentRequest.ToString(), new PayInvoiceParams());
if (result.Result == PayResult.Ok)
var proofBlob = new PayoutLightningBlob() {PaymentHash = bolt11PaymentRequest.PaymentHash.ToString()};
try
{
var message = result.Details?.TotalAmount != null
? $"Paid out {result.Details.TotalAmount.ToDecimal(LightMoneyUnit.BTC)}"
: null;
payoutData.State = PayoutState.Completed;
using var cts = new CancellationTokenSource(SendTimeout);
var result = await lightningClient.Pay(bolt11PaymentRequest.ToString(),
new PayInvoiceParams()
{
Amount = bolt11PaymentRequest.MinimumAmount == LightMoney.Zero
? new LightMoney((decimal)payoutBlob.CryptoAmount, LightMoneyUnit.BTC)
: null
}, cts.Token);
string message = null;
if (result.Result == PayResult.Ok)
{
message = result.Details?.TotalAmount != null
? $"Paid out {result.Details.TotalAmount.ToDecimal(LightMoneyUnit.BTC)}"
: null;
payoutData.State = PayoutState.Completed;
try
{
var payment = await lightningClient.GetPayment(bolt11PaymentRequest.PaymentHash.ToString());
proofBlob.Preimage = payment.Preimage;
}
catch (Exception e)
{
}
}
payoutData.SetProofBlob(proofBlob, null);
return new ResultVM
{
PayoutId = payoutData.Id,
@ -286,14 +315,21 @@ namespace BTCPayServer.Data.Payouts.LightningLike
Message = message
};
}
return new ResultVM
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
PayoutId = payoutData.Id,
Result = result.Result,
Destination = payoutBlob.Destination,
Message = result.ErrorDetail
};
// Timeout, potentially caused by hold invoices
// Payment will be saved as pending, the LightningPendingPayoutListener will handle settling/cancelling
payoutData.State = PayoutState.InProgress;
payoutData.SetProofBlob(proofBlob, null);
return new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Ok,
Destination = payoutBlob.Destination,
Message = "The payment timed out. We will verify if it completed later."
};
}
}

View file

@ -39,12 +39,13 @@ namespace BTCPayServer.Data
{
data.Blob = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob, serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode)));
}
public static void SetProofBlob(this PayoutData data, ManualPayoutProof blob)
public static void SetProofBlob(this PayoutData data, IPayoutProof blob, JsonSerializerSettings settings)
{
if (blob is null)
return;
var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob));
var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob, settings));
// We only update the property if the bytes actually changed, this prevent from hammering the DB too much
if (data.Proof is null || bytes.Length != data.Proof.Length || !bytes.SequenceEqual(data.Proof))
{

View file

@ -138,6 +138,52 @@ namespace BTCPayServer.HostedServices
return o.Id;
}
public class PayoutQuery
{
public PayoutState[] States { get; set; }
public string[] PullPayments { get; set; }
public string[] PayoutIds { get; set; }
public string[] PaymentMethods { get; set; }
public string[] Stores { get; set; }
}
public async Task<List<PayoutData>> GetPayouts(PayoutQuery payoutQuery)
{
await using var ctx = _dbContextFactory.CreateContext();
return await GetPayouts(payoutQuery, ctx);
}
public async Task<List<PayoutData>> GetPayouts(PayoutQuery payoutQuery, ApplicationDbContext ctx)
{
var query = ctx.Payouts.AsQueryable();
if (payoutQuery.States is not null)
{
query = query.Where(data => payoutQuery.States.Contains(data.State));
}
if (payoutQuery.PullPayments is not null)
{
query = query.Where(data => payoutQuery.PullPayments.Contains(data.PullPaymentDataId));
}
if (payoutQuery.PayoutIds is not null)
{
query = query.Where(data => payoutQuery.PayoutIds.Contains(data.Id));
}
if (payoutQuery.PaymentMethods is not null)
{
query = query.Where(data => payoutQuery.PaymentMethods.Contains(data.PaymentMethodId));
}
if (payoutQuery.Stores is not null)
{
query = query.Where(data => payoutQuery.Stores.Contains(data.StoreDataId));
}
return await query.ToListAsync();
}
public async Task<Data.PullPaymentData> GetPullPayment(string pullPaymentId, bool includePayouts)
{
await using var ctx = _dbContextFactory.CreateContext();
@ -205,7 +251,7 @@ namespace BTCPayServer.HostedServices
payoutHandler.StartBackgroundCheck(Subscribe);
}
return new[] { Loop() };
return new[] {Loop()};
}
private void Subscribe(params Type[] events)
@ -326,7 +372,8 @@ namespace BTCPayServer.HostedServices
payout.State = PayoutState.AwaitingPayment;
if (payout.PullPaymentData is null || paymentMethod.CryptoCode == payout.PullPaymentData.GetBlob().Currency)
if (payout.PullPaymentData is null ||
paymentMethod.CryptoCode == payout.PullPaymentData.GetBlob().Currency)
req.Rate = 1.0m;
var cryptoAmount = payoutBlob.Amount / req.Rate;
var payoutHandler = _payoutHandlers.FindPayoutHandler(paymentMethod);
@ -375,7 +422,7 @@ namespace BTCPayServer.HostedServices
if (req.Request.Proof != null)
{
payout.SetProofBlob(req.Request.Proof);
payout.SetProofBlob(req.Request.Proof, null);
}
payout.State = PayoutState.Completed;
@ -465,7 +512,7 @@ namespace BTCPayServer.HostedServices
: await ctx.Payouts.GetPayoutInPeriod(pp, now)
.Where(p => p.State != PayoutState.Cancelled).ToListAsync();
var payouts = payoutsRaw?.Select(o => new { Entity = o, Blob = o.GetBlob(_jsonSerializerSettings) });
var payouts = payoutsRaw?.Select(o => new {Entity = o, Blob = o.GetBlob(_jsonSerializerSettings)});
var limit = ppBlob?.Limit ?? 0;
var totalPayout = payouts?.Select(p => p.Blob.Amount)?.Sum();
var claimed = req.ClaimRequest.Value is decimal v ? v : limit - (totalPayout ?? 0);
@ -493,8 +540,7 @@ namespace BTCPayServer.HostedServices
};
var payoutBlob = new PayoutBlob()
{
Amount = claimed,
Destination = req.ClaimRequest.Destination.ToString()
Amount = claimed, Destination = req.ClaimRequest.Destination.ToString()
};
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
await ctx.Payouts.AddAsync(payout);
@ -502,7 +548,7 @@ namespace BTCPayServer.HostedServices
{
await payoutHandler.TrackClaim(req.ClaimRequest.PaymentMethodId, req.ClaimRequest.Destination);
await ctx.SaveChangesAsync();
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);
var rateResult = await GetRate(payout, null, CancellationToken.None);
@ -511,15 +557,19 @@ namespace BTCPayServer.HostedServices
var approveResult = new TaskCompletionSource<PayoutApproval.Result>();
await HandleApproval(new PayoutApproval()
{
PayoutId = payout.Id, Revision = payoutBlob.Revision, Rate = rateResult.BidAsk.Ask, Completion =approveResult
PayoutId = payout.Id,
Revision = payoutBlob.Revision,
Rate = rateResult.BidAsk.Ask,
Completion = approveResult
});
if ((await approveResult.Task) == PayoutApproval.Result.Ok)
{
payout.State = PayoutState.AwaitingPayment;
}
}
}
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Ok, payout));
await _notificationSender.SendNotification(new StoreScope(payout.StoreDataId),
new PayoutNotification()
@ -550,7 +600,7 @@ namespace BTCPayServer.HostedServices
List<PayoutData> payouts = null;
if (cancel.PullPaymentId != null)
{
ctx.PullPayments.Attach(new Data.PullPaymentData() { Id = cancel.PullPaymentId, Archived = true })
ctx.PullPayments.Attach(new Data.PullPaymentData() {Id = cancel.PullPaymentId, Archived = true})
.Property(o => o.Archived).IsModified = true;
payouts = await ctx.Payouts
.Where(p => p.PullPaymentDataId == cancel.PullPaymentId)
@ -617,10 +667,11 @@ namespace BTCPayServer.HostedServices
}
public PullPaymentsModel.PullPaymentModel.ProgressModel CalculatePullPaymentProgress(PullPaymentData pp, DateTimeOffset now)
public PullPaymentsModel.PullPaymentModel.ProgressModel CalculatePullPaymentProgress(PullPaymentData pp,
DateTimeOffset now)
{
var ppBlob = pp.GetBlob();
var ni = _currencyNameTable.GetCurrencyData(ppBlob.Currency, true);
var nfi = _currencyNameTable.GetNumberFormatInfo(ppBlob.Currency, true);
var totalCompleted = pp.Payouts.Where(p => (p.State == PayoutState.Completed ||
@ -647,16 +698,15 @@ namespace BTCPayServer.HostedServices
EndIn = pp.EndDate is { } end ? ZeroIfNegative(end - now).TimeString() : null,
};
}
public TimeSpan ZeroIfNegative(TimeSpan time)
{
if (time < TimeSpan.Zero)
time = TimeSpan.Zero;
return time;
}
class InternalPayoutPaidRequest
{

View file

@ -365,6 +365,7 @@ namespace BTCPayServer.Hosting
services.AddSingleton<IUIExtension>(new UIExtension("LNURL/LightningAddressOption",
"store-integrations-list"));
services.AddSingleton<IHostedService, LightningListener>();
services.AddSingleton<IHostedService, LightningPendingPayoutListener>();
services.AddSingleton<PaymentMethodHandlerDictionary>();

View file

@ -0,0 +1,137 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Data.Payouts.LightningLike;
using BTCPayServer.HostedServices;
using BTCPayServer.Lightning;
using BTCPayServer.Services;
using BTCPayServer.Services.Stores;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PayoutData = BTCPayServer.Data.PayoutData;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Payments.Lightning;
public class LightningPendingPayoutListener : BaseAsyncService
{
private readonly LightningClientFactoryService _lightningClientFactoryService;
private readonly ApplicationDbContextFactory _applicationDbContextFactory;
private readonly PullPaymentHostedService _pullPaymentHostedService;
private readonly LightningLikePayoutHandler _lightningLikePayoutHandler;
private readonly StoreRepository _storeRepository;
private readonly IOptions<LightningNetworkOptions> _options;
private readonly BTCPayNetworkProvider _networkProvider;
public static int SecondsDelay = 60 * 10;
public LightningPendingPayoutListener(
LightningClientFactoryService lightningClientFactoryService,
ApplicationDbContextFactory applicationDbContextFactory,
PullPaymentHostedService pullPaymentHostedService,
LightningLikePayoutHandler lightningLikePayoutHandler,
StoreRepository storeRepository,
IOptions<LightningNetworkOptions> options,
BTCPayNetworkProvider networkProvider,
ILogger<LightningPendingPayoutListener> logger) : base(logger)
{
_lightningClientFactoryService = lightningClientFactoryService;
_applicationDbContextFactory = applicationDbContextFactory;
_pullPaymentHostedService = pullPaymentHostedService;
_lightningLikePayoutHandler = lightningLikePayoutHandler;
_storeRepository = storeRepository;
_options = options;
_networkProvider = networkProvider;
}
private async Task Act()
{
await using var context = _applicationDbContextFactory.CreateContext();
var networks = _networkProvider.GetAll()
.OfType<BTCPayNetwork>()
.Where(network => network.SupportLightning)
.ToDictionary(network => new PaymentMethodId(network.CryptoCode, PaymentTypes.LightningLike));
var payouts = await _pullPaymentHostedService.GetPayouts(
new PullPaymentHostedService.PayoutQuery()
{
States = new PayoutState[] {PayoutState.InProgress},
PaymentMethods = networks.Keys.Select(id => id.ToString()).ToArray()
}, context);
var storeIds = payouts.Select(data => data.StoreDataId).Distinct();
var stores = (await Task.WhenAll(storeIds.Select(_storeRepository.FindStore)))
.Where(data => data is not null).ToDictionary(data => data.Id, data => (StoreData)data);
foreach (IGrouping<string, PayoutData> payoutByStore in payouts.GroupBy(data => data.StoreDataId))
{
if (!stores.TryGetValue(payoutByStore.Key, out var store))
{
foreach (PayoutData payoutData in payoutByStore)
{
payoutData.State = PayoutState.Cancelled;
}
continue;
}
foreach (IGrouping<string, PayoutData> payoutByStoreByPaymentMethod in payoutByStore.GroupBy(data =>
data.PaymentMethodId))
{
var pmi = PaymentMethodId.Parse(payoutByStoreByPaymentMethod.Key);
var pm = store.GetSupportedPaymentMethods(_networkProvider)
.OfType<LightningSupportedPaymentMethod>()
.FirstOrDefault(method => method.PaymentId == pmi);
if (pm is null)
{
continue;
}
var client =
pm.CreateLightningClient(networks[pmi], _options.Value, _lightningClientFactoryService);
foreach (PayoutData payoutData in payoutByStoreByPaymentMethod)
{
var proof = _lightningLikePayoutHandler.ParseProof(payoutData);
switch (proof)
{
case null:
break;
case PayoutLightningBlob payoutLightningBlob:
{
var payment = await client.GetPayment(payoutLightningBlob.Id, Cancellation);
if (payment is null)
{
continue;
}
switch (payment.Status)
{
case LightningPaymentStatus.Complete:
payoutData.State = PayoutState.Completed;
payoutLightningBlob.Preimage = payment.Preimage;
payoutData.SetProofBlob(payoutLightningBlob, null);
break;
case LightningPaymentStatus.Failed:
payoutData.State = PayoutState.Cancelled;
break;
}
break;
}
}
}
}
}
await context.SaveChangesAsync(Cancellation);
await Task.Delay(TimeSpan.FromSeconds(SecondsDelay), Cancellation);
}
internal override Task[] InitializeTasks()
{
return new[] {CreateLoopTask(Act)};
}
}

View file

@ -1,4 +1,6 @@
using System.Linq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
@ -20,6 +22,7 @@ public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T
protected readonly StoreRepository _storeRepository;
protected readonly PayoutProcessorData _PayoutProcesserSettings;
protected readonly ApplicationDbContextFactory _applicationDbContextFactory;
private readonly PullPaymentHostedService _pullPaymentHostedService;
protected readonly BTCPayNetworkProvider _btcPayNetworkProvider;
protected readonly PaymentMethodId PaymentMethodId;
@ -28,12 +31,14 @@ public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T
StoreRepository storeRepository,
PayoutProcessorData payoutProcesserSettings,
ApplicationDbContextFactory applicationDbContextFactory,
PullPaymentHostedService pullPaymentHostedService,
BTCPayNetworkProvider btcPayNetworkProvider) : base(logger.CreateLogger($"{payoutProcesserSettings.Processor}:{payoutProcesserSettings.StoreId}:{payoutProcesserSettings.PaymentMethod}"))
{
_storeRepository = storeRepository;
_PayoutProcesserSettings = payoutProcesserSettings;
PaymentMethodId = _PayoutProcesserSettings.GetPaymentMethodId();
_applicationDbContextFactory = applicationDbContextFactory;
_pullPaymentHostedService = pullPaymentHostedService;
_btcPayNetworkProvider = btcPayNetworkProvider;
}
@ -42,7 +47,7 @@ public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T
return new[] { CreateLoopTask(Act) };
}
protected abstract Task Process(ISupportedPaymentMethod paymentMethod, PayoutData[] payouts);
protected abstract Task Process(ISupportedPaymentMethod paymentMethod, List<PayoutData> payouts);
private async Task Act()
{
@ -54,11 +59,20 @@ public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T
var blob = GetBlob(_PayoutProcesserSettings);
if (paymentMethod is not null)
{
var payouts = await GetRelevantPayouts();
if (payouts.Length > 0)
await using var context = _applicationDbContextFactory.CreateContext();
var payouts = await _pullPaymentHostedService.GetPayouts(
new PullPaymentHostedService.PayoutQuery()
{
States = new[] {PayoutState.AwaitingPayment},
PaymentMethods = new[] {_PayoutProcesserSettings.PaymentMethod},
Stores = new[] {_PayoutProcesserSettings.StoreId}
}, context);
if (payouts.Any())
{
Logs.PayServer.LogInformation($"{payouts.Length} found to process. Starting (and after will sleep for {blob.Interval})");
Logs.PayServer.LogInformation($"{payouts.Count} found to process. Starting (and after will sleep for {blob.Interval})");
await Process(paymentMethod, payouts);
await context.SaveChangesAsync();
}
}
await Task.Delay(blob.Interval, CancellationToken);
@ -69,16 +83,4 @@ public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T
{
return InvoiceRepository.FromBytes<T>(data.Blob);
}
private async Task<PayoutData[]> GetRelevantPayouts()
{
await using var context = _applicationDbContextFactory.CreateContext();
var pmi = _PayoutProcesserSettings.PaymentMethod;
return await context.Payouts
.Where(data => data.State == PayoutState.AwaitingPayment)
.Where(data => data.PaymentMethodId == pmi)
.Where(data => data.StoreDataId == _PayoutProcesserSettings.StoreId)
.OrderBy(data => data.Date)
.ToArrayAsync();
}
}

View file

@ -7,6 +7,7 @@ using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Data.Data;
using BTCPayServer.Data.Payouts.LightningLike;
using BTCPayServer.HostedServices;
using BTCPayServer.Lightning;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
@ -38,8 +39,8 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Au
UserService userService,
ILoggerFactory logger, IOptions<LightningNetworkOptions> options,
StoreRepository storeRepository, PayoutProcessorData payoutProcesserSettings,
ApplicationDbContextFactory applicationDbContextFactory, BTCPayNetworkProvider btcPayNetworkProvider) :
base(logger, storeRepository, payoutProcesserSettings, applicationDbContextFactory,
ApplicationDbContextFactory applicationDbContextFactory, PullPaymentHostedService pullPaymentHostedService, BTCPayNetworkProvider btcPayNetworkProvider) :
base(logger, storeRepository, payoutProcesserSettings, applicationDbContextFactory, pullPaymentHostedService,
btcPayNetworkProvider)
{
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
@ -51,11 +52,8 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Au
_network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(_PayoutProcesserSettings.GetPaymentMethodId().CryptoCode);
}
protected override async Task Process(ISupportedPaymentMethod paymentMethod, PayoutData[] payouts)
protected override async Task Process(ISupportedPaymentMethod paymentMethod, List<PayoutData> payouts)
{
await using var ctx = _applicationDbContextFactory.CreateContext();
var lightningSupportedPaymentMethod = (LightningSupportedPaymentMethod)paymentMethod;
if (lightningSupportedPaymentMethod.IsInternalNode &&
@ -70,8 +68,6 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Au
lightningSupportedPaymentMethod.CreateLightningClient(_network, _options.Value,
_lightningClientFactoryService);
foreach (var payoutData in payouts)
{
var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings);
@ -81,29 +77,18 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Au
switch (claim.destination)
{
case LNURLPayClaimDestinaton lnurlPayClaimDestinaton:
var lnurlResult = await UILightningLikePayoutController.GetInvoiceFromLNURL(payoutData, _payoutHandler, blob,
var lnurlResult = await UILightningLikePayoutController.GetInvoiceFromLNURL(payoutData,
_payoutHandler, blob,
lnurlPayClaimDestinaton, _network.NBitcoinNetwork);
if (lnurlResult.Item2 is not null)
{
continue;
}
if (await TrypayBolt(client, blob, payoutData,
lnurlResult.Item1))
{
ctx.Attach(payoutData).State = EntityState.Modified;
payoutData.State = PayoutState.Completed;
}
await TrypayBolt(client, blob, payoutData,
lnurlResult.Item1);
break;
case BoltInvoiceClaimDestination item1:
if (await TrypayBolt(client, blob, payoutData, item1.PaymentRequest))
{
ctx.Attach(payoutData).State = EntityState.Modified;
payoutData.State = PayoutState.Completed;
}
await TrypayBolt(client, blob, payoutData, item1.PaymentRequest);
break;
}
}
@ -112,9 +97,6 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Au
Logs.PayServer.LogError(e, $"Could not process payout {payoutData.Id}");
}
}
await ctx.SaveChangesAsync();
}
//we group per store and init the transfers by each

View file

@ -41,8 +41,9 @@ namespace BTCPayServer.PayoutProcessors.OnChain
EventAggregator eventAggregator,
StoreRepository storeRepository,
PayoutProcessorData payoutProcesserSettings,
PullPaymentHostedService pullPaymentHostedService,
BTCPayNetworkProvider btcPayNetworkProvider) :
base(logger, storeRepository, payoutProcesserSettings, applicationDbContextFactory,
base(logger, storeRepository, payoutProcesserSettings, applicationDbContextFactory,pullPaymentHostedService,
btcPayNetworkProvider)
{
_explorerClientProvider = explorerClientProvider;
@ -52,7 +53,7 @@ namespace BTCPayServer.PayoutProcessors.OnChain
_eventAggregator = eventAggregator;
}
protected override async Task Process(ISupportedPaymentMethod paymentMethod, PayoutData[] payouts)
protected override async Task Process(ISupportedPaymentMethod paymentMethod, List<PayoutData> payouts)
{
var storePaymentMethod = paymentMethod as DerivationSchemeSettings;
if (storePaymentMethod?.IsHotWallet is not true)
@ -143,11 +144,9 @@ namespace BTCPayServer.PayoutProcessors.OnChain
{
try
{
await using var context = _applicationDbContextFactory.CreateContext();
var txHash = workingTx.GetHash();
foreach (PayoutData payoutData in transfersProcessing)
{
context.Attach(payoutData).State = EntityState.Modified;
payoutData.State = PayoutState.InProgress;
_bitcoinLikePayoutHandler.SetProofBlob(payoutData,
new PayoutTransactionOnChainBlob()
@ -156,7 +155,6 @@ namespace BTCPayServer.PayoutProcessors.OnChain
TransactionId = txHash,
Candidates = new HashSet<uint256>() { txHash }
});
await context.SaveChangesAsync();
}
TaskCompletionSource<bool> tcs = new();
var cts = new CancellationTokenSource();

View file

@ -9,6 +9,7 @@ using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace BTCPayServer.PayoutProcessors;
@ -18,6 +19,10 @@ public class PayoutProcessorUpdated
public PayoutProcessorData Data { get; set; }
public TaskCompletionSource Processed { get; set; }
public override string ToString()
{
return $"{Data}";
}
}
public class PayoutProcessorService : EventHostedServiceBase

View file

@ -63,9 +63,9 @@ namespace BTCPayServer.Services.Labels
string PayoutLabelText(KeyValuePair<string, List<string>> pair)
{
if (pair.Value.Count == 1)
return $"Paid a payout of a pull payment ({pair.Key})";
return $"Paid a payout {(string.IsNullOrEmpty(pair.Key)? string.Empty: $"of a pull payment ({pair.Key})")}";
else
return $"Paid payouts of a pull payment ({pair.Key})";
return $"Paid {pair.Value.Count} payouts {(string.IsNullOrEmpty(pair.Key)? string.Empty: $"of a pull payment ({pair.Key})")}";
}
if (uncoloredLabel is ReferenceLabel refLabel)
@ -103,7 +103,7 @@ namespace BTCPayServer.Services.Labels
{
coloredLabel.Tooltip = payoutLabel.PullPaymentPayouts.Count > 1
? $"<ul>{string.Join(string.Empty, payoutLabel.PullPaymentPayouts.Select(pair => $"<li>{PayoutLabelText(pair)}</li>"))}</ul>"
: payoutLabel.PullPaymentPayouts.Select(PayoutLabelText).ToString();
: PayoutLabelText(payoutLabel.PullPaymentPayouts.First());
coloredLabel.Link = string.IsNullOrEmpty(payoutLabel.WalletId)
? null