diff --git a/src/Data/Models/Rebalance.cs b/src/Data/Models/Rebalance.cs
index f5d14db7..f1ae70e6 100644
--- a/src/Data/Models/Rebalance.cs
+++ b/src/Data/Models/Rebalance.cs
@@ -90,6 +90,12 @@ public class Rebalance : Entity
[NotMapped]
public Money FeePaid => new Money(FeePaidSats ?? 0, MoneyUnit.Satoshi);
+ ///
+ /// Worst-case fee a not-yet-settled rebalance can still consume: the whole amount at its fee cap.
+ ///
+ public static long WorstCaseFeeSats(long amountSats, double maxFeePct)
+ => (long)(amountSats * maxFeePct / 100.0);
+
public int? SourceChannelId { get; set; }
public Channel? SourceChannel { get; set; }
diff --git a/src/Data/Repositories/ChannelRepository.cs b/src/Data/Repositories/ChannelRepository.cs
index 09bac94e..32c9c278 100644
--- a/src/Data/Repositories/ChannelRepository.cs
+++ b/src/Data/Repositories/ChannelRepository.cs
@@ -105,15 +105,6 @@ public async Task> GetOpenChannels()
.ToListAsync();
}
- public async Task> GetChannelsByOpenAndDynamicFeeEnabled()
- {
- await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync();
-
- return await applicationDbContext.Channels
- .Where(c => c.Status == Channel.ChannelStatus.Open && c.ChanId != 0 && c.IsDynamicFeeEnabled)
- .ToListAsync();
- }
-
public async Task<(bool, string?)> AddAsync(Channel type)
{
await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync();
diff --git a/src/Data/Repositories/Interfaces/IChannelRepository.cs b/src/Data/Repositories/Interfaces/IChannelRepository.cs
index bfeb84a1..475ad5d2 100644
--- a/src/Data/Repositories/Interfaces/IChannelRepository.cs
+++ b/src/Data/Repositories/Interfaces/IChannelRepository.cs
@@ -32,8 +32,6 @@ public interface IChannelRepository
Task> GetOpenChannels();
- Task > GetChannelsByOpenAndDynamicFeeEnabled();
-
Task<(bool, string?)> AddAsync(Channel type);
Task<(bool, string?)> AddRangeAsync(List type);
diff --git a/src/Data/Repositories/RebalanceRepository.cs b/src/Data/Repositories/RebalanceRepository.cs
index 9d2c47cc..ab61529f 100644
--- a/src/Data/Repositories/RebalanceRepository.cs
+++ b/src/Data/Repositories/RebalanceRepository.cs
@@ -181,9 +181,8 @@ public async Task GetConsumedFeesSince(int nodeId, DateTimeOffset since)
foreach (var r in rows)
{
var feePaid = r.FeePaidSats ?? 0;
- // Conservative worst-case reservation for a still-in-flight rebalance: RequestedAmountSats ×
- // (MaxFeePct/100).
- var reserved = (long)(r.RequestedAmountSats * r.MaxFeePct / 100.0);
+ // Conservative worst-case reservation for a still-in-flight rebalance.
+ var reserved = Rebalance.WorstCaseFeeSats(r.RequestedAmountSats, r.MaxFeePct);
total += r.Status == RebalanceStatus.Succeeded
? feePaid
: reserved;
diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs
index 498ecd96..daa05d88 100644
--- a/src/Helpers/Constants.cs
+++ b/src/Helpers/Constants.cs
@@ -273,11 +273,23 @@ public class Constants
public static int ROUTING_ENGINE_CATEGORY_FLIP_HYSTERESIS_CYCLES = 3;
///
- /// Cadence of TargetRatioReevaluationJob and ChannelFeeOptimizerJob in prod, in minutes. Default 30. In dev
+ /// Cadence of TargetRatioReevaluationJob and RoutingEngineActuatorJob in prod, in minutes. Default 30. In dev
/// (IS_DEV_ENVIRONMENT) the job runs every 5 minutes regardless.
///
public static int ROUTING_ENGINE_JOB_INTERVAL_MINUTES = 30;
+ ///
+ /// How long after TargetRatioReevaluationJob the RoutingEngineActuatorJob first fires,
+ /// in minutes (prod only).
+ ///
+ public static int ROUTING_ENGINE_ACTUATOR_OFFSET_MINUTES = 5;
+
+ ///
+ /// Cadence of AutoRebalanceJob in prod, in minutes. Independent of
+ /// ROUTING_ENGINE_JOB_INTERVAL_MINUTES. In dev it is 1 minute.
+ ///
+ public static int ROUTING_ENGINE_REBALANCE_JOB_INTERVAL_MINUTES = 10;
+
// Both fees use integral control: each cycle the applied value is nudged by gain·deviation·baseline
// off its previous value, so a persistent deviation keeps driving the fee until the channel balances.
public static double ROUTING_ENGINE_FEE_OUTBOUND_INTEGRAL_GAIN = 0.8;
@@ -288,7 +300,7 @@ public class Constants
public static double ROUTING_ENGINE_FEE_DEADBAND = 0.03;
// The rebalancer's imbalance deadband is a separate, more aggressive threshold for triggering rebalances.
- public static double ROUTING_ENGINE_REBALANCE_TRIGGER = 0.15;
+ public static double ROUTING_ENGINE_REBALANCE_DEADBAND = 0.15;
// Max outbound ppm change applied in a single cycle (rate limiter / anti-jump).
public static uint ROUTING_ENGINE_FEE_MAX_STEP_PPM = 50;
@@ -335,6 +347,27 @@ public class Constants
///
public static double MAX_HTLC_CAPACITY_RATIO = 0.99;
+ // Routing Engine: automated rebalancer
+
+ /// Upper clamp on a single automated rebalance amount (sats)
+ public static long ROUTING_ENGINE_REBALANCE_MAX_AMOUNT_SATS = 10_000_000;
+
+ /// Max rebalances the routing-engine actuator initiates per node per run
+ public static int ROUTING_ENGINE_REBALANCE_MAX_INITIATIONS_PER_RUN = 5;
+
+ /// Fallback profitability margin when a node leaves MaxRebalanceCostToEarnRatio unset. The
+ /// gate caps rebalance cost at ratio × (capacity-weighted-avg outbound ppm of the refilled
+ /// peer). 0.5 = spend at most half the destination's earn rate.
+ public static double ROUTING_ENGINE_REBALANCE_DEFAULT_COST_TO_EARN_RATIO = 0.5;
+
+ /// Fallback max concurrent (Pending/InFlight) rebalances when a node leaves
+ /// MaxRebalancesInFlight unset.
+ public static int ROUTING_ENGINE_REBALANCE_DEFAULT_MAX_IN_FLIGHT = 5;
+
+ /// Fallback rebalance-budget refresh window (hours) when a node leaves
+ /// RebalanceBudgetRefreshInterval unset.
+ public static int ROUTING_ENGINE_REBALANCE_DEFAULT_BUDGET_REFRESH_HOURS = 24;
+
public const string IsFrozenTag = "frozen";
public const string IsManuallyFrozenTag = "manually_frozen";
@@ -606,6 +639,10 @@ static Constants()
var reJobInterval = Environment.GetEnvironmentVariable("ROUTING_ENGINE_JOB_INTERVAL_MINUTES");
if (reJobInterval != null) ROUTING_ENGINE_JOB_INTERVAL_MINUTES = int.Parse(reJobInterval);
+ var reActuatorOffset = Environment.GetEnvironmentVariable("ROUTING_ENGINE_ACTUATOR_OFFSET_MINUTES");
+ if (reActuatorOffset != null) ROUTING_ENGINE_ACTUATOR_OFFSET_MINUTES = int.Parse(reActuatorOffset);
+ var reRebalanceInterval = Environment.GetEnvironmentVariable("ROUTING_ENGINE_REBALANCE_JOB_INTERVAL_MINUTES");
+ if (reRebalanceInterval != null) ROUTING_ENGINE_REBALANCE_JOB_INTERVAL_MINUTES = int.Parse(reRebalanceInterval);
// Routing Engine
var feeOutboundIntegralGain = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_OUTBOUND_INTEGRAL_GAIN");
@@ -618,7 +655,7 @@ static Constants()
if (feeDeadband != null) ROUTING_ENGINE_FEE_DEADBAND = double.Parse(feeDeadband, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture);
var rebalanceDeadband = Environment.GetEnvironmentVariable("ROUTING_ENGINE_REBALANCE_DEADBAND");
- if (rebalanceDeadband != null) ROUTING_ENGINE_REBALANCE_TRIGGER = double.Parse(rebalanceDeadband, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture);
+ if (rebalanceDeadband != null) ROUTING_ENGINE_REBALANCE_DEADBAND = double.Parse(rebalanceDeadband, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture);
var feeMaxStep = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_MAX_STEP_PPM");
if (feeMaxStep != null) ROUTING_ENGINE_FEE_MAX_STEP_PPM = uint.Parse(feeMaxStep);
@@ -665,6 +702,16 @@ static Constants()
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]");
}
+ var reRebalanceMaxAmount = Environment.GetEnvironmentVariable("ROUTING_ENGINE_REBALANCE_MAX_AMOUNT_SATS");
+ if (reRebalanceMaxAmount != null) ROUTING_ENGINE_REBALANCE_MAX_AMOUNT_SATS = long.Parse(reRebalanceMaxAmount);
+ var reRebalanceMaxInit = Environment.GetEnvironmentVariable("ROUTING_ENGINE_REBALANCE_MAX_INITIATIONS_PER_RUN");
+ if (reRebalanceMaxInit != null) ROUTING_ENGINE_REBALANCE_MAX_INITIATIONS_PER_RUN = int.Parse(reRebalanceMaxInit);
+ var reRebalanceDefaultRatio = Environment.GetEnvironmentVariable("ROUTING_ENGINE_REBALANCE_DEFAULT_COST_TO_EARN_RATIO");
+ if (reRebalanceDefaultRatio != null) ROUTING_ENGINE_REBALANCE_DEFAULT_COST_TO_EARN_RATIO = double.Parse(reRebalanceDefaultRatio, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture);
+ var reRebalanceDefaultMaxInFlight = Environment.GetEnvironmentVariable("ROUTING_ENGINE_REBALANCE_DEFAULT_MAX_IN_FLIGHT");
+ if (reRebalanceDefaultMaxInFlight != null) ROUTING_ENGINE_REBALANCE_DEFAULT_MAX_IN_FLIGHT = int.Parse(reRebalanceDefaultMaxInFlight);
+ var reRebalanceDefaultRefreshHours = Environment.GetEnvironmentVariable("ROUTING_ENGINE_REBALANCE_DEFAULT_BUDGET_REFRESH_HOURS");
+ if (reRebalanceDefaultRefreshHours != null) ROUTING_ENGINE_REBALANCE_DEFAULT_BUDGET_REFRESH_HOURS = int.Parse(reRebalanceDefaultRefreshHours);
// DB Initialization
ALICE_PUBKEY = Environment.GetEnvironmentVariable("ALICE_PUBKEY") ?? ALICE_PUBKEY;
diff --git a/src/Jobs/AutoRebalanceJob.cs b/src/Jobs/AutoRebalanceJob.cs
new file mode 100644
index 00000000..8173a8a4
--- /dev/null
+++ b/src/Jobs/AutoRebalanceJob.cs
@@ -0,0 +1,309 @@
+/*
+ * NodeGuard
+ * Copyright (C) 2023 Elenpay
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see http://www.gnu.org/licenses/.
+ *
+ */
+
+using NodeGuard.Data.Models;
+using NodeGuard.Data.Repositories.Interfaces;
+using NodeGuard.Helpers;
+using NodeGuard.Services;
+using Quartz;
+using Channel = NodeGuard.Data.Models.Channel;
+
+namespace NodeGuard.Jobs;
+
+///
+/// The routing engine's rebalance actuator. For every node with
+/// it takes a snapshot, hands it to the pure to plan
+/// too-local-source → too-remote-destination circular rebalances, and dispatches them via
+/// — bounded by the node's fee budget, its in-flight cap, and the
+/// per-run initiation cap.
+///
+/// Runs on its own cadence (ROUTING_ENGINE_REBALANCE_JOB_INTERVAL_MINUTES), independent of
+/// .
+///
+[DisallowConcurrentExecution]
+public class AutoRebalanceJob : IJob
+{
+ private readonly ILogger _logger;
+ private readonly INodeRepository _nodeRepository;
+ private readonly IChannelRepository _channelRepository;
+ private readonly IRebalanceRepository _rebalanceRepository;
+ private readonly IRebalanceService _rebalanceService;
+ private readonly IRoutingEngineSnapshotService _snapshotService;
+ private readonly ILightningService _lightningService;
+ private readonly IAuditService _auditService;
+
+ public AutoRebalanceJob(
+ ILogger logger,
+ INodeRepository nodeRepository,
+ IChannelRepository channelRepository,
+ IRebalanceRepository rebalanceRepository,
+ IRebalanceService rebalanceService,
+ IRoutingEngineSnapshotService snapshotService,
+ ILightningService lightningService,
+ IAuditService auditService)
+ {
+ _logger = logger;
+ _nodeRepository = nodeRepository;
+ _channelRepository = channelRepository;
+ _rebalanceRepository = rebalanceRepository;
+ _rebalanceService = rebalanceService;
+ _snapshotService = snapshotService;
+ _lightningService = lightningService;
+ _auditService = auditService;
+ }
+
+ public async Task Execute(IJobExecutionContext context)
+ {
+ // Global kill switch — checked before any work
+ if (!Constants.ROUTING_ENGINE_ENABLED)
+ {
+ return;
+ }
+
+ _logger.LogInformation("Starting {JobName}...", nameof(AutoRebalanceJob));
+
+ try
+ {
+ var managedNodes = await _nodeRepository.GetAllManagedByNodeGuard(withDisabled: false);
+
+ var relevantNodes = managedNodes.Where(n => n.AutoRebalanceEnabled).ToList();
+ if (relevantNodes.Count == 0)
+ {
+ _logger.LogInformation("No managed nodes with the rebalancer enabled; skipping {JobName}",
+ nameof(AutoRebalanceJob));
+ return;
+ }
+
+ // Shared per-run context, only needed when at least one node is under management
+ var openChannelsByChanId = (await _channelRepository.GetOpenChannels())
+ .ToDictionary(c => c.ChanId);
+ var inFlightSourceChannelIds = await _rebalanceRepository.GetPendingInFlightSourceChannelIds();
+
+ foreach (var node in relevantNodes)
+ {
+ try
+ {
+ await RebalanceNode(node, openChannelsByChanId, inFlightSourceChannelIds);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error rebalancing node {NodeName} ({NodePubKey})",
+ node.Name, node.PubKey);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error in {JobName}", nameof(AutoRebalanceJob));
+ }
+
+ _logger.LogInformation("{JobName} ended", nameof(AutoRebalanceJob));
+ }
+
+ private async Task RebalanceNode(
+ Node node,
+ IReadOnlyDictionary openChannelsByChanId,
+ IReadOnlySet inFlightSourceChannelIds)
+ {
+ var now = DateTimeOffset.UtcNow;
+
+ // A budget must be configured before we spend anything. Checked before the LND round-trip
+ var budgetSats = node.RebalanceBudgetSats ?? 0;
+ if (budgetSats <= 0)
+ {
+ _logger.LogInformation("Node {NodeName}: no rebalance budget configured; skipping", node.Name);
+ return;
+ }
+
+ // Budget-period refresh
+ var refreshInterval = node.RebalanceBudgetRefreshInterval
+ ?? TimeSpan.FromHours(Constants.ROUTING_ENGINE_REBALANCE_DEFAULT_BUDGET_REFRESH_HOURS);
+ if (!node.RebalanceBudgetStartDatetime.HasValue ||
+ now - node.RebalanceBudgetStartDatetime.Value >= refreshInterval)
+ {
+ _logger.LogInformation("Refreshing rebalance budget for node {NodeName}", node.Name);
+ node.RebalanceBudgetStartDatetime = now;
+ _nodeRepository.Update(node);
+ }
+
+ var periodStart = node.RebalanceBudgetStartDatetime ?? now;
+
+ // Remaining fee budget for this period. GetConsumedFeesSince already counts in-flight
+ // reservations, so a rebalance started earlier this period is charged immediately
+ var consumed = await _rebalanceRepository.GetConsumedFeesSince(node.Id, periodStart);
+ var remainingBudget = budgetSats - consumed;
+ if (remainingBudget <= 0)
+ {
+ _logger.LogInformation("Node {NodeName}: rebalance fee budget exhausted ({Consumed}/{Budget} sats)",
+ node.Name, consumed, budgetSats);
+ return;
+ }
+
+ // In-flight cap
+ var inFlight = await _rebalanceRepository.GetInFlightByNode(node.Id);
+ var maxInFlight = node.MaxRebalancesInFlight ?? Constants.ROUTING_ENGINE_REBALANCE_DEFAULT_MAX_IN_FLIGHT;
+ if (inFlight >= maxInFlight)
+ {
+ _logger.LogInformation("Node {NodeName}: max rebalances in flight reached ({InFlight}/{Max})",
+ node.Name, inFlight, maxInFlight);
+ return;
+ }
+
+ var owned = await _snapshotService.GetOwnedChannelsAsync(node, openChannelsByChanId, withFeeState: false);
+ if (owned == null)
+ {
+ _logger.LogWarning("Skipping node {NodeName}: ListChannels unavailable", node.Name);
+ return;
+ }
+
+ var signals = owned.Select(oc => new ChannelSignal(
+ oc.DbChannel.Id,
+ oc.Lnd.ChanId,
+ oc.Lnd.RemotePubkey,
+ oc.Lnd.LocalBalance,
+ oc.Lnd.RemoteBalance,
+ oc.RoutingState.EmaLocalRatio,
+ oc.RoutingState.TargetLocalRatio,
+ oc.Lnd.Active,
+ // A channel is a fresh source only if opted in and not already being drained
+ oc.DbChannel.IsAutoRebalanceEnabled && !inFlightSourceChannelIds.Contains(oc.DbChannel.Id)))
+ .ToList();
+
+ var tunables = RebalanceInitiatorTunables.FromConstants(node);
+ var classification = RebalanceInitiatorService.Classify(signals, tunables);
+
+ if (classification.Sources.Count == 0 && classification.Destinations.Count == 0)
+ {
+ _logger.LogInformation("Node {NodeName}: nothing to rebalance (no channel tripped the trigger)", node.Name);
+ return;
+ }
+
+ _logger.LogInformation(
+ "Node {NodeName}: detected {Sources} source(s) and {Destinations} destination(s); " +
+ "fallback pool {FallbackSources} source(s), {FallbackDestinations} destination(s)",
+ node.Name, classification.Sources.Count, classification.Destinations.Count,
+ classification.FallbackSources.Count, classification.FallbackDestinations.Count);
+
+ var earnRates = await _lightningService.GetLocalOutboundFeeRatesPpmAsync(node);
+ if (earnRates == null)
+ {
+ _logger.LogWarning("Skipping node {NodeName}: FeeReport unavailable, so nothing can be profit-gated",
+ node.Name);
+ return;
+ }
+
+ var plans = RebalanceInitiatorService.BuildPlans(classification, earnRates, tunables);
+ if (plans.Count == 0)
+ {
+ _logger.LogInformation("Node {NodeName}: no profitable rebalance plans this cycle", node.Name);
+ return;
+ }
+
+ var initiations = 0;
+
+ for (var i = 0; i < plans.Count; i++)
+ {
+ var plan = plans[i];
+
+ // The cap stops the loop outright, so every remaining plan is abandoned
+ if (inFlight + initiations >= maxInFlight)
+ {
+ _logger.LogInformation(
+ "Node {NodeName}: dropped {DroppedCount} of {PlannedCount} planned rebalance(s) — " +
+ "in-flight cap reached ({Reached}/{Max}, {Before} already in flight before this run; " +
+ "raise the node's MaxRebalancesInFlight to dispatch more per cycle)",
+ node.Name, plans.Count - i, plans.Count, inFlight + initiations, maxInFlight, inFlight);
+
+ for (var dropped = i; dropped < plans.Count; dropped++)
+ {
+ _logger.LogInformation("Node {NodeName}: dropped plan — {Reason}", node.Name, plans[dropped].Reason);
+ }
+
+ break;
+ }
+
+ var reservedFee = Rebalance.WorstCaseFeeSats(plan.AmountSats, plan.MaxFeePct);
+ if (reservedFee > remainingBudget)
+ {
+ _logger.LogInformation(
+ "Node {NodeName}: skipping plan (reserved {Reserved} sats > remaining budget {Remaining} sats): {Reason}",
+ node.Name, reservedFee, remainingBudget, plan.Reason);
+ continue;
+ }
+
+ if (node.RoutingEngineDryRun)
+ {
+ _logger.LogInformation("Dry-run: node {NodeName} would rebalance — {Reason}", node.Name, plan.Reason);
+ remainingBudget -= reservedFee;
+ initiations++;
+ continue;
+ }
+
+ try
+ {
+ var request = new RebalanceRequest(
+ NodeId: node.Id,
+ SourceChannelId: plan.SourceChannelId,
+ TargetPubkey: plan.DestinationPeerPubKey,
+ AmountSats: plan.AmountSats,
+ MaxFeePct: plan.MaxFeePct,
+ IsManual: false,
+ // Keep retries within the profitable ceiling
+ RetryMaxFeePct: plan.MaxFeePct);
+
+ await _rebalanceService.RebalanceAsync(request, CancellationToken.None);
+
+ // Audit the performed rebalance
+ await _auditService.LogSystemAsync(
+ AuditActionType.RebalanceInitiated,
+ AuditEventType.Attempt,
+ AuditObjectType.Rebalance,
+ plan.SourceChannelId.ToString(),
+ new
+ {
+ NodeName = node.Name,
+ NodeId = node.Id,
+ NodePubKey = node.PubKey,
+ SourceChannelId = plan.SourceChannelId,
+ TargetPubkey = plan.DestinationPeerPubKey,
+ AmountSats = plan.AmountSats,
+ MaxFeePct = plan.MaxFeePct,
+ ReservedFeeSats = reservedFee,
+ BudgetRemainingSats = remainingBudget - reservedFee,
+ BudgetTotalSats = budgetSats,
+ InFlightCount = inFlight + initiations + 1,
+ MaxInFlightCount = maxInFlight,
+ Reason = plan.Reason
+ });
+
+ remainingBudget -= reservedFee;
+ initiations++;
+ _logger.LogInformation("Node {NodeName}: initiated rebalance — {Reason}", node.Name, plan.Reason);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Node {NodeName}: failed to initiate rebalance ({Reason})", node.Name, plan.Reason);
+ }
+ }
+
+ _logger.LogInformation(
+ "Node {NodeName}: initiated {Count} of {PlannedCount} planned rebalance(s), remaining budget {Remaining}/{Budget} sats",
+ node.Name, initiations, plans.Count, remainingBudget, budgetSats);
+ }
+}
diff --git a/src/Jobs/ChannelFeeOptimizerJob.cs b/src/Jobs/ChannelFeeOptimizerJob.cs
index 2e31284a..a4d79201 100644
--- a/src/Jobs/ChannelFeeOptimizerJob.cs
+++ b/src/Jobs/ChannelFeeOptimizerJob.cs
@@ -27,11 +27,10 @@
namespace NodeGuard.Jobs;
///
-/// Dynamic fee actuator. For every eligible, owned channel
-/// on a node with dynamic fee management enabled it reads the signal
+/// The routing engine's fee actuator. For every eligible channel on a node with
+/// it reads the signal
/// (), runs the pure control
-/// law, and applies the resulting outbound/inbound ppm via LND — enforcing the fee-vs-rebalance
-/// authority split.
+/// law, and applies the resulting outbound/inbound ppm via LND.
/// Everything is gated by the global ROUTING_ENGINE_ENABLED kill switch.
///
[DisallowConcurrentExecution]
@@ -40,30 +39,27 @@ public class ChannelFeeOptimizerJob : IJob
private readonly ILogger _logger;
private readonly INodeRepository _nodeRepository;
private readonly IChannelRepository _channelRepository;
- private readonly IChannelRoutingStateRepository _routingStateRepository;
private readonly IChannelFeeStateRepository _feeStateRepository;
private readonly IRebalanceRepository _rebalanceRepository;
+ private readonly IRoutingEngineSnapshotService _snapshotService;
private readonly ILightningService _lightningService;
- private readonly ILightningClientService _lightningClientService;
public ChannelFeeOptimizerJob(
ILogger logger,
INodeRepository nodeRepository,
IChannelRepository channelRepository,
- IChannelRoutingStateRepository routingStateRepository,
IChannelFeeStateRepository feeStateRepository,
IRebalanceRepository rebalanceRepository,
- ILightningService lightningService,
- ILightningClientService lightningClientService)
+ IRoutingEngineSnapshotService snapshotService,
+ ILightningService lightningService)
{
_logger = logger;
_nodeRepository = nodeRepository;
_channelRepository = channelRepository;
- _routingStateRepository = routingStateRepository;
_feeStateRepository = feeStateRepository;
_rebalanceRepository = rebalanceRepository;
+ _snapshotService = snapshotService;
_lightningService = lightningService;
- _lightningClientService = lightningClientService;
}
public async Task Execute(IJobExecutionContext context)
@@ -81,31 +77,33 @@ public async Task Execute(IJobExecutionContext context)
var tunables = FeeOptimizerTunables.FromConstants();
var managedNodes = await _nodeRepository.GetAllManagedByNodeGuard(withDisabled: false);
- var anyEnabled = managedNodes.Any(n => n.DynamicFeeManagementEnabled);
- if (!anyEnabled)
+ var relevantNodes = managedNodes.Where(n => n.DynamicFeeManagementEnabled).ToList();
+ if (relevantNodes.Count == 0)
{
_logger.LogInformation("No managed nodes with dynamic fee management enabled; skipping {JobName}",
nameof(ChannelFeeOptimizerJob));
return;
}
- // Shared per-run context, only needed when at least one node is under management.
- var channelsByChanId = (await _channelRepository.GetChannelsByOpenAndDynamicFeeEnabled())
- .ToDictionary(c => c.ChanId);
var inFlightSourceChannelIds = await _rebalanceRepository.GetPendingInFlightSourceChannelIds();
- foreach (var managedNode in managedNodes)
+ // Get all the open channels that are eligible for fee optimization in one DB call, then filter per node.
+ var openChannelsByChanId = (await _channelRepository.GetOpenChannels())
+ .Where(c => c.IsDynamicFeeEnabled)
+ .Where(c => c.SatsAmount >= Constants.ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS)
+ .Where(c => !inFlightSourceChannelIds.Contains(c.Id))
+ .ToDictionary(c => c.ChanId);
+
+ foreach (var node in relevantNodes)
{
try
{
- if (!managedNode.DynamicFeeManagementEnabled) continue;
-
- await OptimizeNode(managedNode, managedNodes, channelsByChanId, inFlightSourceChannelIds, tunables);
+ await OptimizeNode(node, openChannelsByChanId, inFlightSourceChannelIds, tunables);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error optimizing fees for node {NodeName} ({NodePubKey})",
- managedNode.Name, managedNode.PubKey);
+ node.Name, node.PubKey);
}
}
}
@@ -118,86 +116,45 @@ public async Task Execute(IJobExecutionContext context)
_logger.LogInformation("{JobName} ended", nameof(ChannelFeeOptimizerJob));
}
- private sealed class Candidate
- {
- public required Lnrpc.Channel LndChannel { get; init; }
- public required Channel DbChannel { get; init; }
- public required ChannelRoutingState RoutingState { get; init; }
- public required ChannelFeeState? FeeState { get; init; }
- }
-
private async Task OptimizeNode(
Node node,
- IReadOnlyCollection managedNodes,
- IReadOnlyDictionary channelsByChanId,
+ IReadOnlyDictionary openChannelsByChanId,
IReadOnlySet inFlightSourceChannelIds,
FeeOptimizerTunables tunables)
{
- var listResp = await _lightningClientService.ListChannels(node);
- if (listResp == null)
+ var owned = await _snapshotService.GetOwnedChannelsAsync(node, openChannelsByChanId, withFeeState: true);
+ if (owned == null)
{
- _logger.LogWarning("Skipping fee optimization for node {NodeName}: ListChannels unavailable", node.Name);
+ _logger.LogWarning("Skipping node {NodeName}: ListChannels unavailable", node.Name);
return;
}
- var routingStates = (await _routingStateRepository.GetByManagedNodePubKey(node.PubKey))
- .ToDictionary(s => s.ChannelId);
- var feeStates = (await _feeStateRepository.GetByManagedNodePubKey(node.PubKey))
- .ToDictionary(s => s.ChannelId);
-
- var now = DateTimeOffset.UtcNow;
-
- // Eligibility filter.
- var candidates = new List();
- foreach (var lndChannel in listResp.Channels)
- {
- if (!ChannelOwnershipHelper.IsOwnedByManagedNode(lndChannel, managedNodes)) continue;
- if (!channelsByChanId.TryGetValue(lndChannel.ChanId, out var dbChannel)) continue;
- if (lndChannel.Capacity < Constants.ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS) continue;
- if (!routingStates.TryGetValue(dbChannel.Id, out var routingState)) continue; // no signal yet
-
- feeStates.TryGetValue(dbChannel.Id, out var feeState);
-
- candidates.Add(new Candidate
- {
- LndChannel = lndChannel,
- DbChannel = dbChannel,
- RoutingState = routingState,
- FeeState = feeState,
- });
- }
-
- foreach (var candidate in candidates)
+ foreach (var oc in owned)
{
- // Authority split: never touch a channel the rebalancer is actively moving.
- if (inFlightSourceChannelIds.Contains(candidate.DbChannel.Id))
- {
- _logger.LogDebug("Skipping channel {ChanId} on {NodeName}: in-flight rebalance owns it",
- candidate.LndChannel.ChanId, node.Name);
- continue;
- }
-
try
{
- await OptimizeChannel(node, candidate, tunables, now);
+ await OptimizeChannel(node, oc, tunables);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error optimizing fees for channel {ChanId} on node {NodeName}",
- candidate.LndChannel.ChanId, node.Name);
+ oc.Lnd.ChanId, node.Name);
}
}
}
/// Runs the control law for one channel and applies the resulting fee update when needed.
- private async Task OptimizeChannel(Node node, Candidate candidate, FeeOptimizerTunables tunables, DateTimeOffset now)
+ private async Task OptimizeChannel(Node node, OwnedChannel candidate, FeeOptimizerTunables tunables)
{
var routingState = candidate.RoutingState;
- var feeState = candidate.FeeState ?? new ChannelFeeState {
+ var feeState = candidate.FeeState ?? new ChannelFeeState
+ {
ChannelId = candidate.DbChannel.Id,
ManagedNodePubKey = node.PubKey,
};
+ var now = DateTimeOffset.UtcNow;
+
var decision = FeeOptimizerService.ComputeNextPolicy(
routingState.EmaLocalRatio,
routingState.TargetLocalRatio,
@@ -213,18 +170,18 @@ private async Task OptimizeChannel(Node node, Candidate candidate, FeeOptimizerT
if (decision.Action != FeeAction.Update)
{
_logger.LogInformation("Channel {ChanId} on {NodeName}: {Action} ({Reason})",
- candidate.LndChannel.ChanId, node.Name, decision.Action, decision.Reason);
+ candidate.Lnd.ChanId, node.Name, decision.Action, decision.Reason);
await _feeStateRepository.UpsertByChannelAndNode(feeState);
return;
}
// The live policy is needed only on the write path for the untouched base fee/timelock.
// NoOp channels never cost an LND round-trip.
- var (managedPolicy, _) = await _lightningService.GetChannelFeePolicy(candidate.LndChannel.ChanId, node);
+ var (managedPolicy, _) = await _lightningService.GetChannelFeePolicy(candidate.Lnd.ChanId, node);
if (managedPolicy == null)
{
_logger.LogWarning("Skipping channel {ChanId} on {NodeName}: current fee policy unavailable",
- candidate.LndChannel.ChanId, node.Name);
+ candidate.Lnd.ChanId, node.Name);
await _feeStateRepository.UpsertByChannelAndNode(feeState);
return;
}
@@ -237,7 +194,7 @@ private async Task OptimizeChannel(Node node, Candidate candidate, FeeOptimizerT
if (node.RoutingEngineDryRun)
{
_logger.LogInformation("Dry-run: would set channel {ChanId} ({NodeName}-{PeerAlias}) to outbound {Outbound}ppm inbound {Inbound}ppm ({Reason})",
- candidate.LndChannel.ChanId, node.Name, candidate.LndChannel.PeerAlias, decision.OutboundPpm, decision.InboundPpm, decision.Reason);
+ candidate.Lnd.ChanId, node.Name, candidate.Lnd.PeerAlias, decision.OutboundPpm, decision.InboundPpm, decision.Reason);
feeState.LastAppliedOutboundBaseFeeMsat = baseFeeMsat;
feeState.LastAppliedOutboundPpm = decision.OutboundPpm;
feeState.LastAppliedInboundBaseMsat = inboundBaseMsat;
@@ -270,7 +227,7 @@ await _lightningService.SetChannelFeePolicy(
feeState.LastFeeUpdateAt = now;
_logger.LogInformation("{NodeName} chan {ChanId}: set outbound {Outbound}ppm inbound {Inbound}ppm ({Reason})",
- node.Name, candidate.LndChannel.ChanId, decision.OutboundPpm, decision.InboundPpm, decision.Reason);
+ node.Name, candidate.Lnd.ChanId, decision.OutboundPpm, decision.InboundPpm, decision.Reason);
await _feeStateRepository.UpsertByChannelAndNode(feeState);
}
@@ -278,7 +235,7 @@ await _lightningService.SetChannelFeePolicy(
{
// Log and skip this channel for this cycle; the next cycle retries.
_logger.LogError(ex, "Failed to set fee policy for channel {ChanId} on {NodeName}",
- candidate.LndChannel.ChanId, node.Name);
+ candidate.Lnd.ChanId, node.Name);
}
}
}
diff --git a/src/Jobs/NodeChannelSubscribeJob.cs b/src/Jobs/NodeChannelSubscribeJob.cs
index 3a860f3e..73bdc6bc 100644
--- a/src/Jobs/NodeChannelSubscribeJob.cs
+++ b/src/Jobs/NodeChannelSubscribeJob.cs
@@ -103,7 +103,8 @@ public async Task NodeUpdateManagement(ChannelEventUpdate channelEventUpdate, No
CreationDatetime = DateTimeOffset.Now,
UpdateDatetime = DateTimeOffset.Now,
IsPrivate = channelOpened.Private,
- IsDynamicFeeEnabled = node.DynamicFeeManagementEnabled
+ IsDynamicFeeEnabled = node.DynamicFeeManagementEnabled,
+ IsAutoRebalanceEnabled = node.AutoRebalanceEnabled
};
var remoteNode = await _nodeRepository.GetOrCreateByPubKey(channelOpened.RemotePubkey, _lightningService);
diff --git a/src/Program.cs b/src/Program.cs
index 0d9671d9..704e5af4 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -149,6 +149,7 @@ public static async Task Main(string[] args)
builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddTransient();
+ builder.Services.AddTransient();
builder.Services.AddScoped();
//DbContext
@@ -273,23 +274,19 @@ public static async Task Main(string[] args)
});
});
- // Both routing (dynamic-fee) jobs share a cadence: ROUTING_ENGINE_JOB_INTERVAL_SECONDS wins
- // when set (the fee-engine e2e uses a few seconds so it converges fast); otherwise
- // ROUTING_ENGINE_JOB_INTERVAL_MINUTES (default 30). Except when the environment is
- // DEV, then overridden to 5 minutes (faster convergence for devs).
var routingJobIntervalSeconds =
int.TryParse(Environment.GetEnvironmentVariable("ROUTING_ENGINE_JOB_INTERVAL_SECONDS"), out var rjs)
? rjs
: (int?)null;
- void ScheduleRoutingJob(SimpleScheduleBuilder sb)
+ void ScheduleRoutingJob(SimpleScheduleBuilder sb, int minutes)
{
if (routingJobIntervalSeconds is int seconds)
sb.WithIntervalInSeconds(seconds).RepeatForever();
else if (Constants.IS_DEV_ENVIRONMENT)
- sb.WithIntervalInMinutes(5).RepeatForever();
+ sb.WithIntervalInMinutes(1).RepeatForever();
else
- sb.WithIntervalInMinutes(Constants.ROUTING_ENGINE_JOB_INTERVAL_MINUTES).RepeatForever();
+ sb.WithIntervalInMinutes(minutes).RepeatForever();
}
//Target Ratio Reevaluation Job
@@ -303,10 +300,10 @@ void ScheduleRoutingJob(SimpleScheduleBuilder sb)
{
opts.ForJob(nameof(TargetRatioReevaluationJob))
.WithIdentity($"{nameof(TargetRatioReevaluationJob)}Trigger")
- .StartNow().WithSimpleSchedule(ScheduleRoutingJob);
+ .StartNow().WithSimpleSchedule(sb => ScheduleRoutingJob(sb, Constants.ROUTING_ENGINE_JOB_INTERVAL_MINUTES));
});
- //Channel Fee Optimizer Job
+ //Channel Fee Optimizer Job (routing-engine fee actuator)
q.AddJob(opts =>
{
opts.DisallowConcurrentExecution();
@@ -317,7 +314,35 @@ void ScheduleRoutingJob(SimpleScheduleBuilder sb)
{
opts.ForJob(nameof(ChannelFeeOptimizerJob))
.WithIdentity($"{nameof(ChannelFeeOptimizerJob)}Trigger")
- .StartNow().WithSimpleSchedule(ScheduleRoutingJob);
+ .WithSimpleSchedule(sb => ScheduleRoutingJob(sb, Constants.ROUTING_ENGINE_JOB_INTERVAL_MINUTES));
+
+ if (Constants.IS_DEV_ENVIRONMENT)
+ opts.StartNow();
+ // Start a few minutes after TargetRatioReevaluationJob (which uses StartNow) so the
+ // fee control law always acts on freshly-written routing state.
+ else
+ opts.StartAt(DateBuilder.FutureDate(Constants.ROUTING_ENGINE_ACTUATOR_OFFSET_MINUTES, IntervalUnit.Minute));
+ });
+
+ //Auto Rebalance Job (routing-engine rebalance actuator, own cadence)
+ q.AddJob(opts =>
+ {
+ opts.DisallowConcurrentExecution();
+ opts.WithIdentity(nameof(AutoRebalanceJob));
+ });
+
+ q.AddTrigger(opts =>
+ {
+ opts.ForJob(nameof(AutoRebalanceJob))
+ .WithIdentity($"{nameof(AutoRebalanceJob)}Trigger")
+ .WithSimpleSchedule(sb => ScheduleRoutingJob(sb, Constants.ROUTING_ENGINE_REBALANCE_JOB_INTERVAL_MINUTES));
+
+ if (Constants.IS_DEV_ENVIRONMENT)
+ opts.StartNow();
+ // Start a few minutes after TargetRatioReevaluationJob (which uses StartNow) so the
+ // fee control law always acts on freshly-written routing state.
+ else
+ opts.StartAt(DateBuilder.FutureDate(Constants.ROUTING_ENGINE_ACTUATOR_OFFSET_MINUTES, IntervalUnit.Minute));
});
//Monitor Withdrawals Job
diff --git a/src/Services/LightningClientService.cs b/src/Services/LightningClientService.cs
index 0a52bbde..c789508f 100644
--- a/src/Services/LightningClientService.cs
+++ b/src/Services/LightningClientService.cs
@@ -38,6 +38,7 @@ public interface ILightningClientService
public Task ListChannels(Node node, Lightning.LightningClient? client = null);
public Task ChannelBalanceAsync(Node node, Lightning.LightningClient? client = null);
public Task GetChanInfo(Node node, ulong chanId, Lightning.LightningClient? client = null);
+ public Task FeeReport(Node node, Lightning.LightningClient? client = null);
public Task AddInvoice(Node node, Invoice invoice, Lightning.LightningClient? client = null);
public Task QueryRoutes(Node node, QueryRoutesRequest request, Lightning.LightningClient? client = null);
public AsyncServerStreamingCall? CloseChannel(Node node, Channel channel, bool forceClose = false, Lightning.LightningClient? client = null);
@@ -222,6 +223,23 @@ public Lightning.LightningClient GetLightningClient(string? endpoint)
return null;
}
}
+
+ public async Task FeeReport(Node node, Lightning.LightningClient? client = null)
+ {
+ try
+ {
+ client ??= GetLightningClient(node.Endpoint);
+ return await client.FeeReportAsync(new FeeReportRequest(), new Metadata
+ {
+ { "macaroon", node.ChannelAdminMacaroon }
+ });
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "Error while getting the fee report for node {NodeId}", node.Id);
+ return null;
+ }
+ }
public async Task AddInvoice(Node node, Invoice invoice, Lightning.LightningClient? client = null)
{
diff --git a/src/Services/LightningService.cs b/src/Services/LightningService.cs
index 1816d61d..0eadb6ab 100644
--- a/src/Services/LightningService.cs
+++ b/src/Services/LightningService.cs
@@ -201,8 +201,21 @@ Task SendPaymentV2Async(Node node, string paymentRequest, long amountSa
/// a specific channel. If the peer has multiple channels with us, the first one
/// returned by ListChannels is used as a representative.
///
- Task GetLocalOutboundFeeRatePpmByPeerAsync
- (Node node, string peerPubkey);
+ Task GetLocalOutboundFeeRatePpmByPeerAsync(Node node, string peerPubkey);
+
+ ///
+ /// Returns the local-outbound fee rate (ppm) of a specific channel, read from the gossip
+ /// graph via GetChanInfo. Backs , its only
+ /// caller.
+ ///
+ Task GetLocalOutboundFeeRatePpmAsync(Node node, ulong chanId);
+
+ ///
+ /// Local-outbound fee rate (ppm) for every channel on the node, keyed by LND chan_id.
+ /// Returns null when FeeReport is unavailable, which callers should treat as "skip this node
+ /// this cycle" rather than as "every channel earns nothing".
+ ///
+ Task?> GetLocalOutboundFeeRatesPpmAsync(Node node);
///
/// Sets the channel fee policy for a given channel identified by its chanPoint
@@ -826,7 +839,8 @@ public async Task CreateChannel(Node source, int destId, ChannelPoint c
DestinationNodeId = destinationNodeId,
CreatedByNodeGuard = true,
IsPrivate = currentChannel.Private,
- IsDynamicFeeEnabled = source.DynamicFeeManagementEnabled
+ IsDynamicFeeEnabled = source.DynamicFeeManagementEnabled,
+ IsAutoRebalanceEnabled = source.AutoRebalanceEnabled
};
return channel;
@@ -1558,7 +1572,7 @@ public async Task> GetChannelsState()
if (channel == null) continue;
// If the source node is not the channel initiator, but the remote node is also managed by NodeGuard
// We skip and wait for the other node to report the channel
- if (nodes.Any((n) => !channel.Initiator && n.PubKey == channel.RemotePubkey)) continue;
+ if (!ChannelOwnershipHelper.IsOwnedByManagedNode(channel, nodes)) continue;
var htlcsLocal = channel.PendingHtlcs.Where(x => x.Incoming == true).Sum(x => x.Amount);
var htlcsRemote = channel.PendingHtlcs.Where(x => x.Incoming == false).Sum(x => x.Amount);
@@ -1776,6 +1790,26 @@ public async Task SendPaymentV2Async(Node node, string paymentRequest,
return policy?.FeeRateMilliMsat;
}
+ public async Task?> GetLocalOutboundFeeRatesPpmAsync(Node node)
+ {
+ var report = await _lightningClientService.FeeReport(node);
+ if (report == null) return null;
+
+ // FeePerMil is "per million" of the amount forwarded, i.e. the same ppm figure as
+ // RoutingPolicy.FeeRateMilliMsat — but reported for our own side, so unlike GetChanInfo
+ // there is no Node1Pub/Node2Pub comparison to get right.
+ //
+ // Indexer rather than ToDictionary: a duplicate chan_id from LND would throw and take
+ // out the whole node's cycle, and last-wins is harmless for a fee rate.
+ var byChanId = new Dictionary(report.ChannelFees.Count);
+ foreach (var fee in report.ChannelFees)
+ {
+ byChanId[fee.ChanId] = fee.FeePerMil;
+ }
+
+ return byChanId;
+ }
+
public async Task GetLocalOutboundFeeRatePpmByPeerAsync(Node node, string peerPubkey)
{
if (string.IsNullOrEmpty(peerPubkey)) return null;
diff --git a/src/Services/RebalanceInitiatorService.cs b/src/Services/RebalanceInitiatorService.cs
new file mode 100644
index 00000000..60146f75
--- /dev/null
+++ b/src/Services/RebalanceInitiatorService.cs
@@ -0,0 +1,354 @@
+/*
+ * NodeGuard
+ * Copyright (C) 2023 Elenpay
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see http://www.gnu.org/licenses/.
+ *
+ */
+
+using NodeGuard.Helpers;
+
+namespace NodeGuard.Services;
+
+///
+/// One of our channels, with the live balances and the smoothed routing-engine signal the
+/// rebalancer needs.
+///
+public record ChannelSignal(
+ int ChannelId,
+ ulong ChanIdLnd,
+ string PeerPubKey,
+ long LocalSats,
+ long RemoteSats,
+ double EmaLocalRatio,
+ double TargetLocalRatio,
+ bool Active,
+ bool SourceOptIn);
+
+///
+/// A channel we can drain (send OUT of via outgoing_chan_id) — precise, per channel.
+///
+public record SourceChannel(
+ int ChannelId,
+ ulong ChanIdLnd,
+ string PeerPubKey,
+ long ExcessSats);
+
+/// One of our channels with a destination peer — carries the earn-rate weight (balance base).
+public record PeerMemberChannel(ulong ChanIdLnd, long BalanceBaseSats);
+
+///
+/// A peer we want to refill, aggregated across all our channels with it. LND's
+/// last_hop_pubkey only constrains the peer, not the exact incoming channel, so the
+/// destination side is modelled at peer granularity.
+///
+public record DestinationPeer(
+ string PeerPubKey,
+ long DeficitSats,
+ IReadOnlyList Members);
+
+///
+/// Output of .
+///
+public record RebalanceClassification(
+ IReadOnlyList Sources,
+ IReadOnlyList Destinations,
+ IReadOnlyList FallbackSources,
+ IReadOnlyList FallbackDestinations);
+
+///
+/// A concrete rebalance the job should dispatch: drain , refill via
+/// last-hop , sized and profit-gated.
+///
+public record RebalancePlan(
+ int SourceChannelId,
+ string DestinationPeerPubKey,
+ long AmountSats,
+ double MaxFeePct,
+ bool IsFallbackPairing,
+ string Reason);
+
+///
+/// Control tunables for .
+///
+public record RebalanceInitiatorTunables(
+ double RebalanceTrigger,
+ long MinAmountSats,
+ long MaxAmountSats,
+ double CostToEarnRatio,
+ int MaxInitiations)
+{
+ ///
+ /// Production wiring: global ROUTING_ENGINE_REBALANCE_* defaults, with the cost-to-earn ratio
+ /// overridden per node. Lives on the record — as
+ /// does — so the pure module owns its own configuration and the job never names a constant.
+ ///
+ public static RebalanceInitiatorTunables FromConstants(Data.Models.Node node) => new(
+ RebalanceTrigger: Constants.ROUTING_ENGINE_REBALANCE_DEADBAND,
+ MinAmountSats: Constants.REBALANCE_MIN_AMOUNT_SATS,
+ MaxAmountSats: Constants.ROUTING_ENGINE_REBALANCE_MAX_AMOUNT_SATS,
+ CostToEarnRatio: node.MaxRebalanceCostToEarnRatio
+ ?? Constants.ROUTING_ENGINE_REBALANCE_DEFAULT_COST_TO_EARN_RATIO,
+ MaxInitiations: Constants.ROUTING_ENGINE_REBALANCE_MAX_INITIATIONS_PER_RUN);
+}
+
+///
+/// Pure decision logic for the automated rebalancer — no I/O, no clock, no DB, so it is a static
+/// function library (mirrors ).
+///
+/// A circular rebalance drains liquidity OUT of a too-local channel (precise: outgoing_chan_id)
+/// and refills a too-remote one (fuzzy: last_hop_pubkey pins only the peer, not the channel).
+///
+public static class RebalanceInitiatorService
+{
+ ///
+ /// Splits into drainable sources and refillable destination peers,
+ /// using the smoothed EMA ratio for the direction decision and live balances for sizing. Whatever
+ /// trips neither trigger lands in the fallback pools instead of being discarded.
+ ///
+ public static RebalanceClassification Classify(
+ IReadOnlyList channels,
+ RebalanceInitiatorTunables t)
+ {
+ // Sources calculation
+ var sources = new List();
+ var fallbackSources = new List();
+ foreach (var c in channels)
+ {
+ if (!c.Active || !c.SourceOptIn) continue;
+
+ var baseSats = c.LocalSats + c.RemoteSats;
+ if (baseSats <= 0) continue;
+
+ var excess = c.LocalSats - SatsAt(c.TargetLocalRatio, baseSats);
+
+ // Too-local by the smoothed signal
+ if (c.EmaLocalRatio - c.TargetLocalRatio > t.RebalanceTrigger && excess > 0)
+ {
+ sources.Add(new SourceChannel(c.ChannelId, c.ChanIdLnd, c.PeerPubKey, excess));
+ continue;
+ }
+
+ // Searching for fallback sources. Avoiding creating a next cycle rebalance by
+ // lending liquidity down to the low edge of the deadband only
+ var lendable = c.LocalSats - SatsAt(Math.Max(0, c.TargetLocalRatio - t.RebalanceTrigger), baseSats);
+ if (lendable > 0 && lendable > t.MinAmountSats)
+ {
+ fallbackSources.Add(new SourceChannel(c.ChannelId, c.ChanIdLnd, c.PeerPubKey, lendable));
+ }
+ }
+
+ // Destinations calculation
+ var destinations = new List();
+ var fallbackDestinations = new List();
+ foreach (var group in channels.Where(c => c.Active).GroupBy(c => c.PeerPubKey))
+ {
+ long peerLocal = 0;
+ long peerBase = 0;
+ double weightedEma = 0;
+ double weightedTarget = 0;
+ var members = new List();
+
+ foreach (var c in group)
+ {
+ var baseSats = c.LocalSats + c.RemoteSats;
+ if (baseSats <= 0) continue;
+
+ peerLocal += c.LocalSats;
+ peerBase += baseSats;
+ weightedEma += c.EmaLocalRatio * baseSats;
+ weightedTarget += c.TargetLocalRatio * baseSats;
+ members.Add(new PeerMemberChannel(c.ChanIdLnd, baseSats));
+ }
+
+ if (peerBase <= 0) continue;
+
+ var aggEma = weightedEma / peerBase;
+ var aggTarget = weightedTarget / peerBase;
+
+ // Sats of local needed to bring the peer aggregate back to target
+ var targetLocalSats = (long)Math.Round(weightedTarget, MidpointRounding.AwayFromZero);
+ var deficit = targetLocalSats - peerLocal;
+
+ // Too-remote in aggregate (smoothed): the peer holds too little of our local
+ if (aggEma - aggTarget < -t.RebalanceTrigger && deficit > 0)
+ {
+ destinations.Add(new DestinationPeer(group.Key, deficit, members));
+ continue;
+ }
+
+ // Searching for fallback destinations. Avoiding creating a next cycle rebalance by
+ // lending liquidity up to the high edge of the deadband only
+ var absorbable = SatsAt(Math.Min(1.0, aggTarget + t.RebalanceTrigger), peerBase) - peerLocal;
+ if (absorbable > 0 && absorbable > t.MinAmountSats)
+ {
+ fallbackDestinations.Add(new DestinationPeer(group.Key, absorbable, members));
+ }
+ }
+
+ return new RebalanceClassification(sources, destinations, fallbackSources, fallbackDestinations);
+ }
+
+ ///
+ /// Turns the classification into sized, profit-gated s.
+ ///
+ /// Pass 1 refills every detected destination from the first source still available,
+ /// otherwise the fallback pool. Pass 2 then drains any detected source pass 1 didn't
+ /// consume into the first fallback destination that fits.
+ ///
+ ///
+ public static IReadOnlyList BuildPlans(
+ RebalanceClassification classification,
+ IReadOnlyDictionary earnPpmByChanIdLnd,
+ RebalanceInitiatorTunables t)
+ {
+ var plans = new List();
+ var usedSourceIds = new HashSet();
+
+ var sources = classification.Sources
+ .OrderByDescending(s => s.ExcessSats)
+ .ToList();
+ var fallbackSources = classification.FallbackSources
+ .OrderByDescending(s => s.ExcessSats)
+ .ToList();
+ var destinations = classification.Destinations
+ .OrderByDescending(d => d.DeficitSats)
+ .ToList();
+ var fallbackDestinations = classification.FallbackDestinations
+ .OrderByDescending(d => d.DeficitSats)
+ .ToList();
+
+ // Pass 1: refill every detected destination
+ foreach (var dest in destinations)
+ {
+ if (plans.Count >= t.MaxInitiations) return plans;
+
+ // No known earn rate ⇒ nothing to profit-gate against ⇒ leave the peer alone.
+ var destEarnPpm = WeightedAverageEarnPpm(dest.Members, earnPpmByChanIdLnd);
+ if (destEarnPpm == null) continue;
+
+ // A channel that tripped the trigger first; failing that, borrow from the fallback pool
+ // so the detected shortfall still gets funded.
+ var source = FirstFreeSource(sources, dest, usedSourceIds);
+ var isFallback = source == null;
+ source ??= FirstFreeSource(fallbackSources, dest, usedSourceIds);
+ if (source == null) continue;
+
+ var plan = TryBuildPlan(source, dest, destEarnPpm.Value, earnPpmByChanIdLnd, t, isFallback);
+ if (plan == null) continue;
+
+ usedSourceIds.Add(source.ChannelId);
+ plans.Add(plan);
+ }
+
+ // Pass 2: drain every detected source pass 1 left unused
+ var gatedFallbackDestinations = fallbackDestinations
+ .Select(d => (Dest: d, EarnPpm: WeightedAverageEarnPpm(d.Members, earnPpmByChanIdLnd)))
+ .Where(x => x.EarnPpm.HasValue)
+ .ToList();
+
+ var refilledFallbackPeers = new HashSet();
+
+ foreach (var source in sources)
+ {
+ if (plans.Count >= t.MaxInitiations) return plans;
+ if (usedSourceIds.Contains(source.ChannelId)) continue;
+
+ foreach (var (dest, destEarnPpm) in gatedFallbackDestinations)
+ {
+ if (dest.PeerPubKey == source.PeerPubKey) continue;
+ if (refilledFallbackPeers.Contains(dest.PeerPubKey)) continue;
+
+ var plan = TryBuildPlan(source, dest, destEarnPpm!.Value, earnPpmByChanIdLnd, t,
+ isFallbackPairing: true);
+ if (plan == null) continue;
+
+ // Both marked only now, so a pairing the profit gate or the min-amount floor
+ // rejected leaves the source and the peer available to everything downstream.
+ usedSourceIds.Add(source.ChannelId);
+ refilledFallbackPeers.Add(dest.PeerPubKey);
+ plans.Add(plan);
+ break;
+ }
+ }
+
+ return plans;
+ }
+
+ ///
+ /// Sats corresponding to of .
+ ///
+ private static long SatsAt(double ratio, long baseSats)
+ => (long)Math.Round(ratio * baseSats, MidpointRounding.AwayFromZero);
+
+ private static SourceChannel? FirstFreeSource(
+ IReadOnlyList pool,
+ DestinationPeer dest,
+ HashSet usedSourceIds)
+ => pool.FirstOrDefault(s => !usedSourceIds.Contains(s.ChannelId) && s.PeerPubKey != dest.PeerPubKey);
+
+ ///
+ /// Sizes and profit-gates one source→destination pairing. Returns null when the pairing can't
+ /// pay for itself or is too small to be worth a hop.
+ ///
+ private static RebalancePlan? TryBuildPlan(
+ SourceChannel source,
+ DestinationPeer dest,
+ long destEarnPpm,
+ IReadOnlyDictionary earnPpmByChanIdLnd,
+ RebalanceInitiatorTunables t,
+ bool isFallbackPairing)
+ {
+ // Profit gate: cost is capped at ratio × the earn rate of the destination we actually chose
+ var maxCostPpm = (long)Math.Round(t.CostToEarnRatio * destEarnPpm, MidpointRounding.AwayFromZero);
+ if (maxCostPpm < 1) return null;
+ var maxFeePct = maxCostPpm / 10_000.0;
+
+ // What the source can give, bounded by what the destination can take
+ var raw = Math.Min(source.ExcessSats, dest.DeficitSats);
+ var amount = Math.Min(raw, t.MaxAmountSats);
+
+ var sourceEarn = earnPpmByChanIdLnd.TryGetValue(source.ChanIdLnd, out var se) ? se : (long?)null;
+ var kind = isFallbackPairing ? "fallback " : string.Empty;
+ return new RebalancePlan(
+ source.ChannelId,
+ dest.PeerPubKey,
+ amount,
+ maxFeePct,
+ isFallbackPairing,
+ $"{kind}drain chan {source.ChanIdLnd} (earn {sourceEarn?.ToString() ?? "?"}ppm) → refill peer {dest.PeerPubKey} " +
+ $"(earn {destEarnPpm}ppm, capacity {dest.DeficitSats} sats); amount {amount} sats, maxCost {maxCostPpm}ppm ({maxFeePct:0.####}%)");
+ }
+
+ ///
+ /// Balance-weighted average of the peer's channels' outbound ppm (weight = balance base),
+ /// over the members whose earn rate is known. Null when none are known.
+ ///
+ private static long? WeightedAverageEarnPpm(
+ IReadOnlyList members,
+ IReadOnlyDictionary earnPpmByChanIdLnd)
+ {
+ double weightedSum = 0;
+ long totalWeight = 0;
+ foreach (var m in members)
+ {
+ if (!earnPpmByChanIdLnd.TryGetValue(m.ChanIdLnd, out var ppm)) continue;
+ weightedSum += (double)ppm * m.BalanceBaseSats;
+ totalWeight += m.BalanceBaseSats;
+ }
+
+ if (totalWeight <= 0) return null;
+ return (long)Math.Round(weightedSum / totalWeight, MidpointRounding.AwayFromZero);
+ }
+}
diff --git a/src/Services/RebalanceService.cs b/src/Services/RebalanceService.cs
index a1a8eb7c..fc300ae9 100644
--- a/src/Services/RebalanceService.cs
+++ b/src/Services/RebalanceService.cs
@@ -114,7 +114,7 @@ public async Task RebalanceAsync(RebalanceRequest request, Cancellati
"Target pubkey is required.",
nameof(request.TargetPubkey));
- var node = await _nodeRepository.GetById(request.NodeId);
+ var node = await _nodeRepository.GetById(request.NodeId, includeRelatedData: false);
if (node == null)
throw new ArgumentException($"Node {request.NodeId} not found", nameof(request.NodeId));
@@ -129,7 +129,7 @@ public async Task RebalanceAsync(RebalanceRequest request, Cancellati
? sourceChannel.DestinationNodeId
: sourceChannel.SourceNodeId;
- var counterpartyPeer = await _nodeRepository.GetById(counterpartyPeerId);
+ var counterpartyPeer = await _nodeRepository.GetById(counterpartyPeerId, includeRelatedData: false);
if (counterpartyPeer == null)
throw new InvalidOperationException(
$"Counterparty peer node {counterpartyPeerId} not found for source channel {request.SourceChannelId}");
@@ -202,7 +202,7 @@ public async Task ExecuteAsync(int rebalanceId, CancellationToken ct
if (rebalance == null)
throw new InvalidOperationException($"Rebalance {rebalanceId} not found");
- var node = rebalance.Node ?? await _nodeRepository.GetById(rebalance.NodeId);
+ var node = rebalance.Node ?? await _nodeRepository.GetById(rebalance.NodeId, includeRelatedData: false);
if (node == null)
throw new InvalidOperationException($"Node {rebalance.NodeId} not found for rebalance {rebalanceId}");
diff --git a/src/Services/RoutingEngineSnapshotService.cs b/src/Services/RoutingEngineSnapshotService.cs
new file mode 100644
index 00000000..1d03f5cd
--- /dev/null
+++ b/src/Services/RoutingEngineSnapshotService.cs
@@ -0,0 +1,111 @@
+/*
+ * NodeGuard
+ * Copyright (C) 2023 Elenpay
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see http://www.gnu.org/licenses/.
+ *
+ */
+
+using NodeGuard.Data.Models;
+using NodeGuard.Data.Repositories.Interfaces;
+using Channel = NodeGuard.Data.Models.Channel;
+
+namespace NodeGuard.Services;
+
+///
+/// One of a node's channels with everything the routing-engine jobs act on, assembled from a
+/// single ListChannels plus the per-(channel, managed node) routing and fee state.
+///
+public sealed class OwnedChannel
+{
+ public required Lnrpc.Channel Lnd { get; init; }
+ public required Channel DbChannel { get; init; }
+ public required ChannelRoutingState RoutingState { get; init; }
+ public required ChannelFeeState? FeeState { get; init; }
+}
+
+///
+/// Builds the per-node channel view both routing-engine actuator jobs
+/// ( and ) start from.
+/// The two jobs run on independent cadences, so each takes its own snapshot.
+///
+public interface IRoutingEngineSnapshotService
+{
+ ///
+ /// The node's actuatable channels: open, known to NodeGuard, and carrying a routing-state
+ /// signal for this managed node. Returns null when LND is unreachable — callers should treat
+ /// that as "skip this node this cycle" rather than as an empty result. Per-job eligibility
+ /// (min size / IsDynamicFeeEnabled for fees, opt-in / trigger for rebalancing) is the caller's.
+ ///
+ Task?> GetOwnedChannelsAsync(
+ Node node,
+ IReadOnlyDictionary openChannelsByChanId,
+ bool withFeeState);
+}
+
+public class RoutingEngineSnapshotService : IRoutingEngineSnapshotService
+{
+ private readonly IChannelRoutingStateRepository _routingStateRepository;
+ private readonly IChannelFeeStateRepository _feeStateRepository;
+ private readonly ILightningClientService _lightningClientService;
+
+ public RoutingEngineSnapshotService(
+ IChannelRoutingStateRepository routingStateRepository,
+ IChannelFeeStateRepository feeStateRepository,
+ ILightningClientService lightningClientService)
+ {
+ _routingStateRepository = routingStateRepository;
+ _feeStateRepository = feeStateRepository;
+ _lightningClientService = lightningClientService;
+ }
+
+ public async Task?> GetOwnedChannelsAsync(
+ Node node,
+ IReadOnlyDictionary openChannelsByChanId,
+ bool withFeeState)
+ {
+ // One LND round-trip per node per job run.
+ var listResp = await _lightningClientService.ListChannels(node);
+ if (listResp == null) return null;
+
+ var routingStates = (await _routingStateRepository.GetByManagedNodePubKey(node.PubKey))
+ .ToDictionary(s => s.ChannelId);
+
+ // Only the fee job reads these; the rebalancer never does.
+ var feeStates = withFeeState
+ ? (await _feeStateRepository.GetByManagedNodePubKey(node.PubKey)).ToDictionary(s => s.ChannelId)
+ : new Dictionary();
+
+ var owned = new List();
+ foreach (var lndChannel in listResp.Channels)
+ {
+ // No ownership dedup: routing/fee state is per (channel, managed node), so when both
+ // ends are managed each side actuates its own view — its own local balance for the
+ // rebalancer, its own outbound policy for the fee job.
+ if (!openChannelsByChanId.TryGetValue(lndChannel.ChanId, out var dbChannel)) continue;
+ if (!routingStates.TryGetValue(dbChannel.Id, out var routingState)) continue; // no signal yet
+
+ feeStates.TryGetValue(dbChannel.Id, out var feeState);
+ owned.Add(new OwnedChannel
+ {
+ Lnd = lndChannel,
+ DbChannel = dbChannel,
+ RoutingState = routingState,
+ FeeState = feeState,
+ });
+ }
+
+ return owned;
+ }
+}
diff --git a/test/NodeGuard.Tests/Jobs/AutoRebalanceJobTests.cs b/test/NodeGuard.Tests/Jobs/AutoRebalanceJobTests.cs
new file mode 100644
index 00000000..9c82e8c1
--- /dev/null
+++ b/test/NodeGuard.Tests/Jobs/AutoRebalanceJobTests.cs
@@ -0,0 +1,444 @@
+/*
+ * NodeGuard
+ * Copyright (C) 2023 Elenpay
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see http://www.gnu.org/licenses/.
+ *
+ */
+
+using Microsoft.Extensions.Logging;
+using NodeGuard.Data.Models;
+using NodeGuard.Data.Repositories.Interfaces;
+using NodeGuard.Helpers;
+using NodeGuard.Services;
+using NodeGuard.Tests.Helpers;
+using NodeGuard.Tests.Jobs;
+using Quartz;
+using Channel = NodeGuard.Data.Models.Channel;
+
+namespace NodeGuard.Jobs;
+
+
+[Collection("RoutingEngine")]
+public class AutoRebalanceJobTests
+{
+ private const string NodePubKey = "managedPubKey";
+
+ private readonly Mock> _logger = new();
+
+ private readonly Mock _nodeRepository = new();
+ private readonly Mock _channelRepository = new();
+ private readonly Mock _routingStateRepository = new();
+ private readonly Mock _feeStateRepository = new();
+ private readonly Mock _rebalanceRepository = new();
+ private readonly Mock _rebalanceService = new();
+ private readonly Mock _lightningService = new();
+ private readonly Mock _lightningClientService = new();
+ private readonly Mock _auditService = new();
+
+ // The real snapshot service over the mocked repos/LND, so these tests still cover the
+ // open-channel + routing-state filtering that feeds the job.
+ private IRoutingEngineSnapshotService BuildSnapshotService() =>
+ new RoutingEngineSnapshotService(
+ _routingStateRepository.Object,
+ _feeStateRepository.Object,
+ _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()
+ {
+ Id = id, ChanId = chanId, Status = Channel.ChannelStatus.Open,
+ IsAutoRebalanceEnabled = optIn,
+ FundingTx = $"tx{id}", FundingTxOutputIndex = 0,
+ };
+
+ /// The matching LND channel, with the balances that drive sizing.
+ private static Lnrpc.Channel Lnd(ulong chanId, long local, long remote, string peer) => new()
+ {
+ ChanId = chanId, Capacity = 20_000_000, LocalBalance = local, RemoteBalance = remote,
+ Active = true, Initiator = true, RemotePubkey = peer,
+ };
+
+ ///
+ /// A scope factory whose scope hands back , mirroring how the job
+ /// resolves a fresh IRebalanceService per dispatch so the payment doesn't run on the job's own
+ /// (soon-disposed) scope.
+ ///
+ private AutoRebalanceJob BuildJob() =>
+ new(
+ _logger.Object,
+ _nodeRepository.Object,
+ _channelRepository.Object,
+ _rebalanceRepository.Object,
+ _rebalanceService.Object,
+ BuildSnapshotService(),
+ _lightningService.Object,
+ _auditService.Object);
+
+
+
+ ///
+ /// A drainable source (too-local 0.75 vs 0.50, cheap 50 ppm) and a depleted destination on a
+ /// different peer (0.10 vs 0.50, dear 2500 ppm), on a node with budget to spend.
+ /// drives the per-channel rebalance opt-in;
+ /// drives the unrelated swap-liquidity flag.
+ ///
+ private void ArrangeRebalancePair(bool sourceOptedIn, bool sourceLiquidityFlag = false)
+ {
+ var node = new Node
+ {
+ Id = 20,
+ PubKey = NodePubKey,
+ Name = "alice",
+ AutoRebalanceEnabled = true,
+ RebalanceBudgetSats = 1_000_000,
+ MaxRebalancesInFlight = 5,
+ MaxRebalanceCostToEarnRatio = 0.5,
+ };
+ _nodeRepository.Setup(x => x.GetAllManagedByNodeGuard(false)).ReturnsAsync(new List { node });
+
+ var sourceDb = new Channel
+ {
+ Id = 101, ChanId = 1001, Status = Channel.ChannelStatus.Open,
+ IsDynamicFeeEnabled = true,
+ IsAutoRebalanceEnabled = sourceOptedIn,
+ IsAutomatedLiquidityEnabled = sourceLiquidityFlag,
+ FundingTx = "txS", FundingTxOutputIndex = 0,
+ };
+ var destDb = new Channel
+ {
+ Id = 102, ChanId = 1002, Status = Channel.ChannelStatus.Open,
+ IsDynamicFeeEnabled = true, IsAutoRebalanceEnabled = false,
+ FundingTx = "txD", FundingTxOutputIndex = 0,
+ };
+ _channelRepository.Setup(x => x.GetOpenChannels()).ReturnsAsync(new List { sourceDb, destDb });
+
+ _routingStateRepository.Setup(x => x.GetByManagedNodePubKey(NodePubKey)).ReturnsAsync(new List
+ {
+ new() { ChannelId = 101, ManagedNodePubKey = NodePubKey, ChanIdLnd = 1001, EmaLocalRatio = 0.75, TargetLocalRatio = 0.50, PeerFlowCategory = PeerFlowCategory.Source },
+ new() { ChannelId = 102, ManagedNodePubKey = NodePubKey, ChanIdLnd = 1002, EmaLocalRatio = 0.10, TargetLocalRatio = 0.50, PeerFlowCategory = PeerFlowCategory.Sink },
+ });
+ _feeStateRepository.Setup(x => x.GetByManagedNodePubKey(NodePubKey)).ReturnsAsync(new List());
+
+ _rebalanceRepository.Setup(x => x.GetPendingInFlightSourceChannelIds()).ReturnsAsync(new HashSet());
+ _rebalanceRepository.Setup(x => x.GetConsumedFeesSince(node.Id, It.IsAny())).ReturnsAsync(0L);
+ _rebalanceRepository.Setup(x => x.GetInFlightByNode(node.Id)).ReturnsAsync(0);
+
+ var listResp = new Lnrpc.ListChannelsResponse
+ {
+ Channels =
+ {
+ new Lnrpc.Channel { ChanId = 1001, Capacity = 20_000_000, LocalBalance = 15_000_000, RemoteBalance = 5_000_000, Active = true, Initiator = true, RemotePubkey = "peerS" },
+ new Lnrpc.Channel { ChanId = 1002, Capacity = 20_000_000, LocalBalance = 2_000_000, RemoteBalance = 18_000_000, Active = true, Initiator = true, RemotePubkey = "peerD" },
+ },
+ };
+ _lightningClientService
+ .Setup(x => x.ListChannels(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(listResp);
+
+ _lightningService.Setup(x => x.GetLocalOutboundFeeRatesPpmAsync(It.IsAny()))
+ .ReturnsAsync(new Dictionary { [1001] = 50, [1002] = 2500 });
+
+ _rebalanceService.Setup(x => x.RebalanceAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new Rebalance());
+ }
+
+
+ [Fact]
+ public async Task Execute_DispatchesThePlannedRebalance()
+ {
+ ArrangeRebalancePair(sourceOptedIn: true);
+
+ await RoutingEngineSwitch.WithEngine(enabled: true, async () =>
+ {
+ await BuildJob().Execute(Mock.Of());
+ });
+
+ // Source 101 -> dest peer "peerD", sized to the excess, gate 0.5 x 2500ppm.
+ _rebalanceService.Verify(x => x.RebalanceAsync(
+ It.Is(r => r.SourceChannelId == 101 && r.TargetPubkey == "peerD"
+ && r.AmountSats == 5_000_000 && !r.IsManual
+ && r.MaxFeePct.HasValue && Math.Abs(r.MaxFeePct.Value - 0.125) < 1e-9),
+ It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task Execute_AwaitsEachPayment_BeforeDispatchingTheNext()
+ {
+ ArrangeTwoRebalancePairs(maxRebalancesInFlight: 5);
+
+ // Two payments we control. Everything else the job awaits is mocked with completed tasks, so
+ // Execute runs straight through and hands the task back only once it is parked on the first
+ // payment — which is what makes the assertions below deterministic rather than timing-based.
+ var first = new TaskCompletionSource();
+ var second = new TaskCompletionSource();
+ var started = 0;
+ _rebalanceService.Setup(x => x.RebalanceAsync(It.IsAny(), It.IsAny()))
+ .Returns(() => Interlocked.Increment(ref started) == 1 ? first.Task : second.Task);
+
+ await RoutingEngineSwitch.WithEngine(enabled: true, async () =>
+ {
+ var run = BuildJob().Execute(Mock.Of());
+
+ // Detached dispatch would have finished the run; concurrent dispatch would have started
+ // both payments. Being parked after exactly one is what "awaits each" means.
+ Assert.False(run.IsCompleted);
+ Assert.Equal(1, started);
+
+ first.SetResult(new Rebalance());
+ second.SetResult(new Rebalance());
+
+ await run;
+ Assert.Equal(2, started);
+ });
+ }
+
+ [Fact]
+ public async Task Execute_KillSwitchOff_DoesNothing()
+ {
+ ArrangeRebalancePair(sourceOptedIn: true);
+
+ await RoutingEngineSwitch.WithEngine(enabled: false, async () =>
+ {
+ await BuildJob().Execute(Mock.Of());
+ });
+
+ _nodeRepository.Verify(x => x.GetAllManagedByNodeGuard(It.IsAny()), Times.Never);
+ _rebalanceService.Verify(x => x.RebalanceAsync(
+ It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Execute_SwapLiquidityFlagAloneDoesNotOptAChannelIn()
+ {
+ // The rebalancer used to borrow IsAutomatedLiquidityEnabled, which means "opted into
+ // swap-based liquidity rules". They are separate opt-ins now: a channel carrying only the
+ // swap flag must not be drained.
+ ArrangeRebalancePair(sourceOptedIn: false, sourceLiquidityFlag: true);
+
+ await RoutingEngineSwitch.WithEngine(enabled: true, async () =>
+ {
+ await BuildJob().Execute(Mock.Of());
+ });
+
+ _rebalanceService.Verify(x => x.RebalanceAsync(
+ It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ ///
+ /// 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
+ /// first-fit in classification order, so chan 1001 pairs with peerD1 and chan 1003 with peerD2.
+ ///
+ private void ArrangeTwoRebalancePairs(int maxRebalancesInFlight = 1)
+ {
+ var node = new Node
+ {
+ Id = 20,
+ PubKey = NodePubKey,
+ Name = "alice",
+ // Fee pass off: this test is only about rebalance dispatch accounting.
+ DynamicFeeManagementEnabled = false,
+ AutoRebalanceEnabled = true,
+ RebalanceBudgetSats = 1_000_000,
+ MaxRebalanceCostToEarnRatio = 0.5,
+ // Pinned, not inherited, so the cap tests don't depend on
+ // ROUTING_ENGINE_REBALANCE_DEFAULT_MAX_IN_FLIGHT.
+ MaxRebalancesInFlight = maxRebalancesInFlight,
+ };
+ _nodeRepository.Setup(x => x.GetAllManagedByNodeGuard(false)).ReturnsAsync(new List { node });
+
+
+ _channelRepository.Setup(x => x.GetOpenChannels()).ReturnsAsync(new List
+ {
+ Db(101, 1001, optIn: true), // source 1
+ Db(102, 1002, optIn: false), // destination 1
+ Db(103, 1003, optIn: true), // source 2
+ Db(104, 1004, optIn: false), // destination 2
+ });
+
+ _routingStateRepository.Setup(x => x.GetByManagedNodePubKey(NodePubKey)).ReturnsAsync(new List
+ {
+ new() { ChannelId = 101, ManagedNodePubKey = NodePubKey, ChanIdLnd = 1001, EmaLocalRatio = 0.75, TargetLocalRatio = 0.50, PeerFlowCategory = PeerFlowCategory.Source },
+ new() { ChannelId = 102, ManagedNodePubKey = NodePubKey, ChanIdLnd = 1002, EmaLocalRatio = 0.10, TargetLocalRatio = 0.50, PeerFlowCategory = PeerFlowCategory.Sink },
+ new() { ChannelId = 103, ManagedNodePubKey = NodePubKey, ChanIdLnd = 1003, EmaLocalRatio = 0.75, TargetLocalRatio = 0.50, PeerFlowCategory = PeerFlowCategory.Source },
+ new() { ChannelId = 104, ManagedNodePubKey = NodePubKey, ChanIdLnd = 1004, EmaLocalRatio = 0.10, TargetLocalRatio = 0.50, PeerFlowCategory = PeerFlowCategory.Sink },
+ });
+ _feeStateRepository.Setup(x => x.GetByManagedNodePubKey(NodePubKey)).ReturnsAsync(new List());
+
+ _rebalanceRepository.Setup(x => x.GetPendingInFlightSourceChannelIds()).ReturnsAsync(new HashSet());
+ _rebalanceRepository.Setup(x => x.GetConsumedFeesSince(node.Id, It.IsAny())).ReturnsAsync(0L);
+ _rebalanceRepository.Setup(x => x.GetInFlightByNode(node.Id)).ReturnsAsync(0);
+
+
+ _lightningClientService
+ .Setup(x => x.ListChannels(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new Lnrpc.ListChannelsResponse
+ {
+ Channels =
+ {
+ Lnd(1001, 15_000_000, 5_000_000, "peerS1"),
+ Lnd(1002, 2_000_000, 18_000_000, "peerD1"),
+ Lnd(1003, 15_000_000, 5_000_000, "peerS2"),
+ Lnd(1004, 2_000_000, 18_000_000, "peerD2"),
+ },
+ });
+
+ _lightningService.Setup(x => x.GetLocalOutboundFeeRatesPpmAsync(It.IsAny()))
+ .ReturnsAsync(new Dictionary
+ {
+ [1001] = 50, [1002] = 2500, [1003] = 60, [1004] = 2400,
+ });
+
+ _rebalanceService.Setup(x => x.RebalanceAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new Rebalance());
+ }
+
+ [Fact]
+ public async Task Execute_LogsPlansDroppedByTheInFlightCap()
+ {
+ ArrangeTwoRebalancePairs();
+
+ await RoutingEngineSwitch.WithEngine(enabled: true, async () =>
+ {
+ await BuildJob().Execute(Mock.Of());
+ });
+
+ // The cap is 1, so only the first plan is dispatched...
+ _rebalanceService.Verify(x => x.RebalanceAsync(
+ It.Is(r => r.SourceChannelId == 101 && r.TargetPubkey == "peerD1"),
+ It.IsAny()), Times.Once);
+ _rebalanceService.Verify(x => x.RebalanceAsync(
+ It.IsAny(), It.IsAny()), Times.Once);
+
+ // ...and the plan the cap ate is reported rather than silently discarded.
+ _logger.Verify(x => x.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString()!.Contains("dropped 1 of 2 planned rebalance(s)")
+ && v.ToString()!.Contains("in-flight cap reached (1/1")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+
+ // The dropped plan's own details, so an operator can see what was left on the table.
+ _logger.Verify(x => x.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString()!.Contains("dropped plan")
+ && v.ToString()!.Contains("drain chan 1003")
+ && v.ToString()!.Contains("refill peer peerD2")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task Execute_PricesEveryChannelInOneRoundTrip()
+ {
+ var node = new Node
+ {
+ Id = 20,
+ PubKey = NodePubKey,
+ Name = "alice",
+ AutoRebalanceEnabled = true,
+ RebalanceBudgetSats = 1_000_000,
+ MaxRebalancesInFlight = 5,
+ MaxRebalanceCostToEarnRatio = 0.5,
+ };
+ _nodeRepository.Setup(x => x.GetAllManagedByNodeGuard(false)).ReturnsAsync(new List { node });
+
+ _channelRepository.Setup(x => x.GetOpenChannels()).ReturnsAsync(new List
+ {
+ Db(101, 1001, optIn: true), // detected source
+ Db(102, 1002, optIn: false), // detected destination
+ Db(103, 1003, optIn: true), // fallback source only
+ });
+
+ _routingStateRepository.Setup(x => x.GetByManagedNodePubKey(NodePubKey)).ReturnsAsync(new List
+ {
+ new() { ChannelId = 101, ManagedNodePubKey = NodePubKey, ChanIdLnd = 1001, EmaLocalRatio = 0.75, TargetLocalRatio = 0.50, PeerFlowCategory = PeerFlowCategory.Source },
+ new() { ChannelId = 102, ManagedNodePubKey = NodePubKey, ChanIdLnd = 1002, EmaLocalRatio = 0.10, TargetLocalRatio = 0.50, PeerFlowCategory = PeerFlowCategory.Sink },
+ new() { ChannelId = 103, ManagedNodePubKey = NodePubKey, ChanIdLnd = 1003, EmaLocalRatio = 0.60, TargetLocalRatio = 0.50, PeerFlowCategory = PeerFlowCategory.Bidirectional },
+ });
+
+ _rebalanceRepository.Setup(x => x.GetPendingInFlightSourceChannelIds()).ReturnsAsync(new HashSet());
+ _rebalanceRepository.Setup(x => x.GetConsumedFeesSince(node.Id, It.IsAny())).ReturnsAsync(0L);
+ _rebalanceRepository.Setup(x => x.GetInFlightByNode(node.Id)).ReturnsAsync(0);
+
+ _lightningClientService
+ .Setup(x => x.ListChannels(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new Lnrpc.ListChannelsResponse
+ {
+ Channels =
+ {
+ new Lnrpc.Channel { ChanId = 1001, Capacity = 20_000_000, LocalBalance = 15_000_000, RemoteBalance = 5_000_000, Active = true, RemotePubkey = "peerS" },
+ new Lnrpc.Channel { ChanId = 1002, Capacity = 20_000_000, LocalBalance = 2_000_000, RemoteBalance = 18_000_000, Active = true, RemotePubkey = "peerD" },
+ new Lnrpc.Channel { ChanId = 1003, Capacity = 20_000_000, LocalBalance = 14_000_000, RemoteBalance = 6_000_000, Active = true, RemotePubkey = "peerF" },
+ },
+ });
+
+ _lightningService.Setup(x => x.GetLocalOutboundFeeRatesPpmAsync(It.IsAny()))
+ .ReturnsAsync(new Dictionary { [1001] = 2000, [1002] = 2000, [1003] = 2000 });
+ _rebalanceService.Setup(x => x.RebalanceAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new Rebalance());
+
+ await RoutingEngineSwitch.WithEngine(enabled: true, async () =>
+ {
+ await BuildJob().Execute(Mock.Of());
+ });
+
+ // Three channels across a detected source, a detected destination and a fallback source —
+ // all priced by ONE round-trip. The old per-channel path cost a GetChanInfo each and had to
+ // reason about which subset the planner would consult; FeeReport removes that decision.
+ _lightningService.Verify(x => x.GetLocalOutboundFeeRatesPpmAsync(It.IsAny()), Times.Once);
+ _lightningService.Verify(x => x.GetLocalOutboundFeeRatePpmAsync(
+ It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Execute_FeeReportUnavailable_SkipsTheNodeWithoutDispatching()
+ {
+ ArrangeRebalancePair(sourceOptedIn: true);
+ // Null, not an empty map: no rate for any channel means nothing can be profit-gated, and
+ // treating that as "everything earns zero" would silently drop every plan as unprofitable.
+ _lightningService.Setup(x => x.GetLocalOutboundFeeRatesPpmAsync(It.IsAny()))
+ .ReturnsAsync((Dictionary?)null);
+
+ await RoutingEngineSwitch.WithEngine(enabled: true, async () =>
+ {
+ await BuildJob().Execute(Mock.Of());
+ });
+
+ _rebalanceService.Verify(x => x.RebalanceAsync(
+ It.IsAny(), It.IsAny()), Times.Never);
+
+ // Skipped deliberately, not by blowing up: without the warning this test would also pass
+ // if BuildPlans threw on a null map and the per-node catch swallowed it.
+ _logger.Verify(x => x.Log(
+ LogLevel.Warning,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString()!.Contains("FeeReport unavailable")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ _logger.Verify(x => x.Log(
+ LogLevel.Error,
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Never);
+ }
+}
diff --git a/test/NodeGuard.Tests/Jobs/ChannelFeeOptimizerJobTests.cs b/test/NodeGuard.Tests/Jobs/ChannelFeeOptimizerJobTests.cs
index 72c76782..81257407 100644
--- a/test/NodeGuard.Tests/Jobs/ChannelFeeOptimizerJobTests.cs
+++ b/test/NodeGuard.Tests/Jobs/ChannelFeeOptimizerJobTests.cs
@@ -23,26 +23,44 @@
using NodeGuard.Helpers;
using NodeGuard.Services;
using NodeGuard.Tests.Helpers;
+using NodeGuard.Tests.Jobs;
using Quartz;
using Channel = NodeGuard.Data.Models.Channel;
namespace NodeGuard.Jobs;
+
+[Collection("RoutingEngine")]
public class ChannelFeeOptimizerJobTests
{
private const string NodePubKey = "managedPubKey";
private const ulong ChanId = 123;
private const int ChannelDbId = 10;
+ // The arranged channel is comfortably above ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS (10M).
+ // Kept as one constant because the job gates on the DB SatsAmount while the control law reads
+ // the LND balances — if the two drift the channel is silently filtered out of every test.
+ private const long ChannelSizeSats = 16_000_000;
+
+ private readonly Mock> _logger = new();
+
private readonly Mock _nodeRepository = new();
private readonly Mock _channelRepository = new();
private readonly Mock _routingStateRepository = new();
private readonly Mock _feeStateRepository = new();
- private readonly Mock _forwardingHtlcEventRepository = new();
private readonly Mock _rebalanceRepository = new();
+ private readonly Mock _rebalanceService = new();
private readonly Mock _lightningService = new();
private readonly Mock _lightningClientService = new();
+ // The real snapshot service over the mocked repos/LND, so these tests still cover the
+ // open-channel + routing-state filtering that feeds the job.
+ private IRoutingEngineSnapshotService BuildSnapshotService() =>
+ new RoutingEngineSnapshotService(
+ _routingStateRepository.Object,
+ _feeStateRepository.Object,
+ _lightningClientService.Object);
+
private Node BuildNode() => new()
{
Id = 20,
@@ -61,11 +79,12 @@ private void ArrangeSingleSinkChannel(Node node, bool inFlightRebalance)
Id = ChannelDbId,
ChanId = ChanId,
Status = Channel.ChannelStatus.Open,
+ SatsAmount = ChannelSizeSats,
IsDynamicFeeEnabled = true,
FundingTx = "txid123",
FundingTxOutputIndex = 1,
};
- _channelRepository.Setup(x => x.GetChannelsByOpenAndDynamicFeeEnabled())
+ _channelRepository.Setup(x => x.GetOpenChannels())
.ReturnsAsync(new List { dbChannel });
// Too remote (ema 0.40 < target 0.50) + Sink → raise outbound, negative inbound.
@@ -92,7 +111,7 @@ private void ArrangeSingleSinkChannel(Node node, bool inFlightRebalance)
new Lnrpc.Channel
{
ChanId = ChanId,
- Capacity = 16_000_000,
+ Capacity = ChannelSizeSats,
LocalBalance = 4_000_000,
RemoteBalance = 12_000_000,
Active = true,
@@ -119,28 +138,14 @@ private void ArrangeSingleSinkChannel(Node node, bool inFlightRebalance)
private ChannelFeeOptimizerJob BuildJob() =>
new(
- new Mock>().Object,
+ _logger.Object,
_nodeRepository.Object,
_channelRepository.Object,
- _routingStateRepository.Object,
_feeStateRepository.Object,
_rebalanceRepository.Object,
- _lightningService.Object,
- _lightningClientService.Object);
+ BuildSnapshotService(),
+ _lightningService.Object);
- private static async Task WithEngine(bool enabled, Func body)
- {
- var prevEnabled = Constants.ROUTING_ENGINE_ENABLED;
- Constants.ROUTING_ENGINE_ENABLED = enabled;
- try
- {
- await body();
- }
- finally
- {
- Constants.ROUTING_ENGINE_ENABLED = prevEnabled;
- }
- }
[Fact]
public async Task Execute_LiveNode_AppliesComputedPolicy()
@@ -148,7 +153,7 @@ public async Task Execute_LiveNode_AppliesComputedPolicy()
var node = BuildNode();
ArrangeSingleSinkChannel(node, inFlightRebalance: false);
- await WithEngine(enabled: true, async () =>
+ await RoutingEngineSwitch.WithEngine(enabled: true, async () =>
{
await BuildJob().Execute(Mock.Of());
});
@@ -164,7 +169,7 @@ public async Task Execute_InFlightRebalance_SkipsChannelEntirely()
var node = BuildNode();
ArrangeSingleSinkChannel(node, inFlightRebalance: true);
- await WithEngine(enabled: true, async () =>
+ await RoutingEngineSwitch.WithEngine(enabled: true, async () =>
{
await BuildJob().Execute(Mock.Of());
});
@@ -182,7 +187,7 @@ public async Task Execute_KillSwitchOff_DoesNothing()
var node = BuildNode();
ArrangeSingleSinkChannel(node, inFlightRebalance: false);
- await WithEngine(enabled: false, async () =>
+ await RoutingEngineSwitch.WithEngine(enabled: false, async () =>
{
await BuildJob().Execute(Mock.Of());
});
@@ -235,7 +240,7 @@ public async Task Execute_NoNodeWithDynamicFeesEnabled_SkipsBeforeFetchingChanne
await BuildJob().Execute(Mock.Of());
- _channelRepository.Verify(x => x.GetChannelsByOpenAndDynamicFeeEnabled(), Times.Never);
+ _channelRepository.Verify(x => x.GetOpenChannels(), Times.Never);
VerifyNoFeeWrite();
}
finally
@@ -274,7 +279,8 @@ public async Task Execute_ChannelBelowMinSize_SkipsChannel()
var prevEnabled = Constants.ROUTING_ENGINE_ENABLED;
var prevMinSize = Constants.ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS;
Constants.ROUTING_ENGINE_ENABLED = true;
- Constants.ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS = 20_000_000; // the arranged channel is 16M
+ // One sat above the arranged channel, so this pins the exact boundary of the size gate.
+ Constants.ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS = ChannelSizeSats + 1;
try
{
var node = BuildNode();
@@ -357,4 +363,66 @@ public async Task Execute_SetFeePolicyThrows_IsSwallowed_AndStateNotPersisted()
Constants.ROUTING_ENGINE_ENABLED = prevEnabled;
}
}
+
+ [Fact]
+ public async Task Execute_ChannelSharedWithAnotherManagedNode_IsStillActuated()
+ {
+ var node = BuildNode();
+ ArrangeSingleSinkChannel(node, inFlightRebalance: false);
+
+ // The peer is also managed by NodeGuard and it opened the channel. The old ownership dedup
+ // handed the channel to the initiator alone, so this side was never actuated: it could not
+ // see its own depleted channel as a rebalance destination and its outbound policy was
+ // frozen. Both sides now carry their own state and both get actuated.
+ var peer = new Node { Id = 21, PubKey = "peerPubKey", Name = "bob", DynamicFeeManagementEnabled = true };
+ _nodeRepository.Setup(x => x.GetAllManagedByNodeGuard(false)).ReturnsAsync(new List { node, peer });
+ _routingStateRepository.Setup(x => x.GetByManagedNodePubKey(peer.PubKey)).ReturnsAsync(new List());
+ _feeStateRepository.Setup(x => x.GetByManagedNodePubKey(peer.PubKey)).ReturnsAsync(new List());
+
+ _lightningClientService
+ .Setup(x => x.ListChannels(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new Lnrpc.ListChannelsResponse
+ {
+ Channels =
+ {
+ new Lnrpc.Channel
+ {
+ ChanId = ChanId,
+ Capacity = ChannelSizeSats,
+ LocalBalance = 4_000_000,
+ RemoteBalance = 12_000_000,
+ Active = true,
+ Initiator = false, // the managed peer opened it
+ RemotePubkey = peer.PubKey,
+ },
+ },
+ });
+
+ await RoutingEngineSwitch.WithEngine(enabled: true, async () =>
+ {
+ await BuildJob().Execute(Mock.Of());
+ });
+
+ _lightningService.Verify(x => x.SetChannelFeePolicy(
+ "txid123:1", NodePubKey, 1000, 2550u, 40u, 0, -50, true), Times.Once);
+ }
+
+ [Fact]
+ public async Task Execute_ColdStartFeeState_IsStampedWithTheActuatingNode()
+ {
+ var node = BuildNode();
+ ArrangeSingleSinkChannel(node, inFlightRebalance: false);
+
+ await RoutingEngineSwitch.WithEngine(enabled: true, async () =>
+ {
+ await BuildJob().Execute(Mock.Of());
+ });
+
+ // A fee state created from scratch must name its node, or it lands outside every
+ // per-node query and the control loop cold-starts forever.
+ _feeStateRepository.Verify(x => x.UpsertByChannelAndNode(
+ It.Is(f => f.ChannelId == ChannelDbId && f.ManagedNodePubKey == NodePubKey)),
+ Times.Once);
+ }
+
}
diff --git a/test/NodeGuard.Tests/Jobs/RoutingEngineCollection.cs b/test/NodeGuard.Tests/Jobs/RoutingEngineCollection.cs
new file mode 100644
index 00000000..dac0062a
--- /dev/null
+++ b/test/NodeGuard.Tests/Jobs/RoutingEngineCollection.cs
@@ -0,0 +1,57 @@
+/*
+ * NodeGuard
+ * Copyright (C) 2023 Elenpay
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see http://www.gnu.org/licenses/.
+ *
+ */
+
+using NodeGuard.Helpers;
+
+namespace NodeGuard.Tests.Jobs;
+
+///
+/// The routing-engine job tests share this collection so they run sequentially: they tune the
+/// engine by assigning to the static Constants.ROUTING_ENGINE_* fields and restore them in
+/// a finally block, so running the classes in parallel lets one class observe another's tuning.
+///
+[CollectionDefinition("RoutingEngine", DisableParallelization = true)]
+public class RoutingEngineCollection
+{
+}
+
+///
+/// Helpers shared by the routing-engine job tests.
+///
+public static class RoutingEngineSwitch
+{
+ ///
+ /// Runs with the global kill switch forced to ,
+ /// restoring the previous value afterwards even if the body throws. One copy, because a missed
+ /// restore leaks static state into every other class in the collection.
+ ///
+ public static async Task WithEngine(bool enabled, Func body)
+ {
+ var prevEnabled = Constants.ROUTING_ENGINE_ENABLED;
+ Constants.ROUTING_ENGINE_ENABLED = enabled;
+ try
+ {
+ await body();
+ }
+ finally
+ {
+ Constants.ROUTING_ENGINE_ENABLED = prevEnabled;
+ }
+ }
+}
diff --git a/test/NodeGuard.Tests/Jobs/TargetRatioReevaluationJobTests.cs b/test/NodeGuard.Tests/Jobs/TargetRatioReevaluationJobTests.cs
index 9f6f45fd..6f32920d 100644
--- a/test/NodeGuard.Tests/Jobs/TargetRatioReevaluationJobTests.cs
+++ b/test/NodeGuard.Tests/Jobs/TargetRatioReevaluationJobTests.cs
@@ -30,7 +30,7 @@
namespace NodeGuard.Jobs;
///
-/// Wiring tests for the Phase-1 sensor/classifier. The pure categorization math lives in
+/// Wiring tests for the sensor/classifier. The pure categorization math lives in
/// ; here the real
/// is used so these prove the JOB feeds it correctly: age gate, ownership/eligibility filter, the
/// push/pull → net-flow sign convention, first-insert EMA seeding, failure handling, and the kill switch.
diff --git a/test/NodeGuard.Tests/Services/RebalanceInitiatorServiceTests.cs b/test/NodeGuard.Tests/Services/RebalanceInitiatorServiceTests.cs
new file mode 100644
index 00000000..e273dcac
--- /dev/null
+++ b/test/NodeGuard.Tests/Services/RebalanceInitiatorServiceTests.cs
@@ -0,0 +1,437 @@
+/*
+ * NodeGuard
+ * Copyright (C) 2023 Elenpay
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see http://www.gnu.org/licenses/.
+ *
+ */
+
+using FluentAssertions;
+
+namespace NodeGuard.Services;
+
+public class RebalanceInitiatorServiceTests
+{
+ private static readonly RebalanceInitiatorTunables Tunables = new(
+ RebalanceTrigger: 0.15,
+ MinAmountSats: 10_000,
+ MaxAmountSats: 5_000_000,
+ CostToEarnRatio: 0.5,
+ MaxInitiations: 5);
+
+ private static ChannelSignal Chan(
+ int id, string peer, long local, long remote,
+ double ema, double target, bool active = true, bool optIn = true)
+ => new(id, (ulong)id, peer, local, remote, ema, target, active, optIn);
+
+ // ── Classify: sources ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Classify_TooLocalOptedIn_BecomesSource_ExcessSizedOnLiveBalance()
+ {
+ // d = 0.80 - 0.50 = 0.30 > 0.15; targetLocal = 0.5 * 1_000_000 = 500_000; excess = 300_000.
+ var channels = new[] { Chan(1, "peerA", local: 800_000, remote: 200_000, ema: 0.80, target: 0.50) };
+
+ var result = RebalanceInitiatorService.Classify(channels, Tunables);
+
+ result.Sources.Should().ContainSingle();
+ result.Sources[0].ChannelId.Should().Be(1);
+ result.Sources[0].ExcessSats.Should().Be(300_000);
+ }
+
+ [Fact]
+ public void Classify_TooLocalButNotOptedIn_IsNotSource()
+ {
+ var channels = new[] { Chan(1, "peerA", 800_000, 200_000, 0.80, 0.50, optIn: false) };
+
+ RebalanceInitiatorService.Classify(channels, Tunables).Sources.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Classify_AboveTargetButWithinTrigger_IsNotSource()
+ {
+ // d = 0.60 - 0.50 = 0.10 <= 0.15 → not "too local" enough to drain.
+ var channels = new[] { Chan(1, "peerA", 600_000, 400_000, 0.60, 0.50) };
+
+ RebalanceInitiatorService.Classify(channels, Tunables).Sources.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Classify_InactiveChannel_IsNeitherSourceNorDestination()
+ {
+ var channels = new[] { Chan(1, "peerA", 900_000, 100_000, 0.90, 0.50, active: false) };
+
+ var result = RebalanceInitiatorService.Classify(channels, Tunables);
+
+ result.Sources.Should().BeEmpty();
+ result.Destinations.Should().BeEmpty();
+ }
+
+ // ── Classify: destinations (peer aggregate) ─────────────────────────────────────────
+
+ [Fact]
+ public void Classify_TooRemotePeer_BecomesDestination_WithDeficit()
+ {
+ // aggEma 0.20 vs target 0.50 → d = -0.30 < -0.15; deficit = 500_000 - 200_000 = 300_000.
+ var channels = new[] { Chan(1, "peerA", local: 200_000, remote: 800_000, ema: 0.20, target: 0.50) };
+
+ var result = RebalanceInitiatorService.Classify(channels, Tunables);
+
+ result.Destinations.Should().ContainSingle();
+ result.Destinations[0].PeerPubKey.Should().Be("peerA");
+ result.Destinations[0].DeficitSats.Should().Be(300_000);
+ result.Destinations[0].Members.Should().ContainSingle();
+ }
+
+ [Fact]
+ public void Classify_AggregatesMultipleChannelsToSamePeer()
+ {
+ // Chan A balanced (0.50), Chan B depleted (0.10); both base 1_000_000, target 0.50.
+ // aggEma = (0.50 + 0.10)/2 = 0.30 → d = -0.20 < -0.15.
+ // deficit = target 1_000_000 - peerLocal 600_000 = 400_000.
+ var channels = new[]
+ {
+ Chan(1, "peerA", 500_000, 500_000, 0.50, 0.50),
+ Chan(2, "peerA", 100_000, 900_000, 0.10, 0.50),
+ };
+
+ var result = RebalanceInitiatorService.Classify(channels, Tunables);
+
+ result.Destinations.Should().ContainSingle();
+ result.Destinations[0].DeficitSats.Should().Be(400_000);
+ result.Destinations[0].Members.Should().HaveCount(2);
+ }
+
+ [Fact]
+ public void Classify_PeerAggregateWithinTrigger_IsNotDestination()
+ {
+ // aggEma 0.42 vs target 0.50 → d = -0.08, inside the 0.15 trigger.
+ var channels = new[] { Chan(1, "peerA", 420_000, 580_000, 0.42, 0.50) };
+
+ RebalanceInitiatorService.Classify(channels, Tunables).Destinations.Should().BeEmpty();
+ }
+
+ // ── BuildPlans ──────────────────────────────────────────────────────────────────────
+
+ private static RebalanceClassification Classify(params ChannelSignal[] channels)
+ => RebalanceInitiatorService.Classify(channels, Tunables);
+
+ [Fact]
+ public void BuildPlans_TakesTheFirstAvailableSource_RegardlessOfEarnRate()
+ {
+ // Two drainable sources on different peers; the FIRST one is the dear one (2000ppm vs 50ppm).
+ // Pairing is first-fit in classification order, so chan 1 is drained anyway — there is no
+ // cheapest-source preference. Listing it first is what makes this assertion meaningful.
+ var classification = Classify(
+ Chan(1, "dear", 800_000, 200_000, 0.80, 0.50),
+ Chan(2, "cheap", 800_000, 200_000, 0.80, 0.50),
+ Chan(3, "dest", 200_000, 800_000, 0.20, 0.50));
+ var earn = new Dictionary { [1] = 2000, [2] = 50, [3] = 2500 };
+
+ var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables);
+
+ plans.Should().ContainSingle();
+ plans[0].SourceChannelId.Should().Be(1);
+ plans[0].DestinationPeerPubKey.Should().Be("dest");
+ }
+
+ [Fact]
+ public void BuildPlans_ProfitGate_SetsMaxFeePctFromDestEarnRate()
+ {
+ var classification = Classify(
+ Chan(1, "cheap", 800_000, 200_000, 0.80, 0.50),
+ Chan(3, "dest", 200_000, 800_000, 0.20, 0.50));
+ var earn = new Dictionary { [1] = 50, [3] = 2500 };
+
+ var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables);
+
+ // maxCostPpm = 0.5 * 2500 = 1250 → MaxFeePct = 1250 / 10_000 = 0.125.
+ plans.Should().ContainSingle();
+ plans[0].MaxFeePct.Should().BeApproximately(0.125, 1e-9);
+ }
+
+ [Fact]
+ public void BuildPlans_ZeroEarnDestination_IsSkipped()
+ {
+ var classification = Classify(
+ Chan(1, "cheap", 800_000, 200_000, 0.80, 0.50),
+ Chan(3, "dest", 200_000, 800_000, 0.20, 0.50));
+ var earn = new Dictionary { [1] = 50, [3] = 0 }; // dest earns nothing
+
+ RebalanceInitiatorService.BuildPlans(classification, earn, Tunables).Should().BeEmpty();
+ }
+
+ [Fact]
+ public void BuildPlans_DestinationWithoutKnownEarnRate_IsSkipped()
+ {
+ var classification = Classify(
+ Chan(1, "cheap", 800_000, 200_000, 0.80, 0.50),
+ Chan(3, "dest", 200_000, 800_000, 0.20, 0.50));
+ var earn = new Dictionary { [1] = 50 }; // no entry for the dest channel
+
+ RebalanceInitiatorService.BuildPlans(classification, earn, Tunables).Should().BeEmpty();
+ }
+
+ [Fact]
+ public void BuildPlans_AvoidsSamePeerPairing()
+ {
+ // Peer "A" is BOTH a too-local source (chan 1) and — thanks to a deeply depleted sibling
+ // (chan 2) — a too-remote destination in aggregate, and it is classified before peer "B".
+ // The only source is on peer "A", so A can't be refilled from itself; B is refilled instead.
+ var classification = Classify(
+ Chan(1, "A", 800_000, 200_000, 0.80, 0.50), // source, peer A, excess 300_000
+ Chan(2, "A", 50_000, 2_950_000, 0.02, 0.50), // pulls peer A aggregate too-remote
+ Chan(3, "B", 200_000, 800_000, 0.20, 0.50)); // destination, peer B
+ var earn = new Dictionary { [1] = 4000, [2] = 4000, [3] = 2500 };
+
+ var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables);
+
+ plans.Should().ContainSingle();
+ plans[0].SourceChannelId.Should().Be(1);
+ plans[0].DestinationPeerPubKey.Should().Be("B"); // NOT "A", despite A being tried first
+ }
+
+ [Fact]
+ public void BuildPlans_SizesToMinOfExcessAndDeficit()
+ {
+ // source excess 300_000, dest deficit 400_000 → amount 300_000.
+ var classification = Classify(
+ Chan(1, "src", 800_000, 200_000, 0.80, 0.50),
+ Chan(3, "dest", 100_000, 900_000, 0.10, 0.50));
+ var earn = new Dictionary { [1] = 50, [3] = 2500 };
+
+ var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables);
+
+ plans.Should().ContainSingle();
+ plans[0].AmountSats.Should().Be(300_000);
+ }
+
+ [Fact]
+ public void BuildPlans_ClampsAmountToMax()
+ {
+ // source excess 6_000_000, dest deficit 8_000_000, both above the 5_000_000 cap → amount 5_000_000.
+ var classification = Classify(
+ Chan(1, "src", 9_000_000, 3_000_000, 0.75, 0.25), // excess = 9M - 3M = 6M
+ Chan(3, "dest", 2_000_000, 18_000_000, 0.10, 0.50)); // deficit = 10M - 2M = 8M
+ var earn = new Dictionary { [1] = 50, [3] = 2500 };
+
+ var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables);
+
+ plans.Should().ContainSingle();
+ plans[0].AmountSats.Should().Be(5_000_000);
+ }
+
+ [Fact]
+ public void BuildPlans_UsesEachSourceAtMostOncePerRun()
+ {
+ // Two depleted destinations, a single drainable source → only one plan.
+ var classification = Classify(
+ Chan(1, "src", 5_000_000, 1_000_000, 0.83, 0.50),
+ Chan(3, "destA", 200_000, 800_000, 0.20, 0.50),
+ Chan(4, "destB", 200_000, 800_000, 0.20, 0.50));
+ var earn = new Dictionary { [1] = 50, [3] = 2500, [4] = 2400 };
+
+ var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables);
+
+ plans.Should().ContainSingle();
+ plans[0].SourceChannelId.Should().Be(1);
+ }
+
+ [Fact]
+ public void BuildPlans_RespectsMaxInitiationsCap()
+ {
+ var tunables = Tunables with { MaxInitiations = 1 };
+ var classification = Classify(
+ Chan(1, "srcA", 800_000, 200_000, 0.80, 0.50),
+ Chan(2, "srcB", 800_000, 200_000, 0.80, 0.50),
+ Chan(3, "destA", 200_000, 800_000, 0.20, 0.50),
+ Chan(4, "destB", 200_000, 800_000, 0.20, 0.50));
+ var earn = new Dictionary { [1] = 50, [2] = 60, [3] = 2500, [4] = 2400 };
+
+ RebalanceInitiatorService.BuildPlans(classification, earn, tunables).Should().ContainSingle();
+ }
+
+ [Fact]
+ public void BuildPlans_WeightsDestinationEarnRateByBalanceBase()
+ {
+ // Dest peer has two channels: 1000ppm (base 1M) and 3000ppm (base 3M).
+ // Weighted avg = (1000·1M + 3000·3M) / 4M = 10_000M/4M = 2500ppm → maxFeePct = 0.5·2500/10_000 = 0.125.
+ var classification = Classify(
+ Chan(1, "src", 800_000, 200_000, 0.80, 0.50),
+ Chan(3, "dest", 200_000, 800_000, 0.20, 0.50), // base 1M
+ Chan(4, "dest", 600_000, 2_400_000, 0.20, 0.50)); // base 3M
+ var earn = new Dictionary { [1] = 50, [3] = 1000, [4] = 3000 };
+
+ var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables);
+
+ plans.Should().ContainSingle();
+ plans[0].MaxFeePct.Should().BeApproximately(0.125, 1e-9);
+ }
+
+ // ── Fallback pairing: act on a detected imbalance even with no qualifying counterparty ──
+
+ [Fact]
+ public void Classify_ChannelInsideTheDeadband_LandsInBothFallbackPools()
+ {
+ // Perfectly on target: not a source, not a destination, but able to play either role.
+ // Lendable down to 0.35 → 500_000 - 350_000 = 150_000. Absorbable up to 0.65 → 150_000.
+ var result = Classify(Chan(1, "peerA", local: 500_000, remote: 500_000, ema: 0.50, target: 0.50));
+
+ result.Sources.Should().BeEmpty();
+ result.Destinations.Should().BeEmpty();
+ result.FallbackSources.Should().ContainSingle().Which.ExcessSats.Should().Be(150_000);
+ result.FallbackDestinations.Should().ContainSingle().Which.DeficitSats.Should().Be(150_000);
+ }
+
+ [Fact]
+ public void Classify_LendableBelowMinAmount_IsNotAFallbackSource()
+ {
+ // Inside the deadband, so not a detected source — and it can only lend down to 0.35
+ // (355_000 - 350_000 = 5_000), below the 10_000 floor, so it isn't worth a hop as a source.
+ var result = Classify(Chan(1, "peerA", local: 355_000, remote: 645_000, ema: 0.355, target: 0.50));
+
+ result.Sources.Should().BeEmpty();
+ result.FallbackSources.Should().BeEmpty("5_000 lendable is below MinAmountSats");
+ // Still a usable counterparty in the other direction: 650_000 - 355_000 = 295_000 to absorb.
+ result.FallbackDestinations.Should().ContainSingle().Which.DeficitSats.Should().Be(295_000);
+ }
+
+ [Fact]
+ public void Classify_AbsorbableBelowMinAmount_IsNotAFallbackDestination()
+ {
+ // Mirror image: inside the deadband, and it can only absorb up to 0.65
+ // (650_000 - 645_000 = 5_000), below the floor.
+ var result = Classify(Chan(1, "peerA", local: 645_000, remote: 355_000, ema: 0.645, target: 0.50));
+
+ result.Destinations.Should().BeEmpty();
+ result.FallbackDestinations.Should().BeEmpty("5_000 absorbable is below MinAmountSats");
+ // Still lendable: 645_000 - 350_000 = 295_000.
+ result.FallbackSources.Should().ContainSingle().Which.ExcessSats.Should().Be(295_000);
+ }
+
+ [Fact]
+ public void BuildPlans_SourceWithNoQualifyingDestination_DrainsIntoTheRoomiestFallbackPeer()
+ {
+ var classification = Classify(
+ Chan(1, "src", 800_000, 200_000, 0.80, 0.50), // source, excess 300_000
+ Chan(2, "peerB", 500_000, 500_000, 0.50, 0.50), // fallback dest, absorbable 150_000
+ Chan(3, "peerC", 450_000, 550_000, 0.45, 0.50)); // fallback dest, absorbable 200_000
+ var earn = new Dictionary { [1] = 50, [2] = 2000, [3] = 2000 };
+
+ classification.Destinations.Should().BeEmpty("neither peer tripped the -0.15 trigger");
+
+ var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables);
+
+ plans.Should().ContainSingle();
+ plans[0].SourceChannelId.Should().Be(1);
+ // peerC over peerB: fallback destinations are ranked by the room they have (200_000 vs
+ // 150_000), so the deeper one absorbs first.
+ plans[0].DestinationPeerPubKey.Should().Be("peerC");
+ plans[0].IsFallbackPairing.Should().BeTrue();
+ // Capped by peerC's room up to target + deadband (650_000 - 450_000), NOT the source's
+ // full 300_000 excess — refilling must never turn the destination into next cycle's source.
+ plans[0].AmountSats.Should().Be(200_000);
+ }
+
+ [Fact]
+ public void BuildPlans_DestinationWithNoQualifyingSource_IsFundedByTheLargestFallbackChannel()
+ {
+ var classification = Classify(
+ Chan(1, "peerA", 600_000, 400_000, 0.60, 0.50), // fallback source, lendable 250_000
+ Chan(2, "peerB", 900_000, 100_000, 0.55, 0.50), // fallback source, lendable 550_000
+ Chan(3, "dest", 100_000, 900_000, 0.10, 0.50)); // destination, deficit 400_000
+ var earn = new Dictionary { [1] = 50, [2] = 60, [3] = 2500 };
+
+ classification.Sources.Should().BeEmpty("neither peer tripped the +0.15 trigger");
+
+ var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables);
+
+ plans.Should().ContainSingle();
+ // peerB over peerA: the fallback pool is ranked by how much it can lend (550_000 vs 250_000).
+ plans[0].SourceChannelId.Should().Be(2);
+ plans[0].DestinationPeerPubKey.Should().Be("dest");
+ plans[0].IsFallbackPairing.Should().BeTrue();
+ // peerB can lend 550_000, so the destination's 400_000 deficit is what binds here.
+ plans[0].AmountSats.Should().Be(400_000);
+ }
+
+ [Fact]
+ public void BuildPlans_FallbackSourceIsNotDrainedBelowItsOwnDeadband()
+ {
+ var classification = Classify(
+ Chan(1, "peerA", 400_000, 600_000, 0.40, 0.50), // fallback source: lendable to 0.35 = 50_000
+ Chan(2, "dest", 0, 1_000_000, 0.00, 0.50)); // destination, deficit 500_000
+ var earn = new Dictionary { [1] = 50, [2] = 2500 };
+
+ var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables);
+
+ plans.Should().ContainSingle();
+ // 50_000, not the 400_000 it actually holds: lending stops at target - deadband so the
+ // fallback source can't become next cycle's destination.
+ plans[0].AmountSats.Should().Be(50_000);
+ }
+
+ [Fact]
+ public void BuildPlans_FallbackPairingIsStillProfitGated()
+ {
+ var classification = Classify(
+ Chan(1, "src", 800_000, 200_000, 0.80, 0.50),
+ Chan(2, "peerB", 500_000, 500_000, 0.50, 0.50));
+ // The only available destination earns nothing, so there is no margin to pay a route with.
+ var earn = new Dictionary { [1] = 50, [2] = 0 };
+
+ RebalanceInitiatorService.BuildPlans(classification, earn, Tunables).Should().BeEmpty();
+ }
+
+ [Fact]
+ public void BuildPlans_RefillsAFallbackDestinationAtMostOncePerRun()
+ {
+ // Two drainable sources, and the only fallback destination has room for 150_000. Each plan
+ // is clamped to that room on its own, so without a per-run guard both sources would send
+ // peerX 150_000 and land it 300_000 above where it started — past target + deadband, making
+ // the peer we just refilled next cycle's source.
+ var classification = Classify(
+ Chan(1, "srcA", 800_000, 200_000, 0.80, 0.50), // detected source, excess 300_000
+ Chan(2, "srcB", 800_000, 200_000, 0.80, 0.50), // detected source, excess 300_000
+ Chan(3, "peerX", 500_000, 500_000, 0.50, 0.50)); // fallback dest, absorbable 150_000
+ var earn = new Dictionary { [1] = 50, [2] = 50, [3] = 2000 };
+
+ classification.Destinations.Should().BeEmpty("peerX did not trip the -0.15 trigger");
+ classification.FallbackDestinations.Should().ContainSingle()
+ .Which.DeficitSats.Should().Be(150_000);
+
+ var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables);
+
+ plans.Should().ContainSingle("peerX has room for one refill, not one per source");
+ plans[0].DestinationPeerPubKey.Should().Be("peerX");
+ plans[0].AmountSats.Should().Be(150_000);
+ plans.Sum(p => p.AmountSats).Should().Be(150_000, "the peer's room is a per-run allowance");
+ }
+
+ [Fact]
+ public void BuildPlans_PrefersAQualifyingSourceOverAFallbackOne()
+ {
+ var classification = Classify(
+ Chan(1, "src", 800_000, 200_000, 0.80, 0.50), // qualifying source
+ Chan(2, "peerB", 950_000, 50_000, 0.55, 0.50), // fallback source, far more to lend
+ Chan(3, "dest", 100_000, 900_000, 0.10, 0.50)); // destination
+ var earn = new Dictionary { [1] = 50, [2] = 60, [3] = 2500 };
+
+ var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables);
+
+ plans.Should().ContainSingle();
+ plans[0].SourceChannelId.Should().Be(1, "a channel that tripped the trigger is drained before one that did not");
+ plans[0].IsFallbackPairing.Should().BeFalse();
+ }
+}