From 95a0614ae165bd489f8bc053252af8fe81ce696b Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Mon, 24 Jul 2023 13:40:26 +0200 Subject: [PATCH] Support accepting 0 amount bolt 11 invoices for payouts (#4014) * Support accepting 0 amount bolt 11 invoices for payouts * add test * handle validation better * fix case when we just want pp to provide amt * Update BTCPayServer/HostedServices/PullPaymentHostedService.cs * Update BTCPayServer/HostedServices/PullPaymentHostedService.cs * Update BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs * Update UILightningLikePayoutController.cs * fix null * fix payments of payouts on cln * add comment * bump lightning lib --------- Co-authored-by: Nicolas Dorier --- BTCPayServer.Tests/GreenfieldAPITests.cs | 14 ++++++++-- BTCPayServer/BTCPayServer.csproj | 2 +- .../GreenfieldPullPaymentController.cs | 27 +++++++------------ .../Controllers/UIPullPaymentController.cs | 18 +++++-------- .../Data/Payouts/IClaimDestination.cs | 1 + .../BoltInvoiceClaimDestination.cs | 1 + .../UILightningLikePayoutController.cs | 7 +++-- .../PullPaymentHostedService.cs | 25 +++++++++++++++++ btcpayserver.sln.DotSettings | 2 ++ 9 files changed, 60 insertions(+), 37 deletions(-) create mode 100644 btcpayserver.sln.DotSettings diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index 7266bac59..6242c8c92 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -3557,7 +3557,7 @@ namespace BTCPayServer.Tests (await adminClient.GetStorePayouts(admin.StoreId, false)).Single(data => data.Id == payout.Id); Assert.Equal(PayoutState.Completed, payoutC.State); }); - + payout = await adminClient.CreatePayout(admin.StoreId, new CreatePayoutThroughStoreRequest() { @@ -3585,8 +3585,18 @@ namespace BTCPayServer.Tests source = "apitest", sourceLink = "https://chocolate.com" }).ToString()); - + customerInvoice = await tester.CustomerLightningD.CreateInvoice(LightMoney.FromUnit(10, LightMoneyUnit.Satoshi), + Guid.NewGuid().ToString(), TimeSpan.FromDays(40)); + var payout2 = await adminClient.CreatePayout(admin.StoreId, + new CreatePayoutThroughStoreRequest() + { + Approved = true, + Amount = new Money(100, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC), + PaymentMethod = "BTC_LightningNetwork", + Destination = customerInvoice.BOLT11 + }); + Assert.Equal(payout2.Amount, new Money(100, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC)); } [Fact(Timeout = 60 * 2 * 1000)] diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 7766e5156..73ebcf387 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -48,7 +48,7 @@ - + diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldPullPaymentController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldPullPaymentController.cs index ad739ca68..f2167241d 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldPullPaymentController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldPullPaymentController.cs @@ -323,21 +323,14 @@ namespace BTCPayServer.Controllers.Greenfield ModelState.AddModelError(nameof(request.Destination), destination.error ?? "The destination is invalid for the payment specified"); return this.CreateValidationError(ModelState); } - - if (request.Amount is null && destination.destination.Amount != null) + + var amtError = ClaimRequest.IsPayoutAmountOk(destination.destination, request.Amount, paymentMethodId.CryptoCode, ppBlob.Currency); + if (amtError.error is not null) { - request.Amount = destination.destination.Amount; - } - else if (request.Amount != null && destination.destination.Amount != null && request.Amount != destination.destination.Amount) - { - ModelState.AddModelError(nameof(request.Amount), $"Amount is implied in destination ({destination.destination.Amount}) that does not match the payout amount provided {request.Amount})"); - return this.CreateValidationError(ModelState); - } - if (request.Amount is { } v && (v < ppBlob.MinimumClaim || v == 0.0m)) - { - ModelState.AddModelError(nameof(request.Amount), $"Amount too small (should be at least {ppBlob.MinimumClaim})"); + ModelState.AddModelError(nameof(request.Amount), amtError.error ); return this.CreateValidationError(ModelState); } + request.Amount = amtError.amount; var result = await _pullPaymentService.Claim(new ClaimRequest() { Destination = destination.destination, @@ -395,15 +388,13 @@ namespace BTCPayServer.Controllers.Greenfield return this.CreateValidationError(ModelState); } - if (request.Amount is null && destination.destination.Amount != null) + var amtError = ClaimRequest.IsPayoutAmountOk(destination.destination, request.Amount); + if (amtError.error is not null) { - request.Amount = destination.destination.Amount; - } - else if (request.Amount != null && destination.destination.Amount != null && request.Amount != destination.destination.Amount) - { - ModelState.AddModelError(nameof(request.Amount), $"Amount is implied in destination ({destination.destination.Amount}) that does not match the payout amount provided {request.Amount})"); + ModelState.AddModelError(nameof(request.Amount), amtError.error ); return this.CreateValidationError(ModelState); } + request.Amount = amtError.amount; if (request.Amount is { } v && (v < ppBlob?.MinimumClaim || v == 0.0m)) { var minimumClaim = ppBlob?.MinimumClaim is decimal val ? val : 0.0m; diff --git a/BTCPayServer/Controllers/UIPullPaymentController.cs b/BTCPayServer/Controllers/UIPullPaymentController.cs index f384040c8..ee79c7197 100644 --- a/BTCPayServer/Controllers/UIPullPaymentController.cs +++ b/BTCPayServer/Controllers/UIPullPaymentController.cs @@ -199,21 +199,15 @@ namespace BTCPayServer.Controllers ModelState.AddModelError(nameof(vm.Destination), destination.error ?? "Invalid destination with selected payment method"); return await ViewPullPayment(pullPaymentId); } - - if (vm.ClaimedAmount == 0) + + var amtError = ClaimRequest.IsPayoutAmountOk(destination.destination, vm.ClaimedAmount == 0? null: vm.ClaimedAmount, paymentMethodId.CryptoCode, ppBlob.Currency); + if (amtError.error is not null) { - ModelState.AddModelError(nameof(vm.ClaimedAmount), "Amount is required"); + ModelState.AddModelError(nameof(vm.ClaimedAmount), amtError.error ); } - else + else if (amtError.amount is not null) { - var amount = ppBlob.Currency == "SATS" ? new Money(vm.ClaimedAmount, MoneyUnit.Satoshi).ToUnit(MoneyUnit.BTC) : vm.ClaimedAmount; - if (destination.destination.Amount != null && amount != destination.destination.Amount) - { - var implied = _displayFormatter.Currency(destination.destination.Amount.Value, paymentMethodId.CryptoCode, DisplayFormatter.CurrencyFormat.Symbol); - var provided = _displayFormatter.Currency(vm.ClaimedAmount, ppBlob.Currency, DisplayFormatter.CurrencyFormat.Symbol); - ModelState.AddModelError(nameof(vm.ClaimedAmount), - $"Amount implied in destination ({implied}) does not match the payout amount provided ({provided})."); - } + vm.ClaimedAmount = amtError.amount.Value; } if (!ModelState.IsValid) diff --git a/BTCPayServer/Data/Payouts/IClaimDestination.cs b/BTCPayServer/Data/Payouts/IClaimDestination.cs index 62f1ac8d3..c2bcf9544 100644 --- a/BTCPayServer/Data/Payouts/IClaimDestination.cs +++ b/BTCPayServer/Data/Payouts/IClaimDestination.cs @@ -6,5 +6,6 @@ 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 c4c45b8ff..ea6693021 100644 --- a/BTCPayServer/Data/Payouts/LightningLike/BoltInvoiceClaimDestination.cs +++ b/BTCPayServer/Data/Payouts/LightningLike/BoltInvoiceClaimDestination.cs @@ -23,5 +23,6 @@ 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/UILightningLikePayoutController.cs b/BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs index 0e2e9aef9..b3cdd90c3 100644 --- a/BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs +++ b/BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs @@ -264,7 +264,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike PaymentMethodId pmi, CancellationToken cancellationToken) { var boltAmount = bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC); - if (boltAmount != payoutBlob.CryptoAmount) + if (boltAmount > payoutBlob.CryptoAmount) { payoutData.State = PayoutState.Cancelled; @@ -295,9 +295,8 @@ namespace BTCPayServer.Data.Payouts.LightningLike var result = await lightningClient.Pay(bolt11PaymentRequest.ToString(), new PayInvoiceParams() { - Amount = bolt11PaymentRequest.MinimumAmount == LightMoney.Zero - ? new LightMoney((decimal)payoutBlob.CryptoAmount, LightMoneyUnit.BTC) - : null + // CLN does not support explicit amount param if it is the same as the invoice amount + Amount = payoutBlob.CryptoAmount == bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC)? null: new LightMoney((decimal)payoutBlob.CryptoAmount, LightMoneyUnit.BTC) }, cancellationToken); string message = null; if (result.Result == PayResult.Ok) diff --git a/BTCPayServer/HostedServices/PullPaymentHostedService.cs b/BTCPayServer/HostedServices/PullPaymentHostedService.cs index 699012338..734fc9dcc 100644 --- a/BTCPayServer/HostedServices/PullPaymentHostedService.cs +++ b/BTCPayServer/HostedServices/PullPaymentHostedService.cs @@ -834,6 +834,31 @@ namespace BTCPayServer.HostedServices public class ClaimRequest { + public static (string error, decimal? amount) IsPayoutAmountOk(IClaimDestination destination, decimal? amount, string payoutCurrency = null, string ppCurrency = null) + { + return amount switch + { + 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) + }; + } + public static string GetErrorMessage(ClaimResult result) { switch (result) diff --git a/btcpayserver.sln.DotSettings b/btcpayserver.sln.DotSettings new file mode 100644 index 000000000..d38abe562 --- /dev/null +++ b/btcpayserver.sln.DotSettings @@ -0,0 +1,2 @@ + + LNURL \ No newline at end of file