Prevent double BOLT11 payment with LNUrlWithdraw

This commit is contained in:
nicolas.dorier
2024-10-09 10:47:06 +09:00
parent 9b1052f023
commit c0aa9a8bd4
6 changed files with 94 additions and 65 deletions

View File

@@ -19,14 +19,14 @@ public partial class BTCPayServerClient
await SendHttpRequest($"api/v1/stores/{storeId}/payout-processors/{processor}/{paymentMethod}", null, HttpMethod.Delete, token); await SendHttpRequest($"api/v1/stores/{storeId}/payout-processors/{processor}/{paymentMethod}", null, HttpMethod.Delete, token);
} }
public virtual async Task<IEnumerable<LightningAutomatedPayoutSettings>> GetStoreLightningAutomatedPayoutProcessors(string storeId, string? paymentMethod = null, CancellationToken token = default) public virtual async Task<IEnumerable<LightningAutomatedPayoutSettings>> GetStoreLightningAutomatedPayoutProcessors(string storeId, string? payoutMethodId = null, CancellationToken token = default)
{ {
return await SendHttpRequest<IEnumerable<LightningAutomatedPayoutSettings>>($"api/v1/stores/{storeId}/payout-processors/LightningAutomatedPayoutSenderFactory{(paymentMethod is null ? string.Empty : $"/{paymentMethod}")}", null, HttpMethod.Get, token); return await SendHttpRequest<IEnumerable<LightningAutomatedPayoutSettings>>($"api/v1/stores/{storeId}/payout-processors/LightningAutomatedPayoutSenderFactory{(payoutMethodId is null ? string.Empty : $"/{payoutMethodId}")}", null, HttpMethod.Get, token);
} }
public virtual async Task<LightningAutomatedPayoutSettings> UpdateStoreLightningAutomatedPayoutProcessors(string storeId, string paymentMethod, LightningAutomatedPayoutSettings request, CancellationToken token = default) public virtual async Task<LightningAutomatedPayoutSettings> UpdateStoreLightningAutomatedPayoutProcessors(string storeId, string payoutMethodId, LightningAutomatedPayoutSettings request, CancellationToken token = default)
{ {
return await SendHttpRequest<LightningAutomatedPayoutSettings>($"api/v1/stores/{storeId}/payout-processors/LightningAutomatedPayoutSenderFactory/{paymentMethod}", request, HttpMethod.Put, token); return await SendHttpRequest<LightningAutomatedPayoutSettings>($"api/v1/stores/{storeId}/payout-processors/LightningAutomatedPayoutSenderFactory/{payoutMethodId}", request, HttpMethod.Put, token);
} }
public virtual async Task<OnChainAutomatedPayoutSettings> UpdateStoreOnChainAutomatedPayoutProcessors(string storeId, string paymentMethod, OnChainAutomatedPayoutSettings request, CancellationToken token = default) public virtual async Task<OnChainAutomatedPayoutSettings> UpdateStoreOnChainAutomatedPayoutProcessors(string storeId, string paymentMethod, OnChainAutomatedPayoutSettings request, CancellationToken token = default)

View File

@@ -183,15 +183,17 @@ retry:
Driver.AssertNoError(); Driver.AssertNoError();
CreatedUser = usr; CreatedUser = usr;
Password = "123456"; Password = "123456";
IsAdmin = isAdmin;
return usr; return usr;
} }
string CreatedUser; string CreatedUser;
public string Password { get; private set; } public string Password { get; private set; }
public bool IsAdmin { get; private set; }
public TestAccount AsTestAccount() public TestAccount AsTestAccount()
{ {
return new TestAccount(Server) { RegisterDetails = new Models.AccountViewModels.RegisterViewModel() { Password = "123456", Email = CreatedUser } }; return new TestAccount(Server) { StoreId = StoreId, Email = CreatedUser, Password = Password, RegisterDetails = new Models.AccountViewModels.RegisterViewModel() { Password = "123456", Email = CreatedUser }, IsAdmin = IsAdmin };
} }
public (string storeName, string storeId) CreateNewStore(bool keepId = true) public (string storeName, string storeId) CreateNewStore(bool keepId = true)

