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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/Data/ApplicationDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<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(true);
modelBuilder.Entity<Node>().Property(x => x.RestoreFeeBaselineOnDisable).HasDefaultValue(true);
modelBuilder.Entity<Node>().Property(x => x.RoutingEngineDryRun).HasDefaultValue(true);

base.OnModelCreating(modelBuilder);
}

Expand Down Expand Up @@ -149,5 +173,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 Phase 2 dynamic fee engine. Defaults true; the node-level
/// <see cref="Node.DynamicFeeManagementEnabled"/> flag still gates all fee writes.
/// </summary>
public bool IsDynamicFeeEnabled { get; set; } = true;

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

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

/// <summary>
/// Per-channel fee-engine state (1:1 with <see cref="Channel"/>). 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.
/// </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; }

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; }

/// <summary>
/// Circuit-breaker counter: incremented on each consecutive failure to apply a fee update,
/// reset to 0 on success.
/// </summary>
public int ConsecutiveFailures { get; set; } = 0;
}
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 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.
/// </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; }
}
61 changes: 61 additions & 0 deletions src/Data/Models/Node.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,67 @@ public class Node : Entity

#endregion Automatic Swap Out Configuration

#region Routing Engine

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

/// <summary>
/// Master gate for the Phase 3 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. Flipped to true
/// alongside <see cref="DynamicFeeManagementEnabled"/> at activation. Defaults OFF.
/// </summary>
public bool AllowPositiveInboundFees { get; set; } = false;

/// <summary>
/// When <see cref="DynamicFeeManagementEnabled"/> flips off, restore each channel's
/// captured fee baseline in one final write. When false, last-set fees are frozen.
/// Defaults ON.
/// </summary>
public bool RestoreFeeBaselineOnDisable { get; set; } = true;

/// <summary>
/// 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 <c>Constants.ROUTING_ENGINE_DRY_RUN</c> is a master
/// override — an actuator writes for real only when both the global flag and this are off.
/// </summary>
public bool RoutingEngineDryRun { get; set; } = true;

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

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

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

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

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

#endregion Routing Engine

#region Relationships

public ICollection<ChannelOperationRequest> ChannelOperationRequestsAsSource { get; set; }
Expand Down
6 changes: 6 additions & 0 deletions src/Data/Models/Rebalance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ public class Rebalance : Entity

public long? FeePaidMsat { get; set; }

/// <summary>
/// Fee reserved for this in-flight rebalance so its spend counts against the node budget
/// before <see cref="FeePaidSats"/> settles (Phase 3 in-flight budget accounting).
/// </summary>
public long? ReservedFeeSats { get; set; }

[NotMapped]
public long? EffectivePpm => FeePaidMsat.HasValue && SatsAmount > 0
? FeePaidMsat.Value * 1_000L / SatsAmount
Expand Down
80 changes: 80 additions & 0 deletions src/Data/Repositories/ChannelFeeStateRepository.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Declared for the Phase 1 batched migration; the Phase 2 fee engine is the first writer.
/// </summary>
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.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();
}
}
Loading
Loading