From 1f707f7a0c2a7052840b32c800e4d4776922266f Mon Sep 17 00:00:00 2001 From: Marcos Date: Thu, 20 Aug 2026 18:00:04 +0200 Subject: [PATCH 01/21] feat: add per-channel automated rebalancing opt-in --- src/Data/ApplicationDbContext.cs | 1 + src/Data/Models/Channel.cs | 6 + ...AddChannelAutoRebalanceEnabled.Designer.cs | 1926 +++++++++++++++++ ...20153340_AddChannelAutoRebalanceEnabled.cs | 29 + .../ApplicationDbContextModelSnapshot.cs | 5 + src/Pages/Channels.razor | 23 +- 6 files changed, 1987 insertions(+), 3 deletions(-) create mode 100644 src/Migrations/20260820153340_AddChannelAutoRebalanceEnabled.Designer.cs create mode 100644 src/Migrations/20260820153340_AddChannelAutoRebalanceEnabled.cs diff --git a/src/Data/ApplicationDbContext.cs b/src/Data/ApplicationDbContext.cs index dce774e2..f3e0084e 100644 --- a/src/Data/ApplicationDbContext.cs +++ b/src/Data/ApplicationDbContext.cs @@ -130,6 +130,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // 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.IsAutoRebalanceEnabled).HasDefaultValue(false); modelBuilder.Entity().Property(x => x.RoutingEngineDryRun).HasDefaultValue(false); base.OnModelCreating(modelBuilder); diff --git a/src/Data/Models/Channel.cs b/src/Data/Models/Channel.cs index 57b23c8f..0121e877 100644 --- a/src/Data/Models/Channel.cs +++ b/src/Data/Models/Channel.cs @@ -74,6 +74,12 @@ public enum ChannelStatus /// public bool IsDynamicFeeEnabled { get; set; } = false; + /// + /// Per-channel opt-in for the auto-rebalancer. Defaults false; the node-level + /// flag still gates all rebalancing. + /// + public bool IsAutoRebalanceEnabled { get; set; } = false; + [NotMapped] public int? OpenedWithId => ChannelOperationRequests?.FirstOrDefault()?.Wallet?.Id; diff --git a/src/Migrations/20260820153340_AddChannelAutoRebalanceEnabled.Designer.cs b/src/Migrations/20260820153340_AddChannelAutoRebalanceEnabled.Designer.cs new file mode 100644 index 00000000..2f29eda4 --- /dev/null +++ b/src/Migrations/20260820153340_AddChannelAutoRebalanceEnabled.Designer.cs @@ -0,0 +1,1926 @@ +// +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("20260820153340_AddChannelAutoRebalanceEnabled")] + partial class AddChannelAutoRebalanceEnabled + { + /// + 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("IsAutoRebalanceEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + 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/20260820153340_AddChannelAutoRebalanceEnabled.cs b/src/Migrations/20260820153340_AddChannelAutoRebalanceEnabled.cs new file mode 100644 index 00000000..2d78ba57 --- /dev/null +++ b/src/Migrations/20260820153340_AddChannelAutoRebalanceEnabled.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NodeGuard.Migrations +{ + /// + public partial class AddChannelAutoRebalanceEnabled : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsAutoRebalanceEnabled", + table: "Channels", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsAutoRebalanceEnabled", + table: "Channels"); + } + } +} diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index 22fac1ad..07c7d96c 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -409,6 +409,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("FundingTxOutputIndex") .HasColumnType("bigint"); + b.Property("IsAutoRebalanceEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + b.Property("IsAutomatedLiquidityEnabled") .HasColumnType("boolean"); diff --git a/src/Pages/Channels.razor b/src/Pages/Channels.razor index d09f69ea..1ed77856 100644 --- a/src/Pages/Channels.razor +++ b/src/Pages/Channels.razor @@ -421,8 +421,24 @@ + + Automated rebalancing (Channel) + + When enabled, the routing engine may drain this channel as the source of a circular + rebalance to refill a depleted one. The node's "Auto rebalance" setting and its fee + budget still gate every rebalance. + + + Enable automated rebalancing for this channel + + + + + Enable automated liquidity management + Swap-based liquidity rules — independent of automated rebalancing above. @if (_selectedChannel.IsAutomatedLiquidityEnabled && _currentLiquidityRule != null) @@ -1168,12 +1184,13 @@ ToastService.ShowSuccess("Channel updated successfully"); await AuditService.LogAsync(AuditActionType.Update, AuditEventType.Success, AuditObjectType.Channel, _selectedChannel.Id.ToString(), - $"Channel updated. ChanId: {_selectedChannel.ChanId}"); + $"Channel updated. ChanId: {_selectedChannel.ChanId}, IsAutoRebalanceEnabled: {_selectedChannel.IsAutoRebalanceEnabled}, IsAutomatedLiquidityEnabled: {_selectedChannel.IsAutomatedLiquidityEnabled}"); } } - //Save the liquidity rule if the liquidity rule id is below or equal zero - if (_currentLiquidityRule != null) + // Save the liquidity rule if the liquidity rule id is below or equal zero + // and the channel has automated liquidity enabled + if (_selectedChannel?.IsAutomatedLiquidityEnabled == true && _currentLiquidityRule != null) { if (_currentLiquidityRule.Id <= 0) { From 5f7a954d16a41feb9049e2115fc2e5add1ee1058 Mon Sep 17 00:00:00 2001 From: Marcos Date: Fri, 21 Aug 2026 14:29:17 +0200 Subject: [PATCH 02/21] 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 36e770faf514fbccaed050bd8c48fa6273370ac4 Mon Sep 17 00:00:00 2001 From: Marcos Date: Fri, 21 Aug 2026 14:55:36 +0200 Subject: [PATCH 03/21] 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 d4f43e6ee1dfc6dfcd02a7cab01f3ff1b469f3b0 Mon Sep 17 00:00:00 2001 From: Marcos Date: Fri, 21 Aug 2026 15:06:41 +0200 Subject: [PATCH 04/21] 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 8e409e274366d87aa087f1eb3cd11ddd112f754d Mon Sep 17 00:00:00 2001 From: Marcos Date: Fri, 21 Aug 2026 15:12:18 +0200 Subject: [PATCH 05/21] 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 4192fcbc67980357e0f163d9b1483feacd351409 Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 25 Aug 2026 11:09:01 +0200 Subject: [PATCH 06/21] test: add RoutingEngineCollection for sequential routing-engine job tests --- .../Jobs/RoutingEngineCollection.cs | 30 +++++++++++++++++++ .../Jobs/TargetRatioReevaluationJobTests.cs | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 test/NodeGuard.Tests/Jobs/RoutingEngineCollection.cs diff --git a/test/NodeGuard.Tests/Jobs/RoutingEngineCollection.cs b/test/NodeGuard.Tests/Jobs/RoutingEngineCollection.cs new file mode 100644 index 00000000..c7d249e3 --- /dev/null +++ b/test/NodeGuard.Tests/Jobs/RoutingEngineCollection.cs @@ -0,0 +1,30 @@ +/* + * 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.Tests.Jobs; + +/// +/// The routing-engine job tests share this collection so they run sequentially: they tune the +/// engine by assigning to the static Constants.ROUTING_ENGINE_* fields and restore them in +/// a finally block, so running the classes in parallel lets one class observe another's tuning. +/// +[CollectionDefinition("RoutingEngine", DisableParallelization = true)] +public class RoutingEngineCollection +{ +} diff --git a/test/NodeGuard.Tests/Jobs/TargetRatioReevaluationJobTests.cs b/test/NodeGuard.Tests/Jobs/TargetRatioReevaluationJobTests.cs index 9f6f45fd..6f32920d 100644 --- a/test/NodeGuard.Tests/Jobs/TargetRatioReevaluationJobTests.cs +++ b/test/NodeGuard.Tests/Jobs/TargetRatioReevaluationJobTests.cs @@ -30,7 +30,7 @@ namespace NodeGuard.Jobs; /// -/// Wiring tests for the Phase-1 sensor/classifier. The pure categorization math lives in +/// Wiring tests for the sensor/classifier. The pure categorization math lives in /// ; here the real /// 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. From 339c0fa14fdb54dc4c72b8b225d4fbf2d8f3776a Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 25 Aug 2026 11:11:01 +0200 Subject: [PATCH 07/21] refactor: replace channel ownership check with helper method for clarity --- src/Services/LightningService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Services/LightningService.cs b/src/Services/LightningService.cs index 0bc04f82..db116230 100644 --- a/src/Services/LightningService.cs +++ b/src/Services/LightningService.cs @@ -1533,7 +1533,7 @@ public async Task> GetChannelsState() if (channel == null) continue; // If the source node is not the channel initiator, but the remote node is also managed by NodeGuard // We skip and wait for the other node to report the channel - if (nodes.Any((n) => !channel.Initiator && n.PubKey == channel.RemotePubkey)) continue; + if (!ChannelOwnershipHelper.IsOwnedByManagedNode(channel, nodes)) continue; var htlcsLocal = channel.PendingHtlcs.Where(x => x.Incoming == true).Sum(x => x.Amount); var htlcsRemote = channel.PendingHtlcs.Where(x => x.Incoming == false).Sum(x => x.Amount); From 982e3550d0796086998bf8b8db0b1b2b08975296 Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 25 Aug 2026 11:11:57 +0200 Subject: [PATCH 08/21] feat: add IsAutoRebalanceEnabled property to new channels configuration --- src/Jobs/NodeChannelSubscribeJob.cs | 3 ++- src/Services/LightningService.cs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Jobs/NodeChannelSubscribeJob.cs b/src/Jobs/NodeChannelSubscribeJob.cs index 3a860f3e..73bdc6bc 100644 --- a/src/Jobs/NodeChannelSubscribeJob.cs +++ b/src/Jobs/NodeChannelSubscribeJob.cs @@ -103,7 +103,8 @@ public async Task NodeUpdateManagement(ChannelEventUpdate channelEventUpdate, No CreationDatetime = DateTimeOffset.Now, UpdateDatetime = DateTimeOffset.Now, IsPrivate = channelOpened.Private, - IsDynamicFeeEnabled = node.DynamicFeeManagementEnabled + IsDynamicFeeEnabled = node.DynamicFeeManagementEnabled, + IsAutoRebalanceEnabled = node.AutoRebalanceEnabled }; var remoteNode = await _nodeRepository.GetOrCreateByPubKey(channelOpened.RemotePubkey, _lightningService); diff --git a/src/Services/LightningService.cs b/src/Services/LightningService.cs index db116230..09946eee 100644 --- a/src/Services/LightningService.cs +++ b/src/Services/LightningService.cs @@ -801,7 +801,8 @@ public async Task CreateChannel(Node source, int destId, ChannelPoint c DestinationNodeId = destinationNodeId, CreatedByNodeGuard = true, IsPrivate = currentChannel.Private, - IsDynamicFeeEnabled = source.DynamicFeeManagementEnabled + IsDynamicFeeEnabled = source.DynamicFeeManagementEnabled, + IsAutoRebalanceEnabled = source.AutoRebalanceEnabled }; return channel; From 96c91c2c33d100e2d15a2444f2af2d253acdc5f8 Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 25 Aug 2026 11:13:57 +0200 Subject: [PATCH 09/21] feat: add GetLocalOutboundFeeRatePpmAsync method to the interface --- src/Services/LightningService.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Services/LightningService.cs b/src/Services/LightningService.cs index 09946eee..33e8cd27 100644 --- a/src/Services/LightningService.cs +++ b/src/Services/LightningService.cs @@ -188,6 +188,14 @@ Task SendPaymentV2Async(Node node, string paymentRequest, long amountSa Task GetLocalOutboundFeeRatePpmByPeerAsync (Node node, string peerPubkey); + /// + /// Returns the local-outbound fee rate (ppm) of a specific channel — the per-channel + /// variant of . Used by the + /// auto-rebalancer to pick the cheapest source to drain and to weight a destination + /// peer's earn rate for the profitability gate. + /// + Task GetLocalOutboundFeeRatePpmAsync(Node node, ulong chanId); + /// /// Sets the channel fee policy for a given channel identified by its chanPoint /// From 3e547da781059fa191e24bd3cc703e2416e9a73e Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 25 Aug 2026 11:15:39 +0200 Subject: [PATCH 10/21] feat: add WorstCaseFeeSats method for calculating worst-case fee reservations --- src/Data/Models/Rebalance.cs | 6 ++++++ src/Data/Repositories/RebalanceRepository.cs | 5 ++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Data/Models/Rebalance.cs b/src/Data/Models/Rebalance.cs index f5d14db7..f1ae70e6 100644 --- a/src/Data/Models/Rebalance.cs +++ b/src/Data/Models/Rebalance.cs @@ -90,6 +90,12 @@ public class Rebalance : Entity [NotMapped] public Money FeePaid => new Money(FeePaidSats ?? 0, MoneyUnit.Satoshi); + /// + /// Worst-case fee a not-yet-settled rebalance can still consume: the whole amount at its fee cap. + /// + public static long WorstCaseFeeSats(long amountSats, double maxFeePct) + => (long)(amountSats * maxFeePct / 100.0); + public int? SourceChannelId { get; set; } public Channel? SourceChannel { get; set; } diff --git a/src/Data/Repositories/RebalanceRepository.cs b/src/Data/Repositories/RebalanceRepository.cs index 9d2c47cc..ab61529f 100644 --- a/src/Data/Repositories/RebalanceRepository.cs +++ b/src/Data/Repositories/RebalanceRepository.cs @@ -181,9 +181,8 @@ public async Task GetConsumedFeesSince(int nodeId, DateTimeOffset since) 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); + // Conservative worst-case reservation for a still-in-flight rebalance. + var reserved = Rebalance.WorstCaseFeeSats(r.RequestedAmountSats, r.MaxFeePct); total += r.Status == RebalanceStatus.Succeeded ? feePaid : reserved; From 802352e1cab546f0a990cd9803e49a57d444c47e Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 25 Aug 2026 11:18:34 +0200 Subject: [PATCH 11/21] feat: add routing engine actuator job configurations and parameters --- src/Helpers/Constants.cs | 45 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index 2c7b2849..aeefbc49 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -273,11 +273,17 @@ public class Constants public static int ROUTING_ENGINE_CATEGORY_FLIP_HYSTERESIS_CYCLES = 3; /// - /// Cadence of TargetRatioReevaluationJob and ChannelFeeOptimizerJob in prod, in minutes. Default 30. In dev + /// Cadence of TargetRatioReevaluationJob and RoutingEngineActuatorJob in prod, in minutes. Default 30. In dev /// (IS_DEV_ENVIRONMENT) the job runs every 5 minutes regardless. /// public static int ROUTING_ENGINE_JOB_INTERVAL_MINUTES = 30; + /// + /// How long after TargetRatioReevaluationJob the RoutingEngineActuatorJob first fires, + /// in minutes (prod only). + /// + public static int ROUTING_ENGINE_ACTUATOR_OFFSET_MINUTES = 5; + // Both fees use integral control: each cycle the applied value is nudged by gain·deviation·baseline // off its previous value, so a persistent deviation keeps driving the fee until the channel balances. public static double ROUTING_ENGINE_FEE_OUTBOUND_INTEGRAL_GAIN = 0.8; @@ -288,7 +294,7 @@ public class Constants public static double ROUTING_ENGINE_FEE_DEADBAND = 0.03; // The rebalancer's imbalance deadband is a separate, more aggressive threshold for triggering rebalances. - public static double ROUTING_ENGINE_REBALANCE_TRIGGER = 0.15; + public static double ROUTING_ENGINE_REBALANCE_DEADBAND = 0.15; // Max outbound ppm change applied in a single cycle (rate limiter / anti-jump). public static uint ROUTING_ENGINE_FEE_MAX_STEP_PPM = 50; @@ -328,6 +334,27 @@ public class Constants // Outbound ppm baseline for not-yet-categorized channels (safe mid default). public static uint ROUTING_ENGINE_FEE_BASELINE_PPM_UNCATEGORIZED = 1500; + // Routing Engine: automated rebalancer + + /// Upper clamp on a single automated rebalance amount (sats) + public static long ROUTING_ENGINE_REBALANCE_MAX_AMOUNT_SATS = 10_000_000; + + /// Max rebalances the routing-engine actuator initiates per node per run + public static int ROUTING_ENGINE_REBALANCE_MAX_INITIATIONS_PER_RUN = 5; + + /// Fallback profitability margin when a node leaves MaxRebalanceCostToEarnRatio unset. The + /// gate caps rebalance cost at ratio × (capacity-weighted-avg outbound ppm of the refilled + /// peer). 0.5 = spend at most half the destination's earn rate. + public static double ROUTING_ENGINE_REBALANCE_DEFAULT_COST_TO_EARN_RATIO = 0.5; + + /// Fallback max concurrent (Pending/InFlight) rebalances when a node leaves + /// MaxRebalancesInFlight unset. + public static int ROUTING_ENGINE_REBALANCE_DEFAULT_MAX_IN_FLIGHT = 1; + + /// Fallback rebalance-budget refresh window (hours) when a node leaves + /// RebalanceBudgetRefreshInterval unset. + public static int ROUTING_ENGINE_REBALANCE_DEFAULT_BUDGET_REFRESH_HOURS = 24; + public const string IsFrozenTag = "frozen"; public const string IsManuallyFrozenTag = "manually_frozen"; @@ -599,6 +626,8 @@ static Constants() var reJobInterval = Environment.GetEnvironmentVariable("ROUTING_ENGINE_JOB_INTERVAL_MINUTES"); if (reJobInterval != null) ROUTING_ENGINE_JOB_INTERVAL_MINUTES = int.Parse(reJobInterval); + var reActuatorOffset = Environment.GetEnvironmentVariable("ROUTING_ENGINE_ACTUATOR_OFFSET_MINUTES"); + if (reActuatorOffset != null) ROUTING_ENGINE_ACTUATOR_OFFSET_MINUTES = int.Parse(reActuatorOffset); // Routing Engine var feeOutboundIntegralGain = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_OUTBOUND_INTEGRAL_GAIN"); @@ -611,7 +640,7 @@ static Constants() 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_TRIGGER = double.Parse(rebalanceDeadband, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); + 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); @@ -648,6 +677,16 @@ static Constants() var feeBaselineUncategorized = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_BASELINE_PPM_UNCATEGORIZED"); if (feeBaselineUncategorized != null) ROUTING_ENGINE_FEE_BASELINE_PPM_UNCATEGORIZED = uint.Parse(feeBaselineUncategorized); + var reRebalanceMaxAmount = Environment.GetEnvironmentVariable("ROUTING_ENGINE_REBALANCE_MAX_AMOUNT_SATS"); + if (reRebalanceMaxAmount != null) ROUTING_ENGINE_REBALANCE_MAX_AMOUNT_SATS = long.Parse(reRebalanceMaxAmount); + var reRebalanceMaxInit = Environment.GetEnvironmentVariable("ROUTING_ENGINE_REBALANCE_MAX_INITIATIONS_PER_RUN"); + if (reRebalanceMaxInit != null) ROUTING_ENGINE_REBALANCE_MAX_INITIATIONS_PER_RUN = int.Parse(reRebalanceMaxInit); + var reRebalanceDefaultRatio = Environment.GetEnvironmentVariable("ROUTING_ENGINE_REBALANCE_DEFAULT_COST_TO_EARN_RATIO"); + if (reRebalanceDefaultRatio != null) ROUTING_ENGINE_REBALANCE_DEFAULT_COST_TO_EARN_RATIO = double.Parse(reRebalanceDefaultRatio, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); + var reRebalanceDefaultMaxInFlight = Environment.GetEnvironmentVariable("ROUTING_ENGINE_REBALANCE_DEFAULT_MAX_IN_FLIGHT"); + if (reRebalanceDefaultMaxInFlight != null) ROUTING_ENGINE_REBALANCE_DEFAULT_MAX_IN_FLIGHT = int.Parse(reRebalanceDefaultMaxInFlight); + var reRebalanceDefaultRefreshHours = Environment.GetEnvironmentVariable("ROUTING_ENGINE_REBALANCE_DEFAULT_BUDGET_REFRESH_HOURS"); + if (reRebalanceDefaultRefreshHours != null) ROUTING_ENGINE_REBALANCE_DEFAULT_BUDGET_REFRESH_HOURS = int.Parse(reRebalanceDefaultRefreshHours); // DB Initialization ALICE_PUBKEY = Environment.GetEnvironmentVariable("ALICE_PUBKEY") ?? ALICE_PUBKEY; From 5a0bc6b34c41eb448bd3cbb5cb72a54fe0461f91 Mon Sep 17 00:00:00 2001 From: Marcos Date: Thu, 27 Aug 2026 11:03:24 +0200 Subject: [PATCH 12/21] refactor: remove GetChannelsByOpenAndDynamicFeeEnabled method and update ChannelFeeOptimizerJob to use GetOpenChannels --- src/Data/Repositories/ChannelRepository.cs | 9 -- .../Interfaces/IChannelRepository.cs | 2 - src/Jobs/ChannelFeeOptimizerJob.cs | 120 ++++++------------ src/Services/RoutingEngineSnapshotService.cs | 111 ++++++++++++++++ .../Jobs/ChannelFeeOptimizerJobTests.cs | 98 ++++++++++++-- 5 files changed, 240 insertions(+), 100 deletions(-) create mode 100644 src/Services/RoutingEngineSnapshotService.cs diff --git a/src/Data/Repositories/ChannelRepository.cs b/src/Data/Repositories/ChannelRepository.cs index 09bac94e..32c9c278 100644 --- a/src/Data/Repositories/ChannelRepository.cs +++ b/src/Data/Repositories/ChannelRepository.cs @@ -105,15 +105,6 @@ public async Task> GetOpenChannels() .ToListAsync(); } - public async Task> GetChannelsByOpenAndDynamicFeeEnabled() - { - await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync(); - - return await applicationDbContext.Channels - .Where(c => c.Status == Channel.ChannelStatus.Open && c.ChanId != 0 && c.IsDynamicFeeEnabled) - .ToListAsync(); - } - public async Task<(bool, string?)> AddAsync(Channel type) { await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync(); diff --git a/src/Data/Repositories/Interfaces/IChannelRepository.cs b/src/Data/Repositories/Interfaces/IChannelRepository.cs index bfeb84a1..475ad5d2 100644 --- a/src/Data/Repositories/Interfaces/IChannelRepository.cs +++ b/src/Data/Repositories/Interfaces/IChannelRepository.cs @@ -32,8 +32,6 @@ public interface IChannelRepository Task> GetOpenChannels(); - Task > GetChannelsByOpenAndDynamicFeeEnabled(); - Task<(bool, string?)> AddAsync(Channel type); Task<(bool, string?)> AddRangeAsync(List type); diff --git a/src/Jobs/ChannelFeeOptimizerJob.cs b/src/Jobs/ChannelFeeOptimizerJob.cs index da517845..a4d79201 100644 --- a/src/Jobs/ChannelFeeOptimizerJob.cs +++ b/src/Jobs/ChannelFeeOptimizerJob.cs @@ -27,11 +27,10 @@ namespace NodeGuard.Jobs; /// -/// Dynamic fee actuator. For every eligible, owned channel -/// on a node with dynamic fee management enabled it reads the signal +/// The routing engine's fee actuator. For every eligible channel on a node with +/// it reads the signal /// (), runs the pure control -/// law, and applies the resulting outbound/inbound ppm via LND — enforcing the fee-vs-rebalance -/// authority split. +/// law, and applies the resulting outbound/inbound ppm via LND. /// Everything is gated by the global ROUTING_ENGINE_ENABLED kill switch. /// [DisallowConcurrentExecution] @@ -40,30 +39,27 @@ public class ChannelFeeOptimizerJob : IJob private readonly ILogger _logger; private readonly INodeRepository _nodeRepository; private readonly IChannelRepository _channelRepository; - private readonly IChannelRoutingStateRepository _routingStateRepository; private readonly IChannelFeeStateRepository _feeStateRepository; private readonly IRebalanceRepository _rebalanceRepository; + private readonly IRoutingEngineSnapshotService _snapshotService; private readonly ILightningService _lightningService; - private readonly ILightningClientService _lightningClientService; public ChannelFeeOptimizerJob( ILogger logger, INodeRepository nodeRepository, IChannelRepository channelRepository, - IChannelRoutingStateRepository routingStateRepository, IChannelFeeStateRepository feeStateRepository, IRebalanceRepository rebalanceRepository, - ILightningService lightningService, - ILightningClientService lightningClientService) + IRoutingEngineSnapshotService snapshotService, + ILightningService lightningService) { _logger = logger; _nodeRepository = nodeRepository; _channelRepository = channelRepository; - _routingStateRepository = routingStateRepository; _feeStateRepository = feeStateRepository; _rebalanceRepository = rebalanceRepository; + _snapshotService = snapshotService; _lightningService = lightningService; - _lightningClientService = lightningClientService; } public async Task Execute(IJobExecutionContext context) @@ -81,31 +77,33 @@ public async Task Execute(IJobExecutionContext context) var tunables = FeeOptimizerTunables.FromConstants(); var managedNodes = await _nodeRepository.GetAllManagedByNodeGuard(withDisabled: false); - var anyEnabled = managedNodes.Any(n => n.DynamicFeeManagementEnabled); - if (!anyEnabled) + var relevantNodes = managedNodes.Where(n => n.DynamicFeeManagementEnabled).ToList(); + if (relevantNodes.Count == 0) { _logger.LogInformation("No managed nodes with dynamic fee management enabled; skipping {JobName}", nameof(ChannelFeeOptimizerJob)); return; } - // Shared per-run context, only needed when at least one node is under management. - var channelsByChanId = (await _channelRepository.GetChannelsByOpenAndDynamicFeeEnabled()) - .ToDictionary(c => c.ChanId); var inFlightSourceChannelIds = await _rebalanceRepository.GetPendingInFlightSourceChannelIds(); - foreach (var managedNode in managedNodes) + // Get all the open channels that are eligible for fee optimization in one DB call, then filter per node. + var openChannelsByChanId = (await _channelRepository.GetOpenChannels()) + .Where(c => c.IsDynamicFeeEnabled) + .Where(c => c.SatsAmount >= Constants.ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS) + .Where(c => !inFlightSourceChannelIds.Contains(c.Id)) + .ToDictionary(c => c.ChanId); + + foreach (var node in relevantNodes) { try { - if (!managedNode.DynamicFeeManagementEnabled) continue; - - await OptimizeNode(managedNode, managedNodes, channelsByChanId, inFlightSourceChannelIds, tunables); + await OptimizeNode(node, openChannelsByChanId, inFlightSourceChannelIds, tunables); } catch (Exception ex) { _logger.LogError(ex, "Error optimizing fees for node {NodeName} ({NodePubKey})", - managedNode.Name, managedNode.PubKey); + node.Name, node.PubKey); } } } @@ -118,82 +116,44 @@ public async Task Execute(IJobExecutionContext context) _logger.LogInformation("{JobName} ended", nameof(ChannelFeeOptimizerJob)); } - private sealed class Candidate - { - public required Lnrpc.Channel LndChannel { get; init; } - public required Channel DbChannel { get; init; } - public required ChannelRoutingState RoutingState { get; init; } - public required ChannelFeeState? FeeState { get; init; } - } - private async Task OptimizeNode( Node node, - IReadOnlyCollection managedNodes, - IReadOnlyDictionary channelsByChanId, + IReadOnlyDictionary openChannelsByChanId, IReadOnlySet inFlightSourceChannelIds, FeeOptimizerTunables tunables) { - var listResp = await _lightningClientService.ListChannels(node); - if (listResp == null) + var owned = await _snapshotService.GetOwnedChannelsAsync(node, openChannelsByChanId, withFeeState: true); + if (owned == null) { - _logger.LogWarning("Skipping fee optimization for node {NodeName}: ListChannels unavailable", node.Name); + _logger.LogWarning("Skipping node {NodeName}: ListChannels unavailable", node.Name); return; } - var routingStates = (await _routingStateRepository.GetByManagedNodePubKey(node.PubKey)) - .ToDictionary(s => s.ChannelId); - var feeStates = (await _feeStateRepository.GetByManagedNodePubKey(node.PubKey)) - .ToDictionary(s => s.ChannelId); - - var now = DateTimeOffset.UtcNow; - - // Eligibility filter. - var candidates = new List(); - foreach (var lndChannel in listResp.Channels) - { - if (!ChannelOwnershipHelper.IsOwnedByManagedNode(lndChannel, managedNodes)) continue; - if (!channelsByChanId.TryGetValue(lndChannel.ChanId, out var dbChannel)) continue; - if (lndChannel.Capacity < Constants.ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS) continue; - if (!routingStates.TryGetValue(dbChannel.Id, out var routingState)) continue; // no signal yet - - feeStates.TryGetValue(dbChannel.Id, out var feeState); - - candidates.Add(new Candidate - { - LndChannel = lndChannel, - DbChannel = dbChannel, - RoutingState = routingState, - FeeState = feeState, - }); - } - - foreach (var candidate in candidates) + foreach (var oc in owned) { - // Authority split: never touch a channel the rebalancer is actively moving. - if (inFlightSourceChannelIds.Contains(candidate.DbChannel.Id)) - { - _logger.LogDebug("Skipping channel {ChanId} on {NodeName}: in-flight rebalance owns it", - candidate.LndChannel.ChanId, node.Name); - continue; - } - try { - await OptimizeChannel(node, candidate, tunables, now); + await OptimizeChannel(node, oc, tunables); } catch (Exception ex) { _logger.LogError(ex, "Error optimizing fees for channel {ChanId} on node {NodeName}", - candidate.LndChannel.ChanId, node.Name); + oc.Lnd.ChanId, node.Name); } } } /// Runs the control law for one channel and applies the resulting fee update when needed. - private async Task OptimizeChannel(Node node, Candidate candidate, FeeOptimizerTunables tunables, DateTimeOffset now) + private async Task OptimizeChannel(Node node, OwnedChannel candidate, FeeOptimizerTunables tunables) { 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 now = DateTimeOffset.UtcNow; var decision = FeeOptimizerService.ComputeNextPolicy( routingState.EmaLocalRatio, @@ -210,18 +170,18 @@ private async Task OptimizeChannel(Node node, Candidate candidate, FeeOptimizerT if (decision.Action != FeeAction.Update) { _logger.LogInformation("Channel {ChanId} on {NodeName}: {Action} ({Reason})", - candidate.LndChannel.ChanId, node.Name, decision.Action, decision.Reason); + candidate.Lnd.ChanId, node.Name, decision.Action, decision.Reason); await _feeStateRepository.UpsertByChannelAndNode(feeState); return; } // The live policy is needed only on the write path for the untouched base fee/timelock. // NoOp channels never cost an LND round-trip. - var (managedPolicy, _) = await _lightningService.GetChannelFeePolicy(candidate.LndChannel.ChanId, node); + var (managedPolicy, _) = await _lightningService.GetChannelFeePolicy(candidate.Lnd.ChanId, node); if (managedPolicy == null) { _logger.LogWarning("Skipping channel {ChanId} on {NodeName}: current fee policy unavailable", - candidate.LndChannel.ChanId, node.Name); + candidate.Lnd.ChanId, node.Name); await _feeStateRepository.UpsertByChannelAndNode(feeState); return; } @@ -234,7 +194,7 @@ private async Task OptimizeChannel(Node node, Candidate candidate, FeeOptimizerT if (node.RoutingEngineDryRun) { _logger.LogInformation("Dry-run: would set channel {ChanId} ({NodeName}-{PeerAlias}) to outbound {Outbound}ppm inbound {Inbound}ppm ({Reason})", - candidate.LndChannel.ChanId, node.Name, candidate.LndChannel.PeerAlias, decision.OutboundPpm, decision.InboundPpm, decision.Reason); + candidate.Lnd.ChanId, node.Name, candidate.Lnd.PeerAlias, decision.OutboundPpm, decision.InboundPpm, decision.Reason); feeState.LastAppliedOutboundBaseFeeMsat = baseFeeMsat; feeState.LastAppliedOutboundPpm = decision.OutboundPpm; feeState.LastAppliedInboundBaseMsat = inboundBaseMsat; @@ -267,7 +227,7 @@ await _lightningService.SetChannelFeePolicy( feeState.LastFeeUpdateAt = now; _logger.LogInformation("{NodeName} chan {ChanId}: set outbound {Outbound}ppm inbound {Inbound}ppm ({Reason})", - node.Name, candidate.LndChannel.ChanId, decision.OutboundPpm, decision.InboundPpm, decision.Reason); + node.Name, candidate.Lnd.ChanId, decision.OutboundPpm, decision.InboundPpm, decision.Reason); await _feeStateRepository.UpsertByChannelAndNode(feeState); } @@ -275,7 +235,7 @@ await _lightningService.SetChannelFeePolicy( { // Log and skip this channel for this cycle; the next cycle retries. _logger.LogError(ex, "Failed to set fee policy for channel {ChanId} on {NodeName}", - candidate.LndChannel.ChanId, node.Name); + candidate.Lnd.ChanId, node.Name); } } } diff --git a/src/Services/RoutingEngineSnapshotService.cs b/src/Services/RoutingEngineSnapshotService.cs new file mode 100644 index 00000000..1d03f5cd --- /dev/null +++ b/src/Services/RoutingEngineSnapshotService.cs @@ -0,0 +1,111 @@ +/* + * 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 Channel = NodeGuard.Data.Models.Channel; + +namespace NodeGuard.Services; + +/// +/// One of a node's channels with everything the routing-engine jobs act on, assembled from a +/// single ListChannels plus the per-(channel, managed node) routing and fee state. +/// +public sealed class OwnedChannel +{ + public required Lnrpc.Channel Lnd { get; init; } + public required Channel DbChannel { get; init; } + public required ChannelRoutingState RoutingState { get; init; } + public required ChannelFeeState? FeeState { get; init; } +} + +/// +/// Builds the per-node channel view both routing-engine actuator jobs +/// ( and ) start from. +/// The two jobs run on independent cadences, so each takes its own snapshot. +/// +public interface IRoutingEngineSnapshotService +{ + /// + /// The node's actuatable channels: open, known to NodeGuard, and carrying a routing-state + /// signal for this managed node. Returns null when LND is unreachable — callers should treat + /// that as "skip this node this cycle" rather than as an empty result. Per-job eligibility + /// (min size / IsDynamicFeeEnabled for fees, opt-in / trigger for rebalancing) is the caller's. + /// + Task?> GetOwnedChannelsAsync( + Node node, + IReadOnlyDictionary openChannelsByChanId, + bool withFeeState); +} + +public class RoutingEngineSnapshotService : IRoutingEngineSnapshotService +{ + private readonly IChannelRoutingStateRepository _routingStateRepository; + private readonly IChannelFeeStateRepository _feeStateRepository; + private readonly ILightningClientService _lightningClientService; + + public RoutingEngineSnapshotService( + IChannelRoutingStateRepository routingStateRepository, + IChannelFeeStateRepository feeStateRepository, + ILightningClientService lightningClientService) + { + _routingStateRepository = routingStateRepository; + _feeStateRepository = feeStateRepository; + _lightningClientService = lightningClientService; + } + + public async Task?> GetOwnedChannelsAsync( + Node node, + IReadOnlyDictionary openChannelsByChanId, + bool withFeeState) + { + // One LND round-trip per node per job run. + var listResp = await _lightningClientService.ListChannels(node); + if (listResp == null) return null; + + var routingStates = (await _routingStateRepository.GetByManagedNodePubKey(node.PubKey)) + .ToDictionary(s => s.ChannelId); + + // Only the fee job reads these; the rebalancer never does. + var feeStates = withFeeState + ? (await _feeStateRepository.GetByManagedNodePubKey(node.PubKey)).ToDictionary(s => s.ChannelId) + : new Dictionary(); + + var owned = new List(); + foreach (var lndChannel in listResp.Channels) + { + // No ownership dedup: routing/fee state is per (channel, managed node), so when both + // ends are managed each side actuates its own view — its own local balance for the + // rebalancer, its own outbound policy for the fee job. + if (!openChannelsByChanId.TryGetValue(lndChannel.ChanId, out var dbChannel)) continue; + if (!routingStates.TryGetValue(dbChannel.Id, out var routingState)) continue; // no signal yet + + feeStates.TryGetValue(dbChannel.Id, out var feeState); + owned.Add(new OwnedChannel + { + Lnd = lndChannel, + DbChannel = dbChannel, + RoutingState = routingState, + FeeState = feeState, + }); + } + + return owned; + } +} diff --git a/test/NodeGuard.Tests/Jobs/ChannelFeeOptimizerJobTests.cs b/test/NodeGuard.Tests/Jobs/ChannelFeeOptimizerJobTests.cs index 72c76782..97189bdc 100644 --- a/test/NodeGuard.Tests/Jobs/ChannelFeeOptimizerJobTests.cs +++ b/test/NodeGuard.Tests/Jobs/ChannelFeeOptimizerJobTests.cs @@ -28,21 +28,38 @@ namespace NodeGuard.Jobs; + +[Collection("RoutingEngine")] public class ChannelFeeOptimizerJobTests { private const string NodePubKey = "managedPubKey"; private const ulong ChanId = 123; private const int ChannelDbId = 10; + // The arranged channel is comfortably above ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS (10M). + // Kept as one constant because the job gates on the DB SatsAmount while the control law reads + // the LND balances — if the two drift the channel is silently filtered out of every test. + private const long ChannelSizeSats = 16_000_000; + + private readonly Mock> _logger = new(); + private readonly Mock _nodeRepository = new(); private readonly Mock _channelRepository = new(); private readonly Mock _routingStateRepository = new(); private readonly Mock _feeStateRepository = new(); - private readonly Mock _forwardingHtlcEventRepository = new(); private readonly Mock _rebalanceRepository = new(); + private readonly Mock _rebalanceService = new(); private readonly Mock _lightningService = new(); private readonly Mock _lightningClientService = new(); + // The real snapshot service over the mocked repos/LND, so these tests still cover the + // open-channel + routing-state filtering that feeds the job. + private IRoutingEngineSnapshotService BuildSnapshotService() => + new RoutingEngineSnapshotService( + _routingStateRepository.Object, + _feeStateRepository.Object, + _lightningClientService.Object); + private Node BuildNode() => new() { Id = 20, @@ -61,11 +78,12 @@ private void ArrangeSingleSinkChannel(Node node, bool inFlightRebalance) Id = ChannelDbId, ChanId = ChanId, Status = Channel.ChannelStatus.Open, + SatsAmount = ChannelSizeSats, IsDynamicFeeEnabled = true, FundingTx = "txid123", FundingTxOutputIndex = 1, }; - _channelRepository.Setup(x => x.GetChannelsByOpenAndDynamicFeeEnabled()) + _channelRepository.Setup(x => x.GetOpenChannels()) .ReturnsAsync(new List { dbChannel }); // Too remote (ema 0.40 < target 0.50) + Sink → raise outbound, negative inbound. @@ -92,7 +110,7 @@ private void ArrangeSingleSinkChannel(Node node, bool inFlightRebalance) new Lnrpc.Channel { ChanId = ChanId, - Capacity = 16_000_000, + Capacity = ChannelSizeSats, LocalBalance = 4_000_000, RemoteBalance = 12_000_000, Active = true, @@ -119,14 +137,13 @@ private void ArrangeSingleSinkChannel(Node node, bool inFlightRebalance) private ChannelFeeOptimizerJob BuildJob() => new( - new Mock>().Object, + _logger.Object, _nodeRepository.Object, _channelRepository.Object, - _routingStateRepository.Object, _feeStateRepository.Object, _rebalanceRepository.Object, - _lightningService.Object, - _lightningClientService.Object); + BuildSnapshotService(), + _lightningService.Object); private static async Task WithEngine(bool enabled, Func body) { @@ -235,7 +252,7 @@ public async Task Execute_NoNodeWithDynamicFeesEnabled_SkipsBeforeFetchingChanne await BuildJob().Execute(Mock.Of()); - _channelRepository.Verify(x => x.GetChannelsByOpenAndDynamicFeeEnabled(), Times.Never); + _channelRepository.Verify(x => x.GetOpenChannels(), Times.Never); VerifyNoFeeWrite(); } finally @@ -274,7 +291,8 @@ public async Task Execute_ChannelBelowMinSize_SkipsChannel() var prevEnabled = Constants.ROUTING_ENGINE_ENABLED; var prevMinSize = Constants.ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS; Constants.ROUTING_ENGINE_ENABLED = true; - Constants.ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS = 20_000_000; // the arranged channel is 16M + // One sat above the arranged channel, so this pins the exact boundary of the size gate. + Constants.ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS = ChannelSizeSats + 1; try { var node = BuildNode(); @@ -357,4 +375,66 @@ public async Task Execute_SetFeePolicyThrows_IsSwallowed_AndStateNotPersisted() Constants.ROUTING_ENGINE_ENABLED = prevEnabled; } } + + [Fact] + public async Task Execute_ChannelSharedWithAnotherManagedNode_IsStillActuated() + { + var node = BuildNode(); + ArrangeSingleSinkChannel(node, inFlightRebalance: false); + + // The peer is also managed by NodeGuard and it opened the channel. The old ownership dedup + // handed the channel to the initiator alone, so this side was never actuated: it could not + // see its own depleted channel as a rebalance destination and its outbound policy was + // frozen. Both sides now carry their own state and both get actuated. + var peer = new Node { Id = 21, PubKey = "peerPubKey", Name = "bob", DynamicFeeManagementEnabled = true }; + _nodeRepository.Setup(x => x.GetAllManagedByNodeGuard(false)).ReturnsAsync(new List { node, peer }); + _routingStateRepository.Setup(x => x.GetByManagedNodePubKey(peer.PubKey)).ReturnsAsync(new List()); + _feeStateRepository.Setup(x => x.GetByManagedNodePubKey(peer.PubKey)).ReturnsAsync(new List()); + + _lightningClientService + .Setup(x => x.ListChannels(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Lnrpc.ListChannelsResponse + { + Channels = + { + new Lnrpc.Channel + { + ChanId = ChanId, + Capacity = ChannelSizeSats, + LocalBalance = 4_000_000, + RemoteBalance = 12_000_000, + Active = true, + Initiator = false, // the managed peer opened it + RemotePubkey = peer.PubKey, + }, + }, + }); + + await WithEngine(enabled: true, async () => + { + await BuildJob().Execute(Mock.Of()); + }); + + _lightningService.Verify(x => x.SetChannelFeePolicy( + "txid123:1", NodePubKey, 1000, 2550u, 40u, 0, -50, true), Times.Once); + } + + [Fact] + public async Task Execute_ColdStartFeeState_IsStampedWithTheActuatingNode() + { + var node = BuildNode(); + ArrangeSingleSinkChannel(node, inFlightRebalance: false); + + await WithEngine(enabled: true, async () => + { + await BuildJob().Execute(Mock.Of()); + }); + + // A fee state created from scratch must name its node, or it lands outside every + // per-node query and the control loop cold-starts forever. + _feeStateRepository.Verify(x => x.UpsertByChannelAndNode( + It.Is(f => f.ChannelId == ChannelDbId && f.ManagedNodePubKey == NodePubKey)), + Times.Once); + } + } From 08293208d82763b003f363ccc16371af98b9f750 Mon Sep 17 00:00:00 2001 From: Marcos Date: Thu, 27 Aug 2026 15:41:57 +0200 Subject: [PATCH 13/21] feat: rebalancing algorithm --- src/Helpers/Constants.cs | 10 +- src/Jobs/AutoRebalanceJob.cs | 330 +++++++++++++ src/Program.cs | 56 ++- src/Services/LightningClientService.cs | 18 + src/Services/LightningService.cs | 27 ++ src/Services/RebalanceInitiatorService.cs | 365 ++++++++++++++ .../Jobs/AutoRebalanceJobTests.cs | 446 ++++++++++++++++++ .../RebalanceInitiatorServiceTests.cs | 426 +++++++++++++++++ 8 files changed, 1664 insertions(+), 14 deletions(-) create mode 100644 src/Jobs/AutoRebalanceJob.cs create mode 100644 src/Services/RebalanceInitiatorService.cs create mode 100644 test/NodeGuard.Tests/Jobs/AutoRebalanceJobTests.cs create mode 100644 test/NodeGuard.Tests/Services/RebalanceInitiatorServiceTests.cs diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index aeefbc49..5af645f1 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -284,6 +284,12 @@ public class Constants /// public static int ROUTING_ENGINE_ACTUATOR_OFFSET_MINUTES = 5; + /// + /// Cadence of AutoRebalanceJob in prod, in minutes. Independent of + /// ROUTING_ENGINE_JOB_INTERVAL_MINUTES. In dev it is 1 minute. + /// + public static int ROUTING_ENGINE_REBALANCE_JOB_INTERVAL_MINUTES = 10; + // Both fees use integral control: each cycle the applied value is nudged by gain·deviation·baseline // off its previous value, so a persistent deviation keeps driving the fee until the channel balances. public static double ROUTING_ENGINE_FEE_OUTBOUND_INTEGRAL_GAIN = 0.8; @@ -349,7 +355,7 @@ public class Constants /// Fallback max concurrent (Pending/InFlight) rebalances when a node leaves /// MaxRebalancesInFlight unset. - public static int ROUTING_ENGINE_REBALANCE_DEFAULT_MAX_IN_FLIGHT = 1; + public static int ROUTING_ENGINE_REBALANCE_DEFAULT_MAX_IN_FLIGHT = 5; /// Fallback rebalance-budget refresh window (hours) when a node leaves /// RebalanceBudgetRefreshInterval unset. @@ -628,6 +634,8 @@ static Constants() if (reJobInterval != null) ROUTING_ENGINE_JOB_INTERVAL_MINUTES = int.Parse(reJobInterval); var reActuatorOffset = Environment.GetEnvironmentVariable("ROUTING_ENGINE_ACTUATOR_OFFSET_MINUTES"); if (reActuatorOffset != null) ROUTING_ENGINE_ACTUATOR_OFFSET_MINUTES = int.Parse(reActuatorOffset); + var reRebalanceInterval = Environment.GetEnvironmentVariable("ROUTING_ENGINE_REBALANCE_JOB_INTERVAL_MINUTES"); + if (reRebalanceInterval != null) ROUTING_ENGINE_REBALANCE_JOB_INTERVAL_MINUTES = int.Parse(reRebalanceInterval); // Routing Engine var feeOutboundIntegralGain = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_OUTBOUND_INTEGRAL_GAIN"); diff --git a/src/Jobs/AutoRebalanceJob.cs b/src/Jobs/AutoRebalanceJob.cs new file mode 100644 index 00000000..a266d9bc --- /dev/null +++ b/src/Jobs/AutoRebalanceJob.cs @@ -0,0 +1,330 @@ +/* + * 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; + +/// +/// The routing engine's rebalance actuator. For every node with +/// it takes a snapshot, hands it to the pure to plan +/// too-local-source → too-remote-destination circular rebalances, and dispatches them via +/// — bounded by the node's fee budget, its in-flight cap, and the +/// per-run initiation cap. +/// +/// Runs on its own cadence (ROUTING_ENGINE_REBALANCE_JOB_INTERVAL_MINUTES), independent of +/// . +/// +[DisallowConcurrentExecution] +public class AutoRebalanceJob : IJob +{ + private readonly ILogger _logger; + private readonly INodeRepository _nodeRepository; + private readonly IChannelRepository _channelRepository; + private readonly IRebalanceRepository _rebalanceRepository; + private readonly IRebalanceService _rebalanceService; + private readonly IRoutingEngineSnapshotService _snapshotService; + private readonly ILightningService _lightningService; + + public AutoRebalanceJob( + ILogger logger, + INodeRepository nodeRepository, + IChannelRepository channelRepository, + IRebalanceRepository rebalanceRepository, + IRebalanceService rebalanceService, + IRoutingEngineSnapshotService snapshotService, + ILightningService lightningService) + { + _logger = logger; + _nodeRepository = nodeRepository; + _channelRepository = channelRepository; + _rebalanceRepository = rebalanceRepository; + _rebalanceService = rebalanceService; + _snapshotService = snapshotService; + _lightningService = lightningService; + } + + public async Task Execute(IJobExecutionContext context) + { + // Global kill switch — checked before any work + if (!Constants.ROUTING_ENGINE_ENABLED) + { + return; + } + + _logger.LogInformation("Starting {JobName}...", nameof(AutoRebalanceJob)); + + try + { + var managedNodes = await _nodeRepository.GetAllManagedByNodeGuard(withDisabled: false); + + var relevantNodes = managedNodes.Where(n => n.AutoRebalanceEnabled).ToList(); + if (relevantNodes.Count == 0) + { + _logger.LogInformation("No managed nodes with the rebalancer enabled; skipping {JobName}", + nameof(AutoRebalanceJob)); + return; + } + + // Shared per-run context, only needed when at least one node is under management + var openChannelsByChanId = (await _channelRepository.GetOpenChannels()) + .ToDictionary(c => c.ChanId); + var inFlightSourceChannelIds = await _rebalanceRepository.GetPendingInFlightSourceChannelIds(); + + foreach (var node in relevantNodes) + { + try + { + await RebalanceNode(node, openChannelsByChanId, inFlightSourceChannelIds); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error rebalancing node {NodeName} ({NodePubKey})", + node.Name, node.PubKey); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in {JobName}", nameof(AutoRebalanceJob)); + } + + _logger.LogInformation("{JobName} ended", nameof(AutoRebalanceJob)); + } + + private async Task RebalanceNode( + Node node, + IReadOnlyDictionary openChannelsByChanId, + IReadOnlySet inFlightSourceChannelIds) + { + var now = DateTimeOffset.UtcNow; + + // A budget must be configured before we spend anything. Checked before the LND round-trip + var budgetSats = node.RebalanceBudgetSats ?? 0; + if (budgetSats <= 0) + { + _logger.LogInformation("Node {NodeName}: no rebalance budget configured; skipping", node.Name); + return; + } + + // Budget-period refresh + var refreshInterval = node.RebalanceBudgetRefreshInterval + ?? TimeSpan.FromHours(Constants.ROUTING_ENGINE_REBALANCE_DEFAULT_BUDGET_REFRESH_HOURS); + if (!node.RebalanceBudgetStartDatetime.HasValue || + now - node.RebalanceBudgetStartDatetime.Value >= refreshInterval) + { + _logger.LogInformation("Refreshing rebalance budget for node {NodeName}", node.Name); + node.RebalanceBudgetStartDatetime = now; + _nodeRepository.Update(node); + } + + var periodStart = node.RebalanceBudgetStartDatetime ?? now; + + // Remaining fee budget for this period. GetConsumedFeesSince already counts in-flight + // reservations, so a rebalance started earlier this period is charged immediately + var consumed = await _rebalanceRepository.GetConsumedFeesSince(node.Id, periodStart); + var remainingBudget = budgetSats - consumed; + if (remainingBudget <= 0) + { + _logger.LogInformation("Node {NodeName}: rebalance fee budget exhausted ({Consumed}/{Budget} sats)", + node.Name, consumed, budgetSats); + return; + } + + // In-flight cap + var inFlight = await _rebalanceRepository.GetInFlightByNode(node.Id); + var maxInFlight = node.MaxRebalancesInFlight ?? Constants.ROUTING_ENGINE_REBALANCE_DEFAULT_MAX_IN_FLIGHT; + if (inFlight >= maxInFlight) + { + _logger.LogInformation("Node {NodeName}: max rebalances in flight reached ({InFlight}/{Max})", + node.Name, inFlight, maxInFlight); + return; + } + + var owned = await _snapshotService.GetOwnedChannelsAsync(node, openChannelsByChanId, withFeeState: false); + if (owned == null) + { + _logger.LogWarning("Skipping node {NodeName}: ListChannels unavailable", node.Name); + return; + } + + var signals = owned.Select(oc => new ChannelSignal( + oc.DbChannel.Id, + oc.Lnd.ChanId, + oc.Lnd.RemotePubkey, + oc.Lnd.Capacity, + oc.Lnd.LocalBalance, + oc.Lnd.RemoteBalance, + oc.RoutingState.EmaLocalRatio, + oc.RoutingState.TargetLocalRatio, + oc.Lnd.Active, + // A channel is a fresh source only if opted in and not already being drained + oc.DbChannel.IsAutoRebalanceEnabled && !inFlightSourceChannelIds.Contains(oc.DbChannel.Id))) + .ToList(); + + var tunables = BuildRebalanceTunables(node); + var classification = RebalanceInitiatorService.Classify(signals, tunables); + + if (classification.Sources.Count == 0 && classification.Destinations.Count == 0) + { + _logger.LogInformation("Node {NodeName}: nothing to rebalance (no channel tripped the trigger)", node.Name); + return; + } + + _logger.LogInformation( + "Node {NodeName}: detected {Sources} source(s) and {Destinations} destination(s); " + + "fallback pool {FallbackSources} source(s), {FallbackDestinations} destination(s)", + node.Name, classification.Sources.Count, classification.Destinations.Count, + classification.FallbackSources.Count, classification.FallbackDestinations.Count); + + var earnRates = await FetchEarnRatesAsync(node, owned); + if (earnRates == null) + { + _logger.LogWarning("Skipping node {NodeName}: FeeReport unavailable, so nothing can be profit-gated", + node.Name); + return; + } + + var plans = RebalanceInitiatorService.BuildPlans(classification, earnRates, tunables); + if (plans.Count == 0) + { + _logger.LogInformation("Node {NodeName}: no profitable rebalance plans this cycle", node.Name); + return; + } + + var initiations = 0; + var planIndex = 0; + string? capStopReason = null; + + for (; planIndex < plans.Count; planIndex++) + { + var plan = plans[planIndex]; + + if (initiations >= tunables.MaxInitiations) + { + capStopReason = $"per-run initiation cap reached ({initiations}/{tunables.MaxInitiations})"; + break; + } + + if (inFlight + initiations >= maxInFlight) + { + capStopReason = + $"in-flight cap reached ({inFlight + initiations}/{maxInFlight}, {inFlight} already in flight " + + "before this run; raise the node's MaxRebalancesInFlight to dispatch more per cycle)"; + break; + } + + var reservedFee = Rebalance.WorstCaseFeeSats(plan.AmountSats, plan.MaxFeePct); + if (reservedFee > remainingBudget) + { + _logger.LogInformation( + "Node {NodeName}: skipping plan (reserved {Reserved} sats > remaining budget {Remaining} sats): {Reason}", + node.Name, reservedFee, remainingBudget, plan.Reason); + continue; + } + + if (node.RoutingEngineDryRun) + { + _logger.LogInformation("Dry-run: node {NodeName} would rebalance — {Reason}", node.Name, plan.Reason); + remainingBudget -= reservedFee; + initiations++; + continue; + } + + try + { + var request = new RebalanceRequest( + NodeId: node.Id, + SourceChannelId: plan.SourceChannelId, + TargetPubkey: plan.DestinationPeerPubKey, + AmountSats: plan.AmountSats, + MaxFeePct: plan.MaxFeePct, + IsManual: false, + // Keep retries within the profitable ceiling + RetryMaxFeePct: plan.MaxFeePct); + + // Fire-and-forget, and deliberately not tracked: RebalanceService logs and audits + // the whole lifecycle itself and MonitorRebalancesJob reconciles anything this process abandons + _ = _rebalanceService.RebalanceAsync(request, CancellationToken.None); + + remainingBudget -= reservedFee; + initiations++; + _logger.LogInformation("Node {NodeName}: initiated rebalance — {Reason}", node.Name, plan.Reason); + } + catch (Exception ex) + { + _logger.LogError(ex, "Node {NodeName}: failed to initiate rebalance ({Reason})", node.Name, plan.Reason); + } + } + + // A cap stops the loop outright, so every remaining plan is abandoned + if (capStopReason != null) + { + var dropped = plans.Count - planIndex; + _logger.LogInformation( + "Node {NodeName}: dropped {DroppedCount} of {PlannedCount} planned rebalance(s) — {CapStopReason}", + node.Name, dropped, plans.Count, capStopReason); + + for (var i = planIndex; i < plans.Count; i++) + { + _logger.LogInformation("Node {NodeName}: dropped plan — {Reason}", node.Name, plans[i].Reason); + } + } + + _logger.LogInformation( + "Node {NodeName}: initiated {Count} of {PlannedCount} planned rebalance(s), remaining budget {Remaining}/{Budget} sats", + node.Name, initiations, plans.Count, remainingBudget, budgetSats); + } + + private static RebalanceInitiatorTunables BuildRebalanceTunables(Node node) => new( + RebalanceTrigger: Constants.ROUTING_ENGINE_REBALANCE_DEADBAND, + MinAmountSats: Constants.REBALANCE_MIN_AMOUNT_SATS, + MaxAmountSats: Constants.ROUTING_ENGINE_REBALANCE_MAX_AMOUNT_SATS, + CostToEarnRatio: node.MaxRebalanceCostToEarnRatio ?? Constants.ROUTING_ENGINE_REBALANCE_DEFAULT_COST_TO_EARN_RATIO, + MaxInitiations: Constants.ROUTING_ENGINE_REBALANCE_MAX_INITIATIONS_PER_RUN); + + /// + /// Maps our channel id → live local-outbound ppm for every channel in the snapshot, from a single + /// FeeReport. + /// + private async Task?> FetchEarnRatesAsync(Node node, IReadOnlyList owned) + { + var ppmByChanId = await _lightningService.GetLocalOutboundFeeRatesPpmAsync(node); + if (ppmByChanId == null) return null; + + var earnRates = new Dictionary(); + foreach (var oc in owned) + { + if (ppmByChanId.TryGetValue(oc.Lnd.ChanId, out var ppm)) + { + earnRates[oc.DbChannel.Id] = ppm; + } + } + + _logger.LogDebug("Node {NodeName}: priced {Priced} of {Total} channel(s) from FeeReport", + node.Name, earnRates.Count, owned.Count); + + return earnRates; + } +} diff --git a/src/Program.cs b/src/Program.cs index f22d980f..22ba0d53 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -149,6 +149,7 @@ public static async Task Main(string[] args) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddScoped(); //DbContext @@ -297,7 +298,7 @@ public static async Task Main(string[] args) }); }); - //Channel Fee Optimizer Job + //Channel Fee Optimizer Job (routing-engine fee actuator) q.AddJob(opts => { opts.DisallowConcurrentExecution(); @@ -307,18 +308,47 @@ public static async Task Main(string[] args) q.AddTrigger(opts => { opts.ForJob(nameof(ChannelFeeOptimizerJob)) - .WithIdentity($"{nameof(ChannelFeeOptimizerJob)}Trigger") - .StartNow().WithSimpleSchedule(scheduleBuilder => - { - if (Constants.IS_DEV_ENVIRONMENT) - { - scheduleBuilder.WithIntervalInMinutes(1).RepeatForever(); - } - else - { - scheduleBuilder.WithIntervalInMinutes(Constants.ROUTING_ENGINE_JOB_INTERVAL_MINUTES).RepeatForever(); - } - }); + .WithIdentity($"{nameof(ChannelFeeOptimizerJob)}Trigger"); + + if (Constants.IS_DEV_ENVIRONMENT) + { + opts.StartNow() + .WithSimpleSchedule(scheduleBuilder => scheduleBuilder.WithIntervalInMinutes(1).RepeatForever()); + } + else + { + // Start a few minutes after TargetRatioReevaluationJob (which uses StartNow) so the + // fee control law always acts on freshly-written routing state. + opts.StartAt(DateBuilder.FutureDate(Constants.ROUTING_ENGINE_ACTUATOR_OFFSET_MINUTES, IntervalUnit.Minute)) + .WithSimpleSchedule(scheduleBuilder => scheduleBuilder.WithIntervalInMinutes(Constants.ROUTING_ENGINE_JOB_INTERVAL_MINUTES).RepeatForever()); + } + }); + + //Auto Rebalance Job (routing-engine rebalance actuator, own cadence) + q.AddJob(opts => + { + opts.DisallowConcurrentExecution(); + opts.WithIdentity(nameof(AutoRebalanceJob)); + }); + + q.AddTrigger(opts => + { + opts.ForJob(nameof(AutoRebalanceJob)) + .WithIdentity($"{nameof(AutoRebalanceJob)}Trigger"); + + if (Constants.IS_DEV_ENVIRONMENT) + { + opts.StartNow() + .WithSimpleSchedule(scheduleBuilder => scheduleBuilder.WithIntervalInMinutes(1).RepeatForever()); + } + else + { + // Same post-signal offset as the fee job, but its own cadence thereafter. Running + // first means the Pending rows it writes are already visible to the fee job, which + // is what keeps the fee-vs-rebalance authority split intact. + opts.StartAt(DateBuilder.FutureDate(Constants.ROUTING_ENGINE_ACTUATOR_OFFSET_MINUTES, IntervalUnit.Minute)) + .WithSimpleSchedule(scheduleBuilder => scheduleBuilder.WithIntervalInMinutes(Constants.ROUTING_ENGINE_REBALANCE_JOB_INTERVAL_MINUTES).RepeatForever()); + } }); //Monitor Withdrawals Job diff --git a/src/Services/LightningClientService.cs b/src/Services/LightningClientService.cs index 7ce3d07b..40d5b648 100644 --- a/src/Services/LightningClientService.cs +++ b/src/Services/LightningClientService.cs @@ -38,6 +38,7 @@ public interface ILightningClientService 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); + public Task FeeReport(Node node, Lightning.LightningClient? client = null); public Task AddInvoice(Node node, Invoice invoice, Lightning.LightningClient? client = null); public Task QueryRoutes(Node node, QueryRoutesRequest request, Lightning.LightningClient? client = null); public AsyncServerStreamingCall? CloseChannel(Node node, Channel channel, bool forceClose = false, Lightning.LightningClient? client = null); @@ -223,6 +224,23 @@ public Lightning.LightningClient GetLightningClient(string? endpoint) return null; } } + + public async Task FeeReport(Node node, Lightning.LightningClient? client = null) + { + try + { + client ??= GetLightningClient(node.Endpoint); + return await client.FeeReportAsync(new FeeReportRequest(), new Metadata + { + { "macaroon", node.ChannelAdminMacaroon } + }); + } + catch (Exception e) + { + _logger.LogError(e, "Error while getting the fee report for node {NodeId}", node.Id); + return null; + } + } public async Task AddInvoice(Node node, Invoice invoice, Lightning.LightningClient? client = null) { diff --git a/src/Services/LightningService.cs b/src/Services/LightningService.cs index 33e8cd27..6f6bbb02 100644 --- a/src/Services/LightningService.cs +++ b/src/Services/LightningService.cs @@ -196,6 +196,13 @@ Task SendPaymentV2Async(Node node, string paymentRequest, long amountSa /// Task GetLocalOutboundFeeRatePpmAsync(Node node, ulong chanId); + /// + /// Local-outbound fee rate (ppm) for every channel on the node, keyed by LND chan_id. + /// Returns null when FeeReport is unavailable, which callers should treat as "skip this node + /// this cycle" rather than as "every channel earns nothing". + /// + Task?> GetLocalOutboundFeeRatesPpmAsync(Node node); + /// /// Sets the channel fee policy for a given channel identified by its chanPoint /// @@ -1760,6 +1767,26 @@ public async Task SendPaymentV2Async(Node node, string paymentRequest, return policy?.FeeRateMilliMsat; } + public async Task?> GetLocalOutboundFeeRatesPpmAsync(Node node) + { + var report = await _lightningClientService.FeeReport(node); + if (report == null) return null; + + // FeePerMil is "per million" of the amount forwarded, i.e. the same ppm figure as + // RoutingPolicy.FeeRateMilliMsat — but reported for our own side, so unlike GetChanInfo + // there is no Node1Pub/Node2Pub comparison to get right. + // + // Indexer rather than ToDictionary: a duplicate chan_id from LND would throw and take + // out the whole node's cycle, and last-wins is harmless for a fee rate. + var byChanId = new Dictionary(report.ChannelFees.Count); + foreach (var fee in report.ChannelFees) + { + byChanId[fee.ChanId] = fee.FeePerMil; + } + + return byChanId; + } + public async Task GetLocalOutboundFeeRatePpmByPeerAsync(Node node, string peerPubkey) { if (string.IsNullOrEmpty(peerPubkey)) return null; diff --git a/src/Services/RebalanceInitiatorService.cs b/src/Services/RebalanceInitiatorService.cs new file mode 100644 index 00000000..f1b13682 --- /dev/null +++ b/src/Services/RebalanceInitiatorService.cs @@ -0,0 +1,365 @@ +/* + * 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.Services; + +/// +/// One of our channels, with the live balances and the smoothed routing-engine signal the +/// rebalancer needs. Built by from ListChannels + +/// ChannelRoutingState; the decision logic itself never touches LND or the DB. +/// +public record ChannelSignal( + int ChannelId, + ulong ChanIdLnd, + string PeerPubKey, + long CapacitySats, + long LocalSats, + long RemoteSats, + double EmaLocalRatio, + double TargetLocalRatio, + bool Active, + bool SourceOptIn); + +/// +/// A channel we can drain (send OUT of via outgoing_chan_id) — precise, per channel. +/// +/// is how much this channel may contribute, not simply how much it holds. +/// For a channel that tripped the trigger it is the excess over its own target. For a fallback +/// source (see ) it is the room down to the +/// low edge of its deadband, so lending liquidity can never turn it into next cycle's destination. +/// +/// +public record SourceChannel( + int ChannelId, + ulong ChanIdLnd, + string PeerPubKey, + long ExcessSats, + long LocalSats); + +/// One of our channels with a destination peer — carries the earn-rate weight (balance base). +public record PeerMemberChannel(int ChannelId, ulong ChanIdLnd, long BalanceBaseSats); + +/// +/// A peer we want to refill, aggregated across all our channels with it. LND's +/// last_hop_pubkey only constrains the peer, not the exact incoming channel, so the +/// destination side is modelled at peer granularity. +/// +/// is how much this peer may absorb. For a peer that tripped the trigger +/// it is the shortfall against its own target. For a fallback destination (see +/// ) it is the room up to the high edge +/// of its deadband, so being refilled can never turn it into next cycle's source. +/// +/// +public record DestinationPeer( + string PeerPubKey, + double AggregateEmaRatio, + double AggregateTargetRatio, + long DeficitSats, + long RemoteSats, + IReadOnlyList Members); + +/// +/// Output of . +/// +/// and are the imbalances the engine actually +/// detected. The two fallback pools are everything else that is merely *able* to take part, and +/// exist so a detected imbalance is still acted on when nothing on the opposite side tripped the +/// trigger — a lone too-local source still gets drained somewhere, a lone depleted peer still gets +/// refilled from somewhere. +/// +/// +public record RebalanceClassification( + IReadOnlyList Sources, + IReadOnlyList Destinations, + IReadOnlyList FallbackSources, + IReadOnlyList FallbackDestinations); + +/// +/// A concrete rebalance the job should dispatch: drain , refill via +/// last-hop , sized and profit-gated. +/// +public record RebalancePlan( + int SourceChannelId, + ulong SourceChanIdLnd, + string DestinationPeerPubKey, + long AmountSats, + double MaxFeePct, + bool IsFallbackPairing, + string Reason); + +/// +/// Control tunables for . Passed in explicitly so the +/// decision logic stays pure and unit-testable; the job builds these from the node config + +/// ROUTING_ENGINE_REBALANCE_* constants. +/// +public record RebalanceInitiatorTunables( + double RebalanceTrigger, + long MinAmountSats, + long MaxAmountSats, + double CostToEarnRatio, + int MaxInitiations); + +/// +/// Pure decision logic for the automated rebalancer — no I/O, no clock, no DB, so it is a static +/// function library (mirrors ). +/// +/// A circular rebalance drains liquidity OUT of a too-local channel (precise: outgoing_chan_id) +/// and refills a too-remote one (fuzzy: last_hop_pubkey pins only the peer, not the channel). +/// +public static class RebalanceInitiatorService +{ + /// + /// Splits into drainable sources and refillable destination peers, + /// using the smoothed EMA ratio for the direction decision and live balances for sizing. Whatever + /// trips neither trigger lands in the fallback pools instead of being discarded. + /// + public static RebalanceClassification Classify( + IReadOnlyList channels, + RebalanceInitiatorTunables t) + { + // Sources calculation + var sources = new List(); + var fallbackSources = new List(); + foreach (var c in channels) + { + if (!c.Active || !c.SourceOptIn) continue; + + var baseSats = c.LocalSats + c.RemoteSats; + if (baseSats <= 0) continue; + + var targetLocal = (long)Math.Round(c.TargetLocalRatio * baseSats, MidpointRounding.AwayFromZero); + var excess = c.LocalSats - targetLocal; + + // Too-local by the smoothed signal + if (c.EmaLocalRatio - c.TargetLocalRatio > t.RebalanceTrigger && excess > 0) + { + sources.Add(new SourceChannel(c.ChannelId, c.ChanIdLnd, c.PeerPubKey, excess, c.LocalSats)); + continue; + } + + // Searching for fallback sources. Avoiding creating a next cycle rebalance by + // lending liquidity down to the low edge of the deadband only + var floorRatio = Math.Max(0, c.TargetLocalRatio - t.RebalanceTrigger); + var floorLocal = (long)Math.Round(floorRatio * baseSats, MidpointRounding.AwayFromZero); + var lendable = c.LocalSats - floorLocal; + if (lendable > 0) + { + fallbackSources.Add(new SourceChannel(c.ChannelId, c.ChanIdLnd, c.PeerPubKey, lendable, c.LocalSats)); + } + } + + // Destinations calculation + var destinations = new List(); + var fallbackDestinations = new List(); + foreach (var group in channels.Where(c => c.Active).GroupBy(c => c.PeerPubKey)) + { + long peerLocal = 0; + long peerRemote = 0; + long peerBase = 0; + double weightedEma = 0; + double weightedTarget = 0; + var members = new List(); + + foreach (var c in group) + { + var baseSats = c.LocalSats + c.RemoteSats; + if (baseSats <= 0) continue; + + peerLocal += c.LocalSats; + peerRemote += c.RemoteSats; + peerBase += baseSats; + weightedEma += c.EmaLocalRatio * baseSats; + weightedTarget += c.TargetLocalRatio * baseSats; + members.Add(new PeerMemberChannel(c.ChannelId, c.ChanIdLnd, baseSats)); + } + + if (peerBase <= 0) continue; + + var aggEma = weightedEma / peerBase; + var aggTarget = weightedTarget / peerBase; + + // Sats of local needed to bring the peer aggregate back to target + var targetLocalSats = (long)Math.Round(weightedTarget, MidpointRounding.AwayFromZero); + var deficit = targetLocalSats - peerLocal; + + // Too-remote in aggregate (smoothed): the peer holds too little of our local + if (aggEma - aggTarget < -t.RebalanceTrigger && deficit > 0) + { + destinations.Add(new DestinationPeer(group.Key, aggEma, aggTarget, deficit, peerRemote, members)); + continue; + } + + // Searching for fallback destinations. Avoiding creating a next cycle rebalance by + // lending liquidity up to the high edge of the deadband only + var ceilingRatio = Math.Min(1.0, aggTarget + t.RebalanceTrigger); + var ceilingLocal = (long)Math.Round(ceilingRatio * peerBase, MidpointRounding.AwayFromZero); + var absorbable = ceilingLocal - peerLocal; + if (absorbable > 0) + { + fallbackDestinations.Add( + new DestinationPeer(group.Key, aggEma, aggTarget, absorbable, peerRemote, members)); + } + } + + return new RebalanceClassification(sources, destinations, fallbackSources, fallbackDestinations); + } + + /// + /// Turns the classification into sized, profit-gated s. + /// + /// Pass 1 refills every detected destination from the first source still available, + /// otherwise the fallback pool. Pass 2 then drains any detected source pass 1 didn't + /// consume into the first fallback destination that fits. + /// + /// + public static IReadOnlyList BuildPlans( + RebalanceClassification classification, + IReadOnlyDictionary outboundEarnPpmByChannelId, + RebalanceInitiatorTunables t) + { + var plans = new List(); + var usedSourceIds = new HashSet(); + + // Only counterparties big enough to be worth a hop + var sources = classification.Sources + .Where(s => s.ExcessSats >= t.MinAmountSats) + .ToList(); + var fallbackSources = classification.FallbackSources + .Where(s => s.ExcessSats >= t.MinAmountSats) + .ToList(); + + // Pass 1: refill every detected destination + foreach (var dest in classification.Destinations) + { + if (plans.Count >= t.MaxInitiations) return plans; + + // No known earn rate ⇒ nothing to profit-gate against ⇒ leave the peer alone. + var destEarnPpm = WeightedAverageEarnPpm(dest.Members, outboundEarnPpmByChannelId); + if (destEarnPpm == null) continue; + + // A channel that tripped the trigger first; failing that, borrow from the fallback pool + // so the detected shortfall still gets funded. + var source = FirstFreeSource(sources, dest, usedSourceIds); + var isFallback = source == null; + source ??= FirstFreeSource(fallbackSources, dest, usedSourceIds); + if (source == null) continue; + + var plan = TryBuildPlan(source, dest, destEarnPpm.Value, outboundEarnPpmByChannelId, t, isFallback); + if (plan == null) continue; + + usedSourceIds.Add(source.ChannelId); + plans.Add(plan); + } + + // Pass 2: drain every detected source pass 1 left unused + var gatedFallbackDestinations = classification.FallbackDestinations + .Select(d => (Dest: d, EarnPpm: WeightedAverageEarnPpm(d.Members, outboundEarnPpmByChannelId))) + .Where(x => x.EarnPpm.HasValue) + .ToList(); + + var refilledFallbackPeers = new HashSet(); + + foreach (var source in sources) + { + if (plans.Count >= t.MaxInitiations) return plans; + if (usedSourceIds.Contains(source.ChannelId)) continue; + + foreach (var (dest, destEarnPpm) in gatedFallbackDestinations) + { + if (dest.PeerPubKey == source.PeerPubKey) continue; + if (refilledFallbackPeers.Contains(dest.PeerPubKey)) continue; + + var plan = TryBuildPlan(source, dest, destEarnPpm!.Value, outboundEarnPpmByChannelId, t, + isFallbackPairing: true); + if (plan == null) continue; + + // Both marked only now, so a pairing the profit gate or the min-amount floor + // rejected leaves the source and the peer available to everything downstream. + usedSourceIds.Add(source.ChannelId); + refilledFallbackPeers.Add(dest.PeerPubKey); + plans.Add(plan); + break; + } + } + + return plans; + } + + private static SourceChannel? FirstFreeSource( + IReadOnlyList pool, + DestinationPeer dest, + HashSet usedSourceIds) + => pool.FirstOrDefault(s => !usedSourceIds.Contains(s.ChannelId) && s.PeerPubKey != dest.PeerPubKey); + + /// + /// Sizes and profit-gates one source→destination pairing. Returns null when the pairing can't + /// pay for itself or is too small to be worth a hop. + /// + private static RebalancePlan? TryBuildPlan( + SourceChannel source, + DestinationPeer dest, + long destEarnPpm, + IReadOnlyDictionary outboundEarnPpmByChannelId, + RebalanceInitiatorTunables t, + bool isFallbackPairing) + { + // Profit gate: cost is capped at ratio × the earn rate of the destination we actually chose + var maxCostPpm = (long)Math.Round(t.CostToEarnRatio * destEarnPpm, MidpointRounding.AwayFromZero); + if (maxCostPpm < 1) return null; + var maxFeePct = maxCostPpm / 10_000.0; + + // What the source can give, bounded by what the destination can take + var raw = Math.Min(source.ExcessSats, dest.DeficitSats); + if (raw < t.MinAmountSats) return null; + var amount = Math.Min(raw, t.MaxAmountSats); + + var sourceEarn = outboundEarnPpmByChannelId.TryGetValue(source.ChannelId, out var se) ? se : (long?)null; + var kind = isFallbackPairing ? "fallback " : string.Empty; + return new RebalancePlan( + source.ChannelId, + source.ChanIdLnd, + dest.PeerPubKey, + amount, + maxFeePct, + isFallbackPairing, + $"{kind}drain chan {source.ChanIdLnd} (earn {sourceEarn?.ToString() ?? "?"}ppm) → refill peer {dest.PeerPubKey} " + + $"(earn {destEarnPpm}ppm, capacity {dest.DeficitSats} sats); amount {amount} sats, maxCost {maxCostPpm}ppm ({maxFeePct:0.####}%)"); + } + + /// + /// Balance-weighted average of the peer's channels' outbound ppm (weight = balance base), + /// over the members whose earn rate is known. Null when none are known. + /// + private static long? WeightedAverageEarnPpm( + IReadOnlyList members, + IReadOnlyDictionary outboundEarnPpmByChannelId) + { + double weightedSum = 0; + long totalWeight = 0; + foreach (var m in members) + { + if (!outboundEarnPpmByChannelId.TryGetValue(m.ChannelId, out var ppm)) continue; + weightedSum += (double)ppm * m.BalanceBaseSats; + totalWeight += m.BalanceBaseSats; + } + + if (totalWeight <= 0) return null; + return (long)Math.Round(weightedSum / totalWeight, MidpointRounding.AwayFromZero); + } +} diff --git a/test/NodeGuard.Tests/Jobs/AutoRebalanceJobTests.cs b/test/NodeGuard.Tests/Jobs/AutoRebalanceJobTests.cs new file mode 100644 index 00000000..667c0bf8 --- /dev/null +++ b/test/NodeGuard.Tests/Jobs/AutoRebalanceJobTests.cs @@ -0,0 +1,446 @@ +/* + * 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.Extensions.Logging; +using NodeGuard.Data.Models; +using NodeGuard.Data.Repositories.Interfaces; +using NodeGuard.Helpers; +using NodeGuard.Services; +using NodeGuard.Tests.Helpers; +using Quartz; +using Channel = NodeGuard.Data.Models.Channel; + +namespace NodeGuard.Jobs; + + +[Collection("RoutingEngine")] +public class AutoRebalanceJobTests +{ + private const string NodePubKey = "managedPubKey"; + + private readonly Mock> _logger = new(); + + private readonly Mock _nodeRepository = new(); + private readonly Mock _channelRepository = new(); + private readonly Mock _routingStateRepository = new(); + private readonly Mock _feeStateRepository = new(); + private readonly Mock _rebalanceRepository = new(); + private readonly Mock _rebalanceService = new(); + private readonly Mock _lightningService = new(); + private readonly Mock _lightningClientService = new(); + + // The real snapshot service over the mocked repos/LND, so these tests still cover the + // open-channel + routing-state filtering that feeds the job. + private IRoutingEngineSnapshotService BuildSnapshotService() => + new RoutingEngineSnapshotService( + _routingStateRepository.Object, + _feeStateRepository.Object, + _lightningClientService.Object); + + private AutoRebalanceJob BuildJob() => + new( + _logger.Object, + _nodeRepository.Object, + _channelRepository.Object, + _rebalanceRepository.Object, + _rebalanceService.Object, + BuildSnapshotService(), + _lightningService.Object); + + private static async Task WithEngine(bool enabled, Func body) + { + var prevEnabled = Constants.ROUTING_ENGINE_ENABLED; + Constants.ROUTING_ENGINE_ENABLED = enabled; + try + { + await body(); + } + finally + { + Constants.ROUTING_ENGINE_ENABLED = prevEnabled; + } + } + + + /// + /// A drainable source (too-local 0.75 vs 0.50, cheap 50 ppm) and a depleted destination on a + /// different peer (0.10 vs 0.50, dear 2500 ppm), on a node with budget to spend. + /// drives the per-channel rebalance opt-in; + /// drives the unrelated swap-liquidity flag. + /// + private void ArrangeRebalancePair(bool sourceOptedIn, bool sourceLiquidityFlag = false) + { + var node = new Node + { + Id = 20, + PubKey = NodePubKey, + Name = "alice", + AutoRebalanceEnabled = true, + RebalanceBudgetSats = 1_000_000, + MaxRebalancesInFlight = 5, + MaxRebalanceCostToEarnRatio = 0.5, + }; + _nodeRepository.Setup(x => x.GetAllManagedByNodeGuard(false)).ReturnsAsync(new List { node }); + + var sourceDb = new Channel + { + Id = 101, ChanId = 1001, Status = Channel.ChannelStatus.Open, + IsDynamicFeeEnabled = true, + IsAutoRebalanceEnabled = sourceOptedIn, + IsAutomatedLiquidityEnabled = sourceLiquidityFlag, + FundingTx = "txS", FundingTxOutputIndex = 0, + }; + var destDb = new Channel + { + Id = 102, ChanId = 1002, Status = Channel.ChannelStatus.Open, + IsDynamicFeeEnabled = true, IsAutoRebalanceEnabled = false, + FundingTx = "txD", FundingTxOutputIndex = 0, + }; + _channelRepository.Setup(x => x.GetOpenChannels()).ReturnsAsync(new List { sourceDb, destDb }); + + _routingStateRepository.Setup(x => x.GetByManagedNodePubKey(NodePubKey)).ReturnsAsync(new List + { + new() { ChannelId = 101, ManagedNodePubKey = NodePubKey, ChanIdLnd = 1001, EmaLocalRatio = 0.75, TargetLocalRatio = 0.50, PeerFlowCategory = PeerFlowCategory.Source }, + new() { ChannelId = 102, ManagedNodePubKey = NodePubKey, ChanIdLnd = 1002, EmaLocalRatio = 0.10, TargetLocalRatio = 0.50, PeerFlowCategory = PeerFlowCategory.Sink }, + }); + _feeStateRepository.Setup(x => x.GetByManagedNodePubKey(NodePubKey)).ReturnsAsync(new List()); + + _rebalanceRepository.Setup(x => x.GetPendingInFlightSourceChannelIds()).ReturnsAsync(new HashSet()); + _rebalanceRepository.Setup(x => x.GetConsumedFeesSince(node.Id, It.IsAny())).ReturnsAsync(0L); + _rebalanceRepository.Setup(x => x.GetInFlightByNode(node.Id)).ReturnsAsync(0); + + var listResp = new Lnrpc.ListChannelsResponse + { + Channels = + { + new Lnrpc.Channel { ChanId = 1001, Capacity = 20_000_000, LocalBalance = 15_000_000, RemoteBalance = 5_000_000, Active = true, Initiator = true, RemotePubkey = "peerS" }, + new Lnrpc.Channel { ChanId = 1002, Capacity = 20_000_000, LocalBalance = 2_000_000, RemoteBalance = 18_000_000, Active = true, Initiator = true, RemotePubkey = "peerD" }, + }, + }; + _lightningClientService + .Setup(x => x.ListChannels(It.IsAny(), It.IsAny())) + .ReturnsAsync(listResp); + + _lightningService.Setup(x => x.GetLocalOutboundFeeRatesPpmAsync(It.IsAny())) + .ReturnsAsync(new Dictionary { [1001] = 50, [1002] = 2500 }); + + _rebalanceService.Setup(x => x.RebalanceAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Rebalance()); + } + + + [Fact] + public async Task Execute_DispatchesThePlannedRebalance() + { + ArrangeRebalancePair(sourceOptedIn: true); + + await WithEngine(enabled: true, async () => + { + await BuildJob().Execute(Mock.Of()); + }); + + // Source 101 -> dest peer "peerD", sized to the excess, gate 0.5 x 2500ppm. + _rebalanceService.Verify(x => x.RebalanceAsync( + It.Is(r => r.SourceChannelId == 101 && r.TargetPubkey == "peerD" + && r.AmountSats == 5_000_000 && !r.IsManual + && r.MaxFeePct.HasValue && Math.Abs(r.MaxFeePct.Value - 0.125) < 1e-9), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Execute_DoesNotWaitForTheRebalanceToSettle() + { + ArrangeRebalancePair(sourceOptedIn: true); + + // A payment that never resolves. Dispatch is fire-and-forget, so the run has to finish + // anyway; if it were awaited this would block until the test timed out. + var neverSettles = new TaskCompletionSource(); + _rebalanceService.Setup(x => x.RebalanceAsync(It.IsAny(), It.IsAny())) + .Returns(neverSettles.Task); + + await WithEngine(enabled: true, async () => + { + var run = BuildJob().Execute(Mock.Of()); + var first = await Task.WhenAny(run, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.Same(run, first); // the run must not block on the payment + await run; + }); + + _rebalanceService.Verify(x => x.RebalanceAsync( + It.IsAny(), It.IsAny()), Times.Once); + Assert.False(neverSettles.Task.IsCompleted); // the payment is still outstanding + } + + [Fact] + public async Task Execute_KillSwitchOff_DoesNothing() + { + ArrangeRebalancePair(sourceOptedIn: true); + + await WithEngine(enabled: false, async () => + { + await BuildJob().Execute(Mock.Of()); + }); + + _nodeRepository.Verify(x => x.GetAllManagedByNodeGuard(It.IsAny()), Times.Never); + _rebalanceService.Verify(x => x.RebalanceAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Execute_SwapLiquidityFlagAloneDoesNotOptAChannelIn() + { + // The rebalancer used to borrow IsAutomatedLiquidityEnabled, which means "opted into + // swap-based liquidity rules". They are separate opt-ins now: a channel carrying only the + // swap flag must not be drained. + ArrangeRebalancePair(sourceOptedIn: false, sourceLiquidityFlag: true); + + await WithEngine(enabled: true, async () => + { + await BuildJob().Execute(Mock.Of()); + }); + + _rebalanceService.Verify(x => x.RebalanceAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Two drainable sources and two depleted destination peers, so the planner produces two plans, + /// on a node pinned to one in-flight rebalance so the second plan is dropped. Pairing is + /// first-fit in classification order, so chan 1001 pairs with peerD1 and chan 1003 with peerD2. + /// + private void ArrangeTwoRebalancePairs() + { + var node = new Node + { + Id = 20, + PubKey = NodePubKey, + Name = "alice", + // Fee pass off: this test is only about rebalance dispatch accounting. + DynamicFeeManagementEnabled = false, + AutoRebalanceEnabled = true, + RebalanceBudgetSats = 1_000_000, + MaxRebalanceCostToEarnRatio = 0.5, + // Pinned, not inherited: this test asserts the drop path, so the cap has to be 1 here + // regardless of what ROUTING_ENGINE_REBALANCE_DEFAULT_MAX_IN_FLIGHT is set to. + MaxRebalancesInFlight = 1, + }; + _nodeRepository.Setup(x => x.GetAllManagedByNodeGuard(false)).ReturnsAsync(new List { node }); + + Channel Db(int id, ulong chanId, bool optIn) => new() + { + Id = id, ChanId = chanId, Status = Channel.ChannelStatus.Open, + IsAutoRebalanceEnabled = optIn, + FundingTx = $"tx{id}", FundingTxOutputIndex = 0, + }; + + _channelRepository.Setup(x => x.GetOpenChannels()).ReturnsAsync(new List + { + Db(101, 1001, optIn: true), // source 1 + Db(102, 1002, optIn: false), // destination 1 + Db(103, 1003, optIn: true), // source 2 + Db(104, 1004, optIn: false), // destination 2 + }); + + _routingStateRepository.Setup(x => x.GetByManagedNodePubKey(NodePubKey)).ReturnsAsync(new List + { + new() { ChannelId = 101, ManagedNodePubKey = NodePubKey, ChanIdLnd = 1001, EmaLocalRatio = 0.75, TargetLocalRatio = 0.50, PeerFlowCategory = PeerFlowCategory.Source }, + new() { ChannelId = 102, ManagedNodePubKey = NodePubKey, ChanIdLnd = 1002, EmaLocalRatio = 0.10, TargetLocalRatio = 0.50, PeerFlowCategory = PeerFlowCategory.Sink }, + new() { ChannelId = 103, ManagedNodePubKey = NodePubKey, ChanIdLnd = 1003, EmaLocalRatio = 0.75, TargetLocalRatio = 0.50, PeerFlowCategory = PeerFlowCategory.Source }, + new() { ChannelId = 104, ManagedNodePubKey = NodePubKey, ChanIdLnd = 1004, EmaLocalRatio = 0.10, TargetLocalRatio = 0.50, PeerFlowCategory = PeerFlowCategory.Sink }, + }); + _feeStateRepository.Setup(x => x.GetByManagedNodePubKey(NodePubKey)).ReturnsAsync(new List()); + + _rebalanceRepository.Setup(x => x.GetPendingInFlightSourceChannelIds()).ReturnsAsync(new HashSet()); + _rebalanceRepository.Setup(x => x.GetConsumedFeesSince(node.Id, It.IsAny())).ReturnsAsync(0L); + _rebalanceRepository.Setup(x => x.GetInFlightByNode(node.Id)).ReturnsAsync(0); + + Lnrpc.Channel Lnd(ulong chanId, long local, long remote, string peer) => new() + { + ChanId = chanId, Capacity = 20_000_000, LocalBalance = local, RemoteBalance = remote, + Active = true, Initiator = true, RemotePubkey = peer, + }; + + _lightningClientService + .Setup(x => x.ListChannels(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Lnrpc.ListChannelsResponse + { + Channels = + { + Lnd(1001, 15_000_000, 5_000_000, "peerS1"), + Lnd(1002, 2_000_000, 18_000_000, "peerD1"), + Lnd(1003, 15_000_000, 5_000_000, "peerS2"), + Lnd(1004, 2_000_000, 18_000_000, "peerD2"), + }, + }); + + _lightningService.Setup(x => x.GetLocalOutboundFeeRatesPpmAsync(It.IsAny())) + .ReturnsAsync(new Dictionary + { + [1001] = 50, [1002] = 2500, [1003] = 60, [1004] = 2400, + }); + + _rebalanceService.Setup(x => x.RebalanceAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Rebalance()); + } + + [Fact] + public async Task Execute_LogsPlansDroppedByTheInFlightCap() + { + ArrangeTwoRebalancePairs(); + + await WithEngine(enabled: true, async () => + { + await BuildJob().Execute(Mock.Of()); + }); + + // The cap is 1, so only the first plan is dispatched... + _rebalanceService.Verify(x => x.RebalanceAsync( + It.Is(r => r.SourceChannelId == 101 && r.TargetPubkey == "peerD1"), + It.IsAny()), Times.Once); + _rebalanceService.Verify(x => x.RebalanceAsync( + It.IsAny(), It.IsAny()), Times.Once); + + // ...and the plan the cap ate is reported rather than silently discarded. + _logger.Verify(x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("dropped 1 of 2 planned rebalance(s)") + && v.ToString()!.Contains("in-flight cap reached (1/1")), + It.IsAny(), + It.IsAny>()), + Times.Once); + + // The dropped plan's own details, so an operator can see what was left on the table. + _logger.Verify(x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("dropped plan") + && v.ToString()!.Contains("drain chan 1003") + && v.ToString()!.Contains("refill peer peerD2")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task Execute_PricesEveryChannelInOneRoundTrip() + { + // Each GetChanInfo is an LND round-trip, so only channels whose rate drives a decision are + // priced. Chan 1003 is a fallback source (live local 0.70 but ema 0.60, so it never tripped + // the +0.15 trigger) and fallback sources are chosen by balance, never by cost. + var node = new Node + { + Id = 20, + PubKey = NodePubKey, + Name = "alice", + AutoRebalanceEnabled = true, + RebalanceBudgetSats = 1_000_000, + MaxRebalancesInFlight = 5, + MaxRebalanceCostToEarnRatio = 0.5, + }; + _nodeRepository.Setup(x => x.GetAllManagedByNodeGuard(false)).ReturnsAsync(new List { node }); + + Channel Db(int id, ulong chanId, bool optIn) => new() + { + Id = id, ChanId = chanId, Status = Channel.ChannelStatus.Open, + IsAutoRebalanceEnabled = optIn, FundingTx = $"tx{id}", FundingTxOutputIndex = 0, + }; + _channelRepository.Setup(x => x.GetOpenChannels()).ReturnsAsync(new List + { + Db(101, 1001, optIn: true), // detected source + Db(102, 1002, optIn: false), // detected destination + Db(103, 1003, optIn: true), // fallback source only + }); + + _routingStateRepository.Setup(x => x.GetByManagedNodePubKey(NodePubKey)).ReturnsAsync(new List + { + new() { ChannelId = 101, ManagedNodePubKey = NodePubKey, ChanIdLnd = 1001, EmaLocalRatio = 0.75, TargetLocalRatio = 0.50, PeerFlowCategory = PeerFlowCategory.Source }, + new() { ChannelId = 102, ManagedNodePubKey = NodePubKey, ChanIdLnd = 1002, EmaLocalRatio = 0.10, TargetLocalRatio = 0.50, PeerFlowCategory = PeerFlowCategory.Sink }, + new() { ChannelId = 103, ManagedNodePubKey = NodePubKey, ChanIdLnd = 1003, EmaLocalRatio = 0.60, TargetLocalRatio = 0.50, PeerFlowCategory = PeerFlowCategory.Bidirectional }, + }); + + _rebalanceRepository.Setup(x => x.GetPendingInFlightSourceChannelIds()).ReturnsAsync(new HashSet()); + _rebalanceRepository.Setup(x => x.GetConsumedFeesSince(node.Id, It.IsAny())).ReturnsAsync(0L); + _rebalanceRepository.Setup(x => x.GetInFlightByNode(node.Id)).ReturnsAsync(0); + + _lightningClientService + .Setup(x => x.ListChannels(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Lnrpc.ListChannelsResponse + { + Channels = + { + new Lnrpc.Channel { ChanId = 1001, Capacity = 20_000_000, LocalBalance = 15_000_000, RemoteBalance = 5_000_000, Active = true, RemotePubkey = "peerS" }, + new Lnrpc.Channel { ChanId = 1002, Capacity = 20_000_000, LocalBalance = 2_000_000, RemoteBalance = 18_000_000, Active = true, RemotePubkey = "peerD" }, + new Lnrpc.Channel { ChanId = 1003, Capacity = 20_000_000, LocalBalance = 14_000_000, RemoteBalance = 6_000_000, Active = true, RemotePubkey = "peerF" }, + }, + }); + + _lightningService.Setup(x => x.GetLocalOutboundFeeRatesPpmAsync(It.IsAny())) + .ReturnsAsync(new Dictionary { [1001] = 2000, [1002] = 2000, [1003] = 2000 }); + _rebalanceService.Setup(x => x.RebalanceAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Rebalance()); + + await WithEngine(enabled: true, async () => + { + await BuildJob().Execute(Mock.Of()); + }); + + // Three channels across a detected source, a detected destination and a fallback source — + // all priced by ONE round-trip. The old per-channel path cost a GetChanInfo each and had to + // reason about which subset the planner would consult; FeeReport removes that decision. + _lightningService.Verify(x => x.GetLocalOutboundFeeRatesPpmAsync(It.IsAny()), Times.Once); + _lightningService.Verify(x => x.GetLocalOutboundFeeRatePpmAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Execute_FeeReportUnavailable_SkipsTheNodeWithoutDispatching() + { + ArrangeRebalancePair(sourceOptedIn: true); + // Null, not an empty map: no rate for any channel means nothing can be profit-gated, and + // treating that as "everything earns zero" would silently drop every plan as unprofitable. + _lightningService.Setup(x => x.GetLocalOutboundFeeRatesPpmAsync(It.IsAny())) + .ReturnsAsync((Dictionary?)null); + + await WithEngine(enabled: true, async () => + { + await BuildJob().Execute(Mock.Of()); + }); + + _rebalanceService.Verify(x => x.RebalanceAsync( + It.IsAny(), It.IsAny()), Times.Never); + + // Skipped deliberately, not by blowing up: without the warning this test would also pass + // if BuildPlans threw on a null map and the per-node catch swallowed it. + _logger.Verify(x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("FeeReport unavailable")), + It.IsAny(), + It.IsAny>()), + Times.Once); + _logger.Verify(x => x.Log( + LogLevel.Error, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Never); + } +} diff --git a/test/NodeGuard.Tests/Services/RebalanceInitiatorServiceTests.cs b/test/NodeGuard.Tests/Services/RebalanceInitiatorServiceTests.cs new file mode 100644 index 00000000..e40c78a7 --- /dev/null +++ b/test/NodeGuard.Tests/Services/RebalanceInitiatorServiceTests.cs @@ -0,0 +1,426 @@ +/* + * 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; + +namespace NodeGuard.Services; + +public class RebalanceInitiatorServiceTests +{ + private static readonly RebalanceInitiatorTunables Tunables = new( + RebalanceTrigger: 0.15, + MinAmountSats: 10_000, + MaxAmountSats: 5_000_000, + CostToEarnRatio: 0.5, + MaxInitiations: 5); + + private static ChannelSignal Chan( + int id, string peer, long local, long remote, + double ema, double target, bool active = true, bool optIn = true) + => new(id, (ulong)id, peer, local + remote, local, remote, ema, target, active, optIn); + + // ── Classify: sources ─────────────────────────────────────────────────────────────── + + [Fact] + public void Classify_TooLocalOptedIn_BecomesSource_ExcessSizedOnLiveBalance() + { + // d = 0.80 - 0.50 = 0.30 > 0.15; targetLocal = 0.5 * 1_000_000 = 500_000; excess = 300_000. + var channels = new[] { Chan(1, "peerA", local: 800_000, remote: 200_000, ema: 0.80, target: 0.50) }; + + var result = RebalanceInitiatorService.Classify(channels, Tunables); + + result.Sources.Should().ContainSingle(); + result.Sources[0].ChannelId.Should().Be(1); + result.Sources[0].ExcessSats.Should().Be(300_000); + } + + [Fact] + public void Classify_TooLocalButNotOptedIn_IsNotSource() + { + var channels = new[] { Chan(1, "peerA", 800_000, 200_000, 0.80, 0.50, optIn: false) }; + + RebalanceInitiatorService.Classify(channels, Tunables).Sources.Should().BeEmpty(); + } + + [Fact] + public void Classify_AboveTargetButWithinTrigger_IsNotSource() + { + // d = 0.60 - 0.50 = 0.10 <= 0.15 → not "too local" enough to drain. + var channels = new[] { Chan(1, "peerA", 600_000, 400_000, 0.60, 0.50) }; + + RebalanceInitiatorService.Classify(channels, Tunables).Sources.Should().BeEmpty(); + } + + [Fact] + public void Classify_InactiveChannel_IsNeitherSourceNorDestination() + { + var channels = new[] { Chan(1, "peerA", 900_000, 100_000, 0.90, 0.50, active: false) }; + + var result = RebalanceInitiatorService.Classify(channels, Tunables); + + result.Sources.Should().BeEmpty(); + result.Destinations.Should().BeEmpty(); + } + + // ── Classify: destinations (peer aggregate) ───────────────────────────────────────── + + [Fact] + public void Classify_TooRemotePeer_BecomesDestination_WithDeficit() + { + // aggEma 0.20 vs target 0.50 → d = -0.30 < -0.15; deficit = 500_000 - 200_000 = 300_000. + var channels = new[] { Chan(1, "peerA", local: 200_000, remote: 800_000, ema: 0.20, target: 0.50) }; + + var result = RebalanceInitiatorService.Classify(channels, Tunables); + + result.Destinations.Should().ContainSingle(); + result.Destinations[0].PeerPubKey.Should().Be("peerA"); + result.Destinations[0].DeficitSats.Should().Be(300_000); + result.Destinations[0].Members.Should().ContainSingle(); + } + + [Fact] + public void Classify_AggregatesMultipleChannelsToSamePeer() + { + // Chan A balanced (0.50), Chan B depleted (0.10); both base 1_000_000, target 0.50. + // aggEma = (0.50 + 0.10)/2 = 0.30 → d = -0.20 < -0.15. + // deficit = target 1_000_000 - peerLocal 600_000 = 400_000. + var channels = new[] + { + Chan(1, "peerA", 500_000, 500_000, 0.50, 0.50), + Chan(2, "peerA", 100_000, 900_000, 0.10, 0.50), + }; + + var result = RebalanceInitiatorService.Classify(channels, Tunables); + + result.Destinations.Should().ContainSingle(); + result.Destinations[0].DeficitSats.Should().Be(400_000); + result.Destinations[0].Members.Should().HaveCount(2); + } + + [Fact] + public void Classify_PeerAggregateWithinTrigger_IsNotDestination() + { + // aggEma 0.42 vs target 0.50 → d = -0.08, inside the 0.15 trigger. + var channels = new[] { Chan(1, "peerA", 420_000, 580_000, 0.42, 0.50) }; + + RebalanceInitiatorService.Classify(channels, Tunables).Destinations.Should().BeEmpty(); + } + + // ── BuildPlans ────────────────────────────────────────────────────────────────────── + + private static RebalanceClassification Classify(params ChannelSignal[] channels) + => RebalanceInitiatorService.Classify(channels, Tunables); + + [Fact] + public void BuildPlans_TakesTheFirstAvailableSource_RegardlessOfEarnRate() + { + // Two drainable sources on different peers; the FIRST one is the dear one (2000ppm vs 50ppm). + // Pairing is first-fit in classification order, so chan 1 is drained anyway — there is no + // cheapest-source preference. Listing it first is what makes this assertion meaningful. + var classification = Classify( + Chan(1, "dear", 800_000, 200_000, 0.80, 0.50), + Chan(2, "cheap", 800_000, 200_000, 0.80, 0.50), + Chan(3, "dest", 200_000, 800_000, 0.20, 0.50)); + var earn = new Dictionary { [1] = 2000, [2] = 50, [3] = 2500 }; + + var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); + + plans.Should().ContainSingle(); + plans[0].SourceChannelId.Should().Be(1); + plans[0].DestinationPeerPubKey.Should().Be("dest"); + } + + [Fact] + public void BuildPlans_ProfitGate_SetsMaxFeePctFromDestEarnRate() + { + var classification = Classify( + Chan(1, "cheap", 800_000, 200_000, 0.80, 0.50), + Chan(3, "dest", 200_000, 800_000, 0.20, 0.50)); + var earn = new Dictionary { [1] = 50, [3] = 2500 }; + + var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); + + // maxCostPpm = 0.5 * 2500 = 1250 → MaxFeePct = 1250 / 10_000 = 0.125. + plans.Should().ContainSingle(); + plans[0].MaxFeePct.Should().BeApproximately(0.125, 1e-9); + } + + [Fact] + public void BuildPlans_ZeroEarnDestination_IsSkipped() + { + var classification = Classify( + Chan(1, "cheap", 800_000, 200_000, 0.80, 0.50), + Chan(3, "dest", 200_000, 800_000, 0.20, 0.50)); + var earn = new Dictionary { [1] = 50, [3] = 0 }; // dest earns nothing + + RebalanceInitiatorService.BuildPlans(classification, earn, Tunables).Should().BeEmpty(); + } + + [Fact] + public void BuildPlans_DestinationWithoutKnownEarnRate_IsSkipped() + { + var classification = Classify( + Chan(1, "cheap", 800_000, 200_000, 0.80, 0.50), + Chan(3, "dest", 200_000, 800_000, 0.20, 0.50)); + var earn = new Dictionary { [1] = 50 }; // no entry for the dest channel + + RebalanceInitiatorService.BuildPlans(classification, earn, Tunables).Should().BeEmpty(); + } + + [Fact] + public void BuildPlans_AvoidsSamePeerPairing() + { + // Peer "A" is BOTH a too-local source (chan 1) and — thanks to a deeply depleted sibling + // (chan 2) — a too-remote destination in aggregate, and it is classified before peer "B". + // The only source is on peer "A", so A can't be refilled from itself; B is refilled instead. + var classification = Classify( + Chan(1, "A", 800_000, 200_000, 0.80, 0.50), // source, peer A, excess 300_000 + Chan(2, "A", 50_000, 2_950_000, 0.02, 0.50), // pulls peer A aggregate too-remote + Chan(3, "B", 200_000, 800_000, 0.20, 0.50)); // destination, peer B + var earn = new Dictionary { [1] = 4000, [2] = 4000, [3] = 2500 }; + + var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); + + plans.Should().ContainSingle(); + plans[0].SourceChannelId.Should().Be(1); + plans[0].DestinationPeerPubKey.Should().Be("B"); // NOT "A", despite A being tried first + } + + [Fact] + public void BuildPlans_SizesToMinOfExcessAndDeficit() + { + // source excess 300_000, dest deficit 400_000 → amount 300_000. + var classification = Classify( + Chan(1, "src", 800_000, 200_000, 0.80, 0.50), + Chan(3, "dest", 100_000, 900_000, 0.10, 0.50)); + var earn = new Dictionary { [1] = 50, [3] = 2500 }; + + var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); + + plans.Should().ContainSingle(); + plans[0].AmountSats.Should().Be(300_000); + } + + [Fact] + public void BuildPlans_ClampsAmountToMax() + { + // source excess 6_000_000, dest deficit 8_000_000, both above the 5_000_000 cap → amount 5_000_000. + var classification = Classify( + Chan(1, "src", 9_000_000, 3_000_000, 0.75, 0.25), // excess = 9M - 3M = 6M + Chan(3, "dest", 2_000_000, 18_000_000, 0.10, 0.50)); // deficit = 10M - 2M = 8M + var earn = new Dictionary { [1] = 50, [3] = 2500 }; + + var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); + + plans.Should().ContainSingle(); + plans[0].AmountSats.Should().Be(5_000_000); + } + + [Fact] + public void BuildPlans_SkipsWhenSourceExcessBelowMin() + { + // Chan 1 qualifies as a source by the smoothed signal (ema 0.80 → d 0.30) but its LIVE excess + // is only 5_000 sats — below the 10_000 min — so it can't feed the depleted destination. + var classification = Classify( + Chan(1, "src", 505_000, 495_000, 0.80, 0.50), // source, live excess = 505_000 - 500_000 = 5_000 + Chan(3, "dest", 100_000, 900_000, 0.10, 0.50)); + var earn = new Dictionary { [1] = 50, [3] = 2500 }; + + classification.Sources.Should().ContainSingle(); + classification.Sources[0].ExcessSats.Should().Be(5_000); + RebalanceInitiatorService.BuildPlans(classification, earn, Tunables).Should().BeEmpty(); + } + + [Fact] + public void BuildPlans_UsesEachSourceAtMostOncePerRun() + { + // Two depleted destinations, a single drainable source → only one plan. + var classification = Classify( + Chan(1, "src", 5_000_000, 1_000_000, 0.83, 0.50), + Chan(3, "destA", 200_000, 800_000, 0.20, 0.50), + Chan(4, "destB", 200_000, 800_000, 0.20, 0.50)); + var earn = new Dictionary { [1] = 50, [3] = 2500, [4] = 2400 }; + + var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); + + plans.Should().ContainSingle(); + plans[0].SourceChannelId.Should().Be(1); + } + + [Fact] + public void BuildPlans_RespectsMaxInitiationsCap() + { + var tunables = Tunables with { MaxInitiations = 1 }; + var classification = Classify( + Chan(1, "srcA", 800_000, 200_000, 0.80, 0.50), + Chan(2, "srcB", 800_000, 200_000, 0.80, 0.50), + Chan(3, "destA", 200_000, 800_000, 0.20, 0.50), + Chan(4, "destB", 200_000, 800_000, 0.20, 0.50)); + var earn = new Dictionary { [1] = 50, [2] = 60, [3] = 2500, [4] = 2400 }; + + RebalanceInitiatorService.BuildPlans(classification, earn, tunables).Should().ContainSingle(); + } + + [Fact] + public void BuildPlans_WeightsDestinationEarnRateByBalanceBase() + { + // Dest peer has two channels: 1000ppm (base 1M) and 3000ppm (base 3M). + // Weighted avg = (1000·1M + 3000·3M) / 4M = 10_000M/4M = 2500ppm → maxFeePct = 0.5·2500/10_000 = 0.125. + var classification = Classify( + Chan(1, "src", 800_000, 200_000, 0.80, 0.50), + Chan(3, "dest", 200_000, 800_000, 0.20, 0.50), // base 1M + Chan(4, "dest", 600_000, 2_400_000, 0.20, 0.50)); // base 3M + var earn = new Dictionary { [1] = 50, [3] = 1000, [4] = 3000 }; + + var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); + + plans.Should().ContainSingle(); + plans[0].MaxFeePct.Should().BeApproximately(0.125, 1e-9); + } + + // ── Fallback pairing: act on a detected imbalance even with no qualifying counterparty ── + + [Fact] + public void Classify_ChannelInsideTheDeadband_LandsInBothFallbackPools() + { + // Perfectly on target: not a source, not a destination, but able to play either role. + // Lendable down to 0.35 → 500_000 - 350_000 = 150_000. Absorbable up to 0.65 → 150_000. + var result = Classify(Chan(1, "peerA", local: 500_000, remote: 500_000, ema: 0.50, target: 0.50)); + + result.Sources.Should().BeEmpty(); + result.Destinations.Should().BeEmpty(); + result.FallbackSources.Should().ContainSingle().Which.ExcessSats.Should().Be(150_000); + result.FallbackDestinations.Should().ContainSingle().Which.DeficitSats.Should().Be(150_000); + } + + [Fact] + public void BuildPlans_SourceWithNoQualifyingDestination_DrainsIntoTheFirstFallbackPeerThatFits() + { + var classification = Classify( + Chan(1, "src", 800_000, 200_000, 0.80, 0.50), // source, excess 300_000 + Chan(2, "peerB", 500_000, 500_000, 0.50, 0.50), // fallback dest, absorbable 150_000 + Chan(3, "peerC", 450_000, 550_000, 0.45, 0.50)); // fallback dest, absorbable 200_000 + var earn = new Dictionary { [1] = 50, [2] = 2000, [3] = 2000 }; + + classification.Destinations.Should().BeEmpty("neither peer tripped the -0.15 trigger"); + + var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); + + plans.Should().ContainSingle(); + plans[0].SourceChannelId.Should().Be(1); + // peerB, not the emptier peerC: pass 2 takes the first fallback destination that yields a + // plan, and has no preference for the one with the most room. + plans[0].DestinationPeerPubKey.Should().Be("peerB"); + plans[0].IsFallbackPairing.Should().BeTrue(); + // Capped by peerB's room up to target + deadband (650_000 - 500_000), NOT the source's + // full 300_000 excess — refilling must never turn the destination into next cycle's source. + plans[0].AmountSats.Should().Be(150_000); + } + + [Fact] + public void BuildPlans_DestinationWithNoQualifyingSource_IsFundedByTheFirstFallbackChannel() + { + var classification = Classify( + Chan(1, "peerA", 600_000, 400_000, 0.60, 0.50), // fallback source, lendable 250_000 + Chan(2, "peerB", 900_000, 100_000, 0.55, 0.50), // fallback source, lendable 550_000 + Chan(3, "dest", 100_000, 900_000, 0.10, 0.50)); // destination, deficit 400_000 + var earn = new Dictionary { [1] = 50, [2] = 60, [3] = 2500 }; + + classification.Sources.Should().BeEmpty("neither peer tripped the +0.15 trigger"); + + var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); + + plans.Should().ContainSingle(); + // peerA, not the fuller peerB: the fallback pool is drawn from in classification order. + plans[0].SourceChannelId.Should().Be(1); + plans[0].DestinationPeerPubKey.Should().Be("dest"); + plans[0].IsFallbackPairing.Should().BeTrue(); + // Bounded by what peerA may lend (250_000), not the destination's full 400_000 deficit. + plans[0].AmountSats.Should().Be(250_000); + } + + [Fact] + public void BuildPlans_FallbackSourceIsNotDrainedBelowItsOwnDeadband() + { + var classification = Classify( + Chan(1, "peerA", 400_000, 600_000, 0.40, 0.50), // fallback source: lendable to 0.35 = 50_000 + Chan(2, "dest", 0, 1_000_000, 0.00, 0.50)); // destination, deficit 500_000 + var earn = new Dictionary { [1] = 50, [2] = 2500 }; + + var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); + + plans.Should().ContainSingle(); + // 50_000, not the 400_000 it actually holds: lending stops at target - deadband so the + // fallback source can't become next cycle's destination. + plans[0].AmountSats.Should().Be(50_000); + } + + [Fact] + public void BuildPlans_FallbackPairingIsStillProfitGated() + { + var classification = Classify( + Chan(1, "src", 800_000, 200_000, 0.80, 0.50), + Chan(2, "peerB", 500_000, 500_000, 0.50, 0.50)); + // The only available destination earns nothing, so there is no margin to pay a route with. + var earn = new Dictionary { [1] = 50, [2] = 0 }; + + RebalanceInitiatorService.BuildPlans(classification, earn, Tunables).Should().BeEmpty(); + } + + [Fact] + public void BuildPlans_RefillsAFallbackDestinationAtMostOncePerRun() + { + // Two drainable sources, and the only fallback destination has room for 150_000. Each plan + // is clamped to that room on its own, so without a per-run guard both sources would send + // peerX 150_000 and land it 300_000 above where it started — past target + deadband, making + // the peer we just refilled next cycle's source. + var classification = Classify( + Chan(1, "srcA", 800_000, 200_000, 0.80, 0.50), // detected source, excess 300_000 + Chan(2, "srcB", 800_000, 200_000, 0.80, 0.50), // detected source, excess 300_000 + Chan(3, "peerX", 500_000, 500_000, 0.50, 0.50)); // fallback dest, absorbable 150_000 + var earn = new Dictionary { [1] = 50, [2] = 50, [3] = 2000 }; + + classification.Destinations.Should().BeEmpty("peerX did not trip the -0.15 trigger"); + classification.FallbackDestinations.Should().ContainSingle() + .Which.DeficitSats.Should().Be(150_000); + + var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); + + plans.Should().ContainSingle("peerX has room for one refill, not one per source"); + plans[0].DestinationPeerPubKey.Should().Be("peerX"); + plans[0].AmountSats.Should().Be(150_000); + plans.Sum(p => p.AmountSats).Should().Be(150_000, "the peer's room is a per-run allowance"); + } + + [Fact] + public void BuildPlans_PrefersAQualifyingSourceOverAFallbackOne() + { + var classification = Classify( + Chan(1, "src", 800_000, 200_000, 0.80, 0.50), // qualifying source + Chan(2, "peerB", 950_000, 50_000, 0.55, 0.50), // fallback source, far more to lend + Chan(3, "dest", 100_000, 900_000, 0.10, 0.50)); // destination + var earn = new Dictionary { [1] = 50, [2] = 60, [3] = 2500 }; + + var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); + + plans.Should().ContainSingle(); + plans[0].SourceChannelId.Should().Be(1, "a channel that tripped the trigger is drained before one that did not"); + plans[0].IsFallbackPairing.Should().BeFalse(); + } +} From 2c8675cead0bd15943c72721fbf68dbf01fa67c1 Mon Sep 17 00:00:00 2001 From: Marcos Date: Thu, 27 Aug 2026 18:59:23 +0200 Subject: [PATCH 14/21] refactor: rebalance alorithm --- src/Jobs/AutoRebalanceJob.cs | 109 ++++++--------- src/Services/LightningService.cs | 10 +- src/Services/RebalanceInitiatorService.cs | 125 ++++++++---------- src/Services/RebalanceService.cs | 6 +- .../Jobs/AutoRebalanceJobTests.cs | 112 ++++++++-------- .../Jobs/ChannelFeeOptimizerJobTests.cs | 24 +--- .../Jobs/RoutingEngineCollection.cs | 27 ++++ .../RebalanceInitiatorServiceTests.cs | 97 ++++++++------ 8 files changed, 250 insertions(+), 260 deletions(-) diff --git a/src/Jobs/AutoRebalanceJob.cs b/src/Jobs/AutoRebalanceJob.cs index a266d9bc..8173a8a4 100644 --- a/src/Jobs/AutoRebalanceJob.cs +++ b/src/Jobs/AutoRebalanceJob.cs @@ -46,6 +46,7 @@ public class AutoRebalanceJob : IJob private readonly IRebalanceService _rebalanceService; private readonly IRoutingEngineSnapshotService _snapshotService; private readonly ILightningService _lightningService; + private readonly IAuditService _auditService; public AutoRebalanceJob( ILogger logger, @@ -54,7 +55,8 @@ public AutoRebalanceJob( IRebalanceRepository rebalanceRepository, IRebalanceService rebalanceService, IRoutingEngineSnapshotService snapshotService, - ILightningService lightningService) + ILightningService lightningService, + IAuditService auditService) { _logger = logger; _nodeRepository = nodeRepository; @@ -63,6 +65,7 @@ public AutoRebalanceJob( _rebalanceService = rebalanceService; _snapshotService = snapshotService; _lightningService = lightningService; + _auditService = auditService; } public async Task Execute(IJobExecutionContext context) @@ -173,7 +176,6 @@ private async Task RebalanceNode( oc.DbChannel.Id, oc.Lnd.ChanId, oc.Lnd.RemotePubkey, - oc.Lnd.Capacity, oc.Lnd.LocalBalance, oc.Lnd.RemoteBalance, oc.RoutingState.EmaLocalRatio, @@ -183,7 +185,7 @@ private async Task RebalanceNode( oc.DbChannel.IsAutoRebalanceEnabled && !inFlightSourceChannelIds.Contains(oc.DbChannel.Id))) .ToList(); - var tunables = BuildRebalanceTunables(node); + var tunables = RebalanceInitiatorTunables.FromConstants(node); var classification = RebalanceInitiatorService.Classify(signals, tunables); if (classification.Sources.Count == 0 && classification.Destinations.Count == 0) @@ -198,7 +200,7 @@ private async Task RebalanceNode( node.Name, classification.Sources.Count, classification.Destinations.Count, classification.FallbackSources.Count, classification.FallbackDestinations.Count); - var earnRates = await FetchEarnRatesAsync(node, owned); + var earnRates = await _lightningService.GetLocalOutboundFeeRatesPpmAsync(node); if (earnRates == null) { _logger.LogWarning("Skipping node {NodeName}: FeeReport unavailable, so nothing can be profit-gated", @@ -214,24 +216,25 @@ private async Task RebalanceNode( } var initiations = 0; - var planIndex = 0; - string? capStopReason = null; - for (; planIndex < plans.Count; planIndex++) + for (var i = 0; i < plans.Count; i++) { - var plan = plans[planIndex]; - - if (initiations >= tunables.MaxInitiations) - { - capStopReason = $"per-run initiation cap reached ({initiations}/{tunables.MaxInitiations})"; - break; - } + var plan = plans[i]; + // The cap stops the loop outright, so every remaining plan is abandoned if (inFlight + initiations >= maxInFlight) { - capStopReason = - $"in-flight cap reached ({inFlight + initiations}/{maxInFlight}, {inFlight} already in flight " + - "before this run; raise the node's MaxRebalancesInFlight to dispatch more per cycle)"; + _logger.LogInformation( + "Node {NodeName}: dropped {DroppedCount} of {PlannedCount} planned rebalance(s) — " + + "in-flight cap reached ({Reached}/{Max}, {Before} already in flight before this run; " + + "raise the node's MaxRebalancesInFlight to dispatch more per cycle)", + node.Name, plans.Count - i, plans.Count, inFlight + initiations, maxInFlight, inFlight); + + for (var dropped = i; dropped < plans.Count; dropped++) + { + _logger.LogInformation("Node {NodeName}: dropped plan — {Reason}", node.Name, plans[dropped].Reason); + } + break; } @@ -264,9 +267,30 @@ private async Task RebalanceNode( // Keep retries within the profitable ceiling RetryMaxFeePct: plan.MaxFeePct); - // Fire-and-forget, and deliberately not tracked: RebalanceService logs and audits - // the whole lifecycle itself and MonitorRebalancesJob reconciles anything this process abandons - _ = _rebalanceService.RebalanceAsync(request, CancellationToken.None); + await _rebalanceService.RebalanceAsync(request, CancellationToken.None); + + // Audit the performed rebalance + await _auditService.LogSystemAsync( + AuditActionType.RebalanceInitiated, + AuditEventType.Attempt, + AuditObjectType.Rebalance, + plan.SourceChannelId.ToString(), + new + { + NodeName = node.Name, + NodeId = node.Id, + NodePubKey = node.PubKey, + SourceChannelId = plan.SourceChannelId, + TargetPubkey = plan.DestinationPeerPubKey, + AmountSats = plan.AmountSats, + MaxFeePct = plan.MaxFeePct, + ReservedFeeSats = reservedFee, + BudgetRemainingSats = remainingBudget - reservedFee, + BudgetTotalSats = budgetSats, + InFlightCount = inFlight + initiations + 1, + MaxInFlightCount = maxInFlight, + Reason = plan.Reason + }); remainingBudget -= reservedFee; initiations++; @@ -278,53 +302,8 @@ private async Task RebalanceNode( } } - // A cap stops the loop outright, so every remaining plan is abandoned - if (capStopReason != null) - { - var dropped = plans.Count - planIndex; - _logger.LogInformation( - "Node {NodeName}: dropped {DroppedCount} of {PlannedCount} planned rebalance(s) — {CapStopReason}", - node.Name, dropped, plans.Count, capStopReason); - - for (var i = planIndex; i < plans.Count; i++) - { - _logger.LogInformation("Node {NodeName}: dropped plan — {Reason}", node.Name, plans[i].Reason); - } - } - _logger.LogInformation( "Node {NodeName}: initiated {Count} of {PlannedCount} planned rebalance(s), remaining budget {Remaining}/{Budget} sats", node.Name, initiations, plans.Count, remainingBudget, budgetSats); } - - private static RebalanceInitiatorTunables BuildRebalanceTunables(Node node) => new( - RebalanceTrigger: Constants.ROUTING_ENGINE_REBALANCE_DEADBAND, - MinAmountSats: Constants.REBALANCE_MIN_AMOUNT_SATS, - MaxAmountSats: Constants.ROUTING_ENGINE_REBALANCE_MAX_AMOUNT_SATS, - CostToEarnRatio: node.MaxRebalanceCostToEarnRatio ?? Constants.ROUTING_ENGINE_REBALANCE_DEFAULT_COST_TO_EARN_RATIO, - MaxInitiations: Constants.ROUTING_ENGINE_REBALANCE_MAX_INITIATIONS_PER_RUN); - - /// - /// Maps our channel id → live local-outbound ppm for every channel in the snapshot, from a single - /// FeeReport. - /// - private async Task?> FetchEarnRatesAsync(Node node, IReadOnlyList owned) - { - var ppmByChanId = await _lightningService.GetLocalOutboundFeeRatesPpmAsync(node); - if (ppmByChanId == null) return null; - - var earnRates = new Dictionary(); - foreach (var oc in owned) - { - if (ppmByChanId.TryGetValue(oc.Lnd.ChanId, out var ppm)) - { - earnRates[oc.DbChannel.Id] = ppm; - } - } - - _logger.LogDebug("Node {NodeName}: priced {Priced} of {Total} channel(s) from FeeReport", - node.Name, earnRates.Count, owned.Count); - - return earnRates; - } } diff --git a/src/Services/LightningService.cs b/src/Services/LightningService.cs index 6f6bbb02..bf760bee 100644 --- a/src/Services/LightningService.cs +++ b/src/Services/LightningService.cs @@ -185,14 +185,12 @@ Task SendPaymentV2Async(Node node, string paymentRequest, long amountSa /// a specific channel. If the peer has multiple channels with us, the first one /// returned by ListChannels is used as a representative. /// - Task GetLocalOutboundFeeRatePpmByPeerAsync - (Node node, string peerPubkey); + Task GetLocalOutboundFeeRatePpmByPeerAsync(Node node, string peerPubkey); /// - /// Returns the local-outbound fee rate (ppm) of a specific channel — the per-channel - /// variant of . Used by the - /// auto-rebalancer to pick the cheapest source to drain and to weight a destination - /// peer's earn rate for the profitability gate. + /// Returns the local-outbound fee rate (ppm) of a specific channel, read from the gossip + /// graph via GetChanInfo. Backs , its only + /// caller. /// Task GetLocalOutboundFeeRatePpmAsync(Node node, ulong chanId); diff --git a/src/Services/RebalanceInitiatorService.cs b/src/Services/RebalanceInitiatorService.cs index f1b13682..60146f75 100644 --- a/src/Services/RebalanceInitiatorService.cs +++ b/src/Services/RebalanceInitiatorService.cs @@ -17,18 +17,18 @@ * */ +using NodeGuard.Helpers; + namespace NodeGuard.Services; /// /// One of our channels, with the live balances and the smoothed routing-engine signal the -/// rebalancer needs. Built by from ListChannels + -/// ChannelRoutingState; the decision logic itself never touches LND or the DB. +/// rebalancer needs. /// public record ChannelSignal( int ChannelId, ulong ChanIdLnd, string PeerPubKey, - long CapacitySats, long LocalSats, long RemoteSats, double EmaLocalRatio, @@ -38,51 +38,28 @@ public record ChannelSignal( /// /// A channel we can drain (send OUT of via outgoing_chan_id) — precise, per channel. -/// -/// is how much this channel may contribute, not simply how much it holds. -/// For a channel that tripped the trigger it is the excess over its own target. For a fallback -/// source (see ) it is the room down to the -/// low edge of its deadband, so lending liquidity can never turn it into next cycle's destination. -/// /// public record SourceChannel( int ChannelId, ulong ChanIdLnd, string PeerPubKey, - long ExcessSats, - long LocalSats); + long ExcessSats); /// One of our channels with a destination peer — carries the earn-rate weight (balance base). -public record PeerMemberChannel(int ChannelId, ulong ChanIdLnd, long BalanceBaseSats); +public record PeerMemberChannel(ulong ChanIdLnd, long BalanceBaseSats); /// /// A peer we want to refill, aggregated across all our channels with it. LND's /// last_hop_pubkey only constrains the peer, not the exact incoming channel, so the /// destination side is modelled at peer granularity. -/// -/// is how much this peer may absorb. For a peer that tripped the trigger -/// it is the shortfall against its own target. For a fallback destination (see -/// ) it is the room up to the high edge -/// of its deadband, so being refilled can never turn it into next cycle's source. -/// /// public record DestinationPeer( string PeerPubKey, - double AggregateEmaRatio, - double AggregateTargetRatio, long DeficitSats, - long RemoteSats, IReadOnlyList Members); /// /// Output of . -/// -/// and are the imbalances the engine actually -/// detected. The two fallback pools are everything else that is merely *able* to take part, and -/// exist so a detected imbalance is still acted on when nothing on the opposite side tripped the -/// trigger — a lone too-local source still gets drained somewhere, a lone depleted peer still gets -/// refilled from somewhere. -/// /// public record RebalanceClassification( IReadOnlyList Sources, @@ -91,12 +68,11 @@ public record RebalanceClassification( IReadOnlyList FallbackDestinations); /// -/// A concrete rebalance the job should dispatch: drain , refill via +/// A concrete rebalance the job should dispatch: drain , refill via /// last-hop , sized and profit-gated. /// public record RebalancePlan( int SourceChannelId, - ulong SourceChanIdLnd, string DestinationPeerPubKey, long AmountSats, double MaxFeePct, @@ -104,16 +80,28 @@ public record RebalancePlan( string Reason); /// -/// Control tunables for . Passed in explicitly so the -/// decision logic stays pure and unit-testable; the job builds these from the node config + -/// ROUTING_ENGINE_REBALANCE_* constants. +/// Control tunables for . /// public record RebalanceInitiatorTunables( double RebalanceTrigger, long MinAmountSats, long MaxAmountSats, double CostToEarnRatio, - int MaxInitiations); + int MaxInitiations) +{ + /// + /// Production wiring: global ROUTING_ENGINE_REBALANCE_* defaults, with the cost-to-earn ratio + /// overridden per node. Lives on the record — as + /// does — so the pure module owns its own configuration and the job never names a constant. + /// + public static RebalanceInitiatorTunables FromConstants(Data.Models.Node node) => new( + RebalanceTrigger: Constants.ROUTING_ENGINE_REBALANCE_DEADBAND, + MinAmountSats: Constants.REBALANCE_MIN_AMOUNT_SATS, + MaxAmountSats: Constants.ROUTING_ENGINE_REBALANCE_MAX_AMOUNT_SATS, + CostToEarnRatio: node.MaxRebalanceCostToEarnRatio + ?? Constants.ROUTING_ENGINE_REBALANCE_DEFAULT_COST_TO_EARN_RATIO, + MaxInitiations: Constants.ROUTING_ENGINE_REBALANCE_MAX_INITIATIONS_PER_RUN); +} /// /// Pure decision logic for the automated rebalancer — no I/O, no clock, no DB, so it is a static @@ -143,24 +131,21 @@ public static RebalanceClassification Classify( var baseSats = c.LocalSats + c.RemoteSats; if (baseSats <= 0) continue; - var targetLocal = (long)Math.Round(c.TargetLocalRatio * baseSats, MidpointRounding.AwayFromZero); - var excess = c.LocalSats - targetLocal; + var excess = c.LocalSats - SatsAt(c.TargetLocalRatio, baseSats); // Too-local by the smoothed signal if (c.EmaLocalRatio - c.TargetLocalRatio > t.RebalanceTrigger && excess > 0) { - sources.Add(new SourceChannel(c.ChannelId, c.ChanIdLnd, c.PeerPubKey, excess, c.LocalSats)); + sources.Add(new SourceChannel(c.ChannelId, c.ChanIdLnd, c.PeerPubKey, excess)); continue; } // Searching for fallback sources. Avoiding creating a next cycle rebalance by // lending liquidity down to the low edge of the deadband only - var floorRatio = Math.Max(0, c.TargetLocalRatio - t.RebalanceTrigger); - var floorLocal = (long)Math.Round(floorRatio * baseSats, MidpointRounding.AwayFromZero); - var lendable = c.LocalSats - floorLocal; - if (lendable > 0) + var lendable = c.LocalSats - SatsAt(Math.Max(0, c.TargetLocalRatio - t.RebalanceTrigger), baseSats); + if (lendable > 0 && lendable > t.MinAmountSats) { - fallbackSources.Add(new SourceChannel(c.ChannelId, c.ChanIdLnd, c.PeerPubKey, lendable, c.LocalSats)); + fallbackSources.Add(new SourceChannel(c.ChannelId, c.ChanIdLnd, c.PeerPubKey, lendable)); } } @@ -170,7 +155,6 @@ public static RebalanceClassification Classify( foreach (var group in channels.Where(c => c.Active).GroupBy(c => c.PeerPubKey)) { long peerLocal = 0; - long peerRemote = 0; long peerBase = 0; double weightedEma = 0; double weightedTarget = 0; @@ -182,11 +166,10 @@ public static RebalanceClassification Classify( if (baseSats <= 0) continue; peerLocal += c.LocalSats; - peerRemote += c.RemoteSats; peerBase += baseSats; weightedEma += c.EmaLocalRatio * baseSats; weightedTarget += c.TargetLocalRatio * baseSats; - members.Add(new PeerMemberChannel(c.ChannelId, c.ChanIdLnd, baseSats)); + members.Add(new PeerMemberChannel(c.ChanIdLnd, baseSats)); } if (peerBase <= 0) continue; @@ -201,19 +184,16 @@ public static RebalanceClassification Classify( // Too-remote in aggregate (smoothed): the peer holds too little of our local if (aggEma - aggTarget < -t.RebalanceTrigger && deficit > 0) { - destinations.Add(new DestinationPeer(group.Key, aggEma, aggTarget, deficit, peerRemote, members)); + destinations.Add(new DestinationPeer(group.Key, deficit, members)); continue; } // Searching for fallback destinations. Avoiding creating a next cycle rebalance by // lending liquidity up to the high edge of the deadband only - var ceilingRatio = Math.Min(1.0, aggTarget + t.RebalanceTrigger); - var ceilingLocal = (long)Math.Round(ceilingRatio * peerBase, MidpointRounding.AwayFromZero); - var absorbable = ceilingLocal - peerLocal; - if (absorbable > 0) + var absorbable = SatsAt(Math.Min(1.0, aggTarget + t.RebalanceTrigger), peerBase) - peerLocal; + if (absorbable > 0 && absorbable > t.MinAmountSats) { - fallbackDestinations.Add( - new DestinationPeer(group.Key, aggEma, aggTarget, absorbable, peerRemote, members)); + fallbackDestinations.Add(new DestinationPeer(group.Key, absorbable, members)); } } @@ -230,27 +210,32 @@ public static RebalanceClassification Classify( /// public static IReadOnlyList BuildPlans( RebalanceClassification classification, - IReadOnlyDictionary outboundEarnPpmByChannelId, + IReadOnlyDictionary earnPpmByChanIdLnd, RebalanceInitiatorTunables t) { var plans = new List(); var usedSourceIds = new HashSet(); - // Only counterparties big enough to be worth a hop var sources = classification.Sources - .Where(s => s.ExcessSats >= t.MinAmountSats) + .OrderByDescending(s => s.ExcessSats) .ToList(); var fallbackSources = classification.FallbackSources - .Where(s => s.ExcessSats >= t.MinAmountSats) + .OrderByDescending(s => s.ExcessSats) + .ToList(); + var destinations = classification.Destinations + .OrderByDescending(d => d.DeficitSats) + .ToList(); + var fallbackDestinations = classification.FallbackDestinations + .OrderByDescending(d => d.DeficitSats) .ToList(); // Pass 1: refill every detected destination - foreach (var dest in classification.Destinations) + foreach (var dest in destinations) { if (plans.Count >= t.MaxInitiations) return plans; // No known earn rate ⇒ nothing to profit-gate against ⇒ leave the peer alone. - var destEarnPpm = WeightedAverageEarnPpm(dest.Members, outboundEarnPpmByChannelId); + var destEarnPpm = WeightedAverageEarnPpm(dest.Members, earnPpmByChanIdLnd); if (destEarnPpm == null) continue; // A channel that tripped the trigger first; failing that, borrow from the fallback pool @@ -260,7 +245,7 @@ public static IReadOnlyList BuildPlans( source ??= FirstFreeSource(fallbackSources, dest, usedSourceIds); if (source == null) continue; - var plan = TryBuildPlan(source, dest, destEarnPpm.Value, outboundEarnPpmByChannelId, t, isFallback); + var plan = TryBuildPlan(source, dest, destEarnPpm.Value, earnPpmByChanIdLnd, t, isFallback); if (plan == null) continue; usedSourceIds.Add(source.ChannelId); @@ -268,8 +253,8 @@ public static IReadOnlyList BuildPlans( } // Pass 2: drain every detected source pass 1 left unused - var gatedFallbackDestinations = classification.FallbackDestinations - .Select(d => (Dest: d, EarnPpm: WeightedAverageEarnPpm(d.Members, outboundEarnPpmByChannelId))) + var gatedFallbackDestinations = fallbackDestinations + .Select(d => (Dest: d, EarnPpm: WeightedAverageEarnPpm(d.Members, earnPpmByChanIdLnd))) .Where(x => x.EarnPpm.HasValue) .ToList(); @@ -285,7 +270,7 @@ public static IReadOnlyList BuildPlans( if (dest.PeerPubKey == source.PeerPubKey) continue; if (refilledFallbackPeers.Contains(dest.PeerPubKey)) continue; - var plan = TryBuildPlan(source, dest, destEarnPpm!.Value, outboundEarnPpmByChannelId, t, + var plan = TryBuildPlan(source, dest, destEarnPpm!.Value, earnPpmByChanIdLnd, t, isFallbackPairing: true); if (plan == null) continue; @@ -301,6 +286,12 @@ public static IReadOnlyList BuildPlans( return plans; } + /// + /// Sats corresponding to of . + /// + private static long SatsAt(double ratio, long baseSats) + => (long)Math.Round(ratio * baseSats, MidpointRounding.AwayFromZero); + private static SourceChannel? FirstFreeSource( IReadOnlyList pool, DestinationPeer dest, @@ -315,7 +306,7 @@ public static IReadOnlyList BuildPlans( SourceChannel source, DestinationPeer dest, long destEarnPpm, - IReadOnlyDictionary outboundEarnPpmByChannelId, + IReadOnlyDictionary earnPpmByChanIdLnd, RebalanceInitiatorTunables t, bool isFallbackPairing) { @@ -326,14 +317,12 @@ public static IReadOnlyList BuildPlans( // What the source can give, bounded by what the destination can take var raw = Math.Min(source.ExcessSats, dest.DeficitSats); - if (raw < t.MinAmountSats) return null; var amount = Math.Min(raw, t.MaxAmountSats); - var sourceEarn = outboundEarnPpmByChannelId.TryGetValue(source.ChannelId, out var se) ? se : (long?)null; + var sourceEarn = earnPpmByChanIdLnd.TryGetValue(source.ChanIdLnd, out var se) ? se : (long?)null; var kind = isFallbackPairing ? "fallback " : string.Empty; return new RebalancePlan( source.ChannelId, - source.ChanIdLnd, dest.PeerPubKey, amount, maxFeePct, @@ -348,13 +337,13 @@ public static IReadOnlyList BuildPlans( /// private static long? WeightedAverageEarnPpm( IReadOnlyList members, - IReadOnlyDictionary outboundEarnPpmByChannelId) + IReadOnlyDictionary earnPpmByChanIdLnd) { double weightedSum = 0; long totalWeight = 0; foreach (var m in members) { - if (!outboundEarnPpmByChannelId.TryGetValue(m.ChannelId, out var ppm)) continue; + if (!earnPpmByChanIdLnd.TryGetValue(m.ChanIdLnd, out var ppm)) continue; weightedSum += (double)ppm * m.BalanceBaseSats; totalWeight += m.BalanceBaseSats; } diff --git a/src/Services/RebalanceService.cs b/src/Services/RebalanceService.cs index a1a8eb7c..fc300ae9 100644 --- a/src/Services/RebalanceService.cs +++ b/src/Services/RebalanceService.cs @@ -114,7 +114,7 @@ public async Task RebalanceAsync(RebalanceRequest request, Cancellati "Target pubkey is required.", nameof(request.TargetPubkey)); - var node = await _nodeRepository.GetById(request.NodeId); + var node = await _nodeRepository.GetById(request.NodeId, includeRelatedData: false); if (node == null) throw new ArgumentException($"Node {request.NodeId} not found", nameof(request.NodeId)); @@ -129,7 +129,7 @@ public async Task RebalanceAsync(RebalanceRequest request, Cancellati ? sourceChannel.DestinationNodeId : sourceChannel.SourceNodeId; - var counterpartyPeer = await _nodeRepository.GetById(counterpartyPeerId); + var counterpartyPeer = await _nodeRepository.GetById(counterpartyPeerId, includeRelatedData: false); if (counterpartyPeer == null) throw new InvalidOperationException( $"Counterparty peer node {counterpartyPeerId} not found for source channel {request.SourceChannelId}"); @@ -202,7 +202,7 @@ public async Task ExecuteAsync(int rebalanceId, CancellationToken ct if (rebalance == null) throw new InvalidOperationException($"Rebalance {rebalanceId} not found"); - var node = rebalance.Node ?? await _nodeRepository.GetById(rebalance.NodeId); + var node = rebalance.Node ?? await _nodeRepository.GetById(rebalance.NodeId, includeRelatedData: false); if (node == null) throw new InvalidOperationException($"Node {rebalance.NodeId} not found for rebalance {rebalanceId}"); diff --git a/test/NodeGuard.Tests/Jobs/AutoRebalanceJobTests.cs b/test/NodeGuard.Tests/Jobs/AutoRebalanceJobTests.cs index 667c0bf8..9c82e8c1 100644 --- a/test/NodeGuard.Tests/Jobs/AutoRebalanceJobTests.cs +++ b/test/NodeGuard.Tests/Jobs/AutoRebalanceJobTests.cs @@ -23,6 +23,7 @@ using NodeGuard.Helpers; using NodeGuard.Services; using NodeGuard.Tests.Helpers; +using NodeGuard.Tests.Jobs; using Quartz; using Channel = NodeGuard.Data.Models.Channel; @@ -44,6 +45,7 @@ public class AutoRebalanceJobTests private readonly Mock _rebalanceService = new(); private readonly Mock _lightningService = new(); private readonly Mock _lightningClientService = new(); + private readonly Mock _auditService = new(); // The real snapshot service over the mocked repos/LND, so these tests still cover the // open-channel + routing-state filtering that feeds the job. @@ -53,6 +55,26 @@ private IRoutingEngineSnapshotService BuildSnapshotService() => _feeStateRepository.Object, _lightningClientService.Object); + /// A NodeGuard channel row the rebalancer will consider, opted in or not. + private static Channel Db(int id, ulong chanId, bool optIn) => new() + { + Id = id, ChanId = chanId, Status = Channel.ChannelStatus.Open, + IsAutoRebalanceEnabled = optIn, + FundingTx = $"tx{id}", FundingTxOutputIndex = 0, + }; + + /// The matching LND channel, with the balances that drive sizing. + private static Lnrpc.Channel Lnd(ulong chanId, long local, long remote, string peer) => new() + { + ChanId = chanId, Capacity = 20_000_000, LocalBalance = local, RemoteBalance = remote, + Active = true, Initiator = true, RemotePubkey = peer, + }; + + /// + /// A scope factory whose scope hands back , mirroring how the job + /// resolves a fresh IRebalanceService per dispatch so the payment doesn't run on the job's own + /// (soon-disposed) scope. + /// private AutoRebalanceJob BuildJob() => new( _logger.Object, @@ -61,21 +83,9 @@ private AutoRebalanceJob BuildJob() => _rebalanceRepository.Object, _rebalanceService.Object, BuildSnapshotService(), - _lightningService.Object); + _lightningService.Object, + _auditService.Object); - private static async Task WithEngine(bool enabled, Func body) - { - var prevEnabled = Constants.ROUTING_ENGINE_ENABLED; - Constants.ROUTING_ENGINE_ENABLED = enabled; - try - { - await body(); - } - finally - { - Constants.ROUTING_ENGINE_ENABLED = prevEnabled; - } - } /// @@ -150,7 +160,7 @@ public async Task Execute_DispatchesThePlannedRebalance() { ArrangeRebalancePair(sourceOptedIn: true); - await WithEngine(enabled: true, async () => + await RoutingEngineSwitch.WithEngine(enabled: true, async () => { await BuildJob().Execute(Mock.Of()); }); @@ -164,27 +174,34 @@ await WithEngine(enabled: true, async () => } [Fact] - public async Task Execute_DoesNotWaitForTheRebalanceToSettle() + public async Task Execute_AwaitsEachPayment_BeforeDispatchingTheNext() { - ArrangeRebalancePair(sourceOptedIn: true); - - // A payment that never resolves. Dispatch is fire-and-forget, so the run has to finish - // anyway; if it were awaited this would block until the test timed out. - var neverSettles = new TaskCompletionSource(); + ArrangeTwoRebalancePairs(maxRebalancesInFlight: 5); + + // Two payments we control. Everything else the job awaits is mocked with completed tasks, so + // Execute runs straight through and hands the task back only once it is parked on the first + // payment — which is what makes the assertions below deterministic rather than timing-based. + var first = new TaskCompletionSource(); + var second = new TaskCompletionSource(); + var started = 0; _rebalanceService.Setup(x => x.RebalanceAsync(It.IsAny(), It.IsAny())) - .Returns(neverSettles.Task); + .Returns(() => Interlocked.Increment(ref started) == 1 ? first.Task : second.Task); - await WithEngine(enabled: true, async () => + await RoutingEngineSwitch.WithEngine(enabled: true, async () => { var run = BuildJob().Execute(Mock.Of()); - var first = await Task.WhenAny(run, Task.Delay(TimeSpan.FromSeconds(5))); - Assert.Same(run, first); // the run must not block on the payment + + // Detached dispatch would have finished the run; concurrent dispatch would have started + // both payments. Being parked after exactly one is what "awaits each" means. + Assert.False(run.IsCompleted); + Assert.Equal(1, started); + + first.SetResult(new Rebalance()); + second.SetResult(new Rebalance()); + await run; + Assert.Equal(2, started); }); - - _rebalanceService.Verify(x => x.RebalanceAsync( - It.IsAny(), It.IsAny()), Times.Once); - Assert.False(neverSettles.Task.IsCompleted); // the payment is still outstanding } [Fact] @@ -192,7 +209,7 @@ public async Task Execute_KillSwitchOff_DoesNothing() { ArrangeRebalancePair(sourceOptedIn: true); - await WithEngine(enabled: false, async () => + await RoutingEngineSwitch.WithEngine(enabled: false, async () => { await BuildJob().Execute(Mock.Of()); }); @@ -210,7 +227,7 @@ public async Task Execute_SwapLiquidityFlagAloneDoesNotOptAChannelIn() // swap flag must not be drained. ArrangeRebalancePair(sourceOptedIn: false, sourceLiquidityFlag: true); - await WithEngine(enabled: true, async () => + await RoutingEngineSwitch.WithEngine(enabled: true, async () => { await BuildJob().Execute(Mock.Of()); }); @@ -224,7 +241,7 @@ await WithEngine(enabled: true, async () => /// on a node pinned to one in-flight rebalance so the second plan is dropped. Pairing is /// first-fit in classification order, so chan 1001 pairs with peerD1 and chan 1003 with peerD2. /// - private void ArrangeTwoRebalancePairs() + private void ArrangeTwoRebalancePairs(int maxRebalancesInFlight = 1) { var node = new Node { @@ -236,18 +253,12 @@ private void ArrangeTwoRebalancePairs() AutoRebalanceEnabled = true, RebalanceBudgetSats = 1_000_000, MaxRebalanceCostToEarnRatio = 0.5, - // Pinned, not inherited: this test asserts the drop path, so the cap has to be 1 here - // regardless of what ROUTING_ENGINE_REBALANCE_DEFAULT_MAX_IN_FLIGHT is set to. - MaxRebalancesInFlight = 1, + // Pinned, not inherited, so the cap tests don't depend on + // ROUTING_ENGINE_REBALANCE_DEFAULT_MAX_IN_FLIGHT. + MaxRebalancesInFlight = maxRebalancesInFlight, }; _nodeRepository.Setup(x => x.GetAllManagedByNodeGuard(false)).ReturnsAsync(new List { node }); - Channel Db(int id, ulong chanId, bool optIn) => new() - { - Id = id, ChanId = chanId, Status = Channel.ChannelStatus.Open, - IsAutoRebalanceEnabled = optIn, - FundingTx = $"tx{id}", FundingTxOutputIndex = 0, - }; _channelRepository.Setup(x => x.GetOpenChannels()).ReturnsAsync(new List { @@ -270,11 +281,6 @@ private void ArrangeTwoRebalancePairs() _rebalanceRepository.Setup(x => x.GetConsumedFeesSince(node.Id, It.IsAny())).ReturnsAsync(0L); _rebalanceRepository.Setup(x => x.GetInFlightByNode(node.Id)).ReturnsAsync(0); - Lnrpc.Channel Lnd(ulong chanId, long local, long remote, string peer) => new() - { - ChanId = chanId, Capacity = 20_000_000, LocalBalance = local, RemoteBalance = remote, - Active = true, Initiator = true, RemotePubkey = peer, - }; _lightningClientService .Setup(x => x.ListChannels(It.IsAny(), It.IsAny())) @@ -304,7 +310,7 @@ public async Task Execute_LogsPlansDroppedByTheInFlightCap() { ArrangeTwoRebalancePairs(); - await WithEngine(enabled: true, async () => + await RoutingEngineSwitch.WithEngine(enabled: true, async () => { await BuildJob().Execute(Mock.Of()); }); @@ -341,9 +347,6 @@ await WithEngine(enabled: true, async () => [Fact] public async Task Execute_PricesEveryChannelInOneRoundTrip() { - // Each GetChanInfo is an LND round-trip, so only channels whose rate drives a decision are - // priced. Chan 1003 is a fallback source (live local 0.70 but ema 0.60, so it never tripped - // the +0.15 trigger) and fallback sources are chosen by balance, never by cost. var node = new Node { Id = 20, @@ -356,11 +359,6 @@ public async Task Execute_PricesEveryChannelInOneRoundTrip() }; _nodeRepository.Setup(x => x.GetAllManagedByNodeGuard(false)).ReturnsAsync(new List { node }); - Channel Db(int id, ulong chanId, bool optIn) => new() - { - Id = id, ChanId = chanId, Status = Channel.ChannelStatus.Open, - IsAutoRebalanceEnabled = optIn, FundingTx = $"tx{id}", FundingTxOutputIndex = 0, - }; _channelRepository.Setup(x => x.GetOpenChannels()).ReturnsAsync(new List { Db(101, 1001, optIn: true), // detected source @@ -396,7 +394,7 @@ public async Task Execute_PricesEveryChannelInOneRoundTrip() _rebalanceService.Setup(x => x.RebalanceAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new Rebalance()); - await WithEngine(enabled: true, async () => + await RoutingEngineSwitch.WithEngine(enabled: true, async () => { await BuildJob().Execute(Mock.Of()); }); @@ -418,7 +416,7 @@ public async Task Execute_FeeReportUnavailable_SkipsTheNodeWithoutDispatching() _lightningService.Setup(x => x.GetLocalOutboundFeeRatesPpmAsync(It.IsAny())) .ReturnsAsync((Dictionary?)null); - await WithEngine(enabled: true, async () => + await RoutingEngineSwitch.WithEngine(enabled: true, async () => { await BuildJob().Execute(Mock.Of()); }); diff --git a/test/NodeGuard.Tests/Jobs/ChannelFeeOptimizerJobTests.cs b/test/NodeGuard.Tests/Jobs/ChannelFeeOptimizerJobTests.cs index 97189bdc..81257407 100644 --- a/test/NodeGuard.Tests/Jobs/ChannelFeeOptimizerJobTests.cs +++ b/test/NodeGuard.Tests/Jobs/ChannelFeeOptimizerJobTests.cs @@ -23,6 +23,7 @@ using NodeGuard.Helpers; using NodeGuard.Services; using NodeGuard.Tests.Helpers; +using NodeGuard.Tests.Jobs; using Quartz; using Channel = NodeGuard.Data.Models.Channel; @@ -145,19 +146,6 @@ private ChannelFeeOptimizerJob BuildJob() => BuildSnapshotService(), _lightningService.Object); - private static async Task WithEngine(bool enabled, Func body) - { - var prevEnabled = Constants.ROUTING_ENGINE_ENABLED; - Constants.ROUTING_ENGINE_ENABLED = enabled; - try - { - await body(); - } - finally - { - Constants.ROUTING_ENGINE_ENABLED = prevEnabled; - } - } [Fact] public async Task Execute_LiveNode_AppliesComputedPolicy() @@ -165,7 +153,7 @@ public async Task Execute_LiveNode_AppliesComputedPolicy() var node = BuildNode(); ArrangeSingleSinkChannel(node, inFlightRebalance: false); - await WithEngine(enabled: true, async () => + await RoutingEngineSwitch.WithEngine(enabled: true, async () => { await BuildJob().Execute(Mock.Of()); }); @@ -181,7 +169,7 @@ public async Task Execute_InFlightRebalance_SkipsChannelEntirely() var node = BuildNode(); ArrangeSingleSinkChannel(node, inFlightRebalance: true); - await WithEngine(enabled: true, async () => + await RoutingEngineSwitch.WithEngine(enabled: true, async () => { await BuildJob().Execute(Mock.Of()); }); @@ -199,7 +187,7 @@ public async Task Execute_KillSwitchOff_DoesNothing() var node = BuildNode(); ArrangeSingleSinkChannel(node, inFlightRebalance: false); - await WithEngine(enabled: false, async () => + await RoutingEngineSwitch.WithEngine(enabled: false, async () => { await BuildJob().Execute(Mock.Of()); }); @@ -410,7 +398,7 @@ public async Task Execute_ChannelSharedWithAnotherManagedNode_IsStillActuated() }, }); - await WithEngine(enabled: true, async () => + await RoutingEngineSwitch.WithEngine(enabled: true, async () => { await BuildJob().Execute(Mock.Of()); }); @@ -425,7 +413,7 @@ public async Task Execute_ColdStartFeeState_IsStampedWithTheActuatingNode() var node = BuildNode(); ArrangeSingleSinkChannel(node, inFlightRebalance: false); - await WithEngine(enabled: true, async () => + await RoutingEngineSwitch.WithEngine(enabled: true, async () => { await BuildJob().Execute(Mock.Of()); }); diff --git a/test/NodeGuard.Tests/Jobs/RoutingEngineCollection.cs b/test/NodeGuard.Tests/Jobs/RoutingEngineCollection.cs index c7d249e3..dac0062a 100644 --- a/test/NodeGuard.Tests/Jobs/RoutingEngineCollection.cs +++ b/test/NodeGuard.Tests/Jobs/RoutingEngineCollection.cs @@ -17,6 +17,8 @@ * */ +using NodeGuard.Helpers; + namespace NodeGuard.Tests.Jobs; /// @@ -28,3 +30,28 @@ namespace NodeGuard.Tests.Jobs; public class RoutingEngineCollection { } + +/// +/// Helpers shared by the routing-engine job tests. +/// +public static class RoutingEngineSwitch +{ + /// + /// Runs with the global kill switch forced to , + /// restoring the previous value afterwards even if the body throws. One copy, because a missed + /// restore leaks static state into every other class in the collection. + /// + public static async Task WithEngine(bool enabled, Func body) + { + var prevEnabled = Constants.ROUTING_ENGINE_ENABLED; + Constants.ROUTING_ENGINE_ENABLED = enabled; + try + { + await body(); + } + finally + { + Constants.ROUTING_ENGINE_ENABLED = prevEnabled; + } + } +} diff --git a/test/NodeGuard.Tests/Services/RebalanceInitiatorServiceTests.cs b/test/NodeGuard.Tests/Services/RebalanceInitiatorServiceTests.cs index e40c78a7..e273dcac 100644 --- a/test/NodeGuard.Tests/Services/RebalanceInitiatorServiceTests.cs +++ b/test/NodeGuard.Tests/Services/RebalanceInitiatorServiceTests.cs @@ -33,7 +33,7 @@ public class RebalanceInitiatorServiceTests private static ChannelSignal Chan( int id, string peer, long local, long remote, double ema, double target, bool active = true, bool optIn = true) - => new(id, (ulong)id, peer, local + remote, local, remote, ema, target, active, optIn); + => new(id, (ulong)id, peer, local, remote, ema, target, active, optIn); // ── Classify: sources ─────────────────────────────────────────────────────────────── @@ -137,7 +137,7 @@ public void BuildPlans_TakesTheFirstAvailableSource_RegardlessOfEarnRate() Chan(1, "dear", 800_000, 200_000, 0.80, 0.50), Chan(2, "cheap", 800_000, 200_000, 0.80, 0.50), Chan(3, "dest", 200_000, 800_000, 0.20, 0.50)); - var earn = new Dictionary { [1] = 2000, [2] = 50, [3] = 2500 }; + var earn = new Dictionary { [1] = 2000, [2] = 50, [3] = 2500 }; var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); @@ -152,7 +152,7 @@ public void BuildPlans_ProfitGate_SetsMaxFeePctFromDestEarnRate() var classification = Classify( Chan(1, "cheap", 800_000, 200_000, 0.80, 0.50), Chan(3, "dest", 200_000, 800_000, 0.20, 0.50)); - var earn = new Dictionary { [1] = 50, [3] = 2500 }; + var earn = new Dictionary { [1] = 50, [3] = 2500 }; var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); @@ -167,7 +167,7 @@ public void BuildPlans_ZeroEarnDestination_IsSkipped() var classification = Classify( Chan(1, "cheap", 800_000, 200_000, 0.80, 0.50), Chan(3, "dest", 200_000, 800_000, 0.20, 0.50)); - var earn = new Dictionary { [1] = 50, [3] = 0 }; // dest earns nothing + var earn = new Dictionary { [1] = 50, [3] = 0 }; // dest earns nothing RebalanceInitiatorService.BuildPlans(classification, earn, Tunables).Should().BeEmpty(); } @@ -178,7 +178,7 @@ public void BuildPlans_DestinationWithoutKnownEarnRate_IsSkipped() var classification = Classify( Chan(1, "cheap", 800_000, 200_000, 0.80, 0.50), Chan(3, "dest", 200_000, 800_000, 0.20, 0.50)); - var earn = new Dictionary { [1] = 50 }; // no entry for the dest channel + var earn = new Dictionary { [1] = 50 }; // no entry for the dest channel RebalanceInitiatorService.BuildPlans(classification, earn, Tunables).Should().BeEmpty(); } @@ -193,7 +193,7 @@ public void BuildPlans_AvoidsSamePeerPairing() Chan(1, "A", 800_000, 200_000, 0.80, 0.50), // source, peer A, excess 300_000 Chan(2, "A", 50_000, 2_950_000, 0.02, 0.50), // pulls peer A aggregate too-remote Chan(3, "B", 200_000, 800_000, 0.20, 0.50)); // destination, peer B - var earn = new Dictionary { [1] = 4000, [2] = 4000, [3] = 2500 }; + var earn = new Dictionary { [1] = 4000, [2] = 4000, [3] = 2500 }; var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); @@ -209,7 +209,7 @@ public void BuildPlans_SizesToMinOfExcessAndDeficit() var classification = Classify( Chan(1, "src", 800_000, 200_000, 0.80, 0.50), Chan(3, "dest", 100_000, 900_000, 0.10, 0.50)); - var earn = new Dictionary { [1] = 50, [3] = 2500 }; + var earn = new Dictionary { [1] = 50, [3] = 2500 }; var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); @@ -224,7 +224,7 @@ public void BuildPlans_ClampsAmountToMax() var classification = Classify( Chan(1, "src", 9_000_000, 3_000_000, 0.75, 0.25), // excess = 9M - 3M = 6M Chan(3, "dest", 2_000_000, 18_000_000, 0.10, 0.50)); // deficit = 10M - 2M = 8M - var earn = new Dictionary { [1] = 50, [3] = 2500 }; + var earn = new Dictionary { [1] = 50, [3] = 2500 }; var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); @@ -232,21 +232,6 @@ public void BuildPlans_ClampsAmountToMax() plans[0].AmountSats.Should().Be(5_000_000); } - [Fact] - public void BuildPlans_SkipsWhenSourceExcessBelowMin() - { - // Chan 1 qualifies as a source by the smoothed signal (ema 0.80 → d 0.30) but its LIVE excess - // is only 5_000 sats — below the 10_000 min — so it can't feed the depleted destination. - var classification = Classify( - Chan(1, "src", 505_000, 495_000, 0.80, 0.50), // source, live excess = 505_000 - 500_000 = 5_000 - Chan(3, "dest", 100_000, 900_000, 0.10, 0.50)); - var earn = new Dictionary { [1] = 50, [3] = 2500 }; - - classification.Sources.Should().ContainSingle(); - classification.Sources[0].ExcessSats.Should().Be(5_000); - RebalanceInitiatorService.BuildPlans(classification, earn, Tunables).Should().BeEmpty(); - } - [Fact] public void BuildPlans_UsesEachSourceAtMostOncePerRun() { @@ -255,7 +240,7 @@ public void BuildPlans_UsesEachSourceAtMostOncePerRun() Chan(1, "src", 5_000_000, 1_000_000, 0.83, 0.50), Chan(3, "destA", 200_000, 800_000, 0.20, 0.50), Chan(4, "destB", 200_000, 800_000, 0.20, 0.50)); - var earn = new Dictionary { [1] = 50, [3] = 2500, [4] = 2400 }; + var earn = new Dictionary { [1] = 50, [3] = 2500, [4] = 2400 }; var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); @@ -272,7 +257,7 @@ public void BuildPlans_RespectsMaxInitiationsCap() Chan(2, "srcB", 800_000, 200_000, 0.80, 0.50), Chan(3, "destA", 200_000, 800_000, 0.20, 0.50), Chan(4, "destB", 200_000, 800_000, 0.20, 0.50)); - var earn = new Dictionary { [1] = 50, [2] = 60, [3] = 2500, [4] = 2400 }; + var earn = new Dictionary { [1] = 50, [2] = 60, [3] = 2500, [4] = 2400 }; RebalanceInitiatorService.BuildPlans(classification, earn, tunables).Should().ContainSingle(); } @@ -286,7 +271,7 @@ public void BuildPlans_WeightsDestinationEarnRateByBalanceBase() Chan(1, "src", 800_000, 200_000, 0.80, 0.50), Chan(3, "dest", 200_000, 800_000, 0.20, 0.50), // base 1M Chan(4, "dest", 600_000, 2_400_000, 0.20, 0.50)); // base 3M - var earn = new Dictionary { [1] = 50, [3] = 1000, [4] = 3000 }; + var earn = new Dictionary { [1] = 50, [3] = 1000, [4] = 3000 }; var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); @@ -310,13 +295,39 @@ public void Classify_ChannelInsideTheDeadband_LandsInBothFallbackPools() } [Fact] - public void BuildPlans_SourceWithNoQualifyingDestination_DrainsIntoTheFirstFallbackPeerThatFits() + public void Classify_LendableBelowMinAmount_IsNotAFallbackSource() + { + // Inside the deadband, so not a detected source — and it can only lend down to 0.35 + // (355_000 - 350_000 = 5_000), below the 10_000 floor, so it isn't worth a hop as a source. + var result = Classify(Chan(1, "peerA", local: 355_000, remote: 645_000, ema: 0.355, target: 0.50)); + + result.Sources.Should().BeEmpty(); + result.FallbackSources.Should().BeEmpty("5_000 lendable is below MinAmountSats"); + // Still a usable counterparty in the other direction: 650_000 - 355_000 = 295_000 to absorb. + result.FallbackDestinations.Should().ContainSingle().Which.DeficitSats.Should().Be(295_000); + } + + [Fact] + public void Classify_AbsorbableBelowMinAmount_IsNotAFallbackDestination() + { + // Mirror image: inside the deadband, and it can only absorb up to 0.65 + // (650_000 - 645_000 = 5_000), below the floor. + var result = Classify(Chan(1, "peerA", local: 645_000, remote: 355_000, ema: 0.645, target: 0.50)); + + result.Destinations.Should().BeEmpty(); + result.FallbackDestinations.Should().BeEmpty("5_000 absorbable is below MinAmountSats"); + // Still lendable: 645_000 - 350_000 = 295_000. + result.FallbackSources.Should().ContainSingle().Which.ExcessSats.Should().Be(295_000); + } + + [Fact] + public void BuildPlans_SourceWithNoQualifyingDestination_DrainsIntoTheRoomiestFallbackPeer() { var classification = Classify( Chan(1, "src", 800_000, 200_000, 0.80, 0.50), // source, excess 300_000 Chan(2, "peerB", 500_000, 500_000, 0.50, 0.50), // fallback dest, absorbable 150_000 Chan(3, "peerC", 450_000, 550_000, 0.45, 0.50)); // fallback dest, absorbable 200_000 - var earn = new Dictionary { [1] = 50, [2] = 2000, [3] = 2000 }; + var earn = new Dictionary { [1] = 50, [2] = 2000, [3] = 2000 }; classification.Destinations.Should().BeEmpty("neither peer tripped the -0.15 trigger"); @@ -324,35 +335,35 @@ public void BuildPlans_SourceWithNoQualifyingDestination_DrainsIntoTheFirstFallb plans.Should().ContainSingle(); plans[0].SourceChannelId.Should().Be(1); - // peerB, not the emptier peerC: pass 2 takes the first fallback destination that yields a - // plan, and has no preference for the one with the most room. - plans[0].DestinationPeerPubKey.Should().Be("peerB"); + // peerC over peerB: fallback destinations are ranked by the room they have (200_000 vs + // 150_000), so the deeper one absorbs first. + plans[0].DestinationPeerPubKey.Should().Be("peerC"); plans[0].IsFallbackPairing.Should().BeTrue(); - // Capped by peerB's room up to target + deadband (650_000 - 500_000), NOT the source's + // Capped by peerC's room up to target + deadband (650_000 - 450_000), NOT the source's // full 300_000 excess — refilling must never turn the destination into next cycle's source. - plans[0].AmountSats.Should().Be(150_000); + plans[0].AmountSats.Should().Be(200_000); } [Fact] - public void BuildPlans_DestinationWithNoQualifyingSource_IsFundedByTheFirstFallbackChannel() + public void BuildPlans_DestinationWithNoQualifyingSource_IsFundedByTheLargestFallbackChannel() { var classification = Classify( Chan(1, "peerA", 600_000, 400_000, 0.60, 0.50), // fallback source, lendable 250_000 Chan(2, "peerB", 900_000, 100_000, 0.55, 0.50), // fallback source, lendable 550_000 Chan(3, "dest", 100_000, 900_000, 0.10, 0.50)); // destination, deficit 400_000 - var earn = new Dictionary { [1] = 50, [2] = 60, [3] = 2500 }; + var earn = new Dictionary { [1] = 50, [2] = 60, [3] = 2500 }; classification.Sources.Should().BeEmpty("neither peer tripped the +0.15 trigger"); var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); plans.Should().ContainSingle(); - // peerA, not the fuller peerB: the fallback pool is drawn from in classification order. - plans[0].SourceChannelId.Should().Be(1); + // peerB over peerA: the fallback pool is ranked by how much it can lend (550_000 vs 250_000). + plans[0].SourceChannelId.Should().Be(2); plans[0].DestinationPeerPubKey.Should().Be("dest"); plans[0].IsFallbackPairing.Should().BeTrue(); - // Bounded by what peerA may lend (250_000), not the destination's full 400_000 deficit. - plans[0].AmountSats.Should().Be(250_000); + // peerB can lend 550_000, so the destination's 400_000 deficit is what binds here. + plans[0].AmountSats.Should().Be(400_000); } [Fact] @@ -361,7 +372,7 @@ public void BuildPlans_FallbackSourceIsNotDrainedBelowItsOwnDeadband() var classification = Classify( Chan(1, "peerA", 400_000, 600_000, 0.40, 0.50), // fallback source: lendable to 0.35 = 50_000 Chan(2, "dest", 0, 1_000_000, 0.00, 0.50)); // destination, deficit 500_000 - var earn = new Dictionary { [1] = 50, [2] = 2500 }; + var earn = new Dictionary { [1] = 50, [2] = 2500 }; var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); @@ -378,7 +389,7 @@ public void BuildPlans_FallbackPairingIsStillProfitGated() Chan(1, "src", 800_000, 200_000, 0.80, 0.50), Chan(2, "peerB", 500_000, 500_000, 0.50, 0.50)); // The only available destination earns nothing, so there is no margin to pay a route with. - var earn = new Dictionary { [1] = 50, [2] = 0 }; + var earn = new Dictionary { [1] = 50, [2] = 0 }; RebalanceInitiatorService.BuildPlans(classification, earn, Tunables).Should().BeEmpty(); } @@ -394,7 +405,7 @@ public void BuildPlans_RefillsAFallbackDestinationAtMostOncePerRun() Chan(1, "srcA", 800_000, 200_000, 0.80, 0.50), // detected source, excess 300_000 Chan(2, "srcB", 800_000, 200_000, 0.80, 0.50), // detected source, excess 300_000 Chan(3, "peerX", 500_000, 500_000, 0.50, 0.50)); // fallback dest, absorbable 150_000 - var earn = new Dictionary { [1] = 50, [2] = 50, [3] = 2000 }; + var earn = new Dictionary { [1] = 50, [2] = 50, [3] = 2000 }; classification.Destinations.Should().BeEmpty("peerX did not trip the -0.15 trigger"); classification.FallbackDestinations.Should().ContainSingle() @@ -415,7 +426,7 @@ public void BuildPlans_PrefersAQualifyingSourceOverAFallbackOne() Chan(1, "src", 800_000, 200_000, 0.80, 0.50), // qualifying source Chan(2, "peerB", 950_000, 50_000, 0.55, 0.50), // fallback source, far more to lend Chan(3, "dest", 100_000, 900_000, 0.10, 0.50)); // destination - var earn = new Dictionary { [1] = 50, [2] = 60, [3] = 2500 }; + var earn = new Dictionary { [1] = 50, [2] = 60, [3] = 2500 }; var plans = RebalanceInitiatorService.BuildPlans(classification, earn, Tunables); From c0b83b2199dd9a4f489a8b7bfc07415f25014239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:41:03 +0200 Subject: [PATCH 15/21] Validate human PSBT approvals against template (#557) Add PsbtApprovalValidator gate to ChannelOperationRequestPSBT and WalletWithdrawalRequestPSBT repositories to reject malformed or duplicate human signatures before they reach the signing path. - Wire ValidateApproval into AddAsync/AddRangeAsync for channel operation PSBTs, using SIGHASH_NONE to match ChannelRequests.razor. - Server-generated rows (template, internal wallet, finalised) bypass validation; human approvals are checked against the request's template PSBT and existing signatures. - Include ChannelOperationRequestPsbts when loading the request so the validator has the full signature set. - Log rejections with the request id and reason, and surface the error to the caller. - Add BitcoinService tests covering the duplicate-template-row and related rejection scenarios on the withdrawal signing path. --- .../ChannelOperationRequestPSBTRepository.cs | 71 ++- .../WalletWithdrawalRequestPsbtRepository.cs | 75 +++- src/Helpers/PsbtApprovalValidator.cs | 143 ++++++ src/Pages/Withdrawals.razor | 21 +- src/Services/BitcoinService.cs | 38 +- src/Shared/PSBTSign.razor | 57 +-- src/Shared/TransferFundsModal.razor | 19 +- .../E2E/PsbtApprovalBindingE2ETests.cs | 410 ++++++++++++++++++ .../Helpers/PsbtApprovalValidatorTests.cs | 270 ++++++++++++ .../Services/BitcoinServiceTests.cs | 324 +++++++++++++- test/NodeGuard.Tests/Shared/PSBTSignTests.cs | 153 +++++++ 11 files changed, 1500 insertions(+), 81 deletions(-) create mode 100644 src/Helpers/PsbtApprovalValidator.cs create mode 100644 test/NodeGuard.Tests/E2E/PsbtApprovalBindingE2ETests.cs create mode 100644 test/NodeGuard.Tests/Helpers/PsbtApprovalValidatorTests.cs create mode 100644 test/NodeGuard.Tests/Shared/PSBTSignTests.cs diff --git a/src/Data/Repositories/ChannelOperationRequestPSBTRepository.cs b/src/Data/Repositories/ChannelOperationRequestPSBTRepository.cs index 8086f324..c4267be9 100644 --- a/src/Data/Repositories/ChannelOperationRequestPSBTRepository.cs +++ b/src/Data/Repositories/ChannelOperationRequestPSBTRepository.cs @@ -20,6 +20,8 @@ using NodeGuard.Data.Models; using NodeGuard.Data.Repositories.Interfaces; using Microsoft.EntityFrameworkCore; +using NBitcoin; +using NodeGuard.Helpers; namespace NodeGuard.Data.Repositories { @@ -58,8 +60,21 @@ public async Task> GetAll() //We set the request status to PSBTSignaturesPending var request = await - applicationDbContext.ChannelOperationRequests.FirstOrDefaultAsync(x => - x.Id == type.ChannelOperationRequestId); + applicationDbContext.ChannelOperationRequests + .Include(x => x.ChannelOperationRequestPsbts) + .FirstOrDefaultAsync(x => x.Id == type.ChannelOperationRequestId); + + // See WalletWithdrawalRequestPsbtRepository.ValidateApproval. Channel operations are signed with + // SIGHASH_NONE (ChannelRequests.razor passes SigHashMode="SigHash.None"). + var validation = ValidateApproval(request, type); + if (!validation.IsValid) + { + _logger.LogWarning("Rejected PSBT for channel operation request {RequestId}: {Reason}", + type.ChannelOperationRequestId, validation.Error); + + return (false, validation.Error); + } + try { if (request != null && !type.IsTemplatePSBT) @@ -83,6 +98,23 @@ public async Task> GetAll() { await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync(); + // Same gate as AddAsync — it writes to the same table. + foreach (var psbt in type) + { + var request = await applicationDbContext.ChannelOperationRequests + .Include(x => x.ChannelOperationRequestPsbts) + .FirstOrDefaultAsync(x => x.Id == psbt.ChannelOperationRequestId); + + var validation = ValidateApproval(request, psbt); + if (!validation.IsValid) + { + _logger.LogWarning("Rejected PSBT for channel operation request {RequestId}: {Reason}", + psbt.ChannelOperationRequestId, validation.Error); + + return (false, validation.Error); + } + } + return await _repository.AddRangeAsync(type, applicationDbContext); } @@ -100,6 +132,41 @@ public async Task> GetAll() return _repository.RemoveRange(types, applicationDbContext); } + /// + /// Validates a human approval against the request's template PSBT. Server-generated rows (template, + /// internal wallet signature, finalised PSBT) pass through. + /// + private static PsbtApprovalValidator.Result ValidateApproval(ChannelOperationRequest? request, + ChannelOperationRequestPSBT type) + { + if (type.IsTemplatePSBT || type.IsInternalWalletPSBT || type.IsFinalisedPSBT) + { + return PsbtApprovalValidator.Result.Ok; + } + + if (request == null) + { + return PsbtApprovalValidator.Result.Fail("The channel operation request could not be found."); + } + + var existing = request.ChannelOperationRequestPsbts? + .Where(x => !x.IsTemplatePSBT && !x.IsInternalWalletPSBT && !x.IsFinalisedPSBT) + .Select(x => x.PSBT) + .ToList() ?? new List(); + + var template = request.ChannelOperationRequestPsbts? + .FirstOrDefault(x => x.IsTemplatePSBT)?.PSBT; + + if (string.IsNullOrWhiteSpace(template)) + { + return PsbtApprovalValidator.Result.Fail( + "This request has no template PSBT to validate the signature against."); + } + + return PsbtApprovalValidator.Validate(template, type.PSBT, SigHash.None, + CurrentNetworkHelper.GetCurrentNetwork(), existing); + } + public (bool, string?) Update(ChannelOperationRequestPSBT type) { using var applicationDbContext = _dbContextFactory.CreateDbContext(); diff --git a/src/Data/Repositories/WalletWithdrawalRequestPsbtRepository.cs b/src/Data/Repositories/WalletWithdrawalRequestPsbtRepository.cs index d1969ef1..7bcb9a97 100644 --- a/src/Data/Repositories/WalletWithdrawalRequestPsbtRepository.cs +++ b/src/Data/Repositories/WalletWithdrawalRequestPsbtRepository.cs @@ -20,6 +20,8 @@ using NodeGuard.Data.Models; using NodeGuard.Data.Repositories.Interfaces; using Microsoft.EntityFrameworkCore; +using NBitcoin; +using NodeGuard.Helpers; namespace NodeGuard.Data.Repositories { @@ -58,8 +60,23 @@ public async Task> GetAll() //We set the request status to PSBTSignaturesPending var request = await - applicationDbContext.WalletWithdrawalRequests.FirstOrDefaultAsync(x => - x.Id == type.WalletWithdrawalRequestId); + applicationDbContext.WalletWithdrawalRequests + .Include(x => x.WalletWithdrawalRequestPSBTs) + .FirstOrDefaultAsync(x => x.Id == type.WalletWithdrawalRequestId); + + // An approval is untrusted input: the approver pastes base64 they produced offline. It must be + // proven to describe the transaction this request was actually raised for BEFORE it is stored and + // counted toward the signature threshold. Without this, a keyholder can substitute a transaction + // paying themselves and have NodeGuard co-sign it. + var validation = ValidateApproval(request, type); + if (!validation.IsValid) + { + _logger.LogWarning("Rejected PSBT for withdrawal request {RequestId}: {Reason}", + type.WalletWithdrawalRequestId, validation.Error); + + return (false, validation.Error); + } + try { if (request != null && !type.IsTemplatePSBT ) @@ -83,9 +100,63 @@ public async Task> GetAll() { await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync(); + // Currently unused, but it writes to the same table, so it gets the same gate rather than being + // left as a way to store an unvalidated approval later on. + foreach (var psbt in type) + { + var request = await applicationDbContext.WalletWithdrawalRequests + .Include(x => x.WalletWithdrawalRequestPSBTs) + .FirstOrDefaultAsync(x => x.Id == psbt.WalletWithdrawalRequestId); + + var validation = ValidateApproval(request, psbt); + if (!validation.IsValid) + { + _logger.LogWarning("Rejected PSBT for withdrawal request {RequestId}: {Reason}", + psbt.WalletWithdrawalRequestId, validation.Error); + + return (false, validation.Error); + } + } + return await _repository.AddRangeAsync(type, applicationDbContext); } + /// + /// Validates a human approval against the request's template PSBT. Rows that are not human approvals — + /// the template itself, NodeGuard's own internal-wallet signature, and the finalised PSBT — are + /// generated server side rather than submitted, so they pass through. + /// + private static PsbtApprovalValidator.Result ValidateApproval(WalletWithdrawalRequest? request, + WalletWithdrawalRequestPSBT type) + { + if (type.IsTemplatePSBT || type.IsInternalWalletPSBT || type.IsFinalisedPSBT) + { + return PsbtApprovalValidator.Result.Ok; + } + + if (request == null) + { + return PsbtApprovalValidator.Result.Fail("The withdrawal request could not be found."); + } + + var existing = request.WalletWithdrawalRequestPSBTs? + .Where(x => !x.IsTemplatePSBT && !x.IsInternalWalletPSBT && !x.IsFinalisedPSBT) + .Select(x => x.PSBT) + .ToList() ?? new List(); + + var template = request.WalletWithdrawalRequestPSBTs? + .FirstOrDefault(x => x.IsTemplatePSBT)?.PSBT; + + if (string.IsNullOrWhiteSpace(template)) + { + return PsbtApprovalValidator.Result.Fail( + "This request has no template PSBT to validate the signature against."); + } + + return PsbtApprovalValidator.Validate(template, type.PSBT, SigHash.All, + CurrentNetworkHelper.GetCurrentNetwork(), existing); + } + public (bool, string?) Remove(WalletWithdrawalRequestPSBT type) { using var applicationDbContext = _dbContextFactory.CreateDbContext(); diff --git a/src/Helpers/PsbtApprovalValidator.cs b/src/Helpers/PsbtApprovalValidator.cs new file mode 100644 index 00000000..668829d2 --- /dev/null +++ b/src/Helpers/PsbtApprovalValidator.cs @@ -0,0 +1,143 @@ +/* + * 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 NBitcoin; + +namespace NodeGuard.Helpers; + +/// +/// Validates an approver-submitted PSBT against the template PSBT the request was raised for. +/// +/// This exists because an approval is inherently untrusted input: the approver copies the template, signs it +/// offline in their own wallet or hardware device, and pastes base64 back into a free-text field. The only +/// thing that makes such an approval meaningful is proving it describes the transaction that was actually +/// approved. +/// +/// It replaces validation that previously lived inside PSBTSign.razor and compared the submitted PSBT against +/// ITSELF — both sides were parsed from the same argument — which made the txid and UTXO checks tautologies +/// that could never fail. Note that PSBTSign runs server side (this is Blazor Server), so the original defect +/// was not "the check was only in the browser"; the server-side check existed and was simply wrong. Being a +/// plain class, this can additionally be enforced in the repositories, which is the better home: the +/// repository is the persistence boundary every path must cross, and it loads the template from the database +/// by request id rather than trusting a component parameter a caller might leave unset, stale, or pointed at +/// a different request. +/// +public static class PsbtApprovalValidator +{ + public record Result(bool IsValid, string? Error) + { + public static Result Ok { get; } = new(true, null); + + public static Result Fail(string error) => new(false, error); + } + + /// + /// Validates a submitted approval PSBT. + /// + /// The template NodeGuard generated for this request. + /// What the approver submitted. + /// + /// Sighash the operation requires — SigHash.All for withdrawals, SigHash.None for channel operations. + /// + /// Network to parse against. + /// + /// PSBTs already stored as approvals of this request, if any. When supplied, a submission carrying the + /// same set of signatures as an existing approval is rejected, so one signer cannot advance the threshold + /// by submitting the same signature twice. The comparison is over signing public keys rather than bytes, + /// so re-serializing the PSBT — or re-signing with a hardware wallet that uses randomized nonces — does + /// not evade it. + /// + public static Result Validate(string? templatePsbtBase64, string? submittedPsbtBase64, + SigHash expectedSigHash, Network network, IEnumerable? existingApprovals = null) + { + if (string.IsNullOrWhiteSpace(submittedPsbtBase64) + || !PSBT.TryParse(submittedPsbtBase64, network, out var submitted)) + { + return Result.Fail("Invalid PSBT, it could not be parsed."); + } + + if (string.IsNullOrWhiteSpace(templatePsbtBase64) + || !PSBT.TryParse(templatePsbtBase64, network, out var template)) + { + return Result.Fail("Invalid template PSBT, it could not be parsed."); + } + + // THE check. The submitted PSBT must describe the very transaction that was approved — same inputs, + // same outputs, same amounts, same destinations. Because NodeGuard builds the template itself from + // the request's destinations, matching the template's transaction hash transitively guarantees the + // approved destinations and amounts. Everything below is defence in depth. + if (submitted.GetGlobalTransaction().GetHash() != template.GetGlobalTransaction().GetHash()) + { + return Result.Fail( + "Invalid PSBT, the transaction does not match the one this request was created for."); + } + + var templateOutpoints = template.Inputs.Select(x => x.PrevOut).ToHashSet(); + var submittedOutpoints = submitted.Inputs.Select(x => x.PrevOut).ToHashSet(); + if (!templateOutpoints.SetEquals(submittedOutpoints)) + { + return Result.Fail("Invalid PSBT, the UTXOs do not match the ones this request was created for."); + } + + if (!submitted.Inputs.All(x => x.PartialSigs.Any())) + { + return Result.Fail($"Invalid PSBT, every input must be signed with Sighash: {expectedSigHash}."); + } + + // Note ".All" at both levels. The original check was ".Any(input => input.PartialSigs.All(...))", so + // a single conforming input satisfied the entire PSBT. + if (!submitted.Inputs.All(x => x.PartialSigs.All(y => y.Value.SigHash == expectedSigHash))) + { + return Result.Fail($"Invalid PSBT, every signature must use Sighash: {expectedSigHash}."); + } + + if (existingApprovals is not null) + { + var submittedSignatures = SignatureFingerprint(submitted); + + foreach (var existing in existingApprovals) + { + if (!PSBT.TryParse(existing, network, out var existingPsbt)) continue; + + if (SignatureFingerprint(existingPsbt).SetEquals(submittedSignatures)) + { + return Result.Fail("This request has already been signed with that key."); + } + } + } + + return Result.Ok; + } + + /// + /// Convenience overload for UI validators, which expect an error string ("" meaning valid). + /// + public static string ValidateForDisplay(string? templatePsbtBase64, string? submittedPsbtBase64, + SigHash expectedSigHash, Network network) + => Validate(templatePsbtBase64, submittedPsbtBase64, expectedSigHash, network).Error ?? string.Empty; + + /// + /// Identifies WHICH signatures a PSBT carries, as a set of "outpoint:pubkey" pairs. Two submissions with + /// the same fingerprint contribute the same signing authority however their bytes differ. + /// + private static HashSet SignatureFingerprint(PSBT psbt) + => psbt.Inputs + .SelectMany(input => input.PartialSigs.Select(sig => $"{input.PrevOut}:{sig.Key}")) + .ToHashSet(); +} diff --git a/src/Pages/Withdrawals.razor b/src/Pages/Withdrawals.razor index 8ac8c92f..b04f18d3 100644 --- a/src/Pages/Withdrawals.razor +++ b/src/Pages/Withdrawals.razor @@ -1437,20 +1437,13 @@ await CoinSelectionService.LockUTXOs(_selectedUTXOs, _selectedRequest, BitcoinRequestType.WalletWithdrawal); } - var templatePsbt = await BitcoinService.GenerateTemplatePSBT(_selectedRequest); - - var walletWithdrawalRequestPsbt = new WalletWithdrawalRequestPSBT() - { - WalletWithdrawalRequestId = _selectedRequest.Id, - PSBT = templatePsbt.ToBase64(), - SignerId = null, - }; - - var addResult = await WalletWithdrawalRequestPsbtRepository.AddAsync(walletWithdrawalRequestPsbt); - if (!addResult.Item1) - { - throw new ShowToUserException("Error while saving the signature"); - } + // GenerateTemplatePSBT persists the template row itself (tagged IsTemplatePSBT = true), so + // nothing further needs storing here. This used to store a SECOND copy of the same PSBT with + // IsTemplatePSBT left at its default false, which NumberOfSignaturesCollected then counted as + // a human signature — inflating the collected count by one on every request. Tagging that copy + // instead is not an option: PerformWithdrawal selects the template with + // .Single(x => x.IsTemplatePSBT), which throws when two exist. + await BitcoinService.GenerateTemplatePSBT(_selectedRequest); ToastService.ShowSuccess("Signature collected"); diff --git a/src/Services/BitcoinService.cs b/src/Services/BitcoinService.cs index 7c634d4a..f106ac25 100644 --- a/src/Services/BitcoinService.cs +++ b/src/Services/BitcoinService.cs @@ -408,6 +408,35 @@ public async Task PerformWithdrawal(WalletWithdrawalRequest walletWithdrawalRequ } + // Defence in depth: approvals were bound to the template at insert, but the COMBINED PSBT is + // what actually gets signed and broadcast, so re-verify it here, immediately before the + // internal wallet applies its signature. Matching the template's txid transitively guarantees + // the destinations and amounts are those the request was approved for, since NodeGuard built + // that template itself from WalletWithdrawalRequestDestinations. + var templatePsbtString = walletWithdrawalRequest.WalletWithdrawalRequestPSBTs + ?.FirstOrDefault(x => x.IsTemplatePSBT)?.PSBT; + + if (string.IsNullOrWhiteSpace(templatePsbtString)) + { + var noTemplate = + $"No template PSBT found for withdrawal request:{walletWithdrawalRequest.Id}, refusing to sign"; + _logger.LogError(noTemplate); + + throw new ArgumentException(noTemplate); + } + + var templatePsbt = PSBT.Parse(templatePsbtString, CurrentNetworkHelper.GetCurrentNetwork()); + if (psbtToSign.GetGlobalTransaction().GetHash() != + templatePsbt.GetGlobalTransaction().GetHash()) + { + var mismatch = + $"The PSBT to be signed for withdrawal request:{walletWithdrawalRequest.Id} does not " + + "match the approved transaction, refusing to sign"; + _logger.LogError(mismatch); + + throw new ArgumentException(mismatch); + } + var derivationStrategyBase = walletWithdrawalRequest.Wallet.GetDerivationStrategy(); PSBT? signedCombinedPSBT = null; @@ -458,7 +487,14 @@ public async Task PerformWithdrawal(WalletWithdrawalRequest walletWithdrawalRequ var transactionCheckResult = tx.Check(); if (transactionCheckResult != TransactionCheckResult.Success) { - _logger.LogError("Invalid tx check reason: {Reason}", transactionCheckResult.Humanize()); + // This used to log and fall through to BroadcastAsync. A transaction failing its own + // sanity check must never be broadcast; the channel-open path already aborts here. + var invalidTx = + $"Invalid tx for withdrawal request:{walletWithdrawalRequest.Id}, " + + $"reason: {transactionCheckResult.Humanize()}"; + _logger.LogError(invalidTx); + + throw new ArgumentException(invalidTx, nameof(tx)); } var node = (await _nodeRepository.GetAllManagedByNodeGuard()).FirstOrDefault(); diff --git a/src/Shared/PSBTSign.razor b/src/Shared/PSBTSign.razor index 76dc927e..9411823b 100644 --- a/src/Shared/PSBTSign.razor +++ b/src/Shared/PSBTSign.razor @@ -140,53 +140,20 @@ } } + /// + /// Validates the pasted PSBT against the TEMPLATE this request was created for. + /// + /// Previously both sides of the comparison were parsed from the pasted string, so the txid and UTXO checks + /// compared it against itself and could never fail. The logic now lives in PsbtApprovalValidator, which is + /// also enforced server side in the PSBT repositories — this call gives the approver immediate feedback, + /// but the repositories are the boundary that actually holds. + /// private string ValidatePSBT(string psbtBase64) { - var errorText =string.Empty; - if (psbtBase64 != null && PSBT.TryParse(psbtBase64, CurrentNetworkHelper.GetCurrentNetwork(), out var templatePSBT)) - { - if (PSBT.TryParse(psbtBase64, CurrentNetworkHelper.GetCurrentNetwork(), out var parsedPSBT)) - { - var templateInputsOutpoints = templatePSBT.Inputs.Select(x => x.PrevOut).ToList(); - var parsedPSBTInputsOutpoints = parsedPSBT.Inputs.Select(x => x.PrevOut).ToList(); - - //TXID Validation - if (parsedPSBT.GetGlobalTransaction().GetHash() != templatePSBT.GetGlobalTransaction().GetHash()) - { - errorText += "Invalid PSBT, the transactions id do not match. "; - - } - //If all the inputs are not signed this is invalid - else if (!parsedPSBT.Inputs.All(x => x.PartialSigs.Any())) - { - errorText += $"Invalid PSBT, please make sure to use Sighash: {SigHashMode} and that every input is signed. "; - } - - //Sighash check - else if (!parsedPSBT.Inputs.Any(x => x.PartialSigs.All(y=> y.Value.SigHash == SigHashMode))) - { - errorText += $"Invalid PSBT, please make sure to use Sighash: {SigHashMode} and that every input is signed. "; - } - //If the inputs outpoints (utxos) are not the same to the template invalidate it - else if (templateInputsOutpoints.Union(parsedPSBTInputsOutpoints).Count() != templateInputsOutpoints.Count()) - { - errorText += "Invalid PSBT, the UTXOs do not match "; - } - else - { - //It looks good - _isSignedPSBTInvalid = false; - } - } - else - { - errorText = "Invalid PSBT, it could not be parsed"; - } - } - else - { - errorText = "Invalid template PSBT, it could not be parsed"; - } + var errorText = PsbtApprovalValidator.ValidateForDisplay( + TemplatePsbtString, psbtBase64, SigHashMode, CurrentNetworkHelper.GetCurrentNetwork()); + + _isSignedPSBTInvalid = errorText.Length > 0; return errorText; } diff --git a/src/Shared/TransferFundsModal.razor b/src/Shared/TransferFundsModal.razor index 8e9ec3c1..99c53de1 100644 --- a/src/Shared/TransferFundsModal.razor +++ b/src/Shared/TransferFundsModal.razor @@ -11,7 +11,6 @@ @inject ILightningService LightningService @inject IWalletWithdrawalRequestRepository WalletWithdrawalRequestRepository @inject IAuditService AuditService -@inject IWalletWithdrawalRequestPsbtRepository WalletWithdrawalRequestPsbtRepository @inject ISchedulerFactory SchedulerFactory @inject IWalletRepository WalletRepository @inject ILogger Logger @@ -535,14 +534,13 @@ _amountToTransfer; } } - var walletWithdrawalRequestPsbt = new WalletWithdrawalRequestPSBT(); - try { - var templatePSBT = await BitcoinService.GenerateTemplatePSBT(withdrawalRequest); - walletWithdrawalRequestPsbt.WalletWithdrawalRequestId = withdrawalRequest.Id; - walletWithdrawalRequestPsbt.PSBT = templatePSBT.ToBase64(); - walletWithdrawalRequestPsbt.SignerId = null; + // GenerateTemplatePSBT persists the template row itself (tagged IsTemplatePSBT = true). This used + // to store a second, UNTAGGED copy of the same PSBT, which NumberOfSignaturesCollected counted as + // a human signature. Tagging that copy is not an option either — PerformWithdrawal selects the + // template with .Single(x => x.IsTemplatePSBT) and would throw on two rows. + await BitcoinService.GenerateTemplatePSBT(withdrawalRequest); } catch (NoUTXOsAvailableException e) { @@ -562,13 +560,6 @@ _amountToTransfer; return result; } - var addResult = await WalletWithdrawalRequestPsbtRepository.AddAsync(walletWithdrawalRequestPsbt); - - if (!addResult.Item1) - { - throw new ShowToUserException("Error while saving the signature"); - } - var req = await WalletWithdrawalRequestRepository.GetById(withdrawalRequest.Id); if (req == null) { diff --git a/test/NodeGuard.Tests/E2E/PsbtApprovalBindingE2ETests.cs b/test/NodeGuard.Tests/E2E/PsbtApprovalBindingE2ETests.cs new file mode 100644 index 00000000..bb1fa4f7 --- /dev/null +++ b/test/NodeGuard.Tests/E2E/PsbtApprovalBindingE2ETests.cs @@ -0,0 +1,410 @@ +/* + * 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 System.Net; +using FluentAssertions; +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using NBitcoin; +using NBitcoin.RPC; +using Nodeguard; +using NodeGuard.Data; +using NodeGuard.Data.Models; +using NodeGuard.Data.Repositories; +using Npgsql; +using Xunit.Abstractions; + +namespace NodeGuard.Tests.E2E; + +/// +/// End-to-end guarantee for the withdrawal approval binding: the PSBT repository — the boundary every +/// approval crosses before it is counted — only accepts an approval whose transaction is the one the +/// withdrawal request was raised for, and a rejected approval never advances the signature threshold. +/// +/// PSBT approval is reachable only from Withdrawals.razor over the Blazor circuit (no gRPC method exists for +/// it, and no background job picks up requests left in PSBTSignaturesPending), so the furthest this can be +/// exercised from outside is the repository — which is exactly where the server-side check belongs. The +/// requests are created through the real gRPC API and the approvals are stored through the real +/// , with the real EF model evaluating the threshold. +/// +/// A cold wallet (E2E_COLD_WALLET_ID) is used throughout, so nothing is ever broadcast and no funds move. +/// Gated by ; also self-skips when POSTGRES_CONNECTIONSTRING is unset, since +/// it reaches the database directly. Connection via env, all with dev-stack defaults: +/// NODEGUARD_GRPC_ENDPOINT default http://localhost:50051 (h2c) +/// POSTGRES_CONNECTIONSTRING (required for this suite to run) +/// BITCOIND_RPC_URL/USER/PASS default http://localhost:18443 / polaruser / polarpass +/// E2E_COLD_WALLET_ID cold multisig wallet to exercise (default 2) +/// +[Trait("Category", "E2E")] +[Collection("E2E")] +public class PsbtApprovalBindingE2ETests +{ + private const string RequestTag = "e2e-psbt-binding"; + + private readonly ITestOutputHelper _output; + private readonly List _createdRequestIds = new(); + + public PsbtApprovalBindingE2ETests(ITestOutputHelper output) + { + _output = output; + AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); + } + + /// + /// An approval whose transaction differs from the request's approved template is rejected when stored, + /// so an approver's signature can never be applied to a transaction the request never described. + /// + [E2EFact] + public async Task ApprovalForADifferentTransaction_IsRejectedAtInsert() + { + if (!DatabaseAvailable()) return; + + var (requestA, requestB) = await CreateTwoWithdrawalRequestsAsync(); + try + { + var templateA = await GetTemplatePsbtAsync(requestA); + var templateB = await GetTemplatePsbtAsync(requestB); + + var hashA = PSBT.Parse(templateA, Network.RegTest).GetGlobalTransaction().GetHash(); + var hashB = PSBT.Parse(templateB, Network.RegTest).GetGlobalTransaction().GetHash(); + hashA.Should().NotBe(hashB, "the two requests must describe different transactions"); + + // Submit request B's transaction as an approval of request A — exactly what the Approve button + // stores, but for a transaction A never approved. + var result = await CreatePsbtRepository().AddAsync(new WalletWithdrawalRequestPSBT + { + WalletWithdrawalRequestId = requestA, + PSBT = templateB, + SignerId = await GetAnyUserIdAsync(), + }); + + _output.WriteLine($"AddAsync(foreign PSBT) -> success={result.Item1} message={result.Item2}"); + + result.Item1.Should().BeFalse( + "a PSBT whose transaction differs from the request's template must be rejected at insert"); + } + finally + { + await CleanupAsync(); + } + } + + /// + /// A rejected approval is not stored, so it does not advance the request toward "all required signatures + /// collected". Submitting the request's own (unsigned) template as an approval is refused and leaves the + /// threshold unmet. + /// + [E2EFact] + public async Task RejectedApproval_DoesNotAdvanceTheThreshold() + { + if (!DatabaseAvailable()) return; + + var (requestA, requestB) = await CreateTwoWithdrawalRequestsAsync(); + try + { + var templateA = await GetTemplatePsbtAsync(requestA); + var signerId = await GetAnyUserIdAsync(); + + for (var i = 1; i <= 2; i++) + { + var stored = await CreatePsbtRepository().AddAsync(new WalletWithdrawalRequestPSBT + { + WalletWithdrawalRequestId = requestA, + PSBT = templateA, + SignerId = signerId, + }); + _output.WriteLine($"AddAsync attempt {i}: success={stored.Item1} message={stored.Item2}"); + } + + var (collected, threshold, satisfied) = await ReadApprovalStateAsync(requestA); + _output.WriteLine($"collected={collected} MofN={threshold} allRequiredCollected={satisfied}"); + + satisfied.Should().BeFalse( + "approvals the repository rejects must not be counted toward the multisig threshold"); + } + finally + { + await CleanupAsync(); + } + } + + /// + /// A request is never marked fully approved on the strength of PSBTs describing a different transaction: + /// even repeated submissions of a foreign transaction leave the request short of its threshold. + /// + [E2EFact] + public async Task SubstitutedApprovals_DoNotMarkTheRequestAsFullyApproved() + { + if (!DatabaseAvailable()) return; + + var (requestA, requestB) = await CreateTwoWithdrawalRequestsAsync(); + try + { + var templateB = await GetTemplatePsbtAsync(requestB); + var signerId = await GetAnyUserIdAsync(); + + for (var i = 1; i <= 2; i++) + { + await CreatePsbtRepository().AddAsync(new WalletWithdrawalRequestPSBT + { + WalletWithdrawalRequestId = requestA, + PSBT = templateB, + SignerId = signerId, + }); + } + + var (collected, threshold, satisfied) = await ReadApprovalStateAsync(requestA); + _output.WriteLine($"collected={collected} MofN={threshold} allRequiredCollected={satisfied}"); + + satisfied.Should().BeFalse( + "a request must never reach the fully-approved state from PSBTs describing a different " + + "transaction"); + } + finally + { + await CleanupAsync(); + } + } + + // ---- environment --------------------------------------------------------------------------------- + + private static string Env(string name, string fallback) + => Environment.GetEnvironmentVariable(name) is { Length: > 0 } v ? v : fallback; + + private static string? PostgresConnectionString + => Environment.GetEnvironmentVariable("POSTGRES_CONNECTIONSTRING") is { Length: > 0 } v ? v : null; + + private static int ColdWalletId => int.Parse(Env("E2E_COLD_WALLET_ID", "2")); + + private bool DatabaseAvailable() + { + if (PostgresConnectionString is not null) return true; + _output.WriteLine("POSTGRES_CONNECTIONSTRING not set — this suite reaches the database directly. Skipping."); + return false; + } + + private static NodeGuardService.NodeGuardServiceClient CreateGrpcClient(out Metadata headers) + { + headers = new Metadata { { "auth-token", Env("NODEGUARD_API_TOKEN", DefaultDevToken) } }; + var endpoint = Env("NODEGUARD_GRPC_ENDPOINT", "http://localhost:50051"); + return new NodeGuardService.NodeGuardServiceClient(GrpcChannel.ForAddress(endpoint)); + } + + private const string DefaultDevToken = "8rvSsUGeyXXdDQrHctcTey/xtHdZQEn945KHwccKp9Q="; + + private static RPCClient CreateBitcoindRpc() + { + var credential = new NetworkCredential(Env("BITCOIND_RPC_USER", "polaruser"), Env("BITCOIND_RPC_PASS", "polarpass")); + return new RPCClient(credential, new Uri(Env("BITCOIND_RPC_URL", "http://localhost:18443")), Network.RegTest); + } + + // ---- database access ----------------------------------------------------------------------------- + + private static NpgsqlDataSource? _dataSource; + private static readonly object DataSourceGate = new(); + + private static IDbContextFactory DbContextFactory() + { + var cs = PostgresConnectionString!; + AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); + lock (DataSourceGate) + { + _dataSource ??= new NpgsqlDataSourceBuilder(cs).EnableDynamicJson().Build(); + } + return new LocalDbContextFactory(_dataSource); + } + + private sealed class LocalDbContextFactory : IDbContextFactory + { + private readonly NpgsqlDataSource _dataSource; + public LocalDbContextFactory(NpgsqlDataSource dataSource) => _dataSource = dataSource; + + public ApplicationDbContext CreateDbContext() + => new(new DbContextOptionsBuilder() + .UseNpgsql(_dataSource, o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery)) + .Options); + } + + private static WalletWithdrawalRequestPsbtRepository CreatePsbtRepository() + => new( + new Repository(NullLogger.Instance), + NullLogger.Instance, + DbContextFactory()); + + private static async Task GetTemplatePsbtAsync(int requestId) + { + await using var context = DbContextFactory().CreateDbContext(); + var template = await context.WalletWithdrawalRequestPSBTs + .Where(x => x.WalletWithdrawalRequestId == requestId && x.IsTemplatePSBT) + .Select(x => x.PSBT) + .FirstOrDefaultAsync(); + + return template ?? throw new InvalidOperationException( + $"Request {requestId} has no template PSBT; it may not have reached PSBT generation."); + } + + /// Reloads the request with the includes the production repository uses, so the NotMapped model + /// logic (NumberOfSignaturesCollected / AreAllRequiredHumanSignaturesCollected) is evaluated as it is + /// in the application. + private static async Task<(int Collected, int Threshold, bool Satisfied)> ReadApprovalStateAsync(int requestId) + { + await using var context = DbContextFactory().CreateDbContext(); + var request = await context.WalletWithdrawalRequests + .Include(x => x.Wallet).ThenInclude(x => x.InternalWallet) + .Include(x => x.Wallet).ThenInclude(x => x.Keys) + .Include(x => x.WalletWithdrawalRequestPSBTs) + .Include(x => x.WalletWithdrawalRequestDestinations) + .SingleOrDefaultAsync(x => x.Id == requestId) + ?? throw new InvalidOperationException($"request {requestId} must still exist"); + + return (request.NumberOfSignaturesCollected, request.Wallet.MofN, + request.AreAllRequiredHumanSignaturesCollected); + } + + private static async Task GetAnyUserIdAsync() + { + await using var connection = new NpgsqlConnection(PostgresConnectionString); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand("SELECT \"Id\" FROM \"AspNetUsers\" ORDER BY \"Id\" LIMIT 1", connection); + var userId = (string?)await command.ExecuteScalarAsync(); + return userId ?? throw new InvalidOperationException("No users in the database."); + } + + // ---- request creation & cleanup ------------------------------------------------------------------ + + /// + /// Creates two cold-wallet withdrawal requests to different destinations through the real gRPC API, + /// funding the wallet from bitcoind first if needed (the seeded cold wallet is not funded by + /// DbInitializer). Cold, so nothing is broadcast — each stays pending until approvals arrive. + /// + private async Task<(int RequestA, int RequestB)> CreateTwoWithdrawalRequestsAsync() + { + await EnsureColdWalletFundedAsync(); + + var client = CreateGrpcClient(out var headers); + var rpc = CreateBitcoindRpc(); + var sink = (await rpc.GetNewAddressAsync()).ToString(); + + async Task CreateAsync(string label, long amountSats) + { + try + { + var response = await client.RequestWithdrawalAsync(new RequestWithdrawalRequest + { + WalletId = ColdWalletId, + Description = $"{RequestTag}-{label}-{Guid.NewGuid():N}", + Destinations = { new Destination { Address = sink, AmountSats = amountSats } }, + MempoolFeeRate = FEES_TYPE.CustomFee, + CustomFeeRate = 2, + }, headers); + + _output.WriteLine($"created request {label} id={response.RequestId}"); + _createdRequestIds.Add(response.RequestId); + return response.RequestId; + } + catch (RpcException e) + { + throw new InvalidOperationException( + $"Could not create withdrawal request {label} on wallet {ColdWalletId} " + + $"({e.StatusCode}: {e.Status.Detail}). Check E2E_COLD_WALLET_ID is a funded cold " + + "multisig wallet.", e); + } + } + + // Different amounts guarantee different transactions, hence different template PSBTs. + return (await CreateAsync("A", 100_000), await CreateAsync("B", 150_000)); + } + + private async Task EnsureColdWalletFundedAsync() + { + var client = CreateGrpcClient(out var headers); + const long probeSats = 500_000; + + // Two UTXOs are needed: coin selection excludes outpoints already used by another pending request, + // so with a single UTXO the second request cannot be built. + const int requiredUtxos = 2; + + var available = await client.GetAvailableUtxosAsync( + new GetAvailableUtxosRequest { WalletId = ColdWalletId, Amount = probeSats }, headers); + if (available.Confirmed.Count >= requiredUtxos && available.Confirmed.Sum(u => u.Amount) >= probeSats) + return; + + var rpc = CreateBitcoindRpc(); + for (var i = 0; i < 4; i++) + { + var address = await client.GetNewWalletAddressAsync( + new GetNewWalletAddressRequest { WalletId = ColdWalletId, Skip = 0, Reserve = true }, headers); + await rpc.SendToAddressAsync(BitcoinAddress.Create(address.Address, Network.RegTest), Money.Coins(0.25m)); + } + + await rpc.GenerateToAddressAsync(6, await rpc.GetNewAddressAsync()); + + await RetryAsync(async () => + { + var utxos = await client.GetAvailableUtxosAsync( + new GetAvailableUtxosRequest { WalletId = ColdWalletId, Amount = probeSats }, headers); + if (utxos.Confirmed.Count < requiredUtxos) + throw new InvalidOperationException($"only {utxos.Confirmed.Count} confirmed UTXOs indexed so far"); + return true; + }, attempts: 30, delay: TimeSpan.FromSeconds(4), what: "cold wallet funding indexed"); + } + + /// Removes the requests this suite created (children first) so a shared dev stack is left clean. + private async Task CleanupAsync() + { + try + { + await using var connection = new NpgsqlConnection(PostgresConnectionString); + await connection.OpenAsync(); + + const string sql = """ + WITH doomed AS (SELECT "Id" FROM "WalletWithdrawalRequests" WHERE "Description" LIKE @prefix) + DELETE FROM "FMUTXOWalletWithdrawalRequest" WHERE "WalletWithdrawalRequestsId" IN (SELECT "Id" FROM doomed); + WITH doomed AS (SELECT "Id" FROM "WalletWithdrawalRequests" WHERE "Description" LIKE @prefix) + DELETE FROM "WalletWithdrawalRequestPSBTs" WHERE "WalletWithdrawalRequestId" IN (SELECT "Id" FROM doomed); + WITH doomed AS (SELECT "Id" FROM "WalletWithdrawalRequests" WHERE "Description" LIKE @prefix) + DELETE FROM "WalletWithdrawalRequestDestinations" WHERE "WalletWithdrawalRequestId" IN (SELECT "Id" FROM doomed); + DELETE FROM "WalletWithdrawalRequests" WHERE "Description" LIKE @prefix; + """; + await using var command = new NpgsqlCommand(sql, connection); + command.Parameters.AddWithValue("prefix", RequestTag + "%"); + await command.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + _output.WriteLine( + $"cleanup failed: {ex.Message}. Remove WalletWithdrawalRequests whose Description starts " + + $"with '{RequestTag}'."); + } + } + + private async Task RetryAsync(Func> action, int attempts, TimeSpan delay, string what) + { + Exception? last = null; + for (var i = 0; i < attempts; i++) + { + try { return await action(); } + catch (Exception ex) { last = ex; _output.WriteLine($"{what} attempt {i + 1}/{attempts}: {ex.Message}"); } + await Task.Delay(delay); + } + throw new InvalidOperationException($"{what} did not succeed after {attempts} attempts", last); + } +} diff --git a/test/NodeGuard.Tests/Helpers/PsbtApprovalValidatorTests.cs b/test/NodeGuard.Tests/Helpers/PsbtApprovalValidatorTests.cs new file mode 100644 index 00000000..bbe19bbe --- /dev/null +++ b/test/NodeGuard.Tests/Helpers/PsbtApprovalValidatorTests.cs @@ -0,0 +1,270 @@ +/* + * 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 NBitcoin; +using NodeGuard.Helpers; + +namespace NodeGuard.Tests.Helpers; + +/// +/// An approver's PSBT must be bound to the transaction the withdrawal request was raised for: it is rejected +/// unless its global transaction matches the template, every input is signed with the required sighash, and +/// its signatures do not duplicate an existing approval. +/// +/// Half of these assert those rejections. The other half assert the accept-path, because an over-strict rule +/// is just as damaging on a treasury tool: if legitimate approvals stop being accepted, funds become +/// unspendable. So the happy path and the multi-signer M-of-N flows are covered explicitly. +/// +public class PsbtApprovalValidatorTests +{ + private static readonly Network Network = Network.RegTest; + + // ---- rejects a substituted transaction ----------------------------------------------------------- + + /// + /// A signature over a transaction of the signer's own choosing, paying a different destination, must be + /// rejected when submitted in place of the approved one. + /// + [Fact] + public void Validate_RejectsSignedPsbtForADifferentTransaction() + { + var f = new Fixture(); + var substitute = f.Sign(f.BuildSpend(f.AttackerAddress, Money.Coins(0.9m)), f.KeyA); + + var result = PsbtApprovalValidator.Validate(f.TemplateBase64, substitute.ToBase64(), + SigHash.All, Network); + + result.IsValid.Should().BeFalse( + "a PSBT describing a different transaction must never be accepted as an approval"); + result.Error.Should().Contain("does not match"); + } + + /// + /// Same inputs and destination but a different amount — a subtler substitution than redirecting the + /// payment, and the one an attacker would reach for to skim. + /// + [Fact] + public void Validate_RejectsTamperedAmountForTheSameDestination() + { + var f = new Fixture(); + var skimmed = f.Sign(f.BuildSpend(f.ApprovedAddress, Money.Coins(0.4m)), f.KeyA); + + var result = PsbtApprovalValidator.Validate(f.TemplateBase64, skimmed.ToBase64(), + SigHash.All, Network); + + result.IsValid.Should().BeFalse("changing the amount changes the transaction"); + } + + /// + /// The threshold half of the finding: one signer must not advance an M-of-N threshold by submitting their + /// signature more than once. The fingerprint is over signing public keys, not bytes, so re-serializing or + /// re-signing with a randomized nonce does not evade it. + /// + [Fact] + public void Validate_RejectsASecondSubmissionCarryingTheSameSignature() + { + var f = new Fixture(); + var firstApproval = f.Sign(f.TemplateTransaction, f.KeyA); + var resubmission = f.Sign(f.TemplateTransaction, f.KeyA); + + var result = PsbtApprovalValidator.Validate(f.TemplateBase64, resubmission.ToBase64(), + SigHash.All, Network, new[] { firstApproval.ToBase64() }); + + result.IsValid.Should().BeFalse("the same key must count once, however many times it is submitted"); + result.Error.Should().Contain("already been signed"); + } + + [Fact] + public void Validate_RejectsUnsignedPsbt() + { + var f = new Fixture(); + + var result = PsbtApprovalValidator.Validate(f.TemplateBase64, f.TemplateBase64, SigHash.All, Network); + + result.IsValid.Should().BeFalse("an approval must actually carry a signature"); + } + + [Fact] + public void Validate_RejectsWrongSigHash() + { + var f = new Fixture(); + var signedWithAll = f.Sign(f.TemplateTransaction, f.KeyA); + + // Channel operations require SIGHASH_NONE; a SIGHASH_ALL signature must not satisfy them. + var result = PsbtApprovalValidator.Validate(f.TemplateBase64, signedWithAll.ToBase64(), + SigHash.None, Network); + + result.IsValid.Should().BeFalse("the sighash must be the one the operation requires"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-a-psbt")] + public void Validate_RejectsUnparseableSubmission(string submitted) + { + var f = new Fixture(); + + var result = PsbtApprovalValidator.Validate(f.TemplateBase64, submitted, SigHash.All, Network); + + result.IsValid.Should().BeFalse(); + result.Error.Should().Contain("could not be parsed"); + } + + [Fact] + public void Validate_RejectsWhenThereIsNoTemplateToCompareAgainst() + { + var f = new Fixture(); + var approval = f.Sign(f.TemplateTransaction, f.KeyA); + + var result = PsbtApprovalValidator.Validate(null, approval.ToBase64(), SigHash.All, Network); + + result.IsValid.Should().BeFalse( + "with no template there is nothing to bind the approval to, so it must not be trusted"); + } + + // ---- the fix must not break legitimate signing --------------------------------------------------- + + /// + /// The single most important test in this file. An honestly-signed approval of the approved transaction + /// must be accepted — otherwise the fix has converted a confidentiality problem into frozen funds. + /// + [Fact] + public void Validate_AcceptsAnHonestApproval() + { + var f = new Fixture(); + var approval = f.Sign(f.TemplateTransaction, f.KeyA); + + var result = PsbtApprovalValidator.Validate(f.TemplateBase64, approval.ToBase64(), + SigHash.All, Network); + + result.IsValid.Should().BeTrue($"a correctly signed approval must be accepted, got: {result.Error}"); + result.Error.Should().BeNull(); + } + + /// + /// M-of-N must still work: a second approval from a DIFFERENT key is a distinct signature and has to be + /// accepted even though one approval is already on file. + /// + [Fact] + public void Validate_AcceptsASecondApprovalFromADifferentKey() + { + var f = new Fixture(); + var firstApproval = f.Sign(f.TemplateTransaction, f.KeyA); + var secondApproval = f.Sign(f.TemplateTransaction, f.KeyB); + + var result = PsbtApprovalValidator.Validate(f.TemplateBase64, secondApproval.ToBase64(), + SigHash.All, Network, new[] { firstApproval.ToBase64() }); + + result.IsValid.Should().BeTrue( + $"a different keyholder's signature must be accepted, got: {result.Error}"); + } + + /// + /// A user holding two keys in the same wallet submits both signatures in one PSBT — a legitimate setup that + /// a naive "one approval per user" rule would have broken. + /// + [Fact] + public void Validate_AcceptsAnApprovalCarryingTwoKeysSignatures() + { + var f = new Fixture(); + var approval = f.Sign(f.TemplateTransaction, f.KeyA, f.KeyB); + + var result = PsbtApprovalValidator.Validate(f.TemplateBase64, approval.ToBase64(), + SigHash.All, Network); + + result.IsValid.Should().BeTrue($"multiple signatures in one PSBT are valid, got: {result.Error}"); + } + + [Fact] + public void ValidateForDisplay_ReturnsEmptyStringForAnHonestApproval() + { + var f = new Fixture(); + var approval = f.Sign(f.TemplateTransaction, f.KeyA); + + var errors = PsbtApprovalValidator.ValidateForDisplay(f.TemplateBase64, approval.ToBase64(), + SigHash.All, Network); + + errors.Should().BeEmpty("the UI overload signals validity with an empty string"); + } + + [Fact] + public void ValidateForDisplay_ReturnsAnErrorForASubstitution() + { + var f = new Fixture(); + var substitute = f.Sign(f.BuildSpend(f.AttackerAddress, Money.Coins(0.9m)), f.KeyA); + + var errors = PsbtApprovalValidator.ValidateForDisplay(f.TemplateBase64, substitute.ToBase64(), + SigHash.All, Network); + + errors.Should().NotBeNullOrWhiteSpace(); + } + + // ---- fixture ------------------------------------------------------------------------------------- + + /// + /// A 2-of-2 P2WSH multisig UTXO — the shape NodeGuard's cold wallets actually use — plus an approved + /// destination and an attacker-controlled one. Using a real multisig matters: with a single-key P2WPKH + /// output only one key can produce a signature, so the multi-signer cases could not be expressed. + /// + private sealed class Fixture + { + internal Key KeyA { get; } = new(); + internal Key KeyB { get; } = new(); + internal BitcoinAddress ApprovedAddress { get; } + internal BitcoinAddress AttackerAddress { get; } + internal Transaction TemplateTransaction { get; } + internal string TemplateBase64 { get; } + + private readonly ScriptCoin _coin; + + internal Fixture() + { + var redeem = PayToMultiSigTemplate.Instance.GenerateScriptPubKey(2, KeyA.PubKey, KeyB.PubKey); + + var funding = Network.CreateTransaction(); + funding.Outputs.Add(new TxOut(Money.Coins(1m), redeem.WitHash.ScriptPubKey)); + _coin = new Coin(funding, 0U).ToScriptCoin(redeem); + + ApprovedAddress = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, Network); + AttackerAddress = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, Network); + + TemplateTransaction = BuildSpend(ApprovedAddress, Money.Coins(0.5m)); + TemplateBase64 = ToPsbt(TemplateTransaction).ToBase64(); + } + + internal Transaction BuildSpend(BitcoinAddress destination, Money amount) + { + var tx = Network.CreateTransaction(); + tx.Inputs.Add(new TxIn(_coin.Outpoint)); + tx.Outputs.Add(new TxOut(amount, destination.ScriptPubKey)); + return tx; + } + + internal PSBT ToPsbt(Transaction tx) => PSBT.FromTransaction(tx, Network).AddCoins(_coin); + + internal PSBT Sign(Transaction tx, params Key[] keys) + { + var psbt = ToPsbt(tx); + psbt.SignWithKeys(keys); + return psbt; + } + } +} diff --git a/test/NodeGuard.Tests/Services/BitcoinServiceTests.cs b/test/NodeGuard.Tests/Services/BitcoinServiceTests.cs index 439166b6..2de46fce 100644 --- a/test/NodeGuard.Tests/Services/BitcoinServiceTests.cs +++ b/test/NodeGuard.Tests/Services/BitcoinServiceTests.cs @@ -737,12 +737,44 @@ async Task GenerateTemplatePSBT_Changeless_SingleSigSucceeds() result.Should().BeEquivalentTo(psbt); } + /// + /// Builds the unsigned template PSBT for the same transaction a set of approver PSBTs signs. Signing does + /// not alter the transaction, so the template shares its txid — which is exactly what PerformWithdrawal's + /// binding check verifies before the internal wallet signs. + /// + private static string TemplateFor(string signedPsbtBase64) + { + var network = CurrentNetworkHelper.GetCurrentNetwork(); + var transaction = PSBT.Parse(signedPsbtBase64, network).GetGlobalTransaction(); + + return PSBT.FromTransaction(transaction, network).ToBase64(); + } + [Fact] async Task PerformWithdrawal_SingleSigSucceeds() { // Arrange var wallet = CreateWallet.SingleSig(_internalWallet); - var psbt = "cHNidP8BAIkBAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wD/////AkBCDwAAAAAAIgAgPaPWaBQgTxHOMVfMfpX21blroUe8KAd6w2gLRelFuiCsUYkAAAAAACIAIDx3862ZOy+vKdDZ4oysyRZX0HARoqQ9LqqK2ukxoopiAAAAAE8BBDWHzwN9uUaNAAAAAYPR/OiA1LbTzxbLPvbXvtAwckIG3g+0T1zblR/ZodaiA5zBFsigPpL8htN/KJ/Ph8SPvQA/K+mSNXTSA0hgvPNuEO0CEMgwAACAAQAAgAEAAAAAAQEfgJaYAAAAAAAWABTpOvUBMqNMfl7P81etji6x4fXrMwAAAA=="; + + // Built rather than hardcoded. The previous literal spent a NULL outpoint (32 zero bytes with index + // 0xFFFFFFFF), which NBitcoin classifies as a coinbase, so tx.Check() rejected it. That went unnoticed + // while the result was merely logged; now that PerformWithdrawal refuses to broadcast a transaction + // failing its own sanity check, the fixture has to be a transaction that could really exist. + var network = CurrentNetworkHelper.GetCurrentNetwork(); + var walletScript = (wallet.GetDerivationStrategy() as StandardDerivationStrategyBase)! + .GetDerivation(KeyPath.Parse("0/0")).ScriptPubKey; + + var fundingTx = network.CreateTransaction(); + fundingTx.Outputs.Add(new TxOut(Money.Coins(0.1m), walletScript)); + var walletCoin = new Coin(fundingTx, 0U); + + var spendTx = network.CreateTransaction(); + spendTx.Inputs.Add(new TxIn(walletCoin.Outpoint)); + spendTx.Outputs.Add(new TxOut(Money.Coins(0.01m), + BitcoinAddress.Create("bcrt1q8k3av6q5yp83rn332lx8a90k6kukhg28hs5qw7krdq95t629hgsqk6ztmf", network))); + + var psbt = PSBT.FromTransaction(spendTx, network).AddCoins(walletCoin).ToBase64(); + var approvedTxId = spendTx.GetHash(); var walletWithdrawalRequestPSBTs = new List() { new () @@ -793,8 +825,10 @@ async Task PerformWithdrawal_SingleSigSucceeds() { new UTXO() { - Value = new Money((long)10000000), - ScriptPubKey = (wallet.GetDerivationStrategy() as StandardDerivationStrategyBase)!.GetDerivation(KeyPath.Parse("0/0")).ScriptPubKey, + // Must match the PSBT's input so the embedded signer finds the keypath. + Outpoint = walletCoin.Outpoint, + Value = walletCoin.Amount, + ScriptPubKey = walletScript, KeyPath = KeyPath.Parse("0/0") } } @@ -813,6 +847,79 @@ async Task PerformWithdrawal_SingleSigSucceeds() // Assert await act.Should().NotThrowAsync(); + + // The transaction reaching the network must be the one that was approved. + nbXplorerService.Verify(x => x.BroadcastAsync( + It.Is(tx => tx.GetHash() == approvedTxId), default, default), Times.Once); + } + + /// + /// Pins the invariant that a request has AT MOST ONE template PSBT row. + /// + /// PerformWithdrawal's hot-wallet branch selects the template with .Single(x => x.IsTemplatePSBT), so a + /// second tagged template makes every hot-wallet withdrawal throw. This matters because + /// GenerateTemplatePSBT already persists the template itself: Withdrawals.razor and TransferFundsModal used + /// to store a redundant second copy (untagged, which silently inflated the signature count), and "just tag + /// it" would have broken this path instead. Both redundant stores were removed. If either is reintroduced + /// as a tagged row, this test explains the failure. + /// + [Fact] + async Task PerformWithdrawal_WithDuplicateTemplateRows_FailsLoudly() + { + // Arrange + var wallet = CreateWallet.SingleSig(_internalWallet); + var network = CurrentNetworkHelper.GetCurrentNetwork(); + var walletScript = (wallet.GetDerivationStrategy() as StandardDerivationStrategyBase)! + .GetDerivation(KeyPath.Parse("0/0")).ScriptPubKey; + + var fundingTx = network.CreateTransaction(); + fundingTx.Outputs.Add(new TxOut(Money.Coins(0.1m), walletScript)); + var walletCoin = new Coin(fundingTx, 0U); + + var spendTx = network.CreateTransaction(); + spendTx.Inputs.Add(new TxIn(walletCoin.Outpoint)); + spendTx.Outputs.Add(new TxOut(Money.Coins(0.01m), + BitcoinAddress.Create("bcrt1q8k3av6q5yp83rn332lx8a90k6kukhg28hs5qw7krdq95t629hgsqk6ztmf", network))); + + var templateBase64 = PSBT.FromTransaction(spendTx, network).AddCoins(walletCoin).ToBase64(); + + var withdrawalRequest = new WalletWithdrawalRequest + { + Id = 1, + Status = WalletWithdrawalRequestStatus.PSBTSignaturesPending, + Wallet = wallet, + WalletWithdrawalRequestPSBTs = new List + { + new() { IsTemplatePSBT = true, PSBT = templateBase64 }, + new() { IsTemplatePSBT = true, PSBT = templateBase64 }, // the duplicate + }, + WalletWithdrawalRequestDestinations = new List + { + new() + { + Address = "bcrt1q8k3av6q5yp83rn332lx8a90k6kukhg28hs5qw7krdq95t629hgsqk6ztmf", + Amount = 0.01m, + }, + }, + }; + + var walletWithdrawalRequestRepository = new Mock(); + walletWithdrawalRequestRepository.Setup(x => x.GetById(It.IsAny())).ReturnsAsync(withdrawalRequest); + walletWithdrawalRequestRepository.Setup(x => x.Update(It.IsAny())) + .Returns((true, null)); + + var nbXplorerService = new Mock(); + var bitcoinService = new BitcoinService(_logger, null, walletWithdrawalRequestRepository.Object, null, + null, null, nbXplorerService.Object, null); + + // Act + var act = () => bitcoinService.PerformWithdrawal(withdrawalRequest); + + // Assert + await act.Should().ThrowAsync( + "two template rows must not be silently tolerated on the signing path"); + + nbXplorerService.Verify(x => x.BroadcastAsync(It.IsAny(), default, default), Times.Never); } [Fact] @@ -824,6 +931,16 @@ async Task PerformWithdrawal_MultiSigSucceeds() var psbt2 = "cHNidP8BAIkBAAAAATqZB6sbll4a0AJOf+RGbdqw07G/O9FatkFr+PDJAy+EAQAAAAD/////AkBCDwAAAAAAIgAgTlHqBosTtDYNNC59Qaz2968zru/mbl0l3tylEw+bKs2YTiZ3AAAAACIAINbjv3PBr8yjQit+5PSOCXdJgwfIoJ3Hv0HMD8+di5CSAAAAAE8BBDWHzwMvESQsgAAAAfw77kI6AYzrbSJqBmMojtD7XuD6nXkKs3DQMOBHMObIA4COLhzUgr3QcZaUPFqBM9Fpr4YCK2uwOBdxZE7AdETXEB/M5N4wAACAAQAAgAEAAIBPAQQ1h88DVqwD9IAAAAH5CK5KZrD/oasUtVrwzkjypwIly5AQkC1pAa+QuT6PgQJRrxXgW7i36sGJWz9fR//v7NgyGgLvIimPidCiA33wYBBg86CzMAAAgAEAAIABAACATwEENYfPA325Ro0AAAAAgN63GqLxTu1/NyL0SV4a0Hn1n8Dzg+Wye9nbb16ZISADr+s+pcKnDcSqKHKWSl4v8Rcq80ZqG/7QObYmZUl/xUYQ7QIQyDAAAIABAACAAAAAAAABASsAlDV3AAAAACIAIAF9guNzq1T08+t+DdFQoBYxMjvBQRTYuFmw2ppaQKvfIgIC0ERV00oCvIbrLIL57dugiHhoc3blZgCOzYK2j+uDBbZHMEQCIF6mZdDgN+Q++oSO0lsvDYsTvCwxlwyGbvDAsDf8VV0RAiAKyQ9ZTd0JgB4rsSC+2aHdPjzWYU0BdeVGel8bDHwatAEBBWlSIQLQRFXTSgK8hussgvnt26CIeGhzduVmAI7NgraP64MFtiEDHvfaz8S4WW4LqTCUmaadde52cCEeX0/qJryg6ukbY4YhA/dSE/9TMSUTREqX5s2YWHSe8Obyw+HSZ+xuyVTUPMUmU64iBgLQRFXTSgK8hussgvnt26CIeGhzduVmAI7NgraP64MFthhg86CzMAAAgAEAAIABAACAAAAAAAsAAAAiBgMe99rPxLhZbgupMJSZpp117nZwIR5fT+omvKDq6RtjhhgfzOTeMAAAgAEAAIABAACAAAAAAAsAAAAiBgP3UhP/UzElE0RKl+bNmFh0nvDm8sPh0mfsbslU1DzFJhjtAhDIMAAAgAEAAIAAAAAAAAAAAAsAAAAAAAA="; var walletWithdrawalRequestPSBTs = new List() { + // PerformWithdrawal verifies the combined PSBT still describes the approved transaction, so the + // fixture must carry the template row that production always has — GenerateTemplatePSBT creates it + // before approval is even possible. + new () + { + IsFinalisedPSBT = false, + IsInternalWalletPSBT = false, + IsTemplatePSBT = true, + PSBT = TemplateFor(psbt1), + }, new () { IsFinalisedPSBT = false, @@ -910,6 +1027,16 @@ async Task PerformWithdrawal_LegacyMultiSigSucceeds() var psbt2 = "cHNidP8BAIkBAAAAATqZB6sbll4a0AJOf+RGbdqw07G/O9FatkFr+PDJAy+EAQAAAAD/////AkBCDwAAAAAAIgAgTlHqBosTtDYNNC59Qaz2968zru/mbl0l3tylEw+bKs2YTiZ3AAAAACIAINbjv3PBr8yjQit+5PSOCXdJgwfIoJ3Hv0HMD8+di5CSAAAAAE8BBDWHzwMvESQsgAAAAfw77kI6AYzrbSJqBmMojtD7XuD6nXkKs3DQMOBHMObIA4COLhzUgr3QcZaUPFqBM9Fpr4YCK2uwOBdxZE7AdETXEB/M5N4wAACAAQAAgAEAAIBPAQQ1h88DVqwD9IAAAAH5CK5KZrD/oasUtVrwzkjypwIly5AQkC1pAa+QuT6PgQJRrxXgW7i36sGJWz9fR//v7NgyGgLvIimPidCiA33wYBBg86CzMAAAgAEAAIABAACATwEENYfPA325Ro0AAAAAgN63GqLxTu1/NyL0SV4a0Hn1n8Dzg+Wye9nbb16ZISADr+s+pcKnDcSqKHKWSl4v8Rcq80ZqG/7QObYmZUl/xUYQ7QIQyDAAAIABAACAAAAAAAABASsAlDV3AAAAACIAIAF9guNzq1T08+t+DdFQoBYxMjvBQRTYuFmw2ppaQKvfIgIC0ERV00oCvIbrLIL57dugiHhoc3blZgCOzYK2j+uDBbZHMEQCIF6mZdDgN+Q++oSO0lsvDYsTvCwxlwyGbvDAsDf8VV0RAiAKyQ9ZTd0JgB4rsSC+2aHdPjzWYU0BdeVGel8bDHwatAEBBWlSIQLQRFXTSgK8hussgvnt26CIeGhzduVmAI7NgraP64MFtiEDHvfaz8S4WW4LqTCUmaadde52cCEeX0/qJryg6ukbY4YhA/dSE/9TMSUTREqX5s2YWHSe8Obyw+HSZ+xuyVTUPMUmU64iBgLQRFXTSgK8hussgvnt26CIeGhzduVmAI7NgraP64MFthhg86CzMAAAgAEAAIABAACAAAAAAAsAAAAiBgMe99rPxLhZbgupMJSZpp117nZwIR5fT+omvKDq6RtjhhgfzOTeMAAAgAEAAIABAACAAAAAAAsAAAAiBgP3UhP/UzElE0RKl+bNmFh0nvDm8sPh0mfsbslU1DzFJhjtAhDIMAAAgAEAAIAAAAAAAAAAAAsAAAAAAAA="; var walletWithdrawalRequestPSBTs = new List() { + // PerformWithdrawal verifies the combined PSBT still describes the approved transaction, so the + // fixture must carry the template row that production always has — GenerateTemplatePSBT creates it + // before approval is even possible. + new () + { + IsFinalisedPSBT = false, + IsInternalWalletPSBT = false, + IsTemplatePSBT = true, + PSBT = TemplateFor(psbt1), + }, new () { IsFinalisedPSBT = false, @@ -1436,4 +1563,195 @@ async Task GenerateTemplatePSBT_ReuseTemplatePSBT_WhenUTXOsStillValid() result.Should().NotBeNull(); result.ToBase64().Should().Be(existingTemplatePSBT); // Should return the existing template PSBT } + + /// + /// Precondition for : + /// on a wallet where NodeGuard is a required co-signer (Keys.Count == MofN, here a 2-of-2), a single + /// human approval already satisfies the threshold, so PerformWithdrawal proceeds to have the internal + /// wallet sign. That is precisely why the PSBT it signs must be bound to the approved template. + /// + [Fact] + public void PerformWithdrawal_2Of2_SingleHumanApprovalTriggersInternalCoSigning() + { + var fixture = new SubstitutionFixture(); + + fixture.Wallet.RequiresInternalWalletSigning.Should().BeTrue( + "the fixture must be a wallet where NodeGuard is a required co-signer (Keys.Count == MofN)"); + + var request = fixture.BuildRequest(fixture.AttackerApprovalBase64); + + request.NumberOfSignaturesCollected.Should().Be(1, "the fixture stores exactly one human approval"); + request.AreAllRequiredHumanSignaturesCollected.Should().BeTrue( + "one human signature plus NodeGuard's own is the whole 2-of-2"); + } + + /// + /// NodeGuard's internal wallet must not co-sign or broadcast a transaction that differs from the + /// withdrawal request's approved template. The only human approval here is a valid signature over a + /// DIFFERENT transaction paying an attacker-controlled address; PerformWithdrawal must refuse it and + /// broadcast nothing, so a lone keyholder cannot redirect funds by substituting the transaction. + /// + [Fact] + public async Task PerformWithdrawal_DoesNotCoSignOrBroadcastASubstitutedTransaction() + { + var fixture = new SubstitutionFixture(); + var request = fixture.BuildRequest(fixture.AttackerApprovalBase64); + + var walletWithdrawalRequestRepository = new Mock(); + walletWithdrawalRequestRepository.Setup(x => x.GetById(It.IsAny())).ReturnsAsync(request); + walletWithdrawalRequestRepository.Setup(x => x.Update(It.IsAny())) + .Returns((true, null)); + + var nbXplorerService = new Mock(); + nbXplorerService.Setup(x => x.GetUTXOsAsync(It.IsAny(), default)) + .ReturnsAsync(fixture.BuildUtxoChanges()); + + Transaction? broadcast = null; + nbXplorerService.Setup(x => x.BroadcastAsync(It.IsAny(), default, default)) + .Callback((tx, _, _) => broadcast = tx) + .ReturnsAsync(new BroadcastResult { Success = true }); + + var nodeRepository = new Mock(); + nodeRepository.Setup(x => x.GetAllManagedByNodeGuard(It.IsAny())) + .ReturnsAsync(new List { new() { PubKey = "02" + new string('a', 64), Name = "test-node" } }); + + var bitcoinService = new BitcoinService(_logger, null, + walletWithdrawalRequestRepository.Object, null, nodeRepository.Object, null, + nbXplorerService.Object, null); + + // Act + var act = () => bitcoinService.PerformWithdrawal(request); + + // Assert — the substitution is refused before signing, and nothing is broadcast. + await act.Should().ThrowAsync(); + broadcast.Should().BeNull( + "a withdrawal whose only approval describes a different transaction must never reach broadcast"); + } + + /// + /// A cold 2-of-2 wallet: one human key (whose seed the substitution uses) plus NodeGuard's internal + /// co-signing key, funded with a single UTXO. Produces the approved template plus the substitute — a + /// valid signature over a different transaction paying a different destination. + /// + private sealed class SubstitutionFixture + { + private const string HumanSeed = + "social mango annual basic work brain economy one safe physical junk other toy valid load cook napkin maple runway island oil fan legend stem"; + + private static readonly Network Network = Network.RegTest; + + internal Wallet Wallet { get; } + internal BitcoinAddress ApprovedAddress { get; } + internal BitcoinAddress AttackerAddress { get; } + internal Money ApprovedAmount { get; } = Money.Coins(0.01m); + internal Money AttackerAmount { get; } = Money.Coins(0.09m); + internal string TemplateBase64 { get; } + internal string AttackerApprovalBase64 { get; } + + private readonly ScriptCoin _coin; + private readonly KeyPath _utxoKeyPath = KeyPath.Parse("0/0"); + private readonly Script _walletScript; + + internal SubstitutionFixture() + { + var internalWallet = CreateWallet.CreateInternalWallet(); + var humanKey = CreateWallet.CreateUserKey("human key", "human-user", HumanSeed); + + // Replica of CreateWallet.CreateInternalKey, which is private. + var internalKey = new Key + { + Name = "NodeGuard Co-signing Key", + XPUB = internalWallet.GetXpubForAccount("0"), + InternalWalletId = internalWallet.Id, + Path = internalWallet.GetKeyPathForAccount("0"), + MasterFingerprint = internalWallet.MasterFingerprint, + }; + + // Keys.Count == MofN => RequiresInternalWalletSigning is true. + Wallet = new Wallet + { + Id = 1, + MofN = 2, + Keys = new List { humanKey, internalKey }, + Name = "2-of-2 wallet", + WalletAddressType = WalletAddressType.NativeSegwit, + InternalWallet = internalWallet, + InternalWalletId = internalWallet.Id, + IsFinalised = true, + InternalWalletSubDerivationPath = "0", + InternalWalletMasterFingerprint = internalWallet.MasterFingerprint, + }; + + var derivation = (Wallet.GetDerivationStrategy() as StandardDerivationStrategyBase)! + .GetDerivation(_utxoKeyPath); + _walletScript = derivation.ScriptPubKey; + + var funding = Network.CreateTransaction(); + funding.Outputs.Add(new TxOut(Money.Coins(0.1m), _walletScript)); + _coin = new Coin(funding, 0U).ToScriptCoin(derivation.Redeem); + + ApprovedAddress = new NBitcoin.Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, Network); + AttackerAddress = new NBitcoin.Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, Network); + + TemplateBase64 = ToPsbt(Spend(ApprovedAddress, ApprovedAmount)).ToBase64(); + + // A valid signature over a different transaction, made with the key the signer legitimately holds. + var attackerPsbt = ToPsbt(Spend(AttackerAddress, AttackerAmount)); + attackerPsbt.SignWithKeys(DeriveHumanPrivateKey(humanKey)); + AttackerApprovalBase64 = attackerPsbt.ToBase64(); + } + + private Transaction Spend(BitcoinAddress destination, Money amount) + { + var tx = Network.CreateTransaction(); + tx.Inputs.Add(new TxIn(_coin.Outpoint)); + tx.Outputs.Add(new TxOut(amount, destination.ScriptPubKey)); + return tx; + } + + private PSBT ToPsbt(Transaction tx) => PSBT.FromTransaction(tx, Network).AddCoins(_coin); + + // Mirrors Wallet.DeriveUtxoPrivateKey for the human key: master -> account path -> utxo path. + private NBitcoin.Key DeriveHumanPrivateKey(Key humanKey) + => new Mnemonic(HumanSeed) + .DeriveExtKey() + .GetWif(Network) + .Derive(KeyPath.Parse(humanKey.Path!)) + .Derive(_utxoKeyPath) + .PrivateKey; + + internal WalletWithdrawalRequest BuildRequest(string humanApprovalBase64) => new() + { + Id = 1, + Status = WalletWithdrawalRequestStatus.PSBTSignaturesPending, + Wallet = Wallet, + WalletId = Wallet.Id, + WalletWithdrawalRequestPSBTs = new List + { + new() { IsTemplatePSBT = true, PSBT = TemplateBase64 }, + new() { IsTemplatePSBT = false, PSBT = humanApprovalBase64, SignerId = "human-user" }, + }, + WalletWithdrawalRequestDestinations = new List + { + new() { Address = ApprovedAddress.ToString(), Amount = ApprovedAmount.ToUnit(MoneyUnit.BTC) }, + }, + }; + + internal UTXOChanges BuildUtxoChanges() => new() + { + Confirmed = new UTXOChange + { + UTXOs = new List + { + new() + { + Outpoint = _coin.Outpoint, + Value = _coin.Amount, + ScriptPubKey = _walletScript, + KeyPath = _utxoKeyPath, + }, + }, + }, + }; + } } diff --git a/test/NodeGuard.Tests/Shared/PSBTSignTests.cs b/test/NodeGuard.Tests/Shared/PSBTSignTests.cs new file mode 100644 index 00000000..cea48c09 --- /dev/null +++ b/test/NodeGuard.Tests/Shared/PSBTSignTests.cs @@ -0,0 +1,153 @@ +/* + * 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 System.Reflection; +using FluentAssertions; +using NBitcoin; + +namespace NodeGuard.Shared; + +/// +/// The PSBTSign approval component must validate a pasted PSBT against the request's real template, so a +/// signed PSBT for a different transaction is rejected before it can be stored as an approval. ValidatePSBT +/// delegates to using the component's TemplatePsbtString +/// parameter; these tests pin that the component wires the template through and surfaces a rejection. +/// +/// ValidatePSBT is a private method on a Razor component and bUnit is not referenced, so the component cannot +/// be rendered in a test. The method is pure with respect to its argument — no injected services, no JS +/// interop, no render tree — so invoking it directly by reflection exercises exactly the code the Approve +/// button runs on the server (this is Blazor Server: the component's @code executes in the circuit). +/// +public class PSBTSignTests +{ + /// + /// A PSBT for a different transaction, paying a different destination and validly signed with the + /// signer's own key, must be rejected. ValidatePSBT returns an empty string only for a valid approval, + /// and that empty result is what lets the Approve button proceed — so a non-empty error is required here. + /// + [Fact] + public void ValidatePSBT_RejectsSignedPsbtForADifferentTransaction() + { + var fixture = BuildSubstitutionFixture(); + + var errors = InvokeValidatePsbt(fixture.TemplateBase64, fixture.ForeignSignedBase64); + + errors.Should().NotBeNullOrWhiteSpace( + "the pasted PSBT must be compared against the template, so a signed PSBT describing a " + + "different transaction is refused"); + } + + /// An unsigned PSBT is rejected (every input must carry a signature). + [Fact] + public void ValidatePSBT_RejectsUnsignedPsbt() + { + var fixture = BuildSubstitutionFixture(); + + var errors = InvokeValidatePsbt(fixture.TemplateBase64, fixture.ForeignUnsignedBase64); + + errors.Should().NotBeNullOrWhiteSpace("an unsigned PSBT must be rejected"); + } + + /// Unparseable input is rejected — confirms the reflection harness reaches the real method. + [Fact] + public void ValidatePSBT_RejectsGarbage() + { + var fixture = BuildSubstitutionFixture(); + + var errors = InvokeValidatePsbt(fixture.TemplateBase64, "not-a-psbt"); + + errors.Should().NotBeNullOrWhiteSpace("unparseable input must be rejected"); + } + + // ---- fixture ------------------------------------------------------------------------------------- + + private sealed record SubstitutionFixture( + string TemplateBase64, + string ForeignSignedBase64, + string ForeignUnsignedBase64); + + /// + /// Builds the template the approver is asked to sign, plus their own transaction spending the same + /// wallet UTXO to a different address — one signed, one unsigned. + /// + private static SubstitutionFixture BuildSubstitutionFixture() + { + var network = Network.RegTest; + + var signingKey = new Key(); + var walletScript = signingKey.PubKey.GetScriptPubKey(ScriptPubKeyType.Segwit); + + var funding = network.CreateTransaction(); + funding.Outputs.Add(new TxOut(Money.Coins(1m), walletScript)); + var coin = new Coin(funding, 0U); + + var approvedDestination = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, network); + var foreignDestination = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, network); + + Transaction Spend(BitcoinAddress destination, Money amount) + { + var tx = network.CreateTransaction(); + tx.Inputs.Add(new TxIn(coin.Outpoint)); + tx.Outputs.Add(new TxOut(amount, destination.ScriptPubKey)); + return tx; + } + + var templatePsbt = PSBT.FromTransaction(Spend(approvedDestination, Money.Coins(0.5m)), network) + .AddCoins(coin); + + // Same input, a different destination and amount — a different transaction id. + var foreignTx = Spend(foreignDestination, Money.Coins(0.9m)); + var foreignUnsigned = PSBT.FromTransaction(foreignTx, network).AddCoins(coin); + + var foreignSigned = PSBT.FromTransaction(foreignTx, network).AddCoins(coin); + foreignSigned.SignWithKeys(signingKey); + + return new SubstitutionFixture( + templatePsbt.ToBase64(), + foreignSigned.ToBase64(), + foreignUnsigned.ToBase64()); + } + + /// + /// Invokes PSBTSign's private ValidatePSBT with the real template supplied through the component's + /// TemplatePsbtString parameter. Returns the validator's error string ("" meaning valid); a thrown + /// exception is surfaced as an error string, since that is also a rejection. + /// + private static string InvokeValidatePsbt(string templateBase64, string pastedBase64) + { + var component = new PSBTSign + { + TemplatePsbtString = templateBase64, + SigHashMode = SigHash.All, + }; + + var method = typeof(PSBTSign).GetMethod("ValidatePSBT", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("PSBTSign.ValidatePSBT not found."); + + try + { + return (string)method.Invoke(component, new object?[] { pastedBase64 })!; + } + catch (TargetInvocationException ex) when (ex.InnerException is not null) + { + return $"threw {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"; + } + } +} From 355050f46f86b9272dd126939d26fb33782d1478 Mon Sep 17 00:00:00 2001 From: Marcos <33052423+markettes@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:45:12 +0900 Subject: [PATCH 16/21] E2E tests: containerized end-to-end suite for rebalance + dynamic fee engine (#559) * feat: add GetInfo and GetBlockHeight methods to LightningClientService and LightningService * feat(migrations): add routing engine foundation with new columns and tables * feat: implement repositories for channel fee, flow analytics, and routing states; add corresponding interfaces and tests * feat: add BlockHeightHelper and ChannelOwnershipHelper with corresponding tests * feat: add PeerCategorizationService with tests for category computation logic * feat: add TargetRatioReevaluationJob for routing engine with scheduling configuration * feat: add heuristic routing engine configuration options for dynamic fee management and automated rebalancing * feat: update ROUTING_ENGINE_DRY_RUN to handle null values in environment variable * feat: enhance SetChannelFeePolicy to support engine-driven fee updates and improve audit logging * feat: update routing engine fee parameters * feat: add methods to retrieve channel fee states by managed node pub key and check in-flight rebalances by source channel * feat: Implement Fee Optimizer Service and Channel Fee Optimization Job * chore: rename test file * feat: migrate channel flow analytics methods to forwarding HTLC event repository and remove unused repository interfaces * feat: update RoutingEngineDryRun default value to false and adjust related documentation * fix: change default value of IsDynamicFeeEnabled to false in Channel model and related migrations * feat: remove PeerCategorizationService from service registrations in Program.cs * fix: remove every mention to phases or development steps * docs: enhance comments in Constants.cs for clarity on routing engine parameters * refactor: remove circuit breaker feature * refactor: remove circuit breaker logic from ChannelFeeOptimizerJob * fix: align ChannelFeeOptimizerJob with PR1 (ForwardingHtlcEventRepository + no dry-run) * refactor: remove ReservedFeeSats from Rebalance model and related migrations * test: clarify comments and update test logic in RebalanceRepositoryRoutingEngineTests * fix: ensure only active channels are processed in TargetRatioReevaluationJob * refactor: remove RebalanceDeadband from FeeOptimizerService and related tests * fix: remove restore fees when auto fee enabled * feat: allow positive inbound fees if node is enabled * feat: check for enabling channel auto fees and blocing changing them if enabled * feat: add methods to retrieve open channels and channels with dynamic fee enabled * refactor: remove unnecessary baseline * refactor: update fee optimizer constants and methods for fee calculations * refactor: simplify retrieval of open channels in TargetRatioReevaluationJob * refactor: improve ChannelFeeOptimizerJob * refactor: remove baseline fee properties from ChannelFeeState and related migrations * refactor: change max outbound ppm back to 5000 * feat: remove ChannelFeeState when channel or node is disabled from the fee engine * fix: change comments to reflect the behavior * fix: remove unnecessary repository * fix: update comment to include ChannelFeeOptimizerJob in job cadence description * refactor: optimize channel lookup by using dictionary for channels in ChannelFeeOptimizerJob and TargetRatioReevaluationJob * feat(migrations): add routing engine foundation with new columns and tables * feat: implement repositories for channel fee, flow analytics, and routing states; add corresponding interfaces and tests * feat: add PeerCategorizationService with tests for category computation logic * feat: update ROUTING_ENGINE_DRY_RUN to handle null values in environment variable * feat: enhance SetChannelFeePolicy to support engine-driven fee updates and improve audit logging * feat: update routing engine fee parameters * feat: add methods to retrieve channel fee states by managed node pub key and check in-flight rebalances by source channel * feat: Implement Fee Optimizer Service and Channel Fee Optimization Job * feat: migrate channel flow analytics methods to forwarding HTLC event repository and remove unused repository interfaces * feat: remove PeerCategorizationService from service registrations in Program.cs * fix: remove every mention to phases or development steps * refactor: remove circuit breaker logic from ChannelFeeOptimizerJob * fix: align ChannelFeeOptimizerJob with PR1 (ForwardingHtlcEventRepository + no dry-run) * refactor: remove RebalanceDeadband from FeeOptimizerService and related tests * refactor: remove ReservedFeeSats from Rebalance model and related migrations * feat: add methods to retrieve open channels and channels with dynamic fee enabled * refactor: remove unnecessary baseline * refactor: update fee optimizer constants and methods for fee calculations * refactor: simplify retrieval of open channels in TargetRatioReevaluationJob * refactor: improve ChannelFeeOptimizerJob * refactor: change max outbound ppm back to 5000 * feat: remove ChannelFeeState when channel or node is disabled from the fee engine * fix: change comments to reflect the behavior * fix: remove unnecessary repository * fix: update comment to include ChannelFeeOptimizerJob in job cadence description * refactor: optimize channel lookup by using dictionary for channels in ChannelFeeOptimizerJob and TargetRatioReevaluationJob * feat: add PeerCategorizationService and adjust job scheduling interval * feat: add E2ETestBase for shared plumbing in end-to-end tests * feat: add TargetRatioReevaluationJobTests to validate channel categorization logic * refactor: replace service interfaces with static classes for FeeOptimizer and PeerCategorization * feat: update routing engine fee parameters and introduce new thresholds for better fee management * feat: rename GetChannelsFeeEngine to GetChannelsByOpenAndDynamicFeeEnabled for clarity * chore: change log level from Debug to Information for channel action logging * refactor: simplify documentation for ComputeNextPolicy parameters in FeeOptimizerService * refactor: update DeleteByChannelId method to return boolean instead of int for clarity * feat(e2e): Implement fee engine flow tests and enhance routing job scheduling * fix: Refactor fee engine tests and remove unused PriorityOrderer class * refactor: reusing base methods and remove unused methods from test * docs: Update README.md for e2e test structure and execution details * fix: Enhance error handling for already connected peers in LndTestClient * fix: update database connection string variable name * feat: add minimum channel size configuration for routing engine --- .github/workflows/dotnet.yml | 6 +- .justfile | 7 +- docker/e2e/README.md | 79 ++--- docker/e2e/docker-compose.yml | 48 ++-- src/Program.cs | 5 +- .../E2E/DustUtxoWithdrawalE2ETests.cs | 52 +--- test/NodeGuard.Tests/E2E/E2ETestBase.cs | 173 +++++++++++ test/NodeGuard.Tests/E2E/FeeEngineE2EBase.cs | 70 +++++ test/NodeGuard.Tests/E2E/FeeEngineE2ETests.cs | 136 +++++++++ .../E2E/FeeEngineFlowE2ETests.cs | 272 ++++++++++++++++++ test/NodeGuard.Tests/E2E/LndTestClient.cs | 183 ++++++++++++ test/NodeGuard.Tests/E2E/RebalanceE2ETests.cs | 122 +------- 12 files changed, 925 insertions(+), 228 deletions(-) create mode 100644 test/NodeGuard.Tests/E2E/E2ETestBase.cs create mode 100644 test/NodeGuard.Tests/E2E/FeeEngineE2EBase.cs create mode 100644 test/NodeGuard.Tests/E2E/FeeEngineE2ETests.cs create mode 100644 test/NodeGuard.Tests/E2E/FeeEngineFlowE2ETests.cs create mode 100644 test/NodeGuard.Tests/E2E/LndTestClient.cs diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 9de51eb5..d3bdfa20 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -102,10 +102,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Run e2e tests - # Clean slate first (DbInitializer only funds the dev wallets on an empty DB), then `run` - # brings up the chain (bitcoind → alice/bob/carol → setup-e2e + extract-env → nodeguard → - # e2e-runner). The runner's exit code becomes the job result. + - name: Run e2e tests (one stack, one ordered pass) + # Clean slate first (DbInitializer only funds the dev wallets on an empty DB), then run the E2E suite (Category=E2E) in one pass: rebalance, fee-engine smoke, fee-engine flow, plus the wallet/UTXO e2e tests. run: | COMPOSE_PROFILES=polar,e2e docker compose -f docker-compose.yml down -v --remove-orphans || true COMPOSE_PROFILES=polar,e2e docker compose -f docker-compose.yml run --rm --build e2e-runner diff --git a/.justfile b/.justfile index 3d6f7716..8815ce54 100644 --- a/.justfile +++ b/.justfile @@ -108,10 +108,9 @@ docker-down: docker-rm: docker compose --profile polar --profile loop --profile 40swap --profile e2e --profile mempool -f {{DOCKER_COMPOSE_FILE}} down -v -# Runs the option-B end-to-end rebalance test in containers: brings up the regtest stack + a live -# NodeGuard, then the runner opens a channel via gRPC and rebalances. Exit code = test result. -# Starts from a CLEAN slate (down -v first) — DbInitializer only funds the dev wallets when the DB -# has none, so a stale postgres volume would leave the wallet unfunded ("no UTXOs" on OpenChannel). +# Runs the full ordered e2e suite (E2ESuiteTests, Category=E2E) in one stack, one `dotnet test` pass: +# rebalance → fee-engine smoke → fee-engine flow. Clean slate first (down -v): DbInitializer only funds the +# dev wallets on an empty DB, so a stale volume leaves it unfunded ("no UTXOs" on OpenChannel). test-e2e: -docker compose --profile polar --profile e2e -f {{DOCKER_COMPOSE_FILE}} down -v --remove-orphans docker compose --profile polar --profile e2e -f {{DOCKER_COMPOSE_FILE}} run --rm --build e2e-runner diff --git a/docker/e2e/README.md b/docker/e2e/README.md index bcbe5c69..b27b4a34 100644 --- a/docker/e2e/README.md +++ b/docker/e2e/README.md @@ -1,9 +1,17 @@ -# Containerized rebalance e2e (option B) +# Containerized e2e suite -A true end-to-end test of Lightning rebalancing: a .NET test runner drives a **live NodeGuard** -entirely over gRPC — it **opens the source channel via NodeGuard's `OpenChannel` API** (so the e2e -also covers channel opening), mines via NBitcoin's `RPCClient`, then performs a circular rebalance -Alice→Bob→Carol→Alice and asserts success. +A true end-to-end test against a **live NodeGuard**: a .NET runner drives it over gRPC — opens the source +channel via NodeGuard's `OpenChannel` API, mines via NBitcoin's `RPCClient`, runs a circular rebalance +Alice→Bob→Carol→Alice, and exercises the dynamic fee engine (smoke + a live SINK→SOURCE flow) — all in one +`dotnet test` pass. + +`just test-e2e` brings the stack up once and runs the entire `Category=E2E` suite in one `dotnet test` pass. +Its rebalance/fee-engine core is three scenarios — **(1)** rebalance (`RebalanceE2ETests`), **(2)** fee-engine +smoke (`FeeEngineE2ETests`), **(3)** fee-engine flow (`FeeEngineFlowE2ETests`; SINK→SOURCE, driving its own +LND traffic in-process via `LndTestClient`) — and the same pass also runs the wallet/UTXO e2e tests +(`DustUtxoWithdrawalE2ETests`, `GetNewWalletAddressE2ETests`). Every e2e class is **order-agnostic** (it +provisions its own channels and resets its own state) and they run **serially** via the shared +`[Collection("E2E")]` — one regtest chain can't take concurrent channel opens/traffic. ## Run it @@ -11,43 +19,36 @@ Alice→Bob→Carol→Alice and asserts success. just test-e2e # or: COMPOSE_PROFILES=polar,e2e docker compose run --rm --build e2e-runner ``` -Everything is profile-gated (`e2e`), so a normal `tilt up` / `docker compose up` ignores it. In the -Tilt UI the e2e services appear under the `e2e` label, disabled (manual trigger only). +Everything is profile-gated (`e2e`), so a normal `tilt up` / `docker compose up` ignores it. ## Pieces | File | Role | |------|------| -| `setup-e2e.sh` | Loads the bitcoind wallet, funds the LND nodes, opens **only** Bob→Carol + Carol→Alice (NodeGuard opens Alice→Bob). docker.sock pattern, like the polar `setup` service. | -| `extract-env.sh` | Writes `nodeguard-macaroons.env` (LND host/macaroon/pubkey) from the mounted LND data volumes + a network `lncli getinfo`. The LND certs include the service-name SANs (`alice`/`bob`/`carol`), so TLS verifies. | +| `setup-e2e.sh` | Loads the bitcoind wallet, funds the LND nodes, opens **only** Bob→Carol + Carol→Alice (NodeGuard opens Alice→Bob). docker.sock, like the polar `setup` service. | +| `extract-env.sh` | Writes `nodeguard-macaroons.env` (LND host/macaroon/pubkey) from the LND data volumes; the certs carry service-name SANs so TLS verifies. | | `nodeguard-entrypoint.sh` | Sources that env file, then launches NodeGuard. | -| `Dockerfile.runner` | .NET SDK image that runs `dotnet test --filter Category=E2E`. The test does the gRPC + mining itself — no grpcurl/curl. | -| `docker-compose.yml` | Wires `setup-e2e` + `extract-env` → `nodeguard` → `e2e-runner` with the right `depends_on` ordering. | - -The test itself is `test/NodeGuard.Tests/E2E/RebalanceE2ETests.cs`, gated by `[E2EFact]` (runs when a -NodeGuard gRPC is reachable on `NODEGUARD_GRPC_ENDPOINT`, or `RUN_E2E_TESTS=1`). - -## Status / shakeout notes - -Every **runtime step** was validated manually against a live NodeGuard (open channel via gRPC → -`ONCHAIN_CONFIRMED` → rebalance `Succeeded`, 500 sat fee). The **compose wiring** has not yet been run -as a full stack in CI; watch these on the first CI run: - -- **App config env (fixed):** the published image (`dotnet NodeGuard.dll`) does NOT read - `launchSettings.json`, so `Constants`' required vars (`MINIMUM_CHANNEL_CAPACITY_SATS`, - `TRANSACTION_CONFIRMATION_MINIMUM_BLOCKS`, `ANCHOR_CLOSINGS_MINIMUM_SATS`, `DEFAULT_DERIVATION_PATH`, - `NBXPLORER_URI`, …) are set explicitly on the `nodeguard` service. Keep them in sync with the dev - launch profile. -- **Clean slate required (fixed):** `DbInitializer` only funds the dev wallets when the DB has none - (`!Wallets.Any()`). A stale postgres volume from an interrupted run leaves the wallet rows present - but unfunded against the fresh chain → `OpenChannel` fails with "Error generating template PSBT" - (no UTXOs). `just test-e2e` and the CI job now `down -v` before running. -- **Critical ordering:** `nodeguard` must start only after `setup-e2e` completes — NodeGuard funds its - hot wallet via bitcoind RPC, which fails ("No wallet is loaded") if the bitcoind wallet isn't loaded - yet. The `depends_on: service_completed_successfully` enforces this. -- **Shared volumes** (`alice_lnd_data`/`bob_lnd_data`/`carol_lnd_data`) are declared in the polar - compose; they're referenced here by name within the same merged project. -- **`nonroot` entrypoint:** the NodeGuard image runs as uid 65532; the entrypoint wrapper + `/shared` - mount must be readable by it. -- **docker.sock** must be available to `setup-e2e` (it is on GitHub-hosted runners). -- **Hot wallet id** is assumed to be `3` (the dev single-sig wallet); override via `E2E_HOT_WALLET_ID`. +| `Dockerfile.runner` | .NET SDK image that runs `dotnet test --filter Category=E2E`. The tests do the gRPC + mining + Postgres reads themselves — no grpcurl/curl. | +| `docker-compose.yml` | Wires `setup-e2e` + `extract-env` → `nodeguard` → `e2e-runner`. | + +Tests live in `test/NodeGuard.Tests/E2E/`. The rebalance/fee-engine scenarios are `RebalanceE2ETests`, +`FeeEngineE2ETests`, and `FeeEngineFlowE2ETests` (on `E2ETestBase` / `FeeEngineE2EBase`), with `LndTestClient` +(direct LND gRPC driver for the flow scenario); the wallet/UTXO tests (`DustUtxoWithdrawalE2ETests`, +`GetNewWalletAddressE2ETests`) sit alongside them. All are `[Collection("E2E")]` and gated by `[E2EFact]` +(they run when NodeGuard gRPC is reachable, or `RUN_E2E_TESTS=1`). + +## Notes + +- **Clean slate**: `DbInitializer` only funds the dev wallets on an empty DB, so `just test-e2e` and CI + `down -v` first — a stale postgres volume leaves the wallet unfunded ("no UTXOs" on `OpenChannel`). +- **Startup order**: `nodeguard` starts only after `setup-e2e` completes (it funds its hot wallet via + bitcoind RPC, which needs the wallet loaded); `depends_on: service_completed_successfully` enforces it. +- **App config env**: the published image doesn't read `launchSettings.json`, so `Constants`' required vars + are set explicitly on the `nodeguard` service — keep them in sync with the dev launch profile. +- **Flow scenario LND access**: scenario (3) drives alice/bob/carol's LND directly, so it needs + `{NODE}_HOST` + `{NODE}_MACAROON` (from `extract-env`). `LndTestClient` reads them from the process env + or, failing that, straight from `nodeguard-macaroons.env` on the mounted `e2e_env` volume — so it works + without the runner entrypoint exporting them. +- **Adding an e2e test**: put it in `[Collection("E2E")]` (so it serialises with the others on the one + regtest chain — without it the class runs in parallel and they interfere), have it provision the + resources it needs and reset its own state, and gate it with `[E2EFact]`. diff --git a/docker/e2e/docker-compose.yml b/docker/e2e/docker-compose.yml index 5081414c..4fd4034b 100644 --- a/docker/e2e/docker-compose.yml +++ b/docker/e2e/docker-compose.yml @@ -1,19 +1,22 @@ # Option-B end-to-end stack: a LIVE NodeGuard whose gRPC API is driven by a .NET test runner. # All services are profile-gated ([e2e]) so a normal `tilt up` / `docker compose up` ignores them. # -# Flow (depends_on order): bitcoind + alice/bob/carol (from the polar stack) → +# depends_on order: bitcoind + alice/bob/carol (polar stack) → # setup-e2e (loads bitcoind wallet, funds nodes, opens Bob→Carol + Carol→Alice — NOT Alice→Bob) + -# extract-env (writes the LND macaroon/host/pubkey env from the mounted data volumes) → +# extract-env (writes the LND macaroon/host/pubkey env) → # nodeguard (seeds nodes, funds its hot wallet) → -# e2e-runner (opens Alice→Bob via gRPC, mines, then rebalances and asserts). +# e2e-runner (runs the whole E2E suite, Category=E2E, in one pass). # -# NOTE: built but not yet runtime-verified as a stack (see docker/e2e/README.md) — every runtime -# STEP was validated manually against a live NodeGuard; the compose wiring needs a CI shakeout. +# The fee-engine flow scenario drives alice/bob/carol's LND directly (reading the extract-env creds file off +# the mounted volume), so there's no traffic sidecar and no second runner. The scenarios are order-agnostic +# but run serially (one test class). +# +# NOTE: not yet runtime-verified as a stack (see docker/e2e/README.md) — every runtime STEP was validated +# manually against a live NodeGuard; the compose wiring needs a CI shakeout. services: - # Loads the bitcoind wallet + funds LND + opens the two non-source channels. docker.sock pattern - # mirrors the polar `setup` service. Must complete before nodeguard boots (NodeGuard funds its - # hot wallet via bitcoind RPC, which needs a loaded wallet). + # Loads the bitcoind wallet, funds LND, opens Bob→Carol + Carol→Alice (docker.sock, like polar `setup`). + # Must finish before nodeguard, which funds its hot wallet via bitcoind RPC (needs a loaded wallet). setup-e2e: profiles: [e2e] image: alpine:latest @@ -58,9 +61,10 @@ services: nbxplorer: { condition: service_healthy } setup-e2e: { condition: service_completed_successfully } extract-env: { condition: service_completed_successfully } - # The published image (dotnet NodeGuard.dll) does NOT read launchSettings.json, so every env - # var the dev profile normally provides must be set here, container-adjusted (service names). - # ALICE/BOB/CAROL_HOST + *_MACAROON + *_PUBKEY are sourced from extract-env (overrides any here). + ports: + - "38080:8080" # NodeGuard gRPC + REST API + # The published image doesn't read launchSettings.json, so every dev-profile env var is set here + # (service-name-adjusted). *_HOST/*_MACAROON/*_PUBKEY come from extract-env. environment: IS_DEV_ENVIRONMENT: "true" ASPNETCORE_ENVIRONMENT: "Development" @@ -89,13 +93,25 @@ services: COINGECKO_ENDPOINT: "https://pro-api.coingecko.com/api/v3/coins/markets?vs_currency=usd&ids=bitcoin" API_TOKEN_SALT: "H/fCx1+maAFMcdi6idIYEg==" FUNDSMANAGER_ENDPOINT: "http://nodeguard:8080" + # MonitorChannelsJob discovers the externally-opened channels the fee tests reuse (Bob→Carol, + # Alice→Bob); a fast 5s cron records them promptly (prod keeps the default hourly cron). + MONITOR_CHANNELS_CRON: "0/5 * * * * ?" + ROUTING_ENGINE_ENABLED: "true" + ROUTING_ENGINE_DRY_RUN: "false" + ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS: "15000000" + ROUTING_ENGINE_JOB_INTERVAL_SECONDS: "15" + ROUTING_ENGINE_CATEGORIZATION_MIN_AGE_BLOCKS: 0 + ROUTING_ENGINE_FLOW_MIN_MSAT: 1000000 + ROUTING_ENGINE_CATEGORY_FLIP_HYSTERESIS_CYCLES: 0 entrypoint: ["/bin/sh", "/scripts/nodeguard-entrypoint.sh"] volumes: - ./nodeguard-entrypoint.sh:/scripts/nodeguard-entrypoint.sh:ro - e2e_env:/shared:ro restart: "no" - # Drives NodeGuard's gRPC: OpenChannel(Alice→Bob) → mine + poll → RequestRebalance → assert. + # Runs the whole E2E suite (Category=E2E) against one live NodeGuard in a single pass. + # Scenario (3) drives alice/bob/carol's LND directly, so the runner mounts the extract-env volume and + # its entrypoint sources the LND host+macaroon env before `dotnet test`. e2e-runner: profiles: [e2e] build: @@ -103,6 +119,7 @@ services: dockerfile: docker/e2e/Dockerfile.runner depends_on: nodeguard: { condition: service_started } + extract-env: { condition: service_completed_successfully } environment: RUN_E2E_TESTS: "1" NODEGUARD_GRPC_ENDPOINT: "http://nodeguard:50051" @@ -112,13 +129,12 @@ services: BITCOIND_RPC_PASS: "polarpass" BITCOIND_RPC_WALLET: "default" E2E_HOT_WALLET_ID: "3" - # The HTLC-reconnect test reads persisted ForwardingHtlcEvents straight from Postgres - # (there is no gRPC to list them) and restarts a forwarding LND node mid-test. POSTGRES_CONNECTIONSTRING: "Host=postgres;Port=5432;Database=nodeguard;User ID=postgres;" E2E_FORWARDING_NODE_CONTAINER: "polar-n1-bob" + ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS: "15000000" volumes: - # Lets the reconnect test restart an LND container via the Docker Engine API socket. - - /var/run/docker.sock:/var/run/docker.sock + - /var/run/docker.sock:/var/run/docker.sock # Lets the reconnect test restart an LND container via the Docker Engine API socket. + - e2e_env:/shared:ro # LND host+macaroon env (extract-env); the flow scenario reads it directly restart: "no" volumes: diff --git a/src/Program.cs b/src/Program.cs index 22ba0d53..2ffa642d 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -289,7 +289,7 @@ public static async Task Main(string[] args) { if (Constants.IS_DEV_ENVIRONMENT) { - scheduleBuilder.WithIntervalInMinutes(5).RepeatForever(); + scheduleBuilder.WithIntervalInMinutes(1).RepeatForever(); } else { @@ -405,7 +405,8 @@ public static async Task Main(string[] args) q.AddTrigger(opts => { - opts.ForJob(nameof(MonitorChannelsJob)).WithIdentity($"{nameof(MonitorChannelsJob)}Trigger") + opts.ForJob(nameof(MonitorChannelsJob)) + .WithIdentity($"{nameof(MonitorChannelsJob)}Trigger") .StartNow().WithCronSchedule(Constants.MONITOR_CHANNELS_CRON); }); diff --git a/test/NodeGuard.Tests/E2E/DustUtxoWithdrawalE2ETests.cs b/test/NodeGuard.Tests/E2E/DustUtxoWithdrawalE2ETests.cs index b8fa523b..679965d7 100644 --- a/test/NodeGuard.Tests/E2E/DustUtxoWithdrawalE2ETests.cs +++ b/test/NodeGuard.Tests/E2E/DustUtxoWithdrawalE2ETests.cs @@ -17,10 +17,7 @@ * */ -using System.Net; using FluentAssertions; -using Grpc.Core; -using Grpc.Net.Client; using NBitcoin; using NBitcoin.RPC; using Nodeguard; @@ -33,7 +30,7 @@ namespace NodeGuard.Tests.E2E; /// 546-sat output sent to a NodeGuard hot wallet must be hidden from GetAvailableUtxos and must /// never be auto-selected as an input of a withdrawal, even though the coin selection picks the /// newest confirmed UTXO first (which the freshly-mined dust output would otherwise be). -/// Exercised against a LIVE NodeGuard instance + bitcoind. +/// Exercised against a LIVE NodeGuard instance + bitcoind; shared plumbing in . /// Gated by (RUN_E2E_TESTS=1). Connection via env: /// NODEGUARD_GRPC_ENDPOINT default http://localhost:50051 (h2c) /// NODEGUARD_API_TOKEN default the dev "Liquidator" token @@ -42,20 +39,15 @@ namespace NodeGuard.Tests.E2E; /// [Trait("Category", "E2E")] [Collection("E2E")] -public class DustUtxoWithdrawalE2ETests +public class DustUtxoWithdrawalE2ETests : E2ETestBase { - private const string DefaultDevToken = "8rvSsUGeyXXdDQrHctcTey/xtHdZQEn945KHwccKp9Q="; private const long DustAmountSats = 546; // The custom NBXplorer selectutxos backend behind GetAvailableUtxos picks UTXOs toward a // target amount (amount=0 always yields an empty selection), so every call must request one. private const long ProbeAmountSats = 2_000_000; - private readonly ITestOutputHelper _output; - - public DustUtxoWithdrawalE2ETests(ITestOutputHelper output) + public DustUtxoWithdrawalE2ETests(ITestOutputHelper output) : base(output) { - _output = output; - AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); } [E2EFact] @@ -174,42 +166,4 @@ await RetryAsync(async () => allUtxos.Confirmed.Select(u => u.Outpoint).Should().Contain(dustOutpoint.ToString(), "the dust UTXO must be left untouched in the wallet"); } - - // ---- helpers ----------------------------------------------------------------------------- - - private async Task MineAsync(RPCClient rpc, int blocks) - { - var addr = await rpc.GetNewAddressAsync(); - await rpc.GenerateToAddressAsync(blocks, addr); - } - - private async Task RetryAsync(Func> action, int attempts, TimeSpan delay, string what) - { - Exception? last = null; - for (var i = 0; i < attempts; i++) - { - try { return await action(); } - catch (Exception ex) { last = ex; _output.WriteLine($"{what} attempt {i + 1}/{attempts} failed: {ex.Message}"); } - await Task.Delay(delay); - } - throw new InvalidOperationException($"{what} did not succeed after {attempts} attempts", last); - } - - private static NodeGuardService.NodeGuardServiceClient CreateClient(out Metadata headers) - { - var endpoint = Env("NODEGUARD_GRPC_ENDPOINT", "http://localhost:50051"); - headers = new Metadata { { "auth-token", Env("NODEGUARD_API_TOKEN", DefaultDevToken) } }; - return new NodeGuardService.NodeGuardServiceClient(GrpcChannel.ForAddress(endpoint)); - } - - private static RPCClient CreateBitcoindRpc() - { - var url = Env("BITCOIND_RPC_URL", "http://localhost:18443"); - var cred = new NetworkCredential(Env("BITCOIND_RPC_USER", "polaruser"), Env("BITCOIND_RPC_PASS", "polarpass")); - var rpc = new RPCClient(cred, new Uri(url), Network.RegTest); - return rpc.SetWalletContext(Env("BITCOIND_RPC_WALLET", "default")); - } - - private static string Env(string name, string fallback) - => Environment.GetEnvironmentVariable(name) is { Length: > 0 } v ? v : fallback; } diff --git a/test/NodeGuard.Tests/E2E/E2ETestBase.cs b/test/NodeGuard.Tests/E2E/E2ETestBase.cs new file mode 100644 index 00000000..7ccd8506 --- /dev/null +++ b/test/NodeGuard.Tests/E2E/E2ETestBase.cs @@ -0,0 +1,173 @@ +/* + * 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 System.Net; +using FluentAssertions; +using Grpc.Core; +using Grpc.Net.Client; +using NBitcoin; +using NBitcoin.RPC; +using Nodeguard; +using Xunit.Abstractions; + +namespace NodeGuard.Tests.E2E; + +/// +/// Shared plumbing for the container e2e tests (gated by ): the gRPC +/// client + auth header, the bitcoind RPC client, mining, retry/poll loops, NodeGuard readiness, and +/// the common "open source→dest through NodeGuard and wait for it to confirm" flow. Concrete test +/// classes add their own [E2EFact] methods and assertions on top. +/// +/// This is pure code-sharing only — it is NOT a collection fixture and assigns no xUnit collection or +/// trait, so each concrete test keeps its own [Trait("Category", …)] and its own (default) +/// parallelisation behaviour. In particular the fee-engine e2e stays under a separate category so it +/// never runs in the same pass as the rebalance e2e. +/// +/// Connection via env (all with dev-friendly defaults): +/// NODEGUARD_GRPC_ENDPOINT default http://localhost:50051 (h2c) +/// NODEGUARD_API_TOKEN default the dev "Liquidator" token +/// BITCOIND_RPC_URL/USER/PASS/WALLET default http://localhost:18443 / polaruser / polarpass / default +/// E2E_HOT_WALLET_ID NodeGuard hot wallet to fund the channel (default 3) +/// +public abstract class E2ETestBase +{ + private const string DefaultDevToken = "8rvSsUGeyXXdDQrHctcTey/xtHdZQEn945KHwccKp9Q="; + + protected readonly ITestOutputHelper _output; + + protected E2ETestBase(ITestOutputHelper output) + { + _output = output; + AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); + } + + /// + /// Waits for NodeGuard to serve gRPC and to have seeded alice/bob/carol. Generous window: a fresh + /// NodeGuard runs migrations + funds its wallet (mining + NBXplorer sync) before serving gRPC. + /// + protected async Task> WaitForNodesAsync( + NodeGuardService.NodeGuardServiceClient client, Metadata headers) + { + return await RetryAsync(async () => + { + var resp = await client.GetNodesAsync(new GetNodesRequest(), headers); + var seeded = resp.Nodes.Where(n => n.Name is "alice" or "bob" or "carol").ToList(); + if (seeded.Count < 3) throw new InvalidOperationException($"only {seeded.Count}/3 nodes seeded"); + return (IReadOnlyList)seeded; + }, attempts: 90, delay: TimeSpan.FromSeconds(4), what: "GetNodes (NodeGuard readiness)"); + } + + /// + /// Opens THROUGH NodeGuard + /// (wallet → PSBT → internal signing → broadcast), mines until the funding tx confirms and + /// NodeGuard records the channel id, then mines a few more so LND marks the channel active and + /// gossip propagates. Returns NodeGuard's channel id. + /// + protected async Task OpenChannelAndConfirmAsync( + NodeGuardService.NodeGuardServiceClient client, Metadata headers, RPCClient rpc, + string sourcePubKey, string destPubKey, long satsAmount = 16_000_000) + { + // Retry briefly in case the dev hot wallet is still being funded by DbInitializer when we connect. + var walletId = int.Parse(Env("E2E_HOT_WALLET_ID", "3")); + var openReq = new OpenChannelRequest + { + SourcePubKey = sourcePubKey, + DestinationPubKey = destPubKey, + WalletId = walletId, + SatsAmount = satsAmount, + Private = false, + Changeless = false, + MempoolFeeRate = FEES_TYPE.CustomFee, + CustomFeeRate = 2, + }; + var opId = await RetryAsync( + async () => (await client.OpenChannelAsync(openReq, headers)).ChannelOperationRequestId, + attempts: 10, delay: TimeSpan.FromSeconds(6), what: "OpenChannel"); + _output.WriteLine($"OpenChannel → operation {opId}"); + + // Mine + poll until the funding tx confirms and NodeGuard records the channel id. + long channelId = 0; + for (var i = 0; i < 40 && channelId == 0; i++) + { + await MineAsync(rpc, 2); + var st = await client.GetChannelOperationRequestAsync( + new GetChannelOperationRequestRequest { ChannelOperationRequestId = opId }, headers); + _output.WriteLine($"poll {i}: status={st.Status} channelId={(st.HasChannelId ? st.ChannelId : 0)}"); + if (st.HasChannelId && st.ChannelId > 0) channelId = st.ChannelId; + else await Task.Delay(TimeSpan.FromSeconds(3)); + } + channelId.Should().BeGreaterThan(0, "NodeGuard should record the opened channel's id"); + + // Mine more so LND marks the channel active and gossip propagates. + await MineAsync(rpc, 6); + await Task.Delay(TimeSpan.FromSeconds(4)); + return channelId; + } + + protected async Task MineAsync(RPCClient rpc, int blocks) + { + var addr = await rpc.GetNewAddressAsync(); + await rpc.GenerateToAddressAsync(blocks, addr); + } + + /// Retries until it succeeds or the attempts are exhausted. + protected async Task RetryAsync(Func> action, int attempts, TimeSpan delay, string what) + { + Exception? last = null; + for (var i = 0; i < attempts; i++) + { + try { return await action(); } + catch (Exception ex) { last = ex; _output.WriteLine($"{what} attempt {i + 1}/{attempts} failed: {ex.Message}"); } + await Task.Delay(delay); + } + throw new InvalidOperationException($"{what} did not succeed after {attempts} attempts", last); + } + + /// Polls until holds or attempts are exhausted. + protected async Task PollAsync(Func> read, Func done, int attempts, TimeSpan delay, string what) + { + T last = default!; + for (var i = 0; i < attempts; i++) + { + last = await read(); + if (done(last)) return last; + _output.WriteLine($"{what} attempt {i + 1}/{attempts}: not ready"); + await Task.Delay(delay); + } + throw new InvalidOperationException($"{what} not satisfied after {attempts} attempts"); + } + + protected static NodeGuardService.NodeGuardServiceClient CreateClient(out Metadata headers) + { + var endpoint = Env("NODEGUARD_GRPC_ENDPOINT", "http://localhost:50051"); + headers = new Metadata { { "auth-token", Env("NODEGUARD_API_TOKEN", DefaultDevToken) } }; + return new NodeGuardService.NodeGuardServiceClient(GrpcChannel.ForAddress(endpoint)); + } + + protected static RPCClient CreateBitcoindRpc() + { + var url = Env("BITCOIND_RPC_URL", "http://localhost:18443"); + var cred = new NetworkCredential(Env("BITCOIND_RPC_USER", "polaruser"), Env("BITCOIND_RPC_PASS", "polarpass")); + var rpc = new RPCClient(cred, new Uri(url), Network.RegTest); + return rpc.SetWalletContext(Env("BITCOIND_RPC_WALLET", "default")); + } + + protected static string Env(string name, string fallback) + => Environment.GetEnvironmentVariable(name) is { Length: > 0 } v ? v : fallback; +} diff --git a/test/NodeGuard.Tests/E2E/FeeEngineE2EBase.cs b/test/NodeGuard.Tests/E2E/FeeEngineE2EBase.cs new file mode 100644 index 00000000..a62a9ed7 --- /dev/null +++ b/test/NodeGuard.Tests/E2E/FeeEngineE2EBase.cs @@ -0,0 +1,70 @@ +/* + * 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; +using NodeGuard.Data.Models; +using Xunit.Abstractions; + +namespace NodeGuard.Tests.E2E; + +/// +/// Base for the fee-engine e2e tests: a direct connection to NodeGuard's Postgres plus the fee-engine state +/// helpers they assert on (there is no gRPC read path for ChannelRoutingState/ChannelFeeState). Sits between +/// and the fee tests so non-fee e2e tests don't inherit fee-specific plumbing. +/// +public abstract class FeeEngineE2EBase : E2ETestBase +{ + protected FeeEngineE2EBase(ITestOutputHelper output) : base(output) + { + } + + protected static ApplicationDbContext CreateDbContext() + { + var cs = Env("POSTGRES_CONNECTIONSTRING", "Host=localhost;Port=25432;Database=nodeguard;User ID=postgres;"); + // Retry transient failures — a momentary DNS/socket blip must not fail a multi-minute run. + var options = new DbContextOptionsBuilder() + .UseNpgsql(cs, o => o.EnableRetryOnFailure(5, TimeSpan.FromSeconds(3), null)) + .Options; + return new ApplicationDbContext(options); + } + + // Truncates the engine's DERIVED state (forwarding events + routing/fee state) so a scenario starts + // clean; Channels/Nodes are left intact so channel discovery still holds. + protected static async Task ResetFeeEngineStateAsync() + { + await using var db = CreateDbContext(); + await db.ForwardingHtlcEvents.ExecuteDeleteAsync(); + await db.ChannelRoutingStates.ExecuteDeleteAsync(); + await db.ChannelFeeStates.ExecuteDeleteAsync(); + } + + protected static async Task ReadFeeStateAsync(int channelId) + { + await using var db = CreateDbContext(); + return await db.ChannelFeeStates.AsNoTracking().FirstOrDefaultAsync(x => x.ChannelId == channelId); + } + + // Polls until a real fee is applied (LastAppliedOutboundPpm is never set by a NoOp). + protected Task PollFeeAppliedAsync(int channelId, string what, int attempts = 40, int delaySeconds = 4) + => PollAsync( + () => ReadFeeStateAsync(channelId), + fs => fs is { LastAppliedOutboundPpm: not null }, + attempts, TimeSpan.FromSeconds(delaySeconds), what); +} diff --git a/test/NodeGuard.Tests/E2E/FeeEngineE2ETests.cs b/test/NodeGuard.Tests/E2E/FeeEngineE2ETests.cs new file mode 100644 index 00000000..c0f9de25 --- /dev/null +++ b/test/NodeGuard.Tests/E2E/FeeEngineE2ETests.cs @@ -0,0 +1,136 @@ +/* + * 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 Xunit.Abstractions; +using Channel = NodeGuard.Data.Models.Channel; + +namespace NodeGuard.Tests.E2E; + +/// +/// E2E: the fee engine applies a real policy to an imbalanced channel, then STOPS once the channel is +/// disabled. Reuses the setup's Bob→Carol (bob-owned, imbalanced) — NodeGuard rejects a duplicate open. +/// Direction isn't asserted (covered by ); the value here is the +/// APPLY-then-STOP-on-disable lifecycle. (Purge is UI-only, unreachable here — see FeeEngineStateServiceTests.) +/// Order-agnostic: reuses the always-present setup channel and resets its own fee-engine state. +/// +[Trait("Category", "E2E")] +[Collection("E2E")] +public class FeeEngineE2ETests : FeeEngineE2EBase +{ + public FeeEngineE2ETests(ITestOutputHelper output) : base(output) + { + } + + [E2EFact] + public async Task FeeEngine_AppliesFeeToImbalancedChannel_ThenStopsWhenDisabled() + { + var client = CreateClient(out var headers); + + var nodes = await WaitForNodesAsync(client, headers); + var bob = nodes.Single(n => n.Name == "bob"); + _output.WriteLine($"bob={bob.PubKey}"); + + try + { + // Clean slate so leftover HTLCs from another scenario can't skew categorization. + await ResetFeeEngineStateAsync(); + + // Enable bob's fee engine. ExecuteUpdate avoids materialising the Node's encrypted macaroon column. + await using (var db = CreateDbContext()) + { + var updated = await db.Nodes + .Where(n => n.PubKey == bob.PubKey) + .ExecuteUpdateAsync(s => s + .SetProperty(n => n.DynamicFeeManagementEnabled, true) + .SetProperty(n => n.RoutingEngineDryRun, false)); + updated.Should().Be(1, "bob should be seeded and have its fee engine enabled"); + } + + int bobNodeId; + await using (var db = CreateDbContext()) + { + bobNodeId = await db.Nodes.Where(n => n.PubKey == bob.PubKey).Select(n => n.Id).FirstAsync(); + } + + // bob initiated Bob→Carol, so NodeGuard records it as SourceNodeId. Poll — it's discovered by + // ChannelMonitorJob's first scan at startup. + var channelId = await PollAsync( + async () => + { + await using var db = CreateDbContext(); + return await db.Channels.AsNoTracking() + .Where(c => c.SourceNodeId == bobNodeId && c.Status == Channel.ChannelStatus.Open && c.ChanId != 0) + .Select(c => c.Id) + .FirstOrDefaultAsync(); + }, + id => id != 0, + attempts: 40, delay: TimeSpan.FromSeconds(3), what: "bob's Bob→Carol channel discovered"); + _output.WriteLine($"bob channel id={channelId}"); + + // Opt in; the row-count assert catches a silent 0-row update ("engine never acts" trap). + await using (var db = CreateDbContext()) + { + var optedIn = await db.Channels + .Where(c => c.Id == channelId) + .ExecuteUpdateAsync(s => s.SetProperty(c => c.IsDynamicFeeEnabled, true)); + optedIn.Should().Be(1, "the channel row must exist and be opted in to the fee engine"); + } + + // LastAppliedOutboundPpm is set only on a real Update (a NoOp leaves it null), so this waits for + // an actual fee write. + var feeState = await PollFeeAppliedAsync(channelId, "ChannelFeeState fee applied", delaySeconds: 3); + _output.WriteLine($"feeState: outbound={feeState!.LastAppliedOutboundPpm} inbound={feeState.LastAppliedInboundPpm} at={feeState.LastFeeUpdateAt:o}"); + feeState.LastAppliedOutboundPpm.Should().NotBeNull(); + feeState.LastFeeUpdateAt.Should().NotBeNull(); + + // Disable it; the engine should stop touching it. + await using (var db = CreateDbContext()) + { + await db.Channels + .Where(c => c.Id == channelId) + .ExecuteUpdateAsync(s => s.SetProperty(c => c.IsDynamicFeeEnabled, false)); + } + + // Snapshot after disable, then again past several optimizer cycles: comparing two post-disable + // reads is race-free vs the last pre-disable update. (LastFeeUpdateAt is written only by + // ChannelFeeOptimizerJob.) + await Task.Delay(TimeSpan.FromSeconds(6)); + var afterDisable = await ReadFeeStateAsync(channelId); + afterDisable.Should().NotBeNull(); + + await Task.Delay(TimeSpan.FromSeconds(18)); + var settled = await ReadFeeStateAsync(channelId); + settled.Should().NotBeNull(); + + settled!.LastFeeUpdateAt.Should().Be(afterDisable!.LastFeeUpdateAt, + "the engine must not update a channel that has opted out"); + settled.LastAppliedOutboundPpm.Should().Be(afterDisable.LastAppliedOutboundPpm); + } + finally + { + // Leave bob un-managed for the next run. + await using var db = CreateDbContext(); + await db.Nodes + .Where(n => n.PubKey == bob.PubKey) + .ExecuteUpdateAsync(s => s.SetProperty(n => n.DynamicFeeManagementEnabled, false)); + } + } +} diff --git a/test/NodeGuard.Tests/E2E/FeeEngineFlowE2ETests.cs b/test/NodeGuard.Tests/E2E/FeeEngineFlowE2ETests.cs new file mode 100644 index 00000000..d48baa6c --- /dev/null +++ b/test/NodeGuard.Tests/E2E/FeeEngineFlowE2ETests.cs @@ -0,0 +1,272 @@ +/* + * 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.Models; +using Xunit.Abstractions; +using Channel = NodeGuard.Data.Models.Channel; + +namespace NodeGuard.Tests.E2E; + +/// +/// E2E: the fee engine categorizes Alice→Bob as SINK under push-heavy load, then FLIPS it to SOURCE as the +/// flow reverses. Drives its own traffic in-process via — phase 1 Carol→Alice→Bob +/// pushes OUT, phase 2 Bob→Alice→Carol pulls IN — so it doesn't need a traffic sidecar. Order-agnostic: +/// self-provisions its channels and finds the one it drives by scid. The longest/flakiest e2e (two flow +/// phases + gossip + job cadence). +/// +[Trait("Category", "E2E")] +[Collection("E2E")] +public class FeeEngineFlowE2ETests : FeeEngineE2EBase +{ + // Flow knobs (former generate-flow.sh defaults). The ~2.4x pull:push ratio flips SINK→SOURCE; each + // 25k-sat payment clears the 1M-msat floor. + private static int PushPayments => int.Parse(Env("FLOW_PUSH_PAYMENTS", "6")); + private static int PullPayments => int.Parse(Env("FLOW_PULL_PAYMENTS", "14")); + private static long FlowPaymentSats => long.Parse(Env("FLOW_PAYMENT_SATS", "25000")); + // bob's sending liquidity to reach before phase 2 pulls; topped up by a DIRECT alice→bob payment (not a + // forward, so it doesn't touch the categorizer's windows). + private static long BobTopupSats => long.Parse(Env("FLOW_BOB_TOPUP_SATS", "2000000")); + private const long AliceCarolLocalSats = 8_000_000; // alice's phase-2 exit hop + private static long FeeMinChannelSizeSats => long.Parse(Env("ROUTING_ENGINE_FEE_MIN_CHANNEL_SIZE_SATS", "15000000")); + + public FeeEngineFlowE2ETests(ITestOutputHelper output) : base(output) + { + } + + [E2EFact, Trait("Speed", "Slow")] + public async Task FeeEngine_CategorizesSinkThenFlipsToSource_AsFlowReverses() + { + var client = CreateClient(out var headers); + var rpc = CreateBitcoindRpc(); + + var nodes = await WaitForNodesAsync(client, headers); + var aliceNode = nodes.Single(n => n.Name == "alice"); + var bobNode = nodes.Single(n => n.Name == "bob"); + var carolNode = nodes.Single(n => n.Name == "carol"); + _output.WriteLine($"alice={aliceNode.PubKey} bob={bobNode.PubKey} carol={carolNode.PubKey}"); + + var alice = LndTestClient.FromEnv("alice", aliceNode.PubKey); + var bob = LndTestClient.FromEnv("bob", bobNode.PubKey); + var carol = LndTestClient.FromEnv("carol", carolNode.PubKey); + + try + { + await ResetFeeEngineStateAsync(); + + // Enable alice's fee engine — alice is the node whose Alice→Bob channel NodeGuard categorizes. + await using (var db = CreateDbContext()) + { + var updated = await db.Nodes + .Where(n => n.PubKey == aliceNode.PubKey) + .ExecuteUpdateAsync(s => s + .SetProperty(n => n.DynamicFeeManagementEnabled, true) + .SetProperty(n => n.RoutingEngineDryRun, false)); + updated.Should().Be(1, "alice should be seeded and have its fee engine enabled"); + } + + await using (var db = CreateDbContext()) + { + var ns = await db.Nodes.AsNoTracking().Select(n => new { n.Id, n.Name }).ToListAsync(); + _output.WriteLine("[diag] nodes: " + string.Join(", ", ns.Select(n => $"{n.Name}=#{n.Id}"))); + } + + // Self-provision the topology FIRST — reuse Alice→Bob if present, else open our own; top up bob; + // open the Alice→Carol exit hop — so this scenario never depends on another having run first. + var (aliceBobScid, carolAliceScid, bobAliceScid) = + await SetUpFlowTopologyAsync(alice, bob, carol, rpc); + + // Find NodeGuard's row for the exact channel we'll drive, by its LND scid (ChanId) — unambiguous + // even if a second Alice→Bob exists. NodeGuard records externally-opened channels via MonitorChannelsJob. + var channelId = await PollAsync( + async () => + { + await using var db = CreateDbContext(); + var chans = await db.Channels.AsNoTracking() + .Select(c => new { c.Id, c.ChanId, c.Status }) + .ToListAsync(); + _output.WriteLine($"[chan] {chans.Count} rows: " + string.Join(" | ", + chans.Select(c => $"Id={c.Id} chanId={c.ChanId} st={c.Status}"))); + var match = chans.FirstOrDefault(c => c.ChanId == aliceBobScid && c.Status == Channel.ChannelStatus.Open); + return match?.Id ?? 0; + }, + id => id != 0, + attempts: 60, delay: TimeSpan.FromSeconds(3), what: $"Alice→Bob (scid {aliceBobScid}) discovered by NodeGuard"); + + await using (var db = CreateDbContext()) + { + var optedIn = await db.Channels + .Where(c => c.Id == channelId) + .ExecuteUpdateAsync(s => s.SetProperty(c => c.IsDynamicFeeEnabled, true)); + optedIn.Should().Be(1, "the channel row must exist and be opted in to the fee engine"); + } + + _output.WriteLine($"[flow] PHASE 1 (SINK): {PushPayments} Carol→Alice→Bob payments of {FlowPaymentSats} sats"); + var pushed = 0; + for (var i = 0; i < PushPayments; i++) + { + if (await carol.PayViaScidAsync(bob, carolAliceScid, FlowPaymentSats)) + _output.WriteLine($"[flow] push {i + 1} OK ({++pushed})"); + else + await Task.Delay(TimeSpan.FromSeconds(2)); + } + pushed.Should().BeGreaterThan(0, "at least one Carol→Alice→Bob push payment must settle to drive SINK"); + + var sink = await PollRoutingStateAsync(channelId, + rs => rs is { PeerFlowCategory: PeerFlowCategory.Sink }, + tag: "poll-sink", what: "Alice→Bob categorized as SINK (side 1)", attempts: 60); + + _output.WriteLine($"[sink] netFlow={sink!.NetFlowRatio:0.###} ema={sink.EmaLocalRatio:0.###} target={sink.TargetLocalRatio:0.###} push={sink.PushMsatWindow} pull={sink.PullMsatWindow}"); + + // Push-heavy ⇒ positive net-flow, and a SINK's target drifts above 0.5. + sink.PeerFlowCategory.Should().Be(PeerFlowCategory.Sink); + sink.NetFlowRatio.Should().BeGreaterThan(0, "outbound-heavy flow on Alice→Bob is a SINK signal"); + sink.TargetLocalRatio.Should().BeGreaterThan(0.5, "a SINK's target ratio drifts upward"); + + var sinkFee = await PollFeeAppliedAsync(channelId, "ChannelFeeState fee applied (SINK)"); + _output.WriteLine($"[sink-fee] outbound={sinkFee!.LastAppliedOutboundPpm} inbound={sinkFee.LastAppliedInboundPpm}"); + sinkFee.LastAppliedOutboundPpm.Should().NotBeNull("the fee engine should have applied a policy to the SINK channel"); + + _output.WriteLine($"[flow] PHASE 2 (SOURCE): {PullPayments} Bob→Alice→Carol payments of {FlowPaymentSats} sats"); + var pulled = 0; + for (var i = 0; i < PullPayments; i++) + { + if (await bob.PayViaScidAsync(carol, bobAliceScid, FlowPaymentSats)) + _output.WriteLine($"[flow] pull {i + 1} OK ({++pulled})"); + else + await Task.Delay(TimeSpan.FromSeconds(2)); + } + pulled.Should().BeGreaterThan(0, "at least one Bob→Alice→Carol pull payment must settle to drive the flip"); + + // Category flips on net-flow crossing, but TargetLocalRatio is a slow EMA — wait for BOTH (a + // mid-drift ~0.503 would fail the < 0.5 assertion). + var source = await PollRoutingStateAsync(channelId, + rs => rs is { PeerFlowCategory: PeerFlowCategory.Source } && rs.TargetLocalRatio < 0.5, + tag: "poll-source", what: "Alice→Bob flipped to SOURCE with target < 0.5 (side 2)", attempts: 90); + + _output.WriteLine($"[source] netFlow={source!.NetFlowRatio:0.###} target={source.TargetLocalRatio:0.###} push={source.PushMsatWindow} pull={source.PullMsatWindow}"); + + source.PeerFlowCategory.Should().Be(PeerFlowCategory.Source); + source.NetFlowRatio.Should().BeLessThan(0, "reversed (inbound-heavy) flow is a SOURCE signal"); + source.TargetLocalRatio.Should().BeLessThan(0.5, "a SOURCE's target drifts downward"); + source.PushMsatWindow.Should().BeGreaterThan(0, "side 1 push flow should still be on record"); + source.PullMsatWindow.Should().BeGreaterThan(0, "side 2 pull flow drove the flip"); + + var sourceFee = await PollFeeAppliedAsync(channelId, "ChannelFeeState fee applied (SOURCE)"); + _output.WriteLine($"[source-fee] outbound={sourceFee!.LastAppliedOutboundPpm} inbound={sourceFee.LastAppliedInboundPpm}"); + sourceFee.LastAppliedOutboundPpm.Should().NotBeNull("the fee engine should have applied a policy to the categorized channel"); + } + finally + { + await using var db = CreateDbContext(); + await db.Nodes + .Where(n => n.PubKey == aliceNode.PubKey) + .ExecuteUpdateAsync(s => s.SetProperty(n => n.DynamicFeeManagementEnabled, false)); + } + } + + // Ensures the channels the flow needs (order-agnostic): reuse Alice→Bob if present, else open our own + // with push; top up bob's side; open the Alice→Carol exit hop; settle gossip. Returns the forced + // first-hop scids for the two phases. + private async Task<(ulong aliceBobScid, ulong carolAliceScid, ulong bobAliceScid)> SetUpFlowTopologyAsync( + LndTestClient alice, LndTestClient bob, LndTestClient carol, NBitcoin.RPC.RPCClient rpc) + { + // Reuse an Alice→Bob channel only if it clears the fee-engine min channel size — the optimizer skips + // smaller ones (e.g. the 5M channel the HTLC-reconnect scenario opens), so a smaller reused channel + // would categorize but never get a fee. Pin the largest that qualifies, else open a fresh 16M one. + var aliceBobScid = await alice.ScidToAsync(bob.PubKey, minCapacitySats: FeeMinChannelSizeSats); + if (aliceBobScid is null) + { + _output.WriteLine($"[flow] no Alice→Bob >= {FeeMinChannelSizeSats} sat — opening one with push"); + await alice.ConnectAsync(bob.PubKey, $"{bob.Name}:9735"); + await alice.OpenChannelAsync(bob.PubKey, localSats: 16_000_000, pushSats: 8_000_000); + aliceBobScid = await MineUntilScidAsync( + rpc, () => alice.ScidToAsync(bob.PubKey, FeeMinChannelSizeSats), "Alice→Bob (>= fee min)"); + } + _output.WriteLine($"[flow] Alice→Bob scid={aliceBobScid} (>= {FeeMinChannelSizeSats} sat)"); + + // Top up bob via a DIRECT alice→bob payment (not a forward, so it doesn't touch the categorizer's + // windows) until bob has the sending liquidity test needs. + var bobLocal = await alice.RemoteBalanceOnScidAsync(bob.PubKey, aliceBobScid.Value); + _output.WriteLine($"[flow] bob local on Alice→Bob = {bobLocal} sat (target {BobTopupSats})"); + for (var i = 0; i < 8 && bobLocal < BobTopupSats; i++) + { + if (!await alice.PayViaScidAsync(bob, aliceBobScid.Value, 1_000_000)) + await Task.Delay(TimeSpan.FromSeconds(2)); + bobLocal = await alice.RemoteBalanceOnScidAsync(bob.PubKey, aliceBobScid.Value); + } + _output.WriteLine($"[flow] bob local on Alice→Bob = {bobLocal} sat"); + + // Alice→Carol: alice's own outbound exit hop for phase 2 (setup's Carol→Alice is carol-owned). + var aliceToCarol = await alice.LocalBalanceToAsync(carol.PubKey); + if (aliceToCarol < FlowPaymentSats) + { + _output.WriteLine("[flow] opening Alice→Carol (alice outbound exit hop)"); + await alice.ConnectAsync(carol.PubKey, $"{carol.Name}:9735"); + await alice.OpenChannelAsync(carol.PubKey, localSats: AliceCarolLocalSats, pushSats: 0); + await MineUntilScidAsync(rpc, () => alice.ScidToAsync(carol.PubKey), "Alice→Carol"); + aliceToCarol = await alice.LocalBalanceToAsync(carol.PubKey); + } + _output.WriteLine($"[flow] alice outbound to carol = {aliceToCarol} sat"); + + // Forced first hops as each SENDER sees them. + var carolAliceScid = await carol.ScidToAsync(alice.PubKey); + carolAliceScid.Should().NotBeNull("Carol→Alice scid is needed to force the phase-1 route"); + var bobAliceScid = aliceBobScid.Value; + _output.WriteLine($"[flow] Carol→Alice scid={carolAliceScid} Bob→Alice scid={bobAliceScid}"); + + await Task.Delay(TimeSpan.FromSeconds(15)); // gossip settle so senders can build the two-hop routes + return (aliceBobScid.Value, carolAliceScid!.Value, bobAliceScid); + } + + // Mines until readScid returns a confirmed scid (channel active), or throws. + private async Task MineUntilScidAsync(NBitcoin.RPC.RPCClient rpc, Func> readScid, string what) + { + await MineAsync(rpc, 6); + for (var i = 0; i < 60; i++) + { + var scid = await readScid(); + if (scid is not null) return scid.Value; + await MineAsync(rpc, 1); + await Task.Delay(TimeSpan.FromSeconds(3)); + } + throw new InvalidOperationException($"{what} never got a confirmed scid after mining"); + } + + // Reads the channel's routing state, or null if the categorizer hasn't written a row yet. + private static async Task ReadRoutingStateAsync(int channelId) + { + await using var db = CreateDbContext(); + return await db.ChannelRoutingStates.AsNoTracking().FirstOrDefaultAsync(x => x.ChannelId == channelId); + } + + // Polls the routing state until done holds, logging the evolving signal under tag each attempt. + private Task PollRoutingStateAsync( + int channelId, Func done, string tag, string what, int attempts) + => PollAsync( + async () => + { + var rs = await ReadRoutingStateAsync(channelId); + if (rs != null) + _output.WriteLine($"[{tag}] cat={rs.PeerFlowCategory} netFlow={rs.NetFlowRatio:0.###} push={rs.PushMsatWindow} pull={rs.PullMsatWindow} age={rs.AgeBlocks} chanIdLnd={rs.ChanIdLnd}"); + return rs; + }, + done, attempts, TimeSpan.FromSeconds(4), what); +} diff --git a/test/NodeGuard.Tests/E2E/LndTestClient.cs b/test/NodeGuard.Tests/E2E/LndTestClient.cs new file mode 100644 index 00000000..2e22247c --- /dev/null +++ b/test/NodeGuard.Tests/E2E/LndTestClient.cs @@ -0,0 +1,183 @@ +/* + * 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 Google.Protobuf; +using Grpc.Core; +using Grpc.Net.Client; +using Lnrpc; +using Routerrpc; + +namespace NodeGuard.Tests.E2E; + +/// +/// Test-only driver for a single LND node over its gRPC API — the in-process replacement for the deleted +/// generate-flow.sh sidecar, so the fee-engine flow scenario can open channels and send force-routed +/// payments itself. Connects like NodeGuard's own LND clients (https://{host}, cert check off for +/// regtest self-signed certs, admin macaroon hex per call); host + macaroon come from {NODE}_HOST / +/// {NODE}_MACAROON — the process env, or the shared env file extract-env.sh writes (see FromEnv). +/// +internal sealed class LndTestClient +{ + public string Name { get; } + public string PubKey { get; } + public Lightning.LightningClient Lightning { get; } + public Router.RouterClient RouterClient { get; } + + private readonly Metadata _auth; + + private LndTestClient(string name, string pubKey, GrpcChannel channel, string macaroonHex) + { + Name = name; + PubKey = pubKey; + Lightning = new Lightning.LightningClient(channel); + RouterClient = new Router.RouterClient(channel); + _auth = new Metadata { { "macaroon", macaroonHex } }; + } + + // extract-env.sh writes {NODE}_HOST/{NODE}_MACAROON to this file on the mounted e2e_env volume. Load it + // into the environment once — like NodeGuard's Program.cs does — so FromEnv finds the creds without the + // runner entrypoint exporting them. Override the path with LND_ENV_FILE; absent file → no-op. + static LndTestClient() + { + var path = Environment.GetEnvironmentVariable("LND_ENV_FILE") ?? "/shared/nodeguard-macaroons.env"; + if (File.Exists(path)) DotNetEnv.Env.Load(path); + } + + public static LndTestClient FromEnv(string name, string pubKey) + { + var key = name.ToUpperInvariant(); + var host = Environment.GetEnvironmentVariable($"{key}_HOST"); + var macaroon = Environment.GetEnvironmentVariable($"{key}_MACAROON"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(macaroon)) + throw new InvalidOperationException( + $"Missing {key}_HOST/{key}_MACAROON — the fee-engine flow e2e drives LND directly and needs the " + + "connection env from docker/e2e/extract-env.sh (process env, or LND_ENV_FILE / /shared/nodeguard-macaroons.env)."); + + var httpHandler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }; + var channel = GrpcChannel.ForAddress($"https://{host}", new GrpcChannelOptions { HttpHandler = httpHandler }); + return new LndTestClient(name, pubKey, channel, macaroon); + } + + // One ListChannels round-trip; the scid/balance helpers below each build on it. + private async Task> ChannelsToAsync(string peerPubKey) + { + var resp = await Lightning.ListChannelsAsync(new ListChannelsRequest(), _auth); + return resp.Channels.Where(c => c.RemotePubkey == peerPubKey).ToList(); + } + + // Largest-capacity CONFIRMED channel (ChanId != 0) toward the peer whose capacity is at least + // minCapacitySats, or null if none qualifies. + public async Task ScidToAsync(string peerPubKey, long minCapacitySats = 0) + => (await ChannelsToAsync(peerPubKey)) + .Where(c => c.ChanId != 0 && c.Capacity >= minCapacitySats) + .OrderByDescending(c => c.Capacity) + .FirstOrDefault()?.ChanId; + + public async Task LocalBalanceToAsync(string peerPubKey) + => (await ChannelsToAsync(peerPubKey)).Select(c => c.LocalBalance).DefaultIfEmpty(0).Max(); + + // Peer's local balance = their sending liquidity toward us. + public async Task RemoteBalanceToAsync(string peerPubKey) + => (await ChannelsToAsync(peerPubKey)).Select(c => c.RemoteBalance).DefaultIfEmpty(0).Max(); + + // Peer's local balance on a SPECIFIC channel (by scid). + public async Task RemoteBalanceOnScidAsync(string peerPubKey, ulong scid) + => (await ChannelsToAsync(peerPubKey)).Where(c => c.ChanId == scid).Select(c => c.RemoteBalance).DefaultIfEmpty(0).Max(); + + // Idempotent — an "already connected" RpcException is expected and swallowed. + public async Task ConnectAsync(string peerPubKey, string hostPort) + { + try + { + await Lightning.ConnectPeerAsync(new ConnectPeerRequest + { + Addr = new LightningAddress { Pubkey = peerPubKey, Host = hostPort }, + Perm = false, + }, _auth); + } + catch (RpcException ex) when ( + ex.StatusCode == StatusCode.AlreadyExists || + ex.StatusCode == StatusCode.FailedPrecondition || + ex.Status.Detail.Contains("already connected", StringComparison.OrdinalIgnoreCase)) + { + // expected: peer already connected + } + } + + // Returns once the funding tx is BROADCAST (not yet confirmed) — the caller mines to confirm. + public async Task OpenChannelAsync(string peerPubKey, long localSats, long pushSats) + { + return await Lightning.OpenChannelSyncAsync(new OpenChannelRequest + { + NodePubkey = ByteString.CopyFrom(Convert.FromHexString(peerPubKey)), + LocalFundingAmount = localSats, + PushSat = pushSats, + SatPerVbyte = 2, // regtest has no fee estimation to fall back on + }, _auth); + } + + public async Task AddInvoiceAsync(long amtSats) + { + var resp = await Lightning.AddInvoiceAsync(new Invoice { Value = amtSats }, _auth); + return resp.PaymentRequest; + } + + // Pays a fresh invoice from the receiver, forcing the FIRST hop over firstHopScid (LND pathfinds the + // rest). Returns true only on a settled payment. + public async Task PayViaScidAsync(LndTestClient receiver, ulong firstHopScid, long amtSats, int timeoutSecs = 60) + { + string paymentRequest; + try + { + paymentRequest = await receiver.AddInvoiceAsync(amtSats); + } + catch (RpcException) + { + return false; + } + if (string.IsNullOrEmpty(paymentRequest)) return false; + + var request = new SendPaymentRequest + { + PaymentRequest = paymentRequest, + OutgoingChanId = firstHopScid, + TimeoutSeconds = timeoutSecs, + FeeLimitSat = Math.Max(1_000, amtSats), // generous for regtest — never the reason a hop fails + NoInflightUpdates = true, + }; + + try + { + using var call = RouterClient.SendPaymentV2(request, _auth); + await foreach (var payment in call.ResponseStream.ReadAllAsync()) + { + if (payment.Status == Payment.Types.PaymentStatus.Succeeded) return true; + if (payment.Status == Payment.Types.PaymentStatus.Failed) return false; + } + } + catch (RpcException) + { + return false; + } + return false; + } +} diff --git a/test/NodeGuard.Tests/E2E/RebalanceE2ETests.cs b/test/NodeGuard.Tests/E2E/RebalanceE2ETests.cs index 7fdf93b5..425d04f5 100644 --- a/test/NodeGuard.Tests/E2E/RebalanceE2ETests.cs +++ b/test/NodeGuard.Tests/E2E/RebalanceE2ETests.cs @@ -17,43 +17,24 @@ * */ -using System.Net; using FluentAssertions; -using Grpc.Core; -using Grpc.Net.Client; -using NBitcoin; -using NBitcoin.RPC; using Nodeguard; using Xunit.Abstractions; namespace NodeGuard.Tests.E2E; /// -/// True end-to-end test driven entirely from .NET (no grpcurl/curl): a generated gRPC client -/// drives a LIVE NodeGuard instance through the whole option-B flow — -/// GetNodes → OpenChannel(Alice→Bob) → mine (NBitcoin RPC) + poll GetChannelOperationRequest -/// until the channel confirms → RequestRebalance(Alice→Bob→Carol→Alice) → assert success. -/// This exercises gRPC auth, channel opening (wallet → PSBT → internal signing → broadcast), -/// channel sync, and the amountless-invoice rebalance against real LND + Postgres. -/// -/// Gated by (RUN_E2E_TESTS=1). Connection via env: -/// NODEGUARD_GRPC_ENDPOINT default http://localhost:50051 (h2c) -/// NODEGUARD_API_TOKEN default the dev "Liquidator" token -/// BITCOIND_RPC_URL/USER/PASS/WALLET default http://localhost:18443 / polaruser / polarpass / default -/// E2E_HOT_WALLET_ID NodeGuard hot wallet to fund the channel (default 3) +/// E2E: opens Alice→Bob through NodeGuard's OpenChannel gRPC (so it also covers channel opening), +/// then a circular rebalance Alice→Bob→Carol→Alice. Order-agnostic — opens and rebalances its own channel +/// (pinned by SourceChannelId); serial with the other e2e classes via [Collection("E2E")]. Shared +/// plumbing in . /// [Trait("Category", "E2E")] [Collection("E2E")] -public class RebalanceE2ETests +public class RebalanceE2ETests : E2ETestBase { - private const string DefaultDevToken = "8rvSsUGeyXXdDQrHctcTey/xtHdZQEn945KHwccKp9Q="; - - private readonly ITestOutputHelper _output; - - public RebalanceE2ETests(ITestOutputHelper output) + public RebalanceE2ETests(ITestOutputHelper output) : base(output) { - _output = output; - AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); } [E2EFact] @@ -62,50 +43,15 @@ public async Task OpenChannelViaGrpc_ThenCircularRebalance_Succeeds() var client = CreateClient(out var headers); var rpc = CreateBitcoindRpc(); - // 0. Wait for NodeGuard to be up and to have seeded the three nodes. var nodes = await WaitForNodesAsync(client, headers); var alice = nodes.Single(n => n.Name == "alice"); var bob = nodes.Single(n => n.Name == "bob"); var carol = nodes.Single(n => n.Name == "carol"); _output.WriteLine($"alice={alice.PubKey} bob={bob.PubKey} carol={carol.PubKey}"); - // 1. Open Alice→Bob THROUGH NodeGuard (option B). Retry briefly in case the dev hot - // wallet is still being funded by DbInitializer when we connect. - var walletId = int.Parse(Env("E2E_HOT_WALLET_ID", "3")); - var openReq = new OpenChannelRequest - { - SourcePubKey = alice.PubKey, - DestinationPubKey = bob.PubKey, - WalletId = walletId, - SatsAmount = 16_000_000, - Private = false, - Changeless = false, - MempoolFeeRate = FEES_TYPE.CustomFee, - CustomFeeRate = 2, - }; - var opId = await RetryAsync( - async () => (await client.OpenChannelAsync(openReq, headers)).ChannelOperationRequestId, - attempts: 10, delay: TimeSpan.FromSeconds(6), what: "OpenChannel"); - _output.WriteLine($"OpenChannel → operation {opId}"); + // Open Alice→Bob through NodeGuard (option B — also covers channel opening), confirmed and active. + var channelId = await OpenChannelAndConfirmAsync(client, headers, rpc, alice.PubKey, bob.PubKey); - // 2. Mine + poll until the funding tx confirms and NodeGuard records the channel id. - long channelId = 0; - for (var i = 0; i < 40 && channelId == 0; i++) - { - await MineAsync(rpc, 2); - var st = await client.GetChannelOperationRequestAsync( - new GetChannelOperationRequestRequest { ChannelOperationRequestId = opId }, headers); - _output.WriteLine($"poll {i}: status={st.Status} channelId={(st.HasChannelId ? st.ChannelId : 0)}"); - if (st.HasChannelId && st.ChannelId > 0) channelId = st.ChannelId; - else await Task.Delay(TimeSpan.FromSeconds(3)); - } - channelId.Should().BeGreaterThan(0, "NodeGuard should record the opened channel's id"); - - // 3. Mine a few more blocks so LND marks the channel active and gossip propagates. - await MineAsync(rpc, 6); - await Task.Delay(TimeSpan.FromSeconds(4)); - - // 4. Circular rebalance Alice→Bob→Carol→Alice over the just-opened channel. var resp = await client.RequestRebalanceAsync(new RequestRebalanceRequest { NodePubkey = alice.PubKey, @@ -121,56 +67,4 @@ public async Task OpenChannelViaGrpc_ThenCircularRebalance_Succeeds() resp.FeePaidSats.Should().BeGreaterThan(0); resp.FeePaidSats.Should().BeLessThanOrEqualTo(1_000); // within 0.2% of 500k } - - // ---- helpers ----------------------------------------------------------------------------- - - private async Task> WaitForNodesAsync( - NodeGuardService.NodeGuardServiceClient client, Metadata headers) - { - return await RetryAsync(async () => - { - var resp = await client.GetNodesAsync(new GetNodesRequest(), headers); - var seeded = resp.Nodes.Where(n => n.Name is "alice" or "bob" or "carol").ToList(); - if (seeded.Count < 3) throw new InvalidOperationException($"only {seeded.Count}/3 nodes seeded"); - return (IReadOnlyList)seeded; - // Generous window: a fresh NodeGuard runs migrations + funds its wallet (mining + NBXplorer - // sync) before serving gRPC, which can take several minutes. - }, attempts: 90, delay: TimeSpan.FromSeconds(4), what: "GetNodes (NodeGuard readiness)"); - } - - private async Task MineAsync(RPCClient rpc, int blocks) - { - var addr = await rpc.GetNewAddressAsync(); - await rpc.GenerateToAddressAsync(blocks, addr); - } - - private async Task RetryAsync(Func> action, int attempts, TimeSpan delay, string what) - { - Exception? last = null; - for (var i = 0; i < attempts; i++) - { - try { return await action(); } - catch (Exception ex) { last = ex; _output.WriteLine($"{what} attempt {i + 1}/{attempts} failed: {ex.Message}"); } - await Task.Delay(delay); - } - throw new InvalidOperationException($"{what} did not succeed after {attempts} attempts", last); - } - - private static NodeGuardService.NodeGuardServiceClient CreateClient(out Metadata headers) - { - var endpoint = Env("NODEGUARD_GRPC_ENDPOINT", "http://localhost:50051"); - headers = new Metadata { { "auth-token", Env("NODEGUARD_API_TOKEN", DefaultDevToken) } }; - return new NodeGuardService.NodeGuardServiceClient(GrpcChannel.ForAddress(endpoint)); - } - - private static RPCClient CreateBitcoindRpc() - { - var url = Env("BITCOIND_RPC_URL", "http://localhost:18443"); - var cred = new NetworkCredential(Env("BITCOIND_RPC_USER", "polaruser"), Env("BITCOIND_RPC_PASS", "polarpass")); - var rpc = new RPCClient(cred, new Uri(url), Network.RegTest); - return rpc.SetWalletContext(Env("BITCOIND_RPC_WALLET", "default")); - } - - private static string Env(string name, string fallback) - => Environment.GetEnvironmentVariable(name) is { Length: > 0 } v ? v : fallback; } From cb598fb564586b54d6e41238ecfbce32703ad160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:32:33 +0200 Subject: [PATCH 17/21] Scope coin-selection ignore list to queried wallet (#575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Scope the coin-selection ignore list to the queried wallet GetIgnoredOutpoints told the NBXplorer backend to skip every locked and frozen outpoint in the database, not just the ones belonging to the wallet being queried: GetLockedUTXOs is called with both filters null, and the frozen lookup is a plain scan of the UTXOTags table with no wallet join. Outpoints from other wallets can never appear in the response anyway, so they were pure weight on the request line, and enough of them pushed it past Kestrel's 8KB limit for a 414 that silently knocked the whole selection back to the plain UTXO listing, discarding the strategy, limit and amount. Intersect the combined list with the wallet's own UTXO set, which the caller has already fetched, and dedupe it: an outpoint that was both locked and frozen was being sent twice. Dust comes off the list entirely. The backend already drops it by value, so enumerating it spent one query parameter per dust UTXO to say something a single parameter says better. FilterLockedFrozenUTXOs still strips dust from the response, so nothing dusty can reach the caller regardless. The gRPC path in NodeGuardService builds its own, already wallet-scoped copy of this list and is deliberately left alone here. * POST the selectutxos ignore list instead of putting it on the request line Scoping the list to the wallet shortens it but does not bound it: a wallet with enough locked or frozen UTXOs of its own still overflows Kestrel's default 8192-byte MaxRequestLineSize and earns a 414, because every ignored outpoint costs another repeated query parameter at roughly 83 bytes apiece. Send the outpoints in a JSON body instead. The selection parameters stay on the query string. Kestrel's body limit is 30,000,000 bytes against 8,192 for the request line, about 3,662x the room, so the list no longer has a practical ceiling — roughly 430,000 outpoints rather than ninety. This makes an NBXplorer serving POST /selectutxos (Elenpay/NBXplorer#18) a hard requirement. Against an older build every call answers 405, which the callers swallow the same way they swallow the 414, so coin selection degrades for every wallet until that image is deployed. Ship and roll out #18 first. Also send minimumValue, so the backend filters dust by value instead of the caller listing each dust outpoint. It defaults to the same 546 the backend hardcoded before, so this is a no-op at the default MINIMUM_UTXO_VALUE_SATS. The tests pin the wire format the fork's controller binds to, at both a handful of outpoints and well past the old ceiling. NBXplorerService had no test at all before this, and the test project had no HttpMessageHandler mocking; Moq covers it without a new package. TestEnvironment supplies NBXPLORER_URI from a module initializer because Constants reads it in a static constructor into a readonly field, so a test cannot set it after the fact, and ExplorerClient construction throws on a null URI. A value already set by the environment wins, so CI can point it elsewhere. --- src/Services/CoinSelectionService.cs | 31 +++- src/Services/NBXplorerService.cs | 16 +- .../Services/CoinSelectionServiceTests.cs | 160 +++++++++++++++++- .../Services/NBXplorerServiceTests.cs | 154 +++++++++++++++++ .../TestHelpers/TestEnvironment.cs | 48 ++++++ 5 files changed, 393 insertions(+), 16 deletions(-) create mode 100644 test/NodeGuard.Tests/Services/NBXplorerServiceTests.cs create mode 100644 test/NodeGuard.Tests/TestHelpers/TestEnvironment.cs diff --git a/src/Services/CoinSelectionService.cs b/src/Services/CoinSelectionService.cs index 927065b0..f2188134 100644 --- a/src/Services/CoinSelectionService.cs +++ b/src/Services/CoinSelectionService.cs @@ -160,16 +160,28 @@ private async Task> GetLockedFrozenOutpoints() } /// - /// Outpoints that must never be offered for coin selection: locked, frozen and dust UTXOs. + /// Outpoints that must never be offered for coin selection: the locked and frozen UTXOs that + /// belong to this wallet. /// + /// + /// Both the locked and the frozen lookups span every wallet in the database, so the result is + /// intersected with this wallet's own UTXO set. Outpoints the backend could never return are + /// dead weight on the request line, and enough of them push it past Kestrel's 8KB limit, which + /// answers with a 414 and knocks the whole selection back to the plain listing. + /// Dust is deliberately absent: the backend drops it by value instead, which costs one scalar + /// query parameter rather than one parameter per dust UTXO. + /// private async Task> GetIgnoredOutpoints(UTXOChanges utxos) { - var ignoredOutpoints = await GetLockedFrozenOutpoints(); - ignoredOutpoints.AddRange(utxos.Confirmed.UTXOs + var walletOutpoints = utxos.Confirmed.UTXOs .Concat(utxos.Unconfirmed.UTXOs) - .Where(utxo => ((Money)utxo.Value).Satoshi <= Constants.MINIMUM_UTXO_VALUE_SATS) - .Select(utxo => utxo.Outpoint.ToString())); - return ignoredOutpoints; + .Select(utxo => utxo.Outpoint.ToString()) + .ToHashSet(); + + return (await GetLockedFrozenOutpoints()) + .Where(walletOutpoints.Contains) + .Distinct() + .ToList(); } private async Task> FilterLockedFrozenUTXOs(UTXOChanges? utxoChanges) @@ -232,9 +244,10 @@ public async Task> GetAvailableUTXOsAsync(DerivationStrategyBase deri { try { - // Tell the backend which UTXOs to skip (locked, frozen and dust), otherwise it - // counts them towards the requested amount and the local filter below strips them - // afterwards, returning a selection that falls short of that amount + // Tell the backend which UTXOs to skip, otherwise it counts them towards the + // requested amount and the local filter below strips them afterwards, returning a + // selection that falls short of that amount. Dust is excluded by the backend on + // value, so it does not need listing here var allUtxos = await _nbXplorerService.GetUTXOsAsync(derivationStrategy); allUtxos.RemoveDuplicateUTXOs(); var ignoreOutpoints = await GetIgnoredOutpoints(allUtxos); diff --git a/src/Services/NBXplorerService.cs b/src/Services/NBXplorerService.cs index fed85114..c0b8c712 100644 --- a/src/Services/NBXplorerService.cs +++ b/src/Services/NBXplorerService.cs @@ -162,16 +162,26 @@ public async Task GetUTXOsByLimitAsync(DerivationStrategyBase deriv new("strategy", strategy.ToString()), new("limit", limit.ToString()), new("amount", amount.ToString()), + // Lets the backend drop dust by value, so callers do not have to spend one query + // parameter per dust outpoint saying the same thing + new("minimumValue", Constants.MINIMUM_UTXO_VALUE_SATS.ToString()), }; if (strategy == CoinSelectionStrategy.ClosestToTargetFirst) { keyValuePairs.Add(new("closestTo", closestTo.ToString())); } - ignoreOutpoints?.ForEach(outpoint => keyValuePairs.Add(new("ignoreOutpoint", outpoint))); - var url = QueryHelpers.AddQueryString(requestUri, keyValuePairs); - var response = await _httpClient.GetAsync(url, cancellation); + + // The ignored outpoints travel in the body. As one repeated ignoreOutpoint query + // parameter each they cost ~83 bytes apiece, so about ninety of them overflowed + // Kestrel's 8KB MaxRequestLineSize and NBXplorer answered 414 — which the callers + // swallow into a degraded selection. The body limit is 30MB, roughly 3,662x the + // room, so the list no longer has a practical ceiling. + // NOTE: this requires an NBXplorer serving POST /selectutxos (Elenpay/NBXplorer#18). + // Against an older build every call answers 405 and coin selection degrades. + var response = await _httpClient.PostAsync(url, + JsonContent.Create(new { ignoreOutpoints }), cancellation); if (response.IsSuccessStatusCode) { diff --git a/test/NodeGuard.Tests/Services/CoinSelectionServiceTests.cs b/test/NodeGuard.Tests/Services/CoinSelectionServiceTests.cs index a9e9bae8..f72b184f 100644 --- a/test/NodeGuard.Tests/Services/CoinSelectionServiceTests.cs +++ b/test/NodeGuard.Tests/Services/CoinSelectionServiceTests.cs @@ -114,7 +114,7 @@ public async Task GetAvailableUTXOsAsync_WithStrategy_ExcludesDustUTXOs(CoinSele } [Fact] - public async Task GetAvailableUTXOsAsync_WithCustomBackend_IgnoresLockedFrozenAndDustServerSide() + public async Task GetAvailableUTXOsAsync_WithCustomBackend_IgnoresLockedAndFrozenServerSide() { var previousCustomBackend = Constants.NBXPLORER_ENABLE_CUSTOM_BACKEND; Constants.NBXPLORER_ENABLE_CUSTOM_BACKEND = true; @@ -182,13 +182,165 @@ public async Task GetAvailableUTXOsAsync_WithCustomBackend_IgnoresLockedFrozenAn availableUTXOs.Should().ContainSingle(); availableUTXOs[0].Outpoint.Should().Be(availableUtxo.Outpoint); - // Locked, frozen and dust UTXOs must all be ignored server-side so the backend does - // not count them towards the requested amount and return a short selection + // Locked and frozen UTXOs must be ignored server-side so the backend does not count + // them towards the requested amount and return a short selection ignoredOutpoints.Should().NotBeNull(); - ignoredOutpoints.Should().Contain(dustUtxo.Outpoint.ToString()); ignoredOutpoints.Should().Contain($"{lockedUtxo.Outpoint.Hash}-{lockedUtxo.Outpoint.N}"); ignoredOutpoints.Should().Contain(frozenUtxo.Outpoint.ToString()); ignoredOutpoints.Should().NotContain(availableUtxo.Outpoint.ToString()); + + // Dust is dropped by the backend on value, via the minimumValue parameter. Listing it + // here as well would spend one query parameter per dust UTXO to say the same thing, + // and that is what used to push the request line past 8KB and earn a 414 + ignoredOutpoints.Should().NotContain(dustUtxo.Outpoint.ToString()); + } + finally + { + Constants.NBXPLORER_ENABLE_CUSTOM_BACKEND = previousCustomBackend; + } + } + + [Fact] + public async Task GetAvailableUTXOsAsync_WithCustomBackend_SkipsOutpointsOutsideTheWallet() + { + var previousCustomBackend = Constants.NBXPLORER_ENABLE_CUSTOM_BACKEND; + Constants.NBXPLORER_ENABLE_CUSTOM_BACKEND = true; + try + { + // Arrange + var derivationStrategy = CreateWallet.SingleSig(_internalWallet).GetDerivationStrategy(); + var availableUtxo = CreateUtxo(1, 40_000); + var otherWalletLockedUtxo = CreateUtxo(2, 20_000); + var otherWalletFrozenUtxo = CreateUtxo(3, 30_000); + + // Neither lookup is scoped to a wallet, so both return rows belonging to other wallets + var fmutxoRepository = new Mock(); + fmutxoRepository + .Setup(x => x.GetLockedUTXOs(null, null)) + .ReturnsAsync(new List() + { + new() + { + TxId = otherWalletLockedUtxo.Outpoint.Hash.ToString(), + OutputIndex = otherWalletLockedUtxo.Outpoint.N, + SatsAmount = 20_000 + } + }); + + var utxoTagRepository = new Mock(); + utxoTagRepository + .Setup(x => x.GetByKeyValue(Constants.IsFrozenTag, "true")) + .ReturnsAsync(new List() { new() { Outpoint = otherWalletFrozenUtxo.Outpoint.ToString() } }); + utxoTagRepository + .Setup(x => x.GetByKeyValue(Constants.IsManuallyFrozenTag, It.IsAny())) + .ReturnsAsync(new List()); + + var nbXplorerService = new Mock(); + nbXplorerService + .Setup(x => x.GetUTXOsAsync(It.IsAny(), default)) + .ReturnsAsync(new UTXOChanges() + { + Confirmed = new UTXOChange() { UTXOs = new List() { availableUtxo } } + }); + List? ignoredOutpoints = null; + nbXplorerService + .Setup(x => x.GetUTXOsByLimitAsync(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), default)) + .Callback?, + CancellationToken>((_, _, _, _, _, ignore, _) => ignoredOutpoints = ignore) + .ReturnsAsync(new UTXOChanges() + { + Confirmed = new UTXOChange() { UTXOs = new List() { availableUtxo } } + }); + + var mapper = new Mock(); + var coinSelectionService = new CoinSelectionService(_logger, mapper.Object, fmutxoRepository.Object, + nbXplorerService.Object, null, null, utxoTagRepository.Object); + + // Act + var availableUTXOs = await coinSelectionService.GetAvailableUTXOsAsync( + derivationStrategy, CoinSelectionStrategy.SmallestFirst, 0, 40_000, 0); + + // Assert + availableUTXOs.Should().ContainSingle(); + + // The backend can only ever return UTXOs from the queried wallet, so telling it to skip + // another wallet's outpoints changes nothing and only lengthens the request line + ignoredOutpoints.Should().BeEmpty(); + } + finally + { + Constants.NBXPLORER_ENABLE_CUSTOM_BACKEND = previousCustomBackend; + } + } + + [Fact] + public async Task GetAvailableUTXOsAsync_WithCustomBackend_DeduplicatesIgnoredOutpoints() + { + var previousCustomBackend = Constants.NBXPLORER_ENABLE_CUSTOM_BACKEND; + Constants.NBXPLORER_ENABLE_CUSTOM_BACKEND = true; + try + { + // Arrange: one UTXO that is both locked and frozen + var derivationStrategy = CreateWallet.SingleSig(_internalWallet).GetDerivationStrategy(); + var lockedAndFrozenUtxo = CreateUtxo(1, 20_000); + var availableUtxo = CreateUtxo(2, 40_000); + + var fmutxoRepository = new Mock(); + fmutxoRepository + .Setup(x => x.GetLockedUTXOs(null, null)) + .ReturnsAsync(new List() + { + new() + { + TxId = lockedAndFrozenUtxo.Outpoint.Hash.ToString(), + OutputIndex = lockedAndFrozenUtxo.Outpoint.N, + SatsAmount = 20_000 + } + }); + + var utxoTagRepository = new Mock(); + utxoTagRepository + .Setup(x => x.GetByKeyValue(Constants.IsFrozenTag, "true")) + .ReturnsAsync(new List() { new() { Outpoint = lockedAndFrozenUtxo.Outpoint.ToString() } }); + utxoTagRepository + .Setup(x => x.GetByKeyValue(Constants.IsManuallyFrozenTag, It.IsAny())) + .ReturnsAsync(new List()); + + var nbXplorerService = new Mock(); + nbXplorerService + .Setup(x => x.GetUTXOsAsync(It.IsAny(), default)) + .ReturnsAsync(new UTXOChanges() + { + Confirmed = new UTXOChange() + { + UTXOs = new List() { lockedAndFrozenUtxo, availableUtxo } + } + }); + List? ignoredOutpoints = null; + nbXplorerService + .Setup(x => x.GetUTXOsByLimitAsync(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), default)) + .Callback?, + CancellationToken>((_, _, _, _, _, ignore, _) => ignoredOutpoints = ignore) + .ReturnsAsync(new UTXOChanges() + { + Confirmed = new UTXOChange() { UTXOs = new List() { availableUtxo } } + }); + + var mapper = new Mock(); + var coinSelectionService = new CoinSelectionService(_logger, mapper.Object, fmutxoRepository.Object, + nbXplorerService.Object, null, null, utxoTagRepository.Object); + + // Act + await coinSelectionService.GetAvailableUTXOsAsync( + derivationStrategy, CoinSelectionStrategy.SmallestFirst, 0, 40_000, 0); + + // Assert: sending it twice would say nothing extra and cost another query parameter + ignoredOutpoints.Should().ContainSingle() + .Which.Should().Be(lockedAndFrozenUtxo.Outpoint.ToString()); } finally { diff --git a/test/NodeGuard.Tests/Services/NBXplorerServiceTests.cs b/test/NodeGuard.Tests/Services/NBXplorerServiceTests.cs new file mode 100644 index 00000000..e8b017b9 --- /dev/null +++ b/test/NodeGuard.Tests/Services/NBXplorerServiceTests.cs @@ -0,0 +1,154 @@ +/* + * 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 System.Net; +using FluentAssertions; +using NodeGuard.Data.Models; +using NodeGuard.Helpers; +using NodeGuard.TestHelpers; +using Microsoft.Extensions.Logging; +using Moq.Protected; +using NBitcoin; + +namespace NodeGuard.Services; + +public class NBXplorerServiceTests +{ + private readonly ILogger _logger = new Mock>().Object; + private readonly InternalWallet _internalWallet = CreateWallet.CreateInternalWallet(); + + /// + /// Captures what the service put on the wire. NBXPLORER_URI is unset under test, which leaves + /// the request URI relative, so the client needs a base address to resolve it against. + /// + private sealed class RequestRecorder + { + public HttpRequestMessage? Request { get; private set; } + public string? Body { get; private set; } + + public HttpClient CreateClient() + { + var handler = new Mock(); + handler + .Protected() + .Setup>("SendAsync", ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((request, _) => + { + Request = request; + // Read it here: HttpClient disposes the content once the send completes + Body = request.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + }) + .ReturnsAsync(() => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}") + }); + + return new HttpClient(handler.Object) { BaseAddress = new Uri("http://localhost:32838") }; + } + } + + private static List CreateOutpoints(int count) + { + return Enumerable.Range(1, count) + .Select(i => new OutPoint(new uint256((uint)i), 0).ToString()) + .ToList(); + } + + private static int CountIgnoreOutpointParams(Uri uri) + { + return uri.Query.Split('&').Count(part => part.TrimStart('?').StartsWith("ignoreOutpoint=")); + } + + // Both a handful of outpoints and a list far past the old ~90-outpoint request-line ceiling: + // the transport does not vary with size, so neither can 414. + [Theory] + [InlineData(5)] + [InlineData(100)] + public async Task GetUTXOsByLimitAsync_SendsPostWithTheOutpointsInTheBody(int outpointCount) + { + // Arrange + var recorder = new RequestRecorder(); + var service = new NBXplorerService(recorder.CreateClient(), _logger); + var derivationStrategy = CreateWallet.SingleSig(_internalWallet).GetDerivationStrategy(); + var ignoreOutpoints = CreateOutpoints(outpointCount); + + // Act + await service.GetUTXOsByLimitAsync(derivationStrategy, CoinSelectionStrategy.SmallestFirst, 50, 40_000, 0, + ignoreOutpoints); + + // Assert: nothing about the exclusion list touches the request line, so Kestrel's 8KB cap + // is out of the picture regardless of how many outpoints there are + recorder.Request.Should().NotBeNull(); + recorder.Request!.Method.Should().Be(HttpMethod.Post); + recorder.Request.RequestUri!.Query.Should().NotContain("ignoreOutpoint"); + CountIgnoreOutpointParams(recorder.Request.RequestUri!).Should().Be(0); + + // Pin the wire format: NBXplorer binds this to SelectUTXOsRequest.IgnoreOutpoints, and the + // fork's CoinSelectionControllerTests posts exactly this shape + recorder.Body.Should().NotBeNull(); + recorder.Body.Should().StartWith("{\"ignoreOutpoints\":["); + foreach (var outpoint in ignoreOutpoints) + { + recorder.Body.Should().Contain($"\"{outpoint}\""); + } + } + + [Fact] + public async Task GetUTXOsByLimitAsync_AlwaysSendsMinimumValueSoDustNeedsNoOutpoints() + { + // Arrange + var recorder = new RequestRecorder(); + var service = new NBXplorerService(recorder.CreateClient(), _logger); + var derivationStrategy = CreateWallet.SingleSig(_internalWallet).GetDerivationStrategy(); + + // Act + await service.GetUTXOsByLimitAsync(derivationStrategy, CoinSelectionStrategy.SmallestFirst, 50, 40_000, 0, + new List()); + + // Assert: one scalar parameter replaces what used to be one parameter per dust UTXO + recorder.Request!.RequestUri!.Query.Should() + .Contain($"minimumValue={Constants.MINIMUM_UTXO_VALUE_SATS}"); + } + + [Fact] + public async Task GetUTXOsByLimitAsync_WhenBackendFails_ThrowsWithTheStatusCode() + { + // Arrange + var handler = new Mock(); + handler + .Protected() + .Setup>("SendAsync", ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.RequestUriTooLong) + { + Content = new StringContent(string.Empty) + }); + var httpClient = new HttpClient(handler.Object) { BaseAddress = new Uri("http://localhost:32838") }; + var service = new NBXplorerService(httpClient, _logger); + var derivationStrategy = CreateWallet.SingleSig(_internalWallet).GetDerivationStrategy(); + + // Act + var act = async () => await service.GetUTXOsByLimitAsync(derivationStrategy, + CoinSelectionStrategy.SmallestFirst, 50, 40_000, 0, new List()); + + // Assert + (await act.Should().ThrowAsync()) + .WithMessage("*414*"); + } +} diff --git a/test/NodeGuard.Tests/TestHelpers/TestEnvironment.cs b/test/NodeGuard.Tests/TestHelpers/TestEnvironment.cs new file mode 100644 index 00000000..66d89504 --- /dev/null +++ b/test/NodeGuard.Tests/TestHelpers/TestEnvironment.cs @@ -0,0 +1,48 @@ +/* + * 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 System.Runtime.CompilerServices; + +namespace NodeGuard.TestHelpers; + +internal static class TestEnvironment +{ + /// + /// Fills in the environment variables that Constants reads in its static constructor. + /// + /// + /// Constants tolerates them being missing under test, but the values it exposes are readonly + /// and initialized on first touch, so a test cannot set one after the fact. A module + /// initializer runs before any code in this assembly, which makes the value deterministic no + /// matter which test happens to touch Constants first. Anything already set by the environment + /// wins, so a CI run can still point these elsewhere. + /// + [ModuleInitializer] + internal static void Initialize() + { + SetIfMissing("NBXPLORER_URI", "http://localhost:32838"); + } + + private static void SetIfMissing(string name, string value) + { + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable(name))) + { + Environment.SetEnvironmentVariable(name, value); + } + } +} From a03a323a60d7921345821aeb01a882022c3c5337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:49:04 +0200 Subject: [PATCH 18/21] E2E: coin selection with an ignore list too long for the request line (#576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Scope coin-selection ignore list to queried wallet (#575) * Scope the coin-selection ignore list to the queried wallet GetIgnoredOutpoints told the NBXplorer backend to skip every locked and frozen outpoint in the database, not just the ones belonging to the wallet being queried: GetLockedUTXOs is called with both filters null, and the frozen lookup is a plain scan of the UTXOTags table with no wallet join. Outpoints from other wallets can never appear in the response anyway, so they were pure weight on the request line, and enough of them pushed it past Kestrel's 8KB limit for a 414 that silently knocked the whole selection back to the plain UTXO listing, discarding the strategy, limit and amount. Intersect the combined list with the wallet's own UTXO set, which the caller has already fetched, and dedupe it: an outpoint that was both locked and frozen was being sent twice. Dust comes off the list entirely. The backend already drops it by value, so enumerating it spent one query parameter per dust UTXO to say something a single parameter says better. FilterLockedFrozenUTXOs still strips dust from the response, so nothing dusty can reach the caller regardless. The gRPC path in NodeGuardService builds its own, already wallet-scoped copy of this list and is deliberately left alone here. * POST the selectutxos ignore list instead of putting it on the request line Scoping the list to the wallet shortens it but does not bound it: a wallet with enough locked or frozen UTXOs of its own still overflows Kestrel's default 8192-byte MaxRequestLineSize and earns a 414, because every ignored outpoint costs another repeated query parameter at roughly 83 bytes apiece. Send the outpoints in a JSON body instead. The selection parameters stay on the query string. Kestrel's body limit is 30,000,000 bytes against 8,192 for the request line, about 3,662x the room, so the list no longer has a practical ceiling — roughly 430,000 outpoints rather than ninety. This makes an NBXplorer serving POST /selectutxos (Elenpay/NBXplorer#18) a hard requirement. Against an older build every call answers 405, which the callers swallow the same way they swallow the 414, so coin selection degrades for every wallet until that image is deployed. Ship and roll out #18 first. Also send minimumValue, so the backend filters dust by value instead of the caller listing each dust outpoint. It defaults to the same 546 the backend hardcoded before, so this is a no-op at the default MINIMUM_UTXO_VALUE_SATS. The tests pin the wire format the fork's controller binds to, at both a handful of outpoints and well past the old ceiling. NBXplorerService had no test at all before this, and the test project had no HttpMessageHandler mocking; Moq covers it without a new package. TestEnvironment supplies NBXPLORER_URI from a module initializer because Constants reads it in a static constructor into a readonly field, so a test cannot set it after the fact, and ExplorerClient construction throws on a null URI. A value already set by the environment wins, so CI can point it elsewhere. * E2E: coin selection with an ignore list too long for the request line Guards the transport switch end to end. The test funds a wallet with 130 small outputs plus a few larger ones in a single transaction, freezes only the small ones through the AddTags RPC, and asserts GetAvailableUtxos still returns the wallet's real UTXOs and never surfaces a frozen one. Frozen UTXOs rather than dust carry the list on purpose. Dust is excluded by value now, so a dust-driven test would stay green even if the exclusion list never reached the server; frozen UTXOs above the dust floor have no other reason to be missing. The test also thaws one at the end and asserts it comes back, which pins their absence on the tag rather than on anything incidental. Before the fix this is a 414 that GetAvailableUtxos swallows into an empty UTXOChanges, so the RPC reports success with nothing selectable for a 20 BTC wallet. That is the assertion that flips. Uses the fourth seeded wallet, not the hot wallet the rest of the suite withdraws from, because the frozen outputs stay in it for the rest of the run. The suite needs an NBXplorer serving POST selectutxos, so the runner gets an NBXPLORER_URI and the test probes the route first, failing with that diagnosis rather than looking like a coin-selection bug. Without the probe it would be red identically before and after the fix, which is no test at all. TODO before this is merged: repin the nbxplorer image to the immutable tag of a build carrying the POST route. See the comment in docker/docker-compose.dev.yml. --- docker/docker-compose.dev.yml | 6 + docker/e2e/README.md | 7 + docker/e2e/docker-compose.yml | 4 + .../LargeIgnoreListCoinSelectionE2ETests.cs | 447 ++++++++++++++++++ 4 files changed, 464 insertions(+) create mode 100644 test/NodeGuard.Tests/E2E/LargeIgnoreListCoinSelectionE2ETests.cs diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 29cc0bee..8bcd14bf 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -6,6 +6,12 @@ include: services: nbxplorer: restart: unless-stopped + # Must carry the POST form of the custom selectutxos endpoint (Elenpay/NBXplorer): NodeGuard moves + # the ignored-outpoint list into a request body once it outgrows the request line, and a GET-only + # build answers 405 there. LargeIgnoreListCoinSelectionE2ETests probes the route and names this pin + # when it is missing. TODO before merging the POST transport: repin to the IMMUTABLE tag of the + # build that carries the route — compose does not re-pull a tag it already has locally, so a + # floating one lets a stale copy keep serving the GET-only route. image: ghcr.io/elenpay/nbxplorer:elenpay-develop platform: linux/amd64 hostname: nbxplorer diff --git a/docker/e2e/README.md b/docker/e2e/README.md index b27b4a34..9830f511 100644 --- a/docker/e2e/README.md +++ b/docker/e2e/README.md @@ -49,6 +49,13 @@ Tests live in `test/NodeGuard.Tests/E2E/`. The rebalance/fee-engine scenarios ar `{NODE}_HOST` + `{NODE}_MACAROON` (from `extract-env`). `LndTestClient` reads them from the process env or, failing that, straight from `nodeguard-macaroons.env` on the mounted `e2e_env` volume — so it works without the runner entrypoint exporting them. +- **NBXplorer must serve `POST selectutxos`**: NodeGuard sends the coin-selection exclusion list in a + request body, so a GET-only NBXplorer answers 405 to every `selectutxos` call and coin selection degrades + for every wallet, not just the ones this test covers. It probes `NBXPLORER_URI` first + (`Allow: GET` = pre-fix, `Allow: GET, POST` = patched) and fails with that diagnostic rather than looking + like a coin-selection bug — without the probe the test is red identically pre-fix and post-fix, because + `GetAvailableUtxos` swallows the 405 into an empty selection. The image is pinned in + `docker/docker-compose.dev.yml`, which must point at a build carrying the route. - **Adding an e2e test**: put it in `[Collection("E2E")]` (so it serialises with the others on the one regtest chain — without it the class runs in parallel and they interfere), have it provision the resources it needs and reset its own state, and gate it with `[E2EFact]`. diff --git a/docker/e2e/docker-compose.yml b/docker/e2e/docker-compose.yml index 4fd4034b..2ab714b2 100644 --- a/docker/e2e/docker-compose.yml +++ b/docker/e2e/docker-compose.yml @@ -119,10 +119,14 @@ services: dockerfile: docker/e2e/Dockerfile.runner depends_on: nodeguard: { condition: service_started } + nbxplorer: { condition: service_healthy } # the coin-selection e2e probes NBXplorer directly extract-env: { condition: service_completed_successfully } environment: RUN_E2E_TESTS: "1" NODEGUARD_GRPC_ENDPOINT: "http://nodeguard:50051" + # LargeIgnoreListCoinSelectionE2ETests probes this for the POST selectutxos route before it trusts + # an empty selection, so an unpatched backend reports itself instead of looking like a NodeGuard bug. + NBXPLORER_URI: "http://nbxplorer:32838" NODEGUARD_API_TOKEN: "8rvSsUGeyXXdDQrHctcTey/xtHdZQEn945KHwccKp9Q=" BITCOIND_RPC_URL: "http://bitcoind:18443" BITCOIND_RPC_USER: "polaruser" diff --git a/test/NodeGuard.Tests/E2E/LargeIgnoreListCoinSelectionE2ETests.cs b/test/NodeGuard.Tests/E2E/LargeIgnoreListCoinSelectionE2ETests.cs new file mode 100644 index 00000000..cbf9eba9 --- /dev/null +++ b/test/NodeGuard.Tests/E2E/LargeIgnoreListCoinSelectionE2ETests.cs @@ -0,0 +1,447 @@ +/* + * 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 System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Grpc.Core; +using NBitcoin; +using NBitcoin.RPC; +using Nodeguard; +using Xunit.Abstractions; + +namespace NodeGuard.Tests.E2E; + +/// +/// End-to-end regression for coin selection when the list of outpoints to exclude is longer than an +/// HTTP request line: GetAvailableUtxos must keep returning the wallet's real UTXOs, and must keep +/// hiding every excluded one, no matter how many exclusions there are. +/// +/// What this guards against. NodeGuard asks NBXplorer's custom selectutxos endpoint to skip +/// the locked, frozen and dust outpoints of the wallet. Those used to travel as one repeated +/// &ignoreOutpoint=<txid>-<n> query parameter each — about 82 bytes apiece — on the +/// request line, and NBXplorer leaves Kestrel's MaxRequestLineSize at its 8KB default. Past roughly +/// 98 exclusions Kestrel rejected the request before it ever reached MVC routing and answered +/// HTTP 414 with an empty body. GetAvailableUtxos catches every backend failure and degrades +/// to new UTXOChanges() (src/Rpc/NodeGuardService.cs), so the RPC still returned OK — +/// with an empty confirmed list for a wallet holding 20 BTC. Nothing threw, nothing logged an +/// error to the caller, and unlike the Blazor path there is no fallback to the plain UTXO listing. +/// Silent, total loss of coin selection for any wallet with enough excluded outpoints. +/// +/// The fix moves the list off the request line for good: NBXplorerService POSTs the exclusions as a +/// JSON body on every call, whatever their number, and the NBXplorer controller accepts them there +/// as well as on the query string. So this test needs an NBXplorer build that serves the POST +/// selectutxos route — and so does every other caller, which is why a GET-only backend is a +/// deployment problem rather than a quirk of this test. It is a precondition, not an assertion about +/// NodeGuard: against an older image the POST is refused, the failure is swallowed the same way, and +/// every assertion below fails identically whether or not NodeGuard is behaving — which is why the +/// route is probed first and reported by name. +/// +/// Why FROZEN outpoints rather than dust drive the list. Dust is now excluded by value +/// (minimumValue), so a dust-driven test would stay green even if the exclusion list were +/// silently dropped on the floor — the backend would hide the dust anyway. Frozen UTXOs worth more +/// than the dust floor have no other reason to be missing: their absence from the response is only +/// explicable if the list actually reached the server. That also keeps the test honest if the +/// gRPC path ever stops enumerating dust outpoints individually, the way the Blazor path already did. +/// +/// Exercised against a LIVE NodeGuard instance + bitcoind; shared plumbing in . +/// Gated by (RUN_E2E_TESTS=1). Connection via env: +/// NODEGUARD_GRPC_ENDPOINT default http://localhost:50051 (h2c) +/// NODEGUARD_API_TOKEN default the dev "Liquidator" token +/// BITCOIND_RPC_URL/USER/PASS/WALLET default http://localhost:18443 / polaruser / polarpass / default +/// NBXPLORER_URI default http://localhost:32838 — probed for the POST selectutxos route +/// E2E_HOT_WALLET_ID the hot wallet the other e2e classes share (default 3) — avoided here +/// E2E_LARGE_IGNORE_LIST_WALLET_ID the hot wallet this test pollutes (default 4) +/// +[Trait("Category", "E2E")] +[Collection("E2E")] +public class LargeIgnoreListCoinSelectionE2ETests : E2ETestBase +{ + /// + /// How many outpoints to push into the wallet's exclusion list. The 414 threshold is ~98 + /// (see ); 130 clears it by a third, which absorbs + /// multi-digit output indices and any outpoint the fixture fails to place. The funding + /// transaction is ~4.2 kvB, far below the 100 kvB standardness cap. + /// + private const int FrozenUtxoCount = 130; + + /// + /// Value of each frozen output. It must sit ABOVE Constants.MINIMUM_UTXO_VALUE_SATS (546), so + /// that the backend's minimumValue filter is not what hides these UTXOs — only the exclusion + /// list can be, which is the whole point of the assertions below. It must also clear bitcoind's + /// P2WPKH dust relay threshold (294 sats at the default minrelaytxfee) or the funding + /// transaction would be rejected as non-standard. + /// + private const long FrozenUtxoValueSats = 1_000; + + /// + /// Values of the outputs the same funding transaction leaves UNFROZEN. DbInitializer funds this + /// wallet exactly once, with a single 20 BTC UTXO, so without these the post-freeze selection is + /// one element long and everything said about it holds trivially: a one-element list is both + /// ascending and descending, and limit=1 cannot be told apart from no limit at all. Three values + /// distinct from each other, from and from the seeded 20 BTC + /// make the ordering observable and give the limit something to discard. + /// + private static readonly long[] UnfrozenUtxoValuesSats = [250_000, 500_000, 750_000]; + + // The custom NBXplorer selectutxos backend behind GetAvailableUtxos picks UTXOs toward a target + // amount (amount=0 always yields an empty selection), so every call must request one. + private const long ProbeAmountSats = 2_000_000; + + /// Kestrel's default request line budget, which NBXplorer does not raise. + private const int KestrelMaxRequestLineSize = 8192; + + // Fee for the funding transaction, as a flat rate over its virtual size plus an allowance for the + // signature the unsigned skeleton does not carry yet. Both are deliberately generous: the + // transaction is ~4.2 kvB, only bitcoind's own change pays for it, and the alternative to + // overpaying on regtest is a batch that never relays. + private const long FundingFeeRateSatsPerVByte = 5; + private const int SignedInputVSizeAllowance = 150; + + private const string IgnoreOutpointParam = "&ignoreOutpoint="; + + // Constants.IsManuallyFrozenTag / the value CoinSelectionService.GetFrozenUTXOs looks for. + private const string ManuallyFrozenTagKey = "manually_frozen"; + + public LargeIgnoreListCoinSelectionE2ETests(ITestOutputHelper output) : base(output) + { + } + + [E2EFact] + public async Task GetAvailableUtxos_WithAnIgnoreListTooLongForTheRequestLine_StillSelects() + { + var client = CreateClient(out var headers); + var rpc = CreateBitcoindRpc(); + + // 0. Harness precondition, established before anything expensive runs. GetAvailableUtxos + // swallows every backend failure into an empty UTXOChanges, so against an NBXplorer that + // predates the POST route this whole test degrades to "the selection is empty" — the same + // red a genuine regression shows, with none of the diagnosis. + await AssertSelectUtxosAcceptsPostAsync(); + + var walletId = int.Parse(Env("E2E_LARGE_IGNORE_LIST_WALLET_ID", "4")); + walletId.Should().NotBe(int.Parse(Env("E2E_HOT_WALLET_ID", "3")), + "this test permanently freezes ~130 UTXOs into the wallet it targets, so it must not run against " + + "the hot wallet the rest of the e2e suite withdraws from and opens channels with; DbInitializer " + + "seeds a fourth wallet (\"Test BIP39 Singlesig wallet\", 20 BTC) that no other e2e class references"); + + // 1. Wait for NodeGuard to be up AND for DbInitializer to have seeded and funded the wallets: + // the host answers gRPC before the seeding hosted service has finished seeding. + var wallet = await RetryAsync(async () => + { + var wallets = await client.GetAvailableWalletsAsync( + new GetAvailableWalletsRequest { WalletType = WALLET_TYPE.Hot }, headers); + return wallets.Wallets.SingleOrDefault(w => w.Id == walletId) + ?? throw new InvalidOperationException( + $"wallet {walletId} is not an available hot wallet; seen: " + + $"[{string.Join(", ", wallets.Wallets.Select(w => $"{w.Id}:{w.Name}"))}]"); + }, attempts: 90, delay: TimeSpan.FromSeconds(4), what: "GetAvailableWallets (NodeGuard readiness)"); + _output.WriteLine($"target wallet {wallet.Id} \"{wallet.Name}\" (hot={wallet.IsHotWallet})"); + + // 2. Baseline, taken BEFORE anything is added to the exclusion list: with a short list the + // selection already works for this wallet. Every later assertion is a regression against + // this snapshot, which is what makes a later failure attributable to the list's LENGTH + // rather than to the wallet being broken or empty. It is a subset, not the whole answer: + // step 3 deliberately adds spendable UTXOs on top of it. + var baselineOutpoints = await RetryAsync(async () => + { + var selection = await SelectAsync(client, headers, walletId, COIN_SELECTION_STRATEGY.BiggestFirst); + if (selection.Confirmed.Sum(u => u.Amount) < ProbeAmountSats) + throw new InvalidOperationException($"wallet {walletId} has no spendable UTXO yet"); + return selection.Confirmed.Select(u => u.Outpoint).ToList(); + }, attempts: 60, delay: TimeSpan.FromSeconds(4), what: "GetAvailableUtxos (target wallet funded)"); + _output.WriteLine($"baseline selection: {baselineOutpoints.Count} UTXO(s)"); + + // 3. Give the wallet enough outputs to overflow the request line — plus a few larger ones that + // stay spendable, so the selection this test inspects has more than one element in it — in + // ONE transaction on ONE reserved address. One transaction because a loop of sendtoaddress + // would chain each send onto the previous one's unconfirmed change and hit bitcoind's + // 25-ancestor mempool limit; one address because NBXplorer keys its UTXO set by outpoint, + // not by script, so repeated payments to the same address are still independent UTXOs — and + // reusing an address keeps the wallet's address index (shared state) where it was. + var addressResponse = await client.GetNewWalletAddressAsync( + new GetNewWalletAddressRequest { WalletId = walletId, Skip = 0, Reserve = true }, headers); + var fundingAddress = BitcoinAddress.Create(addressResponse.Address, Network.RegTest); + var fundingValues = Enumerable.Repeat(FrozenUtxoValueSats, FrozenUtxoCount) + .Concat(UnfrozenUtxoValuesSats) + .ToList(); + var fundingTxId = await BroadcastManyOutputsAsync(rpc, fundingAddress, fundingValues); + await MineAsync(rpc, 6); + + // Only the outputs paying our address are ours: bitcoind's change output pays a script of its + // own. Value tells the two batches apart — no unfrozen value coincides with the frozen one. + var fundingTx = await rpc.GetRawTransactionAsync(fundingTxId); + var ours = fundingTx.Outputs.AsIndexedOutputs() + .Where(o => o.TxOut.ScriptPubKey == fundingAddress.ScriptPubKey) + .ToList(); + var frozenOutpoints = ours.Where(o => o.TxOut.Value == Money.Satoshis(FrozenUtxoValueSats)) + .Select(o => new OutPoint(fundingTxId, o.N).ToString()).ToList(); + var spendableOutpoints = ours.Where(o => o.TxOut.Value != Money.Satoshis(FrozenUtxoValueSats)) + .Select(o => new OutPoint(fundingTxId, o.N).ToString()).ToList(); + frozenOutpoints.Should().HaveCount(FrozenUtxoCount, + "the funding transaction must carry one output per intended exclusion"); + spendableOutpoints.Should().HaveCount(UnfrozenUtxoValuesSats.Length, + "the same transaction must carry the larger outputs that keep the selection multi-element"); + _output.WriteLine($"funded {FrozenUtxoCount} x {FrozenUtxoValueSats} sats to freeze and " + + $"[{string.Join(", ", UnfrozenUtxoValuesSats)}] sats to leave spendable, in {fundingTxId}"); + + // NBXplorer indexes on block-connect through a notification pipeline that can lag a second or + // two, and an outpoint it has not indexed yet is not in the wallet's UTXO set — so NodeGuard + // would filter it straight back out of the frozen list instead of putting it on the wire. + var fundedOutpoints = frozenOutpoints.Concat(spendableOutpoints).ToList(); + await RetryAsync(async () => + { + var all = await client.GetUtxosAsync(new GetUtxosRequest(), headers); + var indexed = all.Confirmed.Select(u => u.Outpoint).ToHashSet(); + var missing = fundedOutpoints.Count(outpoint => !indexed.Contains(outpoint)); + if (missing > 0) + throw new InvalidOperationException($"{missing}/{fundedOutpoints.Count} funded UTXOs not indexed yet"); + return true; + }, attempts: 30, delay: TimeSpan.FromSeconds(4), what: "GetUtxos (funded UTXOs indexed)"); + + // 4. Non-vacuity guard. The transport no longer varies with the size of the list, so what + // makes this a regression test rather than a plain smoke test is that the list is big + // enough that the OLD query-string form could not have carried it: a shorter one would + // have been served fine before the fix and would prove nothing. So assert on the byte + // count that decided it: the exclusion parameters ALONE must not fit on a request line. + // This is + // a strict lower bound (it charges nothing for the ~110-char derivation scheme in the path + // or for the strategy/limit/amount/minimumValue parameters), so clearing it is conclusive. + var ignoreListBytes = frozenOutpoints.Sum(outpoint => IgnoreOutpointParam.Length + outpoint.Length); + var requestLineLowerBound = "GET ".Length + "/v1/cryptos/btc/derivations/".Length + + "/selectutxos".Length + " HTTP/1.1".Length + ignoreListBytes; + _output.WriteLine($"exclusion list: {frozenOutpoints.Count} outpoints / {ignoreListBytes} bytes; " + + $"request line >= {requestLineLowerBound} bytes vs Kestrel's {KestrelMaxRequestLineSize}"); + requestLineLowerBound.Should().BeGreaterThan(KestrelMaxRequestLineSize, + $"{FrozenUtxoCount} exclusions must overflow Kestrel's request line, otherwise this test would " + + "also have passed against the pre-fix query-string form and would be guarding nothing"); + + // 5. Freeze them all in one call. Frozen ∩ wallet is the part of the exclusion list that both + // code paths deliberately keep sending, so it is the durable way to make the list long. + await SetManuallyFrozenAsync(client, headers, frozenOutpoints, frozen: true); + + // 6. The regression itself. No retry loop: once the UTXOs are indexed the answer is + // deterministic, so retrying would only turn an instant failure into a slow one — and it + // would mask an intermittent regression rather than report it. + var expectedSelectable = baselineOutpoints.Count + spendableOutpoints.Count; + var selection = await SelectAsync(client, headers, walletId, COIN_SELECTION_STRATEGY.BiggestFirst); + selection.Confirmed.Should().NotBeEmpty( + $"the {frozenOutpoints.Count}-outpoint exclusion list travels in a POST body; on the query string it " + + "would overflow the request line, NBXplorer would answer 414, GetAvailableUtxos would swallow the " + + "failure into new UTXOChanges() and report a successful RPC with nothing selectable for a 20 BTC wallet"); + selection.Confirmed.Select(u => u.Outpoint).Should().Contain(baselineOutpoints, + "a long exclusion list must not cost the wallet any of the UTXOs it could spend before"); + selection.Confirmed.Select(u => u.Outpoint).Should().Contain(spendableOutpoints, + "the larger outputs of the funding transaction were never frozen, so a correct backend returns them " + + "alongside the baseline — and they are what keeps the ordering and limit assertions below honest"); + selection.Confirmed.Select(u => u.Outpoint).Should().NotIntersectWith(frozenOutpoints, + $"every frozen outpoint is worth {FrozenUtxoValueSats} sats, comfortably above the backend's " + + "minimumValue floor, and the gRPC path applies no local filter afterwards — so the only thing that " + + "can keep them out of the response is the exclusion list arriving and being honoured server-side"); + selection.Confirmed.Should().HaveCountGreaterThanOrEqualTo(expectedSelectable, + "the ordering assertion that follows is only worth making over several UTXOs of different values: " + + "any one-element list is both ascending and descending"); + selection.Confirmed.Select(u => u.Amount).Should().BeInDescendingOrder( + "BiggestFirst orders the selection server-side (value DESC), so a correctly ordered response is " + + "proof the answer came from the custom backend rather than from some degraded fallback"); + + // SmallestFirst is the adversarial ordering: the frozen UTXOs are by far the smallest, so any + // leak surfaces at the very front of this response instead of being buried. + var smallestFirst = await SelectAsync(client, headers, walletId, COIN_SELECTION_STRATEGY.SmallestFirst); + smallestFirst.Confirmed.Should().NotBeEmpty( + "an empty answer here is the swallowed-failure mode this whole test exists to catch, not a pass: if " + + "NBXplorer 5xx'd or restarted between the two calls, GetAvailableUtxos would degrade to " + + "new UTXOChanges() and both assertions below would hold over nothing at all"); + smallestFirst.Confirmed.Should().HaveCountGreaterThanOrEqualTo(expectedSelectable, + "SmallestFirst sees the same unfrozen UTXOs as BiggestFirst, so a shorter answer means something " + + "was dropped — and would leave the ordering assertion below with too little to order"); + smallestFirst.Confirmed.Select(u => u.Outpoint).Should().NotIntersectWith(frozenOutpoints, + "SmallestFirst would rank the frozen UTXOs ahead of everything else if they leaked through"); + smallestFirst.Confirmed.Select(u => u.Amount).Should().BeInAscendingOrder( + "SmallestFirst orders value ASC server-side, the exact reverse of the response above, over the same set"); + + // The body carries only the exclusions; strategy/limit/amount/minimumValue stay on the query + // string. Asking for a single UTXO checks that the split did not lose them: with several + // candidates available, a limit that never reached the backend comes back with all of them, + // and a limit applied to the wrong ordering comes back with the wrong one. + var limitedToOne = await client.GetAvailableUtxosAsync(new GetAvailableUtxosRequest + { + WalletId = walletId, + Strategy = COIN_SELECTION_STRATEGY.BiggestFirst, + Amount = 1, + Limit = 1, + }, headers); + limitedToOne.Confirmed.Should().ContainSingle( + $"limit=1 travels as a query parameter alongside the body, so with {selection.Confirmed.Count} " + + "UTXOs available it must still trim the answer to exactly one") + .Which.Amount.Should().Be(selection.Confirmed.Max(u => u.Amount), + "BiggestFirst with limit=1 must pick the wallet's largest available UTXO, not just any one of them"); + + // 7. Fixture proof: unfreeze exactly one of them and it comes back. Without this, the + // NotIntersectWith assertions above would also hold if the UTXOs were missing for some + // unrelated reason (never indexed, wrong wallet, filtered on value) — this pins the + // exclusion on the tag we set, and the list is still 129 long while it runs. + var probeOutpoint = frozenOutpoints[0]; + try + { + await SetManuallyFrozenAsync(client, headers, [probeOutpoint], frozen: false); + var withProbeThawed = await SelectAsync(client, headers, walletId, COIN_SELECTION_STRATEGY.SmallestFirst); + withProbeThawed.Confirmed.Select(u => u.Outpoint).Should().Contain(probeOutpoint, + "an unfrozen UTXO above the dust floor must be selectable again, which proves the other " + + "outpoints are absent because they are frozen and not for some incidental reason"); + } + finally + { + // Re-freeze even if the assertion failed: these UTXOs stay frozen for the rest of the run + // on purpose. Nothing else uses this wallet, and frozen UTXOs are inert for every selection + // path, whereas leaving 130 spendable 1000-sat inputs behind would reshape any later + // selection on it. The few larger outputs stay spendable by design — they are ordinary + // wallet coins and each further run simply adds its own. Nothing is cleaned up: `just + // test-e2e` tears the volumes down, and a long-lived dev stack keeps them. + await SetManuallyFrozenAsync(client, headers, [probeOutpoint], frozen: true); + } + } + + private static Task SelectAsync(NodeGuardService.NodeGuardServiceClient client, + Metadata headers, int walletId, COIN_SELECTION_STRATEGY strategy) + => client.GetAvailableUtxosAsync(new GetAvailableUtxosRequest + { + WalletId = walletId, + Strategy = strategy, + Amount = ProbeAmountSats, + // 0 means "do not trim the result", so the response is the full set of UTXOs the backend + // considers available — which is what the "none of these appear" assertions need. + Limit = 0, + }, headers).ResponseAsync; + + /// + /// Fails fast unless NBXplorer routes POST on selectutxos — the transport every selectutxos call uses. + /// + /// + /// MVC answers a method mismatch from the endpoint's method matcher, before model binding and + /// before the controller's [Authorize], so a POST carrying a deliberately unparseable + /// derivation scheme is a read-only capability probe: it touches no wallet and needs no + /// credentials. A pre-fix image answers 405 with Allow: GET; a patched one answers + /// 400, because the junk scheme fails to bind. The readiness call comes first so "this backend + /// predates the fix" is never confused with "this backend is not up yet" — and so an unreachable + /// NBXplorer fails loudly instead of quietly excusing the test. + /// + private async Task AssertSelectUtxosAcceptsPostAsync() + { + var baseUri = Env("NBXPLORER_URI", "http://localhost:32838").TrimEnd('/'); + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + + await RetryAsync(async () => + { + var status = await http.GetAsync($"{baseUri}/v1/cryptos/btc/status"); + if (!status.IsSuccessStatusCode) + throw new InvalidOperationException($"status endpoint answered {(int)status.StatusCode}"); + return true; + }, attempts: 30, delay: TimeSpan.FromSeconds(2), what: $"NBXplorer readiness ({baseUri})"); + + var probe = await http.PostAsync($"{baseUri}/v1/cryptos/btc/derivations/PROBE/selectutxos?amount=0", + JsonContent.Create(new { ignoreOutpoints = Array.Empty() })); + var allow = probe.Content.Headers.Allow.Count > 0 + ? string.Join(", ", probe.Content.Headers.Allow) + : probe.Headers.TryGetValues("Allow", out var values) ? string.Join(", ", values) : "unset"; + _output.WriteLine($"NBXplorer {baseUri}: POST selectutxos -> {(int)probe.StatusCode} (Allow: {allow})"); + + probe.StatusCode.Should().NotBe(HttpStatusCode.MethodNotAllowed, + $"the NBXplorer at {baseUri} serves selectutxos on GET only (Allow: {allow}), so it predates the POST " + + "route this test exercises. Nothing below would be measuring NodeGuard: the POST 405s, " + + "GetAvailableUtxos degrades to an empty UTXOChanges, and every assertion fails identically whether " + + "or not NodeGuard is behaving. Point the stack at an NBXplorer carrying the POST route: " + + "the image is pinned in docker/docker-compose.dev.yml"); + } + + /// + /// Freezes (or unfreezes) outpoints the same way the Wallets UI does, in a single AddTags call. + /// + private static async Task SetManuallyFrozenAsync(NodeGuardService.NodeGuardServiceClient client, + Metadata headers, IEnumerable outpoints, bool frozen) + { + var request = new AddTagsRequest(); + foreach (var outpoint in outpoints) + { + request.Tags.Add(new Tag + { + Key = ManuallyFrozenTagKey, + Value = frozen ? "true" : "false", + UtxoOutpoint = outpoint, + }); + } + + await client.AddTagsAsync(request, headers); + } + + /// + /// Broadcasts one transaction paying every value in to the + /// same address, as that many separate outputs, and returns its txid. + /// + /// + /// Assembled with NBitcoin and handed to bitcoind only to sign and broadcast, because none of + /// bitcoind's transaction-building RPCs will produce this shape. sendmany takes its outputs as a + /// map keyed by address, so repeated payments to one address collapse into a single summed + /// output. createrawtransaction's array form rejects the same address twice outright — "Invalid + /// parameter, duplicated address" (RPC error -8), and its help says so: "no address may be + /// duplicated". That restriction belongs to the RPC alone. A transaction is a plain list of + /// outputs, so one carrying 130 identical scriptPubKeys is valid, standard and relayable, and + /// bitcoind signs and accepts it without complaint. NBitcoin serialises it happily too: its only + /// refusal is an INPUT-LESS transaction, which is why the input is picked here from listunspent + /// rather than left to fundrawtransaction. + /// + private async Task BroadcastManyOutputsAsync(RPCClient rpc, BitcoinAddress address, + IReadOnlyCollection outputValuesSats) + { + var paid = Money.Satoshis(outputValuesSats.Sum()); + + // One confirmed coin of bitcoind's own pays for the whole batch: a single input keeps the + // transaction small beside its many outputs, and keeps it off any unconfirmed ancestor chain. + var coin = (await rpc.ListUnspentAsync(1, int.MaxValue)) + .Where(c => c.IsSpendable) + .OrderByDescending(c => c.Amount) + .FirstOrDefault(); + if (coin is null) + throw new InvalidOperationException("bitcoind's wallet holds no confirmed spendable coin to fund from"); + + var tx = Network.RegTest.CreateTransaction(); + tx.Inputs.Add(coin.OutPoint); + foreach (var value in outputValuesSats) + { + tx.Outputs.Add(Money.Satoshis(value), address); + } + + var change = tx.Outputs.Add(Money.Zero, await rpc.GetRawChangeAddressAsync()); + var fee = Money.Satoshis(FundingFeeRateSatsPerVByte * (tx.GetVirtualSize() + SignedInputVSizeAllowance)); + change.Value = coin.Amount - paid - fee; + change.Value.Satoshi.Should().BeGreaterThan(FrozenUtxoValueSats, + $"the largest confirmed coin bitcoind holds ({coin.Amount}) has to cover {paid} of outputs and a {fee} " + + "fee and still leave a change output well clear of the dust relay threshold"); + + var signed = await rpc.SignRawTransactionWithWalletAsync(new SignRawTransactionRequest { Transaction = tx }); + signed.Complete.Should().BeTrue( + $"bitcoind must be able to sign the coin it listed as spendable ({coin.OutPoint}); errors: " + + (signed.Errors?.Length > 0 ? string.Join(", ", signed.Errors) : "none")); + + var txId = await rpc.SendRawTransactionAsync(signed.SignedTransaction); + _output.WriteLine($"funding tx {txId}: {outputValuesSats.Count} outputs totalling {paid}, " + + $"{tx.GetVirtualSize()} vB, {fee} fee, funded by {coin.OutPoint}"); + return txId; + } +} From 21252c0230ed0e7db5589965596b6be5b795ac26 Mon Sep 17 00:00:00 2001 From: Ismael Date: Wed, 26 Aug 2026 15:28:02 +0200 Subject: [PATCH 19/21] Fix sweep node wallets job to return errors without clear reason --- src/Jobs/SweepNodeWalletsJob.cs | 146 +++++++++++++++++++------------- 1 file changed, 87 insertions(+), 59 deletions(-) diff --git a/src/Jobs/SweepNodeWalletsJob.cs b/src/Jobs/SweepNodeWalletsJob.cs index 0fc6b3a9..a1fc724c 100644 --- a/src/Jobs/SweepNodeWalletsJob.cs +++ b/src/Jobs/SweepNodeWalletsJob.cs @@ -95,69 +95,67 @@ async Task SweepFunds(Node node, Wallet wallet, Lightning.LightningClient lightn }); var totalSatsAvailable = utxos.Sum(x => x.AmountSat); - if (returningAddress != null && lndChangeAddress != null && utxos.Any() && totalSatsAvailable > Constants.MINIMUM_SWEEP_TRANSACTION_AMOUNT_SATS) + var canSweep = returningAddress != null && lndChangeAddress != null && utxos.Any() && totalSatsAvailable > Constants.MINIMUM_SWEEP_TRANSACTION_AMOUNT_SATS; + + if (!canSweep) { - // We need to maintain onchain balance to be at least RequiredAnchorChannelClosingAmount but also we apply a 10% buffer to pay for this sweep fees and let some more money on the wallet - var sweepedFundsAmount = (long)((totalSatsAvailable - requiredAnchorChannelClosingAmount) * 0.9); - var sendManyResponse = await lightningClient.SendManyAsync(new SendManyRequest() + // Sweep can't be performed, log the reason + var reason = GetReasonFailureErrorSweeping(returningAddress, lndChangeAddress); + if (!string.IsNullOrEmpty(reason)) { - AddrToAmount = - { - {returningAddress.Address.ToString(), sweepedFundsAmount}, //Sweeped funds - }, - MinConfs = 6, - Label = $"Hot wallet Sweep tx on {DateTime.UtcNow.ToString("O")} to walletId:{wallet.Id}", - SpendUnconfirmed = false, - TargetConf = Constants.SWEEP_CONF_TARGET - }, - new Metadata - { - { - "macaroon", node.ChannelAdminMacaroon - } - }); - - _logger.LogInformation("Utxos swept out for nodeId: {NodeId} on txid: {TxId} with returnAddress: {Address}", - node.Id, - sendManyResponse.Txid, - returningAddress.Address); - - // Audit successful wallet sweep - await _auditService.LogSystemAsync( - AuditActionType.WalletSweep, - AuditEventType.Success, - AuditObjectType.Wallet, - wallet.Id.ToString(), - new - { - NodeId = node.Id, - NodeName = node.Name, - WalletId = wallet.Id, - WalletName = wallet.Name, - AmountSats = sweepedFundsAmount, - TxId = sendManyResponse.Txid, - ReturnAddress = returningAddress.Address.ToString() - }); - - //TODO We need to store the txid somewhere to monitor it.. + _logger.LogError("Error while funding sweep transaction reason: {Reason}", reason); + } else + { + reason = GetReasonFailureWarningSweeping(utxos, requiredAnchorChannelClosingAmount, totalSatsAvailable); + _logger.LogWarning("Cannot sweep transaction reason: {Reason}", reason); + } + return; } - else + + + // We need to maintain onchain balance to be at least RequiredAnchorChannelClosingAmount but also we apply a 10% buffer to pay for this sweep fees and let some more money on the wallet + var sweepedFundsAmount = (long)((totalSatsAvailable - requiredAnchorChannelClosingAmount) * 0.9); + var sendManyResponse = await lightningClient.SendManyAsync(new SendManyRequest() { - var reason = returningAddress == null - ? "Returning address not found / null" - : - lndChangeAddress == null - ? "LND returning address not found / null" - : - !utxos.Any() - ? "No UTXOs found to fund the sweep tx" - : - totalSatsAvailable <= requiredAnchorChannelClosingAmount - ? - "Total sats available is less than the required to have for channel closing amounts, ignoring tx" : string.Empty; - - _logger.LogError("Error while funding sweep transaction reason: {Reason}", reason); - } + AddrToAmount = + { + {returningAddress.Address.ToString(), sweepedFundsAmount}, //Sweeped funds + }, + MinConfs = 6, + Label = $"Hot wallet Sweep tx on {DateTime.UtcNow.ToString("O")} to walletId:{wallet.Id}", + SpendUnconfirmed = false, + TargetConf = Constants.SWEEP_CONF_TARGET + }, + new Metadata + { + { + "macaroon", node.ChannelAdminMacaroon + } + }); + + _logger.LogInformation("Utxos swept out for nodeId: {NodeId} on txid: {TxId} with returnAddress: {Address}", + node.Id, + sendManyResponse.Txid, + returningAddress.Address); + + // Audit successful wallet sweep + await _auditService.LogSystemAsync( + AuditActionType.WalletSweep, + AuditEventType.Success, + AuditObjectType.Wallet, + wallet.Id.ToString(), + new + { + NodeId = node.Id, + NodeName = node.Name, + WalletId = wallet.Id, + WalletName = wallet.Name, + AmountSats = sweepedFundsAmount, + TxId = sendManyResponse.Txid, + ReturnAddress = returningAddress.Address.ToString() + }); + + //TODO We need to store the txid somewhere to monitor it.. } } @@ -266,4 +264,34 @@ await _auditService.LogSystemAsync( } _logger.LogInformation("{JobName} ended on node: {NodeName}", nameof(SweepNodeWalletsJob), node.Name); } + + private static string GetReasonFailureErrorSweeping( + NBXplorer.Models.KeyPathInformation? returningAddress, NewAddressResponse? lndChangeAddress + ) + { + if (returningAddress == null) + { + return "Returning address not found / null"; + } + else if (lndChangeAddress == null) + { + return "LND returning address not found / null"; + } + + return string.Empty; + } + private static string GetReasonFailureWarningSweeping( + List utxos, long requiredAnchorChannelClosingAmount, long totalSatsAvailable) + { + if (!utxos.Any()) + { + return "No UTXOs found to fund the sweep tx"; + } + else if (totalSatsAvailable <= requiredAnchorChannelClosingAmount) + { + return "Total sats available is less than the required to have for channel closing amounts, ignoring tx"; + } + + return "Total sats available is below threshold for sweep transaction, ignoring tx"; + } } \ No newline at end of file From 600f4b3d155ebe209ff6edad440db60a1c5589a2 Mon Sep 17 00:00:00 2001 From: Marcos <33052423+markettes@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:16:32 +0900 Subject: [PATCH 20/21] feat: implement max HTLC sync functionality and related tests (#578) --- src/Helpers/Constants.cs | 19 +- src/Jobs/ChannelMonitorJob.cs | 16 +- src/Services/LightningClientService.cs | 10 +- src/Services/LightningService.cs | 146 +++++++ .../Jobs/ChannelMonitorJobTests.cs | 79 ++++ .../Services/LightningClientServiceTests.cs | 4 +- .../Services/LightningServiceTests.cs | 378 +++++++++++++++++- 7 files changed, 634 insertions(+), 18 deletions(-) diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index 5af645f1..daa05d88 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -99,7 +99,7 @@ public class Constants public static readonly decimal MAXIMUM_WITHDRAWAL_BTC_AMOUNT = 21_000_000; public static readonly int TRANSACTION_CONFIRMATION_MINIMUM_BLOCKS; public static int DEFAULT_CHANNEL_FEE_POLICY_TIMELOCK_DELTA_BLOCKS = 40; - public static long DEFAULT_CHANNEL_FEE_POLICY_BASE_FEE_MSAT = 0; + public static long DEFAULT_CHANNEL_FEE_POLICY_BASE_FEE_MSAT = 0; public static long DEFAULT_CHANNEL_FEE_POLICY_FEE_RATE_PPM = 1500; public static readonly long ANCHOR_CLOSINGS_MINIMUM_SATS; public static readonly long MINIMUM_SWEEP_TRANSACTION_AMOUNT_SATS = 25_000_000; //25M sats @@ -340,6 +340,13 @@ public class Constants // Outbound ppm baseline for not-yet-categorized channels (safe mid default). public static uint ROUTING_ENGINE_FEE_BASELINE_PPM_UNCATEGORIZED = 1500; + /// + /// Fraction of a channel's capacity advertised as its max_htlc_msat. LND itself uses ~0.99 for + /// channels it opens, so at the default this reconciles drifted channels without touching + /// untouched ones. + /// + public static double MAX_HTLC_CAPACITY_RATIO = 0.99; + // Routing Engine: automated rebalancer /// Upper clamp on a single automated rebalance amount (sats) @@ -685,6 +692,16 @@ static Constants() var feeBaselineUncategorized = Environment.GetEnvironmentVariable("ROUTING_ENGINE_FEE_BASELINE_PPM_UNCATEGORIZED"); if (feeBaselineUncategorized != null) ROUTING_ENGINE_FEE_BASELINE_PPM_UNCATEGORIZED = uint.Parse(feeBaselineUncategorized); + + // Max HTLC + var maxHtlcCapacityRatio = Environment.GetEnvironmentVariable("MAX_HTLC_CAPACITY_RATIO"); + if (maxHtlcCapacityRatio != null) + { + var parsedRatio = double.Parse(maxHtlcCapacityRatio, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); + // A ratio outside (0, 1] would resolve to 0 or above capacity, both of which LND rejects. + if (parsedRatio > 0 && parsedRatio <= 1) MAX_HTLC_CAPACITY_RATIO = parsedRatio; + else throw new ArgumentOutOfRangeException(nameof(MAX_HTLC_CAPACITY_RATIO), parsedRatio, "MAX_HTLC_CAPACITY_RATIO must be in (0, 1]"); + } var reRebalanceMaxAmount = Environment.GetEnvironmentVariable("ROUTING_ENGINE_REBALANCE_MAX_AMOUNT_SATS"); if (reRebalanceMaxAmount != null) ROUTING_ENGINE_REBALANCE_MAX_AMOUNT_SATS = long.Parse(reRebalanceMaxAmount); var reRebalanceMaxInit = Environment.GetEnvironmentVariable("ROUTING_ENGINE_REBALANCE_MAX_INITIATIONS_PER_RUN"); diff --git a/src/Jobs/ChannelMonitorJob.cs b/src/Jobs/ChannelMonitorJob.cs index 11680344..d48682f2 100644 --- a/src/Jobs/ChannelMonitorJob.cs +++ b/src/Jobs/ChannelMonitorJob.cs @@ -87,6 +87,15 @@ public async Task Execute(IJobExecutionContext context) // Recover Operations on channels await RecoverGhostChannels(node1, node2, channel); await RecoverChannelInConfirmationPendingStatus(node1); + + try + { + await _lightningService.SyncChannelMaxHtlc(node1, channel); + } + catch (Exception e) + { + _logger.LogError(e, "Error while syncing max htlc for channel {ChanId} of node {NodeId}", channel.ChanId, node1.Id); + } } } catch (Exception e) @@ -124,7 +133,7 @@ private async Task RefreshExternalNodeData(Node managedNode, Node remoteNode, Li return; } - if (remoteNode.Name == nodeInfo.Alias) return; + if (remoteNode.Name == nodeInfo.Alias) return; remoteNode.Name = nodeInfo.Alias; var (updated, error) = _nodeRepository.Update(remoteNode); if (!updated) @@ -140,7 +149,7 @@ public async Task RecoverGhostChannels(Node source, Node destination, Channel ch try { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); - + var channelPoint = channel.ChannelPoint.Split(":"); var fundingTx = channelPoint[0]; var outputIndex = Convert.ToUInt32(channelPoint[1]); @@ -150,7 +159,8 @@ public async Task RecoverGhostChannels(Node source, Node destination, Channel ch var parsedChannelPoint = new ChannelPoint { - FundingTxidStr = fundingTx, FundingTxidBytes = ByteString.CopyFrom(Convert.FromHexString(fundingTx).Reverse().ToArray()), + FundingTxidStr = fundingTx, + FundingTxidBytes = ByteString.CopyFrom(Convert.FromHexString(fundingTx).Reverse().ToArray()), OutputIndex = outputIndex }; diff --git a/src/Services/LightningClientService.cs b/src/Services/LightningClientService.cs index 40d5b648..c789508f 100644 --- a/src/Services/LightningClientService.cs +++ b/src/Services/LightningClientService.cs @@ -49,8 +49,7 @@ public interface ILightningClientService public void FundingStateStepVerify(Node node, PSBT finalizedPSBT, byte[] pendingChannelId, Lightning.LightningClient? client = null); public void FundingStateStepFinalize(Node node, PSBT finalizedPSBT, byte[] pendingChannelId, Lightning.LightningClient? client = null); public void FundingStateStepCancel(Node node, byte[] pendingChannelId, Lightning.LightningClient? client = null); - - public Task SetChannelFeePolicy(Node node, NBitcoin.OutPoint chanPoint, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm, Lightning.LightningClient? client = null); + public Task SetChannelFeePolicy(Node node, NBitcoin.OutPoint chanPoint, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm, ulong? maxHtlcMsat = null, Lightning.LightningClient? client = null); } public class LightningClientService : ILightningClientService @@ -471,7 +470,7 @@ public void FundingStateStepCancel(Node node, byte[] pendingChannelId, Lightning }, new Metadata { { "macaroon", node.ChannelAdminMacaroon } }); } - public async Task SetChannelFeePolicy(Node node, NBitcoin.OutPoint chanPoint, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm, Lightning.LightningClient? client = null) + public async Task SetChannelFeePolicy(Node node, NBitcoin.OutPoint chanPoint, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm, ulong? maxHtlcMsat = null, Lightning.LightningClient? client = null) { client ??= GetLightningClient(node.Endpoint); @@ -496,6 +495,11 @@ public void FundingStateStepCancel(Node node, byte[] pendingChannelId, Lightning }; } + if (maxHtlcMsat.HasValue) + { + request.MaxHtlcMsat = maxHtlcMsat.Value; + } + return await client.UpdateChannelPolicyAsync(request, new Metadata { { "macaroon", node.ChannelAdminMacaroon } }); } } \ No newline at end of file diff --git a/src/Services/LightningService.cs b/src/Services/LightningService.cs index bf760bee..0eadb6ab 100644 --- a/src/Services/LightningService.cs +++ b/src/Services/LightningService.cs @@ -42,6 +42,22 @@ namespace NodeGuard.Services { + /// + /// Outcome of a call. A failed write is an + /// exception rather than a value here: means we decided not to act. + /// + public enum MaxHtlcSyncResult + { + /// The channel already advertises the target max_htlc_msat — no RPC was made. + NoOp = 0, + + /// The channel's max_htlc_msat was written to LND. + Updated = 1, + + /// The target could not be resolved or the channel is not one we act on. + Skipped = 2, + } + /// /// Service to interact with LND /// @@ -222,6 +238,15 @@ Task SendPaymentV2Async(Node node, string paymentRequest, long amountSa /// /// public Task<(RoutingPolicy?, RoutingPolicy?)> GetChannelFeePolicy(ulong chanId, Node node); + + /// + /// Reconciles the max_htlc_msat that advertises on + /// with of the + /// channel's capacity, writing to LND only when the advertised value differs. + /// + /// The managed node whose side of the channel is updated. + /// The channel as reported by LND — the authority on capacity and chan id. + public Task SyncChannelMaxHtlc(Node node, Lnrpc.Channel lndChannel); } public class LightningService : ILightningService @@ -1928,5 +1953,126 @@ await _auditService.LogAsync( return (managedNodePolicy, counterpartyNodePolicy); } + + public async Task SyncChannelMaxHtlc(Node node, Lnrpc.Channel lndChannel) + { + ArgumentNullException.ThrowIfNull(node); + ArgumentNullException.ThrowIfNull(lndChannel); + + if (!node.IsManaged || string.IsNullOrWhiteSpace(node.ChannelAdminMacaroon)) + { + _logger.LogWarning("Skipping max htlc sync for channel {ChanId}: node {NodeName} is not managed with channel admin access", + lndChannel.ChanId, node.Name); + return MaxHtlcSyncResult.Skipped; + } + + if (!OutPoint.TryParse(lndChannel.ChannelPoint, out var outPoint)) + { + _logger.LogWarning("Skipping max htlc sync for channel {ChanId} on {NodeName}: invalid chanPoint {ChanPoint}", + lndChannel.ChanId, node.Name, lndChannel.ChannelPoint); + return MaxHtlcSyncResult.Skipped; + } + + RoutingPolicy? managedPolicy; + try + { + (managedPolicy, _) = await GetChannelFeePolicy(lndChannel.ChanId, node); + } + catch (Exception e) + { + // A channel with no graph edge yet (freshly confirmed, or unannounced) throws here. + // The next monitor pass retries it. + _logger.LogWarning(e, "Skipping max htlc sync for channel {ChanId} on {NodeName}: current policy unavailable", + lndChannel.ChanId, node.Name); + return MaxHtlcSyncResult.Skipped; + } + + if (managedPolicy == null) + { + _logger.LogWarning("Skipping max htlc sync for channel {ChanId} on {NodeName}: no policy for the managed side", + lndChannel.ChanId, node.Name); + return MaxHtlcSyncResult.Skipped; + } + + var capacityMsat = (ulong)lndChannel.Capacity * 1_000; + var minHtlcMsat = (ulong)Math.Max(managedPolicy.MinHtlc, 0); + + if (capacityMsat == 0 || minHtlcMsat > capacityMsat) + { + _logger.LogWarning("Skipping max htlc sync for channel {ChanId} on {NodeName}: no valid target between min_htlc {MinHtlcMsat} msat and capacity {CapacityMsat} msat", + lndChannel.ChanId, node.Name, minHtlcMsat, capacityMsat); + return MaxHtlcSyncResult.Skipped; + } + + var desiredMaxHtlcMsat = Math.Clamp( + (ulong)(capacityMsat * Constants.MAX_HTLC_CAPACITY_RATIO), + minHtlcMsat, + capacityMsat); + + if (managedPolicy.MaxHtlcMsat == desiredMaxHtlcMsat) + { + _logger.LogDebug("Channel {ChanId} on {NodeName} already advertises max htlc {MaxHtlcMsat} msat", + lndChannel.ChanId, node.Name, desiredMaxHtlcMsat); + return MaxHtlcSyncResult.NoOp; + } + + // Only channels NodeGuard tracks are acted on, so the write is always auditable against a + // channel row. + var channel = await _channelRepository.GetByOutpoint(outPoint); + if (channel == null) + { + _logger.LogWarning("Skipping max htlc sync for channel {ChanId} on {NodeName}: no channel found for chanPoint {ChanPoint}", + lndChannel.ChanId, node.Name, lndChannel.ChannelPoint); + return MaxHtlcSyncResult.Skipped; + } + + // The fee fields are not being changed, but LND requires them to be echoed back in a policy update. + // Except the inbound fees, which are omitted to retain the current inbound policy. + var response = await _lightningClientService.SetChannelFeePolicy( + node, + outPoint, + managedPolicy.FeeBaseMsat, + (uint)Math.Clamp(managedPolicy.FeeRateMilliMsat, 0, uint.MaxValue), + managedPolicy.TimeLockDelta, + inboundBaseFeeMsat: null, + inboundFeeRatePpm: null, + maxHtlcMsat: desiredMaxHtlcMsat); + + if (response?.FailedUpdates != null && response.FailedUpdates.Count > 0) + { + _logger.LogError("Failed to update max htlc for channel: {ChanPoint}", lndChannel.ChannelPoint); + throw new Exception($"Failed to update max htlc for channel: {lndChannel.ChannelPoint}"); + } + + _logger.LogInformation("{NodeName} chan {ChanId}: set max htlc {PreviousMaxHtlcMsat}->{MaxHtlcMsat} msat (capacity {CapacityMsat} msat, ratio {Ratio})", + node.Name, lndChannel.ChanId, managedPolicy.MaxHtlcMsat, desiredMaxHtlcMsat, capacityMsat, Constants.MAX_HTLC_CAPACITY_RATIO); + + try + { + await _auditService.LogSystemAsync( + AuditActionType.Update, + AuditEventType.Success, + AuditObjectType.Channel, + channel.Id.ToString(), + new + { + ChanPoint = lndChannel.ChannelPoint, + ChannelId = channel.Id, + lndChannel.ChanId, + NodeId = node.Id, + NodePubKey = node.PubKey, + PreviousMaxHtlcMsat = managedPolicy.MaxHtlcMsat, + MaxHtlcMsat = desiredMaxHtlcMsat, + CapacityMsat = capacityMsat, + CapacityRatio = Constants.MAX_HTLC_CAPACITY_RATIO + }); + } + catch (Exception e) + { + _logger.LogError(e, "Error while saving max htlc audit log for chanPoint: {ChanPoint}", lndChannel.ChannelPoint); + } + + return MaxHtlcSyncResult.Updated; + } } } diff --git a/test/NodeGuard.Tests/Jobs/ChannelMonitorJobTests.cs b/test/NodeGuard.Tests/Jobs/ChannelMonitorJobTests.cs index 8bb5a146..c35935d3 100644 --- a/test/NodeGuard.Tests/Jobs/ChannelMonitorJobTests.cs +++ b/test/NodeGuard.Tests/Jobs/ChannelMonitorJobTests.cs @@ -47,6 +47,15 @@ private Mock> SetupDbContextFactory() return dbContextFactory; } + private Quartz.IJobExecutionContext BuildJobContext(int nodeId) + { + var jobDetail = new Mock(); + jobDetail.Setup(x => x.JobDataMap).Returns(new Quartz.JobDataMap { { "nodeId", nodeId.ToString() } }); + var context = new Mock(); + context.Setup(x => x.JobDetail).Returns(jobDetail.Object); + return context.Object; + } + [Fact] public async Task RecoverGhostChannels_ChannelIsNotInitiatorButManaged() { @@ -227,6 +236,76 @@ public async Task RecoverGhostChannels_CreatesChannelNotInitiator() context.Channels.Count().Should().Be(1); } + [Fact] + public async Task Execute_SyncsMaxHtlcOfEveryChannel() + { + // Arrange + var logger = new Mock>(); + var dbContextFactory = SetupDbContextFactory(); + + var source = new Node() { Id = 3, Endpoint = "localhost", ChannelAdminMacaroon = "abc" }; + // A managed peer we did not initiate with: ghost recovery and alias refresh both bail out early, + // leaving the max htlc sync as the only work Execute does per channel. + var remote = new Node() { Id = 9, PubKey = "peer", Endpoint = "localhost" }; + var channel1 = new Lnrpc.Channel() { ChanId = 1, Capacity = 1000, RemotePubkey = remote.PubKey, Initiator = false }; + var channel2 = new Lnrpc.Channel() { ChanId = 2, Capacity = 2000, RemotePubkey = remote.PubKey, Initiator = false }; + + var nodeRepository = new Mock(); + nodeRepository.Setup(x => x.GetById(source.Id)).ReturnsAsync(source); + nodeRepository.Setup(x => x.GetOrCreateByPubKey(remote.PubKey, It.IsAny())).ReturnsAsync(remote); + + var lightningClientService = new Mock(); + lightningClientService.Setup(x => x.ListChannels(source, It.IsAny())) + .ReturnsAsync(new ListChannelsResponse { Channels = { channel1, channel2 } }); + + var lightningService = new Mock(); + lightningService.Setup(x => x.SyncChannelMaxHtlc(source, It.IsAny())).ReturnsAsync(MaxHtlcSyncResult.Updated); + + var channelMonitorJob = new ChannelMonitorJob(logger.Object, dbContextFactory.Object, nodeRepository.Object, lightningService.Object, lightningClientService.Object); + + // Act + var act = () => channelMonitorJob.Execute(BuildJobContext(source.Id)); + + // Assert + await act.Should().NotThrowAsync(); + lightningService.Verify(x => x.SyncChannelMaxHtlc(source, channel1), Times.Once); + lightningService.Verify(x => x.SyncChannelMaxHtlc(source, channel2), Times.Once); + } + + [Fact] + public async Task Execute_MaxHtlcSyncThrows() + { + // Arrange + var logger = new Mock>(); + var dbContextFactory = SetupDbContextFactory(); + + var source = new Node() { Id = 3, Endpoint = "localhost", ChannelAdminMacaroon = "abc" }; + var remote = new Node() { Id = 9, PubKey = "peer", Endpoint = "localhost" }; + var channel1 = new Lnrpc.Channel() { ChanId = 1, Capacity = 1000, RemotePubkey = remote.PubKey, Initiator = false }; + var channel2 = new Lnrpc.Channel() { ChanId = 2, Capacity = 2000, RemotePubkey = remote.PubKey, Initiator = false }; + + var nodeRepository = new Mock(); + nodeRepository.Setup(x => x.GetById(source.Id)).ReturnsAsync(source); + nodeRepository.Setup(x => x.GetOrCreateByPubKey(remote.PubKey, It.IsAny())).ReturnsAsync(remote); + + var lightningClientService = new Mock(); + lightningClientService.Setup(x => x.ListChannels(source, It.IsAny())) + .ReturnsAsync(new ListChannelsResponse { Channels = { channel1, channel2 } }); + + var lightningService = new Mock(); + lightningService.Setup(x => x.SyncChannelMaxHtlc(source, channel1)).ThrowsAsync(new Exception("policy update rejected")); + lightningService.Setup(x => x.SyncChannelMaxHtlc(source, channel2)).ReturnsAsync(MaxHtlcSyncResult.Updated); + + var channelMonitorJob = new ChannelMonitorJob(logger.Object, dbContextFactory.Object, nodeRepository.Object, lightningService.Object, lightningClientService.Object); + + // Act + var act = () => channelMonitorJob.Execute(BuildJobContext(source.Id)); + + // Assert - a failed policy write is contained, so the run finishes and the next channel is synced + await act.Should().NotThrowAsync(); + lightningService.Verify(x => x.SyncChannelMaxHtlc(source, channel2), Times.Once); + } + [Fact] public async Task RecoverChannelInConfirmationPendingStatus_RequestWithDifferentSource() { diff --git a/test/NodeGuard.Tests/Services/LightningClientServiceTests.cs b/test/NodeGuard.Tests/Services/LightningClientServiceTests.cs index 824725a1..d4866582 100644 --- a/test/NodeGuard.Tests/Services/LightningClientServiceTests.cs +++ b/test/NodeGuard.Tests/Services/LightningClientServiceTests.cs @@ -99,7 +99,7 @@ public async Task SetChannelFeePolicy_BuildsPolicyUpdateRequestWithInboundFee() timeLockDelta: 40, inboundBaseFeeMsat: -100, inboundFeeRatePpm: -25, - lightningClient.Object); + client: lightningClient.Object); // Assert response.Should().NotBeNull(); @@ -152,7 +152,7 @@ await lightningClientService.SetChannelFeePolicy( timeLockDelta: 40, inboundBaseFeeMsat: null, inboundFeeRatePpm: null, - lightningClient.Object); + client: lightningClient.Object); // Assert capturedRequest.Should().NotBeNull(); diff --git a/test/NodeGuard.Tests/Services/LightningServiceTests.cs b/test/NodeGuard.Tests/Services/LightningServiceTests.cs index d43b05ea..a670ca0d 100644 --- a/test/NodeGuard.Tests/Services/LightningServiceTests.cs +++ b/test/NodeGuard.Tests/Services/LightningServiceTests.cs @@ -297,7 +297,7 @@ private static Mock GetNBXplorerServiceFullyMocked(UTXOChange var nbXplorerMock = new Mock(); //Mock to return a wallet address var keyPathInformation = new KeyPathInformation() - { Address = BitcoinAddress.Create("bcrt1q590shaxaf5u08ml8jwlzghz99dup3z9592vxal", Network.RegTest) }; + { Address = BitcoinAddress.Create("bcrt1q590shaxaf5u08ml8jwlzghz99dup3z9592vxal", Network.RegTest) }; nbXplorerMock .Setup(x => x.GetUnusedAsync(It.IsAny(), It.IsAny(), @@ -497,7 +497,8 @@ public async Task OpenChannel_SuccessLegacyMultiSig() .ReturnsAsync((true, "")); lightningClientService.Setup( - x => x.FundingStateStepVerify(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())); lightningClientService.Setup( + x => x.FundingStateStepVerify(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())); + lightningClientService.Setup( x => x.FundingStateStepFinalize(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())); // Mock channel repository var channelRepository = new Mock(); @@ -700,7 +701,8 @@ public async Task OpenChannel_SuccessMultiSig() It.IsAny(), It.IsAny(), It.IsAny() - )); lightningClient + )); + lightningClient .Setup(x => x.FundingStateStepFinalize( It.IsAny(), It.IsAny(), @@ -1998,7 +2000,7 @@ public async Task GetChannelsStatus_SourceNodeIsManaged_SourceIsInitiator() }; lightningClientService.Setup(x => x.ListChannels(It.IsAny(), null)).ReturnsAsync(listChannelsResponse); - var lightningService = new LightningService(null, null, nodeRepository.Object, null, null, null, null, null ,null, lightningClientService.Object, null, null); + var lightningService = new LightningService(null, null, nodeRepository.Object, null, null, null, null, null, null, lightningClientService.Object, null, null); // Act var channelStatus = await lightningService.GetChannelsState(); @@ -2042,7 +2044,7 @@ public async Task GetChannelsStatus_SourceNodeIsManaged_SourceIsNotInitiator() }; lightningClientService.Setup(x => x.ListChannels(It.IsAny(), null)).ReturnsAsync(listChannelsResponse); - var lightningService = new LightningService(null, null, nodeRepository.Object, null, null, null, null, null ,null, lightningClientService.Object, null, null); + var lightningService = new LightningService(null, null, nodeRepository.Object, null, null, null, null, null, null, lightningClientService.Object, null, null); // Act var channelStatus = await lightningService.GetChannelsState(); @@ -2109,7 +2111,7 @@ public async Task GetChannelsStatus_BothNodesAreManaged_SourceIsInitiator() lightningClientService.SetupSequence(x => x.ListChannels(It.IsAny(), null)) .ReturnsAsync(listChannelsResponse1) .ReturnsAsync(listChannelsResponse2); - var lightningService = new LightningService(null, null, nodeRepository.Object, null, null, null, null, null ,null, lightningClientService.Object, null, null); + var lightningService = new LightningService(null, null, nodeRepository.Object, null, null, null, null, null, null, lightningClientService.Object, null, null); // Act var channelStatus = await lightningService.GetChannelsState(); @@ -2176,7 +2178,7 @@ public async Task GetChannelsStatus_BothNodesAreManaged_SourceIsNotInitiator() lightningClientService.SetupSequence(x => x.ListChannels(It.IsAny(), null)) .ReturnsAsync(listChannelsResponse1) .ReturnsAsync(listChannelsResponse2); - var lightningService = new LightningService(null, null, nodeRepository.Object, null, null, null, null, null ,null, lightningClientService.Object, null, null); + var lightningService = new LightningService(null, null, nodeRepository.Object, null, null, null, null, null, null, lightningClientService.Object, null, null); // Act var channelStatus = await lightningService.GetChannelsState(); @@ -2393,7 +2395,7 @@ public async Task SetChannelFeePolicy_EngineAllowsPositiveInbound_UpdatesPolicyA .Setup(x => x.GetByPubkey(node.PubKey)) .ReturnsAsync(node); lightningClientService - .Setup(x => x.SetChannelFeePolicy(node, It.IsAny(), 1000, 250, 40, 0, 50, null)) + .Setup(x => x.SetChannelFeePolicy(node, It.IsAny(), 1000, 250, 40, 0, 50, maxHtlcMsat: null, client: null)) .ReturnsAsync(new PolicyUpdateResponse()); var lightningService = new LightningService( @@ -2423,7 +2425,7 @@ await lightningService.SetChannelFeePolicy( // Assert — the positive inbound rate reached LND (no <= 0 throw)... lightningClientService.Verify(x => x.SetChannelFeePolicy( - node, It.IsAny(), 1000, 250, 40, 0, 50, null), Times.Once); + node, It.IsAny(), 1000, 250, 40, 0, 50, maxHtlcMsat: null, client: null), Times.Once); // ...and the write was audited through the system (engine-driven) path. auditService.Verify(x => x.LogSystemAsync( @@ -2623,5 +2625,363 @@ await act.Should() .ThrowAsync() .WithMessage("Channel not found for the given chanId. (Parameter 'chanId')"); } + + private const string MaxHtlcChanPoint = "0000000000000000000000000000000000000000000000000000000000000001:2"; + + private static Node MaxHtlcNode() => new() + { + Id = 30, + Name = "managedNode", + PubKey = "managedPubKey", + Endpoint = "127.0.0.1:10009", + ChannelAdminMacaroon = "test-macaroon" + }; + + private static Lnrpc.Channel MaxHtlcLndChannel(long capacitySats) => new() + { + ChanId = 123, + Capacity = capacitySats, + ChannelPoint = MaxHtlcChanPoint + }; + + /// + /// Wires up a LightningService with only the collaborators SyncChannelMaxHtlc touches: the LND + /// client (policy read + write), the channel repository (audit target) and the audit service. + /// A null reproduces LND having no graph edge for the channel. + /// + private (LightningService Service, Mock Client, Mock AuditService) BuildMaxHtlcService( + Node node, + ChannelEdge? channelEdge, + Channel? trackedChannel) + { + var outPoint = NBitcoin.OutPoint.Parse(MaxHtlcChanPoint); + + var lightningClientService = new Mock(); + lightningClientService + .Setup(x => x.GetChanInfo(node, 123UL, null)) + .ReturnsAsync(channelEdge); + lightningClientService + .Setup(x => x.SetChannelFeePolicy( + node, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new PolicyUpdateResponse()); + + var channelRepository = new Mock(); + channelRepository + .Setup(x => x.GetByOutpoint(It.Is(point => point.Hash == outPoint.Hash && point.N == outPoint.N))) + .ReturnsAsync(trackedChannel); + + var auditService = new Mock(); + + var lightningService = new LightningService( + _logger, null, null, null, null, channelRepository.Object, null, null, null, + lightningClientService.Object, null, auditService.Object); + + return (lightningService, lightningClientService, auditService); + } + + private static ChannelEdge MaxHtlcChannelEdge(string managedPubKey, RoutingPolicy? managedPolicy) => new() + { + Node1Pub = managedPubKey, + Node2Pub = "counterpartyPubKey", + Node1Policy = managedPolicy, + Node2Policy = new RoutingPolicy { FeeBaseMsat = 5000, FeeRateMilliMsat = 900, TimeLockDelta = 80 } + }; + + [Fact] + public async Task SyncChannelMaxHtlc_AlreadyAtTarget_MakesNoPolicyUpdate() + { + // Arrange — 1M sat channel already advertising 99% of capacity. + var node = MaxHtlcNode(); + var policy = new RoutingPolicy + { + FeeBaseMsat = 1000, + FeeRateMilliMsat = 250, + TimeLockDelta = 40, + MinHtlc = 1000, + MaxHtlcMsat = 990_000_000 + }; + var (service, client, auditService) = BuildMaxHtlcService( + node, + MaxHtlcChannelEdge(node.PubKey, policy), + new Channel { Id = 40 }); + + // Act + var result = await service.SyncChannelMaxHtlc(node, MaxHtlcLndChannel(1_000_000)); + + // Assert — LND rate-limits channel_update, so an unchanged policy must cost no write at all. + result.Should().Be(MaxHtlcSyncResult.NoOp); + client.Verify(x => x.SetChannelFeePolicy( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + auditService.VerifyNoOtherCalls(); + } + + [Fact] + public async Task SyncChannelMaxHtlc_OffTarget_EchoesFeePolicyAndAuditsTheWrite() + { + // Arrange — same channel, but advertising a stale 50k sat max htlc. + var node = MaxHtlcNode(); + var policy = new RoutingPolicy + { + FeeBaseMsat = 1000, + FeeRateMilliMsat = 250, + TimeLockDelta = 40, + MinHtlc = 1000, + MaxHtlcMsat = 50_000_000 + }; + var (service, client, auditService) = BuildMaxHtlcService( + node, + MaxHtlcChannelEdge(node.PubKey, policy), + new Channel { Id = 40 }); + var outPoint = NBitcoin.OutPoint.Parse(MaxHtlcChanPoint); + + // Act + var result = await service.SyncChannelMaxHtlc(node, MaxHtlcLndChannel(1_000_000)); + + // Assert — the fee fields are absolute in a policy update, so they must be echoed back + // unchanged, and the inbound fee must be omitted for LND to retain it. + result.Should().Be(MaxHtlcSyncResult.Updated); + client.Verify(x => x.SetChannelFeePolicy( + node, + It.Is(point => point.Hash == outPoint.Hash && point.N == outPoint.N), + 1000, + 250u, + 40u, + null, + null, + 990_000_000UL, + null), Times.Once); + auditService.Verify(x => x.LogSystemAsync( + AuditActionType.Update, + AuditEventType.Success, + AuditObjectType.Channel, + "40", + It.IsAny()), Times.Once); + } + + [Theory] + // Plain ratio of capacity. + [InlineData(1_000_000L, 1_000L, 990_000_000UL)] + // Small channel, same ratio. + [InlineData(20_000L, 1_000L, 19_800_000UL)] + // The peer's min_htlc sits above the ratio result, so the floor wins — LND rejects a max_htlc + // below min_htlc outright. + [InlineData(20_000L, 19_900_000L, 19_900_000UL)] + public async Task SyncChannelMaxHtlc_ResolvesTargetWithinChannelBounds(long capacitySats, long minHtlcMsat, ulong expectedMaxHtlcMsat) + { + // Arrange + var node = MaxHtlcNode(); + var policy = new RoutingPolicy + { + FeeBaseMsat = 0, + FeeRateMilliMsat = 1500, + TimeLockDelta = 40, + MinHtlc = minHtlcMsat, + MaxHtlcMsat = 1 // any value off target, so a write is attempted + }; + var (service, client, _) = BuildMaxHtlcService( + node, + MaxHtlcChannelEdge(node.PubKey, policy), + new Channel { Id = 40 }); + + // Act + var result = await service.SyncChannelMaxHtlc(node, MaxHtlcLndChannel(capacitySats)); + + // Assert + result.Should().Be(MaxHtlcSyncResult.Updated); + client.Verify(x => x.SetChannelFeePolicy( + node, It.IsAny(), 0, 1500u, 40u, null, null, + expectedMaxHtlcMsat, null), Times.Once); + } + + [Fact] + public async Task SyncChannelMaxHtlc_MinHtlcAboveCapacity_Skips() + { + // Arrange — no value satisfies both bounds, so there is nothing valid to write. + var node = MaxHtlcNode(); + var policy = new RoutingPolicy + { + FeeBaseMsat = 0, + FeeRateMilliMsat = 1500, + TimeLockDelta = 40, + MinHtlc = 30_000_000, + MaxHtlcMsat = 1 + }; + var (service, client, _) = BuildMaxHtlcService( + node, + MaxHtlcChannelEdge(node.PubKey, policy), + new Channel { Id = 40 }); + + // Act + var result = await service.SyncChannelMaxHtlc(node, MaxHtlcLndChannel(20_000)); + + // Assert + result.Should().Be(MaxHtlcSyncResult.Skipped); + client.Verify(x => x.SetChannelFeePolicy( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task SyncChannelMaxHtlc_ZeroCapacity_Skips() + { + // Arrange — a 0 target would reach LND as "leave max_htlc unchanged", so it must never be + // sent: the write would report success while changing nothing, every single run. + var node = MaxHtlcNode(); + var policy = new RoutingPolicy + { + FeeBaseMsat = 0, + FeeRateMilliMsat = 1500, + TimeLockDelta = 40, + MinHtlc = 0, + MaxHtlcMsat = 1 + }; + var (service, client, _) = BuildMaxHtlcService( + node, + MaxHtlcChannelEdge(node.PubKey, policy), + new Channel { Id = 40 }); + + // Act + var result = await service.SyncChannelMaxHtlc(node, MaxHtlcLndChannel(0)); + + // Assert + result.Should().Be(MaxHtlcSyncResult.Skipped); + client.Verify(x => x.SetChannelFeePolicy( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task SyncChannelMaxHtlc_NoGraphEdge_Skips() + { + // Arrange — a freshly confirmed or unannounced channel has no edge yet. + var node = MaxHtlcNode(); + var (service, client, _) = BuildMaxHtlcService(node, null, new Channel { Id = 40 }); + + // Act + var result = await service.SyncChannelMaxHtlc(node, MaxHtlcLndChannel(1_000_000)); + + // Assert + result.Should().Be(MaxHtlcSyncResult.Skipped); + client.Verify(x => x.SetChannelFeePolicy( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task SyncChannelMaxHtlc_NoPolicyForManagedSide_Skips() + { + // Arrange — the edge exists but our side has no policy on it. + var node = MaxHtlcNode(); + var (service, client, _) = BuildMaxHtlcService( + node, + MaxHtlcChannelEdge(node.PubKey, null), + new Channel { Id = 40 }); + + // Act + var result = await service.SyncChannelMaxHtlc(node, MaxHtlcLndChannel(1_000_000)); + + // Assert + result.Should().Be(MaxHtlcSyncResult.Skipped); + client.Verify(x => x.SetChannelFeePolicy( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task SyncChannelMaxHtlc_UntrackedChannel_SkipsWithoutWriting() + { + // Arrange — NodeGuard has no channel row yet (ghost recovery hasn't created it), so the write + // would not be auditable against a channel. + var node = MaxHtlcNode(); + var policy = new RoutingPolicy + { + FeeBaseMsat = 1000, + FeeRateMilliMsat = 250, + TimeLockDelta = 40, + MinHtlc = 1000, + MaxHtlcMsat = 50_000_000 + }; + var (service, client, _) = BuildMaxHtlcService( + node, + MaxHtlcChannelEdge(node.PubKey, policy), + trackedChannel: null); + + // Act + var result = await service.SyncChannelMaxHtlc(node, MaxHtlcLndChannel(1_000_000)); + + // Assert + result.Should().Be(MaxHtlcSyncResult.Skipped); + client.Verify(x => x.SetChannelFeePolicy( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task SyncChannelMaxHtlc_UnmanagedNode_Skips() + { + // Arrange — no endpoint means no gRPC surface to write to. + var node = new Node { Id = 31, Name = "externalNode", PubKey = "externalPubKey" }; + var (service, client, _) = BuildMaxHtlcService(node, null, new Channel { Id = 40 }); + + // Act + var result = await service.SyncChannelMaxHtlc(node, MaxHtlcLndChannel(1_000_000)); + + // Assert + result.Should().Be(MaxHtlcSyncResult.Skipped); + client.VerifyNoOtherCalls(); + } + + [Fact] + public async Task SyncChannelMaxHtlc_FailedUpdates_Throws() + { + // Arrange + var node = MaxHtlcNode(); + var policy = new RoutingPolicy + { + FeeBaseMsat = 1000, + FeeRateMilliMsat = 250, + TimeLockDelta = 40, + MinHtlc = 1000, + MaxHtlcMsat = 50_000_000 + }; + var (service, client, auditService) = BuildMaxHtlcService( + node, + MaxHtlcChannelEdge(node.PubKey, policy), + new Channel { Id = 40 }); + + var failedResponse = new PolicyUpdateResponse(); + failedResponse.FailedUpdates.Add(new FailedUpdate { Reason = UpdateFailure.NotFound }); + client + .Setup(x => x.SetChannelFeePolicy( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(failedResponse); + + // Act + var act = async () => await service.SyncChannelMaxHtlc(node, MaxHtlcLndChannel(1_000_000)); + + // Assert — a rejected write surfaces as an exception, never as a silent Skipped. + await act.Should().ThrowAsync() + .WithMessage($"Failed to update max htlc for channel: {MaxHtlcChanPoint}"); + auditService.Verify(x => x.LogSystemAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } } } From be1d2d885ff4a2b3601910eece029779ed2bff88 Mon Sep 17 00:00:00 2001 From: Marcos Date: Fri, 28 Aug 2026 14:50:32 +0200 Subject: [PATCH 21/21] feat: enhance routing job scheduling with dynamic interval configuration --- src/Program.cs | 62 +++++++++++++++++++++++--------------------------- 1 file changed, 28 insertions(+), 34 deletions(-) diff --git a/src/Program.cs b/src/Program.cs index 2ffa642d..704e5af4 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -274,6 +274,21 @@ public static async Task Main(string[] args) }); }); + var routingJobIntervalSeconds = + int.TryParse(Environment.GetEnvironmentVariable("ROUTING_ENGINE_JOB_INTERVAL_SECONDS"), out var rjs) + ? rjs + : (int?)null; + + void ScheduleRoutingJob(SimpleScheduleBuilder sb, int minutes) + { + if (routingJobIntervalSeconds is int seconds) + sb.WithIntervalInSeconds(seconds).RepeatForever(); + else if (Constants.IS_DEV_ENVIRONMENT) + sb.WithIntervalInMinutes(1).RepeatForever(); + else + sb.WithIntervalInMinutes(minutes).RepeatForever(); + } + //Target Ratio Reevaluation Job q.AddJob(opts => { @@ -285,17 +300,7 @@ public static async Task Main(string[] args) { opts.ForJob(nameof(TargetRatioReevaluationJob)) .WithIdentity($"{nameof(TargetRatioReevaluationJob)}Trigger") - .StartNow().WithSimpleSchedule(scheduleBuilder => - { - if (Constants.IS_DEV_ENVIRONMENT) - { - scheduleBuilder.WithIntervalInMinutes(1).RepeatForever(); - } - else - { - scheduleBuilder.WithIntervalInMinutes(Constants.ROUTING_ENGINE_JOB_INTERVAL_MINUTES).RepeatForever(); - } - }); + .StartNow().WithSimpleSchedule(sb => ScheduleRoutingJob(sb, Constants.ROUTING_ENGINE_JOB_INTERVAL_MINUTES)); }); //Channel Fee Optimizer Job (routing-engine fee actuator) @@ -308,20 +313,15 @@ public static async Task Main(string[] args) q.AddTrigger(opts => { opts.ForJob(nameof(ChannelFeeOptimizerJob)) - .WithIdentity($"{nameof(ChannelFeeOptimizerJob)}Trigger"); + .WithIdentity($"{nameof(ChannelFeeOptimizerJob)}Trigger") + .WithSimpleSchedule(sb => ScheduleRoutingJob(sb, Constants.ROUTING_ENGINE_JOB_INTERVAL_MINUTES)); if (Constants.IS_DEV_ENVIRONMENT) - { - opts.StartNow() - .WithSimpleSchedule(scheduleBuilder => scheduleBuilder.WithIntervalInMinutes(1).RepeatForever()); - } + opts.StartNow(); + // Start a few minutes after TargetRatioReevaluationJob (which uses StartNow) so the + // fee control law always acts on freshly-written routing state. else - { - // Start a few minutes after TargetRatioReevaluationJob (which uses StartNow) so the - // fee control law always acts on freshly-written routing state. - opts.StartAt(DateBuilder.FutureDate(Constants.ROUTING_ENGINE_ACTUATOR_OFFSET_MINUTES, IntervalUnit.Minute)) - .WithSimpleSchedule(scheduleBuilder => scheduleBuilder.WithIntervalInMinutes(Constants.ROUTING_ENGINE_JOB_INTERVAL_MINUTES).RepeatForever()); - } + opts.StartAt(DateBuilder.FutureDate(Constants.ROUTING_ENGINE_ACTUATOR_OFFSET_MINUTES, IntervalUnit.Minute)); }); //Auto Rebalance Job (routing-engine rebalance actuator, own cadence) @@ -334,21 +334,15 @@ public static async Task Main(string[] args) q.AddTrigger(opts => { opts.ForJob(nameof(AutoRebalanceJob)) - .WithIdentity($"{nameof(AutoRebalanceJob)}Trigger"); + .WithIdentity($"{nameof(AutoRebalanceJob)}Trigger") + .WithSimpleSchedule(sb => ScheduleRoutingJob(sb, Constants.ROUTING_ENGINE_REBALANCE_JOB_INTERVAL_MINUTES)); if (Constants.IS_DEV_ENVIRONMENT) - { - opts.StartNow() - .WithSimpleSchedule(scheduleBuilder => scheduleBuilder.WithIntervalInMinutes(1).RepeatForever()); - } + opts.StartNow(); + // Start a few minutes after TargetRatioReevaluationJob (which uses StartNow) so the + // fee control law always acts on freshly-written routing state. else - { - // Same post-signal offset as the fee job, but its own cadence thereafter. Running - // first means the Pending rows it writes are already visible to the fee job, which - // is what keeps the fee-vs-rebalance authority split intact. - opts.StartAt(DateBuilder.FutureDate(Constants.ROUTING_ENGINE_ACTUATOR_OFFSET_MINUTES, IntervalUnit.Minute)) - .WithSimpleSchedule(scheduleBuilder => scheduleBuilder.WithIntervalInMinutes(Constants.ROUTING_ENGINE_REBALANCE_JOB_INTERVAL_MINUTES).RepeatForever()); - } + opts.StartAt(DateBuilder.FutureDate(Constants.ROUTING_ENGINE_ACTUATOR_OFFSET_MINUTES, IntervalUnit.Minute)); }); //Monitor Withdrawals Job