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