From 0fa6aa58445e8fd15fdcb07fe9e89db6a8294233 Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 7 Jul 2026 13:47:40 +0200 Subject: [PATCH 01/23] feat: add GetInfo and GetBlockHeight methods to LightningClientService and LightningService --- src/Services/LightningClientService.cs | 19 +++++++++++++++++++ src/Services/LightningService.cs | 16 ++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/Services/LightningClientService.cs b/src/Services/LightningClientService.cs index 93eb9d92..80654fa9 100644 --- a/src/Services/LightningClientService.cs +++ b/src/Services/LightningClientService.cs @@ -33,6 +33,7 @@ namespace NodeGuard.Services; public interface ILightningClientService { public Lightning.LightningClient GetLightningClient(string? endpoint); + public Task GetInfo(Node node, Lightning.LightningClient? client = null); 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); @@ -104,6 +105,24 @@ public Lightning.LightningClient GetLightningClient(string? endpoint) } } + public async Task GetInfo(Node node, Lightning.LightningClient? client = null) + { + try + { + client ??= GetLightningClient(node.Endpoint); + return await client.GetInfoAsync(new GetInfoRequest(), + new Metadata + { + { "macaroon", node.ChannelAdminMacaroon } + }); + } + catch (Exception e) + { + _logger.LogError(e, "Error while getting info for node {NodeId}", node.Id); + return null; + } + } + public async Task ListChannels(Node node, Lightning.LightningClient? client = null) { //This method is here to avoid a circular dependency between the LightningService and the ChannelRepository diff --git a/src/Services/LightningService.cs b/src/Services/LightningService.cs index 04e6c045..c4e966d5 100644 --- a/src/Services/LightningService.cs +++ b/src/Services/LightningService.cs @@ -120,6 +120,14 @@ public interface ILightningService /// public Task CreateChannel(Node source, int destId, ChannelPoint channelPoint, long satsAmount, string? closeAddress = null); + /// + /// Gets the current chain tip (block height) as reported by the node's LND. + /// Returns null if the GetInfo RPC fails, so callers can skip the node cleanly. + /// + /// + /// + public Task GetBlockHeight(Node node); + /// /// Lists all channels for a given node /// @@ -1540,6 +1548,14 @@ public async Task> GetChannelsState() return result; } + public async Task GetBlockHeight(Node node) + { + if (node == null) throw new ArgumentNullException(nameof(node)); + + var info = await _lightningClientService.GetInfo(node); + return info?.BlockHeight; + } + public async Task ListChannels(Node node) { if (node == null) throw new ArgumentNullException(nameof(node)); From c60b85a4a0069302cce6c4153211b83ee30849bd Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 7 Jul 2026 17:56:33 +0200 Subject: [PATCH 02/23] feat(migrations): add routing engine foundation with new columns and tables --- src/Data/ApplicationDbContext.cs | 28 + src/Data/Models/Channel.cs | 6 + src/Data/Models/ChannelFeeState.cs | 53 + src/Data/Models/ChannelRoutingState.cs | 105 + src/Data/Models/Node.cs | 61 + src/Data/Models/Rebalance.cs | 6 + src/Helpers/Constants.cs | 188 ++ ...251_AddRoutingEngineFoundation.Designer.cs | 1947 +++++++++++++++++ ...260707155251_AddRoutingEngineFoundation.cs | 237 ++ .../ApplicationDbContextModelSnapshot.cs | 208 ++ 10 files changed, 2839 insertions(+) create mode 100644 src/Data/Models/ChannelFeeState.cs create mode 100644 src/Data/Models/ChannelRoutingState.cs create mode 100644 src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs create mode 100644 src/Migrations/20260707155251_AddRoutingEngineFoundation.cs diff --git a/src/Data/ApplicationDbContext.cs b/src/Data/ApplicationDbContext.cs index 26b8ce74..51dd477d 100644 --- a/src/Data/ApplicationDbContext.cs +++ b/src/Data/ApplicationDbContext.cs @@ -109,6 +109,30 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(r => r.SourceChannelId) .OnDelete(DeleteBehavior.Restrict); + // Routing engine: 1:1 read models keyed on ChannelId. Composite ForwardingHtlcEvent + // indexes are intentionally deferred (see phase-1 spec) — added only once measured. + modelBuilder.Entity() + .HasOne(x => x.Channel) + .WithOne() + .HasForeignKey(x => x.ChannelId) + .OnDelete(DeleteBehavior.Cascade); + // Only one ChannelRoutingState per channel. + modelBuilder.Entity().HasIndex(x => x.ChannelId).IsUnique(); + + modelBuilder.Entity() + .HasOne(x => x.Channel) + .WithOne() + .HasForeignKey(x => x.ChannelId) + .OnDelete(DeleteBehavior.Cascade); + // Only one ChannelFeeState per channel. + modelBuilder.Entity().HasIndex(x => x.ChannelId).IsUnique(); + + // These default ON: existing rows must be backfilled true (the C# initializer + // only affects new in-code instances, not the DB column default / migration backfill). + modelBuilder.Entity().Property(x => x.IsDynamicFeeEnabled).HasDefaultValue(true); + modelBuilder.Entity().Property(x => x.RestoreFeeBaselineOnDisable).HasDefaultValue(true); + modelBuilder.Entity().Property(x => x.RoutingEngineDryRun).HasDefaultValue(true); + base.OnModelCreating(modelBuilder); } @@ -149,5 +173,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public DbSet AuditLogs { get; set; } public DbSet ForwardingHtlcEvents { get; set; } + + public DbSet ChannelRoutingStates { get; set; } + + public DbSet ChannelFeeStates { get; set; } } } diff --git a/src/Data/Models/Channel.cs b/src/Data/Models/Channel.cs index 30c78dbb..b6427971 100644 --- a/src/Data/Models/Channel.cs +++ b/src/Data/Models/Channel.cs @@ -68,6 +68,12 @@ public enum ChannelStatus /// public bool IsPrivate { get; set; } + /// + /// Per-channel opt-out for the Phase 2 dynamic fee engine. Defaults true; the node-level + /// flag still gates all fee writes. + /// + public bool IsDynamicFeeEnabled { get; set; } = true; + [NotMapped] public int? OpenedWithId => ChannelOperationRequests?.FirstOrDefault()?.Wallet?.Id; diff --git a/src/Data/Models/ChannelFeeState.cs b/src/Data/Models/ChannelFeeState.cs new file mode 100644 index 00000000..b3f0813d --- /dev/null +++ b/src/Data/Models/ChannelFeeState.cs @@ -0,0 +1,53 @@ +/* + * 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/. + * + */ + +namespace NodeGuard.Data.Models; + +/// +/// Per-channel fee-engine state (1:1 with ). Declared in the Phase 1 +/// migration so Phase 2 needs no schema change; rows are not created or populated until the +/// Phase 2 fee engine ships. Holds last-applied policy + baseline snapshot (for rollback on +/// disable) + a per-channel circuit-breaker counter, all of which must survive restarts. +/// +public class ChannelFeeState : Entity +{ + /// FK to (unique — one fee state per channel). + public int ChannelId { get; set; } + public Channel Channel { get; set; } = null!; + + public DateTimeOffset? LastFeeUpdateAt { get; set; } + public long? LastAppliedOutboundBaseFeeMsat { get; set; } + public uint? LastAppliedOutboundPpm { get; set; } + public int? LastAppliedInboundBaseMsat { get; set; } + public int? LastAppliedInboundPpm { get; set; } + public double? LastComputedTarget { get; set; } + public double? LastObservedRatio { get; set; } + + public DateTimeOffset? BaselineCapturedAt { get; set; } + public long? BaselineOutboundBaseFeeMsat { get; set; } + public uint? BaselineOutboundPpm { get; set; } + public int? BaselineInboundBaseMsat { get; set; } + public int? BaselineInboundPpm { get; set; } + + /// + /// Circuit-breaker counter: incremented on each consecutive failure to apply a fee update, + /// reset to 0 on success. + /// + public int ConsecutiveFailures { get; set; } = 0; +} diff --git a/src/Data/Models/ChannelRoutingState.cs b/src/Data/Models/ChannelRoutingState.cs new file mode 100644 index 00000000..6289b545 --- /dev/null +++ b/src/Data/Models/ChannelRoutingState.cs @@ -0,0 +1,105 @@ +/* + * 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/. + * + */ + +namespace NodeGuard.Data.Models; + +/// +/// Flow-based categorization of a channel's peer, derived from settled forwarding history. +/// See the flow-sign convention: push = forwarded OUT (drains our local), pull = forwarded IN +/// (fills our local), NetFlowRatio = (push - pull) / (push + pull). +/// +public enum PeerFlowCategory +{ + Uncategorized = 0, + + /// Push-heavy (NetFlowRatio > 0): the peer drains our local balance. Hold more local, higher fees. + Sink = 1, + + /// Pull-heavy (NetFlowRatio < 0): the peer fills our local balance. Hold less local, lower fees. + Source = 2, + + /// Balanced flow: target ratio held near 0.5. + Bidirectional = 3 +} + +/// +/// Per-channel routing-engine read model (1:1 with ). Written by +/// TargetRatioReevaluationJob; read by the Phase 2 fee engine and Phase 3 rebalancer. +/// This is the single canonical place target ratio / category / smoothed balance live — +/// actuators must not re-derive them. +/// +public class ChannelRoutingState : Entity +{ + /// FK to (unique — one routing state per channel). + public int ChannelId { get; set; } + public Channel Channel { get; set; } = null!; + + /// LND short-channel-id snapshot, refreshed every evaluation (alias -> confirmed scid). + public ulong ChanIdLnd { get; set; } + + /// 66-hex pubkey of the managed node that owns routing state for this channel. + public string ManagedNodePubKey { get; set; } = null!; + + /// Dynamic target local-balance ratio, clamped to [0.10, 0.90]. Defaults to 0.5. + public double TargetLocalRatio { get; set; } = 0.5; + + public PeerFlowCategory PeerFlowCategory { get; set; } = PeerFlowCategory.Uncategorized; + + /// + /// Tentative category currently being counted toward a hysteresis flip; null in steady state. + /// + public PeerFlowCategory? PendingCategory { get; set; } + + /// Consecutive cycles the has been observed; 0 in steady state. + public uint ConsecutiveCategoryCyclesInNewState { get; set; } + + /// Funding block height parsed from the scid (bits 63..40); null for pending/alias/zero-conf. + public uint? FundingBlockHeight { get; set; } + + /// Channel age in blocks (chainTip - FundingBlockHeight); null when height can't be derived. + public uint? AgeBlocks { get; set; } + + /// EMA of local/(local+remote); seeded with the first observed ratio on insert. + public double EmaLocalRatio { get; set; } + + /// Σ settled msat leaving us via this channel over the categorization window (drains local). + public long PushMsatWindow { get; set; } + + /// Σ settled msat arriving at us via this channel over the same window (fills local). + public long PullMsatWindow { get; set; } + + /// (push - pull) / (push + pull); positive = SINK, negative = SOURCE. + public double NetFlowRatio { get; set; } + + /// == !channel.Initiator — true when the peer opened the channel. + public bool PeerInitiated { get; set; } + + public long? LastKnownNumUpdates { get; set; } + + /// Seconds; EXPERIMENTAL per LND. + public long? LastKnownLifetime { get; set; } + + /// Seconds; EXPERIMENTAL per LND, resets on restart. + public long? LastKnownUptime { get; set; } + + /// Set when a category flip commits. + public DateTimeOffset? LastCategorizedAt { get; set; } + + public DateTimeOffset LastEvaluatedAt { get; set; } +} diff --git a/src/Data/Models/Node.cs b/src/Data/Models/Node.cs index 8c969814..9e2ce20a 100644 --- a/src/Data/Models/Node.cs +++ b/src/Data/Models/Node.cs @@ -135,6 +135,67 @@ public class Node : Entity #endregion Automatic Swap Out Configuration + #region Routing Engine + + /// + /// Master gate for the Phase 2 dynamic fee engine on this node. Defaults OFF. + /// + public bool DynamicFeeManagementEnabled { get; set; } = false; + + /// + /// Master gate for the Phase 3 automated rebalancer on this node. Defaults OFF. + /// + public bool AutoRebalanceEnabled { get; set; } = false; + + /// + /// Allows the fee engine to set positive inbound fees on this node. Flipped to true + /// alongside at activation. Defaults OFF. + /// + public bool AllowPositiveInboundFees { get; set; } = false; + + /// + /// When flips off, restore each channel's + /// captured fee baseline in one final write. When false, last-set fees are frozen. + /// Defaults ON. + /// + public bool RestoreFeeBaselineOnDisable { get; set; } = true; + + /// + /// Per-node dry-run for the routing-engine actuators (Phase 2 fee engine, Phase 3 + /// rebalancer): when true they log the fee/rebalance they would perform without calling + /// LND. Lets an operator stage a node in dry-run and take it live one node at a time. + /// Defaults ON (safe). The global Constants.ROUTING_ENGINE_DRY_RUN is a master + /// override — an actuator writes for real only when both the global flag and this are off. + /// + public bool RoutingEngineDryRun { get; set; } = true; + + /// + /// Maximum sats spendable on rebalance fees over the budget refresh interval (Phase 3). + /// + public long? RebalanceBudgetSats { get; set; } + + /// + /// Time interval after which the rebalance budget is refreshed (Phase 3). + /// + public TimeSpan? RebalanceBudgetRefreshInterval { get; set; } + + /// + /// The datetime when the current rebalance budget period started (Phase 3). + /// + public DateTimeOffset? RebalanceBudgetStartDatetime { get; set; } + + /// + /// Maximum number of concurrent (Pending/InFlight) rebalances for this node (Phase 3). + /// + public int? MaxRebalancesInFlight { get; set; } + + /// + /// Maximum acceptable rebalance cost-to-earn ratio used by the profitability gate (Phase 3). + /// + public double? MaxRebalanceCostToEarnRatio { get; set; } + + #endregion Routing Engine + #region Relationships public ICollection ChannelOperationRequestsAsSource { get; set; } diff --git a/src/Data/Models/Rebalance.cs b/src/Data/Models/Rebalance.cs index f5d14db7..ca04e394 100644 --- a/src/Data/Models/Rebalance.cs +++ b/src/Data/Models/Rebalance.cs @@ -82,6 +82,12 @@ public class Rebalance : Entity public long? FeePaidMsat { get; set; } + /// + /// Fee reserved for this in-flight rebalance so its spend counts against the node budget + /// before settles (Phase 3 in-flight budget accounting). + /// + public long? ReservedFeeSats { get; set; } + [NotMapped] public long? EffectivePpm => FeePaidMsat.HasValue && SatsAmount > 0 ? FeePaidMsat.Value * 1_000L / SatsAmount diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index 4478b60f..2a875af7 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -206,6 +206,105 @@ public class Constants /// public static int REBALANCE_RECONCILE_TERMINAL_WINDOW_HOURS = 24; + // ── Routing Engine (heuristic routing-optimization engine) ────────────────────────── + // Phase 1 (foundation + signal) reads the constants in this first block. The Phase 2 + // fee-engine block below is declared now so the migration/config surface is touched once, + // but nothing reads it until Phase 2. + + /// + /// Global kill switch for the whole routing engine. Every routing-engine job checks this + /// first and returns immediately when false. Defaults OFF. Env: ROUTING_ENGINE_ENABLED. + /// + public static bool ROUTING_ENGINE_ENABLED = false; + + /// + /// Master dry-run gate for actuators (Phase 2+): when true, actuators log the fee/rebalance + /// they would perform without calling LND. No effect in Phase 1 (no writes). Defaults ON. + /// Env: ROUTING_ENGINE_DRY_RUN. + /// + public static bool ROUTING_ENGINE_DRY_RUN = true; + + /// + /// Age gate for categorization: channels younger than this many blocks stay Uncategorized + /// at target 0.5. Default 3024 = 21 block-days. Env: ROUTING_ENGINE_CATEGORIZATION_MIN_AGE_BLOCKS. + /// + public static uint ROUTING_ENGINE_CATEGORIZATION_MIN_AGE_BLOCKS = 3024; + + /// + /// Categorization + net-flow lookback window over ForwardingHtlcEvent, in days. Default 21. + /// Env: ROUTING_ENGINE_FLOW_WINDOW_DAYS. + /// + public static int ROUTING_ENGINE_FLOW_WINDOW_DAYS = 21; + + /// + /// Minimum total in-window flow (push+pull) in msat required to categorize a channel; below + /// this it stays / decays to Uncategorized. Default 10_000_000_000 (10M sat). + /// Env: ROUTING_ENGINE_FLOW_MIN_MSAT. + /// + public static long ROUTING_ENGINE_FLOW_MIN_MSAT = 10_000_000_000; + + /// + /// |NetFlowRatio| beyond this classifies a channel as Sink (positive) or Source (negative); + /// inside the band it is Bidirectional. Default 0.25. Env: ROUTING_ENGINE_CATEGORY_NET_FLOW_THRESHOLD. + /// + public static double ROUTING_ENGINE_CATEGORY_NET_FLOW_THRESHOLD = 0.25; + + /// + /// Proportional gain mapping net-flow to target drift: target_goal = 0.5 + clamp(K·netFlowRatio, ±maxDrift). + /// Default 0.70. Env: ROUTING_ENGINE_TARGET_K. + /// + public static double ROUTING_ENGINE_TARGET_K = 0.70; + + /// + /// Maximum drift of target_goal away from 0.5 (reached at |netFlowRatio| = 0.5). Default 0.35, + /// giving a target_goal range of [0.15, 0.85]. Env: ROUTING_ENGINE_TARGET_MAX_DRIFT. + /// + public static double ROUTING_ENGINE_TARGET_MAX_DRIFT = 0.35; + + /// + /// EWMA smoothing factor folding target_goal into the stored TargetLocalRatio. Default 0.10 + /// (~10 cycles to converge). Env: ROUTING_ENGINE_TARGET_ALPHA. + /// + public static double ROUTING_ENGINE_TARGET_ALPHA = 0.10; + + /// + /// EWMA smoothing factor for EmaLocalRatio (~24h effective window at 30-min sampling). + /// Default 0.04. Env: ROUTING_ENGINE_FEE_EMA_ALPHA. + /// + public static double ROUTING_ENGINE_FEE_EMA_ALPHA = 0.04; + + /// + /// Consecutive divergent cycles required before a category flip commits (anti-flap hysteresis). + /// Default 3. Env: ROUTING_ENGINE_CATEGORY_FLIP_HYSTERESIS_CYCLES. + /// + public static int ROUTING_ENGINE_CATEGORY_FLIP_HYSTERESIS_CYCLES = 3; + + /// + /// Cadence of TargetRatioReevaluationJob in prod, in minutes. Default 30. In dev + /// (IS_DEV_ENVIRONMENT) the job runs every 5 minutes regardless. Env: ROUTING_ENGINE_JOB_INTERVAL_MINUTES. + /// + public static int ROUTING_ENGINE_JOB_INTERVAL_MINUTES = 30; + + // ── Routing Engine — Phase 2 fee engine (declared now, unused in Phase 1) ──────────── + public static double ROUTING_ENGINE_FEE_KP_OUT = 0.8; + public static double ROUTING_ENGINE_FEE_KI = 0.5; + public static double ROUTING_ENGINE_FEE_DEADBAND = 0.03; + public static double ROUTING_ENGINE_REBALANCE_DEADBAND = 0.15; + public static uint ROUTING_ENGINE_FEE_MAX_STEP_PPM = 50; + public static uint ROUTING_ENGINE_FEE_MAX_INBOUND_STEP_PPM = 25; + public static uint ROUTING_ENGINE_FEE_MIN_DELTA_PPM = 5; + public static uint ROUTING_ENGINE_FEE_MIN_OUTBOUND_PPM = 0; + public static uint ROUTING_ENGINE_FEE_MAX_OUTBOUND_PPM = 5000; + public static int ROUTING_ENGINE_FEE_MIN_INBOUND_PPM = -250; + public static int ROUTING_ENGINE_FEE_MAX_INBOUND_PPM = 100; + public static int ROUTING_ENGINE_FEE_MIN_UPDATE_INTERVAL_MINUTES = 30; + public static int ROUTING_ENGINE_FEE_MAX_UPDATES_PER_RUN = 50; + public static int ROUTING_ENGINE_FEE_MAX_CONSECUTIVE_FAILURES = 5; + public static long ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS = 10_000_000; + public static uint ROUTING_ENGINE_FEE_BASELINE_PPM_SOURCE = 50; + public static uint ROUTING_ENGINE_FEE_BASELINE_PPM_BIDIRECTIONAL = 500; + public static uint ROUTING_ENGINE_FEE_BASELINE_PPM_SINK = 2500; + public const string IsFrozenTag = "frozen"; public const string IsManuallyFrozenTag = "manually_frozen"; @@ -443,6 +542,95 @@ static Constants() var rebReconcileWindow = Environment.GetEnvironmentVariable("REBALANCE_RECONCILE_TERMINAL_WINDOW_HOURS"); if (rebReconcileWindow != null) REBALANCE_RECONCILE_TERMINAL_WINDOW_HOURS = int.Parse(rebReconcileWindow); + // Routing Engine — Phase 1 + ROUTING_ENGINE_ENABLED = StringHelper.IsTrue(Environment.GetEnvironmentVariable("ROUTING_ENGINE_ENABLED")); + ROUTING_ENGINE_DRY_RUN = Environment.GetEnvironmentVariable("ROUTING_ENGINE_DRY_RUN") != "false"; // default true + + var reMinAgeBlocks = Environment.GetEnvironmentVariable("ROUTING_ENGINE_CATEGORIZATION_MIN_AGE_BLOCKS"); + if (reMinAgeBlocks != null) ROUTING_ENGINE_CATEGORIZATION_MIN_AGE_BLOCKS = uint.Parse(reMinAgeBlocks); + + var reFlowWindowDays = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FLOW_WINDOW_DAYS"); + if (reFlowWindowDays != null) ROUTING_ENGINE_FLOW_WINDOW_DAYS = int.Parse(reFlowWindowDays); + + var reFlowMinMsat = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FLOW_MIN_MSAT"); + if (reFlowMinMsat != null) ROUTING_ENGINE_FLOW_MIN_MSAT = long.Parse(reFlowMinMsat); + + var reNetFlowThreshold = Environment.GetEnvironmentVariable("ROUTING_ENGINE_CATEGORY_NET_FLOW_THRESHOLD"); + if (reNetFlowThreshold != null) ROUTING_ENGINE_CATEGORY_NET_FLOW_THRESHOLD = double.Parse(reNetFlowThreshold, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); + + var reTargetK = Environment.GetEnvironmentVariable("ROUTING_ENGINE_TARGET_K"); + if (reTargetK != null) ROUTING_ENGINE_TARGET_K = double.Parse(reTargetK, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); + + var reTargetMaxDrift = Environment.GetEnvironmentVariable("ROUTING_ENGINE_TARGET_MAX_DRIFT"); + if (reTargetMaxDrift != null) ROUTING_ENGINE_TARGET_MAX_DRIFT = double.Parse(reTargetMaxDrift, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); + + var reTargetAlpha = Environment.GetEnvironmentVariable("ROUTING_ENGINE_TARGET_ALPHA"); + if (reTargetAlpha != null) ROUTING_ENGINE_TARGET_ALPHA = double.Parse(reTargetAlpha, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); + + var reEmaAlpha = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_EMA_ALPHA"); + if (reEmaAlpha != null) ROUTING_ENGINE_FEE_EMA_ALPHA = double.Parse(reEmaAlpha, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); + + var reFlipHysteresis = Environment.GetEnvironmentVariable("ROUTING_ENGINE_CATEGORY_FLIP_HYSTERESIS_CYCLES"); + if (reFlipHysteresis != null) ROUTING_ENGINE_CATEGORY_FLIP_HYSTERESIS_CYCLES = int.Parse(reFlipHysteresis); + + var reJobInterval = Environment.GetEnvironmentVariable("ROUTING_ENGINE_JOB_INTERVAL_MINUTES"); + if (reJobInterval != null) ROUTING_ENGINE_JOB_INTERVAL_MINUTES = int.Parse(reJobInterval); + + // Routing Engine — Phase 2 fee engine (declared now, unused in Phase 1) + var feeKpOut = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_KP_OUT"); + if (feeKpOut != null) ROUTING_ENGINE_FEE_KP_OUT = double.Parse(feeKpOut, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); + + var feeKi = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_KI"); + if (feeKi != null) ROUTING_ENGINE_FEE_KI = double.Parse(feeKi, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); + + var feeDeadband = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_DEADBAND"); + 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_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); + + var feeMaxInboundStep = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_MAX_INBOUND_STEP_PPM"); + if (feeMaxInboundStep != null) ROUTING_ENGINE_FEE_MAX_INBOUND_STEP_PPM = uint.Parse(feeMaxInboundStep); + + var feeMinDelta = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_MIN_DELTA_PPM"); + if (feeMinDelta != null) ROUTING_ENGINE_FEE_MIN_DELTA_PPM = uint.Parse(feeMinDelta); + + var feeMinOutbound = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_MIN_OUTBOUND_PPM"); + if (feeMinOutbound != null) ROUTING_ENGINE_FEE_MIN_OUTBOUND_PPM = uint.Parse(feeMinOutbound); + + var feeMaxOutbound = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_MAX_OUTBOUND_PPM"); + if (feeMaxOutbound != null) ROUTING_ENGINE_FEE_MAX_OUTBOUND_PPM = uint.Parse(feeMaxOutbound); + + var feeMinInbound = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_MIN_INBOUND_PPM"); + if (feeMinInbound != null) ROUTING_ENGINE_FEE_MIN_INBOUND_PPM = int.Parse(feeMinInbound); + + var feeMaxInbound = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_MAX_INBOUND_PPM"); + if (feeMaxInbound != null) ROUTING_ENGINE_FEE_MAX_INBOUND_PPM = int.Parse(feeMaxInbound); + + var feeMinUpdateInterval = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_MIN_UPDATE_INTERVAL_MINUTES"); + if (feeMinUpdateInterval != null) ROUTING_ENGINE_FEE_MIN_UPDATE_INTERVAL_MINUTES = int.Parse(feeMinUpdateInterval); + + var feeMaxUpdatesPerRun = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_MAX_UPDATES_PER_RUN"); + if (feeMaxUpdatesPerRun != null) ROUTING_ENGINE_FEE_MAX_UPDATES_PER_RUN = int.Parse(feeMaxUpdatesPerRun); + + var feeMaxConsecutiveFailures = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_MAX_CONSECUTIVE_FAILURES"); + if (feeMaxConsecutiveFailures != null) ROUTING_ENGINE_FEE_MAX_CONSECUTIVE_FAILURES = int.Parse(feeMaxConsecutiveFailures); + + var feeMinChannelSize = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS"); + if (feeMinChannelSize != null) ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS = long.Parse(feeMinChannelSize); + + var feeBaselineSource = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_BASELINE_PPM_SOURCE"); + if (feeBaselineSource != null) ROUTING_ENGINE_FEE_BASELINE_PPM_SOURCE = uint.Parse(feeBaselineSource); + + var feeBaselineBidirectional = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_BASELINE_PPM_BIDIRECTIONAL"); + if (feeBaselineBidirectional != null) ROUTING_ENGINE_FEE_BASELINE_PPM_BIDIRECTIONAL = uint.Parse(feeBaselineBidirectional); + + var feeBaselineSink = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_BASELINE_PPM_SINK"); + if (feeBaselineSink != null) ROUTING_ENGINE_FEE_BASELINE_PPM_SINK = uint.Parse(feeBaselineSink); + // DB Initialization ALICE_PUBKEY = Environment.GetEnvironmentVariable("ALICE_PUBKEY") ?? ALICE_PUBKEY; ALICE_HOST = Environment.GetEnvironmentVariable("ALICE_HOST") ?? ALICE_HOST; diff --git a/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs b/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs new file mode 100644 index 00000000..400964b4 --- /dev/null +++ b/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs @@ -0,0 +1,1947 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodeGuard.Data; +using NodeGuard.Helpers; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NodeGuard.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260707155251_AddRoutingEngineFoundation")] + partial class AddRoutingEngineFoundation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationUserNode", b => + { + b.Property("NodesId") + .HasColumnType("integer"); + + b.Property("UsersId") + .HasColumnType("text"); + + b.HasKey("NodesId", "UsersId"); + + b.HasIndex("UsersId"); + + b.ToTable("ApplicationUserNode"); + }); + + modelBuilder.Entity("ChannelOperationRequestFMUTXO", b => + { + b.Property("ChannelOperationRequestsId") + .HasColumnType("integer"); + + b.Property("UtxosId") + .HasColumnType("integer"); + + b.HasKey("ChannelOperationRequestsId", "UtxosId"); + + b.HasIndex("UtxosId"); + + b.ToTable("ChannelOperationRequestFMUTXO"); + }); + + modelBuilder.Entity("FMUTXOWalletWithdrawalRequest", b => + { + b.Property("UTXOsId") + .HasColumnType("integer"); + + b.Property("WalletWithdrawalRequestsId") + .HasColumnType("integer"); + + b.HasKey("UTXOsId", "WalletWithdrawalRequestsId"); + + b.HasIndex("WalletWithdrawalRequestsId"); + + b.ToTable("FMUTXOWalletWithdrawalRequest"); + }); + + modelBuilder.Entity("KeyWallet", b => + { + b.Property("KeysId") + .HasColumnType("integer"); + + b.Property("WalletsId") + .HasColumnType("integer"); + + b.HasKey("KeysId", "WalletsId"); + + b.HasIndex("WalletsId"); + + b.ToTable("KeyWallet"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("character varying(21)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + + b.HasDiscriminator().HasValue("IdentityUser"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.APIToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionType") + .HasColumnType("integer"); + + b.Property("Details") + .HasColumnType("text"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("ObjectAffected") + .HasColumnType("integer"); + + b.Property("ObjectId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Username") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BtcCloseAddress") + .HasColumnType("text"); + + b.Property("ChanId") + .HasColumnType("numeric(20,0)"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByNodeGuard") + .HasColumnType("boolean"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DestinationNodeId") + .HasColumnType("integer"); + + b.Property("FundingTx") + .IsRequired() + .HasColumnType("text"); + + b.Property("FundingTxOutputIndex") + .HasColumnType("bigint"); + + b.Property("IsAutomatedLiquidityEnabled") + .HasColumnType("boolean"); + + b.Property("IsDynamicFeeEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsPrivate") + .HasColumnType("boolean"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceNodeId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DestinationNodeId"); + + b.HasIndex("SourceNodeId"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelFeeState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BaselineCapturedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("BaselineInboundBaseMsat") + .HasColumnType("integer"); + + b.Property("BaselineInboundPpm") + .HasColumnType("integer"); + + b.Property("BaselineOutboundBaseFeeMsat") + .HasColumnType("bigint"); + + b.Property("BaselineOutboundPpm") + .HasColumnType("bigint"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAppliedInboundBaseMsat") + .HasColumnType("integer"); + + b.Property("LastAppliedInboundPpm") + .HasColumnType("integer"); + + b.Property("LastAppliedOutboundBaseFeeMsat") + .HasColumnType("bigint"); + + b.Property("LastAppliedOutboundPpm") + .HasColumnType("bigint"); + + b.Property("LastComputedTarget") + .HasColumnType("double precision"); + + b.Property("LastFeeUpdateAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastObservedRatio") + .HasColumnType("double precision"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId") + .IsUnique(); + + b.ToTable("ChannelFeeStates"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountCryptoUnit") + .HasColumnType("integer"); + + b.Property("Changeless") + .HasColumnType("boolean"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("ClosingReason") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DestNodeId") + .HasColumnType("integer"); + + b.Property("FeeRate") + .HasColumnType("numeric"); + + b.Property("InitialChannelBaseFeeMsat") + .HasColumnType("bigint"); + + b.Property("InitialChannelFeeRatePpm") + .HasColumnType("bigint"); + + b.Property("IsChannelPrivate") + .HasColumnType("boolean"); + + b.Property("MempoolRecommendedFeesType") + .HasColumnType("integer"); + + b.Property("RequestType") + .HasColumnType("integer"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceNodeId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property>("StatusLogs") + .HasColumnType("jsonb"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("WalletId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("DestNodeId"); + + b.HasIndex("SourceNodeId"); + + b.HasIndex("UserId"); + + b.HasIndex("WalletId"); + + b.ToTable("ChannelOperationRequests"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequestPSBT", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelOperationRequestId") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsFinalisedPSBT") + .HasColumnType("boolean"); + + b.Property("IsInternalWalletPSBT") + .HasColumnType("boolean"); + + b.Property("IsTemplatePSBT") + .HasColumnType("boolean"); + + b.Property("PSBT") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserSignerId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ChannelOperationRequestId"); + + b.HasIndex("UserSignerId"); + + b.ToTable("ChannelOperationRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelRoutingState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgeBlocks") + .HasColumnType("bigint"); + + b.Property("ChanIdLnd") + .HasColumnType("numeric(20,0)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("ConsecutiveCategoryCyclesInNewState") + .HasColumnType("bigint"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("EmaLocalRatio") + .HasColumnType("double precision"); + + b.Property("FundingBlockHeight") + .HasColumnType("bigint"); + + b.Property("LastCategorizedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastEvaluatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKnownLifetime") + .HasColumnType("bigint"); + + b.Property("LastKnownNumUpdates") + .HasColumnType("bigint"); + + b.Property("LastKnownUptime") + .HasColumnType("bigint"); + + b.Property("ManagedNodePubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("NetFlowRatio") + .HasColumnType("double precision"); + + b.Property("PeerFlowCategory") + .HasColumnType("integer"); + + b.Property("PeerInitiated") + .HasColumnType("boolean"); + + b.Property("PendingCategory") + .HasColumnType("integer"); + + b.Property("PullMsatWindow") + .HasColumnType("bigint"); + + b.Property("PushMsatWindow") + .HasColumnType("bigint"); + + b.Property("TargetLocalRatio") + .HasColumnType("double precision"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId") + .IsUnique(); + + b.ToTable("ChannelRoutingStates"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.FMUTXO", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("OutputIndex") + .HasColumnType("bigint"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("TxId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("FMUTXOs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ForwardingHtlcEvent", b => + { + b.Property("ManagedNodePubKey") + .HasColumnType("text"); + + b.Property("IncomingChannelId") + .HasColumnType("numeric(20,0)"); + + b.Property("OutgoingChannelId") + .HasColumnType("numeric(20,0)"); + + b.Property("IncomingHtlcId") + .HasColumnType("numeric(20,0)"); + + b.Property("OutgoingHtlcId") + .HasColumnType("numeric(20,0)"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("EventCase") + .HasColumnType("integer"); + + b.Property("EventTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("FailureDetail") + .HasColumnType("integer"); + + b.Property("FailureString") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FeeMsat") + .HasColumnType("bigint"); + + b.Property("GrossFeeMsat") + .HasColumnType("bigint"); + + b.Property("InboundFeeMsat") + .HasColumnType("bigint"); + + b.Property("InboundFeePpm") + .HasColumnType("bigint"); + + b.Property("IncomingAmountMsat") + .HasColumnType("numeric(20,0)"); + + b.Property("IncomingPeerAlias") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IncomingTimelock") + .HasColumnType("bigint"); + + b.Property("ManagedNodeName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Outcome") + .HasColumnType("integer"); + + b.Property("OutgoingAmountMsat") + .HasColumnType("numeric(20,0)"); + + b.Property("OutgoingPeerAlias") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OutgoingTimelock") + .HasColumnType("bigint"); + + b.Property("RoutingFeePpm") + .HasColumnType("bigint"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WireFailureCode") + .HasColumnType("integer"); + + b.HasKey("ManagedNodePubKey", "IncomingChannelId", "OutgoingChannelId", "IncomingHtlcId", "OutgoingHtlcId"); + + b.HasIndex("CreationDatetime"); + + b.HasIndex("EventTimestamp"); + + b.ToTable("ForwardingHtlcEvents"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.InternalWallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DerivationPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("MasterFingerprint") + .HasColumnType("text"); + + b.Property("MnemonicString") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("XPUB") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("InternalWallets"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Key", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("InternalWalletId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsBIP39ImportedKey") + .HasColumnType("boolean"); + + b.Property("IsCompromised") + .HasColumnType("boolean"); + + b.Property("MasterFingerprint") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Path") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("XPUB") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InternalWalletId"); + + b.HasIndex("UserId"); + + b.ToTable("Keys"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.LiquidityRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsReverseSwapWalletRule") + .HasColumnType("boolean"); + + b.Property("MinimumLocalBalance") + .HasColumnType("numeric"); + + b.Property("MinimumRemoteBalance") + .HasColumnType("numeric"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("RebalanceTarget") + .HasColumnType("numeric"); + + b.Property("ReverseSwapAddress") + .HasColumnType("text"); + + b.Property("ReverseSwapWalletId") + .HasColumnType("integer"); + + b.Property("SwapWalletId") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId") + .IsUnique(); + + b.HasIndex("NodeId"); + + b.HasIndex("ReverseSwapWalletId"); + + b.HasIndex("SwapWalletId"); + + b.ToTable("LiquidityRules"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowPositiveInboundFees") + .HasColumnType("boolean"); + + b.Property("AutoLiquidityManagementEnabled") + .HasColumnType("boolean"); + + b.Property("AutoRebalanceEnabled") + .HasColumnType("boolean"); + + b.Property("AutosweepEnabled") + .HasColumnType("boolean"); + + b.Property("ChannelAdminMacaroon") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DynamicFeeManagementEnabled") + .HasColumnType("boolean"); + + b.Property("Endpoint") + .HasColumnType("text"); + + b.Property("FortySwapEndpoint") + .HasColumnType("text"); + + b.Property("FortySwapWeight") + .HasColumnType("integer"); + + b.Property("FundsDestinationWalletId") + .HasColumnType("integer"); + + b.Property("IsNodeDisabled") + .HasColumnType("boolean"); + + b.Property("LoopSwapWeight") + .HasColumnType("integer"); + + b.Property("LoopdCert") + .HasColumnType("text"); + + b.Property("LoopdEndpoint") + .HasColumnType("text"); + + b.Property("LoopdMacaroon") + .HasColumnType("text"); + + b.Property("MaxRebalanceCostToEarnRatio") + .HasColumnType("double precision"); + + b.Property("MaxRebalancesInFlight") + .HasColumnType("integer"); + + b.Property("MaxSwapRoutingFeeRatio") + .HasColumnType("numeric"); + + b.Property("MaxSwapsInFlight") + .HasColumnType("integer"); + + b.Property("MinimumBalanceThresholdSats") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("RebalanceBudgetRefreshInterval") + .HasColumnType("interval"); + + b.Property("RebalanceBudgetSats") + .HasColumnType("bigint"); + + b.Property("RebalanceBudgetStartDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("RestoreFeeBaselineOnDisable") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("RoutingEngineDryRun") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("SwapBudgetRefreshInterval") + .HasColumnType("interval"); + + b.Property("SwapBudgetSats") + .HasColumnType("bigint"); + + b.Property("SwapBudgetStartDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("SwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("SwapMinAmountSats") + .HasColumnType("bigint"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FundsDestinationWalletId"); + + b.HasIndex("PubKey") + .IsUnique(); + + b.ToTable("Nodes"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Rebalance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountBackoffRatio") + .HasColumnType("double precision"); + + b.Property("AttemptNumber") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("FeePaidMsat") + .HasColumnType("bigint"); + + b.Property("FeePaidSats") + .HasColumnType("bigint"); + + b.Property("IsManual") + .HasColumnType("boolean"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("MaxFeePct") + .HasColumnType("double precision"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("PaymentHashHex") + .HasColumnType("text"); + + b.Property("PaymentRequest") + .HasColumnType("text"); + + b.Property("PreimageHex") + .HasColumnType("text"); + + b.Property("RequestedAmountSats") + .HasColumnType("bigint"); + + b.Property("ReservedFeeSats") + .HasColumnType("bigint"); + + b.Property("RetryMaxFeePct") + .HasColumnType("double precision"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceChanIdLnd") + .HasColumnType("numeric(20,0)"); + + b.Property("SourceChannelId") + .HasColumnType("integer"); + + b.Property("SourceNodePubKey") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TargetPubkey") + .HasColumnType("text"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("NodeId"); + + b.HasIndex("SourceChannelId"); + + b.HasIndex("UserRequestorId"); + + b.ToTable("Rebalances"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.SwapOut", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DestinationWalletId") + .HasColumnType("integer"); + + b.Property("ErrorDetails") + .HasColumnType("text"); + + b.Property("IsManual") + .HasColumnType("boolean"); + + b.Property("LightningFeeSats") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("OnChainFeeSats") + .HasColumnType("bigint"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("ServiceFeeSats") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DestinationWalletId"); + + b.HasIndex("NodeId"); + + b.HasIndex("UserRequestorId"); + + b.ToTable("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.UTXOTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Outpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key", "Outpoint") + .IsUnique(); + + b.ToTable("UTXOTags"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BIP39Seedphrase") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ImportedOutputDescriptor") + .HasColumnType("text"); + + b.Property("InternalWalletId") + .HasColumnType("integer"); + + b.Property("InternalWalletMasterFingerprint") + .HasColumnType("text"); + + b.Property("InternalWalletSubDerivationPath") + .HasColumnType("text"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsBIP39Imported") + .HasColumnType("boolean"); + + b.Property("IsCompromised") + .HasColumnType("boolean"); + + b.Property("IsFinalised") + .HasColumnType("boolean"); + + b.Property("IsHotWallet") + .HasColumnType("boolean"); + + b.Property("IsUnSortedMultiSig") + .HasColumnType("boolean"); + + b.Property("MofN") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletAddressType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("InternalWalletId"); + + b.HasIndex("InternalWalletSubDerivationPath", "InternalWalletMasterFingerprint") + .IsUnique(); + + b.ToTable("Wallets"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BumpingWalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.Property("Changeless") + .HasColumnType("boolean"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomFeeRate") + .HasColumnType("numeric"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("MempoolRecommendedFeesType") + .HasColumnType("integer"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("RejectCancelDescription") + .HasColumnType("text"); + + b.Property("RequestMetadata") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.Property("WalletId") + .HasColumnType("integer"); + + b.Property("WithdrawAllFunds") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("BumpingWalletWithdrawalRequestId"); + + b.HasIndex("UserRequestorId"); + + b.HasIndex("WalletId"); + + b.ToTable("WalletWithdrawalRequests"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestDestination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("WalletWithdrawalRequestId"); + + b.ToTable("WalletWithdrawalRequestDestinations"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestPSBT", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsFinalisedPSBT") + .HasColumnType("boolean"); + + b.Property("IsInternalWalletPSBT") + .HasColumnType("boolean"); + + b.Property("IsTemplatePSBT") + .HasColumnType("boolean"); + + b.Property("PSBT") + .IsRequired() + .HasColumnType("text"); + + b.Property("SignerId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SignerId"); + + b.HasIndex("WalletWithdrawalRequestId"); + + b.ToTable("WalletWithdrawalRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ApplicationUser", b => + { + b.HasBaseType("Microsoft.AspNetCore.Identity.IdentityUser"); + + b.HasDiscriminator().HasValue("ApplicationUser"); + }); + + modelBuilder.Entity("ApplicationUserNode", b => + { + b.HasOne("NodeGuard.Data.Models.Node", null) + .WithMany() + .HasForeignKey("NodesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChannelOperationRequestFMUTXO", b => + { + b.HasOne("NodeGuard.Data.Models.ChannelOperationRequest", null) + .WithMany() + .HasForeignKey("ChannelOperationRequestsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.FMUTXO", null) + .WithMany() + .HasForeignKey("UtxosId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FMUTXOWalletWithdrawalRequest", b => + { + b.HasOne("NodeGuard.Data.Models.FMUTXO", null) + .WithMany() + .HasForeignKey("UTXOsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", null) + .WithMany() + .HasForeignKey("WalletWithdrawalRequestsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("KeyWallet", b => + { + b.HasOne("NodeGuard.Data.Models.Key", null) + .WithMany() + .HasForeignKey("KeysId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Wallet", null) + .WithMany() + .HasForeignKey("WalletsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.APIToken", b => + { + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.HasOne("NodeGuard.Data.Models.Node", "DestinationNode") + .WithMany() + .HasForeignKey("DestinationNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Node", "SourceNode") + .WithMany() + .HasForeignKey("SourceNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DestinationNode"); + + b.Navigation("SourceNode"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelFeeState", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithOne() + .HasForeignKey("NodeGuard.Data.Models.ChannelFeeState", "ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithMany("ChannelOperationRequests") + .HasForeignKey("ChannelId"); + + b.HasOne("NodeGuard.Data.Models.Node", "DestNode") + .WithMany("ChannelOperationRequestsAsDestination") + .HasForeignKey("DestNodeId"); + + b.HasOne("NodeGuard.Data.Models.Node", "SourceNode") + .WithMany("ChannelOperationRequestsAsSource") + .HasForeignKey("SourceNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "User") + .WithMany("ChannelOperationRequests") + .HasForeignKey("UserId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "Wallet") + .WithMany("ChannelOperationRequestsAsSource") + .HasForeignKey("WalletId"); + + b.Navigation("Channel"); + + b.Navigation("DestNode"); + + b.Navigation("SourceNode"); + + b.Navigation("User"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequestPSBT", b => + { + b.HasOne("NodeGuard.Data.Models.ChannelOperationRequest", "ChannelOperationRequest") + .WithMany("ChannelOperationRequestPsbts") + .HasForeignKey("ChannelOperationRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserSigner") + .WithMany() + .HasForeignKey("UserSignerId"); + + b.Navigation("ChannelOperationRequest"); + + b.Navigation("UserSigner"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelRoutingState", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithOne() + .HasForeignKey("NodeGuard.Data.Models.ChannelRoutingState", "ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Key", b => + { + b.HasOne("NodeGuard.Data.Models.InternalWallet", "InternalWallet") + .WithMany() + .HasForeignKey("InternalWalletId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "User") + .WithMany("Keys") + .HasForeignKey("UserId"); + + b.Navigation("InternalWallet"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.LiquidityRule", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithMany("LiquidityRules") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Wallet", "ReverseSwapWallet") + .WithMany("LiquidityRulesAsReverseSwapWallet") + .HasForeignKey("ReverseSwapWalletId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "SwapWallet") + .WithMany("LiquidityRulesAsSwapWallet") + .HasForeignKey("SwapWalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + + b.Navigation("Node"); + + b.Navigation("ReverseSwapWallet"); + + b.Navigation("SwapWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.HasOne("NodeGuard.Data.Models.Wallet", "FundsDestinationWallet") + .WithMany() + .HasForeignKey("FundsDestinationWalletId"); + + b.Navigation("FundsDestinationWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Rebalance", b => + { + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Channel", "SourceChannel") + .WithMany() + .HasForeignKey("SourceChannelId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany() + .HasForeignKey("UserRequestorId"); + + b.Navigation("Node"); + + b.Navigation("SourceChannel"); + + b.Navigation("UserRequestor"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.SwapOut", b => + { + b.HasOne("NodeGuard.Data.Models.Wallet", "DestinationWallet") + .WithMany("SwapOuts") + .HasForeignKey("DestinationWalletId"); + + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany("SwapOuts") + .HasForeignKey("NodeId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany() + .HasForeignKey("UserRequestorId"); + + b.Navigation("DestinationWallet"); + + b.Navigation("Node"); + + b.Navigation("UserRequestor"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.HasOne("NodeGuard.Data.Models.InternalWallet", "InternalWallet") + .WithMany() + .HasForeignKey("InternalWalletId"); + + b.Navigation("InternalWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "BumpingWalletWithdrawalRequest") + .WithMany() + .HasForeignKey("BumpingWalletWithdrawalRequestId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany("WalletWithdrawalRequests") + .HasForeignKey("UserRequestorId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BumpingWalletWithdrawalRequest"); + + b.Navigation("UserRequestor"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestDestination", b => + { + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "WalletWithdrawalRequest") + .WithMany("WalletWithdrawalRequestDestinations") + .HasForeignKey("WalletWithdrawalRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WalletWithdrawalRequest"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestPSBT", b => + { + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "Signer") + .WithMany() + .HasForeignKey("SignerId"); + + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "WalletWithdrawalRequest") + .WithMany("WalletWithdrawalRequestPSBTs") + .HasForeignKey("WalletWithdrawalRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Signer"); + + b.Navigation("WalletWithdrawalRequest"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.Navigation("ChannelOperationRequests"); + + b.Navigation("LiquidityRules"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.Navigation("ChannelOperationRequestPsbts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.Navigation("ChannelOperationRequestsAsDestination"); + + b.Navigation("ChannelOperationRequestsAsSource"); + + b.Navigation("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.Navigation("ChannelOperationRequestsAsSource"); + + b.Navigation("LiquidityRulesAsReverseSwapWallet"); + + b.Navigation("LiquidityRulesAsSwapWallet"); + + b.Navigation("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.Navigation("WalletWithdrawalRequestDestinations"); + + b.Navigation("WalletWithdrawalRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ApplicationUser", b => + { + b.Navigation("ChannelOperationRequests"); + + b.Navigation("Keys"); + + b.Navigation("WalletWithdrawalRequests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs b/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs new file mode 100644 index 00000000..272475a0 --- /dev/null +++ b/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs @@ -0,0 +1,237 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NodeGuard.Migrations +{ + /// + public partial class AddRoutingEngineFoundation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ReservedFeeSats", + table: "Rebalances", + type: "bigint", + nullable: true); + + migrationBuilder.AddColumn( + name: "AllowPositiveInboundFees", + table: "Nodes", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "AutoRebalanceEnabled", + table: "Nodes", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DynamicFeeManagementEnabled", + table: "Nodes", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "MaxRebalanceCostToEarnRatio", + table: "Nodes", + type: "double precision", + nullable: true); + + migrationBuilder.AddColumn( + name: "MaxRebalancesInFlight", + table: "Nodes", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "RebalanceBudgetRefreshInterval", + table: "Nodes", + type: "interval", + nullable: true); + + migrationBuilder.AddColumn( + name: "RebalanceBudgetSats", + table: "Nodes", + type: "bigint", + nullable: true); + + migrationBuilder.AddColumn( + name: "RebalanceBudgetStartDatetime", + table: "Nodes", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "RestoreFeeBaselineOnDisable", + table: "Nodes", + type: "boolean", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "RoutingEngineDryRun", + table: "Nodes", + type: "boolean", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "IsDynamicFeeEnabled", + table: "Channels", + type: "boolean", + nullable: false, + defaultValue: true); + + migrationBuilder.CreateTable( + name: "ChannelFeeStates", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ChannelId = table.Column(type: "integer", nullable: false), + LastFeeUpdateAt = table.Column(type: "timestamp with time zone", nullable: true), + LastAppliedOutboundBaseFeeMsat = table.Column(type: "bigint", nullable: true), + LastAppliedOutboundPpm = table.Column(type: "bigint", nullable: true), + LastAppliedInboundBaseMsat = table.Column(type: "integer", nullable: true), + LastAppliedInboundPpm = table.Column(type: "integer", nullable: true), + LastComputedTarget = table.Column(type: "double precision", nullable: true), + LastObservedRatio = table.Column(type: "double precision", nullable: true), + BaselineCapturedAt = table.Column(type: "timestamp with time zone", nullable: true), + BaselineOutboundBaseFeeMsat = table.Column(type: "bigint", nullable: true), + BaselineOutboundPpm = table.Column(type: "bigint", nullable: true), + BaselineInboundBaseMsat = table.Column(type: "integer", nullable: true), + BaselineInboundPpm = table.Column(type: "integer", nullable: true), + ConsecutiveFailures = table.Column(type: "integer", nullable: false), + CreationDatetime = table.Column(type: "timestamp with time zone", nullable: false), + UpdateDatetime = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChannelFeeStates", x => x.Id); + table.ForeignKey( + name: "FK_ChannelFeeStates_Channels_ChannelId", + column: x => x.ChannelId, + principalTable: "Channels", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ChannelRoutingStates", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ChannelId = table.Column(type: "integer", nullable: false), + ChanIdLnd = table.Column(type: "numeric(20,0)", nullable: false), + ManagedNodePubKey = table.Column(type: "text", nullable: false), + TargetLocalRatio = table.Column(type: "double precision", nullable: false), + PeerFlowCategory = table.Column(type: "integer", nullable: false), + PendingCategory = table.Column(type: "integer", nullable: true), + ConsecutiveCategoryCyclesInNewState = table.Column(type: "bigint", nullable: false), + FundingBlockHeight = table.Column(type: "bigint", nullable: true), + AgeBlocks = table.Column(type: "bigint", nullable: true), + EmaLocalRatio = table.Column(type: "double precision", nullable: false), + PushMsatWindow = table.Column(type: "bigint", nullable: false), + PullMsatWindow = table.Column(type: "bigint", nullable: false), + NetFlowRatio = table.Column(type: "double precision", nullable: false), + PeerInitiated = table.Column(type: "boolean", nullable: false), + LastKnownNumUpdates = table.Column(type: "bigint", nullable: true), + LastKnownLifetime = table.Column(type: "bigint", nullable: true), + LastKnownUptime = table.Column(type: "bigint", nullable: true), + LastCategorizedAt = table.Column(type: "timestamp with time zone", nullable: true), + LastEvaluatedAt = table.Column(type: "timestamp with time zone", nullable: false), + CreationDatetime = table.Column(type: "timestamp with time zone", nullable: false), + UpdateDatetime = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChannelRoutingStates", x => x.Id); + table.ForeignKey( + name: "FK_ChannelRoutingStates_Channels_ChannelId", + column: x => x.ChannelId, + principalTable: "Channels", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ChannelFeeStates_ChannelId", + table: "ChannelFeeStates", + column: "ChannelId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ChannelRoutingStates_ChannelId", + table: "ChannelRoutingStates", + column: "ChannelId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChannelFeeStates"); + + migrationBuilder.DropTable( + name: "ChannelRoutingStates"); + + migrationBuilder.DropColumn( + name: "ReservedFeeSats", + table: "Rebalances"); + + migrationBuilder.DropColumn( + name: "AllowPositiveInboundFees", + table: "Nodes"); + + migrationBuilder.DropColumn( + name: "AutoRebalanceEnabled", + table: "Nodes"); + + migrationBuilder.DropColumn( + name: "DynamicFeeManagementEnabled", + table: "Nodes"); + + migrationBuilder.DropColumn( + name: "MaxRebalanceCostToEarnRatio", + table: "Nodes"); + + migrationBuilder.DropColumn( + name: "MaxRebalancesInFlight", + table: "Nodes"); + + migrationBuilder.DropColumn( + name: "RebalanceBudgetRefreshInterval", + table: "Nodes"); + + migrationBuilder.DropColumn( + name: "RebalanceBudgetSats", + table: "Nodes"); + + migrationBuilder.DropColumn( + name: "RebalanceBudgetStartDatetime", + table: "Nodes"); + + migrationBuilder.DropColumn( + name: "RestoreFeeBaselineOnDisable", + table: "Nodes"); + + migrationBuilder.DropColumn( + name: "RoutingEngineDryRun", + table: "Nodes"); + + migrationBuilder.DropColumn( + name: "IsDynamicFeeEnabled", + table: "Channels"); + } + } +} diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index bcba49cf..3975e822 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -412,6 +412,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsAutomatedLiquidityEnabled") .HasColumnType("boolean"); + b.Property("IsDynamicFeeEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + b.Property("IsPrivate") .HasColumnType("boolean"); @@ -436,6 +441,70 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Channels"); }); + modelBuilder.Entity("NodeGuard.Data.Models.ChannelFeeState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BaselineCapturedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("BaselineInboundBaseMsat") + .HasColumnType("integer"); + + b.Property("BaselineInboundPpm") + .HasColumnType("integer"); + + b.Property("BaselineOutboundBaseFeeMsat") + .HasColumnType("bigint"); + + b.Property("BaselineOutboundPpm") + .HasColumnType("bigint"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAppliedInboundBaseMsat") + .HasColumnType("integer"); + + b.Property("LastAppliedInboundPpm") + .HasColumnType("integer"); + + b.Property("LastAppliedOutboundBaseFeeMsat") + .HasColumnType("bigint"); + + b.Property("LastAppliedOutboundPpm") + .HasColumnType("bigint"); + + b.Property("LastComputedTarget") + .HasColumnType("double precision"); + + b.Property("LastFeeUpdateAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastObservedRatio") + .HasColumnType("double precision"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId") + .IsUnique(); + + b.ToTable("ChannelFeeStates"); + }); + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => { b.Property("Id") @@ -564,6 +633,86 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ChannelOperationRequestPSBTs"); }); + modelBuilder.Entity("NodeGuard.Data.Models.ChannelRoutingState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgeBlocks") + .HasColumnType("bigint"); + + b.Property("ChanIdLnd") + .HasColumnType("numeric(20,0)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("ConsecutiveCategoryCyclesInNewState") + .HasColumnType("bigint"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("EmaLocalRatio") + .HasColumnType("double precision"); + + b.Property("FundingBlockHeight") + .HasColumnType("bigint"); + + b.Property("LastCategorizedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastEvaluatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKnownLifetime") + .HasColumnType("bigint"); + + b.Property("LastKnownNumUpdates") + .HasColumnType("bigint"); + + b.Property("LastKnownUptime") + .HasColumnType("bigint"); + + b.Property("ManagedNodePubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("NetFlowRatio") + .HasColumnType("double precision"); + + b.Property("PeerFlowCategory") + .HasColumnType("integer"); + + b.Property("PeerInitiated") + .HasColumnType("boolean"); + + b.Property("PendingCategory") + .HasColumnType("integer"); + + b.Property("PullMsatWindow") + .HasColumnType("bigint"); + + b.Property("PushMsatWindow") + .HasColumnType("bigint"); + + b.Property("TargetLocalRatio") + .HasColumnType("double precision"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId") + .IsUnique(); + + b.ToTable("ChannelRoutingStates"); + }); + modelBuilder.Entity("NodeGuard.Data.Models.FMUTXO", b => { b.Property("Id") @@ -837,9 +986,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("AllowPositiveInboundFees") + .HasColumnType("boolean"); + b.Property("AutoLiquidityManagementEnabled") .HasColumnType("boolean"); + b.Property("AutoRebalanceEnabled") + .HasColumnType("boolean"); + b.Property("AutosweepEnabled") .HasColumnType("boolean"); @@ -852,6 +1007,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Description") .HasColumnType("text"); + b.Property("DynamicFeeManagementEnabled") + .HasColumnType("boolean"); + b.Property("Endpoint") .HasColumnType("text"); @@ -879,6 +1037,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LoopdMacaroon") .HasColumnType("text"); + b.Property("MaxRebalanceCostToEarnRatio") + .HasColumnType("double precision"); + + b.Property("MaxRebalancesInFlight") + .HasColumnType("integer"); + b.Property("MaxSwapRoutingFeeRatio") .HasColumnType("numeric"); @@ -896,6 +1060,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); + b.Property("RebalanceBudgetRefreshInterval") + .HasColumnType("interval"); + + b.Property("RebalanceBudgetSats") + .HasColumnType("bigint"); + + b.Property("RebalanceBudgetStartDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("RestoreFeeBaselineOnDisable") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("RoutingEngineDryRun") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + b.Property("SwapBudgetRefreshInterval") .HasColumnType("interval"); @@ -971,6 +1154,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RequestedAmountSats") .HasColumnType("bigint"); + b.Property("ReservedFeeSats") + .HasColumnType("bigint"); + b.Property("RetryMaxFeePct") .HasColumnType("double precision"); @@ -1469,6 +1655,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("SourceNode"); }); + modelBuilder.Entity("NodeGuard.Data.Models.ChannelFeeState", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithOne() + .HasForeignKey("NodeGuard.Data.Models.ChannelFeeState", "ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => { b.HasOne("NodeGuard.Data.Models.Channel", "Channel") @@ -1521,6 +1718,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("UserSigner"); }); + modelBuilder.Entity("NodeGuard.Data.Models.ChannelRoutingState", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithOne() + .HasForeignKey("NodeGuard.Data.Models.ChannelRoutingState", "ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + modelBuilder.Entity("NodeGuard.Data.Models.Key", b => { b.HasOne("NodeGuard.Data.Models.InternalWallet", "InternalWallet") From 42b04f4ffbf02150c63deb5a1cc3b4d4ae6b2b7c Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 7 Jul 2026 18:25:50 +0200 Subject: [PATCH 03/23] feat: implement repositories for channel fee, flow analytics, and routing states; add corresponding interfaces and tests --- .../Repositories/ChannelFeeStateRepository.cs | 80 +++++++++++++ .../ChannelFlowAnalyticsRepository.cs | 74 ++++++++++++ .../ChannelRoutingStateRepository.cs | 92 +++++++++++++++ .../Interfaces/IChannelFeeStateRepository.cs | 32 ++++++ .../IChannelFlowAnalyticsRepository.cs | 46 ++++++++ .../IChannelRoutingStateRepository.cs | 36 ++++++ .../Interfaces/IRebalanceRepository.cs | 13 +++ src/Data/Repositories/RebalanceRepository.cs | 36 ++++++ src/Program.cs | 3 + .../ChannelFlowAnalyticsRepositoryTests.cs | 106 ++++++++++++++++++ .../ChannelRoutingStateRepositoryTests.cs | 99 ++++++++++++++++ .../RebalanceRepositoryRoutingEngineTests.cs | 106 ++++++++++++++++++ 12 files changed, 723 insertions(+) create mode 100644 src/Data/Repositories/ChannelFeeStateRepository.cs create mode 100644 src/Data/Repositories/ChannelFlowAnalyticsRepository.cs create mode 100644 src/Data/Repositories/ChannelRoutingStateRepository.cs create mode 100644 src/Data/Repositories/Interfaces/IChannelFeeStateRepository.cs create mode 100644 src/Data/Repositories/Interfaces/IChannelFlowAnalyticsRepository.cs create mode 100644 src/Data/Repositories/Interfaces/IChannelRoutingStateRepository.cs create mode 100644 test/NodeGuard.Tests/Data/Repositories/ChannelFlowAnalyticsRepositoryTests.cs create mode 100644 test/NodeGuard.Tests/Data/Repositories/ChannelRoutingStateRepositoryTests.cs create mode 100644 test/NodeGuard.Tests/Data/Repositories/RebalanceRepositoryRoutingEngineTests.cs diff --git a/src/Data/Repositories/ChannelFeeStateRepository.cs b/src/Data/Repositories/ChannelFeeStateRepository.cs new file mode 100644 index 00000000..f20e4710 --- /dev/null +++ b/src/Data/Repositories/ChannelFeeStateRepository.cs @@ -0,0 +1,80 @@ +/* + * 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.EntityFrameworkCore; +using NodeGuard.Data.Models; +using NodeGuard.Data.Repositories.Interfaces; + +namespace NodeGuard.Data.Repositories; + +/// +/// Declared for the Phase 1 batched migration; the Phase 2 fee engine is the first writer. +/// +public class ChannelFeeStateRepository : IChannelFeeStateRepository +{ + private readonly IDbContextFactory _dbContextFactory; + + public ChannelFeeStateRepository(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + public async Task GetByChannelId(int channelId) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + + return await context.ChannelFeeStates + .FirstOrDefaultAsync(x => x.ChannelId == channelId); + } + + public async Task UpsertByChannelId(ChannelFeeState state) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + + var existing = await context.ChannelFeeStates + .FirstOrDefaultAsync(x => x.ChannelId == state.ChannelId); + + if (existing == null) + { + state.Channel = null!; + state.SetCreationDatetime(); + state.SetUpdateDatetime(); + await context.ChannelFeeStates.AddAsync(state); + } + else + { + existing.LastFeeUpdateAt = state.LastFeeUpdateAt; + existing.LastAppliedOutboundBaseFeeMsat = state.LastAppliedOutboundBaseFeeMsat; + existing.LastAppliedOutboundPpm = state.LastAppliedOutboundPpm; + existing.LastAppliedInboundBaseMsat = state.LastAppliedInboundBaseMsat; + existing.LastAppliedInboundPpm = state.LastAppliedInboundPpm; + existing.LastComputedTarget = state.LastComputedTarget; + existing.LastObservedRatio = state.LastObservedRatio; + existing.BaselineCapturedAt = state.BaselineCapturedAt; + existing.BaselineOutboundBaseFeeMsat = state.BaselineOutboundBaseFeeMsat; + existing.BaselineOutboundPpm = state.BaselineOutboundPpm; + existing.BaselineInboundBaseMsat = state.BaselineInboundBaseMsat; + existing.BaselineInboundPpm = state.BaselineInboundPpm; + existing.ConsecutiveFailures = state.ConsecutiveFailures; + existing.SetUpdateDatetime(); + } + + await context.SaveChangesAsync(); + } +} diff --git a/src/Data/Repositories/ChannelFlowAnalyticsRepository.cs b/src/Data/Repositories/ChannelFlowAnalyticsRepository.cs new file mode 100644 index 00000000..c5594550 --- /dev/null +++ b/src/Data/Repositories/ChannelFlowAnalyticsRepository.cs @@ -0,0 +1,74 @@ +/* + * 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.EntityFrameworkCore; +using NodeGuard.Data.Models; +using NodeGuard.Data.Repositories.Interfaces; + +namespace NodeGuard.Data.Repositories; + +public class ChannelFlowAnalyticsRepository : IChannelFlowAnalyticsRepository +{ + private readonly IDbContextFactory _dbContextFactory; + + public ChannelFlowAnalyticsRepository(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + public async Task GetOutgoingAmountMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + + return await Settled(context, managedNodePubKey, since) + .Where(x => x.OutgoingChannelId == chanIdLnd) + .SumAsync(x => (long?)x.OutgoingAmountMsat) ?? 0; + } + + public async Task GetIncomingAmountMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + + return await Settled(context, managedNodePubKey, since) + .Where(x => x.IncomingChannelId == chanIdLnd) + .SumAsync(x => (long?)x.IncomingAmountMsat) ?? 0; + } + + public async Task GetOrganicFeesEarnedMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + + return await Settled(context, managedNodePubKey, since) + .Where(x => x.OutgoingChannelId == chanIdLnd) + .SumAsync(x => x.FeeMsat) ?? 0; + } + + /// + /// Base query shared by every method: succeeded (Settled) forwards for the given managed + /// node within the window. No caller ever reads non-Settled rows. + /// + private static IQueryable Settled( + ApplicationDbContext context, string managedNodePubKey, DateTimeOffset since) + { + return context.ForwardingHtlcEvents + .Where(x => x.ManagedNodePubKey == managedNodePubKey + && x.Outcome == ForwardingOutcome.Settled + && x.EventTimestamp >= since); + } +} diff --git a/src/Data/Repositories/ChannelRoutingStateRepository.cs b/src/Data/Repositories/ChannelRoutingStateRepository.cs new file mode 100644 index 00000000..66a21e98 --- /dev/null +++ b/src/Data/Repositories/ChannelRoutingStateRepository.cs @@ -0,0 +1,92 @@ +/* + * 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.EntityFrameworkCore; +using NodeGuard.Data.Models; +using NodeGuard.Data.Repositories.Interfaces; + +namespace NodeGuard.Data.Repositories; + +public class ChannelRoutingStateRepository : IChannelRoutingStateRepository +{ + private readonly IDbContextFactory _dbContextFactory; + + public ChannelRoutingStateRepository(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + public async Task GetByChannelId(int channelId) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + + return await context.ChannelRoutingStates + .FirstOrDefaultAsync(x => x.ChannelId == channelId); + } + + public async Task> GetByManagedNodePubKey(string managedNodePubKey) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + + return await context.ChannelRoutingStates + .Where(x => x.ManagedNodePubKey == managedNodePubKey) + .ToListAsync(); + } + + public async Task UpsertByChannelId(ChannelRoutingState state) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + + var existing = await context.ChannelRoutingStates + .FirstOrDefaultAsync(x => x.ChannelId == state.ChannelId); + + if (existing == null) + { + // Insert via FK only — never let the Channel nav try to insert/attach a Channel. + state.Channel = null!; + state.SetCreationDatetime(); + state.SetUpdateDatetime(); + await context.ChannelRoutingStates.AddAsync(state); + } + else + { + existing.ChanIdLnd = state.ChanIdLnd; + existing.ManagedNodePubKey = state.ManagedNodePubKey; + existing.TargetLocalRatio = state.TargetLocalRatio; + existing.PeerFlowCategory = state.PeerFlowCategory; + existing.PendingCategory = state.PendingCategory; + existing.ConsecutiveCategoryCyclesInNewState = state.ConsecutiveCategoryCyclesInNewState; + existing.FundingBlockHeight = state.FundingBlockHeight; + existing.AgeBlocks = state.AgeBlocks; + existing.EmaLocalRatio = state.EmaLocalRatio; + existing.PushMsatWindow = state.PushMsatWindow; + existing.PullMsatWindow = state.PullMsatWindow; + existing.NetFlowRatio = state.NetFlowRatio; + existing.PeerInitiated = state.PeerInitiated; + existing.LastKnownNumUpdates = state.LastKnownNumUpdates; + existing.LastKnownLifetime = state.LastKnownLifetime; + existing.LastKnownUptime = state.LastKnownUptime; + existing.LastCategorizedAt = state.LastCategorizedAt; + existing.LastEvaluatedAt = state.LastEvaluatedAt; + existing.SetUpdateDatetime(); + } + + await context.SaveChangesAsync(); + } +} diff --git a/src/Data/Repositories/Interfaces/IChannelFeeStateRepository.cs b/src/Data/Repositories/Interfaces/IChannelFeeStateRepository.cs new file mode 100644 index 00000000..6ceaaa57 --- /dev/null +++ b/src/Data/Repositories/Interfaces/IChannelFeeStateRepository.cs @@ -0,0 +1,32 @@ +/* + * 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; + +namespace NodeGuard.Data.Repositories.Interfaces; + +/// +/// Declared for Phase 1 (batched migration) but not exercised until the Phase 2 fee engine. +/// +public interface IChannelFeeStateRepository +{ + Task GetByChannelId(int channelId); + + Task UpsertByChannelId(ChannelFeeState state); +} diff --git a/src/Data/Repositories/Interfaces/IChannelFlowAnalyticsRepository.cs b/src/Data/Repositories/Interfaces/IChannelFlowAnalyticsRepository.cs new file mode 100644 index 00000000..36892c7b --- /dev/null +++ b/src/Data/Repositories/Interfaces/IChannelFlowAnalyticsRepository.cs @@ -0,0 +1,46 @@ +/* + * 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/. + * + */ + +namespace NodeGuard.Data.Repositories.Interfaces; + +/// +/// Windowed per-channel aggregations over ForwardingHtlcEvent. Succeeded HTLCs only — every +/// query filters Outcome == Settled, so failed and in-flight events never count as flow. +/// +public interface IChannelFlowAnalyticsRepository +{ + /// + /// Σ OutgoingAmountMsat of succeeded forwards where OutgoingChannelId == chanIdLnd, for the + /// given managed node, since the window start (drains our local balance — "push"). + /// + Task GetOutgoingAmountMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since); + + /// + /// Σ IncomingAmountMsat of succeeded forwards where IncomingChannelId == chanIdLnd, for the + /// given managed node, since the window start (fills our local balance — "pull"). + /// + Task GetIncomingAmountMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since); + + /// + /// Σ FeeMsat of succeeded forwards where OutgoingChannelId == chanIdLnd, since the window + /// start. Attributed to the outgoing side (the channel whose liquidity was spent) so + /// per-channel revenue sums don't double-count. Phase 2 prioritization input. + /// + Task GetOrganicFeesEarnedMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since); +} diff --git a/src/Data/Repositories/Interfaces/IChannelRoutingStateRepository.cs b/src/Data/Repositories/Interfaces/IChannelRoutingStateRepository.cs new file mode 100644 index 00000000..9a7ca5f9 --- /dev/null +++ b/src/Data/Repositories/Interfaces/IChannelRoutingStateRepository.cs @@ -0,0 +1,36 @@ +/* + * 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; + +namespace NodeGuard.Data.Repositories.Interfaces; + +public interface IChannelRoutingStateRepository +{ + Task GetByChannelId(int channelId); + + Task> GetByManagedNodePubKey(string managedNodePubKey); + + /// + /// Insert-or-update keyed on . Load-then-update + /// is sufficient because TargetRatioReevaluationJob is the sole writer under + /// [DisallowConcurrentExecution]. + /// + Task UpsertByChannelId(ChannelRoutingState state); +} diff --git a/src/Data/Repositories/Interfaces/IRebalanceRepository.cs b/src/Data/Repositories/Interfaces/IRebalanceRepository.cs index 9fe1babf..f8cca49b 100644 --- a/src/Data/Repositories/Interfaces/IRebalanceRepository.cs +++ b/src/Data/Repositories/Interfaces/IRebalanceRepository.cs @@ -51,4 +51,17 @@ public interface IRebalanceRepository Task<(bool, string?)> AddAsync(Rebalance rebalance); (bool, string?) Update(Rebalance rebalance); + + /// + /// Counts non-terminal rebalances (Pending + InFlight) for a node. Used by the Phase 3 + /// in-flight cap. Declared in Phase 1 alongside the routing-engine repositories. + /// + Task GetInFlightByNode(int nodeId); + + /// + /// Budget consumption for a node since (by CreationDatetime): + /// non-terminal rows count MAX(FeePaidSats, ReservedFeeSats) so in-flight spend counts + /// immediately, Succeeded rows count FeePaidSats, other terminal rows count 0. + /// + Task GetConsumedFeesSince(int nodeId, DateTimeOffset since); } diff --git a/src/Data/Repositories/RebalanceRepository.cs b/src/Data/Repositories/RebalanceRepository.cs index 1c59997e..668ef226 100644 --- a/src/Data/Repositories/RebalanceRepository.cs +++ b/src/Data/Repositories/RebalanceRepository.cs @@ -138,4 +138,40 @@ public async Task> GetReconcilable(TimeSpan recentTerminalWindow return _repository.Update(rebalance, context); } + + public async Task GetInFlightByNode(int nodeId) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + + return await context.Rebalances + .CountAsync(r => r.NodeId == nodeId + && (r.Status == RebalanceStatus.Pending || r.Status == RebalanceStatus.InFlight)); + } + + public async Task GetConsumedFeesSince(int nodeId, DateTimeOffset since) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + + // Only rows that can have consumed budget: in-flight (reserved or partially paid) and + // settled. Other terminal states (Failed/NoRoute/Timeout/…) paid nothing → excluded. + var rows = await context.Rebalances + .Where(r => r.NodeId == nodeId + && r.CreationDatetime >= since + && (r.Status == RebalanceStatus.Pending + || r.Status == RebalanceStatus.InFlight + || r.Status == RebalanceStatus.Succeeded)) + .Select(r => new { r.Status, r.FeePaidSats, r.ReservedFeeSats }) + .ToListAsync(); + + long total = 0; + foreach (var r in rows) + { + var feePaid = r.FeePaidSats ?? 0; + total += r.Status == RebalanceStatus.Succeeded + ? feePaid + : Math.Max(feePaid, r.ReservedFeeSats ?? 0); // in-flight spend counts immediately + } + + return total; + } } diff --git a/src/Program.cs b/src/Program.cs index 838c0984..3b3f16e6 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -126,6 +126,9 @@ public static async Task Main(string[] args) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/test/NodeGuard.Tests/Data/Repositories/ChannelFlowAnalyticsRepositoryTests.cs b/test/NodeGuard.Tests/Data/Repositories/ChannelFlowAnalyticsRepositoryTests.cs new file mode 100644 index 00000000..b5745ff4 --- /dev/null +++ b/test/NodeGuard.Tests/Data/Repositories/ChannelFlowAnalyticsRepositoryTests.cs @@ -0,0 +1,106 @@ +/* + * 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; +using Microsoft.EntityFrameworkCore; +using NodeGuard.Data; +using NodeGuard.Data.Models; + +namespace NodeGuard.Data.Repositories; + +public class ChannelFlowAnalyticsRepositoryTests +{ + private readonly Random _random = new(); + private const string Node = "02node"; + private const string OtherNode = "02other"; + private const ulong Chan = 111; + private const ulong OtherChan = 222; + + private (Mock> factory, ApplicationDbContext seedContext) SetupDb() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "FlowAnalytics" + _random.Next()) + .Options; + var factory = new Mock>(); + factory.Setup(x => x.CreateDbContext()).Returns(() => new ApplicationDbContext(options)); + factory.Setup(x => x.CreateDbContextAsync(default)).ReturnsAsync(() => new ApplicationDbContext(options)); + return (factory, new ApplicationDbContext(options)); + } + + private static ForwardingHtlcEvent Event( + string node, ulong incomingChan, ulong outgoingChan, ForwardingOutcome outcome, + DateTimeOffset ts, ulong incomingAmt = 0, ulong outgoingAmt = 0, long fee = 0, + ulong inHtlc = 0, ulong outHtlc = 0) + => new() + { + ManagedNodePubKey = node, + IncomingChannelId = incomingChan, + OutgoingChannelId = outgoingChan, + IncomingHtlcId = inHtlc, + OutgoingHtlcId = outHtlc, + Outcome = outcome, + EventTimestamp = ts, + IncomingAmountMsat = incomingAmt, + OutgoingAmountMsat = outgoingAmt, + FeeMsat = fee, + }; + + [Fact] + public async Task Getters_SumOnlySettledInWindowForThisNodeAndChannel() + { + var (factory, seed) = SetupDb(); + var now = DateTimeOffset.UtcNow; + var since = now.AddDays(-1); + + seed.ForwardingHtlcEvents.AddRange( + // Settled, in-window, outgoing on our channel — counted. + Event(Node, 900, Chan, ForwardingOutcome.Settled, now, outgoingAmt: 1000, fee: 10, inHtlc: 1, outHtlc: 1), + Event(Node, 901, Chan, ForwardingOutcome.Settled, now, outgoingAmt: 500, fee: 5, inHtlc: 2, outHtlc: 2), + // Settled, in-window, incoming on our channel — counted for incoming only. + Event(Node, Chan, 902, ForwardingOutcome.Settled, now, incomingAmt: 2000, inHtlc: 3, outHtlc: 3), + // Out of window — excluded. + Event(Node, 903, Chan, ForwardingOutcome.Settled, now.AddDays(-2), outgoingAmt: 9999, fee: 99, inHtlc: 4, outHtlc: 4), + // Not settled — excluded. + Event(Node, 904, Chan, ForwardingOutcome.Failed, now, outgoingAmt: 7777, fee: 77, inHtlc: 5, outHtlc: 5), + Event(Node, Chan, 905, ForwardingOutcome.Unknown, now, incomingAmt: 4444, inHtlc: 6, outHtlc: 6), + // Different node — excluded. + Event(OtherNode, 906, Chan, ForwardingOutcome.Settled, now, outgoingAmt: 6666, fee: 66, inHtlc: 7, outHtlc: 7), + // Different channel — excluded from Chan sums. + Event(Node, 907, OtherChan, ForwardingOutcome.Settled, now, outgoingAmt: 3333, fee: 33, inHtlc: 8, outHtlc: 8) + ); + await seed.SaveChangesAsync(); + + var sut = new ChannelFlowAnalyticsRepository(factory.Object); + + (await sut.GetOutgoingAmountMsat(Node, Chan, since)).Should().Be(1500); + (await sut.GetIncomingAmountMsat(Node, Chan, since)).Should().Be(2000); + (await sut.GetOrganicFeesEarnedMsat(Node, Chan, since)).Should().Be(15); + } + + [Fact] + public async Task Getters_ReturnZero_WhenNoMatchingRows() + { + var (factory, _) = SetupDb(); + var sut = new ChannelFlowAnalyticsRepository(factory.Object); + + (await sut.GetOutgoingAmountMsat(Node, Chan, DateTimeOffset.UtcNow.AddDays(-1))).Should().Be(0); + (await sut.GetIncomingAmountMsat(Node, Chan, DateTimeOffset.UtcNow.AddDays(-1))).Should().Be(0); + (await sut.GetOrganicFeesEarnedMsat(Node, Chan, DateTimeOffset.UtcNow.AddDays(-1))).Should().Be(0); + } +} diff --git a/test/NodeGuard.Tests/Data/Repositories/ChannelRoutingStateRepositoryTests.cs b/test/NodeGuard.Tests/Data/Repositories/ChannelRoutingStateRepositoryTests.cs new file mode 100644 index 00000000..71b1e2f9 --- /dev/null +++ b/test/NodeGuard.Tests/Data/Repositories/ChannelRoutingStateRepositoryTests.cs @@ -0,0 +1,99 @@ +/* + * 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; +using Microsoft.EntityFrameworkCore; +using NodeGuard.Data; +using NodeGuard.Data.Models; + +namespace NodeGuard.Data.Repositories; + +public class ChannelRoutingStateRepositoryTests +{ + private readonly Random _random = new(); + + private (Mock> factory, DbContextOptions options) SetupDb() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "RoutingState" + _random.Next()) + .Options; + var factory = new Mock>(); + factory.Setup(x => x.CreateDbContext()).Returns(() => new ApplicationDbContext(options)); + factory.Setup(x => x.CreateDbContextAsync(default)).ReturnsAsync(() => new ApplicationDbContext(options)); + return (factory, options); + } + + [Fact] + public async Task UpsertByChannelId_InsertsThenUpdatesInPlace_PreservingSmoothedFields() + { + var (factory, options) = SetupDb(); + var sut = new ChannelRoutingStateRepository(factory.Object); + + // First call inserts. + await sut.UpsertByChannelId(new ChannelRoutingState + { + ChannelId = 42, + ManagedNodePubKey = "02node", + EmaLocalRatio = 0.70, + TargetLocalRatio = 0.60, + PeerFlowCategory = PeerFlowCategory.Sink, + LastEvaluatedAt = DateTimeOffset.UtcNow, + }); + + var afterInsert = await sut.GetByChannelId(42); + afterInsert.Should().NotBeNull(); + afterInsert!.EmaLocalRatio.Should().Be(0.70); + afterInsert.PeerFlowCategory.Should().Be(PeerFlowCategory.Sink); + + // Second call with the same ChannelId updates in place. + await sut.UpsertByChannelId(new ChannelRoutingState + { + ChannelId = 42, + ManagedNodePubKey = "02node", + EmaLocalRatio = 0.75, + TargetLocalRatio = 0.62, + PeerFlowCategory = PeerFlowCategory.Bidirectional, + LastEvaluatedAt = DateTimeOffset.UtcNow, + }); + + var afterUpdate = await sut.GetByChannelId(42); + afterUpdate!.EmaLocalRatio.Should().Be(0.75); + afterUpdate.TargetLocalRatio.Should().Be(0.62); + afterUpdate.PeerFlowCategory.Should().Be(PeerFlowCategory.Bidirectional); + + // Exactly one row — the second call updated, it did not insert a duplicate. + await using var verify = new ApplicationDbContext(options); + (await verify.ChannelRoutingStates.CountAsync(x => x.ChannelId == 42)).Should().Be(1); + } + + [Fact] + public async Task GetByManagedNodePubKey_ReturnsOnlyThatNodesRows() + { + var (factory, _) = SetupDb(); + var sut = new ChannelRoutingStateRepository(factory.Object); + + await sut.UpsertByChannelId(new ChannelRoutingState { ChannelId = 1, ManagedNodePubKey = "02a", LastEvaluatedAt = DateTimeOffset.UtcNow }); + await sut.UpsertByChannelId(new ChannelRoutingState { ChannelId = 2, ManagedNodePubKey = "02a", LastEvaluatedAt = DateTimeOffset.UtcNow }); + await sut.UpsertByChannelId(new ChannelRoutingState { ChannelId = 3, ManagedNodePubKey = "02b", LastEvaluatedAt = DateTimeOffset.UtcNow }); + + var forA = await sut.GetByManagedNodePubKey("02a"); + forA.Should().HaveCount(2); + forA.Select(x => x.ChannelId).Should().BeEquivalentTo(new[] { 1, 2 }); + } +} diff --git a/test/NodeGuard.Tests/Data/Repositories/RebalanceRepositoryRoutingEngineTests.cs b/test/NodeGuard.Tests/Data/Repositories/RebalanceRepositoryRoutingEngineTests.cs new file mode 100644 index 00000000..2fe24478 --- /dev/null +++ b/test/NodeGuard.Tests/Data/Repositories/RebalanceRepositoryRoutingEngineTests.cs @@ -0,0 +1,106 @@ +/* + * 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; +using Microsoft.EntityFrameworkCore; +using NodeGuard.Data; +using NodeGuard.Data.Models; +using NodeGuard.Data.Repositories.Interfaces; + +namespace NodeGuard.Data.Repositories; + +public class RebalanceRepositoryRoutingEngineTests +{ + private readonly Random _random = new(); + private const int NodeId = 1; + private const int OtherNodeId = 2; + + private (RebalanceRepository sut, ApplicationDbContext seed) SetupDb() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "RebalanceRE" + _random.Next()) + .Options; + var factory = new Mock>(); + factory.Setup(x => x.CreateDbContext()).Returns(() => new ApplicationDbContext(options)); + factory.Setup(x => x.CreateDbContextAsync(default)).ReturnsAsync(() => new ApplicationDbContext(options)); + var repository = new Mock>(); + return (new RebalanceRepository(repository.Object, factory.Object), new ApplicationDbContext(options)); + } + + private static Rebalance Reb(int nodeId, RebalanceStatus status, DateTimeOffset created, + long? feePaid = null, long? reserved = null) + => new() + { + NodeId = nodeId, + Status = status, + CreationDatetime = created, + UpdateDatetime = created, + FeePaidSats = feePaid, + ReservedFeeSats = reserved, + }; + + [Fact] + public async Task GetInFlightByNode_CountsOnlyPendingAndInFlightForThatNode() + { + var (sut, seed) = SetupDb(); + var now = DateTimeOffset.UtcNow; + + seed.Rebalances.AddRange( + Reb(NodeId, RebalanceStatus.Pending, now), + Reb(NodeId, RebalanceStatus.InFlight, now), + Reb(NodeId, RebalanceStatus.Succeeded, now), + Reb(NodeId, RebalanceStatus.Failed, now), + Reb(NodeId, RebalanceStatus.NoRoute, now), + Reb(NodeId, RebalanceStatus.Timeout, now), + Reb(NodeId, RebalanceStatus.InsufficientBalance, now), + Reb(NodeId, RebalanceStatus.ExceededFeeLimit, now), + Reb(OtherNodeId, RebalanceStatus.Pending, now), // different node — excluded + Reb(OtherNodeId, RebalanceStatus.InFlight, now) + ); + await seed.SaveChangesAsync(); + + (await sut.GetInFlightByNode(NodeId)).Should().Be(2); + } + + [Fact] + public async Task GetConsumedFeesSince_UsesReservedOrPaidForInFlightAndPaidForSucceeded() + { + var (sut, seed) = SetupDb(); + var now = DateTimeOffset.UtcNow; + var since = now.AddHours(-1); + + seed.Rebalances.AddRange( + // Pending with only a reservation → counts reserved (100). + Reb(NodeId, RebalanceStatus.Pending, now, feePaid: null, reserved: 100), + // InFlight where paid already exceeds reserved → counts MAX (50). + Reb(NodeId, RebalanceStatus.InFlight, now, feePaid: 50, reserved: 30), + // Succeeded → counts paid (200). + Reb(NodeId, RebalanceStatus.Succeeded, now, feePaid: 200, reserved: 999), + // Failed with a stale reservation → nothing consumed (excluded). + Reb(NodeId, RebalanceStatus.Failed, now, feePaid: null, reserved: 999), + // Succeeded but before the window → excluded. + Reb(NodeId, RebalanceStatus.Succeeded, now.AddHours(-2), feePaid: 777), + // Different node → excluded. + Reb(OtherNodeId, RebalanceStatus.Succeeded, now, feePaid: 500) + ); + await seed.SaveChangesAsync(); + + (await sut.GetConsumedFeesSince(NodeId, since)).Should().Be(100 + 50 + 200); + } +} From 7cf82ce3196bd6bc7b2dac66c203c037673c5dc7 Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 7 Jul 2026 18:40:05 +0200 Subject: [PATCH 04/23] feat: add BlockHeightHelper and ChannelOwnershipHelper with corresponding tests --- src/Helpers/BlockHeightHelper.cs | 46 +++++++++++++ src/Helpers/ChannelOwnershipHelper.cs | 44 +++++++++++++ .../Helpers/BlockHeightHelperTests.cs | 60 +++++++++++++++++ .../Helpers/ChannelOwnershipHelperTests.cs | 65 +++++++++++++++++++ 4 files changed, 215 insertions(+) create mode 100644 src/Helpers/BlockHeightHelper.cs create mode 100644 src/Helpers/ChannelOwnershipHelper.cs create mode 100644 test/NodeGuard.Tests/Helpers/BlockHeightHelperTests.cs create mode 100644 test/NodeGuard.Tests/Helpers/ChannelOwnershipHelperTests.cs diff --git a/src/Helpers/BlockHeightHelper.cs b/src/Helpers/BlockHeightHelper.cs new file mode 100644 index 00000000..f3908c89 --- /dev/null +++ b/src/Helpers/BlockHeightHelper.cs @@ -0,0 +1,46 @@ +/* + * 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/. + * + */ + +namespace NodeGuard.Helpers; + +public static class BlockHeightHelper +{ + /// + /// Parse funding block height (bits 63..40) from scid. Returns null for pending, + /// alias, zero-conf, or malformed scids. + /// + public static uint? FundingHeightFromChanId(ulong chanId, uint chainTip) + { + if (chanId == 0) return null; + + var blockHeight = (uint)(chanId >> 40); + if (blockHeight == 0 || blockHeight > chainTip) return null; + + return blockHeight; + } + + /// + /// Channel age in blocks (chainTip - funding height), or null if unfunded/aliased/malformed. + /// + public static uint? AgeBlocksFromChanId(ulong chanId, uint chainTip) + { + var height = FundingHeightFromChanId(chanId, chainTip); + return height == null ? null : chainTip - height.Value; + } +} diff --git a/src/Helpers/ChannelOwnershipHelper.cs b/src/Helpers/ChannelOwnershipHelper.cs new file mode 100644 index 00000000..4df51cec --- /dev/null +++ b/src/Helpers/ChannelOwnershipHelper.cs @@ -0,0 +1,44 @@ +/* + * 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; + +namespace NodeGuard.Helpers; + +public static class ChannelOwnershipHelper +{ + /// + /// Returns false if not initiator and peer is a managed node (dedup rule), true otherwise. + /// + public static bool IsOwnedByManagedNode( + Lnrpc.Channel lndChannel, + IReadOnlyCollection allManagedNodes) + { + if (lndChannel == null) throw new ArgumentNullException(nameof(lndChannel)); + if (allManagedNodes == null) throw new ArgumentNullException(nameof(allManagedNodes)); + + if (!lndChannel.Initiator && + allManagedNodes.Any(n => n.PubKey == lndChannel.RemotePubkey)) + { + return false; + } + + return true; + } +} diff --git a/test/NodeGuard.Tests/Helpers/BlockHeightHelperTests.cs b/test/NodeGuard.Tests/Helpers/BlockHeightHelperTests.cs new file mode 100644 index 00000000..b4e2deae --- /dev/null +++ b/test/NodeGuard.Tests/Helpers/BlockHeightHelperTests.cs @@ -0,0 +1,60 @@ +/* + * 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; +using NodeGuard.Helpers; + +namespace NodeGuard.Tests; + +public class BlockHeightHelperTests +{ + // scid = (blockHeight << 40) | (txIndex << 16) | outputIndex + private static ulong Scid(uint blockHeight, uint txIndex = 3, ushort outputIndex = 1) + => ((ulong)blockHeight << 40) | ((ulong)txIndex << 16) | outputIndex; + + [Fact] + public void FundingHeight_ParsesBlockHeightComponent() + { + BlockHeightHelper.FundingHeightFromChanId(Scid(800_000), chainTip: 800_010) + .Should().Be(800_000); + } + + [Fact] + public void AgeBlocks_IsChainTipMinusFundingHeight() + { + BlockHeightHelper.AgeBlocksFromChanId(Scid(800_000), chainTip: 800_010) + .Should().Be(10); + } + + [Fact] + public void ZeroChanId_ReturnsNull() + { + BlockHeightHelper.FundingHeightFromChanId(0, chainTip: 800_010).Should().BeNull(); + BlockHeightHelper.AgeBlocksFromChanId(0, chainTip: 800_010).Should().BeNull(); + } + + [Fact] + public void AliasScid_AboveChainTip_ReturnsNull() + { + // LND alias range encodes heights far above any real chain tip. + var alias = Scid(16_000_000); + BlockHeightHelper.FundingHeightFromChanId(alias, chainTip: 800_010).Should().BeNull(); + BlockHeightHelper.AgeBlocksFromChanId(alias, chainTip: 800_010).Should().BeNull(); + } +} diff --git a/test/NodeGuard.Tests/Helpers/ChannelOwnershipHelperTests.cs b/test/NodeGuard.Tests/Helpers/ChannelOwnershipHelperTests.cs new file mode 100644 index 00000000..ed4983f8 --- /dev/null +++ b/test/NodeGuard.Tests/Helpers/ChannelOwnershipHelperTests.cs @@ -0,0 +1,65 @@ +/* + * 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; +using NodeGuard.Data.Models; +using NodeGuard.Helpers; + +namespace NodeGuard.Tests; + +public class ChannelOwnershipHelperTests +{ + private const string NodeAPubKey = "02aaaa"; + private const string NodeBPubKey = "02bbbb"; + private const string UnmanagedPubKey = "03cccc"; + + private static readonly IReadOnlyCollection ManagedNodes = new List + { + new() { PubKey = NodeAPubKey }, + new() { PubKey = NodeBPubKey }, + }; + + [Fact] + public void ChannelBetweenTwoManagedNodes_IsOwnedByExactlyTheInitiatorSide() + { + // Same channel seen from each side. A opened it. + var fromA = new Lnrpc.Channel { Initiator = true, RemotePubkey = NodeBPubKey }; + var fromB = new Lnrpc.Channel { Initiator = false, RemotePubkey = NodeAPubKey }; + + ChannelOwnershipHelper.IsOwnedByManagedNode(fromA, ManagedNodes).Should().BeTrue(); + ChannelOwnershipHelper.IsOwnedByManagedNode(fromB, ManagedNodes).Should().BeFalse(); + } + + [Fact] + public void NonInitiatorChannel_ToUnmanagedPeer_IsOwned() + { + // We didn't open it, but the peer isn't managed — no other side will report it, so we own it. + var channel = new Lnrpc.Channel { Initiator = false, RemotePubkey = UnmanagedPubKey }; + + ChannelOwnershipHelper.IsOwnedByManagedNode(channel, ManagedNodes).Should().BeTrue(); + } + + [Fact] + public void InitiatorChannel_IsAlwaysOwned() + { + var channel = new Lnrpc.Channel { Initiator = true, RemotePubkey = UnmanagedPubKey }; + + ChannelOwnershipHelper.IsOwnedByManagedNode(channel, ManagedNodes).Should().BeTrue(); + } +} From 1a4ffebd863f75485e8c8aee5f25a54959108ae8 Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 7 Jul 2026 18:41:21 +0200 Subject: [PATCH 05/23] feat: add PeerCategorizationService with tests for category computation logic --- src/Program.cs | 1 + src/Services/PeerCategorizationService.cs | 122 +++++++++++ .../PeerCategorizationServiceTests.cs | 203 ++++++++++++++++++ 3 files changed, 326 insertions(+) create mode 100644 src/Services/PeerCategorizationService.cs create mode 100644 test/NodeGuard.Tests/Services/PeerCategorizationServiceTests.cs diff --git a/src/Program.cs b/src/Program.cs index 3b3f16e6..3bc07c15 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -134,6 +134,7 @@ public static async Task Main(string[] args) builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Services/PeerCategorizationService.cs b/src/Services/PeerCategorizationService.cs new file mode 100644 index 00000000..3199c025 --- /dev/null +++ b/src/Services/PeerCategorizationService.cs @@ -0,0 +1,122 @@ +/* + * 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; + +namespace NodeGuard.Services; + +/// +/// Outcome of one categorization cycle. Carries the full hysteresis state so the caller can +/// persist it without re-deriving anything. +/// +/// Committed category after this cycle. +/// Tentative category being counted toward a flip; null in steady state. +/// Updated streak counter (0 in steady state). +/// True when the committed category changed this cycle. +public record CategoryDecision( + PeerFlowCategory Category, + PeerFlowCategory? PendingCategory, + uint ConsecutiveCyclesInNewState, + bool Flipped); + +public interface IPeerCategorizationService +{ + /// + /// Applies the volume gate + net-flow thresholds to derive a tentative category, then runs + /// the N-cycle anti-flap hysteresis against the current/pending state. Pure — no I/O. + /// + CategoryDecision ComputeCategory( + double netFlowRatio, + long windowVolumeMsat, + PeerFlowCategory currentCategory, + PeerFlowCategory? pendingCategory, + uint consecutiveCyclesInNewState, + int flipHysteresisCycles, + double netFlowThreshold, + long minVolumeMsat); + + /// + /// target_goal = clamp(0.5 + clamp(kTarget · netFlowRatio, -maxDrift, +maxDrift), 0.10, 0.90). + /// Positive net-flow (SINK) pulls the target above 0.5; negative (SOURCE) below. + /// + double ComputeTargetGoal(double netFlowRatio, double kTarget, double maxDrift); + + /// EWMA: alphaTarget · targetGoal + (1 - alphaTarget) · currentTarget. + double SmoothTarget(double currentTarget, double targetGoal, double alphaTarget); + + /// EWMA: alphaRatio · observedRatio + (1 - alphaRatio) · currentEma. + double SmoothEma(double currentEma, double observedRatio, double alphaRatio); +} + +public class PeerCategorizationService : IPeerCategorizationService +{ + public CategoryDecision ComputeCategory( + double netFlowRatio, + long windowVolumeMsat, + PeerFlowCategory currentCategory, + PeerFlowCategory? pendingCategory, + uint consecutiveCyclesInNewState, + int flipHysteresisCycles, + double netFlowThreshold, + long minVolumeMsat) + { + var tentative = ComputeTentative(netFlowRatio, windowVolumeMsat, netFlowThreshold, minVolumeMsat); + + // Steady state: what we observe matches the committed category — clear any pending streak. + if (tentative == currentCategory) + { + return new CategoryDecision(currentCategory, null, 0, false); + } + + // Diverging from the committed category: extend the streak if it's the same tentative we + // were already counting, otherwise restart the streak at 1 for this new tentative. + var newStreak = pendingCategory == tentative + ? consecutiveCyclesInNewState + 1 + : 1u; + + // Commit the flip once the streak reaches the hysteresis threshold. + if (newStreak >= (uint)Math.Max(1, flipHysteresisCycles)) + { + return new CategoryDecision(tentative, null, 0, true); + } + + return new CategoryDecision(currentCategory, tentative, newStreak, false); + } + + private static PeerFlowCategory ComputeTentative( + double netFlowRatio, long windowVolumeMsat, double netFlowThreshold, long minVolumeMsat) + { + if (windowVolumeMsat < minVolumeMsat) return PeerFlowCategory.Uncategorized; + if (netFlowRatio >= netFlowThreshold) return PeerFlowCategory.Sink; + if (netFlowRatio <= -netFlowThreshold) return PeerFlowCategory.Source; + return PeerFlowCategory.Bidirectional; + } + + public double ComputeTargetGoal(double netFlowRatio, double kTarget, double maxDrift) + { + var drift = Math.Clamp(kTarget * netFlowRatio, -maxDrift, maxDrift); + return Math.Clamp(0.5 + drift, 0.10, 0.90); + } + + public double SmoothTarget(double currentTarget, double targetGoal, double alphaTarget) + => alphaTarget * targetGoal + (1 - alphaTarget) * currentTarget; + + public double SmoothEma(double currentEma, double observedRatio, double alphaRatio) + => alphaRatio * observedRatio + (1 - alphaRatio) * currentEma; +} diff --git a/test/NodeGuard.Tests/Services/PeerCategorizationServiceTests.cs b/test/NodeGuard.Tests/Services/PeerCategorizationServiceTests.cs new file mode 100644 index 00000000..4c96e011 --- /dev/null +++ b/test/NodeGuard.Tests/Services/PeerCategorizationServiceTests.cs @@ -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 FluentAssertions; +using NodeGuard.Data.Models; + +namespace NodeGuard.Services; + +public class PeerCategorizationServiceTests +{ + private readonly PeerCategorizationService _sut = new(); + + private const int Hysteresis = 3; + private const double Threshold = 0.25; + private const long MinVolume = 10_000_000_000; + private const long AboveMin = 20_000_000_000; + + private CategoryDecision Compute( + double netFlowRatio, + long volume, + PeerFlowCategory current, + PeerFlowCategory? pending, + uint streak) + => _sut.ComputeCategory(netFlowRatio, volume, current, pending, streak, Hysteresis, Threshold, MinVolume); + + [Fact] + public void ComputeCategory_PushHeavy_CommitsSink_OnThirdConsecutiveCycle() + { + // Cycle 1 & 2: pending Sink, not yet flipped. + var c1 = Compute(0.30, AboveMin, PeerFlowCategory.Uncategorized, null, 0); + c1.Should().Be(new CategoryDecision(PeerFlowCategory.Uncategorized, PeerFlowCategory.Sink, 1, false)); + + var c2 = Compute(0.30, AboveMin, c1.Category, c1.PendingCategory, c1.ConsecutiveCyclesInNewState); + c2.Should().Be(new CategoryDecision(PeerFlowCategory.Uncategorized, PeerFlowCategory.Sink, 2, false)); + + // Cycle 3: flip commits. + var c3 = Compute(0.30, AboveMin, c2.Category, c2.PendingCategory, c2.ConsecutiveCyclesInNewState); + c3.Should().Be(new CategoryDecision(PeerFlowCategory.Sink, null, 0, true)); + } + + [Fact] + public void ComputeCategory_PullHeavy_CommitsSource_OnThirdConsecutiveCycle() + { + var current = PeerFlowCategory.Uncategorized; + PeerFlowCategory? pending = null; + uint streak = 0; + CategoryDecision decision = default!; + + for (var i = 0; i < Hysteresis; i++) + { + decision = Compute(-0.30, AboveMin, current, pending, streak); + current = decision.Category; + pending = decision.PendingCategory; + streak = decision.ConsecutiveCyclesInNewState; + } + + decision.Category.Should().Be(PeerFlowCategory.Source); + decision.Flipped.Should().BeTrue(); + } + + [Fact] + public void ComputeCategory_BalancedFlow_CommitsBidirectional() + { + var current = PeerFlowCategory.Uncategorized; + PeerFlowCategory? pending = null; + uint streak = 0; + CategoryDecision decision = default!; + + for (var i = 0; i < Hysteresis; i++) + { + decision = Compute(0.05, AboveMin, current, pending, streak); + current = decision.Category; + pending = decision.PendingCategory; + streak = decision.ConsecutiveCyclesInNewState; + } + + decision.Category.Should().Be(PeerFlowCategory.Bidirectional); + } + + [Fact] + public void ComputeCategory_BelowMinVolume_YieldsUncategorized_RegardlessOfRatio() + { + // Strong push ratio but not enough volume — stays Uncategorized in steady state. + var decision = Compute(0.90, MinVolume - 1, PeerFlowCategory.Uncategorized, null, 0); + decision.Should().Be(new CategoryDecision(PeerFlowCategory.Uncategorized, null, 0, false)); + } + + [Fact] + public void ComputeCategory_InterruptedStreak_DoesNotFlip() + { + // Sink, Sink, then Bidirectional resets the streak — no flip to either. + var c1 = Compute(0.30, AboveMin, PeerFlowCategory.Uncategorized, null, 0); + var c2 = Compute(0.30, AboveMin, c1.Category, c1.PendingCategory, c1.ConsecutiveCyclesInNewState); + var c3 = Compute(0.05, AboveMin, c2.Category, c2.PendingCategory, c2.ConsecutiveCyclesInNewState); + + c3.Flipped.Should().BeFalse(); + c3.Category.Should().Be(PeerFlowCategory.Uncategorized); + c3.PendingCategory.Should().Be(PeerFlowCategory.Bidirectional); + c3.ConsecutiveCyclesInNewState.Should().Be(1); + } + + [Fact] + public void ComputeCategory_SteadyState_ReturnsCurrentWithClearedStreak() + { + var decision = Compute(0.30, AboveMin, PeerFlowCategory.Sink, null, 0); + decision.Should().Be(new CategoryDecision(PeerFlowCategory.Sink, null, 0, false)); + } + + [Fact] + public void ComputeCategory_EstablishedSink_DecaysToUncategorized_AfterHysteresis_WhenVolumeDrops() + { + var current = PeerFlowCategory.Sink; + PeerFlowCategory? pending = null; + uint streak = 0; + CategoryDecision decision = default!; + + for (var i = 0; i < Hysteresis; i++) + { + decision = Compute(0.30, MinVolume - 1, current, pending, streak); // volume dropped + current = decision.Category; + pending = decision.PendingCategory; + streak = decision.ConsecutiveCyclesInNewState; + } + + decision.Category.Should().Be(PeerFlowCategory.Uncategorized); + decision.Flipped.Should().BeTrue(); + } + + [Theory] + [InlineData(1.0, 0.85)] // max positive drift (sink) + [InlineData(0.5, 0.85)] // 0.7*0.5 = 0.35 = maxDrift + [InlineData(-1.0, 0.15)] // max negative drift (source) + [InlineData(-0.5, 0.15)] + [InlineData(0.0, 0.5)] + public void ComputeTargetGoal_ClampsToBand(double netFlowRatio, double expected) + { + _sut.ComputeTargetGoal(netFlowRatio, kTarget: 0.70, maxDrift: 0.35) + .Should().BeApproximately(expected, 1e-9); + } + + [Fact] + public void ComputeTargetGoal_SignFollowsFlow() + { + _sut.ComputeTargetGoal(0.10, 0.70, 0.35).Should().BeGreaterThan(0.5); // sink → above + _sut.ComputeTargetGoal(-0.10, 0.70, 0.35).Should().BeLessThan(0.5); // source → below + } + + [Fact] + public void SmoothEma_IsAlphaWeightedAverage() + { + _sut.SmoothEma(currentEma: 0.5, observedRatio: 1.0, alphaRatio: 0.04) + .Should().BeApproximately(0.52, 1e-9); + } + + [Fact] + public void SmoothEma_StepInput_Reaches63PercentIn1OverAlphaSamples() + { + const double alpha = 0.1; + var ema = 0.0; // start; step target = 1.0 + for (var i = 0; i < (int)(1 / alpha); i++) + { + ema = _sut.SmoothEma(ema, 1.0, alpha); + } + + // Classic first-order step response: ~63% of the way to the goal after 1/alpha samples. + ema.Should().BeApproximately(0.63, 0.05); + } + + [Fact] + public void SmoothTarget_ConvergesMonotonicallyTowardGoal() + { + const double goal = 0.8; + var target = 0.5; + var previousGap = goal - target; + + for (var i = 0; i < 25; i++) + { + target = _sut.SmoothTarget(target, goal, alphaTarget: 0.10); + var gap = goal - target; + gap.Should().BeLessThan(previousGap); // strictly closing the gap + gap.Should().BeGreaterThan(0); // never overshoots + previousGap = gap; + } + + target.Should().BeApproximately(goal, 0.05); + } +} From cd7599493b0c30815dda4d2bb3bb87dbeda940da Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 8 Jul 2026 10:17:27 +0200 Subject: [PATCH 06/23] feat: add TargetRatioReevaluationJob for routing engine with scheduling configuration --- src/Jobs/TargetRatioReevaluationJob.cs | 238 +++++++++++++++++++++++++ src/Program.cs | 24 +++ 2 files changed, 262 insertions(+) create mode 100644 src/Jobs/TargetRatioReevaluationJob.cs diff --git a/src/Jobs/TargetRatioReevaluationJob.cs b/src/Jobs/TargetRatioReevaluationJob.cs new file mode 100644 index 00000000..1c97d9a4 --- /dev/null +++ b/src/Jobs/TargetRatioReevaluationJob.cs @@ -0,0 +1,238 @@ +/* + * 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; + +/// +/// Phase 1 of the routing engine. Read-only: for every owned, open channel it refreshes +/// ChannelRoutingState — EMA-smoothed local ratio, net-flow, peer category (with hysteresis), +/// and dynamic target ratio — from settled forwarding history and live ListChannels. Writes +/// only to Postgres; performs zero LND writes. Guarded by the global ROUTING_ENGINE_ENABLED +/// kill switch. +/// +[DisallowConcurrentExecution] +public class TargetRatioReevaluationJob : IJob +{ + private readonly ILogger _logger; + private readonly INodeRepository _nodeRepository; + private readonly IChannelRepository _channelRepository; + private readonly IChannelRoutingStateRepository _routingStateRepository; + private readonly IChannelFlowAnalyticsRepository _flowAnalyticsRepository; + private readonly IPeerCategorizationService _peerCategorizationService; + private readonly ILightningService _lightningService; + private readonly ILightningClientService _lightningClientService; + + public TargetRatioReevaluationJob( + ILogger logger, + INodeRepository nodeRepository, + IChannelRepository channelRepository, + IChannelRoutingStateRepository routingStateRepository, + IChannelFlowAnalyticsRepository flowAnalyticsRepository, + IPeerCategorizationService peerCategorizationService, + ILightningService lightningService, + ILightningClientService lightningClientService) + { + _logger = logger; + _nodeRepository = nodeRepository; + _channelRepository = channelRepository; + _routingStateRepository = routingStateRepository; + _flowAnalyticsRepository = flowAnalyticsRepository; + _peerCategorizationService = peerCategorizationService; + _lightningService = lightningService; + _lightningClientService = lightningClientService; + } + + public async Task Execute(IJobExecutionContext context) + { + // Global kill switch — checked before any work, before touching the DB or LND. + if (!Constants.ROUTING_ENGINE_ENABLED) + { + return; + } + + _logger.LogInformation("Starting {JobName}...", nameof(TargetRatioReevaluationJob)); + + try + { + var managedNodes = await _nodeRepository.GetAllManagedByNodeGuard(withDisabled: false); + + // One DB round-trip for channels; index the open/confirmed ones by their LND scid. + var openChannelsByChanId = new Dictionary(); + foreach (var channel in await _channelRepository.GetAll()) + { + if (channel.Status == Channel.ChannelStatus.Open && channel.ChanId != 0) + { + openChannelsByChanId[channel.ChanId] = channel; + } + } + + foreach (var managedNode in managedNodes) + { + try + { + await ReevaluateNode(managedNode, managedNodes, openChannelsByChanId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error re-evaluating routing state for node {NodeName} ({NodePubKey})", + managedNode.Name, managedNode.PubKey); + } + } + } + catch (Exception ex) + { + // Never surface an exception from Execute — the next cycle is our retry. + _logger.LogError(ex, "Error in {JobName}", nameof(TargetRatioReevaluationJob)); + } + + _logger.LogInformation("{JobName} ended", nameof(TargetRatioReevaluationJob)); + } + + private async Task ReevaluateNode( + Node managedNode, + IReadOnlyCollection managedNodes, + IReadOnlyDictionary openChannelsByChanId) + { + var chainTip = await _lightningService.GetBlockHeight(managedNode); + if (chainTip == null) + { + _logger.LogWarning("Skipping routing re-eval for node {NodeName}: GetInfo/block height unavailable", + managedNode.Name); + return; + } + + var listResp = await _lightningClientService.ListChannels(managedNode); + if (listResp == null) + { + _logger.LogWarning("Skipping routing re-eval for node {NodeName}: ListChannels unavailable", + managedNode.Name); + return; + } + + var now = DateTimeOffset.UtcNow; + var windowStart = now - TimeSpan.FromDays(Constants.ROUTING_ENGINE_FLOW_WINDOW_DAYS); + + foreach (var lndChannel in listResp.Channels) + { + try + { + // Canonical ownership rule — a channel between two managed nodes is owned by one side. + if (!ChannelOwnershipHelper.IsOwnedByManagedNode(lndChannel, managedNodes)) + { + continue; + } + + // Act only on a channel we have a confirmed, open DB row for. + if (!openChannelsByChanId.TryGetValue(lndChannel.ChanId, out var dbChannel)) + { + continue; + } + + await ReevaluateChannel(managedNode, lndChannel, dbChannel, chainTip.Value, windowStart, now); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error re-evaluating channel {ChanId} on node {NodeName}", + lndChannel.ChanId, managedNode.Name); + } + } + } + + private async Task ReevaluateChannel( + Node managedNode, + Lnrpc.Channel lndChannel, + Channel dbChannel, + uint chainTip, + DateTimeOffset windowStart, + DateTimeOffset now) + { + var observedRatio = (double)lndChannel.LocalBalance + / Math.Max(1, lndChannel.LocalBalance + lndChannel.RemoteBalance); + + // Seed EmaLocalRatio with the first observation on insert — no 0.5 cold-start bias. + var state = await _routingStateRepository.GetByChannelId(dbChannel.Id) + ?? new ChannelRoutingState + { + ChannelId = dbChannel.Id, + ManagedNodePubKey = managedNode.PubKey, + EmaLocalRatio = observedRatio, + }; + + state.ChanIdLnd = lndChannel.ChanId; // refresh: alias scid -> confirmed scid + state.FundingBlockHeight = BlockHeightHelper.FundingHeightFromChanId(lndChannel.ChanId, chainTip); + state.AgeBlocks = BlockHeightHelper.AgeBlocksFromChanId(lndChannel.ChanId, chainTip); + state.PeerInitiated = !lndChannel.Initiator; + state.EmaLocalRatio = _peerCategorizationService.SmoothEma( + state.EmaLocalRatio, observedRatio, Constants.ROUTING_ENGINE_FEE_EMA_ALPHA); + + var push = await _flowAnalyticsRepository.GetOutgoingAmountMsat(managedNode.PubKey, lndChannel.ChanId, windowStart); + var pull = await _flowAnalyticsRepository.GetIncomingAmountMsat(managedNode.PubKey, lndChannel.ChanId, windowStart); + state.PushMsatWindow = push; + state.PullMsatWindow = pull; + var total = push + pull; + state.NetFlowRatio = total == 0 ? 0.0 : (double)(push - pull) / total; // >0 = SINK + + // Age gate (job-side); the volume gate lives inside ComputeCategory. Young / alias + // channels (AgeBlocks null) stay Uncategorized at target 0.5. + if (state.AgeBlocks >= Constants.ROUTING_ENGINE_CATEGORIZATION_MIN_AGE_BLOCKS) + { + var decision = _peerCategorizationService.ComputeCategory( + state.NetFlowRatio, + total, + state.PeerFlowCategory, + state.PendingCategory, + state.ConsecutiveCategoryCyclesInNewState, + Constants.ROUTING_ENGINE_CATEGORY_FLIP_HYSTERESIS_CYCLES, + Constants.ROUTING_ENGINE_CATEGORY_NET_FLOW_THRESHOLD, + Constants.ROUTING_ENGINE_FLOW_MIN_MSAT); + + state.PeerFlowCategory = decision.Category; + state.PendingCategory = decision.PendingCategory; + state.ConsecutiveCategoryCyclesInNewState = decision.ConsecutiveCyclesInNewState; + if (decision.Flipped) + { + state.LastCategorizedAt = now; + } + + var targetGoal = state.PeerFlowCategory == PeerFlowCategory.Uncategorized + ? 0.5 + : _peerCategorizationService.ComputeTargetGoal( + state.NetFlowRatio, + Constants.ROUTING_ENGINE_TARGET_K, + Constants.ROUTING_ENGINE_TARGET_MAX_DRIFT); + + state.TargetLocalRatio = _peerCategorizationService.SmoothTarget( + state.TargetLocalRatio, targetGoal, Constants.ROUTING_ENGINE_TARGET_ALPHA); + } + + state.LastKnownNumUpdates = (long)lndChannel.NumUpdates; + state.LastKnownLifetime = lndChannel.Lifetime; + state.LastKnownUptime = lndChannel.Uptime; + state.LastEvaluatedAt = now; + + await _routingStateRepository.UpsertByChannelId(state); + } +} diff --git a/src/Program.cs b/src/Program.cs index 3bc07c15..affa6e5b 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -274,6 +274,30 @@ public static async Task Main(string[] args) }); }); + //Target Ratio Reevaluation Job (Routing Engine — Phase 1, read-only) + q.AddJob(opts => + { + opts.DisallowConcurrentExecution(); + opts.WithIdentity(nameof(TargetRatioReevaluationJob)); + }); + + q.AddTrigger(opts => + { + opts.ForJob(nameof(TargetRatioReevaluationJob)) + .WithIdentity($"{nameof(TargetRatioReevaluationJob)}Trigger") + .StartNow().WithSimpleSchedule(scheduleBuilder => + { + if (Constants.IS_DEV_ENVIRONMENT) + { + scheduleBuilder.WithIntervalInMinutes(5).RepeatForever(); + } + else + { + scheduleBuilder.WithIntervalInMinutes(Constants.ROUTING_ENGINE_JOB_INTERVAL_MINUTES).RepeatForever(); + } + }); + }); + //Monitor Withdrawals Job q.AddJob(opts => { From 62a35c88dc4dd0966a103e8cbf0819140ec3fe6a Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 8 Jul 2026 12:41:42 +0200 Subject: [PATCH 07/23] feat: add heuristic routing engine configuration options for dynamic fee management and automated rebalancing --- src/Pages/Nodes.razor | 131 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 129 insertions(+), 2 deletions(-) diff --git a/src/Pages/Nodes.razor b/src/Pages/Nodes.razor index db4e0a72..76aa38c8 100644 --- a/src/Pages/Nodes.razor +++ b/src/Pages/Nodes.razor @@ -433,6 +433,115 @@ } + + + + + + These settings configure the heuristic routing engine. They take effect once the + fee engine (Phase 2) and automated rebalancer (Phase 3) ship — enabling them here + stages a node's configuration ahead of time. The global ROUTING_ENGINE_ENABLED + kill switch and ROUTING_ENGINE_DRY_RUN flag still gate everything. + + + + + Dynamic Fee Management + Let the engine adjust this node's outbound/inbound channel fees toward each channel's target ratio + + Enable dynamic fee management + + + + @if (_selectedNodeForLiquidity.DynamicFeeManagementEnabled) + { + + Positive Inbound Fees + Allow the engine to set positive inbound fees (surcharges) to repel entry on over-local channels, not just negative discounts + + Allow positive inbound fees + + + + + Restore Baseline on Disable + When dynamic fees are turned off, restore each channel's captured fee baseline in one final write. When off, last-set fees are frozen in place. + + Restore fee baseline on disable + + + } + + + Automated Rebalancing + Let the engine initiate circular rebalances to steer channels toward their target ratio, within the budget below + + Enable automated rebalancing + + + + @if (_selectedNodeForLiquidity.AutoRebalanceEnabled) + { + + + + Rebalance Fee Budget (sats) + Max sats spendable on rebalance fees per refresh period. 0 = unset. + + + + + + Budget Refresh Interval (days) + Period after which the rebalance fee budget resets. 0 = unset. + + + + + + + + Max Rebalances In Flight + Maximum concurrent (Pending/InFlight) rebalances for this node. 0 = unset. + + + + + + Max Cost-to-Earn Ratio + Profitability gate: spend at most this fraction of a channel's earn rate on rebalancing it (e.g. 0.5 = 50%). 0 = unset. + + + + + } + + @if (_selectedNodeForLiquidity.DynamicFeeManagementEnabled || _selectedNodeForLiquidity.AutoRebalanceEnabled) + { + + Dry Run + When on, the engine logs the fee/rebalance actions it would take for this node without calling LND. Turn off to let this node act for real — flip nodes live one at a time. + + Dry run (log only, no LND writes) + + + + @if (!_selectedNodeForLiquidity.RoutingEngineDryRun) + { + + + Dry run is off for this node — once the engine is live, it will + perform real fee changes and/or rebalances on LND. The global + ROUTING_ENGINE_DRY_RUN flag can still override this as a master safety switch. + + + } + } @@ -484,6 +593,12 @@ private int _budgetRefreshDays; private decimal _maxSwapRoutingFeePercent; + // Routing-engine rebalance-budget edit fields (entity properties are nullable; 0 = unset) + private long _rebalanceBudgetSats; + private int _rebalanceBudgetRefreshDays; + private int _maxRebalancesInFlight; + private double _maxRebalanceCostToEarnRatio; + public abstract class NodesColumnName { public static readonly ColumnDefault Name = new("Name"); @@ -820,7 +935,13 @@ _budgetRefreshDays = node.SwapBudgetRefreshInterval.Days; // Convert ratio from 0-1 to 0-100 for display _maxSwapRoutingFeePercent = node.MaxSwapRoutingFeeRatio * 100m; - + + // Routing-engine rebalance budget (nullable → 0 when unset) + _rebalanceBudgetSats = node.RebalanceBudgetSats ?? 0; + _rebalanceBudgetRefreshDays = node.RebalanceBudgetRefreshInterval?.Days ?? 0; + _maxRebalancesInFlight = node.MaxRebalancesInFlight ?? 0; + _maxRebalanceCostToEarnRatio = node.MaxRebalanceCostToEarnRatio ?? 0; + if (_nodeLiquidityModal != null) await _nodeLiquidityModal.Show(); } @@ -844,7 +965,13 @@ _selectedNodeForLiquidity.SwapBudgetRefreshInterval = TimeSpan.FromDays(_budgetRefreshDays); // Convert percent (0-100) back to ratio (0-1) _selectedNodeForLiquidity.MaxSwapRoutingFeeRatio = _maxSwapRoutingFeePercent / 100m; - + + // Routing-engine rebalance budget: 0 maps back to null (unset) + _selectedNodeForLiquidity.RebalanceBudgetSats = _rebalanceBudgetSats > 0 ? _rebalanceBudgetSats : null; + _selectedNodeForLiquidity.RebalanceBudgetRefreshInterval = _rebalanceBudgetRefreshDays > 0 ? TimeSpan.FromDays(_rebalanceBudgetRefreshDays) : null; + _selectedNodeForLiquidity.MaxRebalancesInFlight = _maxRebalancesInFlight > 0 ? _maxRebalancesInFlight : null; + _selectedNodeForLiquidity.MaxRebalanceCostToEarnRatio = _maxRebalanceCostToEarnRatio > 0 ? _maxRebalanceCostToEarnRatio : null; + _selectedNodeForLiquidity.UpdateDatetime = DateTimeOffset.Now; var updateResult = NodeRepository.Update(_selectedNodeForLiquidity); From def4d49ce74cdd3a65f184dcb138955f711eebcf Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 8 Jul 2026 14:36:52 +0200 Subject: [PATCH 08/23] feat: update ROUTING_ENGINE_DRY_RUN to handle null values in environment variable --- src/Helpers/Constants.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index 2a875af7..bf06a866 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -544,7 +544,7 @@ static Constants() // Routing Engine — Phase 1 ROUTING_ENGINE_ENABLED = StringHelper.IsTrue(Environment.GetEnvironmentVariable("ROUTING_ENGINE_ENABLED")); - ROUTING_ENGINE_DRY_RUN = Environment.GetEnvironmentVariable("ROUTING_ENGINE_DRY_RUN") != "false"; // default true + ROUTING_ENGINE_DRY_RUN = Environment.GetEnvironmentVariable("ROUTING_ENGINE_DRY_RUN")?.ToLowerInvariant() != "false"; // default true var reMinAgeBlocks = Environment.GetEnvironmentVariable("ROUTING_ENGINE_CATEGORIZATION_MIN_AGE_BLOCKS"); if (reMinAgeBlocks != null) ROUTING_ENGINE_CATEGORIZATION_MIN_AGE_BLOCKS = uint.Parse(reMinAgeBlocks); From 789bb6fe98a3130e4a3b26c32260df138aeaae8a Mon Sep 17 00:00:00 2001 From: Marcos Date: Mon, 13 Jul 2026 17:39:39 +0200 Subject: [PATCH 09/23] chore: rename test file --- ...epositoryRoutingEngineTests.cs => RebalanceRepositoryTests.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/NodeGuard.Tests/Data/Repositories/{RebalanceRepositoryRoutingEngineTests.cs => RebalanceRepositoryTests.cs} (100%) diff --git a/test/NodeGuard.Tests/Data/Repositories/RebalanceRepositoryRoutingEngineTests.cs b/test/NodeGuard.Tests/Data/Repositories/RebalanceRepositoryTests.cs similarity index 100% rename from test/NodeGuard.Tests/Data/Repositories/RebalanceRepositoryRoutingEngineTests.cs rename to test/NodeGuard.Tests/Data/Repositories/RebalanceRepositoryTests.cs From 75539ca7ac2c663d9d92d6f07f9f660dd78f2605 Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 14 Jul 2026 08:59:51 +0200 Subject: [PATCH 10/23] feat: migrate channel flow analytics methods to forwarding HTLC event repository and remove unused repository interfaces --- .../ChannelFlowAnalyticsRepository.cs | 74 ------------------- .../ForwardingHtlcEventRepository.cs | 40 ++++++++++ .../IChannelFlowAnalyticsRepository.cs | 46 ------------ .../IForwardingHtlcEventRepository.cs | 19 +++++ src/Jobs/TargetRatioReevaluationJob.cs | 10 +-- src/Program.cs | 1 - ... => ForwardingHtlcEventRepositoryTests.cs} | 12 ++- 7 files changed, 72 insertions(+), 130 deletions(-) delete mode 100644 src/Data/Repositories/ChannelFlowAnalyticsRepository.cs delete mode 100644 src/Data/Repositories/Interfaces/IChannelFlowAnalyticsRepository.cs rename test/NodeGuard.Tests/Data/Repositories/{ChannelFlowAnalyticsRepositoryTests.cs => ForwardingHtlcEventRepositoryTests.cs} (91%) diff --git a/src/Data/Repositories/ChannelFlowAnalyticsRepository.cs b/src/Data/Repositories/ChannelFlowAnalyticsRepository.cs deleted file mode 100644 index c5594550..00000000 --- a/src/Data/Repositories/ChannelFlowAnalyticsRepository.cs +++ /dev/null @@ -1,74 +0,0 @@ -/* - * 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.EntityFrameworkCore; -using NodeGuard.Data.Models; -using NodeGuard.Data.Repositories.Interfaces; - -namespace NodeGuard.Data.Repositories; - -public class ChannelFlowAnalyticsRepository : IChannelFlowAnalyticsRepository -{ - private readonly IDbContextFactory _dbContextFactory; - - public ChannelFlowAnalyticsRepository(IDbContextFactory dbContextFactory) - { - _dbContextFactory = dbContextFactory; - } - - public async Task GetOutgoingAmountMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since) - { - await using var context = await _dbContextFactory.CreateDbContextAsync(); - - return await Settled(context, managedNodePubKey, since) - .Where(x => x.OutgoingChannelId == chanIdLnd) - .SumAsync(x => (long?)x.OutgoingAmountMsat) ?? 0; - } - - public async Task GetIncomingAmountMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since) - { - await using var context = await _dbContextFactory.CreateDbContextAsync(); - - return await Settled(context, managedNodePubKey, since) - .Where(x => x.IncomingChannelId == chanIdLnd) - .SumAsync(x => (long?)x.IncomingAmountMsat) ?? 0; - } - - public async Task GetOrganicFeesEarnedMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since) - { - await using var context = await _dbContextFactory.CreateDbContextAsync(); - - return await Settled(context, managedNodePubKey, since) - .Where(x => x.OutgoingChannelId == chanIdLnd) - .SumAsync(x => x.FeeMsat) ?? 0; - } - - /// - /// Base query shared by every method: succeeded (Settled) forwards for the given managed - /// node within the window. No caller ever reads non-Settled rows. - /// - private static IQueryable Settled( - ApplicationDbContext context, string managedNodePubKey, DateTimeOffset since) - { - return context.ForwardingHtlcEvents - .Where(x => x.ManagedNodePubKey == managedNodePubKey - && x.Outcome == ForwardingOutcome.Settled - && x.EventTimestamp >= since); - } -} diff --git a/src/Data/Repositories/ForwardingHtlcEventRepository.cs b/src/Data/Repositories/ForwardingHtlcEventRepository.cs index c40633b7..819ca1ef 100644 --- a/src/Data/Repositories/ForwardingHtlcEventRepository.cs +++ b/src/Data/Repositories/ForwardingHtlcEventRepository.cs @@ -97,6 +97,46 @@ public ForwardingHtlcEventRepository(IDbContextFactory dbC } } + public async Task GetOutgoingAmountMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + + return await Settled(context, managedNodePubKey, since) + .Where(x => x.OutgoingChannelId == chanIdLnd) + .SumAsync(x => (long?)x.OutgoingAmountMsat) ?? 0; + } + + public async Task GetIncomingAmountMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + + return await Settled(context, managedNodePubKey, since) + .Where(x => x.IncomingChannelId == chanIdLnd) + .SumAsync(x => (long?)x.IncomingAmountMsat) ?? 0; + } + + public async Task GetOrganicFeesEarnedMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + + return await Settled(context, managedNodePubKey, since) + .Where(x => x.OutgoingChannelId == chanIdLnd) + .SumAsync(x => x.FeeMsat) ?? 0; + } + + /// + /// Base query shared by every windowed aggregation: succeeded (Settled) forwards for the given + /// managed node within the window. No caller ever reads non-Settled rows. + /// + private static IQueryable Settled( + ApplicationDbContext context, string managedNodePubKey, DateTimeOffset since) + { + return context.ForwardingHtlcEvents + .Where(x => x.ManagedNodePubKey == managedNodePubKey + && x.Outcome == ForwardingOutcome.Settled + && x.EventTimestamp >= since); + } + private static T MergeEnum(T currentValue, T newValue) where T : struct, Enum { return EqualityComparer.Default.Equals(newValue, default) ? currentValue : newValue; diff --git a/src/Data/Repositories/Interfaces/IChannelFlowAnalyticsRepository.cs b/src/Data/Repositories/Interfaces/IChannelFlowAnalyticsRepository.cs deleted file mode 100644 index 36892c7b..00000000 --- a/src/Data/Repositories/Interfaces/IChannelFlowAnalyticsRepository.cs +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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/. - * - */ - -namespace NodeGuard.Data.Repositories.Interfaces; - -/// -/// Windowed per-channel aggregations over ForwardingHtlcEvent. Succeeded HTLCs only — every -/// query filters Outcome == Settled, so failed and in-flight events never count as flow. -/// -public interface IChannelFlowAnalyticsRepository -{ - /// - /// Σ OutgoingAmountMsat of succeeded forwards where OutgoingChannelId == chanIdLnd, for the - /// given managed node, since the window start (drains our local balance — "push"). - /// - Task GetOutgoingAmountMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since); - - /// - /// Σ IncomingAmountMsat of succeeded forwards where IncomingChannelId == chanIdLnd, for the - /// given managed node, since the window start (fills our local balance — "pull"). - /// - Task GetIncomingAmountMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since); - - /// - /// Σ FeeMsat of succeeded forwards where OutgoingChannelId == chanIdLnd, since the window - /// start. Attributed to the outgoing side (the channel whose liquidity was spent) so - /// per-channel revenue sums don't double-count. Phase 2 prioritization input. - /// - Task GetOrganicFeesEarnedMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since); -} diff --git a/src/Data/Repositories/Interfaces/IForwardingHtlcEventRepository.cs b/src/Data/Repositories/Interfaces/IForwardingHtlcEventRepository.cs index 9fcec4aa..0b373b18 100644 --- a/src/Data/Repositories/Interfaces/IForwardingHtlcEventRepository.cs +++ b/src/Data/Repositories/Interfaces/IForwardingHtlcEventRepository.cs @@ -24,4 +24,23 @@ namespace NodeGuard.Data.Repositories.Interfaces; public interface IForwardingHtlcEventRepository { Task<(bool, string?)> UpsertAsync(ForwardingHtlcEvent forwardingHtlcEvent); + + /// + /// Σ OutgoingAmountMsat of succeeded forwards where OutgoingChannelId == chanIdLnd, for the + /// given managed node, since the window start (drains our local balance — "push"). + /// + Task GetOutgoingAmountMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since); + + /// + /// Σ IncomingAmountMsat of succeeded forwards where IncomingChannelId == chanIdLnd, for the + /// given managed node, since the window start (fills our local balance — "pull"). + /// + Task GetIncomingAmountMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since); + + /// + /// Σ FeeMsat of succeeded forwards where OutgoingChannelId == chanIdLnd, since the window + /// start. Attributed to the outgoing side (the channel whose liquidity was spent) so + /// per-channel revenue sums don't double-count. Phase 2 prioritization input. + /// + Task GetOrganicFeesEarnedMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since); } \ No newline at end of file diff --git a/src/Jobs/TargetRatioReevaluationJob.cs b/src/Jobs/TargetRatioReevaluationJob.cs index 1c97d9a4..2b5d6080 100644 --- a/src/Jobs/TargetRatioReevaluationJob.cs +++ b/src/Jobs/TargetRatioReevaluationJob.cs @@ -40,7 +40,7 @@ public class TargetRatioReevaluationJob : IJob private readonly INodeRepository _nodeRepository; private readonly IChannelRepository _channelRepository; private readonly IChannelRoutingStateRepository _routingStateRepository; - private readonly IChannelFlowAnalyticsRepository _flowAnalyticsRepository; + private readonly IForwardingHtlcEventRepository _forwardingHtlcEventRepository; private readonly IPeerCategorizationService _peerCategorizationService; private readonly ILightningService _lightningService; private readonly ILightningClientService _lightningClientService; @@ -50,7 +50,7 @@ public TargetRatioReevaluationJob( INodeRepository nodeRepository, IChannelRepository channelRepository, IChannelRoutingStateRepository routingStateRepository, - IChannelFlowAnalyticsRepository flowAnalyticsRepository, + IForwardingHtlcEventRepository forwardingHtlcEventRepository, IPeerCategorizationService peerCategorizationService, ILightningService lightningService, ILightningClientService lightningClientService) @@ -59,7 +59,7 @@ public TargetRatioReevaluationJob( _nodeRepository = nodeRepository; _channelRepository = channelRepository; _routingStateRepository = routingStateRepository; - _flowAnalyticsRepository = flowAnalyticsRepository; + _forwardingHtlcEventRepository = forwardingHtlcEventRepository; _peerCategorizationService = peerCategorizationService; _lightningService = lightningService; _lightningClientService = lightningClientService; @@ -188,8 +188,8 @@ private async Task ReevaluateChannel( state.EmaLocalRatio = _peerCategorizationService.SmoothEma( state.EmaLocalRatio, observedRatio, Constants.ROUTING_ENGINE_FEE_EMA_ALPHA); - var push = await _flowAnalyticsRepository.GetOutgoingAmountMsat(managedNode.PubKey, lndChannel.ChanId, windowStart); - var pull = await _flowAnalyticsRepository.GetIncomingAmountMsat(managedNode.PubKey, lndChannel.ChanId, windowStart); + var push = await _forwardingHtlcEventRepository.GetOutgoingAmountMsat(managedNode.PubKey, lndChannel.ChanId, windowStart); + var pull = await _forwardingHtlcEventRepository.GetIncomingAmountMsat(managedNode.PubKey, lndChannel.ChanId, windowStart); state.PushMsatWindow = push; state.PullMsatWindow = pull; var total = push + pull; diff --git a/src/Program.cs b/src/Program.cs index affa6e5b..3a45c7bd 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -128,7 +128,6 @@ public static async Task Main(string[] args) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); - builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/test/NodeGuard.Tests/Data/Repositories/ChannelFlowAnalyticsRepositoryTests.cs b/test/NodeGuard.Tests/Data/Repositories/ForwardingHtlcEventRepositoryTests.cs similarity index 91% rename from test/NodeGuard.Tests/Data/Repositories/ChannelFlowAnalyticsRepositoryTests.cs rename to test/NodeGuard.Tests/Data/Repositories/ForwardingHtlcEventRepositoryTests.cs index b5745ff4..6cb51d5c 100644 --- a/test/NodeGuard.Tests/Data/Repositories/ChannelFlowAnalyticsRepositoryTests.cs +++ b/test/NodeGuard.Tests/Data/Repositories/ForwardingHtlcEventRepositoryTests.cs @@ -19,12 +19,13 @@ using FluentAssertions; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using NodeGuard.Data; using NodeGuard.Data.Models; namespace NodeGuard.Data.Repositories; -public class ChannelFlowAnalyticsRepositoryTests +public class ForwardingHtlcEventRepositoryTests { private readonly Random _random = new(); private const string Node = "02node"; @@ -35,7 +36,7 @@ public class ChannelFlowAnalyticsRepositoryTests private (Mock> factory, ApplicationDbContext seedContext) SetupDb() { var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(databaseName: "FlowAnalytics" + _random.Next()) + .UseInMemoryDatabase(databaseName: "ForwardingHtlcEvent" + _random.Next()) .Options; var factory = new Mock>(); factory.Setup(x => x.CreateDbContext()).Returns(() => new ApplicationDbContext(options)); @@ -43,6 +44,9 @@ public class ChannelFlowAnalyticsRepositoryTests return (factory, new ApplicationDbContext(options)); } + private static ForwardingHtlcEventRepository Sut(Mock> factory) + => new(factory.Object, Mock.Of>()); + private static ForwardingHtlcEvent Event( string node, ulong incomingChan, ulong outgoingChan, ForwardingOutcome outcome, DateTimeOffset ts, ulong incomingAmt = 0, ulong outgoingAmt = 0, long fee = 0, @@ -86,7 +90,7 @@ public async Task Getters_SumOnlySettledInWindowForThisNodeAndChannel() ); await seed.SaveChangesAsync(); - var sut = new ChannelFlowAnalyticsRepository(factory.Object); + var sut = Sut(factory); (await sut.GetOutgoingAmountMsat(Node, Chan, since)).Should().Be(1500); (await sut.GetIncomingAmountMsat(Node, Chan, since)).Should().Be(2000); @@ -97,7 +101,7 @@ public async Task Getters_SumOnlySettledInWindowForThisNodeAndChannel() public async Task Getters_ReturnZero_WhenNoMatchingRows() { var (factory, _) = SetupDb(); - var sut = new ChannelFlowAnalyticsRepository(factory.Object); + var sut = Sut(factory); (await sut.GetOutgoingAmountMsat(Node, Chan, DateTimeOffset.UtcNow.AddDays(-1))).Should().Be(0); (await sut.GetIncomingAmountMsat(Node, Chan, DateTimeOffset.UtcNow.AddDays(-1))).Should().Be(0); From cb3ef19e7a43efbec9f596d1ee25c9dc7ee8f7ae Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 14 Jul 2026 09:29:12 +0200 Subject: [PATCH 11/23] feat: update RoutingEngineDryRun default value to false and adjust related documentation --- src/Data/ApplicationDbContext.cs | 2 +- src/Data/Models/Node.cs | 9 +++------ src/Helpers/Constants.cs | 11 ----------- ...60707155251_AddRoutingEngineFoundation.Designer.cs | 2 +- .../20260707155251_AddRoutingEngineFoundation.cs | 2 +- src/Migrations/ApplicationDbContextModelSnapshot.cs | 2 +- src/Pages/Nodes.razor | 3 +-- 7 files changed, 8 insertions(+), 23 deletions(-) diff --git a/src/Data/ApplicationDbContext.cs b/src/Data/ApplicationDbContext.cs index 51dd477d..7cd97fc5 100644 --- a/src/Data/ApplicationDbContext.cs +++ b/src/Data/ApplicationDbContext.cs @@ -131,7 +131,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // only affects new in-code instances, not the DB column default / migration backfill). modelBuilder.Entity().Property(x => x.IsDynamicFeeEnabled).HasDefaultValue(true); modelBuilder.Entity().Property(x => x.RestoreFeeBaselineOnDisable).HasDefaultValue(true); - modelBuilder.Entity().Property(x => x.RoutingEngineDryRun).HasDefaultValue(true); + modelBuilder.Entity().Property(x => x.RoutingEngineDryRun).HasDefaultValue(false); base.OnModelCreating(modelBuilder); } diff --git a/src/Data/Models/Node.cs b/src/Data/Models/Node.cs index 9e2ce20a..2472bd39 100644 --- a/src/Data/Models/Node.cs +++ b/src/Data/Models/Node.cs @@ -161,13 +161,10 @@ public class Node : Entity public bool RestoreFeeBaselineOnDisable { get; set; } = true; /// - /// Per-node dry-run for the routing-engine actuators (Phase 2 fee engine, Phase 3 - /// rebalancer): when true they log the fee/rebalance they would perform without calling - /// LND. Lets an operator stage a node in dry-run and take it live one node at a time. - /// Defaults ON (safe). The global Constants.ROUTING_ENGINE_DRY_RUN is a master - /// override — an actuator writes for real only when both the global flag and this are off. + /// Per-node dry-run for the routing-engine actuators: when true they log the + /// fee/rebalance they would perform without calling LND. /// - public bool RoutingEngineDryRun { get; set; } = true; + public bool RoutingEngineDryRun { get; set; } = false; /// /// Maximum sats spendable on rebalance fees over the budget refresh interval (Phase 3). diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index bf06a866..15844e41 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -217,13 +217,6 @@ public class Constants /// public static bool ROUTING_ENGINE_ENABLED = false; - /// - /// Master dry-run gate for actuators (Phase 2+): when true, actuators log the fee/rebalance - /// they would perform without calling LND. No effect in Phase 1 (no writes). Defaults ON. - /// Env: ROUTING_ENGINE_DRY_RUN. - /// - public static bool ROUTING_ENGINE_DRY_RUN = true; - /// /// Age gate for categorization: channels younger than this many blocks stay Uncategorized /// at target 0.5. Default 3024 = 21 block-days. Env: ROUTING_ENGINE_CATEGORIZATION_MIN_AGE_BLOCKS. @@ -542,10 +535,6 @@ static Constants() var rebReconcileWindow = Environment.GetEnvironmentVariable("REBALANCE_RECONCILE_TERMINAL_WINDOW_HOURS"); if (rebReconcileWindow != null) REBALANCE_RECONCILE_TERMINAL_WINDOW_HOURS = int.Parse(rebReconcileWindow); - // Routing Engine — Phase 1 - ROUTING_ENGINE_ENABLED = StringHelper.IsTrue(Environment.GetEnvironmentVariable("ROUTING_ENGINE_ENABLED")); - ROUTING_ENGINE_DRY_RUN = Environment.GetEnvironmentVariable("ROUTING_ENGINE_DRY_RUN")?.ToLowerInvariant() != "false"; // default true - var reMinAgeBlocks = Environment.GetEnvironmentVariable("ROUTING_ENGINE_CATEGORIZATION_MIN_AGE_BLOCKS"); if (reMinAgeBlocks != null) ROUTING_ENGINE_CATEGORIZATION_MIN_AGE_BLOCKS = uint.Parse(reMinAgeBlocks); diff --git a/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs b/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs index 400964b4..0985e500 100644 --- a/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs +++ b/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs @@ -1080,7 +1080,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("RoutingEngineDryRun") .ValueGeneratedOnAdd() .HasColumnType("boolean") - .HasDefaultValue(true); + .HasDefaultValue(false); b.Property("SwapBudgetRefreshInterval") .HasColumnType("interval"); diff --git a/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs b/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs index 272475a0..130a678d 100644 --- a/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs +++ b/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs @@ -81,7 +81,7 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "Nodes", type: "boolean", nullable: false, - defaultValue: true); + defaultValue: false); migrationBuilder.AddColumn( name: "IsDynamicFeeEnabled", diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index 3975e822..8594c52a 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1077,7 +1077,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RoutingEngineDryRun") .ValueGeneratedOnAdd() .HasColumnType("boolean") - .HasDefaultValue(true); + .HasDefaultValue(false); b.Property("SwapBudgetRefreshInterval") .HasColumnType("interval"); diff --git a/src/Pages/Nodes.razor b/src/Pages/Nodes.razor index 76aa38c8..dbb93f31 100644 --- a/src/Pages/Nodes.razor +++ b/src/Pages/Nodes.razor @@ -536,8 +536,7 @@ Dry run is off for this node — once the engine is live, it will - perform real fee changes and/or rebalances on LND. The global - ROUTING_ENGINE_DRY_RUN flag can still override this as a master safety switch. + perform real fee changes and/or rebalances on LND. } From bf9f5dc082039edacc7226f72149deede1de78b0 Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 14 Jul 2026 09:49:37 +0200 Subject: [PATCH 12/23] fix: change default value of IsDynamicFeeEnabled to false in Channel model and related migrations --- src/Data/ApplicationDbContext.cs | 2 +- src/Data/Models/Channel.cs | 2 +- .../20260707155251_AddRoutingEngineFoundation.Designer.cs | 2 +- src/Migrations/20260707155251_AddRoutingEngineFoundation.cs | 2 +- src/Migrations/ApplicationDbContextModelSnapshot.cs | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Data/ApplicationDbContext.cs b/src/Data/ApplicationDbContext.cs index 7cd97fc5..30733520 100644 --- a/src/Data/ApplicationDbContext.cs +++ b/src/Data/ApplicationDbContext.cs @@ -129,7 +129,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // These default ON: existing rows must be backfilled true (the C# initializer // only affects new in-code instances, not the DB column default / migration backfill). - modelBuilder.Entity().Property(x => x.IsDynamicFeeEnabled).HasDefaultValue(true); + modelBuilder.Entity().Property(x => x.IsDynamicFeeEnabled).HasDefaultValue(false); modelBuilder.Entity().Property(x => x.RestoreFeeBaselineOnDisable).HasDefaultValue(true); modelBuilder.Entity().Property(x => x.RoutingEngineDryRun).HasDefaultValue(false); diff --git a/src/Data/Models/Channel.cs b/src/Data/Models/Channel.cs index b6427971..8d7604b4 100644 --- a/src/Data/Models/Channel.cs +++ b/src/Data/Models/Channel.cs @@ -72,7 +72,7 @@ public enum ChannelStatus /// Per-channel opt-out for the Phase 2 dynamic fee engine. Defaults true; the node-level /// flag still gates all fee writes. /// - public bool IsDynamicFeeEnabled { get; set; } = true; + public bool IsDynamicFeeEnabled { get; set; } = false; [NotMapped] public int? OpenedWithId => ChannelOperationRequests?.FirstOrDefault()?.Wallet?.Id; diff --git a/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs b/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs index 0985e500..1f045b8f 100644 --- a/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs +++ b/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs @@ -418,7 +418,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("IsDynamicFeeEnabled") .ValueGeneratedOnAdd() .HasColumnType("boolean") - .HasDefaultValue(true); + .HasDefaultValue(false); b.Property("IsPrivate") .HasColumnType("boolean"); diff --git a/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs b/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs index 130a678d..3a2facd1 100644 --- a/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs +++ b/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs @@ -88,7 +88,7 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "Channels", type: "boolean", nullable: false, - defaultValue: true); + defaultValue: false); migrationBuilder.CreateTable( name: "ChannelFeeStates", diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index 8594c52a..5f9d7a0d 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -415,7 +415,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsDynamicFeeEnabled") .ValueGeneratedOnAdd() .HasColumnType("boolean") - .HasDefaultValue(true); + .HasDefaultValue(false); b.Property("IsPrivate") .HasColumnType("boolean"); From a38cc5e71c2a93a8408d50dae89523c9031e42d0 Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 14 Jul 2026 09:56:07 +0200 Subject: [PATCH 13/23] feat: remove PeerCategorizationService from service registrations in Program.cs --- src/Program.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Program.cs b/src/Program.cs index 3a45c7bd..e59d8f83 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -133,7 +133,6 @@ public static async Task Main(string[] args) builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); From b9d125d10da07f7127a9e6c0249efcc293d50d49 Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 14 Jul 2026 15:32:29 +0200 Subject: [PATCH 14/23] fix: remove every mention to phases or development steps --- src/Data/ApplicationDbContext.cs | 4 +-- src/Data/Models/Channel.cs | 2 +- src/Data/Models/ChannelFeeState.cs | 7 +++-- src/Data/Models/ChannelRoutingState.cs | 6 ++--- src/Data/Models/Node.cs | 17 ++++++------ src/Data/Models/Rebalance.cs | 2 +- .../Repositories/ChannelFeeStateRepository.cs | 3 --- .../Interfaces/IChannelFeeStateRepository.cs | 3 --- .../IForwardingHtlcEventRepository.cs | 2 +- .../Interfaces/IRebalanceRepository.cs | 4 +-- src/Helpers/Constants.cs | 26 +++++++------------ src/Jobs/TargetRatioReevaluationJob.cs | 10 +++---- src/Pages/Nodes.razor | 5 +--- src/Program.cs | 2 +- 14 files changed, 37 insertions(+), 56 deletions(-) diff --git a/src/Data/ApplicationDbContext.cs b/src/Data/ApplicationDbContext.cs index 30733520..8c0bd03a 100644 --- a/src/Data/ApplicationDbContext.cs +++ b/src/Data/ApplicationDbContext.cs @@ -109,13 +109,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(r => r.SourceChannelId) .OnDelete(DeleteBehavior.Restrict); - // Routing engine: 1:1 read models keyed on ChannelId. Composite ForwardingHtlcEvent - // indexes are intentionally deferred (see phase-1 spec) — added only once measured. + // Routing engine: 1:1 read models keyed on ChannelId. modelBuilder.Entity() .HasOne(x => x.Channel) .WithOne() .HasForeignKey(x => x.ChannelId) .OnDelete(DeleteBehavior.Cascade); + // Only one ChannelRoutingState per channel. modelBuilder.Entity().HasIndex(x => x.ChannelId).IsUnique(); diff --git a/src/Data/Models/Channel.cs b/src/Data/Models/Channel.cs index 8d7604b4..57b23c8f 100644 --- a/src/Data/Models/Channel.cs +++ b/src/Data/Models/Channel.cs @@ -69,7 +69,7 @@ public enum ChannelStatus public bool IsPrivate { get; set; } /// - /// Per-channel opt-out for the Phase 2 dynamic fee engine. Defaults true; the node-level + /// Per-channel opt-out for the dynamic fee engine. Defaults false; the node-level /// flag still gates all fee writes. /// public bool IsDynamicFeeEnabled { get; set; } = false; diff --git a/src/Data/Models/ChannelFeeState.cs b/src/Data/Models/ChannelFeeState.cs index b3f0813d..6bbf5415 100644 --- a/src/Data/Models/ChannelFeeState.cs +++ b/src/Data/Models/ChannelFeeState.cs @@ -20,10 +20,9 @@ namespace NodeGuard.Data.Models; /// -/// Per-channel fee-engine state (1:1 with ). Declared in the Phase 1 -/// migration so Phase 2 needs no schema change; rows are not created or populated until the -/// Phase 2 fee engine ships. Holds last-applied policy + baseline snapshot (for rollback on -/// disable) + a per-channel circuit-breaker counter, all of which must survive restarts. +/// Per-channel fee-engine state (1:1 with ). Holds last-applied +/// policy + baseline snapshot (for rollback on disable) + a per-channel circuit-breaker +/// counter, all of which must survive restarts. /// public class ChannelFeeState : Entity { diff --git a/src/Data/Models/ChannelRoutingState.cs b/src/Data/Models/ChannelRoutingState.cs index 6289b545..02a018c4 100644 --- a/src/Data/Models/ChannelRoutingState.cs +++ b/src/Data/Models/ChannelRoutingState.cs @@ -40,9 +40,9 @@ public enum PeerFlowCategory /// /// Per-channel routing-engine read model (1:1 with ). Written by -/// TargetRatioReevaluationJob; read by the Phase 2 fee engine and Phase 3 rebalancer. -/// This is the single canonical place target ratio / category / smoothed balance live — -/// actuators must not re-derive them. +/// TargetRatioReevaluationJob; read by the fee engine and rebalancer. This is the single +/// canonical place target ratio / category / smoothed balance live — actuators must not +/// re-derive them. /// public class ChannelRoutingState : Entity { diff --git a/src/Data/Models/Node.cs b/src/Data/Models/Node.cs index 2472bd39..b6008b04 100644 --- a/src/Data/Models/Node.cs +++ b/src/Data/Models/Node.cs @@ -138,18 +138,17 @@ public class Node : Entity #region Routing Engine /// - /// Master gate for the Phase 2 dynamic fee engine on this node. Defaults OFF. + /// Master gate for the dynamic fee engine on this node. Defaults OFF. /// public bool DynamicFeeManagementEnabled { get; set; } = false; /// - /// Master gate for the Phase 3 automated rebalancer on this node. Defaults OFF. + /// Master gate for the automated rebalancer on this node. Defaults OFF. /// public bool AutoRebalanceEnabled { get; set; } = false; /// - /// Allows the fee engine to set positive inbound fees on this node. Flipped to true - /// alongside at activation. Defaults OFF. + /// Allows the fee engine to set positive inbound fees on this node. Defaults OFF. /// public bool AllowPositiveInboundFees { get; set; } = false; @@ -167,27 +166,27 @@ public class Node : Entity public bool RoutingEngineDryRun { get; set; } = false; /// - /// Maximum sats spendable on rebalance fees over the budget refresh interval (Phase 3). + /// Maximum sats spendable on rebalance fees over the budget refresh interval. /// public long? RebalanceBudgetSats { get; set; } /// - /// Time interval after which the rebalance budget is refreshed (Phase 3). + /// Time interval after which the rebalance budget is refreshed. /// public TimeSpan? RebalanceBudgetRefreshInterval { get; set; } /// - /// The datetime when the current rebalance budget period started (Phase 3). + /// The datetime when the current rebalance budget period started. /// public DateTimeOffset? RebalanceBudgetStartDatetime { get; set; } /// - /// Maximum number of concurrent (Pending/InFlight) rebalances for this node (Phase 3). + /// Maximum number of concurrent (Pending/InFlight) rebalances for this node. /// public int? MaxRebalancesInFlight { get; set; } /// - /// Maximum acceptable rebalance cost-to-earn ratio used by the profitability gate (Phase 3). + /// Maximum acceptable rebalance cost-to-earn ratio used by the profitability gate. /// public double? MaxRebalanceCostToEarnRatio { get; set; } diff --git a/src/Data/Models/Rebalance.cs b/src/Data/Models/Rebalance.cs index ca04e394..125664f7 100644 --- a/src/Data/Models/Rebalance.cs +++ b/src/Data/Models/Rebalance.cs @@ -84,7 +84,7 @@ public class Rebalance : Entity /// /// Fee reserved for this in-flight rebalance so its spend counts against the node budget - /// before settles (Phase 3 in-flight budget accounting). + /// before settles (in-flight budget accounting). /// public long? ReservedFeeSats { get; set; } diff --git a/src/Data/Repositories/ChannelFeeStateRepository.cs b/src/Data/Repositories/ChannelFeeStateRepository.cs index f20e4710..c348436f 100644 --- a/src/Data/Repositories/ChannelFeeStateRepository.cs +++ b/src/Data/Repositories/ChannelFeeStateRepository.cs @@ -23,9 +23,6 @@ namespace NodeGuard.Data.Repositories; -/// -/// Declared for the Phase 1 batched migration; the Phase 2 fee engine is the first writer. -/// public class ChannelFeeStateRepository : IChannelFeeStateRepository { private readonly IDbContextFactory _dbContextFactory; diff --git a/src/Data/Repositories/Interfaces/IChannelFeeStateRepository.cs b/src/Data/Repositories/Interfaces/IChannelFeeStateRepository.cs index 6ceaaa57..930230ee 100644 --- a/src/Data/Repositories/Interfaces/IChannelFeeStateRepository.cs +++ b/src/Data/Repositories/Interfaces/IChannelFeeStateRepository.cs @@ -21,9 +21,6 @@ namespace NodeGuard.Data.Repositories.Interfaces; -/// -/// Declared for Phase 1 (batched migration) but not exercised until the Phase 2 fee engine. -/// public interface IChannelFeeStateRepository { Task GetByChannelId(int channelId); diff --git a/src/Data/Repositories/Interfaces/IForwardingHtlcEventRepository.cs b/src/Data/Repositories/Interfaces/IForwardingHtlcEventRepository.cs index 0b373b18..6f19f8f3 100644 --- a/src/Data/Repositories/Interfaces/IForwardingHtlcEventRepository.cs +++ b/src/Data/Repositories/Interfaces/IForwardingHtlcEventRepository.cs @@ -40,7 +40,7 @@ public interface IForwardingHtlcEventRepository /// /// Σ FeeMsat of succeeded forwards where OutgoingChannelId == chanIdLnd, since the window /// start. Attributed to the outgoing side (the channel whose liquidity was spent) so - /// per-channel revenue sums don't double-count. Phase 2 prioritization input. + /// per-channel revenue sums don't double-count. /// Task GetOrganicFeesEarnedMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since); } \ No newline at end of file diff --git a/src/Data/Repositories/Interfaces/IRebalanceRepository.cs b/src/Data/Repositories/Interfaces/IRebalanceRepository.cs index f8cca49b..25dd57c8 100644 --- a/src/Data/Repositories/Interfaces/IRebalanceRepository.cs +++ b/src/Data/Repositories/Interfaces/IRebalanceRepository.cs @@ -53,8 +53,8 @@ public interface IRebalanceRepository (bool, string?) Update(Rebalance rebalance); /// - /// Counts non-terminal rebalances (Pending + InFlight) for a node. Used by the Phase 3 - /// in-flight cap. Declared in Phase 1 alongside the routing-engine repositories. + /// Counts non-terminal rebalances (Pending + InFlight) for a node. Used by the + /// in-flight cap. /// Task GetInFlightByNode(int nodeId); diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index 15844e41..65b1cadc 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -207,68 +207,61 @@ public class Constants public static int REBALANCE_RECONCILE_TERMINAL_WINDOW_HOURS = 24; // ── Routing Engine (heuristic routing-optimization engine) ────────────────────────── - // Phase 1 (foundation + signal) reads the constants in this first block. The Phase 2 - // fee-engine block below is declared now so the migration/config surface is touched once, - // but nothing reads it until Phase 2. /// /// Global kill switch for the whole routing engine. Every routing-engine job checks this - /// first and returns immediately when false. Defaults OFF. Env: ROUTING_ENGINE_ENABLED. + /// first and returns immediately when false. /// public static bool ROUTING_ENGINE_ENABLED = false; /// /// Age gate for categorization: channels younger than this many blocks stay Uncategorized - /// at target 0.5. Default 3024 = 21 block-days. Env: ROUTING_ENGINE_CATEGORIZATION_MIN_AGE_BLOCKS. + /// at target 0.5. Default 3024 = 21 block-days. /// public static uint ROUTING_ENGINE_CATEGORIZATION_MIN_AGE_BLOCKS = 3024; /// - /// Categorization + net-flow lookback window over ForwardingHtlcEvent, in days. Default 21. - /// Env: ROUTING_ENGINE_FLOW_WINDOW_DAYS. + /// Categorization + net-flow lookback window over ForwardingHtlcEvent, in days. /// public static int ROUTING_ENGINE_FLOW_WINDOW_DAYS = 21; /// /// Minimum total in-window flow (push+pull) in msat required to categorize a channel; below - /// this it stays / decays to Uncategorized. Default 10_000_000_000 (10M sat). - /// Env: ROUTING_ENGINE_FLOW_MIN_MSAT. + /// this it stays / decays to Uncategorized. /// public static long ROUTING_ENGINE_FLOW_MIN_MSAT = 10_000_000_000; /// /// |NetFlowRatio| beyond this classifies a channel as Sink (positive) or Source (negative); - /// inside the band it is Bidirectional. Default 0.25. Env: ROUTING_ENGINE_CATEGORY_NET_FLOW_THRESHOLD. + /// inside the band it is Bidirectional. /// public static double ROUTING_ENGINE_CATEGORY_NET_FLOW_THRESHOLD = 0.25; /// /// Proportional gain mapping net-flow to target drift: target_goal = 0.5 + clamp(K·netFlowRatio, ±maxDrift). - /// Default 0.70. Env: ROUTING_ENGINE_TARGET_K. /// public static double ROUTING_ENGINE_TARGET_K = 0.70; /// /// Maximum drift of target_goal away from 0.5 (reached at |netFlowRatio| = 0.5). Default 0.35, - /// giving a target_goal range of [0.15, 0.85]. Env: ROUTING_ENGINE_TARGET_MAX_DRIFT. + /// giving a target_goal range of [0.15, 0.85]. /// public static double ROUTING_ENGINE_TARGET_MAX_DRIFT = 0.35; /// /// EWMA smoothing factor folding target_goal into the stored TargetLocalRatio. Default 0.10 - /// (~10 cycles to converge). Env: ROUTING_ENGINE_TARGET_ALPHA. + /// (19 cycles to converge). ⍺ = 2/(cycles+1). /// public static double ROUTING_ENGINE_TARGET_ALPHA = 0.10; /// /// EWMA smoothing factor for EmaLocalRatio (~24h effective window at 30-min sampling). - /// Default 0.04. Env: ROUTING_ENGINE_FEE_EMA_ALPHA. + /// Default 0.04 (49 cycles to converge). ⍺ = 2/(cycles+1). /// public static double ROUTING_ENGINE_FEE_EMA_ALPHA = 0.04; /// /// Consecutive divergent cycles required before a category flip commits (anti-flap hysteresis). - /// Default 3. Env: ROUTING_ENGINE_CATEGORY_FLIP_HYSTERESIS_CYCLES. /// public static int ROUTING_ENGINE_CATEGORY_FLIP_HYSTERESIS_CYCLES = 3; @@ -278,7 +271,6 @@ public class Constants /// public static int ROUTING_ENGINE_JOB_INTERVAL_MINUTES = 30; - // ── Routing Engine — Phase 2 fee engine (declared now, unused in Phase 1) ──────────── public static double ROUTING_ENGINE_FEE_KP_OUT = 0.8; public static double ROUTING_ENGINE_FEE_KI = 0.5; public static double ROUTING_ENGINE_FEE_DEADBAND = 0.03; @@ -565,7 +557,7 @@ static Constants() var reJobInterval = Environment.GetEnvironmentVariable("ROUTING_ENGINE_JOB_INTERVAL_MINUTES"); if (reJobInterval != null) ROUTING_ENGINE_JOB_INTERVAL_MINUTES = int.Parse(reJobInterval); - // Routing Engine — Phase 2 fee engine (declared now, unused in Phase 1) + // Routing Engine var feeKpOut = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_KP_OUT"); if (feeKpOut != null) ROUTING_ENGINE_FEE_KP_OUT = double.Parse(feeKpOut, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); diff --git a/src/Jobs/TargetRatioReevaluationJob.cs b/src/Jobs/TargetRatioReevaluationJob.cs index 2b5d6080..ca1df1f7 100644 --- a/src/Jobs/TargetRatioReevaluationJob.cs +++ b/src/Jobs/TargetRatioReevaluationJob.cs @@ -27,11 +27,11 @@ namespace NodeGuard.Jobs; /// -/// Phase 1 of the routing engine. Read-only: for every owned, open channel it refreshes -/// ChannelRoutingState — EMA-smoothed local ratio, net-flow, peer category (with hysteresis), -/// and dynamic target ratio — from settled forwarding history and live ListChannels. Writes -/// only to Postgres; performs zero LND writes. Guarded by the global ROUTING_ENGINE_ENABLED -/// kill switch. +/// For every owned, open channel it refreshes ChannelRoutingState — EMA-smoothed +/// local ratio, net-flow, peer category (with hysteresis), and dynamic target +/// ratio — from settled forwarding history and live ListChannels. Writes only +/// to Postgres; performs zero LND writes. +/// Guarded by the global ROUTING_ENGINE_ENABLED kill switch. /// [DisallowConcurrentExecution] public class TargetRatioReevaluationJob : IJob diff --git a/src/Pages/Nodes.razor b/src/Pages/Nodes.razor index dbb93f31..1b954c29 100644 --- a/src/Pages/Nodes.razor +++ b/src/Pages/Nodes.razor @@ -438,10 +438,7 @@ - These settings configure the heuristic routing engine. They take effect once the - fee engine (Phase 2) and automated rebalancer (Phase 3) ship — enabling them here - stages a node's configuration ahead of time. The global ROUTING_ENGINE_ENABLED - kill switch and ROUTING_ENGINE_DRY_RUN flag still gate everything. + These settings configure the heuristic routing engine. The engine can automatically adjust channel fees and initiate circular rebalances to steer channels toward their target ratio. The engine is disabled by default and must be enabled for each node individually. diff --git a/src/Program.cs b/src/Program.cs index e59d8f83..239dc583 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -272,7 +272,7 @@ public static async Task Main(string[] args) }); }); - //Target Ratio Reevaluation Job (Routing Engine — Phase 1, read-only) + //Target Ratio Reevaluation Job q.AddJob(opts => { opts.DisallowConcurrentExecution(); From b5e6f0cd06d37bdf9a83f0ad345b6f034856878b Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 14 Jul 2026 18:13:07 +0200 Subject: [PATCH 15/23] docs: enhance comments in Constants.cs for clarity on routing engine parameters --- src/Helpers/Constants.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index 65b1cadc..ad797e80 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -238,7 +238,7 @@ public class Constants public static double ROUTING_ENGINE_CATEGORY_NET_FLOW_THRESHOLD = 0.25; /// - /// Proportional gain mapping net-flow to target drift: target_goal = 0.5 + clamp(K·netFlowRatio, ±maxDrift). + /// Proportional gain mapping net-flow to target drift: target_goal = 0.5 + clamp(K·netFlowRatio, ±maxDrift). Simply, how far the target drifts from 0.5 (default) per unit of flow imbalance. /// public static double ROUTING_ENGINE_TARGET_K = 0.70; @@ -250,13 +250,15 @@ public class Constants /// /// EWMA smoothing factor folding target_goal into the stored TargetLocalRatio. Default 0.10 - /// (19 cycles to converge). ⍺ = 2/(cycles+1). + /// (19 cycles to converge). ⍺ = 2/(cycles+1). This smooths out the target ratio to avoid + /// overreacting to transient flow spikes. /// public static double ROUTING_ENGINE_TARGET_ALPHA = 0.10; /// /// EWMA smoothing factor for EmaLocalRatio (~24h effective window at 30-min sampling). - /// Default 0.04 (49 cycles to converge). ⍺ = 2/(cycles+1). + /// Default 0.04 (49 cycles to converge). ⍺ = 2/(cycles+1). It smooths out the observed + /// local ratio to avoid overreacting to transient flow spikes. /// public static double ROUTING_ENGINE_FEE_EMA_ALPHA = 0.04; From e5e4ac20ba30336750737c86d1592d6b526645bb Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 14 Jul 2026 18:30:37 +0200 Subject: [PATCH 16/23] refactor: remove circuit breaker feature --- src/Data/Models/ChannelFeeState.cs | 9 +-------- src/Data/Repositories/ChannelFeeStateRepository.cs | 1 - src/Helpers/Constants.cs | 4 ---- ...20260707155251_AddRoutingEngineFoundation.Designer.cs | 3 --- .../20260707155251_AddRoutingEngineFoundation.cs | 1 - src/Migrations/ApplicationDbContextModelSnapshot.cs | 3 --- 6 files changed, 1 insertion(+), 20 deletions(-) diff --git a/src/Data/Models/ChannelFeeState.cs b/src/Data/Models/ChannelFeeState.cs index 6bbf5415..83c1fd06 100644 --- a/src/Data/Models/ChannelFeeState.cs +++ b/src/Data/Models/ChannelFeeState.cs @@ -21,8 +21,7 @@ namespace NodeGuard.Data.Models; /// /// Per-channel fee-engine state (1:1 with ). Holds last-applied -/// policy + baseline snapshot (for rollback on disable) + a per-channel circuit-breaker -/// counter, all of which must survive restarts. +/// policy + baseline snapshot (for rollback on disable), all of which must survive restarts. /// public class ChannelFeeState : Entity { @@ -43,10 +42,4 @@ public class ChannelFeeState : Entity public uint? BaselineOutboundPpm { get; set; } public int? BaselineInboundBaseMsat { get; set; } public int? BaselineInboundPpm { get; set; } - - /// - /// Circuit-breaker counter: incremented on each consecutive failure to apply a fee update, - /// reset to 0 on success. - /// - public int ConsecutiveFailures { get; set; } = 0; } diff --git a/src/Data/Repositories/ChannelFeeStateRepository.cs b/src/Data/Repositories/ChannelFeeStateRepository.cs index c348436f..b37f1b6c 100644 --- a/src/Data/Repositories/ChannelFeeStateRepository.cs +++ b/src/Data/Repositories/ChannelFeeStateRepository.cs @@ -68,7 +68,6 @@ public async Task UpsertByChannelId(ChannelFeeState state) existing.BaselineOutboundPpm = state.BaselineOutboundPpm; existing.BaselineInboundBaseMsat = state.BaselineInboundBaseMsat; existing.BaselineInboundPpm = state.BaselineInboundPpm; - existing.ConsecutiveFailures = state.ConsecutiveFailures; existing.SetUpdateDatetime(); } diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index ad797e80..d73be85d 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -286,7 +286,6 @@ public class Constants public static int ROUTING_ENGINE_FEE_MAX_INBOUND_PPM = 100; public static int ROUTING_ENGINE_FEE_MIN_UPDATE_INTERVAL_MINUTES = 30; public static int ROUTING_ENGINE_FEE_MAX_UPDATES_PER_RUN = 50; - public static int ROUTING_ENGINE_FEE_MAX_CONSECUTIVE_FAILURES = 5; public static long ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS = 10_000_000; public static uint ROUTING_ENGINE_FEE_BASELINE_PPM_SOURCE = 50; public static uint ROUTING_ENGINE_FEE_BASELINE_PPM_BIDIRECTIONAL = 500; @@ -599,9 +598,6 @@ static Constants() var feeMaxUpdatesPerRun = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_MAX_UPDATES_PER_RUN"); if (feeMaxUpdatesPerRun != null) ROUTING_ENGINE_FEE_MAX_UPDATES_PER_RUN = int.Parse(feeMaxUpdatesPerRun); - var feeMaxConsecutiveFailures = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_MAX_CONSECUTIVE_FAILURES"); - if (feeMaxConsecutiveFailures != null) ROUTING_ENGINE_FEE_MAX_CONSECUTIVE_FAILURES = int.Parse(feeMaxConsecutiveFailures); - var feeMinChannelSize = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS"); if (feeMinChannelSize != null) ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS = long.Parse(feeMinChannelSize); diff --git a/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs b/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs index 1f045b8f..f1e8ab47 100644 --- a/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs +++ b/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs @@ -470,9 +470,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("ChannelId") .HasColumnType("integer"); - b.Property("ConsecutiveFailures") - .HasColumnType("integer"); - b.Property("CreationDatetime") .HasColumnType("timestamp with time zone"); diff --git a/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs b/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs index 3a2facd1..7ae58b4f 100644 --- a/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs +++ b/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs @@ -109,7 +109,6 @@ protected override void Up(MigrationBuilder migrationBuilder) BaselineOutboundPpm = table.Column(type: "bigint", nullable: true), BaselineInboundBaseMsat = table.Column(type: "integer", nullable: true), BaselineInboundPpm = table.Column(type: "integer", nullable: true), - ConsecutiveFailures = table.Column(type: "integer", nullable: false), CreationDatetime = table.Column(type: "timestamp with time zone", nullable: false), UpdateDatetime = table.Column(type: "timestamp with time zone", nullable: false) }, diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index 5f9d7a0d..6b69171d 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -467,9 +467,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ChannelId") .HasColumnType("integer"); - b.Property("ConsecutiveFailures") - .HasColumnType("integer"); - b.Property("CreationDatetime") .HasColumnType("timestamp with time zone"); From 0bab2650e7dd7b93fb846960f9f7756a83da29d7 Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 15 Jul 2026 10:58:32 +0200 Subject: [PATCH 17/23] refactor: remove ReservedFeeSats from Rebalance model and related migrations --- src/Data/Models/Rebalance.cs | 6 ------ .../Repositories/Interfaces/IRebalanceRepository.cs | 5 +++-- src/Data/Repositories/RebalanceRepository.cs | 7 +++++-- ...260707155251_AddRoutingEngineFoundation.Designer.cs | 3 --- .../20260707155251_AddRoutingEngineFoundation.cs | 10 ---------- src/Migrations/ApplicationDbContextModelSnapshot.cs | 3 --- .../Data/Repositories/RebalanceRepositoryTests.cs | 9 ++++++--- 7 files changed, 14 insertions(+), 29 deletions(-) diff --git a/src/Data/Models/Rebalance.cs b/src/Data/Models/Rebalance.cs index 125664f7..f5d14db7 100644 --- a/src/Data/Models/Rebalance.cs +++ b/src/Data/Models/Rebalance.cs @@ -82,12 +82,6 @@ public class Rebalance : Entity public long? FeePaidMsat { get; set; } - /// - /// Fee reserved for this in-flight rebalance so its spend counts against the node budget - /// before settles (in-flight budget accounting). - /// - public long? ReservedFeeSats { get; set; } - [NotMapped] public long? EffectivePpm => FeePaidMsat.HasValue && SatsAmount > 0 ? FeePaidMsat.Value * 1_000L / SatsAmount diff --git a/src/Data/Repositories/Interfaces/IRebalanceRepository.cs b/src/Data/Repositories/Interfaces/IRebalanceRepository.cs index 25dd57c8..5ba23cee 100644 --- a/src/Data/Repositories/Interfaces/IRebalanceRepository.cs +++ b/src/Data/Repositories/Interfaces/IRebalanceRepository.cs @@ -60,8 +60,9 @@ public interface IRebalanceRepository /// /// Budget consumption for a node since (by CreationDatetime): - /// non-terminal rows count MAX(FeePaidSats, ReservedFeeSats) so in-flight spend counts - /// immediately, Succeeded rows count FeePaidSats, other terminal rows count 0. + /// non-terminal rows count MAX(FeePaidSats, RequestedAmountSats × MaxFeePct) so in-flight + /// spend counts immediately against a conservative on-demand reservation, Succeeded rows + /// count FeePaidSats, other terminal rows count 0. /// Task GetConsumedFeesSince(int nodeId, DateTimeOffset since); } diff --git a/src/Data/Repositories/RebalanceRepository.cs b/src/Data/Repositories/RebalanceRepository.cs index 668ef226..2dd82564 100644 --- a/src/Data/Repositories/RebalanceRepository.cs +++ b/src/Data/Repositories/RebalanceRepository.cs @@ -160,16 +160,19 @@ public async Task GetConsumedFeesSince(int nodeId, DateTimeOffset since) && (r.Status == RebalanceStatus.Pending || r.Status == RebalanceStatus.InFlight || r.Status == RebalanceStatus.Succeeded)) - .Select(r => new { r.Status, r.FeePaidSats, r.ReservedFeeSats }) + .Select(r => new { r.Status, r.FeePaidSats, r.RequestedAmountSats, r.MaxFeePct }) .ToListAsync(); long total = 0; 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); total += r.Status == RebalanceStatus.Succeeded ? feePaid - : Math.Max(feePaid, r.ReservedFeeSats ?? 0); // in-flight spend counts immediately + : reserved; } return total; diff --git a/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs b/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs index f1e8ab47..b434742e 100644 --- a/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs +++ b/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs @@ -1154,9 +1154,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("RequestedAmountSats") .HasColumnType("bigint"); - b.Property("ReservedFeeSats") - .HasColumnType("bigint"); - b.Property("RetryMaxFeePct") .HasColumnType("double precision"); diff --git a/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs b/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs index 7ae58b4f..75dd789d 100644 --- a/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs +++ b/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs @@ -12,12 +12,6 @@ public partial class AddRoutingEngineFoundation : Migration /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.AddColumn( - name: "ReservedFeeSats", - table: "Rebalances", - type: "bigint", - nullable: true); - migrationBuilder.AddColumn( name: "AllowPositiveInboundFees", table: "Nodes", @@ -184,10 +178,6 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.DropTable( name: "ChannelRoutingStates"); - migrationBuilder.DropColumn( - name: "ReservedFeeSats", - table: "Rebalances"); - migrationBuilder.DropColumn( name: "AllowPositiveInboundFees", table: "Nodes"); diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index 6b69171d..5e81e2e5 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1151,9 +1151,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RequestedAmountSats") .HasColumnType("bigint"); - b.Property("ReservedFeeSats") - .HasColumnType("bigint"); - b.Property("RetryMaxFeePct") .HasColumnType("double precision"); diff --git a/test/NodeGuard.Tests/Data/Repositories/RebalanceRepositoryTests.cs b/test/NodeGuard.Tests/Data/Repositories/RebalanceRepositoryTests.cs index 2fe24478..60c1ea29 100644 --- a/test/NodeGuard.Tests/Data/Repositories/RebalanceRepositoryTests.cs +++ b/test/NodeGuard.Tests/Data/Repositories/RebalanceRepositoryTests.cs @@ -44,7 +44,7 @@ public class RebalanceRepositoryRoutingEngineTests } private static Rebalance Reb(int nodeId, RebalanceStatus status, DateTimeOffset created, - long? feePaid = null, long? reserved = null) + long? feePaid = null, long reserved = 0) => new() { NodeId = nodeId, @@ -52,7 +52,10 @@ private static Rebalance Reb(int nodeId, RebalanceStatus status, DateTimeOffset CreationDatetime = created, UpdateDatetime = created, FeePaidSats = feePaid, - ReservedFeeSats = reserved, + // The reservation is derived on demand as RequestedAmountSats × MaxFeePct (÷100). + // Encode the desired reserved amount at a 1% cap so the arithmetic yields it exactly. + RequestedAmountSats = reserved * 100, + MaxFeePct = 1.0, }; [Fact] @@ -92,7 +95,7 @@ public async Task GetConsumedFeesSince_UsesReservedOrPaidForInFlightAndPaidForSu Reb(NodeId, RebalanceStatus.InFlight, now, feePaid: 50, reserved: 30), // Succeeded → counts paid (200). Reb(NodeId, RebalanceStatus.Succeeded, now, feePaid: 200, reserved: 999), - // Failed with a stale reservation → nothing consumed (excluded). + // Failed → excluded regardless of amount/fee cap (nothing consumed). Reb(NodeId, RebalanceStatus.Failed, now, feePaid: null, reserved: 999), // Succeeded but before the window → excluded. Reb(NodeId, RebalanceStatus.Succeeded, now.AddHours(-2), feePaid: 777), From 02058b4ef6d2843644e358161f4ca2e50e4920e8 Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 15 Jul 2026 11:53:23 +0200 Subject: [PATCH 18/23] test: clarify comments and update test logic in RebalanceRepositoryRoutingEngineTests --- .../Data/Repositories/RebalanceRepositoryTests.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/test/NodeGuard.Tests/Data/Repositories/RebalanceRepositoryTests.cs b/test/NodeGuard.Tests/Data/Repositories/RebalanceRepositoryTests.cs index 60c1ea29..8ccda927 100644 --- a/test/NodeGuard.Tests/Data/Repositories/RebalanceRepositoryTests.cs +++ b/test/NodeGuard.Tests/Data/Repositories/RebalanceRepositoryTests.cs @@ -52,8 +52,7 @@ private static Rebalance Reb(int nodeId, RebalanceStatus status, DateTimeOffset CreationDatetime = created, UpdateDatetime = created, FeePaidSats = feePaid, - // The reservation is derived on demand as RequestedAmountSats × MaxFeePct (÷100). - // Encode the desired reserved amount at a 1% cap so the arithmetic yields it exactly. + // Encode the desired reserved amount at a 1% cap. RequestedAmountSats = reserved * 100, MaxFeePct = 1.0, }; @@ -82,7 +81,7 @@ public async Task GetInFlightByNode_CountsOnlyPendingAndInFlightForThatNode() } [Fact] - public async Task GetConsumedFeesSince_UsesReservedOrPaidForInFlightAndPaidForSucceeded() + public async Task GetConsumedFeesSince_UsesReservationForNonTerminalAndPaidForSucceeded() { var (sut, seed) = SetupDb(); var now = DateTimeOffset.UtcNow; @@ -91,8 +90,8 @@ public async Task GetConsumedFeesSince_UsesReservedOrPaidForInFlightAndPaidForSu seed.Rebalances.AddRange( // Pending with only a reservation → counts reserved (100). Reb(NodeId, RebalanceStatus.Pending, now, feePaid: null, reserved: 100), - // InFlight where paid already exceeds reserved → counts MAX (50). - Reb(NodeId, RebalanceStatus.InFlight, now, feePaid: 50, reserved: 30), + // InFlight → counts the reservation (30), ignoring the partial FeePaidSats (50). + Reb(NodeId, RebalanceStatus.InFlight, now, feePaid: null, reserved: 30), // Succeeded → counts paid (200). Reb(NodeId, RebalanceStatus.Succeeded, now, feePaid: 200, reserved: 999), // Failed → excluded regardless of amount/fee cap (nothing consumed). @@ -104,6 +103,6 @@ public async Task GetConsumedFeesSince_UsesReservedOrPaidForInFlightAndPaidForSu ); await seed.SaveChangesAsync(); - (await sut.GetConsumedFeesSince(NodeId, since)).Should().Be(100 + 50 + 200); + (await sut.GetConsumedFeesSince(NodeId, since)).Should().Be(100 + 30 + 200); } } From a706fec42f35043a765eb0099c39bed9689cf50e Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 15 Jul 2026 12:12:57 +0200 Subject: [PATCH 19/23] fix: ensure only active channels are processed in TargetRatioReevaluationJob --- src/Jobs/TargetRatioReevaluationJob.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Jobs/TargetRatioReevaluationJob.cs b/src/Jobs/TargetRatioReevaluationJob.cs index ca1df1f7..fb3bf2f4 100644 --- a/src/Jobs/TargetRatioReevaluationJob.cs +++ b/src/Jobs/TargetRatioReevaluationJob.cs @@ -151,6 +151,12 @@ private async Task ReevaluateNode( continue; } + // Act only on channels that are active. + if (!lndChannel.Active) + { + continue; + } + await ReevaluateChannel(managedNode, lndChannel, dbChannel, chainTip.Value, windowStart, now); } catch (Exception ex) From 191b202e449a062faac0099171dee091aad9ac87 Mon Sep 17 00:00:00 2001 From: Marcos Date: Thu, 16 Jul 2026 16:19:15 +0200 Subject: [PATCH 20/23] fix: remove restore fees when auto fee enabled --- src/Data/ApplicationDbContext.cs | 1 - src/Data/Models/Node.cs | 7 ------- ...60707155251_AddRoutingEngineFoundation.Designer.cs | 5 ----- .../20260707155251_AddRoutingEngineFoundation.cs | 11 ----------- src/Migrations/ApplicationDbContextModelSnapshot.cs | 5 ----- src/Pages/Nodes.razor | 9 --------- 6 files changed, 38 deletions(-) diff --git a/src/Data/ApplicationDbContext.cs b/src/Data/ApplicationDbContext.cs index 8c0bd03a..dce774e2 100644 --- a/src/Data/ApplicationDbContext.cs +++ b/src/Data/ApplicationDbContext.cs @@ -130,7 +130,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // These default ON: existing rows must be backfilled true (the C# initializer // only affects new in-code instances, not the DB column default / migration backfill). modelBuilder.Entity().Property(x => x.IsDynamicFeeEnabled).HasDefaultValue(false); - modelBuilder.Entity().Property(x => x.RestoreFeeBaselineOnDisable).HasDefaultValue(true); modelBuilder.Entity().Property(x => x.RoutingEngineDryRun).HasDefaultValue(false); base.OnModelCreating(modelBuilder); diff --git a/src/Data/Models/Node.cs b/src/Data/Models/Node.cs index b6008b04..97a9e5f6 100644 --- a/src/Data/Models/Node.cs +++ b/src/Data/Models/Node.cs @@ -152,13 +152,6 @@ public class Node : Entity /// public bool AllowPositiveInboundFees { get; set; } = false; - /// - /// When flips off, restore each channel's - /// captured fee baseline in one final write. When false, last-set fees are frozen. - /// Defaults ON. - /// - public bool RestoreFeeBaselineOnDisable { get; set; } = true; - /// /// Per-node dry-run for the routing-engine actuators: when true they log the /// fee/rebalance they would perform without calling LND. diff --git a/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs b/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs index b434742e..9872a0be 100644 --- a/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs +++ b/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs @@ -1069,11 +1069,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("RebalanceBudgetStartDatetime") .HasColumnType("timestamp with time zone"); - b.Property("RestoreFeeBaselineOnDisable") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - b.Property("RoutingEngineDryRun") .ValueGeneratedOnAdd() .HasColumnType("boolean") diff --git a/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs b/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs index 75dd789d..0bfb554e 100644 --- a/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs +++ b/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs @@ -63,13 +63,6 @@ protected override void Up(MigrationBuilder migrationBuilder) type: "timestamp with time zone", nullable: true); - migrationBuilder.AddColumn( - name: "RestoreFeeBaselineOnDisable", - table: "Nodes", - type: "boolean", - nullable: false, - defaultValue: true); - migrationBuilder.AddColumn( name: "RoutingEngineDryRun", table: "Nodes", @@ -210,10 +203,6 @@ protected override void Down(MigrationBuilder migrationBuilder) name: "RebalanceBudgetStartDatetime", table: "Nodes"); - migrationBuilder.DropColumn( - name: "RestoreFeeBaselineOnDisable", - table: "Nodes"); - migrationBuilder.DropColumn( name: "RoutingEngineDryRun", table: "Nodes"); diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index 5e81e2e5..0ef42c64 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1066,11 +1066,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RebalanceBudgetStartDatetime") .HasColumnType("timestamp with time zone"); - b.Property("RestoreFeeBaselineOnDisable") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - b.Property("RoutingEngineDryRun") .ValueGeneratedOnAdd() .HasColumnType("boolean") diff --git a/src/Pages/Nodes.razor b/src/Pages/Nodes.razor index 1b954c29..d4c9e2d1 100644 --- a/src/Pages/Nodes.razor +++ b/src/Pages/Nodes.razor @@ -461,15 +461,6 @@ Allow positive inbound fees - - - Restore Baseline on Disable - When dynamic fees are turned off, restore each channel's captured fee baseline in one final write. When off, last-set fees are frozen in place. - - Restore fee baseline on disable - - } From 3f8c93d5395fda6dcb37271e49f34471c1844182 Mon Sep 17 00:00:00 2001 From: Marcos Date: Thu, 16 Jul 2026 17:06:25 +0200 Subject: [PATCH 21/23] feat: allow positive inbound fees if node is enabled --- src/Pages/Channels.razor | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Pages/Channels.razor b/src/Pages/Channels.razor index 199fb249..4f681213 100644 --- a/src/Pages/Channels.razor +++ b/src/Pages/Channels.razor @@ -684,7 +684,7 @@ Inbound base fee (msat) Optional inbound base fee. When set, it must be zero or below. - @@ -697,7 +697,7 @@ Inbound fee rate (ppm) Optional inbound proportional fee. When set, it must be zero or below. - @@ -747,6 +747,7 @@ private Validations? _channelFeePolicyValidationsRef; private ChannelFeePolicyForm _channelFeePolicy = new(); private CurrentFeePolicies _currentFeePolicies = new() { IsLoading = false }; + private bool _allowedPositiveInboundFeesSelectedChannel; private string _selectedChannelManagementTab = ChannelManagementLiquidityTab; private string? _selectedChannelOutpoint; private string? _selectedChannelNodePubKey; @@ -1072,6 +1073,7 @@ return; } + _allowedPositiveInboundFeesSelectedChannel = node.AllowPositiveInboundFees; _currentFeePolicies.IsLoading = true; _currentFeePolicies.ErrorMessage = null; StateHasChanged(); From e4d0ea8845515fba03ead6335d9bec5c18e3aeb2 Mon Sep 17 00:00:00 2001 From: Marcos Date: Thu, 16 Jul 2026 18:04:27 +0200 Subject: [PATCH 22/23] feat: check for enabling channel auto fees and blocing changing them if enabled --- src/Pages/Channels.razor | 186 ++++++++++++++++++++++++--------------- 1 file changed, 114 insertions(+), 72 deletions(-) diff --git a/src/Pages/Channels.razor b/src/Pages/Channels.razor index 4f681213..37fcd4dd 100644 --- a/src/Pages/Channels.razor +++ b/src/Pages/Channels.razor @@ -629,84 +629,105 @@
Set New Fee Policy
- - - - - - Base fee (msat) - Flat outbound fee charged for forwarded payments. - - - - - - - - - - - - Fee rate (ppm) - Outbound proportional fee in parts per million. - - - - - - - - - - - - Time lock delta - CLTV delta advertised for forwarded HTLCs on this channel. - - - - - - - + - Inbound fee policy - Optional. Leave both inbound fields blank to keep the current inbound fee - policy unchanged. + Dynamic Fee Management (Channel) + When enabled, the routing engine controls this channel fees and manual fee updates are blocked. + + Enable dynamic fees for this channel + - - + + + + @if (_selectedChannel.IsDynamicFeeEnabled) + { + + + ⚠️ Dynamic fees are enabled for this channel. Manual fee adjustment is not allowed. Please disable dynamic fees to set a new fee policy. + + + } + else{ + - Inbound base fee (msat) - Optional inbound base fee. When set, it must be zero or below. - - - - - - + + Base fee (msat) + Flat outbound fee charged for forwarded payments. + + + + + + - Inbound fee rate (ppm) - Optional inbound proportional fee. When set, it must be zero - or below. - - - - - + + Fee rate (ppm) + Outbound proportional fee in parts per million. + + + + + + - - + + + + Time lock delta + CLTV delta advertised for forwarded HTLCs on this channel. + + + + + + + + + Inbound fee policy + Optional. Leave both inbound fields blank to keep the current inbound fee + policy unchanged. + + + + + + Inbound base fee (msat) + Optional inbound base fee. When set, it must be zero or below. + + + + + + + + + + + Inbound fee rate (ppm) + Optional inbound proportional fee. When set, it must be zero + or below. + + + + + + + + + + } @@ -1193,15 +1214,36 @@ private async Task SaveAndCloseChannelFeePolicyTab() { - if (_channelFeePolicyValidationsRef == null || !await _channelFeePolicyValidationsRef.ValidateAll()) + if (_selectedChannel == null || string.IsNullOrWhiteSpace(_selectedChannelOutpoint) || string.IsNullOrWhiteSpace(_selectedChannelNodePubKey)) { - ToastService.ShowError("Please fix the errors"); + ToastService.ShowError("The selected channel could not be resolved for fee policy updates."); return; } - if (_selectedChannel == null || string.IsNullOrWhiteSpace(_selectedChannelOutpoint) || string.IsNullOrWhiteSpace(_selectedChannelNodePubKey)) + var channelUpdateResult = ChannelRepository.Update(_selectedChannel); + if (!channelUpdateResult.Item1) { - ToastService.ShowError("The selected channel could not be resolved for fee policy updates."); + ToastService.ShowError("Could not update channel dynamic fee setting."); + await AuditService.LogAsync(AuditActionType.Update, AuditEventType.Failure, AuditObjectType.Channel, + _selectedChannel.Id.ToString(), + $"Failed to update dynamic fee flag. ChanId: {_selectedChannel.ChanId}, IsDynamicFeeEnabled: {_selectedChannel.IsDynamicFeeEnabled}"); + return; + } + + await AuditService.LogAsync(AuditActionType.Update, AuditEventType.Success, AuditObjectType.Channel, + _selectedChannel.Id.ToString(), + $"Updated dynamic fee flag. ChanId: {_selectedChannel.ChanId}, IsDynamicFeeEnabled: {_selectedChannel.IsDynamicFeeEnabled}"); + + if (_selectedChannel.IsDynamicFeeEnabled) + { + ToastService.ShowSuccess("Channel dynamic fee management enabled."); + await CloseChannelManagementModal(); + return; + } + + if (_channelFeePolicyValidationsRef == null || !await _channelFeePolicyValidationsRef.ValidateAll()) + { + ToastService.ShowError("Please fix the errors"); return; } From 90772bc1200d900fdb0b7c9fceab96dbc2a38ae6 Mon Sep 17 00:00:00 2001 From: Marcos Date: Fri, 17 Jul 2026 19:15:33 +0200 Subject: [PATCH 23/23] refactor: remove baseline fee properties from ChannelFeeState and related migrations --- src/Data/Models/ChannelFeeState.cs | 8 +------- .../Repositories/ChannelFeeStateRepository.cs | 5 ----- ...7155251_AddRoutingEngineFoundation.Designer.cs | 15 --------------- .../20260707155251_AddRoutingEngineFoundation.cs | 5 ----- .../ApplicationDbContextModelSnapshot.cs | 15 --------------- 5 files changed, 1 insertion(+), 47 deletions(-) diff --git a/src/Data/Models/ChannelFeeState.cs b/src/Data/Models/ChannelFeeState.cs index 83c1fd06..118b67bb 100644 --- a/src/Data/Models/ChannelFeeState.cs +++ b/src/Data/Models/ChannelFeeState.cs @@ -21,7 +21,7 @@ namespace NodeGuard.Data.Models; /// /// Per-channel fee-engine state (1:1 with ). Holds last-applied -/// policy + baseline snapshot (for rollback on disable), all of which must survive restarts. +/// policy and control state that must survive restarts. /// public class ChannelFeeState : Entity { @@ -36,10 +36,4 @@ public class ChannelFeeState : Entity public int? LastAppliedInboundPpm { get; set; } public double? LastComputedTarget { get; set; } public double? LastObservedRatio { get; set; } - - public DateTimeOffset? BaselineCapturedAt { get; set; } - public long? BaselineOutboundBaseFeeMsat { get; set; } - public uint? BaselineOutboundPpm { get; set; } - public int? BaselineInboundBaseMsat { get; set; } - public int? BaselineInboundPpm { get; set; } } diff --git a/src/Data/Repositories/ChannelFeeStateRepository.cs b/src/Data/Repositories/ChannelFeeStateRepository.cs index b37f1b6c..baa3fc0e 100644 --- a/src/Data/Repositories/ChannelFeeStateRepository.cs +++ b/src/Data/Repositories/ChannelFeeStateRepository.cs @@ -63,11 +63,6 @@ public async Task UpsertByChannelId(ChannelFeeState state) existing.LastAppliedInboundPpm = state.LastAppliedInboundPpm; existing.LastComputedTarget = state.LastComputedTarget; existing.LastObservedRatio = state.LastObservedRatio; - existing.BaselineCapturedAt = state.BaselineCapturedAt; - existing.BaselineOutboundBaseFeeMsat = state.BaselineOutboundBaseFeeMsat; - existing.BaselineOutboundPpm = state.BaselineOutboundPpm; - existing.BaselineInboundBaseMsat = state.BaselineInboundBaseMsat; - existing.BaselineInboundPpm = state.BaselineInboundPpm; existing.SetUpdateDatetime(); } diff --git a/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs b/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs index 9872a0be..00f36473 100644 --- a/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs +++ b/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs @@ -452,21 +452,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - b.Property("BaselineCapturedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("BaselineInboundBaseMsat") - .HasColumnType("integer"); - - b.Property("BaselineInboundPpm") - .HasColumnType("integer"); - - b.Property("BaselineOutboundBaseFeeMsat") - .HasColumnType("bigint"); - - b.Property("BaselineOutboundPpm") - .HasColumnType("bigint"); - b.Property("ChannelId") .HasColumnType("integer"); diff --git a/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs b/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs index 0bfb554e..0d1e6578 100644 --- a/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs +++ b/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs @@ -91,11 +91,6 @@ protected override void Up(MigrationBuilder migrationBuilder) LastAppliedInboundPpm = table.Column(type: "integer", nullable: true), LastComputedTarget = table.Column(type: "double precision", nullable: true), LastObservedRatio = table.Column(type: "double precision", nullable: true), - BaselineCapturedAt = table.Column(type: "timestamp with time zone", nullable: true), - BaselineOutboundBaseFeeMsat = table.Column(type: "bigint", nullable: true), - BaselineOutboundPpm = table.Column(type: "bigint", nullable: true), - BaselineInboundBaseMsat = table.Column(type: "integer", nullable: true), - BaselineInboundPpm = table.Column(type: "integer", nullable: true), CreationDatetime = table.Column(type: "timestamp with time zone", nullable: false), UpdateDatetime = table.Column(type: "timestamp with time zone", nullable: false) }, diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index 0ef42c64..22fac1ad 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -449,21 +449,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - b.Property("BaselineCapturedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("BaselineInboundBaseMsat") - .HasColumnType("integer"); - - b.Property("BaselineInboundPpm") - .HasColumnType("integer"); - - b.Property("BaselineOutboundBaseFeeMsat") - .HasColumnType("bigint"); - - b.Property("BaselineOutboundPpm") - .HasColumnType("bigint"); - b.Property("ChannelId") .HasColumnType("integer");