diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index 2c7b2849..498ecd96 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -99,7 +99,7 @@ public class Constants public static readonly decimal MAXIMUM_WITHDRAWAL_BTC_AMOUNT = 21_000_000; public static readonly int TRANSACTION_CONFIRMATION_MINIMUM_BLOCKS; public static int DEFAULT_CHANNEL_FEE_POLICY_TIMELOCK_DELTA_BLOCKS = 40; - public static long DEFAULT_CHANNEL_FEE_POLICY_BASE_FEE_MSAT = 0; + public static long DEFAULT_CHANNEL_FEE_POLICY_BASE_FEE_MSAT = 0; public static long DEFAULT_CHANNEL_FEE_POLICY_FEE_RATE_PPM = 1500; public static readonly long ANCHOR_CLOSINGS_MINIMUM_SATS; public static readonly long MINIMUM_SWEEP_TRANSACTION_AMOUNT_SATS = 25_000_000; //25M sats @@ -328,6 +328,13 @@ public class Constants // Outbound ppm baseline for not-yet-categorized channels (safe mid default). public static uint ROUTING_ENGINE_FEE_BASELINE_PPM_UNCATEGORIZED = 1500; + /// + /// Fraction of a channel's capacity advertised as its max_htlc_msat. LND itself uses ~0.99 for + /// channels it opens, so at the default this reconciles drifted channels without touching + /// untouched ones. + /// + public static double MAX_HTLC_CAPACITY_RATIO = 0.99; + public const string IsFrozenTag = "frozen"; public const string IsManuallyFrozenTag = "manually_frozen"; @@ -649,6 +656,16 @@ static Constants() var feeBaselineUncategorized = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_BASELINE_PPM_UNCATEGORIZED"); if (feeBaselineUncategorized != null) ROUTING_ENGINE_FEE_BASELINE_PPM_UNCATEGORIZED = uint.Parse(feeBaselineUncategorized); + // Max HTLC + var maxHtlcCapacityRatio = Environment.GetEnvironmentVariable("MAX_HTLC_CAPACITY_RATIO"); + if (maxHtlcCapacityRatio != null) + { + var parsedRatio = double.Parse(maxHtlcCapacityRatio, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); + // A ratio outside (0, 1] would resolve to 0 or above capacity, both of which LND rejects. + if (parsedRatio > 0 && parsedRatio <= 1) MAX_HTLC_CAPACITY_RATIO = parsedRatio; + else throw new ArgumentOutOfRangeException(nameof(MAX_HTLC_CAPACITY_RATIO), parsedRatio, "MAX_HTLC_CAPACITY_RATIO must be in (0, 1]"); + } + // DB Initialization ALICE_PUBKEY = Environment.GetEnvironmentVariable("ALICE_PUBKEY") ?? ALICE_PUBKEY; ALICE_HOST = Environment.GetEnvironmentVariable("ALICE_HOST") ?? ALICE_HOST; diff --git a/src/Jobs/ChannelMonitorJob.cs b/src/Jobs/ChannelMonitorJob.cs index 11680344..d48682f2 100644 --- a/src/Jobs/ChannelMonitorJob.cs +++ b/src/Jobs/ChannelMonitorJob.cs @@ -87,6 +87,15 @@ public async Task Execute(IJobExecutionContext context) // Recover Operations on channels await RecoverGhostChannels(node1, node2, channel); await RecoverChannelInConfirmationPendingStatus(node1); + + try + { + await _lightningService.SyncChannelMaxHtlc(node1, channel); + } + catch (Exception e) + { + _logger.LogError(e, "Error while syncing max htlc for channel {ChanId} of node {NodeId}", channel.ChanId, node1.Id); + } } } catch (Exception e) @@ -124,7 +133,7 @@ private async Task RefreshExternalNodeData(Node managedNode, Node remoteNode, Li return; } - if (remoteNode.Name == nodeInfo.Alias) return; + if (remoteNode.Name == nodeInfo.Alias) return; remoteNode.Name = nodeInfo.Alias; var (updated, error) = _nodeRepository.Update(remoteNode); if (!updated) @@ -140,7 +149,7 @@ public async Task RecoverGhostChannels(Node source, Node destination, Channel ch try { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); - + var channelPoint = channel.ChannelPoint.Split(":"); var fundingTx = channelPoint[0]; var outputIndex = Convert.ToUInt32(channelPoint[1]); @@ -150,7 +159,8 @@ public async Task RecoverGhostChannels(Node source, Node destination, Channel ch var parsedChannelPoint = new ChannelPoint { - FundingTxidStr = fundingTx, FundingTxidBytes = ByteString.CopyFrom(Convert.FromHexString(fundingTx).Reverse().ToArray()), + FundingTxidStr = fundingTx, + FundingTxidBytes = ByteString.CopyFrom(Convert.FromHexString(fundingTx).Reverse().ToArray()), OutputIndex = outputIndex }; diff --git a/src/Services/LightningClientService.cs b/src/Services/LightningClientService.cs index 7ce3d07b..0a52bbde 100644 --- a/src/Services/LightningClientService.cs +++ b/src/Services/LightningClientService.cs @@ -48,8 +48,7 @@ public interface ILightningClientService public void FundingStateStepVerify(Node node, PSBT finalizedPSBT, byte[] pendingChannelId, Lightning.LightningClient? client = null); public void FundingStateStepFinalize(Node node, PSBT finalizedPSBT, byte[] pendingChannelId, Lightning.LightningClient? client = null); public void FundingStateStepCancel(Node node, byte[] pendingChannelId, Lightning.LightningClient? client = null); - - public Task SetChannelFeePolicy(Node node, NBitcoin.OutPoint chanPoint, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm, Lightning.LightningClient? client = null); + public Task SetChannelFeePolicy(Node node, NBitcoin.OutPoint chanPoint, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm, ulong? maxHtlcMsat = null, Lightning.LightningClient? client = null); } public class LightningClientService : ILightningClientService @@ -453,7 +452,7 @@ public void FundingStateStepCancel(Node node, byte[] pendingChannelId, Lightning }, new Metadata { { "macaroon", node.ChannelAdminMacaroon } }); } - public async Task SetChannelFeePolicy(Node node, NBitcoin.OutPoint chanPoint, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm, Lightning.LightningClient? client = null) + public async Task SetChannelFeePolicy(Node node, NBitcoin.OutPoint chanPoint, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm, ulong? maxHtlcMsat = null, Lightning.LightningClient? client = null) { client ??= GetLightningClient(node.Endpoint); @@ -478,6 +477,11 @@ public void FundingStateStepCancel(Node node, byte[] pendingChannelId, Lightning }; } + if (maxHtlcMsat.HasValue) + { + request.MaxHtlcMsat = maxHtlcMsat.Value; + } + return await client.UpdateChannelPolicyAsync(request, new Metadata { { "macaroon", node.ChannelAdminMacaroon } }); } } \ No newline at end of file diff --git a/src/Services/LightningService.cs b/src/Services/LightningService.cs index 0bc04f82..1816d61d 100644 --- a/src/Services/LightningService.cs +++ b/src/Services/LightningService.cs @@ -42,6 +42,22 @@ namespace NodeGuard.Services { + /// + /// Outcome of a call. A failed write is an + /// exception rather than a value here: means we decided not to act. + /// + public enum MaxHtlcSyncResult + { + /// The channel already advertises the target max_htlc_msat — no RPC was made. + NoOp = 0, + + /// The channel's max_htlc_msat was written to LND. + Updated = 1, + + /// The target could not be resolved or the channel is not one we act on. + Skipped = 2, + } + /// /// Service to interact with LND /// @@ -209,6 +225,15 @@ Task SendPaymentV2Async(Node node, string paymentRequest, long amountSa /// /// public Task<(RoutingPolicy?, RoutingPolicy?)> GetChannelFeePolicy(ulong chanId, Node node); + + /// + /// Reconciles the max_htlc_msat that advertises on + /// with of the + /// channel's capacity, writing to LND only when the advertised value differs. + /// + /// The managed node whose side of the channel is updated. + /// The channel as reported by LND — the authority on capacity and chan id. + public Task SyncChannelMaxHtlc(Node node, Lnrpc.Channel lndChannel); } public class LightningService : ILightningService @@ -1894,5 +1919,126 @@ await _auditService.LogAsync( return (managedNodePolicy, counterpartyNodePolicy); } + + public async Task SyncChannelMaxHtlc(Node node, Lnrpc.Channel lndChannel) + { + ArgumentNullException.ThrowIfNull(node); + ArgumentNullException.ThrowIfNull(lndChannel); + + if (!node.IsManaged || string.IsNullOrWhiteSpace(node.ChannelAdminMacaroon)) + { + _logger.LogWarning("Skipping max htlc sync for channel {ChanId}: node {NodeName} is not managed with channel admin access", + lndChannel.ChanId, node.Name); + return MaxHtlcSyncResult.Skipped; + } + + if (!OutPoint.TryParse(lndChannel.ChannelPoint, out var outPoint)) + { + _logger.LogWarning("Skipping max htlc sync for channel {ChanId} on {NodeName}: invalid chanPoint {ChanPoint}", + lndChannel.ChanId, node.Name, lndChannel.ChannelPoint); + return MaxHtlcSyncResult.Skipped; + } + + RoutingPolicy? managedPolicy; + try + { + (managedPolicy, _) = await GetChannelFeePolicy(lndChannel.ChanId, node); + } + catch (Exception e) + { + // A channel with no graph edge yet (freshly confirmed, or unannounced) throws here. + // The next monitor pass retries it. + _logger.LogWarning(e, "Skipping max htlc sync for channel {ChanId} on {NodeName}: current policy unavailable", + lndChannel.ChanId, node.Name); + return MaxHtlcSyncResult.Skipped; + } + + if (managedPolicy == null) + { + _logger.LogWarning("Skipping max htlc sync for channel {ChanId} on {NodeName}: no policy for the managed side", + lndChannel.ChanId, node.Name); + return MaxHtlcSyncResult.Skipped; + } + + var capacityMsat = (ulong)lndChannel.Capacity * 1_000; + var minHtlcMsat = (ulong)Math.Max(managedPolicy.MinHtlc, 0); + + if (capacityMsat == 0 || minHtlcMsat > capacityMsat) + { + _logger.LogWarning("Skipping max htlc sync for channel {ChanId} on {NodeName}: no valid target between min_htlc {MinHtlcMsat} msat and capacity {CapacityMsat} msat", + lndChannel.ChanId, node.Name, minHtlcMsat, capacityMsat); + return MaxHtlcSyncResult.Skipped; + } + + var desiredMaxHtlcMsat = Math.Clamp( + (ulong)(capacityMsat * Constants.MAX_HTLC_CAPACITY_RATIO), + minHtlcMsat, + capacityMsat); + + if (managedPolicy.MaxHtlcMsat == desiredMaxHtlcMsat) + { + _logger.LogDebug("Channel {ChanId} on {NodeName} already advertises max htlc {MaxHtlcMsat} msat", + lndChannel.ChanId, node.Name, desiredMaxHtlcMsat); + return MaxHtlcSyncResult.NoOp; + } + + // Only channels NodeGuard tracks are acted on, so the write is always auditable against a + // channel row. + var channel = await _channelRepository.GetByOutpoint(outPoint); + if (channel == null) + { + _logger.LogWarning("Skipping max htlc sync for channel {ChanId} on {NodeName}: no channel found for chanPoint {ChanPoint}", + lndChannel.ChanId, node.Name, lndChannel.ChannelPoint); + return MaxHtlcSyncResult.Skipped; + } + + // The fee fields are not being changed, but LND requires them to be echoed back in a policy update. + // Except the inbound fees, which are omitted to retain the current inbound policy. + var response = await _lightningClientService.SetChannelFeePolicy( + node, + outPoint, + managedPolicy.FeeBaseMsat, + (uint)Math.Clamp(managedPolicy.FeeRateMilliMsat, 0, uint.MaxValue), + managedPolicy.TimeLockDelta, + inboundBaseFeeMsat: null, + inboundFeeRatePpm: null, + maxHtlcMsat: desiredMaxHtlcMsat); + + if (response?.FailedUpdates != null && response.FailedUpdates.Count > 0) + { + _logger.LogError("Failed to update max htlc for channel: {ChanPoint}", lndChannel.ChannelPoint); + throw new Exception($"Failed to update max htlc for channel: {lndChannel.ChannelPoint}"); + } + + _logger.LogInformation("{NodeName} chan {ChanId}: set max htlc {PreviousMaxHtlcMsat}->{MaxHtlcMsat} msat (capacity {CapacityMsat} msat, ratio {Ratio})", + node.Name, lndChannel.ChanId, managedPolicy.MaxHtlcMsat, desiredMaxHtlcMsat, capacityMsat, Constants.MAX_HTLC_CAPACITY_RATIO); + + try + { + await _auditService.LogSystemAsync( + AuditActionType.Update, + AuditEventType.Success, + AuditObjectType.Channel, + channel.Id.ToString(), + new + { + ChanPoint = lndChannel.ChannelPoint, + ChannelId = channel.Id, + lndChannel.ChanId, + NodeId = node.Id, + NodePubKey = node.PubKey, + PreviousMaxHtlcMsat = managedPolicy.MaxHtlcMsat, + MaxHtlcMsat = desiredMaxHtlcMsat, + CapacityMsat = capacityMsat, + CapacityRatio = Constants.MAX_HTLC_CAPACITY_RATIO + }); + } + catch (Exception e) + { + _logger.LogError(e, "Error while saving max htlc audit log for chanPoint: {ChanPoint}", lndChannel.ChannelPoint); + } + + return MaxHtlcSyncResult.Updated; + } } } diff --git a/test/NodeGuard.Tests/Jobs/ChannelMonitorJobTests.cs b/test/NodeGuard.Tests/Jobs/ChannelMonitorJobTests.cs index 8bb5a146..c35935d3 100644 --- a/test/NodeGuard.Tests/Jobs/ChannelMonitorJobTests.cs +++ b/test/NodeGuard.Tests/Jobs/ChannelMonitorJobTests.cs @@ -47,6 +47,15 @@ private Mock> SetupDbContextFactory() return dbContextFactory; } + private Quartz.IJobExecutionContext BuildJobContext(int nodeId) + { + var jobDetail = new Mock(); + jobDetail.Setup(x => x.JobDataMap).Returns(new Quartz.JobDataMap { { "nodeId", nodeId.ToString() } }); + var context = new Mock(); + context.Setup(x => x.JobDetail).Returns(jobDetail.Object); + return context.Object; + } + [Fact] public async Task RecoverGhostChannels_ChannelIsNotInitiatorButManaged() { @@ -227,6 +236,76 @@ public async Task RecoverGhostChannels_CreatesChannelNotInitiator() context.Channels.Count().Should().Be(1); } + [Fact] + public async Task Execute_SyncsMaxHtlcOfEveryChannel() + { + // Arrange + var logger = new Mock>(); + var dbContextFactory = SetupDbContextFactory(); + + var source = new Node() { Id = 3, Endpoint = "localhost", ChannelAdminMacaroon = "abc" }; + // A managed peer we did not initiate with: ghost recovery and alias refresh both bail out early, + // leaving the max htlc sync as the only work Execute does per channel. + var remote = new Node() { Id = 9, PubKey = "peer", Endpoint = "localhost" }; + var channel1 = new Lnrpc.Channel() { ChanId = 1, Capacity = 1000, RemotePubkey = remote.PubKey, Initiator = false }; + var channel2 = new Lnrpc.Channel() { ChanId = 2, Capacity = 2000, RemotePubkey = remote.PubKey, Initiator = false }; + + var nodeRepository = new Mock(); + nodeRepository.Setup(x => x.GetById(source.Id)).ReturnsAsync(source); + nodeRepository.Setup(x => x.GetOrCreateByPubKey(remote.PubKey, It.IsAny())).ReturnsAsync(remote); + + var lightningClientService = new Mock(); + lightningClientService.Setup(x => x.ListChannels(source, It.IsAny())) + .ReturnsAsync(new ListChannelsResponse { Channels = { channel1, channel2 } }); + + var lightningService = new Mock(); + lightningService.Setup(x => x.SyncChannelMaxHtlc(source, It.IsAny())).ReturnsAsync(MaxHtlcSyncResult.Updated); + + var channelMonitorJob = new ChannelMonitorJob(logger.Object, dbContextFactory.Object, nodeRepository.Object, lightningService.Object, lightningClientService.Object); + + // Act + var act = () => channelMonitorJob.Execute(BuildJobContext(source.Id)); + + // Assert + await act.Should().NotThrowAsync(); + lightningService.Verify(x => x.SyncChannelMaxHtlc(source, channel1), Times.Once); + lightningService.Verify(x => x.SyncChannelMaxHtlc(source, channel2), Times.Once); + } + + [Fact] + public async Task Execute_MaxHtlcSyncThrows() + { + // Arrange + var logger = new Mock>(); + var dbContextFactory = SetupDbContextFactory(); + + var source = new Node() { Id = 3, Endpoint = "localhost", ChannelAdminMacaroon = "abc" }; + var remote = new Node() { Id = 9, PubKey = "peer", Endpoint = "localhost" }; + var channel1 = new Lnrpc.Channel() { ChanId = 1, Capacity = 1000, RemotePubkey = remote.PubKey, Initiator = false }; + var channel2 = new Lnrpc.Channel() { ChanId = 2, Capacity = 2000, RemotePubkey = remote.PubKey, Initiator = false }; + + var nodeRepository = new Mock(); + nodeRepository.Setup(x => x.GetById(source.Id)).ReturnsAsync(source); + nodeRepository.Setup(x => x.GetOrCreateByPubKey(remote.PubKey, It.IsAny())).ReturnsAsync(remote); + + var lightningClientService = new Mock(); + lightningClientService.Setup(x => x.ListChannels(source, It.IsAny())) + .ReturnsAsync(new ListChannelsResponse { Channels = { channel1, channel2 } }); + + var lightningService = new Mock(); + lightningService.Setup(x => x.SyncChannelMaxHtlc(source, channel1)).ThrowsAsync(new Exception("policy update rejected")); + lightningService.Setup(x => x.SyncChannelMaxHtlc(source, channel2)).ReturnsAsync(MaxHtlcSyncResult.Updated); + + var channelMonitorJob = new ChannelMonitorJob(logger.Object, dbContextFactory.Object, nodeRepository.Object, lightningService.Object, lightningClientService.Object); + + // Act + var act = () => channelMonitorJob.Execute(BuildJobContext(source.Id)); + + // Assert - a failed policy write is contained, so the run finishes and the next channel is synced + await act.Should().NotThrowAsync(); + lightningService.Verify(x => x.SyncChannelMaxHtlc(source, channel2), Times.Once); + } + [Fact] public async Task RecoverChannelInConfirmationPendingStatus_RequestWithDifferentSource() { diff --git a/test/NodeGuard.Tests/Services/LightningClientServiceTests.cs b/test/NodeGuard.Tests/Services/LightningClientServiceTests.cs index 824725a1..d4866582 100644 --- a/test/NodeGuard.Tests/Services/LightningClientServiceTests.cs +++ b/test/NodeGuard.Tests/Services/LightningClientServiceTests.cs @@ -99,7 +99,7 @@ public async Task SetChannelFeePolicy_BuildsPolicyUpdateRequestWithInboundFee() timeLockDelta: 40, inboundBaseFeeMsat: -100, inboundFeeRatePpm: -25, - lightningClient.Object); + client: lightningClient.Object); // Assert response.Should().NotBeNull(); @@ -152,7 +152,7 @@ await lightningClientService.SetChannelFeePolicy( timeLockDelta: 40, inboundBaseFeeMsat: null, inboundFeeRatePpm: null, - lightningClient.Object); + client: lightningClient.Object); // Assert capturedRequest.Should().NotBeNull(); diff --git a/test/NodeGuard.Tests/Services/LightningServiceTests.cs b/test/NodeGuard.Tests/Services/LightningServiceTests.cs index d43b05ea..a670ca0d 100644 --- a/test/NodeGuard.Tests/Services/LightningServiceTests.cs +++ b/test/NodeGuard.Tests/Services/LightningServiceTests.cs @@ -297,7 +297,7 @@ private static Mock GetNBXplorerServiceFullyMocked(UTXOChange var nbXplorerMock = new Mock(); //Mock to return a wallet address var keyPathInformation = new KeyPathInformation() - { Address = BitcoinAddress.Create("bcrt1q590shaxaf5u08ml8jwlzghz99dup3z9592vxal", Network.RegTest) }; + { Address = BitcoinAddress.Create("bcrt1q590shaxaf5u08ml8jwlzghz99dup3z9592vxal", Network.RegTest) }; nbXplorerMock .Setup(x => x.GetUnusedAsync(It.IsAny(), It.IsAny(), @@ -497,7 +497,8 @@ public async Task OpenChannel_SuccessLegacyMultiSig() .ReturnsAsync((true, "")); lightningClientService.Setup( - x => x.FundingStateStepVerify(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())); lightningClientService.Setup( + x => x.FundingStateStepVerify(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())); + lightningClientService.Setup( x => x.FundingStateStepFinalize(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())); // Mock channel repository var channelRepository = new Mock(); @@ -700,7 +701,8 @@ public async Task OpenChannel_SuccessMultiSig() It.IsAny(), It.IsAny(), It.IsAny() - )); lightningClient + )); + lightningClient .Setup(x => x.FundingStateStepFinalize( It.IsAny(), It.IsAny(), @@ -1998,7 +2000,7 @@ public async Task GetChannelsStatus_SourceNodeIsManaged_SourceIsInitiator() }; lightningClientService.Setup(x => x.ListChannels(It.IsAny(), null)).ReturnsAsync(listChannelsResponse); - var lightningService = new LightningService(null, null, nodeRepository.Object, null, null, null, null, null ,null, lightningClientService.Object, null, null); + var lightningService = new LightningService(null, null, nodeRepository.Object, null, null, null, null, null, null, lightningClientService.Object, null, null); // Act var channelStatus = await lightningService.GetChannelsState(); @@ -2042,7 +2044,7 @@ public async Task GetChannelsStatus_SourceNodeIsManaged_SourceIsNotInitiator() }; lightningClientService.Setup(x => x.ListChannels(It.IsAny(), null)).ReturnsAsync(listChannelsResponse); - var lightningService = new LightningService(null, null, nodeRepository.Object, null, null, null, null, null ,null, lightningClientService.Object, null, null); + var lightningService = new LightningService(null, null, nodeRepository.Object, null, null, null, null, null, null, lightningClientService.Object, null, null); // Act var channelStatus = await lightningService.GetChannelsState(); @@ -2109,7 +2111,7 @@ public async Task GetChannelsStatus_BothNodesAreManaged_SourceIsInitiator() lightningClientService.SetupSequence(x => x.ListChannels(It.IsAny(), null)) .ReturnsAsync(listChannelsResponse1) .ReturnsAsync(listChannelsResponse2); - var lightningService = new LightningService(null, null, nodeRepository.Object, null, null, null, null, null ,null, lightningClientService.Object, null, null); + var lightningService = new LightningService(null, null, nodeRepository.Object, null, null, null, null, null, null, lightningClientService.Object, null, null); // Act var channelStatus = await lightningService.GetChannelsState(); @@ -2176,7 +2178,7 @@ public async Task GetChannelsStatus_BothNodesAreManaged_SourceIsNotInitiator() lightningClientService.SetupSequence(x => x.ListChannels(It.IsAny(), null)) .ReturnsAsync(listChannelsResponse1) .ReturnsAsync(listChannelsResponse2); - var lightningService = new LightningService(null, null, nodeRepository.Object, null, null, null, null, null ,null, lightningClientService.Object, null, null); + var lightningService = new LightningService(null, null, nodeRepository.Object, null, null, null, null, null, null, lightningClientService.Object, null, null); // Act var channelStatus = await lightningService.GetChannelsState(); @@ -2393,7 +2395,7 @@ public async Task SetChannelFeePolicy_EngineAllowsPositiveInbound_UpdatesPolicyA .Setup(x => x.GetByPubkey(node.PubKey)) .ReturnsAsync(node); lightningClientService - .Setup(x => x.SetChannelFeePolicy(node, It.IsAny(), 1000, 250, 40, 0, 50, null)) + .Setup(x => x.SetChannelFeePolicy(node, It.IsAny(), 1000, 250, 40, 0, 50, maxHtlcMsat: null, client: null)) .ReturnsAsync(new PolicyUpdateResponse()); var lightningService = new LightningService( @@ -2423,7 +2425,7 @@ await lightningService.SetChannelFeePolicy( // Assert — the positive inbound rate reached LND (no <= 0 throw)... lightningClientService.Verify(x => x.SetChannelFeePolicy( - node, It.IsAny(), 1000, 250, 40, 0, 50, null), Times.Once); + node, It.IsAny(), 1000, 250, 40, 0, 50, maxHtlcMsat: null, client: null), Times.Once); // ...and the write was audited through the system (engine-driven) path. auditService.Verify(x => x.LogSystemAsync( @@ -2623,5 +2625,363 @@ await act.Should() .ThrowAsync() .WithMessage("Channel not found for the given chanId. (Parameter 'chanId')"); } + + private const string MaxHtlcChanPoint = "0000000000000000000000000000000000000000000000000000000000000001:2"; + + private static Node MaxHtlcNode() => new() + { + Id = 30, + Name = "managedNode", + PubKey = "managedPubKey", + Endpoint = "127.0.0.1:10009", + ChannelAdminMacaroon = "test-macaroon" + }; + + private static Lnrpc.Channel MaxHtlcLndChannel(long capacitySats) => new() + { + ChanId = 123, + Capacity = capacitySats, + ChannelPoint = MaxHtlcChanPoint + }; + + /// + /// Wires up a LightningService with only the collaborators SyncChannelMaxHtlc touches: the LND + /// client (policy read + write), the channel repository (audit target) and the audit service. + /// A null reproduces LND having no graph edge for the channel. + /// + private (LightningService Service, Mock Client, Mock AuditService) BuildMaxHtlcService( + Node node, + ChannelEdge? channelEdge, + Channel? trackedChannel) + { + var outPoint = NBitcoin.OutPoint.Parse(MaxHtlcChanPoint); + + var lightningClientService = new Mock(); + lightningClientService + .Setup(x => x.GetChanInfo(node, 123UL, null)) + .ReturnsAsync(channelEdge); + lightningClientService + .Setup(x => x.SetChannelFeePolicy( + node, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new PolicyUpdateResponse()); + + var channelRepository = new Mock(); + channelRepository + .Setup(x => x.GetByOutpoint(It.Is(point => point.Hash == outPoint.Hash && point.N == outPoint.N))) + .ReturnsAsync(trackedChannel); + + var auditService = new Mock(); + + var lightningService = new LightningService( + _logger, null, null, null, null, channelRepository.Object, null, null, null, + lightningClientService.Object, null, auditService.Object); + + return (lightningService, lightningClientService, auditService); + } + + private static ChannelEdge MaxHtlcChannelEdge(string managedPubKey, RoutingPolicy? managedPolicy) => new() + { + Node1Pub = managedPubKey, + Node2Pub = "counterpartyPubKey", + Node1Policy = managedPolicy, + Node2Policy = new RoutingPolicy { FeeBaseMsat = 5000, FeeRateMilliMsat = 900, TimeLockDelta = 80 } + }; + + [Fact] + public async Task SyncChannelMaxHtlc_AlreadyAtTarget_MakesNoPolicyUpdate() + { + // Arrange — 1M sat channel already advertising 99% of capacity. + var node = MaxHtlcNode(); + var policy = new RoutingPolicy + { + FeeBaseMsat = 1000, + FeeRateMilliMsat = 250, + TimeLockDelta = 40, + MinHtlc = 1000, + MaxHtlcMsat = 990_000_000 + }; + var (service, client, auditService) = BuildMaxHtlcService( + node, + MaxHtlcChannelEdge(node.PubKey, policy), + new Channel { Id = 40 }); + + // Act + var result = await service.SyncChannelMaxHtlc(node, MaxHtlcLndChannel(1_000_000)); + + // Assert — LND rate-limits channel_update, so an unchanged policy must cost no write at all. + result.Should().Be(MaxHtlcSyncResult.NoOp); + client.Verify(x => x.SetChannelFeePolicy( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + auditService.VerifyNoOtherCalls(); + } + + [Fact] + public async Task SyncChannelMaxHtlc_OffTarget_EchoesFeePolicyAndAuditsTheWrite() + { + // Arrange — same channel, but advertising a stale 50k sat max htlc. + var node = MaxHtlcNode(); + var policy = new RoutingPolicy + { + FeeBaseMsat = 1000, + FeeRateMilliMsat = 250, + TimeLockDelta = 40, + MinHtlc = 1000, + MaxHtlcMsat = 50_000_000 + }; + var (service, client, auditService) = BuildMaxHtlcService( + node, + MaxHtlcChannelEdge(node.PubKey, policy), + new Channel { Id = 40 }); + var outPoint = NBitcoin.OutPoint.Parse(MaxHtlcChanPoint); + + // Act + var result = await service.SyncChannelMaxHtlc(node, MaxHtlcLndChannel(1_000_000)); + + // Assert — the fee fields are absolute in a policy update, so they must be echoed back + // unchanged, and the inbound fee must be omitted for LND to retain it. + result.Should().Be(MaxHtlcSyncResult.Updated); + client.Verify(x => x.SetChannelFeePolicy( + node, + It.Is(point => point.Hash == outPoint.Hash && point.N == outPoint.N), + 1000, + 250u, + 40u, + null, + null, + 990_000_000UL, + null), Times.Once); + auditService.Verify(x => x.LogSystemAsync( + AuditActionType.Update, + AuditEventType.Success, + AuditObjectType.Channel, + "40", + It.IsAny()), Times.Once); + } + + [Theory] + // Plain ratio of capacity. + [InlineData(1_000_000L, 1_000L, 990_000_000UL)] + // Small channel, same ratio. + [InlineData(20_000L, 1_000L, 19_800_000UL)] + // The peer's min_htlc sits above the ratio result, so the floor wins — LND rejects a max_htlc + // below min_htlc outright. + [InlineData(20_000L, 19_900_000L, 19_900_000UL)] + public async Task SyncChannelMaxHtlc_ResolvesTargetWithinChannelBounds(long capacitySats, long minHtlcMsat, ulong expectedMaxHtlcMsat) + { + // Arrange + var node = MaxHtlcNode(); + var policy = new RoutingPolicy + { + FeeBaseMsat = 0, + FeeRateMilliMsat = 1500, + TimeLockDelta = 40, + MinHtlc = minHtlcMsat, + MaxHtlcMsat = 1 // any value off target, so a write is attempted + }; + var (service, client, _) = BuildMaxHtlcService( + node, + MaxHtlcChannelEdge(node.PubKey, policy), + new Channel { Id = 40 }); + + // Act + var result = await service.SyncChannelMaxHtlc(node, MaxHtlcLndChannel(capacitySats)); + + // Assert + result.Should().Be(MaxHtlcSyncResult.Updated); + client.Verify(x => x.SetChannelFeePolicy( + node, It.IsAny(), 0, 1500u, 40u, null, null, + expectedMaxHtlcMsat, null), Times.Once); + } + + [Fact] + public async Task SyncChannelMaxHtlc_MinHtlcAboveCapacity_Skips() + { + // Arrange — no value satisfies both bounds, so there is nothing valid to write. + var node = MaxHtlcNode(); + var policy = new RoutingPolicy + { + FeeBaseMsat = 0, + FeeRateMilliMsat = 1500, + TimeLockDelta = 40, + MinHtlc = 30_000_000, + MaxHtlcMsat = 1 + }; + var (service, client, _) = BuildMaxHtlcService( + node, + MaxHtlcChannelEdge(node.PubKey, policy), + new Channel { Id = 40 }); + + // Act + var result = await service.SyncChannelMaxHtlc(node, MaxHtlcLndChannel(20_000)); + + // Assert + result.Should().Be(MaxHtlcSyncResult.Skipped); + client.Verify(x => x.SetChannelFeePolicy( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task SyncChannelMaxHtlc_ZeroCapacity_Skips() + { + // Arrange — a 0 target would reach LND as "leave max_htlc unchanged", so it must never be + // sent: the write would report success while changing nothing, every single run. + var node = MaxHtlcNode(); + var policy = new RoutingPolicy + { + FeeBaseMsat = 0, + FeeRateMilliMsat = 1500, + TimeLockDelta = 40, + MinHtlc = 0, + MaxHtlcMsat = 1 + }; + var (service, client, _) = BuildMaxHtlcService( + node, + MaxHtlcChannelEdge(node.PubKey, policy), + new Channel { Id = 40 }); + + // Act + var result = await service.SyncChannelMaxHtlc(node, MaxHtlcLndChannel(0)); + + // Assert + result.Should().Be(MaxHtlcSyncResult.Skipped); + client.Verify(x => x.SetChannelFeePolicy( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task SyncChannelMaxHtlc_NoGraphEdge_Skips() + { + // Arrange — a freshly confirmed or unannounced channel has no edge yet. + var node = MaxHtlcNode(); + var (service, client, _) = BuildMaxHtlcService(node, null, new Channel { Id = 40 }); + + // Act + var result = await service.SyncChannelMaxHtlc(node, MaxHtlcLndChannel(1_000_000)); + + // Assert + result.Should().Be(MaxHtlcSyncResult.Skipped); + client.Verify(x => x.SetChannelFeePolicy( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task SyncChannelMaxHtlc_NoPolicyForManagedSide_Skips() + { + // Arrange — the edge exists but our side has no policy on it. + var node = MaxHtlcNode(); + var (service, client, _) = BuildMaxHtlcService( + node, + MaxHtlcChannelEdge(node.PubKey, null), + new Channel { Id = 40 }); + + // Act + var result = await service.SyncChannelMaxHtlc(node, MaxHtlcLndChannel(1_000_000)); + + // Assert + result.Should().Be(MaxHtlcSyncResult.Skipped); + client.Verify(x => x.SetChannelFeePolicy( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task SyncChannelMaxHtlc_UntrackedChannel_SkipsWithoutWriting() + { + // Arrange — NodeGuard has no channel row yet (ghost recovery hasn't created it), so the write + // would not be auditable against a channel. + var node = MaxHtlcNode(); + var policy = new RoutingPolicy + { + FeeBaseMsat = 1000, + FeeRateMilliMsat = 250, + TimeLockDelta = 40, + MinHtlc = 1000, + MaxHtlcMsat = 50_000_000 + }; + var (service, client, _) = BuildMaxHtlcService( + node, + MaxHtlcChannelEdge(node.PubKey, policy), + trackedChannel: null); + + // Act + var result = await service.SyncChannelMaxHtlc(node, MaxHtlcLndChannel(1_000_000)); + + // Assert + result.Should().Be(MaxHtlcSyncResult.Skipped); + client.Verify(x => x.SetChannelFeePolicy( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task SyncChannelMaxHtlc_UnmanagedNode_Skips() + { + // Arrange — no endpoint means no gRPC surface to write to. + var node = new Node { Id = 31, Name = "externalNode", PubKey = "externalPubKey" }; + var (service, client, _) = BuildMaxHtlcService(node, null, new Channel { Id = 40 }); + + // Act + var result = await service.SyncChannelMaxHtlc(node, MaxHtlcLndChannel(1_000_000)); + + // Assert + result.Should().Be(MaxHtlcSyncResult.Skipped); + client.VerifyNoOtherCalls(); + } + + [Fact] + public async Task SyncChannelMaxHtlc_FailedUpdates_Throws() + { + // Arrange + var node = MaxHtlcNode(); + var policy = new RoutingPolicy + { + FeeBaseMsat = 1000, + FeeRateMilliMsat = 250, + TimeLockDelta = 40, + MinHtlc = 1000, + MaxHtlcMsat = 50_000_000 + }; + var (service, client, auditService) = BuildMaxHtlcService( + node, + MaxHtlcChannelEdge(node.PubKey, policy), + new Channel { Id = 40 }); + + var failedResponse = new PolicyUpdateResponse(); + failedResponse.FailedUpdates.Add(new FailedUpdate { Reason = UpdateFailure.NotFound }); + client + .Setup(x => x.SetChannelFeePolicy( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(failedResponse); + + // Act + var act = async () => await service.SyncChannelMaxHtlc(node, MaxHtlcLndChannel(1_000_000)); + + // Assert — a rejected write surfaces as an exception, never as a silent Skipped. + await act.Should().ThrowAsync() + .WithMessage($"Failed to update max htlc for channel: {MaxHtlcChanPoint}"); + auditService.Verify(x => x.LogSystemAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } } }