Add configurable BOLT11Expiration for refunds (Fix #3281) (#3341)

* Add configurable BOLT11Expiration for refunds (Fix #3281)

* Add BOLT11Expiration configuration in Payment
This commit is contained in:
Nicolas Dorier 2022-01-24 20:17:09 +09:00 committed by GitHub
parent 28dbf10a31
commit 090da6cfb6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 150 additions and 22 deletions

View file

@ -29,6 +29,17 @@ namespace BTCPayServer.Client.JsonConverters
return TimeSpan.FromMinutes(value);
}
}
public class Days : TimeSpanJsonConverter
{
protected override long ToLong(TimeSpan value)
{
return (long)value.TotalDays;
}
protected override TimeSpan ToTimespan(long value)
{
return TimeSpan.FromDays(value);
}
}
public override bool CanConvert(Type objectType)
{
return objectType == typeof(TimeSpan) || objectType == typeof(TimeSpan?);

View file

@ -13,6 +13,9 @@ namespace BTCPayServer.Client.Models
public string Currency { get; set; }
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
public TimeSpan? Period { get; set; }
[JsonConverter(typeof(TimeSpanJsonConverter.Days))]
[JsonProperty("BOLT11Expiration")]
public TimeSpan? BOLT11Expiration { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? ExpiresAt { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]

View file

@ -18,6 +18,9 @@ namespace BTCPayServer.Client.Models
public decimal Amount { get; set; }
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
public TimeSpan? Period { get; set; }
[JsonConverter(typeof(TimeSpanJsonConverter.Days))]
[JsonProperty("BOLT11Expiration")]
public TimeSpan BOLT11Expiration { get; set; }
public bool Archived { get; set; }
public string ViewLink { get; set; }
}

View file

@ -386,6 +386,9 @@ namespace BTCPayServer.Tests
// BTC crash by 50%
s.Server.PayTester.ChangeRate("BTC_USD", new Rating.BidAsk(5000.0m / 2.0m, 5100.0m / 2.0m));
s.GoToStore(StoreNavPages.Payment);
s.Driver.FindElement(By.Id("BOLT11Expiration")).Clear();
s.Driver.FindElement(By.Id("BOLT11Expiration")).SendKeys("5" + Keys.Enter);
s.GoToInvoice(invoice.Id);
s.Driver.FindElement(By.Id("refundlink")).Click();
if (multiCurrency)
@ -408,6 +411,11 @@ namespace BTCPayServer.Tests
s.GoToInvoice(invoice.Id);
s.Driver.FindElement(By.Id("refundlink")).Click();
Assert.Contains("pull-payments", s.Driver.Url);
var client = await user.CreateClient();
var ppid = s.Driver.Url.Split('/').Last();
var pps = await client.GetPullPayments(user.StoreId);
var pp = Assert.Single(pps, p => p.Id == ppid);
Assert.Equal(TimeSpan.FromDays(5.0), pp.BOLT11Expiration);
}
[Fact(Timeout = TestTimeout)]

View file

