diff --git a/src/Data/ApplicationDbContext.cs b/src/Data/ApplicationDbContext.cs index 26b8ce74..dce774e2 100644 --- a/src/Data/ApplicationDbContext.cs +++ b/src/Data/ApplicationDbContext.cs @@ -109,6 +109,29 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(r => r.SourceChannelId) .OnDelete(DeleteBehavior.Restrict); + // Routing engine: 1:1 read models keyed on ChannelId. + modelBuilder.Entity() + .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(false); + modelBuilder.Entity().Property(x => x.RoutingEngineDryRun).HasDefaultValue(false); + base.OnModelCreating(modelBuilder); } @@ -149,5 +172,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..57b23c8f 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 dynamic fee engine. Defaults false; the node-level + /// flag still gates all fee writes. + /// + public bool IsDynamicFeeEnabled { get; set; } = false; + [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..118b67bb --- /dev/null +++ b/src/Data/Models/ChannelFeeState.cs @@ -0,0 +1,39 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +namespace NodeGuard.Data.Models; + +/// +/// Per-channel fee-engine state (1:1 with ). Holds last-applied +/// policy and control state that 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; } +} diff --git a/src/Data/Models/ChannelRoutingState.cs b/src/Data/Models/ChannelRoutingState.cs new file mode 100644 index 00000000..02a018c4 --- /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 fee engine and rebalancer. This is the single +/// canonical place target ratio / category / smoothed balance live — actuators must not +/// re-derive them. +/// +public class ChannelRoutingState : Entity +{ + /// 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..97a9e5f6 100644 --- a/src/Data/Models/Node.cs +++ b/src/Data/Models/Node.cs @@ -135,6 +135,56 @@ public class Node : Entity #endregion Automatic Swap Out Configuration + #region Routing Engine + + /// + /// Master gate for the dynamic fee engine on this node. Defaults OFF. + /// + public bool DynamicFeeManagementEnabled { get; set; } = false; + + /// + /// Master gate for the automated rebalancer on this node. Defaults OFF. + /// + public bool AutoRebalanceEnabled { get; set; } = false; + + /// + /// Allows the fee engine to set positive inbound fees on this node. Defaults OFF. + /// + public bool AllowPositiveInboundFees { get; set; } = false; + + /// + /// Per-node dry-run for the routing-engine actuators: when true they log the + /// fee/rebalance they would perform without calling LND. + /// + public bool RoutingEngineDryRun { get; set; } = false; + + /// + /// Maximum sats spendable on rebalance fees over the budget refresh interval. + /// + public long? RebalanceBudgetSats { get; set; } + + /// + /// Time interval after which the rebalance budget is refreshed. + /// + public TimeSpan? RebalanceBudgetRefreshInterval { get; set; } + + /// + /// The datetime when the current rebalance budget period started. + /// + public DateTimeOffset? RebalanceBudgetStartDatetime { get; set; } + + /// + /// Maximum number of concurrent (Pending/InFlight) rebalances for this node. + /// + public int? MaxRebalancesInFlight { get; set; } + + /// + /// Maximum acceptable rebalance cost-to-earn ratio used by the profitability gate. + /// + public double? MaxRebalanceCostToEarnRatio { get; set; } + + #endregion Routing Engine + #region Relationships public ICollection ChannelOperationRequestsAsSource { get; set; } diff --git a/src/Data/Repositories/ChannelFeeStateRepository.cs b/src/Data/Repositories/ChannelFeeStateRepository.cs new file mode 100644 index 00000000..baa3fc0e --- /dev/null +++ b/src/Data/Repositories/ChannelFeeStateRepository.cs @@ -0,0 +1,71 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using Microsoft.EntityFrameworkCore; +using NodeGuard.Data.Models; +using NodeGuard.Data.Repositories.Interfaces; + +namespace NodeGuard.Data.Repositories; + +public class ChannelFeeStateRepository : IChannelFeeStateRepository +{ + private readonly IDbContextFactory _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.SetUpdateDatetime(); + } + + await context.SaveChangesAsync(); + } +} 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/ForwardingHtlcEventRepository.cs b/src/Data/Repositories/ForwardingHtlcEventRepository.cs index c40633b7..819ca1ef 100644 --- a/src/Data/Repositories/ForwardingHtlcEventRepository.cs +++ b/src/Data/Repositories/ForwardingHtlcEventRepository.cs @@ -97,6 +97,46 @@ public ForwardingHtlcEventRepository(IDbContextFactory dbC } } + public async Task GetOutgoingAmountMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + + return await Settled(context, managedNodePubKey, since) + .Where(x => x.OutgoingChannelId == chanIdLnd) + .SumAsync(x => (long?)x.OutgoingAmountMsat) ?? 0; + } + + public async Task GetIncomingAmountMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + + return await Settled(context, managedNodePubKey, since) + .Where(x => x.IncomingChannelId == chanIdLnd) + .SumAsync(x => (long?)x.IncomingAmountMsat) ?? 0; + } + + public async Task GetOrganicFeesEarnedMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + + return await Settled(context, managedNodePubKey, since) + .Where(x => x.OutgoingChannelId == chanIdLnd) + .SumAsync(x => x.FeeMsat) ?? 0; + } + + /// + /// Base query shared by every windowed aggregation: succeeded (Settled) forwards for the given + /// managed node within the window. No caller ever reads non-Settled rows. + /// + private static IQueryable Settled( + ApplicationDbContext context, string managedNodePubKey, DateTimeOffset since) + { + return context.ForwardingHtlcEvents + .Where(x => x.ManagedNodePubKey == managedNodePubKey + && x.Outcome == ForwardingOutcome.Settled + && x.EventTimestamp >= since); + } + private static T MergeEnum(T currentValue, T newValue) where T : struct, Enum { return EqualityComparer.Default.Equals(newValue, default) ? currentValue : newValue; diff --git a/src/Data/Repositories/Interfaces/IChannelFeeStateRepository.cs b/src/Data/Repositories/Interfaces/IChannelFeeStateRepository.cs new file mode 100644 index 00000000..930230ee --- /dev/null +++ b/src/Data/Repositories/Interfaces/IChannelFeeStateRepository.cs @@ -0,0 +1,29 @@ +/* + * 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 IChannelFeeStateRepository +{ + Task GetByChannelId(int channelId); + + Task UpsertByChannelId(ChannelFeeState state); +} 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/IForwardingHtlcEventRepository.cs b/src/Data/Repositories/Interfaces/IForwardingHtlcEventRepository.cs index 9fcec4aa..6f19f8f3 100644 --- a/src/Data/Repositories/Interfaces/IForwardingHtlcEventRepository.cs +++ b/src/Data/Repositories/Interfaces/IForwardingHtlcEventRepository.cs @@ -24,4 +24,23 @@ namespace NodeGuard.Data.Repositories.Interfaces; public interface IForwardingHtlcEventRepository { Task<(bool, string?)> UpsertAsync(ForwardingHtlcEvent forwardingHtlcEvent); + + /// + /// Σ OutgoingAmountMsat of succeeded forwards where OutgoingChannelId == chanIdLnd, for the + /// given managed node, since the window start (drains our local balance — "push"). + /// + Task GetOutgoingAmountMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since); + + /// + /// Σ IncomingAmountMsat of succeeded forwards where IncomingChannelId == chanIdLnd, for the + /// given managed node, since the window start (fills our local balance — "pull"). + /// + Task GetIncomingAmountMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since); + + /// + /// Σ FeeMsat of succeeded forwards where OutgoingChannelId == chanIdLnd, since the window + /// start. Attributed to the outgoing side (the channel whose liquidity was spent) so + /// per-channel revenue sums don't double-count. + /// + Task GetOrganicFeesEarnedMsat(string managedNodePubKey, ulong chanIdLnd, DateTimeOffset since); } \ No newline at end of file diff --git a/src/Data/Repositories/Interfaces/IRebalanceRepository.cs b/src/Data/Repositories/Interfaces/IRebalanceRepository.cs index 9fe1babf..5ba23cee 100644 --- a/src/Data/Repositories/Interfaces/IRebalanceRepository.cs +++ b/src/Data/Repositories/Interfaces/IRebalanceRepository.cs @@ -51,4 +51,18 @@ 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 + /// in-flight cap. + /// + Task GetInFlightByNode(int nodeId); + + /// + /// Budget consumption for a node since (by CreationDatetime): + /// non-terminal rows count MAX(FeePaidSats, RequestedAmountSats × MaxFeePct) so in-flight + /// spend counts immediately against a conservative on-demand reservation, Succeeded rows + /// count FeePaidSats, other terminal rows count 0. + /// + Task GetConsumedFeesSince(int nodeId, DateTimeOffset since); } diff --git a/src/Data/Repositories/RebalanceRepository.cs b/src/Data/Repositories/RebalanceRepository.cs index 1c59997e..2dd82564 100644 --- a/src/Data/Repositories/RebalanceRepository.cs +++ b/src/Data/Repositories/RebalanceRepository.cs @@ -138,4 +138,43 @@ 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.RequestedAmountSats, r.MaxFeePct }) + .ToListAsync(); + + long total = 0; + foreach (var r in rows) + { + var feePaid = r.FeePaidSats ?? 0; + // Conservative worst-case reservation for a still-in-flight rebalance: RequestedAmountSats × + // (MaxFeePct/100). + var reserved = (long)(r.RequestedAmountSats * r.MaxFeePct / 100.0); + total += r.Status == RebalanceStatus.Succeeded + ? feePaid + : reserved; + } + + 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..d73be85d 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -206,6 +206,91 @@ public class Constants /// public static int REBALANCE_RECONCILE_TERMINAL_WINDOW_HOURS = 24; + // ── Routing Engine (heuristic routing-optimization engine) ────────────────────────── + + /// + /// Global kill switch for the whole routing engine. Every routing-engine job checks this + /// first and returns immediately when false. + /// + public static bool ROUTING_ENGINE_ENABLED = false; + + /// + /// Age gate for categorization: channels younger than this many blocks stay Uncategorized + /// at target 0.5. Default 3024 = 21 block-days. + /// + public static uint ROUTING_ENGINE_CATEGORIZATION_MIN_AGE_BLOCKS = 3024; + + /// + /// Categorization + net-flow lookback window over ForwardingHtlcEvent, in days. + /// + public static int ROUTING_ENGINE_FLOW_WINDOW_DAYS = 21; + + /// + /// Minimum total in-window flow (push+pull) in msat required to categorize a channel; below + /// this it stays / decays to Uncategorized. + /// + 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. + /// + 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). Simply, how far the target drifts from 0.5 (default) per unit of flow imbalance. + /// + 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]. + /// + public static double ROUTING_ENGINE_TARGET_MAX_DRIFT = 0.35; + + /// + /// EWMA smoothing factor folding target_goal into the stored TargetLocalRatio. Default 0.10 + /// (19 cycles to converge). ⍺ = 2/(cycles+1). This smooths out the target ratio to avoid + /// overreacting to transient flow spikes. + /// + public static double ROUTING_ENGINE_TARGET_ALPHA = 0.10; + + /// + /// EWMA smoothing factor for EmaLocalRatio (~24h effective window at 30-min sampling). + /// Default 0.04 (49 cycles to converge). ⍺ = 2/(cycles+1). It smooths out the observed + /// local ratio to avoid overreacting to transient flow spikes. + /// + public static double ROUTING_ENGINE_FEE_EMA_ALPHA = 0.04; + + /// + /// Consecutive divergent cycles required before a category flip commits (anti-flap hysteresis). + /// + 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; + + 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 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 +528,88 @@ static Constants() var rebReconcileWindow = Environment.GetEnvironmentVariable("REBALANCE_RECONCILE_TERMINAL_WINDOW_HOURS"); if (rebReconcileWindow != null) REBALANCE_RECONCILE_TERMINAL_WINDOW_HOURS = int.Parse(rebReconcileWindow); + 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 + 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 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..fb3bf2f4 --- /dev/null +++ b/src/Jobs/TargetRatioReevaluationJob.cs @@ -0,0 +1,244 @@ +/* + * 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; + +/// +/// 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 IForwardingHtlcEventRepository _forwardingHtlcEventRepository; + private readonly IPeerCategorizationService _peerCategorizationService; + private readonly ILightningService _lightningService; + private readonly ILightningClientService _lightningClientService; + + public TargetRatioReevaluationJob( + ILogger logger, + INodeRepository nodeRepository, + IChannelRepository channelRepository, + IChannelRoutingStateRepository routingStateRepository, + IForwardingHtlcEventRepository forwardingHtlcEventRepository, + IPeerCategorizationService peerCategorizationService, + ILightningService lightningService, + ILightningClientService lightningClientService) + { + _logger = logger; + _nodeRepository = nodeRepository; + _channelRepository = channelRepository; + _routingStateRepository = routingStateRepository; + _forwardingHtlcEventRepository = forwardingHtlcEventRepository; + _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; + } + + // Act only on channels that are active. + if (!lndChannel.Active) + { + 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 _forwardingHtlcEventRepository.GetOutgoingAmountMsat(managedNode.PubKey, lndChannel.ChanId, windowStart); + var pull = await _forwardingHtlcEventRepository.GetIncomingAmountMsat(managedNode.PubKey, lndChannel.ChanId, windowStart); + state.PushMsatWindow = push; + state.PullMsatWindow = pull; + var total = push + pull; + 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..00f36473 --- /dev/null +++ b/src/Migrations/20260707155251_AddRoutingEngineFoundation.Designer.cs @@ -0,0 +1,1921 @@ +// +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(false); + + 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("ChannelId") + .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("RoutingEngineDryRun") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + 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("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..0d1e6578 --- /dev/null +++ b/src/Migrations/20260707155251_AddRoutingEngineFoundation.cs @@ -0,0 +1,210 @@ +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: "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: "RoutingEngineDryRun", + table: "Nodes", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "IsDynamicFeeEnabled", + table: "Channels", + type: "boolean", + nullable: false, + defaultValue: false); + + 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), + 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: "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: "RoutingEngineDryRun", + table: "Nodes"); + + migrationBuilder.DropColumn( + name: "IsDynamicFeeEnabled", + table: "Channels"); + } + } +} diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index bcba49cf..22fac1ad 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(false); + b.Property("IsPrivate") .HasColumnType("boolean"); @@ -436,6 +441,52 @@ 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("ChannelId") + .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 +615,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 +968,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 +989,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Description") .HasColumnType("text"); + b.Property("DynamicFeeManagementEnabled") + .HasColumnType("boolean"); + b.Property("Endpoint") .HasColumnType("text"); @@ -879,6 +1019,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 +1042,20 @@ 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("RoutingEngineDryRun") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + b.Property("SwapBudgetRefreshInterval") .HasColumnType("interval"); @@ -1469,6 +1629,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 +1692,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/Channels.razor b/src/Pages/Channels.razor index 199fb249..37fcd4dd 100644 --- a/src/Pages/Channels.razor +++ b/src/Pages/Channels.razor @@ -629,84 +629,105 @@
Set New Fee Policy
- - - - - - Base fee (msat) - Flat outbound fee charged for forwarded payments. - - - - - - - - - - - - Fee rate (ppm) - Outbound proportional fee in parts per million. - - - - - - - - - - - - Time lock delta - CLTV delta advertised for forwarded HTLCs on this channel. - - - - - - - + - Inbound fee policy - Optional. Leave both inbound fields blank to keep the current inbound fee - policy unchanged. + Dynamic Fee Management (Channel) + When enabled, the routing engine controls this channel fees and manual fee updates are blocked. + + Enable dynamic fees for this channel + - - + + + + @if (_selectedChannel.IsDynamicFeeEnabled) + { + + + ⚠️ Dynamic fees are enabled for this channel. Manual fee adjustment is not allowed. Please disable dynamic fees to set a new fee policy. + + + } + else{ + - Inbound base fee (msat) - Optional inbound base fee. When set, it must be zero or below. - - - - - - + + Base fee (msat) + Flat outbound fee charged for forwarded payments. + + + + + + - Inbound fee rate (ppm) - Optional inbound proportional fee. When set, it must be zero - or below. - - - - - + + Fee rate (ppm) + Outbound proportional fee in parts per million. + + + + + + - - + + + + Time lock delta + CLTV delta advertised for forwarded HTLCs on this channel. + + + + + + + + + Inbound fee policy + Optional. Leave both inbound fields blank to keep the current inbound fee + policy unchanged. + + + + + + Inbound base fee (msat) + Optional inbound base fee. When set, it must be zero or below. + + + + + + + + + + + Inbound fee rate (ppm) + Optional inbound proportional fee. When set, it must be zero + or below. + + + + + + + + + + } @@ -747,6 +768,7 @@ private Validations? _channelFeePolicyValidationsRef; private ChannelFeePolicyForm _channelFeePolicy = new(); private CurrentFeePolicies _currentFeePolicies = new() { IsLoading = false }; + private bool _allowedPositiveInboundFeesSelectedChannel; private string _selectedChannelManagementTab = ChannelManagementLiquidityTab; private string? _selectedChannelOutpoint; private string? _selectedChannelNodePubKey; @@ -1072,6 +1094,7 @@ return; } + _allowedPositiveInboundFeesSelectedChannel = node.AllowPositiveInboundFees; _currentFeePolicies.IsLoading = true; _currentFeePolicies.ErrorMessage = null; StateHasChanged(); @@ -1191,15 +1214,36 @@ private async Task SaveAndCloseChannelFeePolicyTab() { - if (_channelFeePolicyValidationsRef == null || !await _channelFeePolicyValidationsRef.ValidateAll()) + if (_selectedChannel == null || string.IsNullOrWhiteSpace(_selectedChannelOutpoint) || string.IsNullOrWhiteSpace(_selectedChannelNodePubKey)) { - ToastService.ShowError("Please fix the errors"); + ToastService.ShowError("The selected channel could not be resolved for fee policy updates."); return; } - if (_selectedChannel == null || string.IsNullOrWhiteSpace(_selectedChannelOutpoint) || string.IsNullOrWhiteSpace(_selectedChannelNodePubKey)) + var channelUpdateResult = ChannelRepository.Update(_selectedChannel); + if (!channelUpdateResult.Item1) { - ToastService.ShowError("The selected channel could not be resolved for fee policy updates."); + ToastService.ShowError("Could not update channel dynamic fee setting."); + await AuditService.LogAsync(AuditActionType.Update, AuditEventType.Failure, AuditObjectType.Channel, + _selectedChannel.Id.ToString(), + $"Failed to update dynamic fee flag. ChanId: {_selectedChannel.ChanId}, IsDynamicFeeEnabled: {_selectedChannel.IsDynamicFeeEnabled}"); + return; + } + + await AuditService.LogAsync(AuditActionType.Update, AuditEventType.Success, AuditObjectType.Channel, + _selectedChannel.Id.ToString(), + $"Updated dynamic fee flag. ChanId: {_selectedChannel.ChanId}, IsDynamicFeeEnabled: {_selectedChannel.IsDynamicFeeEnabled}"); + + if (_selectedChannel.IsDynamicFeeEnabled) + { + ToastService.ShowSuccess("Channel dynamic fee management enabled."); + await CloseChannelManagementModal(); + return; + } + + if (_channelFeePolicyValidationsRef == null || !await _channelFeePolicyValidationsRef.ValidateAll()) + { + ToastService.ShowError("Please fix the errors"); return; } diff --git a/src/Pages/Nodes.razor b/src/Pages/Nodes.razor index db4e0a72..d4c9e2d1 100644 --- a/src/Pages/Nodes.razor +++ b/src/Pages/Nodes.razor @@ -433,6 +433,102 @@ } + + + + + + These settings configure the heuristic routing engine. The engine can automatically adjust channel fees and initiate circular rebalances to steer channels toward their target ratio. The engine is disabled by default and must be enabled for each node individually. + + + + + 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 + + + } + + + 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. + + + } + } @@ -484,6 +580,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 +922,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 +952,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..239dc583 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -126,6 +126,8 @@ 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(); @@ -270,6 +272,30 @@ public static async Task Main(string[] args) }); }); + //Target Ratio Reevaluation Job + 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/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/ForwardingHtlcEventRepositoryTests.cs b/test/NodeGuard.Tests/Data/Repositories/ForwardingHtlcEventRepositoryTests.cs new file mode 100644 index 00000000..6cb51d5c --- /dev/null +++ b/test/NodeGuard.Tests/Data/Repositories/ForwardingHtlcEventRepositoryTests.cs @@ -0,0 +1,110 @@ +/* + * 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 Microsoft.Extensions.Logging; +using NodeGuard.Data; +using NodeGuard.Data.Models; + +namespace NodeGuard.Data.Repositories; + +public class ForwardingHtlcEventRepositoryTests +{ + 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: "ForwardingHtlcEvent" + _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 ForwardingHtlcEventRepository Sut(Mock> factory) + => new(factory.Object, Mock.Of>()); + + private static ForwardingHtlcEvent Event( + string node, ulong incomingChan, ulong outgoingChan, ForwardingOutcome outcome, + DateTimeOffset ts, ulong incomingAmt = 0, ulong outgoingAmt = 0, long fee = 0, + 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 = Sut(factory); + + (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 = Sut(factory); + + (await sut.GetOutgoingAmountMsat(Node, Chan, DateTimeOffset.UtcNow.AddDays(-1))).Should().Be(0); + (await sut.GetIncomingAmountMsat(Node, Chan, DateTimeOffset.UtcNow.AddDays(-1))).Should().Be(0); + (await sut.GetOrganicFeesEarnedMsat(Node, Chan, DateTimeOffset.UtcNow.AddDays(-1))).Should().Be(0); + } +} diff --git a/test/NodeGuard.Tests/Data/Repositories/RebalanceRepositoryTests.cs b/test/NodeGuard.Tests/Data/Repositories/RebalanceRepositoryTests.cs new file mode 100644 index 00000000..8ccda927 --- /dev/null +++ b/test/NodeGuard.Tests/Data/Repositories/RebalanceRepositoryTests.cs @@ -0,0 +1,108 @@ +/* + * 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 = 0) + => new() + { + NodeId = nodeId, + Status = status, + CreationDatetime = created, + UpdateDatetime = created, + FeePaidSats = feePaid, + // Encode the desired reserved amount at a 1% cap. + RequestedAmountSats = reserved * 100, + MaxFeePct = 1.0, + }; + + [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_UsesReservationForNonTerminalAndPaidForSucceeded() + { + 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 → counts the reservation (30), ignoring the partial FeePaidSats (50). + Reb(NodeId, RebalanceStatus.InFlight, now, feePaid: null, reserved: 30), + // Succeeded → counts paid (200). + Reb(NodeId, RebalanceStatus.Succeeded, now, feePaid: 200, reserved: 999), + // Failed → excluded regardless of amount/fee cap (nothing consumed). + 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 + 30 + 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); + } +}