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
No known key found for this signature in database
GPG Key ID: 6618763EF09186FE
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);
}
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)

View File

@ -183,15 +183,17 @@ retry:
Driver.AssertNoError();
CreatedUser = usr;
Password = "123456";
IsAdmin = isAdmin;
return usr;
}
string CreatedUser;
public string Password { get; private set; }
public bool IsAdmin { get; private set; }
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)

View File

@ -2487,7 +2487,16 @@ namespace BTCPayServer.Tests
$"LNurl w payout test {DateTime.UtcNow.Ticks}",
TimeSpan.FromHours(1), CancellationToken.None));
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();
Assert.Contains(bolt2.BOLT11, s.Driver.PageSource);
@ -2577,7 +2586,9 @@ namespace BTCPayServer.Tests
$"LNurl w payout test {DateTime.UtcNow.Ticks}",
TimeSpan.FromHours(1), CancellationToken.None));
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();
Assert.Contains(bolt2.BOLT11, s.Driver.PageSource);

View File

@ -19,6 +19,8 @@ using BTCPayServer.HostedServices;
using BTCPayServer.Lightning;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.PayoutProcessors;
using BTCPayServer.PayoutProcessors.Lightning;
using BTCPayServer.Payouts;
using BTCPayServer.Plugins;
using BTCPayServer.Plugins.Crowdfund;
@ -60,11 +62,13 @@ namespace BTCPayServer
private readonly IPluginHookService _pluginHookService;
private readonly InvoiceActivator _invoiceActivator;
private readonly PaymentMethodHandlerDictionary _handlers;
private readonly PayoutProcessorService _payoutProcessorService;
public UILNURLController(InvoiceRepository invoiceRepository,
EventAggregator eventAggregator,
PayoutMethodHandlerDictionary payoutHandlers,
PaymentMethodHandlerDictionary handlers,
PayoutProcessorService payoutProcessorService,
StoreRepository storeRepository,
AppService appService,
UIInvoiceController invoiceController,
@ -79,6 +83,7 @@ namespace BTCPayServer
_eventAggregator = eventAggregator;
_payoutHandlers = payoutHandlers;
_handlers = handlers;
_payoutProcessorService = payoutProcessorService;
_storeRepository = storeRepository;
_appService = appService;
_invoiceController = invoiceController;
@ -151,6 +156,7 @@ namespace BTCPayServer
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)" });
var store = await _storeRepository.FindStore(pp.StoreId);
var pm = store!.GetPaymentMethodConfig<LightningPaymentMethodConfig>(paymentMethodId, _handlers);
if (pm is null)
@ -158,74 +164,83 @@ namespace BTCPayServer
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),
PayoutMethodId = pmi,
PullPaymentId = pullPaymentId,
StoreId = pp.StoreId,
Value = result.MinimumAmount.ToDecimal(unit)
Value = result.MinimumAmount.ToDecimal(unit),
});
if (claimResponse.Result != ClaimRequest.ClaimResult.Ok)
return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Payment request could not be paid" });
var lightningHandler = _handlers.GetLightningHandler(network);
switch (claimResponse.PayoutData.State)
var payout = claimResponse.PayoutData;
DateTimeOffset since = DateTimeOffset.UtcNow;
while (true)
{
case PayoutState.AwaitingPayment:
{
var client =
lightningHandler.CreateLightningClient(pm);
var payResult = await UILightningLikePayoutController.TrypayBolt(client,
claimResponse.PayoutData.GetBlob(_btcPayNetworkJsonSerializerSettings),
claimResponse.PayoutData, result, cancellationToken);
switch (payResult.Result)
{
case PayResult.Ok:
case PayResult.Unknown:
await _pullPaymentHostedService.MarkPaid(new MarkPayoutRequest
{
PayoutId = claimResponse.PayoutData.Id,
State = claimResponse.PayoutData.State,
Proof = claimResponse.PayoutData.GetProofBlobJson()
});
return Ok(new LNUrlStatusResponse
{
Status = "OK",
Reason = payResult.Message
});
case PayResult.CouldNotFindRoute:
case PayResult.Error:
default:
await _pullPaymentHostedService.Cancel(
new PullPaymentHostedService.CancelRequest(new[]
{ claimResponse.PayoutData.Id }, null));
return BadRequest(new LNUrlStatusResponse
{
Status = "ERROR",
Reason = payResult.Message ?? payResult.Result.ToString()
});
}
}
case PayoutState.AwaitingApproval:
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" });
switch (payout.State)
{
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" });
case PayoutState.AwaitingApproval when !autoApprove:
return Ok(new LNUrlStatusResponse
{
Status = "OK",
Reason =
"The request has been recorded, but still need to be approved before execution."
});
}
if (instantProcessing)
{
if (DateTimeOffset.UtcNow - since > TimeSpan.FromSeconds(10.0))
return Ok(new LNUrlStatusResponse
{
Status = "OK",
Reason = $"The payment is in pending state and should be completed shortly. ({payout.State})"
});
await WaitPayoutChanged(claimResponse.PayoutData.Id, cancellationToken);
payout = (await _pullPaymentHostedService.GetPayouts(new PullPaymentHostedService.PayoutQuery()
{
PayoutIds = [claimResponse.PayoutData.Id]
})).Single();
}
else
{
var message = interval switch
{
double intervalMinutes => $"The payment will be sent after {intervalMinutes} minutes.",
null => "The sender needs to send the payment manually. (Or activate the lightning automated payment processor)"
};
return Ok(new LNUrlStatusResponse
{
Status = "OK",
Reason = $"The request has been approved. {message}"
});
}
}
}
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)

View File

@ -22,6 +22,7 @@ namespace BTCPayServer.PayoutProcessors;
public class AutomatedPayoutConstants
{
public const double MinIntervalMinutes = 1.0;
public const double DefaultIntervalMinutes = 60.0;
public const double MaxIntervalMinutes = 24 * 60; //1 day
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 claim = await _payoutHandler.ParseClaimDestination(blob.Destination, CancellationToken);
try
{
switch (claim.destination)
var claim = await _payoutHandler.ParseClaimDestination(blob.Destination, CancellationToken);
switch (claim.destination)
{
case LNURLPayClaimDestinaton lnurlPayClaimDestinaton:
var lnurlResult = await UILightningLikePayoutController.GetInvoiceFromLNURL(payoutData,