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 <nicolas.dorier@gmail.com>
This commit is contained in:
Andrew Camilleri 2023-07-24 13:40:26 +02:00 committed by GitHub
parent 36ea17a6b7
commit 95a0614ae1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 60 additions and 37 deletions

View File

@ -3586,7 +3586,17 @@ namespace BTCPayServer.Tests
sourceLink = "https://chocolate.com" sourceLink = "https://chocolate.com"
}).ToString()); }).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)] [Fact(Timeout = 60 * 2 * 1000)]

View File

@ -48,7 +48,7 @@
<PackageReference Include="YamlDotNet" Version="8.0.0" /> <PackageReference Include="YamlDotNet" Version="8.0.0" />
<PackageReference Include="BIP78.Sender" Version="0.2.2" /> <PackageReference Include="BIP78.Sender" Version="0.2.2" />
<PackageReference Include="BTCPayServer.Hwi" Version="2.0.2" /> <PackageReference Include="BTCPayServer.Hwi" Version="2.0.2" />
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.4.28" /> <PackageReference Include="BTCPayServer.Lightning.All" Version="1.4.29" />
<PackageReference Include="CsvHelper" Version="15.0.5" /> <PackageReference Include="CsvHelper" Version="15.0.5" />
<PackageReference Include="Dapper" Version="2.0.123" /> <PackageReference Include="Dapper" Version="2.0.123" />
<PackageReference Include="Fido2" Version="2.0.2" /> <PackageReference Include="Fido2" Version="2.0.2" />

View File

@ -324,20 +324,13 @@ namespace BTCPayServer.Controllers.Greenfield
return this.CreateValidationError(ModelState); 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; ModelState.AddModelError(nameof(request.Amount), amtError.error );
}
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})");
return this.CreateValidationError(ModelState); return this.CreateValidationError(ModelState);
} }
request.Amount = amtError.amount;
var result = await _pullPaymentService.Claim(new ClaimRequest() var result = await _pullPaymentService.Claim(new ClaimRequest()
{ {
Destination = destination.destination, Destination = destination.destination,
@ -395,15 +388,13 @@ namespace BTCPayServer.Controllers.Greenfield
return this.CreateValidationError(ModelState); 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; ModelState.AddModelError(nameof(request.Amount), amtError.error );
}
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); return this.CreateValidationError(ModelState);
} }
request.Amount = amtError.amount;
if (request.Amount is { } v && (v < ppBlob?.MinimumClaim || v == 0.0m)) if (request.Amount is { } v && (v < ppBlob?.MinimumClaim || v == 0.0m))
{ {
var minimumClaim = ppBlob?.MinimumClaim is decimal val ? val : 0.0m; var minimumClaim = ppBlob?.MinimumClaim is decimal val ? val : 0.0m;

View File

@ -200,20 +200,14 @@ namespace BTCPayServer.Controllers
return await ViewPullPayment(pullPaymentId); 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; vm.ClaimedAmount = amtError.amount.Value;
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}).");
}
} }
if (!ModelState.IsValid) if (!ModelState.IsValid)

View File

@ -6,5 +6,6 @@ namespace BTCPayServer.Data
{ {
public string? Id { get; } public string? Id { get; }
decimal? Amount { get; } decimal? Amount { get; }
bool IsExplicitAmountMinimum => false;
} }
} }

View File

@ -23,5 +23,6 @@ namespace BTCPayServer.Data.Payouts.LightningLike
public uint256 PaymentHash { get; } public uint256 PaymentHash { get; }
public string Id => PaymentHash.ToString(); public string Id => PaymentHash.ToString();
public decimal? Amount { get; } public decimal? Amount { get; }
public bool IsExplicitAmountMinimum => true;
} }
} }

View File

@ -264,7 +264,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
PaymentMethodId pmi, CancellationToken cancellationToken) PaymentMethodId pmi, CancellationToken cancellationToken)
{ {
var boltAmount = bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC); var boltAmount = bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC);
if (boltAmount != payoutBlob.CryptoAmount) if (boltAmount > payoutBlob.CryptoAmount)
{ {
payoutData.State = PayoutState.Cancelled; payoutData.State = PayoutState.Cancelled;
@ -295,9 +295,8 @@ namespace BTCPayServer.Data.Payouts.LightningLike
var result = await lightningClient.Pay(bolt11PaymentRequest.ToString(), var result = await lightningClient.Pay(bolt11PaymentRequest.ToString(),
new PayInvoiceParams() new PayInvoiceParams()
{ {
Amount = bolt11PaymentRequest.MinimumAmount == LightMoney.Zero // CLN does not support explicit amount param if it is the same as the invoice amount
? new LightMoney((decimal)payoutBlob.CryptoAmount, LightMoneyUnit.BTC) Amount = payoutBlob.CryptoAmount == bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC)? null: new LightMoney((decimal)payoutBlob.CryptoAmount, LightMoneyUnit.BTC)
: null
}, cancellationToken); }, cancellationToken);
string message = null; string message = null;
if (result.Result == PayResult.Ok) if (result.Result == PayResult.Ok)

View File

@ -834,6 +834,31 @@ namespace BTCPayServer.HostedServices
public class ClaimRequest 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) public static string GetErrorMessage(ClaimResult result)
{ {
switch (result) switch (result)

View File

@ -0,0 +1,2 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=LNURL/@EntryIndexedValue">LNURL</s:String></wpf:ResourceDictionary>