Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0fa6aa5
feat: add GetInfo and GetBlockHeight methods to LightningClientServic…
markettes Jul 7, 2026
c60b85a
feat(migrations): add routing engine foundation with new columns and …
markettes Jul 7, 2026
42b04f4
feat: implement repositories for channel fee, flow analytics, and rou…
markettes Jul 7, 2026
7cf82ce
feat: add BlockHeightHelper and ChannelOwnershipHelper with correspon…
markettes Jul 7, 2026
1a4ffeb
feat: add PeerCategorizationService with tests for category computati…
markettes Jul 7, 2026
cd75994
feat: add TargetRatioReevaluationJob for routing engine with scheduli…
markettes Jul 8, 2026
62a35c8
feat: add heuristic routing engine configuration options for dynamic …
markettes Jul 8, 2026
1e31d41
Merge branch 'main' into feat/heuristic-fee-engine-phase1
markettes Jul 8, 2026
def4d49
feat: update ROUTING_ENGINE_DRY_RUN to handle null values in environm…
markettes Jul 8, 2026
789bb6f
chore: rename test file
markettes Jul 13, 2026
75539ca
feat: migrate channel flow analytics methods to forwarding HTLC event…
markettes Jul 14, 2026
cb3ef19
feat: update RoutingEngineDryRun default value to false and adjust re…
markettes Jul 14, 2026
bf9f5dc
fix: change default value of IsDynamicFeeEnabled to false in Channel …
markettes Jul 14, 2026
a38cc5e
feat: remove PeerCategorizationService from service registrations in …
markettes Jul 14, 2026
b9d125d
fix: remove every mention to phases or development steps
markettes Jul 14, 2026
b5e6f0c
docs: enhance comments in Constants.cs for clarity on routing engine …
markettes Jul 14, 2026
e5e4ac2
refactor: remove circuit breaker feature
markettes Jul 14, 2026
0bab265
refactor: remove ReservedFeeSats from Rebalance model and related mig…
markettes Jul 15, 2026
02058b4
test: clarify comments and update test logic in RebalanceRepositoryRo…
markettes Jul 15, 2026
a706fec
fix: ensure only active channels are processed in TargetRatioReevalua…
markettes Jul 15, 2026
191b202
fix: remove restore fees when auto fee enabled
markettes Jul 16, 2026
3f8c93d
feat: allow positive inbound fees if node is enabled
markettes Jul 16, 2026
e4d0ea8
feat: check for enabling channel auto fees and blocing changing them …
markettes Jul 16, 2026
90772bc
refactor: remove baseline fee properties from ChannelFeeState and rel…
markettes Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/Data/ApplicationDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,29 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.HasForeignKey(r => r.SourceChannelId)
.OnDelete(DeleteBehavior.Restrict);

// Routing engine: 1:1 read models keyed on ChannelId.
modelBuilder.Entity<ChannelRoutingState>()
.HasOne(x => x.Channel)
.WithOne()
.HasForeignKey<ChannelRoutingState>(x => x.ChannelId)
.OnDelete(DeleteBehavior.Cascade);

// Only one ChannelRoutingState per channel.
modelBuilder.Entity<ChannelRoutingState>().HasIndex(x => x.ChannelId).IsUnique();

modelBuilder.Entity<ChannelFeeState>()
.HasOne(x => x.Channel)
.WithOne()
.HasForeignKey<ChannelFeeState>(x => x.ChannelId)
.OnDelete(DeleteBehavior.Cascade);
// Only one ChannelFeeState per channel.
modelBuilder.Entity<ChannelFeeState>().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<Channel>().Property(x => x.IsDynamicFeeEnabled).HasDefaultValue(false);
modelBuilder.Entity<Node>().Property(x => x.RoutingEngineDryRun).HasDefaultValue(false);

base.OnModelCreating(modelBuilder);
}

Expand Down Expand Up @@ -149,5 +172,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
public DbSet<AuditLog> AuditLogs { get; set; }

public DbSet<ForwardingHtlcEvent> ForwardingHtlcEvents { get; set; }

public DbSet<ChannelRoutingState> ChannelRoutingStates { get; set; }

public DbSet<ChannelFeeState> ChannelFeeStates { get; set; }
}
}
6 changes: 6 additions & 0 deletions src/Data/Models/Channel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ public enum ChannelStatus
/// </summary>
public bool IsPrivate { get; set; }

/// <summary>
/// Per-channel opt-out for the dynamic fee engine. Defaults false; the node-level
/// <see cref="Node.DynamicFeeManagementEnabled"/> flag still gates all fee writes.
/// </summary>
public bool IsDynamicFeeEnabled { get; set; } = false;

[NotMapped]
public int? OpenedWithId => ChannelOperationRequests?.FirstOrDefault()?.Wallet?.Id;

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

/// <summary>
/// Per-channel fee-engine state (1:1 with <see cref="Channel"/>). Holds last-applied
/// policy and control state that must survive restarts.
/// </summary>
public class ChannelFeeState : Entity
{
/// <summary>FK to <see cref="Channel"/> (unique — one fee state per channel).</summary>
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; }
}
105 changes: 105 additions & 0 deletions src/Data/Models/ChannelRoutingState.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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).
/// </summary>
public enum PeerFlowCategory
{
Uncategorized = 0,

/// <summary>Push-heavy (NetFlowRatio > 0): the peer drains our local balance. Hold more local, higher fees.</summary>
Sink = 1,

/// <summary>Pull-heavy (NetFlowRatio < 0): the peer fills our local balance. Hold less local, lower fees.</summary>
Source = 2,

/// <summary>Balanced flow: target ratio held near 0.5.</summary>
Bidirectional = 3
}