View File

@@ -2487,7 +2487,16 @@ namespace BTCPayServer.Tests
$"LNurl w payout test {DateTime.UtcNow.Ticks}", $"LNurl w payout test {DateTime.UtcNow.Ticks}",
TimeSpan.FromHours(1), CancellationToken.None)); TimeSpan.FromHours(1), CancellationToken.None));
var response = await info.SendRequest(bolt2.BOLT11, s.Server.PayTester.HttpClient, null,null); var response = await info.SendRequest(bolt2.BOLT11, s.Server.PayTester.HttpClient, null,null);
await TestUtils.EventuallyAsync(async () => // Oops!
Assert.Equal("The request has been approved. The sender needs to send the payment manually. (Or activate the lightning automated payment processor)", response.Reason);
var account = await s.AsTestAccount().CreateClient();
await account.UpdateStoreLightningAutomatedPayoutProcessors(s.StoreId, "BTC-LN", new()
{
ProcessNewPayoutsInstantly = true,
IntervalSeconds = TimeSpan.FromSeconds(60)
});
// Now it should process to complete
await TestUtils.EventuallyAsync(async () =>
{ {
s.Driver.Navigate().Refresh(); s.Driver.Navigate().Refresh();
Assert.Contains(bolt2.BOLT11, s.Driver.PageSource); Assert.Contains(bolt2.BOLT11, s.Driver.PageSource);
@@ -2577,7 +2586,9 @@ namespace BTCPayServer.Tests
$"LNurl w payout test {DateTime.UtcNow.Ticks}", $"LNurl w payout test {DateTime.UtcNow.Ticks}",
TimeSpan.FromHours(1), CancellationToken.None)); TimeSpan.FromHours(1), CancellationToken.None));
response = await info.SendRequest(bolt2.BOLT11, s.Server.PayTester.HttpClient, null,null); response = await info.SendRequest(bolt2.BOLT11, s.Server.PayTester.HttpClient, null,null);
TestUtils.Eventually(() => // Nope, you need to approve the claim automatically
Assert.Equal("The request has been recorded, but still need to be approved before execution.", response.Reason);
TestUtils.Eventually(() =>
{ {
s.Driver.Navigate().Refresh(); s.Driver.Navigate().Refresh();
Assert.Contains(bolt2.BOLT11, s.Driver.PageSource); Assert.Contains(bolt2.BOLT11, s.Driver.PageSource);

View File

@@ -19,6 +19,8 @@ using BTCPayServer.HostedServices;
using BTCPayServer.Lightning; using BTCPayServer.Lightning;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning; using BTCPayServer.Payments.Lightning;
using BTCPayServer.PayoutProcessors;
using BTCPayServer.PayoutProcessors.Lightning;
using BTCPayServer.Payouts; using BTCPayServer.Payouts;
using BTCPayServer.Plugins; using BTCPayServer.Plugins;
using BTCPayServer.Plugins.Crowdfund; using BTCPayServer.Plugins.Crowdfund;
@@ -60,11 +62,13 @@ namespace BTCPayServer
private readonly IPluginHookService _pluginHookService; private readonly IPluginHookService _pluginHookService;
private readonly InvoiceActivator _invoiceActivator; private readonly InvoiceActivator _invoiceActivator;
private readonly PaymentMethodHandlerDictionary _handlers; private readonly PaymentMethodHandlerDictionary _handlers;
private readonly PayoutProcessorService _payoutProcessorService;
public UILNURLController(InvoiceRepository invoiceRepository, public UILNURLController(InvoiceRepository invoiceRepository,
EventAggregator eventAggregator, EventAggregator eventAggregator,
PayoutMethodHandlerDictionary payoutHandlers, PayoutMethodHandlerDictionary payoutHandlers,
PaymentMethodHandlerDictionary handlers, PaymentMethodHandlerDictionary handlers,
PayoutProcessorService payoutProcessorService,
StoreRepository storeRepository, StoreRepository storeRepository,
AppService appService, AppService appService,
UIInvoiceController invoiceController, UIInvoiceController invoiceController,
@@ -79,6 +83,7 @@ namespace BTCPayServer
_eventAggregator = eventAggregator; _eventAggregator = eventAggregator;
_payoutHandlers = payoutHandlers; _payoutHandlers = payoutHandlers;
_handlers = handlers; _handlers = handlers;
_payoutProcessorService = payoutProcessorService;
_storeRepository = storeRepository; _storeRepository = storeRepository;
_appService = appService; _appService = appService;
_invoiceController = invoiceController; _invoiceController = invoiceController;
@@ -151,6 +156,7 @@ namespace BTCPayServer
if (result.MinimumAmount < request.MinWithdrawable || result.MinimumAmount > request.MaxWithdrawable) if (result.MinimumAmount < request.MinWithdrawable || result.MinimumAmount > request.MaxWithdrawable)
return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = $"Payment request was not within bounds ({request.MinWithdrawable.ToUnit(LightMoneyUnit.Satoshi)} - {request.MaxWithdrawable.ToUnit(LightMoneyUnit.Satoshi)} sats)" }); return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = $"Payment request was not within bounds ({request.MinWithdrawable.ToUnit(LightMoneyUnit.Satoshi)} - {request.MaxWithdrawable.ToUnit(LightMoneyUnit.Satoshi)} sats)" });
var store = await _storeRepository.FindStore(pp.StoreId); var store = await _storeRepository.FindStore(pp.StoreId);
var pm = store!.GetPaymentMethodConfig<LightningPaymentMethodConfig>(paymentMethodId, _handlers); var pm = store!.GetPaymentMethodConfig<LightningPaymentMethodConfig>(paymentMethodId, _handlers);
if (pm is null) if (pm is null)
@@ -158,74 +164,83 @@ namespace BTCPayServer
return NotFound(); return NotFound();
} }
var claimResponse = await _pullPaymentHostedService.Claim(new ClaimRequest var processors = await _payoutProcessorService.GetProcessors(new PayoutProcessorService.PayoutProcessorQuery()
{
Stores = [pp.StoreId],
PayoutMethods = [pmi],
Processors = [LightningAutomatedPayoutSenderFactory.ProcessorName]
});
var processorBlob = processors.FirstOrDefault()?.HasTypedBlob<LightningAutomatedPayoutBlob>().GetBlob();
var instantProcessing = processorBlob?.ProcessNewPayoutsInstantly is true;
var interval = processorBlob?.Interval.TotalMinutes;
var autoApprove = pp.GetBlob().AutoApproveClaims;
var claimResponse = await _pullPaymentHostedService.Claim(new ClaimRequest
{ {
Destination = new BoltInvoiceClaimDestination(pr, result), Destination = new BoltInvoiceClaimDestination(pr, result),
PayoutMethodId = pmi, PayoutMethodId = pmi,
PullPaymentId = pullPaymentId, PullPaymentId = pullPaymentId,
StoreId = pp.StoreId, StoreId = pp.StoreId,
Value = result.MinimumAmount.ToDecimal(unit) Value = result.MinimumAmount.ToDecimal(unit),
}); });
if (claimResponse.Result != ClaimRequest.ClaimResult.Ok) if (claimResponse.Result != ClaimRequest.ClaimResult.Ok)
return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Payment request could not be paid" }); return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Payment request could not be paid" });
var payout = claimResponse.PayoutData;
var lightningHandler = _handlers.GetLightningHandler(network); DateTimeOffset since = DateTimeOffset.UtcNow;
switch (claimResponse.PayoutData.State) while (true)
{ {
case PayoutState.AwaitingPayment: switch (payout.State)
{ {
var client = case PayoutState.Completed:
lightningHandler.CreateLightningClient(pm); return Ok(new LNUrlStatusResponse { Status = "OK" });
var payResult = await UILightningLikePayoutController.TrypayBolt(client, case PayoutState.Cancelled:
claimResponse.PayoutData.GetBlob(_btcPayNetworkJsonSerializerSettings), return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Payment request could not be paid" });
claimResponse.PayoutData, result, cancellationToken); case PayoutState.AwaitingApproval when !autoApprove:
return Ok(new LNUrlStatusResponse
switch (payResult.Result) {
{ Status = "OK",
case PayResult.Ok: Reason =
case PayResult.Unknown: "The request has been recorded, but still need to be approved before execution."
await _pullPaymentHostedService.MarkPaid(new MarkPayoutRequest });
{ }
PayoutId = claimResponse.PayoutData.Id, if (instantProcessing)
State = claimResponse.PayoutData.State, {
Proof = claimResponse.PayoutData.GetProofBlobJson() if (DateTimeOffset.UtcNow - since > TimeSpan.FromSeconds(10.0))
}); return Ok(new LNUrlStatusResponse
{
return Ok(new LNUrlStatusResponse Status = "OK",
{ Reason = $"The payment is in pending state and should be completed shortly. ({payout.State})"
Status = "OK", });
Reason = payResult.Message await WaitPayoutChanged(claimResponse.PayoutData.Id, cancellationToken);
}); payout = (await _pullPaymentHostedService.GetPayouts(new PullPaymentHostedService.PayoutQuery()
case PayResult.CouldNotFindRoute: {
case PayResult.Error: PayoutIds = [claimResponse.PayoutData.Id]
default: })).Single();
await _pullPaymentHostedService.Cancel( }
new PullPaymentHostedService.CancelRequest(new[] else
{ claimResponse.PayoutData.Id }, null)); {
var message = interval switch
return BadRequest(new LNUrlStatusResponse {
{ double intervalMinutes => $"The payment will be sent after {intervalMinutes} minutes.",
Status = "ERROR", null => "The sender needs to send the payment manually. (Or activate the lightning automated payment processor)"
Reason = payResult.Message ?? payResult.Result.ToString() };
}); return Ok(new LNUrlStatusResponse
} {
} Status = "OK",
case PayoutState.AwaitingApproval: Reason = $"The request has been approved. {message}"
return Ok(new LNUrlStatusResponse });
{ }
Status = "OK",
Reason =
"The payment request has been recorded, but still needs to be approved before execution."
});
case PayoutState.InProgress:
case PayoutState.Completed:
return Ok(new LNUrlStatusResponse { Status = "OK" });
case PayoutState.Cancelled:
return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Payment request could not be paid" });
} }
}
return Ok(request); private async Task WaitPayoutChanged(string payoutId, CancellationToken cancellationToken)
{
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
// We also wait delay, in case we missed the event
var delay = Task.Delay(1000, cts.Token);
var payoutEvent = _eventAggregator.WaitNext<PayoutEvent>(o => o.Payout.Id == payoutId, cts.Token);
await Task.WhenAny(delay, payoutEvent);
cts.Cancel();
} }
private BTCPayNetwork GetNetwork(string cryptoCode) private BTCPayNetwork GetNetwork(string cryptoCode)

