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(); + } +}