/// <summary>
/// Per-channel routing-engine read model (1:1 with <see cref="Channel"/>). Written by
/// 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.
/// </summary>
public class ChannelRoutingState : Entity
{
/// <summary>FK to <see cref="Channel"/> (unique — one routing state per channel).</summary>
public int ChannelId { get; set; }
public Channel Channel { get; set; } = null!;

/// <summary>LND short-channel-id snapshot, refreshed every evaluation (alias -&gt; confirmed scid).</summary>
public ulong ChanIdLnd { get; set; }

/// <summary>66-hex pubkey of the managed node that owns routing state for this channel.</summary>
public string ManagedNodePubKey { get; set; } = null!;

/// <summary>Dynamic target local-balance ratio, clamped to [0.10, 0.90]. Defaults to 0.5.</summary>
public double TargetLocalRatio { get; set; } = 0.5;

public PeerFlowCategory PeerFlowCategory { get; set; } = PeerFlowCategory.Uncategorized;

/// <summary>
/// Tentative category currently being counted toward a hysteresis flip; null in steady state.
/// </summary>
public PeerFlowCategory? PendingCategory { get; set; }

/// <summary>Consecutive cycles the <see cref="PendingCategory"/> has been observed; 0 in steady state.</summary>
public uint ConsecutiveCategoryCyclesInNewState { get; set; }

/// <summary>Funding block height parsed from the scid (bits 63..40); null for pending/alias/zero-conf.</summary>
public uint? FundingBlockHeight { get; set; }

/// <summary>Channel age in blocks (chainTip - FundingBlockHeight); null when height can't be derived.</summary>
public uint? AgeBlocks { get; set; }

/// <summary>EMA of local/(local+remote); seeded with the first observed ratio on insert.</summary>
public double EmaLocalRatio { get; set; }

/// <summary>Σ settled msat leaving us via this channel over the categorization window (drains local).</summary>
public long PushMsatWindow { get; set; }

/// <summary>Σ settled msat arriving at us via this channel over the same window (fills local).</summary>
public long PullMsatWindow { get; set; }

/// <summary>(push - pull) / (push + pull); positive = SINK, negative = SOURCE.</summary>
public double NetFlowRatio { get; set; }

/// <summary>== !channel.Initiator — true when the peer opened the channel.</summary>
public bool PeerInitiated { get; set; }

public long? LastKnownNumUpdates { get; set; }

/// <summary>Seconds; EXPERIMENTAL per LND.</summary>
public long? LastKnownLifetime { get; set; }

/// <summary>Seconds; EXPERIMENTAL per LND, resets on restart.</summary>
public long? LastKnownUptime { get; set; }

/// <summary>Set when a category flip commits.</summary>
public DateTimeOffset? LastCategorizedAt { get; set; }

public DateTimeOffset LastEvaluatedAt { get; set; }
}
50 changes: 50 additions & 0 deletions src/Data/Models/Node.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,56 @@ public class Node : Entity

#endregion Automatic Swap Out Configuration

#region Routing Engine

/// <summary>
/// Master gate for the dynamic fee engine on this node. Defaults OFF.
/// </summary>
public bool DynamicFeeManagementEnabled { get; set; } = false;

/// <summary>
/// Master gate for the automated rebalancer on this node. Defaults OFF.
/// </summary>
public bool AutoRebalanceEnabled { get; set; } = false;

/// <summary>
/// Allows the fee engine to set positive inbound fees on this node. Defaults OFF.
/// </summary>
public bool AllowPositiveInboundFees { get; set; } = false;

/// <summary>
/// Per-node dry-run for the routing-engine actuators: when true they log the
/// fee/rebalance they would perform without calling LND.
/// </summary>
public bool RoutingEngineDryRun { get; set; } = false;

/// <summary>
/// Maximum sats spendable on rebalance fees over the budget refresh interval.
/// </summary>
public long? RebalanceBudgetSats { get; set; }

/// <summary>
/// Time interval after which the rebalance budget is refreshed.
/// </summary>
public TimeSpan? RebalanceBudgetRefreshInterval { get; set; }

/// <summary>
/// The datetime when the current rebalance budget period started.
/// </summary>
public DateTimeOffset? RebalanceBudgetStartDatetime { get; set; }

/// <summary>
/// Maximum number of concurrent (Pending/InFlight) rebalances for this node.
/// </summary>
public int? MaxRebalancesInFlight { get; set; }

/// <summary>
/// Maximum acceptable rebalance cost-to-earn ratio used by the profitability gate.
/// </summary>
public double? MaxRebalanceCostToEarnRatio { get; set; }

#endregion Routing Engine

#region Relationships

public ICollection<ChannelOperationRequest> ChannelOperationRequestsAsSource { get; set; }
Expand Down
71 changes: 71 additions & 0 deletions src/Data/Repositories/ChannelFeeStateRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* 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 ChannelFeeStateRepository : IChannelFeeStateRepository
{
private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;

public ChannelFeeStateRepository(IDbContextFactory<ApplicationDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}

public async Task<ChannelFeeState?> 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.SetUpdateDatetime();
}

await context.SaveChangesAsync();
}
}
Loading
Loading