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