@ -1252,6 +1252,14 @@ namespace BTCPayServer.Tests
Assert.Equal(cache.States[0].Rates[0].Pair, cache2.States[0].Rates[0].Pair);
}
[Fact]
public void KitchenSinkTest()
{
var b = JsonConvert.DeserializeObject<PullPaymentBlob>("{}");
Assert.Equal(TimeSpan.FromDays(30.0), b.BOLT11Expiration);
var aaa = JsonConvert.SerializeObject(b);
}
[Fact]
public void CanParseRateRules()
{

View file

@ -389,14 +389,15 @@ namespace BTCPayServer.Tests
result = Assert.Single(pullPayments);
VerifyResult();
Thread.Sleep(1000);
var test2 = await client.CreatePullPayment(storeId, new Client.Models.CreatePullPaymentRequest()
{
Name = "Test 2",
Amount = 12.3m,
Currency = "BTC",
PaymentMethods = new[] { "BTC" }
PaymentMethods = new[] { "BTC" },
BOLT11Expiration = TimeSpan.FromDays(31.0)
});
Assert.Equal(TimeSpan.FromDays(31.0), test2.BOLT11Expiration);
TestLogs.LogInformation("Can't archive without knowing the walletId");
var ex = await AssertAPIError("missing-permission", async () => await client.ArchivePullPayment("lol", result.Id));
@ -405,6 +406,7 @@ namespace BTCPayServer.Tests
await AssertAPIError("unauthenticated", async () => await unauthenticated.ArchivePullPayment(storeId, result.Id));
await client.ArchivePullPayment(storeId, result.Id);
result = await unauthenticated.GetPullPayment(result.Id);
Assert.Equal(TimeSpan.FromDays(30.0), result.BOLT11Expiration);
Assert.True(result.Archived);
var pps = await client.GetPullPayments(storeId);
result = Assert.Single(pps);

View file

@ -99,6 +99,10 @@ namespace BTCPayServer.Controllers.Greenfield
{
ModelState.AddModelError(nameof(request.Period), $"The period should be positive");
}
if (request.BOLT11Expiration <= TimeSpan.Zero)
{
ModelState.AddModelError(nameof(request.BOLT11Expiration), $"The BOLT11 expiration should be positive");
}
PaymentMethodId?[]? paymentMethods = null;
if (request.PaymentMethods is { } paymentMethodsStr)
{
@ -127,6 +131,7 @@ namespace BTCPayServer.Controllers.Greenfield
StartsAt = request.StartsAt,
ExpiresAt = request.ExpiresAt,
Period = request.Period,
BOLT11Expiration = request.BOLT11Expiration,
Name = request.Name,
Amount = request.Amount,
Currency = request.Currency,
@ -150,6 +155,7 @@ namespace BTCPayServer.Controllers.Greenfield
Currency = ppBlob.Currency,
Period = ppBlob.Period,
Archived = pp.Archived,
BOLT11Expiration = ppBlob.BOLT11Expiration,
ViewLink = _linkGenerator.GetUriByAction(
nameof(UIPullPaymentController.ViewPullPayment),
"UIPullPayment",
@ -245,7 +251,7 @@ namespace BTCPayServer.Controllers.Greenfield
if (pp is null)
return PullPaymentNotFound();
var ppBlob = pp.GetBlob();
var destination = await payoutHandler.ParseClaimDestination(paymentMethodId, request!.Destination, true);
var destination = await payoutHandler.ParseAndValidateClaimDestination(paymentMethodId, request!.Destination, ppBlob);
if (destination.destination is null)
{
ModelState.AddModelError(nameof(request.Destination), destination.error ?? "The destination is invalid for the payment specified");

View file

@ -285,7 +285,8 @@ namespace BTCPayServer.Controllers
{
Name = $"Refund {invoice.Id}",
PaymentMethodIds = new[] { paymentMethodId },
StoreId = invoice.StoreId
StoreId = invoice.StoreId,
BOLT11Expiration = store.GetStoreBlob().RefundBOLT11Expiration
};
switch (model.SelectedRefundOption)
{

View file

@ -115,7 +115,7 @@ namespace BTCPayServer.Controllers
ModelState.AddModelError(nameof(vm.SelectedPaymentMethod), $"Invalid destination with selected payment method");
return await ViewPullPayment(pullPaymentId);
}
var destination = await payoutHandler?.ParseClaimDestination(paymentMethodId, vm.Destination, true);
var destination = await payoutHandler?.ParseAndValidateClaimDestination(paymentMethodId, vm.Destination, ppBlob);
if (destination.destination is null)
{
ModelState.AddModelError(nameof(vm.Destination), destination.error ?? "Invalid destination with selected payment method");

View file

@ -134,7 +134,8 @@ namespace BTCPayServer.Controllers
StoreId = storeId,
PaymentMethodIds = selectedPaymentMethodIds,
EmbeddedCSS = model.EmbeddedCSS,
CustomCSSLink = model.CustomCSSLink
CustomCSSLink = model.CustomCSSLink,
BOLT11Expiration = TimeSpan.FromDays(model.BOLT11Expiration)
});
this.TempData.SetStatusMessageModel(new StatusMessageModel()
{

View file

@ -604,7 +604,8 @@ namespace BTCPayServer.Controllers
AnyoneCanCreateInvoice = storeBlob.AnyoneCanInvoice,
PaymentTolerance = storeBlob.PaymentTolerance,
InvoiceExpiration = (int)storeBlob.InvoiceExpiration.TotalMinutes,
DefaultCurrency = storeBlob.DefaultCurrency
DefaultCurrency = storeBlob.DefaultCurrency,
BOLT11Expiration = (long)storeBlob.RefundBOLT11Expiration.TotalDays
};
return View(vm);
@ -620,6 +621,7 @@ namespace BTCPayServer.Controllers
blob.PaymentTolerance = model.PaymentTolerance;
blob.DefaultCurrency = model.DefaultCurrency;
blob.InvoiceExpiration = TimeSpan.FromMinutes(model.InvoiceExpiration);
blob.RefundBOLT11Expiration = TimeSpan.FromDays(model.BOLT11Expiration);
if (CurrentStore.SetStoreBlob(blob))
{

View file

@ -68,7 +68,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
await explorerClient.TrackAsync(TrackedSource.Create(bitcoinLikeClaimDestination.Address));
}
public Task<(IClaimDestination destination, string error)> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination, bool validate)
public Task<(IClaimDestination destination, string error)> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination)
{
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethodId.CryptoCode);
destination = destination.Trim();
@ -88,6 +88,11 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
}
}
public (bool valid, string error) ValidateClaimDestination(IClaimDestination claimDestination, PullPaymentBlob pullPaymentBlob)
{
return (true, null);
}
public IPayoutProof ParseProof(PayoutData payout)
{
if (payout?.Proof is null)
@ -251,7 +256,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
var blob = payout.GetBlob(_jsonSerializerSettings);
if (payout.GetPaymentMethodId() != paymentMethodId)
continue;
var claim = await ParseClaimDestination(paymentMethodId, blob.Destination, false);
var claim = await ParseClaimDestination(paymentMethodId, blob.Destination);
switch (claim.destination)
{
case UriClaimDestination uriClaimDestination:

View file

@ -14,7 +14,18 @@ public interface IPayoutHandler
public bool CanHandle(PaymentMethodId paymentMethod);
public Task TrackClaim(PaymentMethodId paymentMethodId, IClaimDestination claimDestination);
//Allows payout handler to parse payout destinations on its own
public Task<(IClaimDestination destination, string error)> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination, bool validate);
public Task<(IClaimDestination destination, string error)> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination);
public (bool valid, string error) ValidateClaimDestination(IClaimDestination claimDestination, PullPaymentBlob pullPaymentBlob);
public async Task<(IClaimDestination destination, string error)> ParseAndValidateClaimDestination(PaymentMethodId paymentMethodId, string destination, PullPaymentBlob pullPaymentBlob)
{
var res = await ParseClaimDestination(paymentMethodId, destination);
if (res.destination is null)
return res;
var res2 = ValidateClaimDestination(res.destination, pullPaymentBlob);
if (!res2.valid)
return (null, res2.error);
return res;
}
public IPayoutProof ParseProof(PayoutData payout);
//Allows you to subscribe the main pull payment hosted service to events and prepare the handler
void StartBackgroundCheck(Action<Type[]> subscribe);

View file

@ -57,7 +57,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
: LightningLikePayoutHandlerClearnetNamedClient);
}
public async Task<(IClaimDestination destination, string error)> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination, bool validate)
public async Task<(IClaimDestination destination, string error)> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination)
{
destination = destination.Trim();
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethodId.CryptoCode);
@ -93,12 +93,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
: null;
if (result == null)
return (null, "A valid BOLT11 invoice (with 30+ day expiry) or LNURL Pay or Lightning address was not provided.");
if (validate && (invoice.ExpiryDate.UtcDateTime - DateTime.UtcNow).Days < 30)
{
return (null,
$"The BOLT11 invoice must have an expiry date of at least 30 days from submission (Provided was only {(invoice.ExpiryDate.UtcDateTime - DateTime.UtcNow).Days}).");
}
return (null, "A valid BOLT11 invoice or LNURL Pay or Lightning address was not provided.");
if (invoice.ExpiryDate.UtcDateTime < DateTime.UtcNow)
{
return (null,
@ -108,6 +103,19 @@ namespace BTCPayServer.Data.Payouts.LightningLike
return (result, null);
}
public (bool valid, string error) ValidateClaimDestination(IClaimDestination claimDestination, PullPaymentBlob pullPaymentBlob)
{
if (claimDestination is not BoltInvoiceClaimDestination bolt)
return (true, null);
var invoice = bolt.PaymentRequest;
if ((invoice.ExpiryDate.UtcDateTime - DateTime.UtcNow) < pullPaymentBlob.BOLT11Expiration)
{
return (false,
$"The BOLT11 invoice must have an expiry date of at least {(long)pullPaymentBlob.BOLT11Expiration.TotalDays} days from submission (Provided was only {(invoice.ExpiryDate.UtcDateTime - DateTime.UtcNow).Days}).");
}
return (true, null);
}
public IPayoutProof ParseProof(PayoutData payout)
{
return null;

View file

@ -190,7 +190,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
foreach (var payoutData in payoutDatas)
{
var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings);
var claim = await payoutHandler.ParseClaimDestination(pmi, blob.Destination, false);
var claim = await payoutHandler.ParseClaimDestination(pmi, blob.Destination);
try
{
switch (claim.destination)

View file

@ -1,4 +1,5 @@
using System;
using System.ComponentModel;
using BTCPayServer.Client.JsonConverters;
using BTCPayServer.JsonConverters;
using BTCPayServer.Payments;
@ -19,6 +20,12 @@ namespace BTCPayServer.Data
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
public TimeSpan? Period { get; set; }
[DefaultValue(typeof(TimeSpan), "30.00:00:00")]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate)]
[JsonConverter(typeof(TimeSpanJsonConverter.Days))]
public TimeSpan BOLT11Expiration { get; set; }
[JsonProperty(ItemConverterType = typeof(PaymentMethodIdJsonConverter))]
public PaymentMethodId[] SupportedPaymentMethods { get; set; }

View file

@ -174,6 +174,11 @@ namespace BTCPayServer.Data
[JsonExtensionData]
public IDictionary<string, JToken> AdditionalData { get; set; } = new Dictionary<string, JToken>();
[DefaultValue(typeof(TimeSpan), "30.00:00:00")]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate)]
[JsonConverter(typeof(TimeSpanJsonConverter.Days))]
public TimeSpan RefundBOLT11Expiration { get; set; }
public class StoreHints
{
public bool Wallet { get; set; }

View file

@ -34,6 +34,7 @@ namespace BTCPayServer.HostedServices
public string EmbeddedCSS { get; set; }
public PaymentMethodId[] PaymentMethodIds { get; set; }
public TimeSpan? Period { get; set; }
public TimeSpan? BOLT11Expiration { get; set; }
}
public class PullPaymentHostedService : BaseAsyncService
{
@ -113,7 +114,8 @@ namespace BTCPayServer.HostedServices
CustomCSSLink = create.CustomCSSLink,
Email = null,
EmbeddedCSS = create.EmbeddedCSS,
}
},
BOLT11Expiration = create.BOLT11Expiration ?? TimeSpan.FromDays(30.0)
});
ctx.PullPayments.Add(o);
await ctx.SaveChangesAsync();
@ -296,7 +298,7 @@ namespace BTCPayServer.HostedServices
var payoutHandler = _payoutHandlers.FindPayoutHandler(paymentMethod);
if (payoutHandler is null)
throw new InvalidOperationException($"No payout handler for {paymentMethod}");
var dest = await payoutHandler.ParseClaimDestination(paymentMethod, payoutBlob.Destination, false);
var dest = await payoutHandler.ParseClaimDestination(paymentMethod, payoutBlob.Destination);
decimal minimumCryptoAmount = await payoutHandler.GetMinimumPayoutAmount(paymentMethod, dest.destination);
if (cryptoAmount < minimumCryptoAmount)
{

View file

@ -203,7 +203,7 @@ namespace BTCPayServer.Hosting
{
continue;
}
var claim = await handler?.ParseClaimDestination(pmi, payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings).Destination, false);
var claim = await handler?.ParseClaimDestination(pmi, payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings).Destination);
payoutData.Destination = claim.destination?.Id;
}
await ctx.SaveChangesAsync();

View file

@ -24,5 +24,9 @@ namespace BTCPayServer.Models.StoreViewModels
[Display(Name = "Default currency")]
[MaxLength(10)]
public string DefaultCurrency { get; set; }
[Display(Name = "Minimum acceptable expiration time for BOLT11 for refunds")]
[Range(1, 365 * 10)]
public long BOLT11Expiration { get; set; }
}
}

View file

@ -52,5 +52,8 @@ namespace BTCPayServer.Models.WalletViewModels
[Display(Name = "Payment Methods")]
public IEnumerable<string> PaymentMethods { get; set; }
public IEnumerable<SelectListItem> PaymentMethodItems { get; set; }
[Display(Name = "Minimum acceptable expiration time for BOLT11 for refunds")]
[Range(1, 365 * 10)]
public long BOLT11Expiration { get; set; } = 30;
}
}

View file

@ -73,6 +73,25 @@
</div>
</div>
</div>
<div class="accordion-item"> <h2 class="accordion-header" id="additional-custom-css-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#additional-lightning" aria-expanded="false" aria-controls="additional-lightning">
Lightning network settings
<vc:icon symbol="caret-down"/>
</button>
</h2>
<div id="additional-lightning" class="accordion-collapse collapse" aria-labelledby="additional-lightning-header">
<div class="accordion-body">
<div class="form-group">
<label asp-for="BOLT11Expiration" class="form-label"></label>
<div class="input-group">
<input asp-for="BOLT11Expiration" class="form-control" style="max-width:10ch;"/>
<span class="input-group-text">days</span>
</div>
<span asp-validation-for="BOLT11Expiration" class="text-danger"></span>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -57,7 +57,14 @@
</div>
<span asp-validation-for="PaymentTolerance" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="BOLT11Expiration" class="form-label"></label>
<div class="input-group">
<input asp-for="BOLT11Expiration" class="form-control" style="max-width:10ch;"/>
<span class="input-group-text">days</span>
</div>
<span asp-validation-for="BOLT11Expiration" class="text-danger"></span>
</div>
<button name="command" type="submit" class="btn btn-primary px-4 mt-3" value="Save" id="Save">Save</button>
</form>
</div>

View file

@ -81,6 +81,13 @@
"nullable": true,
"description": "The length of each period in seconds."
},
"BOLT11Expiration": {
"type": "string",
"example": 30,
"default": 30,
"nullable": true,
"description": "If lightning is activated, do not accept BOLT11 invoices with expiration less than … days"
},
"startsAt": {
"type": "integer",
"format": "unix timestamp in seconds",
@ -657,6 +664,11 @@
"nullable": true,
"description": "The length of each period in seconds"
},
"BOLT11Expiration": {
"type": "string",
"example": 30,
"description": "If lightning is activated, do not accept BOLT11 invoices with expiration less than … days"
},
"archived": {
"type": "boolean",
"description": "Whether this pull payment is archived"