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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ public static async Task Main(string[] args)
builder.Services.AddSingleton<ILightningClientService, LightningClientService>();
builder.Services.AddSingleton<ILightningRouterService, LightningRouterService>();
builder.Services.AddSingleton<IPeerCategorizationService, PeerCategorizationService>();
builder.Services.AddSingleton<IFeeOptimizerService, FeeOptimizerService>();
builder.Services.AddSingleton<ILoopService, LoopService>();
builder.Services.AddSingleton<IFortySwapService, FortySwapService>();

Expand Down
203 changes: 203 additions & 0 deletions src/Services/FeeOptimizerService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/*
* 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.Helpers;

namespace NodeGuard.Services;

/// <summary>
/// What the fee optimizer decided to do this cycle for a single channel.
/// </summary>
public enum FeeAction
{
/// <summary>Inside the fee deadband (or computed delta below the min-delta dead-zone) — leave fees as they are.</summary>
NoOp = 0,

/// <summary>|deviation| exceeds the rebalance deadband — the rebalancer owns this channel, the fee engine must not touch it.</summary>
DeferToRebalancer = 1,

/// <summary>Apply the new outbound/inbound ppm.</summary>
Update = 2,
}

/// <summary>
/// Result of one <see cref="IFeeOptimizerService.ComputeNextPolicy"/> call. For <see cref="FeeAction.Update"/>,
/// <see cref="OutboundPpm"/>/<see cref="InboundPpm"/> are the values to apply; for NoOp/DeferToRebalancer they
/// echo the current (unchanged) values so the caller can log a coherent "would-keep" line.
/// </summary>
public record FeePolicyDecision(FeeAction Action, uint OutboundPpm, int InboundPpm, string Reason);

/// <summary>
/// Control-law tunables for <see cref="IFeeOptimizerService.ComputeNextPolicy"/>. Passed in explicitly so the
/// decision logic stays pure and unit-testable; <see cref="FromConstants"/> is the production wiring.
/// </summary>
public record FeeOptimizerTunables(
double KpOut,
double Ki,
double FeeDeadband,
double RebalanceDeadband,
uint MaxStepPpm,
uint MaxInboundStepPpm,
uint MinDeltaPpm,
uint MinOutboundPpm,
uint MaxOutboundPpm,
int MinInboundPpm,
int MaxInboundPpm,
uint BaselineSourcePpm,
uint BaselineBidirectionalPpm,
uint BaselineSinkPpm,
uint BaselineUncategorizedPpm)
{
/// <summary>Builds the tunable set from the ROUTING_ENGINE_FEE_* constants (the production configuration).</summary>
public static FeeOptimizerTunables FromConstants() => new(
KpOut: Constants.ROUTING_ENGINE_FEE_KP_OUT,
Ki: Constants.ROUTING_ENGINE_FEE_KI,
FeeDeadband: Constants.ROUTING_ENGINE_FEE_DEADBAND,
RebalanceDeadband: Constants.ROUTING_ENGINE_REBALANCE_DEADBAND,
MaxStepPpm: Constants.ROUTING_ENGINE_FEE_MAX_STEP_PPM,
MaxInboundStepPpm: Constants.ROUTING_ENGINE_FEE_MAX_INBOUND_STEP_PPM,
MinDeltaPpm: Constants.ROUTING_ENGINE_FEE_MIN_DELTA_PPM,
MinOutboundPpm: Constants.ROUTING_ENGINE_FEE_MIN_OUTBOUND_PPM,
MaxOutboundPpm: Constants.ROUTING_ENGINE_FEE_MAX_OUTBOUND_PPM,
MinInboundPpm: Constants.ROUTING_ENGINE_FEE_MIN_INBOUND_PPM,
MaxInboundPpm: Constants.ROUTING_ENGINE_FEE_MAX_INBOUND_PPM,
BaselineSourcePpm: Constants.ROUTING_ENGINE_FEE_BASELINE_PPM_SOURCE,
BaselineBidirectionalPpm: Constants.ROUTING_ENGINE_FEE_BASELINE_PPM_BIDIRECTIONAL,
BaselineSinkPpm: Constants.ROUTING_ENGINE_FEE_BASELINE_PPM_SINK,
BaselineUncategorizedPpm: (uint)Constants.DEFAULT_CHANNEL_FEE_POLICY_FEE_RATE_PPM);
}

public interface IFeeOptimizerService
{
/// <summary>
/// Proportional control on the EMA-smoothed local ratio, driving both outbound ppm and (signed)
/// inbound ppm toward the channel's target. Pure — no I/O, no clock, no DB.
/// <para>
/// With <c>d = emaLocalRatio - targetLocalRatio</c>:
/// <list type="bullet">
/// <item><c>|d| &gt; rebalanceDeadband</c> → <see cref="FeeAction.DeferToRebalancer"/> (authority split).</item>
/// <item><c>|d| ≤ feeDeadband</c> → <see cref="FeeAction.NoOp"/>.</item>
/// <item><c>d &gt; 0</c> (too local): lower outbound to attract exits, positive inbound to repel entry.</item>
/// <item><c>d &lt; 0</c> (too remote): raise outbound to preserve local, negative inbound to attract entry.</item>
/// </list>
/// The category baseline (p₀) both scales the proportional term and seeds the "last" values on the
/// first evaluation of a channel, so a freshly categorized off-target channel jumps toward its
/// category baseline and then fine-tunes within the per-step clamp.
/// </para>
/// </summary>
/// <param name="emaLocalRatio">Pre-smoothed local/(local+remote) ratio from ChannelRoutingState.</param>
/// <param name="targetLocalRatio">Dynamic target ratio from ChannelRoutingState.</param>
/// <param name="category">Peer flow category — selects the outbound baseline p₀.</param>
/// <param name="lastOutboundPpm">Last-applied outbound ppm (ChannelFeeState); null on first eval → seeded with p₀.</param>
/// <param name="lastInboundPpm">Last-applied inbound ppm (ChannelFeeState); null on first eval → seeded with 0.</param>
/// <param name="allowPositiveInboundFees">When false, inbound is collapsed to ≤ 0 regardless of direction.</param>
/// <param name="tunables">Control-law constants.</param>
FeePolicyDecision ComputeNextPolicy(
double emaLocalRatio,
double targetLocalRatio,
PeerFlowCategory category,
uint? lastOutboundPpm,
int? lastInboundPpm,
bool allowPositiveInboundFees,
FeeOptimizerTunables tunables);
}

public class FeeOptimizerService : IFeeOptimizerService
{
public FeePolicyDecision ComputeNextPolicy(
double emaLocalRatio,
double targetLocalRatio,
PeerFlowCategory category,
uint? lastOutboundPpm,
int? lastInboundPpm,
bool allowPositiveInboundFees,
FeeOptimizerTunables tunables)
{
var p0 = BaselineFor(category, tunables);
// Seed the initial category "jump": with no last-applied value, start from the category
// baseline so the first actionable write lands near p₀ rather than crawling from the
// operator's pre-engine fee.
var pLast = lastOutboundPpm ?? p0;
var iLast = lastInboundPpm ?? 0;

var d = emaLocalRatio - targetLocalRatio;
var absD = Math.Abs(d);

// Authority split — outside the rebalance deadband the rebalancer moves the ratio artificially,
// so the fee engine must not react to (its own) manufactured signal.
if (absD > tunables.RebalanceDeadband)
{
return new FeePolicyDecision(FeeAction.DeferToRebalancer, pLast, iLast,
$"|d|={absD:0.###} > rebalanceDeadband={tunables.RebalanceDeadband:0.###}");
}

// Nothing to correct inside the fee deadband.
if (absD <= tunables.FeeDeadband)
{
return new FeePolicyDecision(FeeAction.NoOp, pLast, iLast,
$"|d|={absD:0.###} <= feeDeadband={tunables.FeeDeadband:0.###}");
}

// Outbound: raise when too remote (d<0), lower when too local (d>0).
var pNew = RoundClampUint(pLast - tunables.KpOut * d * p0, tunables.MinOutboundPpm, tunables.MaxOutboundPpm);
var dp = Math.Clamp((long)pNew - pLast, -(long)tunables.MaxStepPpm, tunables.MaxStepPpm);
var outbound = Math.Abs(dp) < tunables.MinDeltaPpm ? pLast : (uint)(pLast + dp);

// Inbound: negative when too remote (attract entry), positive when too local (repel entry).
var iTargetRaw = tunables.Ki * d * p0;
if (!allowPositiveInboundFees)
{
iTargetRaw = Math.Min(iTargetRaw, 0);
}
var iTarget = RoundClampInt(iTargetRaw, tunables.MinInboundPpm, tunables.MaxInboundPpm);
var di = Math.Clamp((long)iTarget - iLast, -(long)tunables.MaxInboundStepPpm, tunables.MaxInboundStepPpm);
var inbound = Math.Abs(di) < tunables.MinDeltaPpm ? iLast : (int)(iLast + di);

if (outbound == pLast && inbound == iLast)
{
return new FeePolicyDecision(FeeAction.NoOp, pLast, iLast, "computed delta below min-delta dead-zone");
}

return new FeePolicyDecision(FeeAction.Update, outbound, inbound,
$"d={d:0.###} outbound {pLast}->{outbound}ppm inbound {iLast}->{inbound}ppm (p0={p0})");
}

private static uint BaselineFor(PeerFlowCategory category, FeeOptimizerTunables t) => category switch
{
PeerFlowCategory.Source => t.BaselineSourcePpm,
PeerFlowCategory.Bidirectional => t.BaselineBidirectionalPpm,
PeerFlowCategory.Sink => t.BaselineSinkPpm,
_ => t.BaselineUncategorizedPpm,
};

private static uint RoundClampUint(double value, uint min, uint max)
{
var rounded = Math.Round(value, MidpointRounding.AwayFromZero);
if (rounded < min) return min;
if (rounded > max) return max;
return (uint)rounded;
}

private static int RoundClampInt(double value, int min, int max)
{
var rounded = (int)Math.Round(value, MidpointRounding.AwayFromZero);
return Math.Clamp(rounded, min, max);
}
}
31 changes: 22 additions & 9 deletions src/Services/LightningService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,13 @@ Task<Payment> SendPaymentV2Async(Node node, string paymentRequest, long amountSa
/// <param name="timeLockDelta"></param>
/// <param name="inboundBaseFeeMsat"></param>
/// <param name="inboundFeeRatePpm"></param>
/// <param name="allowPositiveInboundFees">
/// When false (default — the manual/UI/gRPC path), inbound base fee and rate must be
/// &lt;= 0. When true, positive inbound fees are permitted; this is the routing engine's
/// fee-optimizer path (Cyberdyne-style positive inbound) and is audit-marked as such.
/// </param>
/// <returns></returns>
public Task SetChannelFeePolicy(string chanPoint, string nodePubKey, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm);
public Task SetChannelFeePolicy(string chanPoint, string nodePubKey, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm, bool allowPositiveInboundFees = false);

/// <summary>
/// Gets the channel fee policy for a given channel identified by its chanPoint
Expand Down Expand Up @@ -1762,7 +1767,7 @@ public async Task<Payment> SendPaymentV2Async(Node node, string paymentRequest,
return await GetLocalOutboundFeeRatePpmAsync(node, match.ChanId);
}

public async Task SetChannelFeePolicy(string chanPoint, string nodePubKey, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm)
public async Task SetChannelFeePolicy(string chanPoint, string nodePubKey, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm, bool allowPositiveInboundFees = false)
{
// Validate chanPoint format
if (!OutPoint.TryParse(chanPoint, out var outPoint))
Expand All @@ -1781,14 +1786,19 @@ public async Task SetChannelFeePolicy(string chanPoint, string nodePubKey, long
throw new ArgumentException("Both inboundBaseFeeMsat and inboundFeeRatePpm must be provided together for inbound fee policy.");
}

if (inboundBaseFeeMsat.HasValue && inboundBaseFeeMsat.Value > 0)
// Positive inbound fees are only permitted on the routing-engine path
// (allowPositiveInboundFees == true). The manual/UI/gRPC path keeps the <= 0 cap.
if (!allowPositiveInboundFees)
{
throw new ArgumentException("Inbound base fee must be lower or equal to zero.", nameof(inboundBaseFeeMsat));
}
if (inboundBaseFeeMsat.HasValue && inboundBaseFeeMsat.Value > 0)
{
throw new ArgumentException("Inbound base fee must be lower or equal to zero.", nameof(inboundBaseFeeMsat));
}

if (inboundFeeRatePpm.HasValue && inboundFeeRatePpm.Value > 0)
{
throw new ArgumentException("Inbound fee rate must be lower or equal to zero.", nameof(inboundFeeRatePpm));
if (inboundFeeRatePpm.HasValue && inboundFeeRatePpm.Value > 0)
{
throw new ArgumentException("Inbound fee rate must be lower or equal to zero.", nameof(inboundFeeRatePpm));
}
}

var channel = await _channelRepository.GetByOutpoint(outPoint);
Expand Down Expand Up @@ -1851,7 +1861,10 @@ await applicationDbContext.AuditLogs.AddAsync(new AuditLog
FeeRatePpm = feeRatePpm,
TimeLockDelta = timeLockDelta,
InboundBaseFeeMsat = inboundBaseFeeMsat,
InboundFeeRatePpm = inboundFeeRatePpm
InboundFeeRatePpm = inboundFeeRatePpm,
// Marks routing-engine fee-optimizer writes (the only path that passes
// allowPositiveInboundFees) so they can be filtered apart from manual/UI writes.
EngineWrite = allowPositiveInboundFees
})
});

Expand Down
Loading