Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion src/Services/RebalanceService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ public async Task<Rebalance> ExecuteAsync(int rebalanceId, CancellationToken ct
{
var memo = $"NG rebalance #{rebalance.Id} attempt {rebalance.AttemptNumber}";
var invoice = await _lightningService.AddInvoiceAsync(node, rebalance.SatsAmount, memo,
rebalance.TimeoutSeconds + 60);
ComputeInvoiceExpirySeconds(rebalance));
if (invoice == null || string.IsNullOrEmpty(invoice.PaymentRequest))
{
rebalance.Status = RebalanceStatus.Failed;
Expand Down Expand Up @@ -324,6 +324,30 @@ private static double ResolveMaxFeePct(double? userSupplied, decimal defaultPct)
private static long ComputeFeeLimitMsat(long satsAmount, double maxFeePct)
=> (long)Math.Round(satsAmount * (decimal)maxFeePct * 10m, MidpointRounding.AwayFromZero);

/// <summary>
/// Sizes the self-invoice's expiry to outlive the full retry window — per-attempt
/// payment timeout + every backoff gap between retries + a safety buffer. The probe is
/// unbounded and an exponential backoff (60s → 120s → 240s) can push later attempts
/// well past a short expiry; matching the retry budget guarantees the invoice is still
/// honoured by LND when SendPaymentV2 finally fires.
/// </summary>
private static long ComputeInvoiceExpirySeconds(Rebalance rebalance)
{
var maxAttempts = Math.Max(1, rebalance.MaxAttempts ?? Constants.REBALANCE_MAX_ATTEMPTS);
var initialDelay = Constants.REBALANCE_INITIAL_RETRY_DELAY_SECONDS;
var multiplier = Constants.REBALANCE_RETRY_BACKOFF_MULTIPLIER;

// Mirrors ScheduleRetryIfEligibleAsync: delay between attempt N and N+1 is
// initialDelay * multiplier^(N-1). Sum across all retries we might schedule.
double totalBackoffSeconds = 0;
for (var i = 0; i < maxAttempts - 1; i++)
{
totalBackoffSeconds += initialDelay * Math.Pow(multiplier, i);
}

return rebalance.TimeoutSeconds + (long)totalBackoffSeconds + 60;
}


internal static void ApplyTerminalPayment(Rebalance rebalance, Payment payment)
{
Expand Down
57 changes: 57 additions & 0 deletions test/NodeGuard.Tests/Services/RebalanceServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,63 @@ public async Task ExecuteAsync_ProbeSucceeds_PaymentSucceeds_StatusSucceeded()
result.PreimageHex.Should().Be("deadbeef");
}

[Fact]
public async Task ExecuteAsync_InvoiceExpiryCoversFullRetryWindowPlusBuffer()
{
// Invoice expiry must outlive the worst-case retry timeline so a delayed attempt
// still has a live invoice when SendPaymentV2 fires. With defaults that's
// TimeoutSeconds + (initial + initial*mult + ... over MaxAttempts-1 retries) + buffer.
var node = CreateNode();
_nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny<bool>())).ReturnsAsync(node);
StubRepoForCapture();

long capturedExpiry = 0;
_lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny<long>(), It.IsAny<string>(), It.IsAny<long>()))
.Callback<Node, long, string, long>((_, _, _, expiry) => capturedExpiry = expiry)
.ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." });
_lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny<long>(), It.IsAny<long>(),
It.IsAny<ulong?>(), It.IsAny<string?>(), It.IsAny<double>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProbeResult.NoRoute("stop"));

var service = CreateService();
// TimeoutSeconds=60, MaxAttempts=3 → backoff = 60 + 120 = 180 → 60 + 180 + buffer.
var request = new RebalanceRequest(node.Id, null, null, 100_000, MaxFeePct: 0.05,
TimeoutSeconds: 60, MaxAttempts: 3);

await service.RebalanceAsync(request);

long expected = 60
+ Constants.REBALANCE_INITIAL_RETRY_DELAY_SECONDS
+ (long)(Constants.REBALANCE_INITIAL_RETRY_DELAY_SECONDS * Constants.REBALANCE_RETRY_BACKOFF_MULTIPLIER)
+ 60;
capturedExpiry.Should().Be(expected);
capturedExpiry.Should().BeGreaterThan(60 + 60, "old TimeoutSeconds+60 expiry was demonstrably too short");
}

[Fact]
public async Task ExecuteAsync_InvoiceExpiry_MaxAttempts1_OmitsBackoffSum()
{
var node = CreateNode();
_nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny<bool>())).ReturnsAsync(node);
StubRepoForCapture();

long capturedExpiry = 0;
_lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny<long>(), It.IsAny<string>(), It.IsAny<long>()))
.Callback<Node, long, string, long>((_, _, _, expiry) => capturedExpiry = expiry)
.ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." });
_lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny<long>(), It.IsAny<long>(),
It.IsAny<ulong?>(), It.IsAny<string?>(), It.IsAny<double>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProbeResult.NoRoute("stop"));

var service = CreateService();
var request = new RebalanceRequest(node.Id, null, null, 100_000, MaxFeePct: 0.05,
TimeoutSeconds: 45, MaxAttempts: 1);

await service.RebalanceAsync(request);

capturedExpiry.Should().Be(45 + 60);
}

[Fact]
public async Task ExecuteAsync_PersistsPaymentHashHexBeforePayment()
{
Expand Down
Loading