From db2c4c807ca8c1d0903321a617230c3b0314047e Mon Sep 17 00:00:00 2001 From: Marcos Date: Fri, 21 Aug 2026 14:29:17 +0200 Subject: [PATCH 1/5] fix: update routing state model to support managed node association --- src/Data/ApplicationDbContext.cs | 23 +- src/Data/Models/ChannelFeeState.cs | 16 +- src/Data/Models/ChannelRoutingState.cs | 16 +- ...604_RoutingStatePerManagedNode.Designer.cs | 1925 +++++++++++++++++ ...260821095604_RoutingStatePerManagedNode.cs | 106 + .../ApplicationDbContextModelSnapshot.cs | 16 +- 6 files changed, 2081 insertions(+), 21 deletions(-) create mode 100644 src/Migrations/20260821095604_RoutingStatePerManagedNode.Designer.cs create mode 100644 src/Migrations/20260821095604_RoutingStatePerManagedNode.cs diff --git a/src/Data/ApplicationDbContext.cs b/src/Data/ApplicationDbContext.cs index f3e0084e..9de66c46 100644 --- a/src/Data/ApplicationDbContext.cs +++ b/src/Data/ApplicationDbContext.cs @@ -109,23 +109,28 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(r => r.SourceChannelId) .OnDelete(DeleteBehavior.Restrict); - // Routing engine: 1:1 read models keyed on ChannelId. + // Routing engine read models are keyed per (channel, managed node), not per channel: + // a channel between two managed nodes has one row per side, since local balance, flow + // history and fee policy are all per-node views of the same channel. modelBuilder.Entity() .HasOne(x => x.Channel) - .WithOne() - .HasForeignKey(x => x.ChannelId) + .WithMany() + .HasForeignKey(x => x.ChannelId) .OnDelete(DeleteBehavior.Cascade); - // Only one ChannelRoutingState per channel. - modelBuilder.Entity().HasIndex(x => x.ChannelId).IsUnique(); + // One ChannelRoutingState per channel per managed node. + modelBuilder.Entity() + .HasIndex(x => new { x.ChannelId, x.ManagedNodePubKey }).IsUnique(); modelBuilder.Entity() .HasOne(x => x.Channel) - .WithOne() - .HasForeignKey(x => x.ChannelId) + .WithMany() + .HasForeignKey(x => x.ChannelId) .OnDelete(DeleteBehavior.Cascade); - // Only one ChannelFeeState per channel. - modelBuilder.Entity().HasIndex(x => x.ChannelId).IsUnique(); + + // One ChannelFeeState per channel per managed node. + modelBuilder.Entity() + .HasIndex(x => new { x.ChannelId, x.ManagedNodePubKey }).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). diff --git a/src/Data/Models/ChannelFeeState.cs b/src/Data/Models/ChannelFeeState.cs index 118b67bb..63fb9c43 100644 --- a/src/Data/Models/ChannelFeeState.cs +++ b/src/Data/Models/ChannelFeeState.cs @@ -20,15 +20,25 @@ namespace NodeGuard.Data.Models; /// -/// Per-channel fee-engine state (1:1 with ). Holds last-applied -/// policy and control state that must survive restarts. +/// Fee-engine state for one channel as seen by one managed node — keyed by +/// (, ). Holds last-applied policy and +/// control state that must survive restarts. +/// +/// Each side of a channel sets its own outbound policy, so a channel between two managed nodes +/// carries one row per side (mirrors ). +/// /// public class ChannelFeeState : Entity { - /// FK to (unique — one fee state per channel). + /// FK to (unique together with ). public int ChannelId { get; set; } public Channel Channel { get; set; } = null!; + /// + /// 66-hex pubkey of the managed node whose outbound/inbound policy this row tracks. + /// + public string ManagedNodePubKey { get; set; } = null!; + public DateTimeOffset? LastFeeUpdateAt { get; set; } public long? LastAppliedOutboundBaseFeeMsat { get; set; } public uint? LastAppliedOutboundPpm { get; set; } diff --git a/src/Data/Models/ChannelRoutingState.cs b/src/Data/Models/ChannelRoutingState.cs index 02a018c4..f50a3f25 100644 --- a/src/Data/Models/ChannelRoutingState.cs +++ b/src/Data/Models/ChannelRoutingState.cs @@ -39,21 +39,31 @@ public enum PeerFlowCategory } /// -/// Per-channel routing-engine read model (1:1 with ). Written by +/// Routing-engine read model for one channel as seen by one managed node — keyed by +/// (, ). 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. +/// +/// A channel between two managed nodes has one row per side. Local balance, forwarding +/// history and fee policy are all per-node views of the same channel, so each node needs its own +/// signal: with a single shared row, whichever node did not own it was blind to its own depleted +/// channels and could never classify them as rebalance destinations. +/// /// public class ChannelRoutingState : Entity { - /// FK to (unique — one routing state per channel). + /// FK to (unique together with ). 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. + /// + /// 66-hex pubkey of the managed node this state belongs to — the side whose local balance, + /// flow history and fee policy the row describes. + /// public string ManagedNodePubKey { get; set; } = null!; /// Dynamic target local-balance ratio, clamped to [0.10, 0.90]. Defaults to 0.5. diff --git a/src/Migrations/20260821095604_RoutingStatePerManagedNode.Designer.cs b/src/Migrations/20260821095604_RoutingStatePerManagedNode.Designer.cs new file mode 100644 index 00000000..17e90a00 --- /dev/null +++ b/src/Migrations/20260821095604_RoutingStatePerManagedNode.Designer.cs @@ -0,0 +1,1925 @@ +// +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("20260819155831_RoutingStatePerManagedNode")] + partial class RoutingStatePerManagedNode + { + /// + 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("ManagedNodePubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ManagedNodePubKey") + .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", "ManagedNodePubKey") + .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") + .WithMany() + .HasForeignKey("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") + .WithMany() + .HasForeignKey("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/20260821095604_RoutingStatePerManagedNode.cs b/src/Migrations/20260821095604_RoutingStatePerManagedNode.cs new file mode 100644 index 00000000..b5659009 --- /dev/null +++ b/src/Migrations/20260821095604_RoutingStatePerManagedNode.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NodeGuard.Migrations +{ + /// + /// Re-keys the routing-engine read models from "one row per channel" to "one row per channel + /// per managed node". + /// + /// A channel between two managed nodes used to get a single row, assigned to the initiator + /// side. The other side was then invisible to the routing engine: it could not see its own + /// depleted channels, so they never classified as rebalance destinations and its outbound fee + /// policy was never managed. Both sides now carry their own state. + /// + /// + public partial class RoutingStatePerManagedNode : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ChannelRoutingStates_ChannelId", + table: "ChannelRoutingStates"); + + migrationBuilder.DropIndex( + name: "IX_ChannelFeeStates_ChannelId", + table: "ChannelFeeStates"); + + migrationBuilder.AddColumn( + name: "ManagedNodePubKey", + table: "ChannelFeeStates", + type: "text", + nullable: false, + defaultValue: ""); + + // ChannelFeeState had no owning node of its own — it was resolved by joining through + // ChannelRoutingState, which was 1:1 with the channel. Carry that owner across so the + // fee control loop keeps its operating point instead of cold-starting everywhere. + migrationBuilder.Sql(@" + UPDATE ""ChannelFeeStates"" fs + SET ""ManagedNodePubKey"" = rs.""ManagedNodePubKey"" + FROM ""ChannelRoutingStates"" rs + WHERE rs.""ChannelId"" = fs.""ChannelId"";"); + + // Any fee state we could not attribute to a node is unreachable by the engine — drop it + // so the channel cold-starts from its category baseline on the next cycle. + migrationBuilder.Sql(@"DELETE FROM ""ChannelFeeStates"" WHERE ""ManagedNodePubKey"" = '';"); + + // The empty-string default existed only to backfill; new rows must always name a node. + migrationBuilder.Sql(@"ALTER TABLE ""ChannelFeeStates"" ALTER COLUMN ""ManagedNodePubKey"" DROP DEFAULT;"); + + migrationBuilder.CreateIndex( + name: "IX_ChannelRoutingStates_ChannelId_ManagedNodePubKey", + table: "ChannelRoutingStates", + columns: new[] { "ChannelId", "ManagedNodePubKey" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ChannelFeeStates_ChannelId_ManagedNodePubKey", + table: "ChannelFeeStates", + columns: new[] { "ChannelId", "ManagedNodePubKey" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ChannelRoutingStates_ChannelId_ManagedNodePubKey", + table: "ChannelRoutingStates"); + + migrationBuilder.DropIndex( + name: "IX_ChannelFeeStates_ChannelId_ManagedNodePubKey", + table: "ChannelFeeStates"); + + // Going back to one row per channel: keep the oldest side, drop the rest, or the + // unique index below cannot be recreated. + migrationBuilder.Sql(@" + DELETE FROM ""ChannelRoutingStates"" a + USING ""ChannelRoutingStates"" b + WHERE a.""ChannelId"" = b.""ChannelId"" AND a.""Id"" > b.""Id"";"); + + migrationBuilder.Sql(@" + DELETE FROM ""ChannelFeeStates"" a + USING ""ChannelFeeStates"" b + WHERE a.""ChannelId"" = b.""ChannelId"" AND a.""Id"" > b.""Id"";"); + + migrationBuilder.DropColumn( + name: "ManagedNodePubKey", + table: "ChannelFeeStates"); + + migrationBuilder.CreateIndex( + name: "IX_ChannelRoutingStates_ChannelId", + table: "ChannelRoutingStates", + column: "ChannelId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ChannelFeeStates_ChannelId", + table: "ChannelFeeStates", + column: "ChannelId", + unique: true); + } + } +} diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index 07c7d96c..6c444144 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -481,12 +481,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LastObservedRatio") .HasColumnType("double precision"); + b.Property("ManagedNodePubKey") + .IsRequired() + .HasColumnType("text"); + b.Property("UpdateDatetime") .HasColumnType("timestamp with time zone"); b.HasKey("Id"); - b.HasIndex("ChannelId") + b.HasIndex("ChannelId", "ManagedNodePubKey") .IsUnique(); b.ToTable("ChannelFeeStates"); @@ -694,7 +698,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("ChannelId") + b.HasIndex("ChannelId", "ManagedNodePubKey") .IsUnique(); b.ToTable("ChannelRoutingStates"); @@ -1637,8 +1641,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("NodeGuard.Data.Models.ChannelFeeState", b => { b.HasOne("NodeGuard.Data.Models.Channel", "Channel") - .WithOne() - .HasForeignKey("NodeGuard.Data.Models.ChannelFeeState", "ChannelId") + .WithMany() + .HasForeignKey("ChannelId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -1700,8 +1704,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("NodeGuard.Data.Models.ChannelRoutingState", b => { b.HasOne("NodeGuard.Data.Models.Channel", "Channel") - .WithOne() - .HasForeignKey("NodeGuard.Data.Models.ChannelRoutingState", "ChannelId") + .WithMany() + .HasForeignKey("ChannelId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); From 96f81946eda3aa0283de39458ab788165afb5998 Mon Sep 17 00:00:00 2001 From: Marcos Date: Fri, 21 Aug 2026 14:55:36 +0200 Subject: [PATCH 2/5] refactor: update channel fee and routing state repositories to include managed node key in methods --- .../Repositories/ChannelFeeStateRepository.cs | 31 +++---- .../ChannelRoutingStateRepository.cs | 10 +- .../Interfaces/IChannelFeeStateRepository.cs | 13 +-- .../IChannelRoutingStateRepository.cs | 9 +- src/Jobs/ChannelFeeOptimizerJob.cs | 8 +- src/Jobs/TargetRatioReevaluationJob.cs | 4 +- .../ChannelFeeStateRepositoryTests.cs | 92 ++++++++++++++----- .../ChannelRoutingStateRepositoryTests.cs | 64 +++++++++++-- 8 files changed, 157 insertions(+), 74 deletions(-) diff --git a/src/Data/Repositories/ChannelFeeStateRepository.cs b/src/Data/Repositories/ChannelFeeStateRepository.cs index 97d0e725..33eec040 100644 --- a/src/Data/Repositories/ChannelFeeStateRepository.cs +++ b/src/Data/Repositories/ChannelFeeStateRepository.cs @@ -32,36 +32,30 @@ public ChannelFeeStateRepository(IDbContextFactory dbConte _dbContextFactory = dbContextFactory; } - public async Task GetByChannelId(int channelId) + public async Task GetByChannelIdAndNode(int channelId, string managedNodePubKey) { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ChannelFeeStates - .FirstOrDefaultAsync(x => x.ChannelId == channelId); + .FirstOrDefaultAsync(x => x.ChannelId == channelId && x.ManagedNodePubKey == managedNodePubKey); } public async Task> GetByManagedNodePubKey(string managedNodePubKey) { await using var context = await _dbContextFactory.CreateDbContextAsync(); - // ChannelFeeState carries no node pubkey; the owning node lives on ChannelRoutingState - // (1:1 with the same Channel), so filter through it. - var channelIds = context.ChannelRoutingStates - .Where(s => s.ManagedNodePubKey == managedNodePubKey) - .Select(s => s.ChannelId); - return await context.ChannelFeeStates - .Include(x => x.Channel) - .Where(x => channelIds.Contains(x.ChannelId)) + .Where(x => x.ManagedNodePubKey == managedNodePubKey) .ToListAsync(); } - public async Task UpsertByChannelId(ChannelFeeState state) + public async Task UpsertByChannelAndNode(ChannelFeeState state) { await using var context = await _dbContextFactory.CreateDbContextAsync(); var existing = await context.ChannelFeeStates - .FirstOrDefaultAsync(x => x.ChannelId == state.ChannelId); + .FirstOrDefaultAsync(x => x.ChannelId == state.ChannelId + && x.ManagedNodePubKey == state.ManagedNodePubKey); if (existing == null) { @@ -90,14 +84,15 @@ public async Task DeleteByChannelId(int channelId) await using var context = await _dbContextFactory.CreateDbContextAsync(); var existing = await context.ChannelFeeStates - .FirstOrDefaultAsync(x => x.ChannelId == channelId); + .Where(x => x.ChannelId == channelId) + .ToListAsync(); - if (existing == null) + if (existing.Count == 0) { return false; } - context.ChannelFeeStates.Remove(existing); + context.ChannelFeeStates.RemoveRange(existing); await context.SaveChangesAsync(); return true; } @@ -106,12 +101,8 @@ public async Task DeleteByManagedNodePubKey(string managedNodePubKey) { await using var context = await _dbContextFactory.CreateDbContextAsync(); - var channelIds = context.ChannelRoutingStates - .Where(s => s.ManagedNodePubKey == managedNodePubKey) - .Select(s => s.ChannelId); - var states = await context.ChannelFeeStates - .Where(x => channelIds.Contains(x.ChannelId)) + .Where(x => x.ManagedNodePubKey == managedNodePubKey) .ToListAsync(); if (states.Count == 0) diff --git a/src/Data/Repositories/ChannelRoutingStateRepository.cs b/src/Data/Repositories/ChannelRoutingStateRepository.cs index 66a21e98..c1e3ba51 100644 --- a/src/Data/Repositories/ChannelRoutingStateRepository.cs +++ b/src/Data/Repositories/ChannelRoutingStateRepository.cs @@ -32,12 +32,12 @@ public ChannelRoutingStateRepository(IDbContextFactory dbC _dbContextFactory = dbContextFactory; } - public async Task GetByChannelId(int channelId) + public async Task GetByChannelIdAndNode(int channelId, string managedNodePubKey) { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ChannelRoutingStates - .FirstOrDefaultAsync(x => x.ChannelId == channelId); + .FirstOrDefaultAsync(x => x.ChannelId == channelId && x.ManagedNodePubKey == managedNodePubKey); } public async Task> GetByManagedNodePubKey(string managedNodePubKey) @@ -49,12 +49,13 @@ public async Task> GetByManagedNodePubKey(string manag .ToListAsync(); } - public async Task UpsertByChannelId(ChannelRoutingState state) + public async Task UpsertByChannelAndNode(ChannelRoutingState state) { await using var context = await _dbContextFactory.CreateDbContextAsync(); var existing = await context.ChannelRoutingStates - .FirstOrDefaultAsync(x => x.ChannelId == state.ChannelId); + .FirstOrDefaultAsync(x => x.ChannelId == state.ChannelId + && x.ManagedNodePubKey == state.ManagedNodePubKey); if (existing == null) { @@ -67,7 +68,6 @@ public async Task UpsertByChannelId(ChannelRoutingState state) else { existing.ChanIdLnd = state.ChanIdLnd; - existing.ManagedNodePubKey = state.ManagedNodePubKey; existing.TargetLocalRatio = state.TargetLocalRatio; existing.PeerFlowCategory = state.PeerFlowCategory; existing.PendingCategory = state.PendingCategory; diff --git a/src/Data/Repositories/Interfaces/IChannelFeeStateRepository.cs b/src/Data/Repositories/Interfaces/IChannelFeeStateRepository.cs index 4bc10834..0c1a768a 100644 --- a/src/Data/Repositories/Interfaces/IChannelFeeStateRepository.cs +++ b/src/Data/Repositories/Interfaces/IChannelFeeStateRepository.cs @@ -23,24 +23,25 @@ namespace NodeGuard.Data.Repositories.Interfaces; public interface IChannelFeeStateRepository { - Task GetByChannelId(int channelId); + Task GetByChannelIdAndNode(int channelId, string managedNodePubKey); /// - /// All fee-state rows for channels owned by the given managed node. + /// All fee-state rows belonging to the given managed node. /// Used by the fee engine to batch per-node state. /// Task> GetByManagedNodePubKey(string managedNodePubKey); - Task UpsertByChannelId(ChannelFeeState state); + Task UpsertByChannelAndNode(ChannelFeeState state); /// - /// Deletes the fee-state row for a single channel, if present. + /// Deletes the fee-state rows for a single channel — every managed side of it, since + /// is a channel-level opt-out. /// - /// true if a row was deleted; false if none existed. + /// true if any row was deleted; false if none existed. Task DeleteByChannelId(int channelId); /// - /// Deletes the fee-state rows for every channel owned by the given managed node. + /// Deletes the fee-state rows belonging to the given managed node. /// /// true if any rows were deleted; false if none existed. Task DeleteByManagedNodePubKey(string managedNodePubKey); diff --git a/src/Data/Repositories/Interfaces/IChannelRoutingStateRepository.cs b/src/Data/Repositories/Interfaces/IChannelRoutingStateRepository.cs index 9a7ca5f9..3b0d5e1a 100644 --- a/src/Data/Repositories/Interfaces/IChannelRoutingStateRepository.cs +++ b/src/Data/Repositories/Interfaces/IChannelRoutingStateRepository.cs @@ -23,14 +23,9 @@ namespace NodeGuard.Data.Repositories.Interfaces; public interface IChannelRoutingStateRepository { - Task GetByChannelId(int channelId); + Task GetByChannelIdAndNode(int channelId, string managedNodePubKey); 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); + Task UpsertByChannelAndNode(ChannelRoutingState state); } diff --git a/src/Jobs/ChannelFeeOptimizerJob.cs b/src/Jobs/ChannelFeeOptimizerJob.cs index cde85e0e..da517845 100644 --- a/src/Jobs/ChannelFeeOptimizerJob.cs +++ b/src/Jobs/ChannelFeeOptimizerJob.cs @@ -211,7 +211,7 @@ private async Task OptimizeChannel(Node node, Candidate candidate, FeeOptimizerT { _logger.LogInformation("Channel {ChanId} on {NodeName}: {Action} ({Reason})", candidate.LndChannel.ChanId, node.Name, decision.Action, decision.Reason); - await _feeStateRepository.UpsertByChannelId(feeState); + await _feeStateRepository.UpsertByChannelAndNode(feeState); return; } @@ -222,7 +222,7 @@ private async Task OptimizeChannel(Node node, Candidate candidate, FeeOptimizerT { _logger.LogWarning("Skipping channel {ChanId} on {NodeName}: current fee policy unavailable", candidate.LndChannel.ChanId, node.Name); - await _feeStateRepository.UpsertByChannelId(feeState); + await _feeStateRepository.UpsertByChannelAndNode(feeState); return; } @@ -241,7 +241,7 @@ private async Task OptimizeChannel(Node node, Candidate candidate, FeeOptimizerT feeState.LastAppliedInboundPpm = decision.InboundPpm; feeState.LastFeeUpdateAt = now; - await _feeStateRepository.UpsertByChannelId(feeState); + await _feeStateRepository.UpsertByChannelAndNode(feeState); return; } @@ -269,7 +269,7 @@ await _lightningService.SetChannelFeePolicy( _logger.LogInformation("{NodeName} chan {ChanId}: set outbound {Outbound}ppm inbound {Inbound}ppm ({Reason})", node.Name, candidate.LndChannel.ChanId, decision.OutboundPpm, decision.InboundPpm, decision.Reason); - await _feeStateRepository.UpsertByChannelId(feeState); + await _feeStateRepository.UpsertByChannelAndNode(feeState); } catch (Exception ex) { diff --git a/src/Jobs/TargetRatioReevaluationJob.cs b/src/Jobs/TargetRatioReevaluationJob.cs index e92140e6..82400276 100644 --- a/src/Jobs/TargetRatioReevaluationJob.cs +++ b/src/Jobs/TargetRatioReevaluationJob.cs @@ -169,7 +169,7 @@ private async Task ReevaluateChannel( / 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) + var state = await _routingStateRepository.GetByChannelIdAndNode(dbChannel.Id, managedNode.PubKey) ?? new ChannelRoutingState { ChannelId = dbChannel.Id, @@ -229,6 +229,6 @@ private async Task ReevaluateChannel( state.LastKnownUptime = lndChannel.Uptime; state.LastEvaluatedAt = now; - await _routingStateRepository.UpsertByChannelId(state); + await _routingStateRepository.UpsertByChannelAndNode(state); } } diff --git a/test/NodeGuard.Tests/Data/Repositories/ChannelFeeStateRepositoryTests.cs b/test/NodeGuard.Tests/Data/Repositories/ChannelFeeStateRepositoryTests.cs index 943c7e6a..39601da7 100644 --- a/test/NodeGuard.Tests/Data/Repositories/ChannelFeeStateRepositoryTests.cs +++ b/test/NodeGuard.Tests/Data/Repositories/ChannelFeeStateRepositoryTests.cs @@ -45,12 +45,28 @@ public async Task DeleteByChannelId_RemovesRow_AndReturnsTrue() var (factory, _) = SetupDb(); var sut = new ChannelFeeStateRepository(factory.Object); - await sut.UpsertByChannelId(new ChannelFeeState { ChannelId = 7, LastAppliedOutboundPpm = 1234 }); + await sut.UpsertByChannelAndNode(new ChannelFeeState { ChannelId = 7, ManagedNodePubKey = "02a", LastAppliedOutboundPpm = 1234 }); var deleted = await sut.DeleteByChannelId(7); deleted.Should().BeTrue(); - (await sut.GetByChannelId(7)).Should().BeNull(); + (await sut.GetByChannelIdAndNode(7, "02a")).Should().BeNull(); + } + + [Fact] + public async Task DeleteByChannelId_RemovesEverySideOfTheChannel() + { + var (factory, _) = SetupDb(); + var sut = new ChannelFeeStateRepository(factory.Object); + + // IsDynamicFeeEnabled is channel-level, so opting out drops both managed sides' state. + await sut.UpsertByChannelAndNode(new ChannelFeeState { ChannelId = 7, ManagedNodePubKey = "02a", LastAppliedOutboundPpm = 10 }); + await sut.UpsertByChannelAndNode(new ChannelFeeState { ChannelId = 7, ManagedNodePubKey = "02b", LastAppliedOutboundPpm = 20 }); + + (await sut.DeleteByChannelId(7)).Should().BeTrue(); + + (await sut.GetByChannelIdAndNode(7, "02a")).Should().BeNull(); + (await sut.GetByChannelIdAndNode(7, "02b")).Should().BeNull(); } [Fact] @@ -63,32 +79,66 @@ public async Task DeleteByChannelId_ReturnsFalse_WhenAbsent() } [Fact] - public async Task DeleteByManagedNodePubKey_RemovesOnlyThatNodesFeeStates_ResolvedViaRoutingState() + public async Task UpsertByChannelAndNode_KeepsOneRowPerManagedSideOfTheSameChannel() { var (factory, options) = SetupDb(); var sut = new ChannelFeeStateRepository(factory.Object); - // Fee states for three channels. - await sut.UpsertByChannelId(new ChannelFeeState { ChannelId = 1, LastAppliedOutboundPpm = 10 }); - await sut.UpsertByChannelId(new ChannelFeeState { ChannelId = 2, LastAppliedOutboundPpm = 20 }); - await sut.UpsertByChannelId(new ChannelFeeState { ChannelId = 3, LastAppliedOutboundPpm = 30 }); - - // Ownership lives on ChannelRoutingState: channels 1 & 2 belong to node A, channel 3 to node B. - await using (var seed = new ApplicationDbContext(options)) - { - seed.ChannelRoutingStates.AddRange( - new ChannelRoutingState { ChannelId = 1, ManagedNodePubKey = "02a", LastEvaluatedAt = DateTimeOffset.UtcNow }, - new ChannelRoutingState { ChannelId = 2, ManagedNodePubKey = "02a", LastEvaluatedAt = DateTimeOffset.UtcNow }, - new ChannelRoutingState { ChannelId = 3, ManagedNodePubKey = "02b", LastEvaluatedAt = DateTimeOffset.UtcNow }); - await seed.SaveChangesAsync(); - } + // Each side of a channel sets its own outbound policy, so each keeps its own fee state. + await sut.UpsertByChannelAndNode(new ChannelFeeState { ChannelId = 7, ManagedNodePubKey = "02a", LastAppliedOutboundPpm = 100 }); + await sut.UpsertByChannelAndNode(new ChannelFeeState { ChannelId = 7, ManagedNodePubKey = "02b", LastAppliedOutboundPpm = 900 }); + + await using var verify = new ApplicationDbContext(options); + (await verify.ChannelFeeStates.CountAsync(x => x.ChannelId == 7)).Should().Be(2); + + // Updating one side leaves the other untouched. + await sut.UpsertByChannelAndNode(new ChannelFeeState { ChannelId = 7, ManagedNodePubKey = "02a", LastAppliedOutboundPpm = 150 }); + + (await sut.GetByChannelIdAndNode(7, "02a"))!.LastAppliedOutboundPpm.Should().Be(150); + (await sut.GetByChannelIdAndNode(7, "02b"))!.LastAppliedOutboundPpm.Should().Be(900); + } + + [Fact] + public async Task GetByManagedNodePubKey_ReturnsOnlyThatNodesRows() + { + var (factory, _) = SetupDb(); + var sut = new ChannelFeeStateRepository(factory.Object); + + await sut.UpsertByChannelAndNode(new ChannelFeeState { ChannelId = 1, ManagedNodePubKey = "02a", LastAppliedOutboundPpm = 10 }); + await sut.UpsertByChannelAndNode(new ChannelFeeState { ChannelId = 2, ManagedNodePubKey = "02a", LastAppliedOutboundPpm = 20 }); + await sut.UpsertByChannelAndNode(new ChannelFeeState { ChannelId = 2, ManagedNodePubKey = "02b", LastAppliedOutboundPpm = 30 }); + + var forA = await sut.GetByManagedNodePubKey("02a"); + + forA.Should().HaveCount(2); + forA.Select(x => x.ChannelId).Should().BeEquivalentTo(new[] { 1, 2 }); + } + + [Fact] + public async Task DeleteByManagedNodePubKey_RemovesOnlyThatNodesFeeStates() + { + var (factory, _) = SetupDb(); + var sut = new ChannelFeeStateRepository(factory.Object); + + await sut.UpsertByChannelAndNode(new ChannelFeeState { ChannelId = 1, ManagedNodePubKey = "02a", LastAppliedOutboundPpm = 10 }); + await sut.UpsertByChannelAndNode(new ChannelFeeState { ChannelId = 2, ManagedNodePubKey = "02a", LastAppliedOutboundPpm = 20 }); + // Same channel, other managed side — must survive. + await sut.UpsertByChannelAndNode(new ChannelFeeState { ChannelId = 2, ManagedNodePubKey = "02b", LastAppliedOutboundPpm = 30 }); var deleted = await sut.DeleteByManagedNodePubKey("02a"); deleted.Should().BeTrue(); - (await sut.GetByChannelId(1)).Should().BeNull(); - (await sut.GetByChannelId(2)).Should().BeNull(); - // Node B's channel is untouched. - (await sut.GetByChannelId(3)).Should().NotBeNull(); + (await sut.GetByChannelIdAndNode(1, "02a")).Should().BeNull(); + (await sut.GetByChannelIdAndNode(2, "02a")).Should().BeNull(); + (await sut.GetByChannelIdAndNode(2, "02b")).Should().NotBeNull(); + } + + [Fact] + public async Task DeleteByManagedNodePubKey_ReturnsFalse_WhenAbsent() + { + var (factory, _) = SetupDb(); + var sut = new ChannelFeeStateRepository(factory.Object); + + (await sut.DeleteByManagedNodePubKey("02a")).Should().BeFalse(); } } diff --git a/test/NodeGuard.Tests/Data/Repositories/ChannelRoutingStateRepositoryTests.cs b/test/NodeGuard.Tests/Data/Repositories/ChannelRoutingStateRepositoryTests.cs index 71b1e2f9..5444f688 100644 --- a/test/NodeGuard.Tests/Data/Repositories/ChannelRoutingStateRepositoryTests.cs +++ b/test/NodeGuard.Tests/Data/Repositories/ChannelRoutingStateRepositoryTests.cs @@ -40,13 +40,13 @@ public class ChannelRoutingStateRepositoryTests } [Fact] - public async Task UpsertByChannelId_InsertsThenUpdatesInPlace_PreservingSmoothedFields() + public async Task UpsertByChannelAndNode_InsertsThenUpdatesInPlace_PreservingSmoothedFields() { var (factory, options) = SetupDb(); var sut = new ChannelRoutingStateRepository(factory.Object); // First call inserts. - await sut.UpsertByChannelId(new ChannelRoutingState + await sut.UpsertByChannelAndNode(new ChannelRoutingState { ChannelId = 42, ManagedNodePubKey = "02node", @@ -56,13 +56,13 @@ await sut.UpsertByChannelId(new ChannelRoutingState LastEvaluatedAt = DateTimeOffset.UtcNow, }); - var afterInsert = await sut.GetByChannelId(42); + var afterInsert = await sut.GetByChannelIdAndNode(42, "02node"); 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 + // Second call with the same (ChannelId, node) updates in place. + await sut.UpsertByChannelAndNode(new ChannelRoutingState { ChannelId = 42, ManagedNodePubKey = "02node", @@ -72,7 +72,7 @@ await sut.UpsertByChannelId(new ChannelRoutingState LastEvaluatedAt = DateTimeOffset.UtcNow, }); - var afterUpdate = await sut.GetByChannelId(42); + var afterUpdate = await sut.GetByChannelIdAndNode(42, "02node"); afterUpdate!.EmaLocalRatio.Should().Be(0.75); afterUpdate.TargetLocalRatio.Should().Be(0.62); afterUpdate.PeerFlowCategory.Should().Be(PeerFlowCategory.Bidirectional); @@ -88,12 +88,58 @@ 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 }); + await sut.UpsertByChannelAndNode(new ChannelRoutingState { ChannelId = 1, ManagedNodePubKey = "02a", LastEvaluatedAt = DateTimeOffset.UtcNow }); + await sut.UpsertByChannelAndNode(new ChannelRoutingState { ChannelId = 2, ManagedNodePubKey = "02a", LastEvaluatedAt = DateTimeOffset.UtcNow }); + await sut.UpsertByChannelAndNode(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 }); } + + [Fact] + public async Task UpsertByChannelAndNode_KeepsOneRowPerManagedSideOfTheSameChannel() + { + var (factory, options) = SetupDb(); + var sut = new ChannelRoutingStateRepository(factory.Object); + + // Both ends of channel 7 are managed by NodeGuard; each keeps its own view of it. + await sut.UpsertByChannelAndNode(new ChannelRoutingState + { + ChannelId = 7, ManagedNodePubKey = "02a", EmaLocalRatio = 0.95, LastEvaluatedAt = DateTimeOffset.UtcNow, + }); + await sut.UpsertByChannelAndNode(new ChannelRoutingState + { + ChannelId = 7, ManagedNodePubKey = "02b", EmaLocalRatio = 0.05, LastEvaluatedAt = DateTimeOffset.UtcNow, + }); + + await using var verify = new ApplicationDbContext(options); + (await verify.ChannelRoutingStates.CountAsync(x => x.ChannelId == 7)).Should().Be(2); + + (await sut.GetByChannelIdAndNode(7, "02a"))!.EmaLocalRatio.Should().Be(0.95); + (await sut.GetByChannelIdAndNode(7, "02b"))!.EmaLocalRatio.Should().Be(0.05); + + // Updating one side leaves the other untouched. + await sut.UpsertByChannelAndNode(new ChannelRoutingState + { + ChannelId = 7, ManagedNodePubKey = "02a", EmaLocalRatio = 0.80, LastEvaluatedAt = DateTimeOffset.UtcNow, + }); + + (await sut.GetByChannelIdAndNode(7, "02a"))!.EmaLocalRatio.Should().Be(0.80); + (await sut.GetByChannelIdAndNode(7, "02b"))!.EmaLocalRatio.Should().Be(0.05); + } + + [Fact] + public async Task GetByManagedNodePubKey_ReturnsThisNodesSideOfASharedChannel() + { + var (factory, _) = SetupDb(); + var sut = new ChannelRoutingStateRepository(factory.Object); + + await sut.UpsertByChannelAndNode(new ChannelRoutingState { ChannelId = 7, ManagedNodePubKey = "02a", EmaLocalRatio = 0.95, LastEvaluatedAt = DateTimeOffset.UtcNow }); + await sut.UpsertByChannelAndNode(new ChannelRoutingState { ChannelId = 7, ManagedNodePubKey = "02b", EmaLocalRatio = 0.05, LastEvaluatedAt = DateTimeOffset.UtcNow }); + + var forB = await sut.GetByManagedNodePubKey("02b"); + forB.Should().ContainSingle(); + forB[0].EmaLocalRatio.Should().Be(0.05); + } } From ae03c6fdf95e921a5af8254ebe3768fc66648931 Mon Sep 17 00:00:00 2001 From: Marcos Date: Fri, 21 Aug 2026 15:06:41 +0200 Subject: [PATCH 3/5] refactor: update TargetRatioReevaluationJob to simplify node reevaluation and enhance routing state handling --- src/Jobs/TargetRatioReevaluationJob.cs | 13 +++----- .../Jobs/TargetRatioReevaluationJobTests.cs | 33 ++++++++++++------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/Jobs/TargetRatioReevaluationJob.cs b/src/Jobs/TargetRatioReevaluationJob.cs index 82400276..4285f546 100644 --- a/src/Jobs/TargetRatioReevaluationJob.cs +++ b/src/Jobs/TargetRatioReevaluationJob.cs @@ -83,7 +83,7 @@ public async Task Execute(IJobExecutionContext context) { try { - await ReevaluateNode(managedNode, managedNodes, openChannelsByChanId); + await ReevaluateNode(managedNode, openChannelsByChanId); } catch (Exception ex) { @@ -103,7 +103,6 @@ public async Task Execute(IJobExecutionContext context) private async Task ReevaluateNode( Node managedNode, - IReadOnlyCollection managedNodes, IReadOnlyDictionary openChannelsByChanId) { var chainTip = await _lightningService.GetBlockHeight(managedNode); @@ -125,16 +124,14 @@ private async Task ReevaluateNode( var now = DateTimeOffset.UtcNow; var windowStart = now - TimeSpan.FromDays(Constants.ROUTING_ENGINE_FLOW_WINDOW_DAYS); + // Every channel the node holds gets state, including channels shared with another managed + // node: routing state is per (channel, managed node), and each side's local balance, flow + // history and fee policy are its own. Deduping these to the initiator left the other side + // blind to its own depleted channels, so they never became rebalance destinations. 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 (O(1) lookup by scid). if (!openChannelsByChanId.TryGetValue(lndChannel.ChanId, out var dbChannel)) { diff --git a/test/NodeGuard.Tests/Jobs/TargetRatioReevaluationJobTests.cs b/test/NodeGuard.Tests/Jobs/TargetRatioReevaluationJobTests.cs index 5b3570b3..9f6f45fd 100644 --- a/test/NodeGuard.Tests/Jobs/TargetRatioReevaluationJobTests.cs +++ b/test/NodeGuard.Tests/Jobs/TargetRatioReevaluationJobTests.cs @@ -35,6 +35,7 @@ namespace NodeGuard.Jobs; /// is used so these prove the JOB feeds it correctly: age gate, ownership/eligibility filter, the /// push/pull → net-flow sign convention, first-insert EMA seeding, failure handling, and the kill switch. /// +[Collection("RoutingEngine")] public class TargetRatioReevaluationJobTests { private const string NodePubKey = "alicePubKey"; @@ -107,7 +108,7 @@ private Channel ArrangeSingleChannel(Node node, long localBalance, long remoteBa .Setup(x => x.ListChannels(It.IsAny(), It.IsAny())) .ReturnsAsync(listResp); - _routingStateRepository.Setup(x => x.GetByChannelId(ChannelDbId)).ReturnsAsync((ChannelRoutingState?)null); + _routingStateRepository.Setup(x => x.GetByChannelIdAndNode(ChannelDbId, NodePubKey)).ReturnsAsync((ChannelRoutingState?)null); _forwardingHtlcEventRepository .Setup(x => x.GetOutgoingAmountMsat(NodePubKey, ChanId, It.IsAny())) .ReturnsAsync(push); @@ -120,10 +121,10 @@ private Channel ArrangeSingleChannel(Node node, long localBalance, long remoteBa private ChannelRoutingState? _captured; - /// Captures the state handed to UpsertByChannelId so a test can assert the persisted result. + /// Captures the state handed to UpsertByChannelAndNode so a test can assert the persisted result. private void CaptureUpsert() => _routingStateRepository - .Setup(x => x.UpsertByChannelId(It.IsAny())) + .Setup(x => x.UpsertByChannelAndNode(It.IsAny())) .Callback(s => _captured = s) .Returns(Task.CompletedTask); @@ -278,7 +279,7 @@ public async Task Execute_YoungChannel_StaysUncategorized_ButStillSensesFlow() } [Fact] - public async Task Execute_PeerInitiatedChannelToManagedPeer_IsSkipped() + public async Task Execute_PeerInitiatedChannelToManagedPeer_StillGetsItsOwnState() { var prevEnabled = Constants.ROUTING_ENGINE_ENABLED; var prevMinAge = Constants.ROUTING_ENGINE_CATEGORIZATION_MIN_AGE_BLOCKS; @@ -297,7 +298,9 @@ public async Task Execute_PeerInitiatedChannelToManagedPeer_IsSkipped() var node = BuildNode(); var peer = new Node { Id = 21, PubKey = PeerPubKey, Name = "bob", DynamicFeeManagementEnabled = true }; - // Both nodes are managed; the channel is peer-initiated ⇒ the dedup rule assigns it to the peer. + // Both nodes are managed and the channel is peer-initiated. There is no dedup any more: + // this side keeps its own routing state, because its local balance and fee policy are + // its own. Without it the node is blind to this channel when it runs dry. _nodeRepository.Setup(x => x.GetAllManagedByNodeGuard(false)).ReturnsAsync(new List { node, peer }); _lightningService.Setup(x => x.GetBlockHeight(It.IsAny())).ReturnsAsync((uint?)5000); @@ -311,11 +314,17 @@ public async Task Execute_PeerInitiatedChannelToManagedPeer_IsSkipped() _lightningClientService.Setup(x => x.ListChannels(It.Is(n => n.PubKey == NodePubKey), It.IsAny())).ReturnsAsync(aliceList); _lightningClientService.Setup(x => x.ListChannels(It.Is(n => n.PubKey == PeerPubKey), It.IsAny())).ReturnsAsync(new Lnrpc.ListChannelsResponse()); + _routingStateRepository.Setup(x => x.GetByChannelIdAndNode(ChannelDbId, NodePubKey)).ReturnsAsync((ChannelRoutingState?)null); + CaptureUpsert(); + await BuildJob().Execute(Mock.Of()); - _routingStateRepository.Verify(x => x.GetByChannelId(It.IsAny()), Times.Never); - _routingStateRepository.Verify(x => x.UpsertByChannelId(It.IsAny()), Times.Never); - _forwardingHtlcEventRepository.Verify(x => x.GetOutgoingAmountMsat(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _routingStateRepository.Verify(x => x.UpsertByChannelAndNode(It.IsAny()), Times.Once); + _captured.Should().NotBeNull(); + _captured!.ChannelId.Should().Be(ChannelDbId); + // Stamped with this node, not the initiator peer. + _captured.ManagedNodePubKey.Should().Be(NodePubKey); + _captured.PeerInitiated.Should().BeTrue(); } finally { @@ -350,8 +359,8 @@ public async Task Execute_InactiveChannel_IsSkipped() await BuildJob().Execute(Mock.Of()); - _routingStateRepository.Verify(x => x.GetByChannelId(It.IsAny()), Times.Never); - _routingStateRepository.Verify(x => x.UpsertByChannelId(It.IsAny()), Times.Never); + _routingStateRepository.Verify(x => x.GetByChannelIdAndNode(It.IsAny(), It.IsAny()), Times.Never); + _routingStateRepository.Verify(x => x.UpsertByChannelAndNode(It.IsAny()), Times.Never); } finally { @@ -389,7 +398,7 @@ public async Task Execute_BlockHeightUnavailable_SkipsNodeWithoutThrowing() await act.Should().NotThrowAsync(); _lightningClientService.Verify(x => x.ListChannels(It.IsAny(), It.IsAny()), Times.Never); - _routingStateRepository.Verify(x => x.UpsertByChannelId(It.IsAny()), Times.Never); + _routingStateRepository.Verify(x => x.UpsertByChannelAndNode(It.IsAny()), Times.Never); } finally { @@ -430,7 +439,7 @@ public async Task Execute_ListChannelsUnavailable_SkipsNodeWithoutThrowing() await act.Should().NotThrowAsync(); _forwardingHtlcEventRepository.Verify(x => x.GetOutgoingAmountMsat(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - _routingStateRepository.Verify(x => x.UpsertByChannelId(It.IsAny()), Times.Never); + _routingStateRepository.Verify(x => x.UpsertByChannelAndNode(It.IsAny()), Times.Never); } finally { From 5762ceca8888fce1264d6972c0ec7578138ba887 Mon Sep 17 00:00:00 2001 From: Marcos Date: Fri, 21 Aug 2026 15:12:18 +0200 Subject: [PATCH 4/5] fix: update fee state repository verification to use UpsertByChannelAndNode method --- test/NodeGuard.Tests/Jobs/ChannelFeeOptimizerJobTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/NodeGuard.Tests/Jobs/ChannelFeeOptimizerJobTests.cs b/test/NodeGuard.Tests/Jobs/ChannelFeeOptimizerJobTests.cs index f0637b36..72c76782 100644 --- a/test/NodeGuard.Tests/Jobs/ChannelFeeOptimizerJobTests.cs +++ b/test/NodeGuard.Tests/Jobs/ChannelFeeOptimizerJobTests.cs @@ -213,7 +213,7 @@ public async Task Execute_DryRunNode_RecordsFeeStateButDoesNotWriteToLnd() // and the computed values are recorded so the operator can see what WOULD have been applied. _lightningService.Verify(x => x.GetChannelFeePolicy(ChanId, It.IsAny()), Times.Once); VerifyNoFeeWrite(); - _feeStateRepository.Verify(x => x.UpsertByChannelId( + _feeStateRepository.Verify(x => x.UpsertByChannelAndNode( It.Is(s => s.LastAppliedOutboundPpm == 2550u && s.LastFeeUpdateAt != null)), Times.Once); } finally @@ -260,7 +260,7 @@ public async Task Execute_NoRoutingStateForChannel_SkipsChannel() _lightningService.Verify(x => x.GetChannelFeePolicy(It.IsAny(), It.IsAny()), Times.Never); VerifyNoFeeWrite(); - _feeStateRepository.Verify(x => x.UpsertByChannelId(It.IsAny()), Times.Never); + _feeStateRepository.Verify(x => x.UpsertByChannelAndNode(It.IsAny()), Times.Never); } finally { @@ -320,7 +320,7 @@ public async Task Execute_InsideDeadband_PersistsStateButDoesNotTouchLnd() // NoOp channels never cost an LND round-trip, but the observed ratio/target are still persisted. _lightningService.Verify(x => x.GetChannelFeePolicy(It.IsAny(), It.IsAny()), Times.Never); VerifyNoFeeWrite(); - _feeStateRepository.Verify(x => x.UpsertByChannelId(It.IsAny()), Times.Once); + _feeStateRepository.Verify(x => x.UpsertByChannelAndNode(It.IsAny()), Times.Once); } finally { @@ -350,7 +350,7 @@ public async Task Execute_SetFeePolicyThrows_IsSwallowed_AndStateNotPersisted() It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); // On write failure the fee state is NOT persisted (LastApplied stays null → next cycle re-seeds). - _feeStateRepository.Verify(x => x.UpsertByChannelId(It.IsAny()), Times.Never); + _feeStateRepository.Verify(x => x.UpsertByChannelAndNode(It.IsAny()), Times.Never); } finally { From 3603b608c97770b4f07eb495df60bd8cd34e5cf3 Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 26 Aug 2026 17:40:56 +0200 Subject: [PATCH 5/5] fix: include managed node public key in fee state initialization --- src/Jobs/ChannelFeeOptimizerJob.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Jobs/ChannelFeeOptimizerJob.cs b/src/Jobs/ChannelFeeOptimizerJob.cs index da517845..2e31284a 100644 --- a/src/Jobs/ChannelFeeOptimizerJob.cs +++ b/src/Jobs/ChannelFeeOptimizerJob.cs @@ -193,7 +193,10 @@ private async Task OptimizeNode( private async Task OptimizeChannel(Node node, Candidate candidate, FeeOptimizerTunables tunables, DateTimeOffset now) { var routingState = candidate.RoutingState; - var feeState = candidate.FeeState ?? new ChannelFeeState { ChannelId = candidate.DbChannel.Id }; + var feeState = candidate.FeeState ?? new ChannelFeeState { + ChannelId = candidate.DbChannel.Id, + ManagedNodePubKey = node.PubKey, + }; var decision = FeeOptimizerService.ComputeNextPolicy( routingState.EmaLocalRatio,