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/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/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/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index 4478b60f..bf06a866 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")?.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); + + 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/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/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") 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); diff --git a/src/Program.cs b/src/Program.cs index 838c0984..affa6e5b 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -126,11 +126,15 @@ 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(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -270,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 => { 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)); 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/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); + } +} 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(); + } +} 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); + } +}