diff --git a/src/Services/RebalanceService.cs b/src/Services/RebalanceService.cs index 943f0054..12c1a4d8 100644 --- a/src/Services/RebalanceService.cs +++ b/src/Services/RebalanceService.cs @@ -183,7 +183,7 @@ public async Task 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; @@ -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); + /// + /// 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. + /// + 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) { diff --git a/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs b/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs index 3b6e0ca8..1004aa69 100644 --- a/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs +++ b/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs @@ -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())).ReturnsAsync(node); + StubRepoForCapture(); + + long capturedExpiry = 0; + _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, _, expiry) => capturedExpiry = expiry) + .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); + _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .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())).ReturnsAsync(node); + StubRepoForCapture(); + + long capturedExpiry = 0; + _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, _, expiry) => capturedExpiry = expiry) + .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); + _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .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() {