diff --git a/src/Jobs/AutoRebalanceJob.cs b/src/Jobs/AutoRebalanceJob.cs index a40b8535..223b54c8 100644 --- a/src/Jobs/AutoRebalanceJob.cs +++ b/src/Jobs/AutoRebalanceJob.cs @@ -92,7 +92,9 @@ public async Task Execute(IJobExecutionContext context) // Shared per-run context, only needed when at least one node is under management var openChannelsByChanId = (await _channelRepository.GetOpenChannels()) + .Where(c => c.SatsAmount >= Constants.ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS) .ToDictionary(c => c.ChanId); + var inFlightSourceChannelIds = await _rebalanceRepository.GetPendingInFlightSourceChannelIds(); foreach (var node in relevantNodes) diff --git a/test/NodeGuard.Tests/E2E/AutoRebalanceE2ETests.cs b/test/NodeGuard.Tests/E2E/AutoRebalanceE2ETests.cs index a23fa0a9..136ae0d8 100644 --- a/test/NodeGuard.Tests/E2E/AutoRebalanceE2ETests.cs +++ b/test/NodeGuard.Tests/E2E/AutoRebalanceE2ETests.cs @@ -36,7 +36,10 @@ namespace NodeGuard.Tests.E2E; [Collection("E2E")] public class AutoRebalanceE2ETests : RoutingEngineE2EBase { - private const long SourceMinCapacitySats = 10_000_000; + // Both constraints at once: enough liquidity for the drain phases, and at least what the job + // now requires of a source, so a reused channel can't be one the rebalancer filters out. + private static long SourceMinCapacitySats => Math.Max( + 10_000_000, long.Parse(Env("ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS", "15000000"))); // 0.5 - 0.30 clears the deadband with margin and leaves a deficit above the amount cap private const double DestinationDrainRatio = 0.30; diff --git a/test/NodeGuard.Tests/Jobs/AutoRebalanceJobTests.cs b/test/NodeGuard.Tests/Jobs/AutoRebalanceJobTests.cs index d12ea309..b058cd0d 100644 --- a/test/NodeGuard.Tests/Jobs/AutoRebalanceJobTests.cs +++ b/test/NodeGuard.Tests/Jobs/AutoRebalanceJobTests.cs @@ -35,6 +35,9 @@ public class AutoRebalanceJobTests { private const string NodePubKey = "managedPubKey"; + /// Capacity of every arranged channel, comfortably over the size floor (10M default). + private const long ChannelSizeSats = 20_000_000; + private readonly Mock> _logger = new(); private readonly Mock _nodeRepository = new(); @@ -56,10 +59,12 @@ private IRoutingEngineSnapshotService BuildSnapshotService() => _lightningClientService.Object); /// A NodeGuard channel row the rebalancer will consider, opted in or not. - private static Channel Db(int id, ulong chanId, bool optIn) => new() + private static Channel Db(int id, ulong chanId, bool optIn, long? satsAmount = null) => new() { Id = id, ChanId = chanId, Status = Channel.ChannelStatus.Open, IsAutoRebalanceEnabled = optIn, + // Matches the paired LND capacity, and clears the routing-engine size floor. + SatsAmount = satsAmount ?? ChannelSizeSats, FundingTx = $"tx{id}", FundingTxOutputIndex = 0, }; @@ -94,7 +99,8 @@ private AutoRebalanceJob BuildJob() => /// The single plan this produces reserves 6_250 sats (5_000_000 at its 0.125% cap). /// private void ArrangeRebalancePair(bool sourceOptedIn, bool sourceLiquidityFlag = false, - long? budgetSats = 1_000_000, long consumedFeesSats = 0) + long? budgetSats = 1_000_000, long consumedFeesSats = 0, + long sourceSatsAmount = ChannelSizeSats, long destSatsAmount = ChannelSizeSats) { var node = new Node { @@ -114,12 +120,14 @@ private void ArrangeRebalancePair(bool sourceOptedIn, bool sourceLiquidityFlag = IsDynamicFeeEnabled = true, IsAutoRebalanceEnabled = sourceOptedIn, IsAutomatedLiquidityEnabled = sourceLiquidityFlag, + SatsAmount = sourceSatsAmount, FundingTx = "txS", FundingTxOutputIndex = 0, }; var destDb = new Channel { Id = 102, ChanId = 1002, Status = Channel.ChannelStatus.Open, IsDynamicFeeEnabled = true, IsAutoRebalanceEnabled = false, + SatsAmount = destSatsAmount, FundingTx = "txD", FundingTxOutputIndex = 0, }; _channelRepository.Setup(x => x.GetOpenChannels()).ReturnsAsync(new List { sourceDb, destDb }); @@ -194,24 +202,9 @@ public async Task Execute_MixedPeer_OverRefillsBecauseUncategorizedSiblingIsInvi _channelRepository.Setup(x => x.GetOpenChannels()).ReturnsAsync(new List { - new() - { - Id = 101, ChanId = 1001, Status = Channel.ChannelStatus.Open, - IsDynamicFeeEnabled = true, IsAutoRebalanceEnabled = true, - FundingTx = "txS", FundingTxOutputIndex = 0, - }, - new() - { - Id = 102, ChanId = 1002, Status = Channel.ChannelStatus.Open, - IsDynamicFeeEnabled = true, IsAutoRebalanceEnabled = false, - FundingTx = "txD", FundingTxOutputIndex = 0, - }, - new() - { - Id = 103, ChanId = 1003, Status = Channel.ChannelStatus.Open, - IsDynamicFeeEnabled = true, IsAutoRebalanceEnabled = false, - FundingTx = "txD2", FundingTxOutputIndex = 0, - }, + Db(101, 1001, optIn: true), // source + Db(102, 1002, optIn: false), // depleted, categorized destination + Db(103, 1003, optIn: false), // full, uncategorized sibling on the same peer }); _routingStateRepository.Setup(x => x.GetByManagedNodePubKey(NodePubKey)).ReturnsAsync(new List @@ -367,6 +360,61 @@ await RoutingEngineSwitch.WithEngine(enabled: true, async () => It.IsAny(), It.IsAny()), Times.Never); } + [Fact] + public async Task Execute_SourceBelowMinChannelSize_IsNeverDrained() + { + // One sat under the floor, so this pins the exact boundary of the size gate. The channel is + // too-local and opted in — the only thing stopping it is its size. + ArrangeRebalancePair(sourceOptedIn: true, + sourceSatsAmount: Constants.ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS - 1); + + await RoutingEngineSwitch.WithEngine(enabled: true, async () => + { + await BuildJob().Execute(Mock.Of()); + }); + + _rebalanceService.Verify(x => x.RebalanceAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Execute_DestinationBelowMinChannelSize_IsNeverRefilled() + { + // The mirror image: the drainable source is fine, but the only depleted peer is too small + // to be worth refilling. Filtering at the repository is what covers this direction — a gate + // inside the planner's source loop would happily pay to fill it. + ArrangeRebalancePair(sourceOptedIn: true, + destSatsAmount: Constants.ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS - 1); + + await RoutingEngineSwitch.WithEngine(enabled: true, async () => + { + await BuildJob().Execute(Mock.Of()); + }); + + _rebalanceService.Verify(x => x.RebalanceAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Execute_ChannelsAtExactlyMinChannelSize_AreStillRebalanced() + { + // Capacity == the floor, and the comparison is >=, so both ends must survive the filter. + // SatsAmount is all the gate reads; the LND balances still drive sizing, hence 5_000_000. + ArrangeRebalancePair(sourceOptedIn: true, + sourceSatsAmount: Constants.ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS, + destSatsAmount: Constants.ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS); + + await RoutingEngineSwitch.WithEngine(enabled: true, async () => + { + await BuildJob().Execute(Mock.Of()); + }); + + _rebalanceService.Verify(x => x.RebalanceAsync( + It.Is(r => r.SourceChannelId == 101 && r.TargetPubkey == "peerD" + && r.AmountSats == 5_000_000), + It.IsAny()), Times.Once); + } + /// /// Two drainable sources and two depleted destination peers, so the planner produces two plans, /// on a node pinned to one in-flight rebalance so the second plan is dropped. Pairing is