View File

@@ -22,6 +22,7 @@ namespace BTCPayServer.PayoutProcessors;
public class AutomatedPayoutConstants public class AutomatedPayoutConstants
{ {
public const double MinIntervalMinutes = 1.0; public const double MinIntervalMinutes = 1.0;
public const double DefaultIntervalMinutes = 60.0;
public const double MaxIntervalMinutes = 24 * 60; //1 day public const double MaxIntervalMinutes = 24 * 60; //1 day
public static void ValidateInterval(ModelStateDictionary modelState, TimeSpan timeSpan, string parameterName) public static void ValidateInterval(ModelStateDictionary modelState, TimeSpan timeSpan, string parameterName)
{ {

View File

@@ -82,10 +82,10 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Li
} }
var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings); var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings);
var claim = await _payoutHandler.ParseClaimDestination(blob.Destination, CancellationToken);
try try
{ {
switch (claim.destination) var claim = await _payoutHandler.ParseClaimDestination(blob.Destination, CancellationToken);
switch (claim.destination)
{ {
case LNURLPayClaimDestinaton lnurlPayClaimDestinaton: case LNURLPayClaimDestinaton lnurlPayClaimDestinaton:
var lnurlResult = await UILightningLikePayoutController.GetInvoiceFromLNURL(payoutData, var lnurlResult = await UILightningLikePayoutController.GetInvoiceFromLNURL(payoutData,