diff --git a/BTCPayServer.Tests/FastTests.cs b/BTCPayServer.Tests/FastTests.cs index cc0be0435..43dc12b0b 100644 --- a/BTCPayServer.Tests/FastTests.cs +++ b/BTCPayServer.Tests/FastTests.cs @@ -679,6 +679,26 @@ namespace BTCPayServer.Tests Assert.Equal(utxo54, utxos[53]); } + [Fact] + public void ResourceTrackerTest() + { + var tracker = new ResourceTracker(); + var t1 = tracker.StartTracking(); + Assert.True(t1.TryTrack("1")); + Assert.False(t1.TryTrack("1")); + var t2 = tracker.StartTracking(); + Assert.True(t2.TryTrack("2")); + Assert.False(t2.TryTrack("1")); + Assert.True(t1.Contains("1")); + Assert.True(t2.Contains("2")); + Assert.True(tracker.Contains("1")); + Assert.True(tracker.Contains("2")); + t1.Dispose(); + Assert.False(tracker.Contains("1")); + Assert.True(tracker.Contains("2")); + Assert.True(t2.TryTrack("1")); + } + [Fact] public void CanAcceptInvoiceWithTolerance() { diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index b6e3e7744..ab4ea0cb7 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -4201,8 +4201,8 @@ namespace BTCPayServer.Tests await TestUtils.EventuallyAsync(async () => { var payoutC = - (await adminClient.GetStorePayouts(admin.StoreId, false)).Single(data => data.Id == payout.Id); - Assert.Equal(PayoutState.Completed, payoutC.State); + (await adminClient.GetStorePayouts(admin.StoreId, false)).SingleOrDefault(data => data.Id == payout.Id); + Assert.Equal(PayoutState.Completed, payoutC?.State); }); payout = await adminClient.CreatePayout(admin.StoreId, diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 0c53dc495..1924ae156 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -41,7 +41,7 @@ - + diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldPullPaymentController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldPullPaymentController.cs index c20032afd..8ec85a185 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldPullPaymentController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldPullPaymentController.cs @@ -407,23 +407,29 @@ namespace BTCPayServer.Controllers.Greenfield return this.CreateValidationError(ModelState); } - var amtError = ClaimRequest.IsPayoutAmountOk(destination.destination, request.Amount, payoutHandler.Currency, pp.Currency); - if (amtError.error is not null) + var amt = ClaimRequest.GetClaimedAmount(destination.destination, request.Amount, payoutHandler.Currency, pp.Currency); + if (amt is ClaimRequest.ClaimedAmountResult.Error err) { - ModelState.AddModelError(nameof(request.Amount), amtError.error ); + ModelState.AddModelError(nameof(request.Amount), err.Message); return this.CreateValidationError(ModelState); } - request.Amount = amtError.amount; - var result = await _pullPaymentService.Claim(new ClaimRequest() + else if (amt is ClaimRequest.ClaimedAmountResult.Success succ) { - Destination = destination.destination, - PullPaymentId = pullPaymentId, - Value = request.Amount, - PayoutMethodId = payoutMethodId, - StoreId = pp.StoreId - }); - - return HandleClaimResult(result); + request.Amount = succ.Amount; + var result = await _pullPaymentService.Claim(new ClaimRequest() + { + Destination = destination.destination, + PullPaymentId = pullPaymentId, + ClaimedAmount = request.Amount, + PayoutMethodId = payoutMethodId, + StoreId = pp.StoreId + }); + return HandleClaimResult(result); + } + else + { + throw new NotSupportedException($"Should never happen {amt}"); + } } [HttpPost("~/api/v1/stores/{storeId}/payouts")] @@ -456,6 +462,7 @@ namespace BTCPayServer.Controllers.Greenfield PullPaymentBlob? ppBlob = null; + string? ppCurrency = null; if (request?.PullPaymentId is not null) { @@ -464,6 +471,7 @@ namespace BTCPayServer.Controllers.Greenfield if (pp is null) return PullPaymentNotFound(); ppBlob = pp.GetBlob(); + ppCurrency = pp.Currency; } var destination = await payoutHandler.ParseAndValidateClaimDestination(request!.Destination, ppBlob, default); if (destination.destination is null) @@ -472,30 +480,37 @@ namespace BTCPayServer.Controllers.Greenfield return this.CreateValidationError(ModelState); } - var amtError = ClaimRequest.IsPayoutAmountOk(destination.destination, request.Amount); - if (amtError.error is not null) + var amt = ClaimRequest.GetClaimedAmount(destination.destination, request.Amount, payoutHandler.Currency, ppCurrency); + if (amt is ClaimRequest.ClaimedAmountResult.Error err) { - ModelState.AddModelError(nameof(request.Amount), amtError.error ); + ModelState.AddModelError(nameof(request.Amount), err.Message); return this.CreateValidationError(ModelState); } - request.Amount = amtError.amount; - if (request.Amount is { } v && (v < ppBlob?.MinimumClaim || v == 0.0m)) + else if (amt is ClaimRequest.ClaimedAmountResult.Success succ) { - var minimumClaim = ppBlob?.MinimumClaim is decimal val ? val : 0.0m; - ModelState.AddModelError(nameof(request.Amount), $"Amount too small (should be at least {minimumClaim})"); - return this.CreateValidationError(ModelState); + request.Amount = succ.Amount; + if (request.Amount is { } v && (v < ppBlob?.MinimumClaim || v == 0.0m)) + { + var minimumClaim = ppBlob?.MinimumClaim is decimal val ? val : 0.0m; + ModelState.AddModelError(nameof(request.Amount), $"Amount too small (should be at least {minimumClaim})"); + return this.CreateValidationError(ModelState); + } + var result = await _pullPaymentService.Claim(new ClaimRequest() + { + Destination = destination.destination, + PullPaymentId = request.PullPaymentId, + PreApprove = request.Approved, + ClaimedAmount = request.Amount, + PayoutMethodId = paymentMethodId, + StoreId = storeId, + Metadata = request.Metadata + }); + return HandleClaimResult(result); } - var result = await _pullPaymentService.Claim(new ClaimRequest() + else { - Destination = destination.destination, - PullPaymentId = request.PullPaymentId, - PreApprove = request.Approved, - Value = request.Amount, - PayoutMethodId = paymentMethodId, - StoreId = storeId, - Metadata = request.Metadata - }); - return HandleClaimResult(result); + throw new NotSupportedException($"Should never happen {amt}"); + } } private IActionResult HandleClaimResult(ClaimRequest.ClaimResponse result) diff --git a/BTCPayServer/Controllers/UILNURLController.cs b/BTCPayServer/Controllers/UILNURLController.cs index 045d62bf4..2cd7f9fcc 100644 --- a/BTCPayServer/Controllers/UILNURLController.cs +++ b/BTCPayServer/Controllers/UILNURLController.cs @@ -193,7 +193,7 @@ namespace BTCPayServer PayoutMethodId = pmi, PullPaymentId = pullPaymentId, StoreId = pp.StoreId, - Value = result.MinimumAmount.ToDecimal(unit), + ClaimedAmount = result.MinimumAmount.ToDecimal(unit), }); if (claimResponse.Result != ClaimRequest.ClaimResult.Ok) diff --git a/BTCPayServer/Controllers/UIPullPaymentController.cs b/BTCPayServer/Controllers/UIPullPaymentController.cs index e9f6bf162..b08a63a40 100644 --- a/BTCPayServer/Controllers/UIPullPaymentController.cs +++ b/BTCPayServer/Controllers/UIPullPaymentController.cs @@ -196,10 +196,13 @@ namespace BTCPayServer.Controllers } [AllowAnonymous] - [HttpPost("pull-payments/{pullPaymentId}/claim")] + [HttpPost("pull-payments/{pullPaymentId}")] public async Task ClaimPullPayment(string pullPaymentId, ViewPullPaymentModel vm, CancellationToken cancellationToken) { - await using var ctx = _dbContextFactory.CreateContext(); + if (vm.ClaimedAmount == 0) + vm.ClaimedAmount = null; + + await using var ctx = _dbContextFactory.CreateContext(); var pp = await ctx.PullPayments.FindAsync(pullPaymentId); if (pp is null) { @@ -251,14 +254,14 @@ namespace BTCPayServer.Controllers ModelState.AddModelError(nameof(vm.Destination), error ?? StringLocalizer["Invalid destination or payment method"]); return await ViewPullPayment(pullPaymentId); } - var amtError = ClaimRequest.IsPayoutAmountOk(destination, vm.ClaimedAmount == 0 ? null : vm.ClaimedAmount, payoutHandler.Currency, pp.Currency); - if (amtError.error is not null) + var claimedAmount = ClaimRequest.GetClaimedAmount(destination, vm.ClaimedAmount, payoutHandler.Currency, pp.Currency); + if (claimedAmount is ClaimRequest.ClaimedAmountResult.Error err2) { - ModelState.AddModelError(nameof(vm.ClaimedAmount), amtError.error); + ModelState.AddModelError(nameof(vm.ClaimedAmount), err2.Message); } - else if (amtError.amount is not null) + else if (claimedAmount is ClaimRequest.ClaimedAmountResult.Success succ) { - vm.ClaimedAmount = amtError.amount.Value; + vm.ClaimedAmount = succ.Amount; } if (!ModelState.IsValid) @@ -270,7 +273,7 @@ namespace BTCPayServer.Controllers { Destination = destination, PullPaymentId = pullPaymentId, - Value = vm.ClaimedAmount, + ClaimedAmount = vm.ClaimedAmount, PayoutMethodId = payoutMethodId, StoreId = pp.StoreId }); @@ -283,11 +286,19 @@ namespace BTCPayServer.Controllers return await ViewPullPayment(pullPaymentId); } - TempData.SetStatusMessageModel(new StatusMessageModel - { - Message = $"Your claim request of {_displayFormatter.Currency(vm.ClaimedAmount, pp.Currency, DisplayFormatter.CurrencyFormat.Symbol)} to {vm.Destination} has been submitted and is awaiting {(result.PayoutData.State == PayoutState.AwaitingApproval ? "approval" : "payment")}.", - Severity = StatusMessageModel.StatusSeverity.Success - }); + + TempData.SetStatusMessageModel(new StatusMessageModel + { + Message = (vm.ClaimedAmount, result.PayoutData.State) switch + { + (null, PayoutState.AwaitingApproval) => $"Your claim request to {vm.Destination} has been submitted and is awaiting approval", + (null, PayoutState.AwaitingPayment) => $"Your claim request to {vm.Destination} has been submitted and is awaiting payment", + ({ } a, PayoutState.AwaitingApproval) => $"Your claim request of {_displayFormatter.Currency(a, pp.Currency, DisplayFormatter.CurrencyFormat.Symbol)} to {vm.Destination} has been submitted and is awaiting approval", + ({ } a, PayoutState.AwaitingPayment) => $"Your claim request of {_displayFormatter.Currency(a, pp.Currency, DisplayFormatter.CurrencyFormat.Symbol)} to {vm.Destination} has been submitted and is awaiting payment", + _ => $"Unexpected payout state ({result.PayoutData.State})" + }, + Severity = StatusMessageModel.StatusSeverity.Success + }); return RedirectToAction(nameof(ViewPullPayment), new { pullPaymentId }); } diff --git a/BTCPayServer/Controllers/UIWalletsController.cs b/BTCPayServer/Controllers/UIWalletsController.cs index c8da90fc2..3eb054b89 100644 --- a/BTCPayServer/Controllers/UIWalletsController.cs +++ b/BTCPayServer/Controllers/UIWalletsController.cs @@ -757,7 +757,7 @@ namespace BTCPayServer.Controllers { Destination = new AddressClaimDestination( BitcoinAddress.Create(output.DestinationAddress, network.NBitcoinNetwork)), - Value = output.Amount, + ClaimedAmount = output.Amount, PayoutMethodId = pmi, StoreId = walletId.StoreId, PreApprove = true, @@ -777,7 +777,7 @@ namespace BTCPayServer.Controllers message = "Payouts scheduled:
"; } - message += $"{claimRequest.Value} to {claimRequest.Destination.ToString()}
"; + message += $"{claimRequest.ClaimedAmount} to {claimRequest.Destination.ToString()}
"; } else @@ -791,10 +791,10 @@ namespace BTCPayServer.Controllers switch (response.Result) { case ClaimRequest.ClaimResult.Duplicate: - errorMessage += $"{claimRequest.Value} to {claimRequest.Destination.ToString()} - address reuse
"; + errorMessage += $"{claimRequest.ClaimedAmount} to {claimRequest.Destination.ToString()} - address reuse
"; break; case ClaimRequest.ClaimResult.AmountTooLow: - errorMessage += $"{claimRequest.Value} to {claimRequest.Destination.ToString()} - amount too low
"; + errorMessage += $"{claimRequest.ClaimedAmount} to {claimRequest.Destination.ToString()} - amount too low
"; break; } } diff --git a/BTCPayServer/Data/Payouts/IClaimDestination.cs b/BTCPayServer/Data/Payouts/IClaimDestination.cs index c2bcf9544..62f1ac8d3 100644 --- a/BTCPayServer/Data/Payouts/IClaimDestination.cs +++ b/BTCPayServer/Data/Payouts/IClaimDestination.cs @@ -6,6 +6,5 @@ namespace BTCPayServer.Data { public string? Id { get; } decimal? Amount { get; } - bool IsExplicitAmountMinimum => false; } } diff --git a/BTCPayServer/Data/Payouts/LightningLike/BoltInvoiceClaimDestination.cs b/BTCPayServer/Data/Payouts/LightningLike/BoltInvoiceClaimDestination.cs index 30956fdff..5b7ef6a30 100644 --- a/BTCPayServer/Data/Payouts/LightningLike/BoltInvoiceClaimDestination.cs +++ b/BTCPayServer/Data/Payouts/LightningLike/BoltInvoiceClaimDestination.cs @@ -23,6 +23,5 @@ namespace BTCPayServer.Data.Payouts.LightningLike public uint256 PaymentHash { get; } public string Id => PaymentHash.ToString(); public decimal? Amount { get; } - public bool IsExplicitAmountMinimum => true; - } + }; } diff --git a/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutHandler.cs b/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutHandler.cs index b20514609..8ba3f20db 100644 --- a/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutHandler.cs +++ b/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutHandler.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Net.Http; @@ -208,5 +209,6 @@ namespace BTCPayServer.Data.Payouts.LightningLike "UILightningLikePayout", new { cryptoCode, payoutIds })); } + public ResourceTracker PayoutsPaymentProcessing { get; } = new ResourceTracker(); } } diff --git a/BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs b/BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs index 357ff58ab..ed866f6a5 100644 --- a/BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs +++ b/BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs @@ -12,6 +12,7 @@ using BTCPayServer.Lightning; using BTCPayServer.Payments; using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.Lightning; +using BTCPayServer.PayoutProcessors.Lightning; using BTCPayServer.Payouts; using BTCPayServer.Security; using BTCPayServer.Services; @@ -32,6 +33,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike public class UILightningLikePayoutController : Controller { private readonly ApplicationDbContextFactory _applicationDbContextFactory; + private readonly LightningAutomatedPayoutSenderFactory _lightningAutomatedPayoutSenderFactory; private readonly UserManager _userManager; private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings; private readonly PayoutMethodHandlerDictionary _payoutHandlers; @@ -43,17 +45,19 @@ namespace BTCPayServer.Data.Payouts.LightningLike private readonly StoreRepository _storeRepository; public UILightningLikePayoutController(ApplicationDbContextFactory applicationDbContextFactory, + LightningAutomatedPayoutSenderFactory lightningAutomatedPayoutSenderFactory, UserManager userManager, BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings, PayoutMethodHandlerDictionary payoutHandlers, PaymentMethodHandlerDictionary handlers, StoreRepository storeRepository, LightningClientFactoryService lightningClientFactoryService, - IOptions options, + IOptions options, IAuthorizationService authorizationService, EventAggregator eventAggregator) { _applicationDbContextFactory = applicationDbContextFactory; + _lightningAutomatedPayoutSenderFactory = lightningAutomatedPayoutSenderFactory; _userManager = userManager; _btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings; _payoutHandlers = payoutHandlers; @@ -132,248 +136,66 @@ namespace BTCPayServer.Data.Payouts.LightningLike var paymentMethodId = PaymentTypes.LN.GetPaymentMethodId(cryptoCode); var payoutHandler = (LightningLikePayoutHandler)_payoutHandlers.TryGet(pmi); - await using var ctx = _applicationDbContextFactory.CreateContext(); - - var payouts = (await GetPayouts(ctx, pmi, payoutIds)).GroupBy(data => data.StoreDataId); + IEnumerable> payouts; + using (var ctx = _applicationDbContextFactory.CreateContext()) + { + payouts = (await GetPayouts(ctx, pmi, payoutIds)).GroupBy(data => data.StoreDataId); + } var results = new List(); //we group per store and init the transfers by each - var authorizedForInternalNode = (await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.CanModifyServerSettings))).Succeeded; foreach (var payoutDatas in payouts) { var store = payoutDatas.First().StoreData; - + var authorized = await _authorizationService.AuthorizeAsync(User, store, new PolicyRequirement(Policies.CanUseLightningNodeInStore)); + if (!authorized.Succeeded) + { + results.AddRange(FailAll(payoutDatas, "You need the 'btcpay.store.canuselightningnode' permission for this action")); + continue; + } var lightningSupportedPaymentMethod = store.GetPaymentMethodConfig(paymentMethodId, _handlers); if (lightningSupportedPaymentMethod.IsInternalNode && !authorizedForInternalNode) { - foreach (PayoutData payoutData in payoutDatas) - { - - var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings); - results.Add(new ResultVM - { - PayoutId = payoutData.Id, - Result = PayResult.Error, - Destination = blob.Destination, - Message = "You are currently using the internal Lightning node for this payout's store but you are not a server admin." - }); - } - + results.AddRange(FailAll(payoutDatas, "You are currently using the internal Lightning node for this payout's store but you are not a server admin.")); continue; } + var processor = _lightningAutomatedPayoutSenderFactory.ConstructProcessor(new PayoutProcessorData() + { + Store = store, + StoreId = store.Id, + PayoutMethodId = pmi.ToString(), + Processor = LightningAutomatedPayoutSenderFactory.ProcessorName, + Id = Guid.NewGuid().ToString() + }); var client = lightningSupportedPaymentMethod.CreateLightningClient(payoutHandler.Network, _options.Value, _lightningClientFactoryService); - foreach (var payoutData in payoutDatas) + + foreach (var payout in payoutDatas) { - ResultVM result; - var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings); - var claim = await payoutHandler.ParseClaimDestination(blob.Destination, cancellationToken); - try - { - switch (claim.destination) - { - case LNURLPayClaimDestinaton lnurlPayClaimDestinaton: - var lnurlResult = await GetInvoiceFromLNURL(payoutData, payoutHandler, blob, - lnurlPayClaimDestinaton, payoutHandler.Network.NBitcoinNetwork, cancellationToken); - if (lnurlResult.Item2 is not null) - { - result = lnurlResult.Item2; - } - else - { - result = await TrypayBolt(client, blob, payoutData, lnurlResult.Item1, cancellationToken); - } - - break; - - case BoltInvoiceClaimDestination item1: - result = await TrypayBolt(client, blob, payoutData, item1.PaymentRequest, cancellationToken); - - break; - default: - result = new ResultVM - { - PayoutId = payoutData.Id, - Result = PayResult.Error, - Destination = blob.Destination, - Message = claim.error - }; - break; - } - } - catch (Exception exception) - { - result = new ResultVM - { - PayoutId = payoutData.Id, - Result = PayResult.Error, - Destination = blob.Destination, - Message = exception.Message - }; - } - results.Add(result); - } - } - - await ctx.SaveChangesAsync(); - foreach (var payoutG in payouts) - { - foreach (PayoutData payout in payoutG) - { - if (payout.State != PayoutState.AwaitingPayment) - { - _eventAggregator.Publish(new PayoutEvent(PayoutEvent.PayoutEventType.Updated, payout)); - } + results.Add(await processor.HandlePayout(payout, client, cancellationToken)); } } return View("LightningPayoutResult", results); } - public static async Task<(BOLT11PaymentRequest, ResultVM)> GetInvoiceFromLNURL(PayoutData payoutData, - LightningLikePayoutHandler handler, PayoutBlob blob, LNURLPayClaimDestinaton lnurlPayClaimDestinaton, Network network, CancellationToken cancellationToken) + + private ResultVM[] FailAll(IEnumerable payouts, string message) { - var endpoint = lnurlPayClaimDestinaton.LNURL.IsValidEmail() - ? LNURL.LNURL.ExtractUriFromInternetIdentifier(lnurlPayClaimDestinaton.LNURL) - : LNURL.LNURL.Parse(lnurlPayClaimDestinaton.LNURL, out _); - var httpClient = handler.CreateClient(endpoint); - var lnurlInfo = - (LNURLPayRequest)await LNURL.LNURL.FetchInformation(endpoint, "payRequest", - httpClient, cancellationToken); - var lm = new LightMoney(payoutData.Amount.Value, LightMoneyUnit.BTC); - if (lm > lnurlInfo.MaxSendable || lm < lnurlInfo.MinSendable) - { - - payoutData.State = PayoutState.Cancelled; - return (null, new ResultVM - { - PayoutId = payoutData.Id, - Result = PayResult.Error, - Destination = blob.Destination, - Message = - $"The LNURL provided would not generate an invoice of {lm.ToDecimal(LightMoneyUnit.Satoshi)} sats" - }); - } - - try - { - var lnurlPayRequestCallbackResponse = - await lnurlInfo.SendRequest(lm, network, httpClient, cancellationToken: cancellationToken); - - return (lnurlPayRequestCallbackResponse.GetPaymentRequest(network), null); - } - catch (LNUrlException e) - { - return (null, - new ResultVM - { - PayoutId = payoutData.Id, - Result = PayResult.Error, - Destination = blob.Destination, - Message = e.Message - }); - } + return payouts.Select(p => Fail(p, message)).ToArray(); } - - public static async Task TrypayBolt( - ILightningClient lightningClient, PayoutBlob payoutBlob, PayoutData payoutData, BOLT11PaymentRequest bolt11PaymentRequest, CancellationToken cancellationToken) + private ResultVM Fail(PayoutData payoutData, string message) { - var boltAmount = bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC); - if (boltAmount > payoutData.Amount) + var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings); + return new ResultVM { - payoutData.State = PayoutState.Cancelled; - return new ResultVM - { - PayoutId = payoutData.Id, - Result = PayResult.Error, - Message = $"The BOLT11 invoice amount ({boltAmount} {payoutData.Currency}) did not match the payout's amount ({payoutData.Amount.GetValueOrDefault()} {payoutData.Currency})", - Destination = payoutBlob.Destination - }; - } - - if (bolt11PaymentRequest.ExpiryDate < DateTimeOffset.Now) - { - payoutData.State = PayoutState.Cancelled; - return new ResultVM - { - PayoutId = payoutData.Id, - Result = PayResult.Error, - Message = $"The BOLT11 invoice expiry date ({bolt11PaymentRequest.ExpiryDate}) has expired", - Destination = payoutBlob.Destination - }; - } - - var proofBlob = new PayoutLightningBlob { PaymentHash = bolt11PaymentRequest.PaymentHash.ToString() }; - try - { - var result = await lightningClient.Pay(bolt11PaymentRequest.ToString(), - new PayInvoiceParams() - { - // CLN does not support explicit amount param if it is the same as the invoice amount - Amount = payoutData.Amount == bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC)? null: new LightMoney((decimal)payoutData.Amount, LightMoneyUnit.BTC) - }, cancellationToken); - if (result == null) throw new NoPaymentResultException(); - - string message = null; - if (result.Result == PayResult.Ok) - { - payoutData.State = result.Details?.Status switch - { - LightningPaymentStatus.Pending => PayoutState.InProgress, - _ => PayoutState.Completed, - }; - if (payoutData.State == PayoutState.Completed) - { - message = result.Details?.TotalAmount != null - ? $"Paid out {result.Details.TotalAmount.ToDecimal(LightMoneyUnit.BTC)}" - : null; - try - { - var payment = await lightningClient.GetPayment(bolt11PaymentRequest.PaymentHash.ToString(), - cancellationToken); - proofBlob.Preimage = payment.Preimage; - } - catch (Exception) - { - // ignored - } - } - } - else if (result.Result == PayResult.Unknown) - { - payoutData.State = PayoutState.InProgress; - } - if (payoutData.State == PayoutState.InProgress) - { - message = "The payment has been initiated but is still in-flight."; - } - - payoutData.SetProofBlob(proofBlob, null); - return new ResultVM - { - PayoutId = payoutData.Id, - Result = result.Result, - Destination = payoutBlob.Destination, - Message = message - }; - } - catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException or NoPaymentResultException) - { - // 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." - }; - } + PayoutId = payoutData.Id, + Result = PayResult.Error, + Destination = blob.Destination, + Message = message + }; } private async Task SetStoreContext() @@ -405,8 +227,4 @@ namespace BTCPayServer.Data.Payouts.LightningLike public decimal Amount { get; set; } } } - - public class NoPaymentResultException : Exception - { - } } diff --git a/BTCPayServer/HostedServices/PullPaymentHostedService.cs b/BTCPayServer/HostedServices/PullPaymentHostedService.cs index 14e79519e..8748d8f78 100644 --- a/BTCPayServer/HostedServices/PullPaymentHostedService.cs +++ b/BTCPayServer/HostedServices/PullPaymentHostedService.cs @@ -8,6 +8,7 @@ using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Events; +using BTCPayServer.Lightning; using BTCPayServer.Logging; using BTCPayServer.Models.WalletViewModels; using BTCPayServer.Payments; @@ -18,11 +19,13 @@ using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Notifications; using BTCPayServer.Services.Notifications.Blobs; using BTCPayServer.Services.Rates; +using Dapper; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using NBitcoin; using NBitcoin.DataEncoders; using NBXplorer; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using PayoutData = BTCPayServer.Data.PayoutData; using PullPaymentData = BTCPayServer.Data.PullPaymentData; @@ -534,6 +537,8 @@ namespace BTCPayServer.HostedServices if (cryptoAmount < minimumCryptoAmount) { req.Completion.TrySetResult(new PayoutApproval.ApprovalResult(PayoutApproval.Result.TooLowAmount, null)); + payout.State = PayoutState.Cancelled; + await ctx.SaveChangesAsync(); return; } @@ -583,6 +588,8 @@ namespace BTCPayServer.HostedServices break; } payout.State = req.Request.State; + if (req.Request.Blob is { } b) + payout.SetBlob(b, _jsonSerializerSettings); await ctx.SaveChangesAsync(); _eventAggregator.Publish(new PayoutEvent(PayoutEvent.PayoutEventType.Updated, payout)); req.Completion.SetResult(MarkPayoutRequest.PayoutPaidResult.Ok); @@ -657,13 +664,6 @@ namespace BTCPayServer.HostedServices } } - if (req.ClaimRequest.Value < - await payoutHandler.GetMinimumPayoutAmount(req.ClaimRequest.Destination)) - { - req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.AmountTooLow)); - return; - } - var payoutsRaw = withoutPullPayment ? null : await ctx.Payouts.Where(p => p.PullPaymentDataId == pp.Id) @@ -672,7 +672,7 @@ namespace BTCPayServer.HostedServices var payouts = payoutsRaw?.Select(o => new { Entity = o, Blob = o.GetBlob(_jsonSerializerSettings) }); var limit = pp?.Limit ?? 0; var totalPayout = payouts?.Select(p => p.Entity.OriginalAmount)?.Sum(); - var claimed = req.ClaimRequest.Value is decimal v ? v : limit - (totalPayout ?? 0); + var claimed = req.ClaimRequest.ClaimedAmount is decimal v ? v : limit - (totalPayout ?? 0); if (totalPayout is not null && totalPayout + claimed > limit) { req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Overdraft)); @@ -731,6 +731,22 @@ namespace BTCPayServer.HostedServices payout.State = PayoutState.AwaitingPayment; payout.Amount = approveResult.CryptoAmount; } + else if (approveResult.Result == PayoutApproval.Result.TooLowAmount) + { + payout.State = PayoutState.Cancelled; + await ctx.SaveChangesAsync(); + req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.AmountTooLow)); + return; + } + else + { + payout.State = PayoutState.Cancelled; + await ctx.SaveChangesAsync(); + // We returns Ok even if the approval failed. This is expected. + // Because the claim worked, what didn't is the approval + req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Ok)); + return; + } } } @@ -923,6 +939,7 @@ namespace BTCPayServer.HostedServices public string PayoutId { get; set; } public JObject Proof { get; set; } public PayoutState State { get; set; } = PayoutState.Completed; + public PayoutBlob Blob { get; internal set; } public static string GetErrorMessage(PayoutPaidResult result) { @@ -942,28 +959,40 @@ namespace BTCPayServer.HostedServices public class ClaimRequest { - public static (string error, decimal? amount) IsPayoutAmountOk(IClaimDestination destination, decimal? amount, string payoutCurrency = null, string ppCurrency = null) + public record ClaimedAmountResult { - return amount switch + public record Error(string Message) : ClaimedAmountResult; + public record Success(decimal? Amount) : ClaimedAmountResult; + } + + + public static ClaimedAmountResult GetClaimedAmount(IClaimDestination destination, decimal? amount, string payoutCurrency, string ppCurrency) + { + var amountsComparable = false; + var destinationAmount = destination.Amount; + if (destinationAmount is not null && + payoutCurrency == "BTC" && + ppCurrency == "SATS") { - null when destination.Amount is null && ppCurrency is null => ("Amount is not specified in destination or payout request", null), - null when destination.Amount is null => (null, null), - null when destination.Amount != null => (null, destination.Amount), - not null when destination.Amount is null => (null, amount), - not null when destination.Amount != null && amount != destination.Amount && - destination.IsExplicitAmountMinimum && - payoutCurrency == "BTC" && ppCurrency == "SATS" && - new Money(amount.Value, MoneyUnit.Satoshi).ToUnit(MoneyUnit.BTC) < destination.Amount => - ($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount", null), - not null when destination.Amount != null && amount != destination.Amount && - destination.IsExplicitAmountMinimum && - !(payoutCurrency == "BTC" && ppCurrency == "SATS") && - amount < destination.Amount => - ($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount", null), - not null when destination.Amount != null && amount != destination.Amount && - !destination.IsExplicitAmountMinimum => - ($"Amount is implied in destination ({destination.Amount}) that does not match the payout amount provided {amount})", null), - _ => (null, amount) + destinationAmount = new LightMoney(destinationAmount.Value, LightMoneyUnit.BTC).ToUnit(LightMoneyUnit.Satoshi); + amountsComparable = true; + } + if (destinationAmount is not null && payoutCurrency == ppCurrency) + { + amountsComparable = true; + } + return (destinationAmount, amount) switch + { + (null, null) when ppCurrency is null => new ClaimedAmountResult.Error("Amount is not specified in destination or payout request"), + ({ } a, null) when ppCurrency is null => new ClaimedAmountResult.Success(a), + (null, null) => new ClaimedAmountResult.Success(null), + ({ } a, null) when amountsComparable => new ClaimedAmountResult.Success(a), + (null, { } b) => new ClaimedAmountResult.Success(b), + ({ } a, { } b) when amountsComparable && a == b => new ClaimedAmountResult.Success(a), + ({ } a, { } b) when amountsComparable && a > b => new ClaimedAmountResult.Error($"The destination's amount ({a} {ppCurrency}) is more than the claimed amount ({b} {ppCurrency})."), + ({ } a, { } b) when amountsComparable && a < b => new ClaimedAmountResult.Success(a), + ({ } a, { } b) when !amountsComparable => new ClaimedAmountResult.Success(b), + _ => new ClaimedAmountResult.Success(amount) }; } @@ -1020,7 +1049,7 @@ namespace BTCPayServer.HostedServices public PayoutMethodId PayoutMethodId { get; set; } public string PullPaymentId { get; set; } - public decimal? Value { get; set; } + public decimal? ClaimedAmount { get; set; } public IClaimDestination Destination { get; set; } public string StoreId { get; set; } public bool? PreApprove { get; set; } diff --git a/BTCPayServer/Models/ViewPullPaymentModel.cs b/BTCPayServer/Models/ViewPullPaymentModel.cs index 4c88c316c..9b370ab28 100644 --- a/BTCPayServer/Models/ViewPullPaymentModel.cs +++ b/BTCPayServer/Models/ViewPullPaymentModel.cs @@ -78,7 +78,7 @@ namespace BTCPayServer.Models public bool IsPending { get; set; } public decimal AmountCollected { get; set; } public decimal AmountDue { get; set; } - public decimal ClaimedAmount { get; set; } + public decimal? ClaimedAmount { get; set; } public decimal MinimumClaim { get; set; } public string Destination { get; set; } public decimal Amount { get; set; } diff --git a/BTCPayServer/Payments/Lightning/LightningPendingPayoutListener.cs b/BTCPayServer/Payments/Lightning/LightningPendingPayoutListener.cs index 55047f742..708510ed1 100644 --- a/BTCPayServer/Payments/Lightning/LightningPendingPayoutListener.cs +++ b/BTCPayServer/Payments/Lightning/LightningPendingPayoutListener.cs @@ -73,17 +73,7 @@ public class LightningPendingPayoutListener : BaseAsyncService foreach (IGrouping payoutByStore in payouts.GroupBy(data => data.StoreDataId)) { - //this should never happen - if (!stores.TryGetValue(payoutByStore.Key, out var store)) - { - foreach (PayoutData payoutData in payoutByStore) - { - payoutData.State = PayoutState.Cancelled; - } - - continue; - } - + var store = stores[payoutByStore.Key]; foreach (IGrouping payoutByStoreByPaymentMethod in payoutByStore.GroupBy(data => data.PayoutMethodId)) { @@ -101,40 +91,37 @@ public class LightningPendingPayoutListener : BaseAsyncService pm.CreateLightningClient(networks[pmi], _options.Value, _lightningClientFactoryService); foreach (PayoutData payoutData in payoutByStoreByPaymentMethod) { - var handler = _payoutHandlers.TryGet(payoutData.GetPayoutMethodId()); - var proof = handler is null ? null : handler.ParseProof(payoutData); - switch (proof) - { - case null: - break; - case PayoutLightningBlob payoutLightningBlob: - { - LightningPayment payment = null; - try - { - payment = await client.GetPayment(payoutLightningBlob.Id, CancellationToken); - } - catch - { - } - 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; - } + var handler = _payoutHandlers.TryGet(payoutData.GetPayoutMethodId()) as LightningLikePayoutHandler; + if (handler is null || handler.PayoutsPaymentProcessing.Contains(payoutData.Id)) + continue; + var proof = handler.ParseProof(payoutData) as PayoutLightningBlob; - break; - } - } - } + LightningPayment payment = null; + try + { + if (proof is not null) + payment = await client.GetPayment(proof.Id, CancellationToken); + } + catch + { + } + if (payment is null) + { + payoutData.State = PayoutState.Cancelled; + continue; + } + switch (payment.Status) + { + case LightningPaymentStatus.Complete: + payoutData.State = PayoutState.Completed; + proof.Preimage = payment.Preimage; + payoutData.SetProofBlob(proof, null); + break; + case LightningPaymentStatus.Failed: + payoutData.State = PayoutState.Cancelled; + break; + } + } } } diff --git a/BTCPayServer/PayoutProcessors/Lightning/LightningAutomatedPayoutProcessor.cs b/BTCPayServer/PayoutProcessors/Lightning/LightningAutomatedPayoutProcessor.cs index ac57a58b7..91b1f77b4 100644 --- a/BTCPayServer/PayoutProcessors/Lightning/LightningAutomatedPayoutProcessor.cs +++ b/BTCPayServer/PayoutProcessors/Lightning/LightningAutomatedPayoutProcessor.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Client; @@ -18,10 +19,13 @@ using BTCPayServer.Payouts; using BTCPayServer.Services; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Stores; +using LNURL; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NBitcoin; +using Newtonsoft.Json.Linq; +using static BTCPayServer.Data.Payouts.LightningLike.UILightningLikePayoutController; using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest; using PayoutData = BTCPayServer.Data.PayoutData; using PayoutProcessorData = BTCPayServer.Data.PayoutProcessorData; @@ -30,117 +34,341 @@ namespace BTCPayServer.PayoutProcessors.Lightning; public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor { - private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings; - private readonly LightningClientFactoryService _lightningClientFactoryService; - private readonly UserService _userService; - private readonly IOptions _options; - private readonly PullPaymentHostedService _pullPaymentHostedService; - private readonly LightningLikePayoutHandler _payoutHandler; - public BTCPayNetwork Network => _payoutHandler.Network; - private readonly PaymentMethodHandlerDictionary _handlers; + private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings; + private readonly LightningClientFactoryService _lightningClientFactoryService; + private readonly UserService _userService; + private readonly IOptions _options; + private readonly PullPaymentHostedService _pullPaymentHostedService; + private readonly LightningLikePayoutHandler _payoutHandler; + public BTCPayNetwork Network => _payoutHandler.Network; + private readonly PaymentMethodHandlerDictionary _handlers; - public LightningAutomatedPayoutProcessor( - PayoutMethodId payoutMethodId, - BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings, - LightningClientFactoryService lightningClientFactoryService, - PayoutMethodHandlerDictionary payoutHandlers, - UserService userService, - ILoggerFactory logger, IOptions options, - StoreRepository storeRepository, PayoutProcessorData payoutProcessorSettings, - ApplicationDbContextFactory applicationDbContextFactory, - PaymentMethodHandlerDictionary handlers, - IPluginHookService pluginHookService, - EventAggregator eventAggregator, - PullPaymentHostedService pullPaymentHostedService) : - base(PaymentTypes.LN.GetPaymentMethodId(GetPayoutHandler(payoutHandlers, payoutMethodId).Network.CryptoCode), logger, storeRepository, payoutProcessorSettings, applicationDbContextFactory, - handlers, pluginHookService, eventAggregator) - { - _btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings; - _lightningClientFactoryService = lightningClientFactoryService; - _userService = userService; - _options = options; - _pullPaymentHostedService = pullPaymentHostedService; - _payoutHandler = GetPayoutHandler(payoutHandlers, payoutMethodId); - _handlers = handlers; - } - private static LightningLikePayoutHandler GetPayoutHandler(PayoutMethodHandlerDictionary payoutHandlers, PayoutMethodId payoutMethodId) - { - return (LightningLikePayoutHandler)payoutHandlers[payoutMethodId]; - } + public LightningAutomatedPayoutProcessor( + PayoutMethodId payoutMethodId, + BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings, + LightningClientFactoryService lightningClientFactoryService, + PayoutMethodHandlerDictionary payoutHandlers, + UserService userService, + ILoggerFactory logger, IOptions options, + StoreRepository storeRepository, PayoutProcessorData payoutProcessorSettings, + ApplicationDbContextFactory applicationDbContextFactory, + PaymentMethodHandlerDictionary handlers, + IPluginHookService pluginHookService, + EventAggregator eventAggregator, + PullPaymentHostedService pullPaymentHostedService) : + base(PaymentTypes.LN.GetPaymentMethodId(GetPayoutHandler(payoutHandlers, payoutMethodId).Network.CryptoCode), logger, storeRepository, payoutProcessorSettings, applicationDbContextFactory, + handlers, pluginHookService, eventAggregator) + { + _btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings; + _lightningClientFactoryService = lightningClientFactoryService; + _userService = userService; + _options = options; + _pullPaymentHostedService = pullPaymentHostedService; + _payoutHandler = GetPayoutHandler(payoutHandlers, payoutMethodId); + _handlers = handlers; + } + private static LightningLikePayoutHandler GetPayoutHandler(PayoutMethodHandlerDictionary payoutHandlers, PayoutMethodId payoutMethodId) + { + return (LightningLikePayoutHandler)payoutHandlers[payoutMethodId]; + } - private async Task HandlePayout(PayoutData payoutData, ILightningClient lightningClient) - { - if (payoutData.State != PayoutState.AwaitingPayment) - return; - var res = await _pullPaymentHostedService.MarkPaid(new MarkPayoutRequest() + public async Task HandlePayout(PayoutData payoutData, ILightningClient lightningClient, CancellationToken cancellationToken) + { + using var scope = _payoutHandler.PayoutsPaymentProcessing.StartTracking(); + if (payoutData.State != PayoutState.AwaitingPayment || !scope.TryTrack(payoutData.Id)) + return InvalidState(payoutData.Id); + var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings); + var res = await _pullPaymentHostedService.MarkPaid(new MarkPayoutRequest() + { + State = PayoutState.InProgress, + PayoutId = payoutData.Id, + Proof = null + }); + if (res != MarkPayoutRequest.PayoutPaidResult.Ok) + return InvalidState(payoutData.Id); + ResultVM result; + var claim = await _payoutHandler.ParseClaimDestination(blob.Destination, cancellationToken); + switch (claim.destination) { - State = PayoutState.InProgress, PayoutId = payoutData.Id, Proof = null - }); - if (res != MarkPayoutRequest.PayoutPaidResult.Ok) - { - return; + case LNURLPayClaimDestinaton lnurlPayClaimDestinaton: + var lnurlResult = await GetInvoiceFromLNURL(payoutData, _payoutHandler, blob, + lnurlPayClaimDestinaton, cancellationToken); + if (lnurlResult.Item2 is not null) + { + result = lnurlResult.Item2; + } + else + { + result = await TrypayBolt(lightningClient, blob, payoutData, lnurlResult.Item1, cancellationToken); + } + break; + + case BoltInvoiceClaimDestination item1: + result = await TrypayBolt(lightningClient, blob, payoutData, item1.PaymentRequest, cancellationToken); + break; + default: + result = new ResultVM + { + PayoutId = payoutData.Id, + Result = PayResult.Error, + Destination = blob.Destination, + Message = claim.error + }; + break; + } + + bool updateBlob = false; + if (result.Result is PayResult.Error or PayResult.CouldNotFindRoute && payoutData.State == PayoutState.AwaitingPayment) + { + var errorCount = IncrementErrorCount(blob); + updateBlob = true; + if (errorCount >= 10) + payoutData.State = PayoutState.Cancelled; + } + if (payoutData.State != PayoutState.InProgress || payoutData.Proof is not null) + { + await _pullPaymentHostedService.MarkPaid(new MarkPayoutRequest() + { + State = payoutData.State, + PayoutId = payoutData.Id, + Proof = payoutData.GetProofBlobJson(), + Blob = updateBlob ? blob : null + }); + } + return result; + } + + private ResultVM InvalidState(string payoutId) => + new ResultVM + { + PayoutId = payoutId, + Result = PayResult.Error, + Message = "The payout isn't in a valid state" + }; + + private int IncrementErrorCount(PayoutBlob blob) + { + int count; + if (blob.AdditionalData.TryGetValue("ErrorCount", out var v) && v.Type == JTokenType.Integer) + { + count = v.Value() + 1; + blob.AdditionalData["ErrorCount"] = count; + } + else + { + count = 1; + blob.AdditionalData.Add("ErrorCount", count); + } + return count; + } + + async Task<(BOLT11PaymentRequest, ResultVM)> GetInvoiceFromLNURL(PayoutData payoutData, + LightningLikePayoutHandler handler, PayoutBlob blob, LNURLPayClaimDestinaton lnurlPayClaimDestinaton, CancellationToken cancellationToken) + { + var endpoint = lnurlPayClaimDestinaton.LNURL.IsValidEmail() + ? LNURL.LNURL.ExtractUriFromInternetIdentifier(lnurlPayClaimDestinaton.LNURL) + : LNURL.LNURL.Parse(lnurlPayClaimDestinaton.LNURL, out _); + var httpClient = handler.CreateClient(endpoint); + var lnurlInfo = + (LNURLPayRequest)await LNURL.LNURL.FetchInformation(endpoint, "payRequest", + httpClient, cancellationToken); + var lm = new LightMoney(payoutData.Amount.Value, LightMoneyUnit.BTC); + if (lm > lnurlInfo.MaxSendable || lm < lnurlInfo.MinSendable) + { + + payoutData.State = PayoutState.Cancelled; + return (null, new ResultVM + { + PayoutId = payoutData.Id, + Result = PayResult.Error, + Destination = blob.Destination, + Message = + $"The LNURL provided would not generate an invoice of {lm.ToDecimal(LightMoneyUnit.Satoshi)} sats" + }); } - var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings); try { - var claim = await _payoutHandler.ParseClaimDestination(blob.Destination, CancellationToken); - switch (claim.destination) - { - case LNURLPayClaimDestinaton lnurlPayClaimDestinaton: - var lnurlResult = await UILightningLikePayoutController.GetInvoiceFromLNURL(payoutData, - _payoutHandler, blob, - lnurlPayClaimDestinaton, Network.NBitcoinNetwork, CancellationToken); - if (lnurlResult.Item2 is null) - { - await TrypayBolt(lightningClient, blob, payoutData, - lnurlResult.Item1); - } - break; - case BoltInvoiceClaimDestination item1: - await TrypayBolt(lightningClient, blob, payoutData, item1.PaymentRequest); - break; - } + var lnurlPayRequestCallbackResponse = + await lnurlInfo.SendRequest(lm, this.Network.NBitcoinNetwork, httpClient, cancellationToken: cancellationToken); + + return (lnurlPayRequestCallbackResponse.GetPaymentRequest(this.Network.NBitcoinNetwork), null); } - catch (Exception e) + catch (LNUrlException e) { - Logs.PayServer.LogError(e, $"Could not process payout {payoutData.Id}"); + return (null, + new ResultVM + { + PayoutId = payoutData.Id, + Result = PayResult.Error, + Destination = blob.Destination, + Message = e.Message + }); + } + } + + async Task TrypayBolt( + ILightningClient lightningClient, PayoutBlob payoutBlob, PayoutData payoutData, BOLT11PaymentRequest bolt11PaymentRequest, CancellationToken cancellationToken) + { + var boltAmount = bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC); + + // BoltAmount == 0: Any amount is OK. + // While we could allow paying more than the minimum amount from the boltAmount, + // Core-Lightning do not support it! It would just refuse to pay more than the boltAmount. + if (boltAmount != payoutData.Amount.Value && boltAmount != 0.0m) + { + payoutData.State = PayoutState.Cancelled; + return new ResultVM + { + PayoutId = payoutData.Id, + Result = PayResult.Error, + Message = $"The BOLT11 invoice amount ({boltAmount} {payoutData.Currency}) did not match the payout's amount ({payoutData.Amount.GetValueOrDefault()} {payoutData.Currency})", + Destination = payoutBlob.Destination + }; } - if (payoutData.State != PayoutState.InProgress || payoutData.Proof is not null) + if (bolt11PaymentRequest.ExpiryDate < DateTimeOffset.Now) { - await _pullPaymentHostedService.MarkPaid(new MarkPayoutRequest() + payoutData.State = PayoutState.Cancelled; + return new ResultVM { - State = payoutData.State, PayoutId = payoutData.Id, Proof = payoutData.GetProofBlobJson() - }); + 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() }; + PayResponse pay = null; + try + { + Exception exception = null; + try + { + pay = await lightningClient.Pay(bolt11PaymentRequest.ToString(), + new PayInvoiceParams() + { + Amount = new LightMoney((decimal)payoutData.Amount, LightMoneyUnit.BTC) + }, cancellationToken); + + if (pay?.Result is PayResult.CouldNotFindRoute) + { + // Payment failed for sure... we can try again later! + payoutData.State = PayoutState.AwaitingPayment; + return new ResultVM + { + PayoutId = payoutData.Id, + Result = PayResult.CouldNotFindRoute, + Message = $"Unable to find a route for the payment, check your channel liquidity", + Destination = payoutBlob.Destination + }; + } + } + catch (Exception ex) + { + exception = ex; + } + + LightningPayment payment = null; + try + { + payment = await lightningClient.GetPayment(bolt11PaymentRequest.PaymentHash.ToString(), cancellationToken); + } + catch (Exception ex) + { + exception = ex; + } + if (payment is null) + { + payoutData.State = PayoutState.Cancelled; + var exceptionMessage = ""; + if (exception is not null) + exceptionMessage = $" ({exception.Message})"; + if (exceptionMessage == "") + exceptionMessage = $" ({pay?.ErrorDetail})"; + return new ResultVM + { + PayoutId = payoutData.Id, + Result = PayResult.Error, + Message = $"Unable to confirm the payment of the invoice" + exceptionMessage, + Destination = payoutBlob.Destination + }; + } + if (payment.Preimage is not null) + proofBlob.Preimage = payment.Preimage; + + if (payment.Status == LightningPaymentStatus.Complete) + { + payoutData.State = PayoutState.Completed; + payoutData.SetProofBlob(proofBlob, null); + return new ResultVM + { + PayoutId = payoutData.Id, + Result = PayResult.Ok, + Destination = payoutBlob.Destination, + Message = payment.AmountSent != null + ? $"Paid out {payment.AmountSent.ToDecimal(LightMoneyUnit.BTC)} {payoutData.Currency}" + : "Paid out" + }; + } + else if (payment.Status == LightningPaymentStatus.Failed) + { + payoutData.State = PayoutState.AwaitingPayment; + string reason = ""; + if (pay?.ErrorDetail is string err) + reason = $" ({err})"; + return new ResultVM + { + PayoutId = payoutData.Id, + Result = PayResult.Error, + Destination = payoutBlob.Destination, + Message = $"The payment failed{reason}" + }; + } + else + { + payoutData.State = PayoutState.InProgress; + return new ResultVM + { + PayoutId = payoutData.Id, + Result = PayResult.Unknown, + Destination = payoutBlob.Destination, + Message = "The payment has been initiated but is still in-flight." + }; + } + } + catch (OperationCanceledException) + { + // 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." + }; } } protected override async Task ProcessShouldSave(object paymentMethodConfig, List payouts) - { - var processorBlob = GetBlob(PayoutProcessorSettings); - var lightningSupportedPaymentMethod = (LightningPaymentMethodConfig)paymentMethodConfig; - if (lightningSupportedPaymentMethod.IsInternalNode && - !await _storeRepository.InternalNodePayoutAuthorized(PayoutProcessorSettings.StoreId)) - { - return false; - } + { + var lightningSupportedPaymentMethod = (LightningPaymentMethodConfig)paymentMethodConfig; + if (lightningSupportedPaymentMethod.IsInternalNode && + !await _storeRepository.InternalNodePayoutAuthorized(PayoutProcessorSettings.StoreId)) + { + return false; + } - var client = - lightningSupportedPaymentMethod.CreateLightningClient(Network, _options.Value, - _lightningClientFactoryService); - await Task.WhenAll(payouts.Select(data => HandlePayout(data, client))); + var client = + lightningSupportedPaymentMethod.CreateLightningClient(Network, _options.Value, + _lightningClientFactoryService); + await Task.WhenAll(payouts.Select(data => HandlePayout(data, client, CancellationToken))); - //we return false because this processor handles db updates on its own - return false; - } - - //we group per store and init the transfers by each - async Task TrypayBolt(ILightningClient lightningClient, PayoutBlob payoutBlob, PayoutData payoutData, - BOLT11PaymentRequest bolt11PaymentRequest) - { - return (await UILightningLikePayoutController.TrypayBolt(lightningClient, payoutBlob, payoutData, - bolt11PaymentRequest, - CancellationToken)).Result is PayResult.Ok ; - } + //we return false because this processor handles db updates on its own + return false; + } } diff --git a/BTCPayServer/PayoutProcessors/Lightning/LightningAutomatedPayoutSenderFactory.cs b/BTCPayServer/PayoutProcessors/Lightning/LightningAutomatedPayoutSenderFactory.cs index 5c16b542e..31311d4ba 100644 --- a/BTCPayServer/PayoutProcessors/Lightning/LightningAutomatedPayoutSenderFactory.cs +++ b/BTCPayServer/PayoutProcessors/Lightning/LightningAutomatedPayoutSenderFactory.cs @@ -47,14 +47,17 @@ public class LightningAutomatedPayoutSenderFactory : IPayoutProcessorFactory public static string ProcessorName => nameof(LightningAutomatedPayoutSenderFactory); public IEnumerable GetSupportedPayoutMethods() => _supportedPayoutMethods; - public Task ConstructProcessor(PayoutProcessorData settings) + public LightningAutomatedPayoutProcessor ConstructProcessor(PayoutProcessorData settings) { if (settings.Processor != Processor) { throw new NotSupportedException("This processor cannot handle the provided requirements"); } var payoutMethodId = settings.GetPayoutMethodId(); - return Task.FromResult(ActivatorUtilities.CreateInstance(_serviceProvider, settings, payoutMethodId)); - + return ActivatorUtilities.CreateInstance(_serviceProvider, settings, payoutMethodId); + } + Task IPayoutProcessorFactory.ConstructProcessor(PayoutProcessorData settings) + { + return Task.FromResult(ConstructProcessor(settings)); } } diff --git a/BTCPayServer/ResourceTracker.cs b/BTCPayServer/ResourceTracker.cs new file mode 100644 index 000000000..a62c8f2ca --- /dev/null +++ b/BTCPayServer/ResourceTracker.cs @@ -0,0 +1,38 @@ +#nullable enable +using System; +using System.Collections.Concurrent; + +namespace BTCPayServer +{ + public class ResourceTracker where T: notnull + { + public class ScopedResourceTracker : IDisposable + { + private ResourceTracker _parent; + + public ScopedResourceTracker(ResourceTracker resourceTracker) + { + _parent = resourceTracker; + } + ConcurrentDictionary _Scoped = new(); + public bool TryTrack(T resource) + { + if (!_parent._TrackedResources.TryAdd(resource, string.Empty)) + return false; + _Scoped.TryAdd(resource, string.Empty); + return true; + } + + public bool Contains(T resource) => _Scoped.ContainsKey(resource); + + public void Dispose() + { + foreach (var d in _Scoped) + _parent._TrackedResources.TryRemove(d.Key, out _); + } + } + internal ConcurrentDictionary _TrackedResources = new(); + public ScopedResourceTracker StartTracking() => new ScopedResourceTracker(this); + public bool Contains(T resource) => _TrackedResources.ContainsKey(resource); + } +}