Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Jobs/AutoRebalanceJob.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion test/NodeGuard.Tests/E2E/AutoRebalanceE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
88 changes: 68 additions & 20 deletions test/NodeGuard.Tests/Jobs/AutoRebalanceJobTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ public class AutoRebalanceJobTests
{
private const string NodePubKey = "managedPubKey";

/// <summary>Capacity of every arranged channel, comfortably over the size floor (10M default).</summary>
private const long ChannelSizeSats = 20_000_000;

private readonly Mock<ILogger<AutoRebalanceJob>> _logger = new();

private readonly Mock<INodeRepository> _nodeRepository = new();
Expand All @@ -56,10 +59,12 @@ private IRoutingEngineSnapshotService BuildSnapshotService() =>
_lightningClientService.Object);

/// <summary>A NodeGuard channel row the rebalancer will consider, opted in or not.</summary>
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,
};

Expand Down Expand Up @@ -94,7 +99,8 @@ private AutoRebalanceJob BuildJob() =>
/// The single plan this produces reserves 6_250 sats (5_000_000 at its 0.125% cap).
/// </summary>
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
{
Expand All @@ -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<Channel> { sourceDb, destDb });
Expand Down Expand Up @@ -194,24 +202,9 @@ public async Task Execute_MixedPeer_OverRefillsBecauseUncategorizedSiblingIsInvi

_channelRepository.Setup(x => x.GetOpenChannels()).ReturnsAsync(new List<Channel>
{
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<ChannelRoutingState>
Expand Down Expand Up @@ -367,6 +360,61 @@ await RoutingEngineSwitch.WithEngine(enabled: true, async () =>
It.IsAny<RebalanceRequest>(), It.IsAny<CancellationToken>()), 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<IJobExecutionContext>());
});

_rebalanceService.Verify(x => x.RebalanceAsync(
It.IsAny<RebalanceRequest>(), It.IsAny<CancellationToken>()), 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<IJobExecutionContext>());
});

_rebalanceService.Verify(x => x.RebalanceAsync(
It.IsAny<RebalanceRequest>(), It.IsAny<CancellationToken>()), 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<IJobExecutionContext>());
});

_rebalanceService.Verify(x => x.RebalanceAsync(
It.Is<RebalanceRequest>(r => r.SourceChannelId == 101 && r.TargetPubkey == "peerD"
&& r.AmountSats == 5_000_000),
It.IsAny<CancellationToken>()), Times.Once);
}

/// <summary>
/// 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
Expand Down
Loading