diff --git a/BTCPayServer.Data/Data/PayoutProcessorData.cs b/BTCPayServer.Data/Data/PayoutProcessorData.cs index 09f936ed9..df4387b0e 100644 --- a/BTCPayServer.Data/Data/PayoutProcessorData.cs +++ b/BTCPayServer.Data/Data/PayoutProcessorData.cs @@ -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}"; + } } diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index 652189aa1..1d1b25cb9 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -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() diff --git a/BTCPayServer.Tests/docker-customer-lncli-holdinvoice.sh b/BTCPayServer.Tests/docker-customer-lncli-holdinvoice.sh new file mode 100755 index 000000000..73e6242f5 --- /dev/null +++ b/BTCPayServer.Tests/docker-customer-lncli-holdinvoice.sh @@ -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" diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldPullPaymentController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldPullPaymentController.cs index 75e91dede..f1a1c4f7b 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldPullPaymentController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldPullPaymentController.cs @@ -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 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 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()); } diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoreAutomatedLightningPayoutProcessorsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoreAutomatedLightningPayoutProcessorsController.cs index 00e3d799a..e8ce6fe8f 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldStoreAutomatedLightningPayoutProcessorsController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoreAutomatedLightningPayoutProcessorsController.cs @@ -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 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 UpdateStoreLightningAutomatedPayoutProcessor( string storeId, string paymentMethod, LightningAutomatedPayoutSettings request) { + paymentMethod = PaymentMethodId.Parse(paymentMethod).ToString(); var activeProcessor = (await _payoutProcessorService.GetProcessors( new PayoutProcessorService.PayoutProcessorQuery() diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoreAutomatedOnChainPayoutProcessorsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoreAutomatedOnChainPayoutProcessorsController.cs index ee70913e4..f70ee14b7 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldStoreAutomatedOnChainPayoutProcessorsController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoreAutomatedOnChainPayoutProcessorsController.cs @@ -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 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 UpdateStoreOnchainAutomatedPayoutProcessor( string storeId, string paymentMethod, OnChainAutomatedPayoutSettings request) { + paymentMethod = PaymentMethodId.Parse(paymentMethod).ToString(); var activeProcessor = (await _payoutProcessorService.GetProcessors( new PayoutProcessorService.PayoutProcessorQuery() diff --git a/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs b/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs index 129c6019a..268a228bd 100644 --- a/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs +++ b/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs @@ -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() == ManualPayoutProof.Type) + + ParseProofType(payout.Proof, out var raw, out var proofType); + if (proofType == ManualPayoutProof.Type) { return raw.ToObject(); } @@ -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(); + } + } + public void StartBackgroundCheck(Action 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)); + } } diff --git a/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutHandler.cs b/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutHandler.cs index 6df981166..872f109ca 100644 --- a/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutHandler.cs +++ b/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutHandler.cs @@ -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(); + } + + return raw.ToObject(); } public void StartBackgroundCheck(Action subscribe) @@ -174,5 +187,6 @@ namespace BTCPayServer.Data.Payouts.LightningLike return Task.FromResult(new RedirectToActionResult("ConfirmLightningPayout", "UILightningLikePayout", new { cryptoCode = paymentMethodId.CryptoCode, payoutIds })); } + } } diff --git a/BTCPayServer/Data/Payouts/LightningLike/PayoutLightningBlob.cs b/BTCPayServer/Data/Payouts/LightningLike/PayoutLightningBlob.cs index 243e8260c..c4ee81265 100644 --- a/BTCPayServer/Data/Payouts/LightningLike/PayoutLightningBlob.cs +++ b/BTCPayServer/Data/Payouts/LightningLike/PayoutLightningBlob.cs @@ -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; } } } diff --git a/BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs b/BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs index dedb6394a..0b20496f2 100644 --- a/BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs +++ b/BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs @@ -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 TrypayBolt(ILightningClient lightningClient, PayoutBlob payoutBlob, PayoutData payoutData, BOLT11PaymentRequest bolt11PaymentRequest, PaymentMethodId pmi) + public static readonly TimeSpan SendTimeout = TimeSpan.FromSeconds(20); + public static async Task 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." + }; + } } diff --git a/BTCPayServer/Data/Payouts/PayoutExtensions.cs b/BTCPayServer/Data/Payouts/PayoutExtensions.cs index c1d7d69f7..69d5985e8 100644 --- a/BTCPayServer/Data/Payouts/PayoutExtensions.cs +++ b/BTCPayServer/Data/Payouts/PayoutExtensions.cs @@ -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)) { diff --git a/BTCPayServer/HostedServices/PullPaymentHostedService.cs b/BTCPayServer/HostedServices/PullPaymentHostedService.cs index 253c74963..031b38b51 100644 --- a/BTCPayServer/HostedServices/PullPaymentHostedService.cs +++ b/BTCPayServer/HostedServices/PullPaymentHostedService.cs @@ -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> GetPayouts(PayoutQuery payoutQuery) + { + await using var ctx = _dbContextFactory.CreateContext(); + return await GetPayouts(payoutQuery, ctx); + } + + public async Task> 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 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(); 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 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 { diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 63ce6f851..5aee66009 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -365,6 +365,7 @@ namespace BTCPayServer.Hosting services.AddSingleton(new UIExtension("LNURL/LightningAddressOption", "store-integrations-list")); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); diff --git a/BTCPayServer/Payments/Lightning/LightningPendingPayoutListener.cs b/BTCPayServer/Payments/Lightning/LightningPendingPayoutListener.cs new file mode 100644 index 000000000..b7d9aebb7 --- /dev/null +++ b/BTCPayServer/Payments/Lightning/LightningPendingPayoutListener.cs @@ -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 _options; + private readonly BTCPayNetworkProvider _networkProvider; + public static int SecondsDelay = 60 * 10; + + public LightningPendingPayoutListener( + LightningClientFactoryService lightningClientFactoryService, + ApplicationDbContextFactory applicationDbContextFactory, + PullPaymentHostedService pullPaymentHostedService, + LightningLikePayoutHandler lightningLikePayoutHandler, + StoreRepository storeRepository, + IOptions options, + BTCPayNetworkProvider networkProvider, + ILogger 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() + .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 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 payoutByStoreByPaymentMethod in payoutByStore.GroupBy(data => + data.PaymentMethodId)) + { + var pmi = PaymentMethodId.Parse(payoutByStoreByPaymentMethod.Key); + var pm = store.GetSupportedPaymentMethods(_networkProvider) + .OfType() + .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)}; + } +} diff --git a/BTCPayServer/PayoutProcessors/BaseAutomatedPayoutProcessor.cs b/BTCPayServer/PayoutProcessors/BaseAutomatedPayoutProcessor.cs index 0e345e32d..2cb478cef 100644 --- a/BTCPayServer/PayoutProcessors/BaseAutomatedPayoutProcessor.cs +++ b/BTCPayServer/PayoutProcessors/BaseAutomatedPayoutProcessor.cs @@ -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 : 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 : 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 : BaseAsyncService where T return new[] { CreateLoopTask(Act) }; } - protected abstract Task Process(ISupportedPaymentMethod paymentMethod, PayoutData[] payouts); + protected abstract Task Process(ISupportedPaymentMethod paymentMethod, List payouts); private async Task Act() { @@ -54,11 +59,20 @@ public abstract class BaseAutomatedPayoutProcessor : 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 : BaseAsyncService where T { return InvoiceRepository.FromBytes(data.Blob); } - - private async Task 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(); - } } diff --git a/BTCPayServer/PayoutProcessors/Lightning/LightningAutomatedPayoutProcessor.cs b/BTCPayServer/PayoutProcessors/Lightning/LightningAutomatedPayoutProcessor.cs index e941fd57c..e8e43b575 100644 --- a/BTCPayServer/PayoutProcessors/Lightning/LightningAutomatedPayoutProcessor.cs +++ b/BTCPayServer/PayoutProcessors/Lightning/LightningAutomatedPayoutProcessor.cs @@ -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 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(_PayoutProcesserSettings.GetPaymentMethodId().CryptoCode); } - protected override async Task Process(ISupportedPaymentMethod paymentMethod, PayoutData[] payouts) + protected override async Task Process(ISupportedPaymentMethod paymentMethod, List payouts) { - await using var ctx = _applicationDbContextFactory.CreateContext(); - - var lightningSupportedPaymentMethod = (LightningSupportedPaymentMethod)paymentMethod; if (lightningSupportedPaymentMethod.IsInternalNode && @@ -70,8 +68,6 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor 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() { txHash } }); - await context.SaveChangesAsync(); } TaskCompletionSource tcs = new(); var cts = new CancellationTokenSource(); diff --git a/BTCPayServer/PayoutProcessors/PayoutProcessorService.cs b/BTCPayServer/PayoutProcessors/PayoutProcessorService.cs index 528136a7a..bbdf66f13 100644 --- a/BTCPayServer/PayoutProcessors/PayoutProcessorService.cs +++ b/BTCPayServer/PayoutProcessors/PayoutProcessorService.cs @@ -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 diff --git a/BTCPayServer/Services/Labels/LabelFactory.cs b/BTCPayServer/Services/Labels/LabelFactory.cs index 016b3fa0d..ff978f119 100644 --- a/BTCPayServer/Services/Labels/LabelFactory.cs +++ b/BTCPayServer/Services/Labels/LabelFactory.cs @@ -63,9 +63,9 @@ namespace BTCPayServer.Services.Labels string PayoutLabelText(KeyValuePair> 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 ? $"
    {string.Join(string.Empty, payoutLabel.PullPaymentPayouts.Select(pair => $"
  • {PayoutLabelText(pair)}
  • "))}
" - : payoutLabel.PullPaymentPayouts.Select(PayoutLabelText).ToString(); + : PayoutLabelText(payoutLabel.PullPaymentPayouts.First()); coloredLabel.Link = string.IsNullOrEmpty(payoutLabel.WalletId) ? null