From 332c02dd7b5c347cdbdb38d916fc0ae5f3490015 Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 8 Jul 2026 17:41:25 +0200 Subject: [PATCH] feat: add FeeOptimizerService and allow positive inbound fees on the fee-engine path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2a of the heuristic routing engine (stacked on Phase 1 / #533): the pure decision logic and the LND-write primitive the fee optimizer job (2b) will drive. - FeeOptimizerService (pure, singleton): ComputeNextPolicy runs proportional control on the EMA-smoothed local ratio. Authority split (defer to rebalancer outside the rebalance deadband), fee-deadband no-op, per-step + min-delta clamps, outbound/inbound ppm floors and ceilings, and inbound collapse to <=0 when the node disallows positive inbound. Category baseline (p0) both scales the term and seeds the first-eval "jump". - SetChannelFeePolicy: per user request, no separate engine method — the existing method gains an opt-in allowPositiveInboundFees flag (default false). The manual/UI/gRPC path keeps its <=0 inbound cap unchanged; the engine passes true and its writes are marked EngineWrite=true in the audit log for filterability. - Unit tests for the control-law direction table, clamps, inbound-collapse, first-eval seeding, and the positive-inbound write path. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Program.cs | 1 + src/Services/FeeOptimizerService.cs | 203 ++++++++++++++++++ src/Services/LightningService.cs | 31 ++- .../Services/FeeOptimizerServiceTests.cs | 161 ++++++++++++++ .../Services/LightningServiceTests.cs | 80 +++++++ 5 files changed, 467 insertions(+), 9 deletions(-) create mode 100644 src/Services/FeeOptimizerService.cs create mode 100644 test/NodeGuard.Tests/Services/FeeOptimizerServiceTests.cs diff --git a/src/Program.cs b/src/Program.cs index affa6e5b..c416c1d4 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -135,6 +135,7 @@ public static async Task Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Services/FeeOptimizerService.cs b/src/Services/FeeOptimizerService.cs new file mode 100644 index 00000000..29d2bcb2 --- /dev/null +++ b/src/Services/FeeOptimizerService.cs @@ -0,0 +1,203 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using NodeGuard.Data.Models; +using NodeGuard.Helpers; + +namespace NodeGuard.Services; + +/// +/// What the fee optimizer decided to do this cycle for a single channel. +/// +public enum FeeAction +{ + /// Inside the fee deadband (or computed delta below the min-delta dead-zone) — leave fees as they are. + NoOp = 0, + + /// |deviation| exceeds the rebalance deadband — the rebalancer owns this channel, the fee engine must not touch it. + DeferToRebalancer = 1, + + /// Apply the new outbound/inbound ppm. + Update = 2, +} + +/// +/// Result of one call. For , +/// / are the values to apply; for NoOp/DeferToRebalancer they +/// echo the current (unchanged) values so the caller can log a coherent "would-keep" line. +/// +public record FeePolicyDecision(FeeAction Action, uint OutboundPpm, int InboundPpm, string Reason); + +/// +/// Control-law tunables for . Passed in explicitly so the +/// decision logic stays pure and unit-testable; is the production wiring. +/// +public record FeeOptimizerTunables( + double KpOut, + double Ki, + double FeeDeadband, + double RebalanceDeadband, + uint MaxStepPpm, + uint MaxInboundStepPpm, + uint MinDeltaPpm, + uint MinOutboundPpm, + uint MaxOutboundPpm, + int MinInboundPpm, + int MaxInboundPpm, + uint BaselineSourcePpm, + uint BaselineBidirectionalPpm, + uint BaselineSinkPpm, + uint BaselineUncategorizedPpm) +{ + /// Builds the tunable set from the ROUTING_ENGINE_FEE_* constants (the production configuration). + public static FeeOptimizerTunables FromConstants() => new( + KpOut: Constants.ROUTING_ENGINE_FEE_KP_OUT, + Ki: Constants.ROUTING_ENGINE_FEE_KI, + FeeDeadband: Constants.ROUTING_ENGINE_FEE_DEADBAND, + RebalanceDeadband: Constants.ROUTING_ENGINE_REBALANCE_DEADBAND, + MaxStepPpm: Constants.ROUTING_ENGINE_FEE_MAX_STEP_PPM, + MaxInboundStepPpm: Constants.ROUTING_ENGINE_FEE_MAX_INBOUND_STEP_PPM, + MinDeltaPpm: Constants.ROUTING_ENGINE_FEE_MIN_DELTA_PPM, + MinOutboundPpm: Constants.ROUTING_ENGINE_FEE_MIN_OUTBOUND_PPM, + MaxOutboundPpm: Constants.ROUTING_ENGINE_FEE_MAX_OUTBOUND_PPM, + MinInboundPpm: Constants.ROUTING_ENGINE_FEE_MIN_INBOUND_PPM, + MaxInboundPpm: Constants.ROUTING_ENGINE_FEE_MAX_INBOUND_PPM, + BaselineSourcePpm: Constants.ROUTING_ENGINE_FEE_BASELINE_PPM_SOURCE, + BaselineBidirectionalPpm: Constants.ROUTING_ENGINE_FEE_BASELINE_PPM_BIDIRECTIONAL, + BaselineSinkPpm: Constants.ROUTING_ENGINE_FEE_BASELINE_PPM_SINK, + BaselineUncategorizedPpm: (uint)Constants.DEFAULT_CHANNEL_FEE_POLICY_FEE_RATE_PPM); +} + +public interface IFeeOptimizerService +{ + /// + /// Proportional control on the EMA-smoothed local ratio, driving both outbound ppm and (signed) + /// inbound ppm toward the channel's target. Pure — no I/O, no clock, no DB. + /// + /// With d = emaLocalRatio - targetLocalRatio: + /// + /// |d| > rebalanceDeadband (authority split). + /// |d| ≤ feeDeadband. + /// d > 0 (too local): lower outbound to attract exits, positive inbound to repel entry. + /// d < 0 (too remote): raise outbound to preserve local, negative inbound to attract entry. + /// + /// The category baseline (p₀) both scales the proportional term and seeds the "last" values on the + /// first evaluation of a channel, so a freshly categorized off-target channel jumps toward its + /// category baseline and then fine-tunes within the per-step clamp. + /// + /// + /// Pre-smoothed local/(local+remote) ratio from ChannelRoutingState. + /// Dynamic target ratio from ChannelRoutingState. + /// Peer flow category — selects the outbound baseline p₀. + /// Last-applied outbound ppm (ChannelFeeState); null on first eval → seeded with p₀. + /// Last-applied inbound ppm (ChannelFeeState); null on first eval → seeded with 0. + /// When false, inbound is collapsed to ≤ 0 regardless of direction. + /// Control-law constants. + FeePolicyDecision ComputeNextPolicy( + double emaLocalRatio, + double targetLocalRatio, + PeerFlowCategory category, + uint? lastOutboundPpm, + int? lastInboundPpm, + bool allowPositiveInboundFees, + FeeOptimizerTunables tunables); +} + +public class FeeOptimizerService : IFeeOptimizerService +{ + public FeePolicyDecision ComputeNextPolicy( + double emaLocalRatio, + double targetLocalRatio, + PeerFlowCategory category, + uint? lastOutboundPpm, + int? lastInboundPpm, + bool allowPositiveInboundFees, + FeeOptimizerTunables tunables) + { + var p0 = BaselineFor(category, tunables); + // Seed the initial category "jump": with no last-applied value, start from the category + // baseline so the first actionable write lands near p₀ rather than crawling from the + // operator's pre-engine fee. + var pLast = lastOutboundPpm ?? p0; + var iLast = lastInboundPpm ?? 0; + + var d = emaLocalRatio - targetLocalRatio; + var absD = Math.Abs(d); + + // Authority split — outside the rebalance deadband the rebalancer moves the ratio artificially, + // so the fee engine must not react to (its own) manufactured signal. + if (absD > tunables.RebalanceDeadband) + { + return new FeePolicyDecision(FeeAction.DeferToRebalancer, pLast, iLast, + $"|d|={absD:0.###} > rebalanceDeadband={tunables.RebalanceDeadband:0.###}"); + } + + // Nothing to correct inside the fee deadband. + if (absD <= tunables.FeeDeadband) + { + return new FeePolicyDecision(FeeAction.NoOp, pLast, iLast, + $"|d|={absD:0.###} <= feeDeadband={tunables.FeeDeadband:0.###}"); + } + + // Outbound: raise when too remote (d<0), lower when too local (d>0). + var pNew = RoundClampUint(pLast - tunables.KpOut * d * p0, tunables.MinOutboundPpm, tunables.MaxOutboundPpm); + var dp = Math.Clamp((long)pNew - pLast, -(long)tunables.MaxStepPpm, tunables.MaxStepPpm); + var outbound = Math.Abs(dp) < tunables.MinDeltaPpm ? pLast : (uint)(pLast + dp); + + // Inbound: negative when too remote (attract entry), positive when too local (repel entry). + var iTargetRaw = tunables.Ki * d * p0; + if (!allowPositiveInboundFees) + { + iTargetRaw = Math.Min(iTargetRaw, 0); + } + var iTarget = RoundClampInt(iTargetRaw, tunables.MinInboundPpm, tunables.MaxInboundPpm); + var di = Math.Clamp((long)iTarget - iLast, -(long)tunables.MaxInboundStepPpm, tunables.MaxInboundStepPpm); + var inbound = Math.Abs(di) < tunables.MinDeltaPpm ? iLast : (int)(iLast + di); + + if (outbound == pLast && inbound == iLast) + { + return new FeePolicyDecision(FeeAction.NoOp, pLast, iLast, "computed delta below min-delta dead-zone"); + } + + return new FeePolicyDecision(FeeAction.Update, outbound, inbound, + $"d={d:0.###} outbound {pLast}->{outbound}ppm inbound {iLast}->{inbound}ppm (p0={p0})"); + } + + private static uint BaselineFor(PeerFlowCategory category, FeeOptimizerTunables t) => category switch + { + PeerFlowCategory.Source => t.BaselineSourcePpm, + PeerFlowCategory.Bidirectional => t.BaselineBidirectionalPpm, + PeerFlowCategory.Sink => t.BaselineSinkPpm, + _ => t.BaselineUncategorizedPpm, + }; + + private static uint RoundClampUint(double value, uint min, uint max) + { + var rounded = Math.Round(value, MidpointRounding.AwayFromZero); + if (rounded < min) return min; + if (rounded > max) return max; + return (uint)rounded; + } + + private static int RoundClampInt(double value, int min, int max) + { + var rounded = (int)Math.Round(value, MidpointRounding.AwayFromZero); + return Math.Clamp(rounded, min, max); + } +} diff --git a/src/Services/LightningService.cs b/src/Services/LightningService.cs index c4e966d5..b11dd84d 100644 --- a/src/Services/LightningService.cs +++ b/src/Services/LightningService.cs @@ -198,8 +198,13 @@ Task SendPaymentV2Async(Node node, string paymentRequest, long amountSa /// /// /// + /// + /// When false (default — the manual/UI/gRPC path), inbound base fee and rate must be + /// <= 0. When true, positive inbound fees are permitted; this is the routing engine's + /// fee-optimizer path (Cyberdyne-style positive inbound) and is audit-marked as such. + /// /// - public Task SetChannelFeePolicy(string chanPoint, string nodePubKey, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm); + public Task SetChannelFeePolicy(string chanPoint, string nodePubKey, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm, bool allowPositiveInboundFees = false); /// /// Gets the channel fee policy for a given channel identified by its chanPoint @@ -1762,7 +1767,7 @@ public async Task SendPaymentV2Async(Node node, string paymentRequest, return await GetLocalOutboundFeeRatePpmAsync(node, match.ChanId); } - public async Task SetChannelFeePolicy(string chanPoint, string nodePubKey, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm) + public async Task SetChannelFeePolicy(string chanPoint, string nodePubKey, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm, bool allowPositiveInboundFees = false) { // Validate chanPoint format if (!OutPoint.TryParse(chanPoint, out var outPoint)) @@ -1781,14 +1786,19 @@ public async Task SetChannelFeePolicy(string chanPoint, string nodePubKey, long throw new ArgumentException("Both inboundBaseFeeMsat and inboundFeeRatePpm must be provided together for inbound fee policy."); } - if (inboundBaseFeeMsat.HasValue && inboundBaseFeeMsat.Value > 0) + // Positive inbound fees are only permitted on the routing-engine path + // (allowPositiveInboundFees == true). The manual/UI/gRPC path keeps the <= 0 cap. + if (!allowPositiveInboundFees) { - throw new ArgumentException("Inbound base fee must be lower or equal to zero.", nameof(inboundBaseFeeMsat)); - } + if (inboundBaseFeeMsat.HasValue && inboundBaseFeeMsat.Value > 0) + { + throw new ArgumentException("Inbound base fee must be lower or equal to zero.", nameof(inboundBaseFeeMsat)); + } - if (inboundFeeRatePpm.HasValue && inboundFeeRatePpm.Value > 0) - { - throw new ArgumentException("Inbound fee rate must be lower or equal to zero.", nameof(inboundFeeRatePpm)); + if (inboundFeeRatePpm.HasValue && inboundFeeRatePpm.Value > 0) + { + throw new ArgumentException("Inbound fee rate must be lower or equal to zero.", nameof(inboundFeeRatePpm)); + } } var channel = await _channelRepository.GetByOutpoint(outPoint); @@ -1851,7 +1861,10 @@ await applicationDbContext.AuditLogs.AddAsync(new AuditLog FeeRatePpm = feeRatePpm, TimeLockDelta = timeLockDelta, InboundBaseFeeMsat = inboundBaseFeeMsat, - InboundFeeRatePpm = inboundFeeRatePpm + InboundFeeRatePpm = inboundFeeRatePpm, + // Marks routing-engine fee-optimizer writes (the only path that passes + // allowPositiveInboundFees) so they can be filtered apart from manual/UI writes. + EngineWrite = allowPositiveInboundFees }) }); diff --git a/test/NodeGuard.Tests/Services/FeeOptimizerServiceTests.cs b/test/NodeGuard.Tests/Services/FeeOptimizerServiceTests.cs new file mode 100644 index 00000000..04519c22 --- /dev/null +++ b/test/NodeGuard.Tests/Services/FeeOptimizerServiceTests.cs @@ -0,0 +1,161 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using FluentAssertions; +using NodeGuard.Data.Models; +using NodeGuard.Helpers; + +namespace NodeGuard.Services; + +public class FeeOptimizerServiceTests +{ + private readonly FeeOptimizerService _sut = new(); + + // Vision-doc defaults. + private static readonly FeeOptimizerTunables Tunables = new( + KpOut: 0.8, + Ki: 0.5, + FeeDeadband: 0.03, + RebalanceDeadband: 0.15, + MaxStepPpm: 50, + MaxInboundStepPpm: 25, + MinDeltaPpm: 5, + MinOutboundPpm: 0, + MaxOutboundPpm: 5000, + MinInboundPpm: -250, + MaxInboundPpm: 100, + BaselineSourcePpm: 50, + BaselineBidirectionalPpm: 500, + BaselineSinkPpm: 2500, + BaselineUncategorizedPpm: 500); + + private FeePolicyDecision Compute( + double ema, + double target, + PeerFlowCategory category, + uint? lastOutbound, + int? lastInbound, + bool allowPositiveInbound) + => _sut.ComputeNextPolicy(ema, target, category, lastOutbound, lastInbound, allowPositiveInbound, Tunables); + + [Fact] + public void OutOfRebalanceDeadband_DefersToRebalancer() + { + // d = 0.9 - 0.5 = 0.40 > 0.15 + var decision = Compute(0.90, 0.50, PeerFlowCategory.Sink, 2500, 0, allowPositiveInbound: true); + + decision.Action.Should().Be(FeeAction.DeferToRebalancer); + } + + [Fact] + public void InsideFeeDeadband_IsNoOp() + { + // d = 0.51 - 0.50 = 0.01 <= 0.03 + var decision = Compute(0.51, 0.50, PeerFlowCategory.Sink, 2500, 0, allowPositiveInbound: true); + + decision.Action.Should().Be(FeeAction.NoOp); + } + + [Fact] + public void TooLocal_LowersOutbound_AndAppliesPositiveInbound() + { + // d = +0.10 (too local, need to drain): outbound down, inbound positive. + var decision = Compute(0.60, 0.50, PeerFlowCategory.Sink, 2500, 0, allowPositiveInbound: true); + + decision.Action.Should().Be(FeeAction.Update); + decision.OutboundPpm.Should().Be(2450); // 2500 - clamp(200, ±50) = 2500 - 50 + decision.InboundPpm.Should().Be(25); // clamp(+125→+100 target, step ±25) from 0 + } + + [Fact] + public void TooRemote_RaisesOutbound_AndAppliesNegativeInbound() + { + // d = -0.10 (too remote, need to fill): outbound up, inbound negative. + var decision = Compute(0.40, 0.50, PeerFlowCategory.Sink, 2500, 0, allowPositiveInbound: true); + + decision.Action.Should().Be(FeeAction.Update); + decision.OutboundPpm.Should().Be(2550); // 2500 + clamp(200, ±50) + decision.InboundPpm.Should().Be(-25); // -125 target, step-clamped to -25 from 0 + } + + [Fact] + public void PositiveInboundDisallowed_CollapsesInboundToZero_ButStillMovesOutbound() + { + // Same too-local case as above, but the node forbids positive inbound. + var decision = Compute(0.60, 0.50, PeerFlowCategory.Sink, 2500, 0, allowPositiveInbound: false); + + decision.Action.Should().Be(FeeAction.Update); + decision.OutboundPpm.Should().Be(2450); + decision.InboundPpm.Should().Be(0); // +125 → min(.,0)=0 → no inbound change + } + + [Fact] + public void ComputedDeltaBelowMinDelta_IsNoOp() + { + // Source baseline p0=50, small d just outside the deadband → both deltas < min-delta (5). + var decision = Compute(0.54, 0.50, PeerFlowCategory.Source, 50, 0, allowPositiveInbound: true); + + decision.Action.Should().Be(FeeAction.NoOp); + decision.OutboundPpm.Should().Be(50); + decision.InboundPpm.Should().Be(0); + } + + [Fact] + public void FirstEvaluation_SeedsLastValuesFromCategoryBaseline() + { + // No last-applied values (first eval). Should behave identically to seeding p_last=p0(sink)=2500, i_last=0. + var decision = Compute(0.40, 0.50, PeerFlowCategory.Sink, lastOutbound: null, lastInbound: null, allowPositiveInbound: true); + + decision.Action.Should().Be(FeeAction.Update); + decision.OutboundPpm.Should().Be(2550); + decision.InboundPpm.Should().Be(-25); + } + + [Fact] + public void Outbound_IsClampedToMaxCeiling() + { + // Near the ceiling, too-remote pressure would push past MAX_OUTBOUND_PPM (5000). + // d = -0.14 (inside the rebalance deadband, so still fee-engine territory). + var decision = Compute(0.36, 0.50, PeerFlowCategory.Sink, 4990, -200, allowPositiveInbound: true); + + decision.Action.Should().Be(FeeAction.Update); + decision.OutboundPpm.Should().Be(5000); // pNew 5270 clamped to 5000, reached within one +10 step + } + + [Fact] + public void Inbound_IsClampedToMaxCeiling() + { + // iLast already near +100; a further positive push must not exceed MAX_INBOUND_PPM. + var decision = Compute(0.60, 0.50, PeerFlowCategory.Sink, 2500, 90, allowPositiveInbound: true); + + decision.Action.Should().Be(FeeAction.Update); + decision.InboundPpm.Should().Be(100); // 90 + clamp(10, ±25) = 100 (ceiling) + } + + [Fact] + public void FromConstants_MapsBaselineTriple() + { + var t = FeeOptimizerTunables.FromConstants(); + + t.BaselineSourcePpm.Should().Be(Constants.ROUTING_ENGINE_FEE_BASELINE_PPM_SOURCE); + t.BaselineBidirectionalPpm.Should().Be(Constants.ROUTING_ENGINE_FEE_BASELINE_PPM_BIDIRECTIONAL); + t.BaselineSinkPpm.Should().Be(Constants.ROUTING_ENGINE_FEE_BASELINE_PPM_SINK); + t.BaselineUncategorizedPpm.Should().Be((uint)Constants.DEFAULT_CHANNEL_FEE_POLICY_FEE_RATE_PPM); + } +} diff --git a/test/NodeGuard.Tests/Services/LightningServiceTests.cs b/test/NodeGuard.Tests/Services/LightningServiceTests.cs index 158abf0f..aafc12c3 100644 --- a/test/NodeGuard.Tests/Services/LightningServiceTests.cs +++ b/test/NodeGuard.Tests/Services/LightningServiceTests.cs @@ -2301,6 +2301,86 @@ await act.Should() .WithMessage("Inbound fee rate must be lower or equal to zero. (Parameter 'inboundFeeRatePpm')"); } + [Fact] + public async Task SetChannelFeePolicy_EngineAllowsPositiveInbound_UpdatesPolicyAndMarksEngineWrite() + { + // Arrange — the routing-engine fee path (allowPositiveInboundFees: true) must accept a + // positive inbound fee rate that the manual/UI path rejects, and mark the audit log. + var channelRepository = new Mock(); + var nodeRepository = new Mock(); + var lightningClientService = new Mock(); + var dbContextFactory = new Mock>(); + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: $"SetChannelFeePolicyEngine_{Guid.NewGuid()}") + .Options; + dbContextFactory + .Setup(x => x.CreateDbContextAsync(default)) + .ReturnsAsync(() => new ApplicationDbContext(options)); + + var chanPoint = "0000000000000000000000000000000000000000000000000000000000000001:2"; + var outPoint = NBitcoin.OutPoint.Parse(chanPoint); + var channel = new Channel + { + Id = 11, + ChanId = 456, + FundingTx = outPoint.Hash.ToString(), + FundingTxOutputIndex = outPoint.N, + SourceNodeId = 21 + }; + var node = new Node + { + Id = 21, + PubKey = "managedPubKey", + Endpoint = "127.0.0.1:10009", + ChannelAdminMacaroon = "test-macaroon" + }; + + channelRepository + .Setup(x => x.GetByOutpoint(It.Is(point => point.Hash == outPoint.Hash && point.N == outPoint.N))) + .ReturnsAsync(channel); + nodeRepository + .Setup(x => x.GetByPubkey(node.PubKey)) + .ReturnsAsync(node); + lightningClientService + .Setup(x => x.SetChannelFeePolicy(node, It.IsAny(), 1000, 250, 40, 0, 50, null)) + .ReturnsAsync(new PolicyUpdateResponse()); + + var lightningService = new LightningService( + _logger, + null, + nodeRepository.Object, + dbContextFactory.Object, + null, + channelRepository.Object, + null, + null, + null, + lightningClientService.Object, + null); + + // Act — positive inbound rate (50 ppm), allowed only because allowPositiveInboundFees is true. + await lightningService.SetChannelFeePolicy( + chanPoint, + node.PubKey, + baseFeeMsat: 1000, + feeRatePpm: 250, + timeLockDelta: 40, + inboundBaseFeeMsat: 0, + inboundFeeRatePpm: 50, + allowPositiveInboundFees: true); + + // Assert + lightningClientService.Verify(x => x.SetChannelFeePolicy( + node, It.IsAny(), 1000, 250, 40, 0, 50, null), Times.Once); + + await using var assertContext = new ApplicationDbContext(options); + var auditLog = await assertContext.AuditLogs.SingleAsync(); + auditLog.Username.Should().Be("SYSTEM"); + using var details = System.Text.Json.JsonDocument.Parse(auditLog.Details!); + details.RootElement.GetProperty("InboundFeeRatePpm").GetInt32().Should().Be(50); + details.RootElement.GetProperty("EngineWrite").GetBoolean().Should().BeTrue(); + } + [Fact] public async Task SetChannelFeePolicy_NodeNotParticipant_ThrowsArgumentException() {