From e388880a56c981c1a49e5017b33684ccebfe3a85 Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 6 May 2026 09:46:14 +0200 Subject: [PATCH 1/5] feat: add GetByOutpoint method to ChannelRepository and IChannelRepository --- src/Data/Repositories/ChannelRepository.cs | 7 +++++++ src/Data/Repositories/Interfaces/IChannelRepository.cs | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/src/Data/Repositories/ChannelRepository.cs b/src/Data/Repositories/ChannelRepository.cs index 2a8a7716..32f0edfc 100644 --- a/src/Data/Repositories/ChannelRepository.cs +++ b/src/Data/Repositories/ChannelRepository.cs @@ -70,6 +70,13 @@ public ChannelRepository(IRepository repository, return await applicationDbContext.Channels.Include(x => x.ChannelOperationRequests).FirstOrDefaultAsync(x => x.ChanId.Equals(chanId)); } + public async Task GetByOutpoint(OutPoint outpoint) + { + await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync(); + + return await applicationDbContext.Channels.FirstOrDefaultAsync(c => c.FundingTx == outpoint.Hash.ToString() && c.FundingTxOutputIndex == outpoint.N); + } + public async Task> GetAll() { await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync(); diff --git a/src/Data/Repositories/Interfaces/IChannelRepository.cs b/src/Data/Repositories/Interfaces/IChannelRepository.cs index 7f0de35f..9fa5e14a 100644 --- a/src/Data/Repositories/Interfaces/IChannelRepository.cs +++ b/src/Data/Repositories/Interfaces/IChannelRepository.cs @@ -17,6 +17,7 @@ * */ +using NBitcoin; using Channel = NodeGuard.Data.Models.Channel; namespace NodeGuard.Data.Repositories.Interfaces; @@ -68,4 +69,9 @@ public interface IChannelRepository string? channelIdFilter = null, DateTimeOffset? fromDate = null, DateTimeOffset? toDate = null); + + /// + /// Retrieves the channel by the outpoint + /// + Task GetByOutpoint(OutPoint outpoint); } \ No newline at end of file From 7b4f7073f7925fefed6a84a9f0b740a97a27c7c3 Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 6 May 2026 09:46:27 +0200 Subject: [PATCH 2/5] feat: add SetChannelFeePolicy method to ILightningClientService and implement it in LightningClientService --- src/Services/LightningClientService.cs | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/Services/LightningClientService.cs b/src/Services/LightningClientService.cs index 742302c7..334b0c31 100644 --- a/src/Services/LightningClientService.cs +++ b/src/Services/LightningClientService.cs @@ -44,6 +44,8 @@ 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 class LightningClientService : ILightningClientService @@ -336,4 +338,32 @@ 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) + { + client ??= GetLightningClient(node.Endpoint); + + var request = new PolicyUpdateRequest + { + ChanPoint = new ChannelPoint + { + FundingTxidStr = chanPoint.Hash.ToString(), + OutputIndex = chanPoint.N + }, + BaseFeeMsat = baseFeeMsat, + FeeRatePpm = feeRatePpm, + TimeLockDelta = timeLockDelta + }; + + if (inboundBaseFeeMsat.HasValue && inboundFeeRatePpm.HasValue) + { + request.InboundFee = new InboundFee + { + BaseFeeMsat = inboundBaseFeeMsat.Value, + FeeRatePpm = inboundFeeRatePpm.Value + }; + } + + return await client.UpdateChannelPolicyAsync(request, new Metadata { { "macaroon", node.ChannelAdminMacaroon } }); + } } \ No newline at end of file From a46b83a804257ba26542ba05d1bfddfed156fd8b Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 6 May 2026 09:47:17 +0200 Subject: [PATCH 3/5] feat: implement SetChannelFeePolicy method in LightningService to manage channel fee policies --- src/Services/LightningService.cs | 102 ++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/src/Services/LightningService.cs b/src/Services/LightningService.cs index 2aa882d3..a0d531de 100644 --- a/src/Services/LightningService.cs +++ b/src/Services/LightningService.cs @@ -34,6 +34,7 @@ using Routerrpc; using Channel = NodeGuard.Data.Models.Channel; using Transaction = NBitcoin.Transaction; +using OutPoint = NBitcoin.OutPoint; // ReSharper disable InconsistentNaming @@ -142,6 +143,19 @@ public interface ILightningService /// Optional timeout in seconds /// public Task EstimateRouteFee(string destPubkey, long amountSat, string? paymentRequest = null, uint timeout = 30); + + /// + /// Sets the channel fee policy for a given channel identified by its chanPoint + /// + /// + /// + /// + /// + /// + /// + /// + /// + public Task SetChannelFeePolicy(string chanPoint, string nodePubKey, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm); } public class LightningService : ILightningService @@ -1528,5 +1542,91 @@ public async Task> GetChannelsState() return null; } } + + public async Task SetChannelFeePolicy(string chanPoint, string nodePubKey, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm) + { + // Validate chanPoint format + if (!OutPoint.TryParse(chanPoint, out var outPoint)) + { + throw new ArgumentException("Invalid chanPoint format.", nameof(chanPoint)); + } + + if (string.IsNullOrWhiteSpace(nodePubKey)) + { + throw new ArgumentException("Node pubkey is required.", nameof(nodePubKey)); + } + + // Validate inbound fee policy parameters + if (inboundBaseFeeMsat.HasValue != inboundFeeRatePpm.HasValue) + { + throw new ArgumentException("Both inboundBaseFeeMsat and inboundFeeRatePpm must be provided together for inbound fee policy."); + } + + var channel = await _channelRepository.GetByOutpoint(outPoint); + if (channel == null) + { + throw new ArgumentException("Channel not found for the given chanPoint.", nameof(chanPoint)); + } + + var node = await _nodeRepository.GetByPubkey(nodePubKey); + if (node == null) + { + throw new ArgumentException("Node not found for the given nodePubKey.", nameof(nodePubKey)); + } + + if (!node.IsManaged || string.IsNullOrWhiteSpace(node.ChannelAdminMacaroon)) + { + throw new ArgumentException("The given nodePubKey is not a managed node with channel admin access.", nameof(nodePubKey)); + } + + try + { + var response = await _lightningClientService.SetChannelFeePolicy(node, outPoint, baseFeeMsat, feeRatePpm, timeLockDelta, inboundBaseFeeMsat, inboundFeeRatePpm); + + if (response?.FailedUpdates != null && response.FailedUpdates.Count > 0) + { + _logger.LogError("Failed to update fee policy for channel: {ChanPoint}", chanPoint); + throw new Exception($"Failed to update fee policy for channel: {chanPoint}"); + } + } + catch (Exception e) + { + _logger.LogError(e, "Error while setting channel fee policy for chanPoint: {ChanPoint}", chanPoint); + throw new Exception($"Error while setting channel fee policy for chanPoint: {chanPoint}"); + } + + try + { + await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync(); + + await applicationDbContext.AuditLogs.AddAsync(new AuditLog + { + ActionType = AuditActionType.Update, + EventType = AuditEventType.Success, + ObjectAffected = AuditObjectType.Channel, + ObjectId = channel.Id.ToString(), + Username = "SYSTEM", + Details = System.Text.Json.JsonSerializer.Serialize(new + { + ChanPoint = chanPoint, + ChannelId = channel.Id, + channel.ChanId, + NodeId = node.Id, + NodePubKey = node.PubKey, + BaseFeeMsat = baseFeeMsat, + FeeRatePpm = feeRatePpm, + TimeLockDelta = timeLockDelta, + InboundBaseFeeMsat = inboundBaseFeeMsat, + InboundFeeRatePpm = inboundFeeRatePpm + }) + }); + + await applicationDbContext.SaveChangesAsync(); + } + catch (Exception e) + { + _logger.LogError(e, "Error while saving channel fee policy audit log for chanPoint: {ChanPoint}", chanPoint); + } + } } -} \ No newline at end of file +} From 79cd1243d3e7961234f66ad0bc6edf91c797d097 Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 6 May 2026 09:47:53 +0200 Subject: [PATCH 4/5] feat: add SetChannelFeePolicy RPC method and request/response messages to manage channel fee policies --- src/Proto/nodeguard.proto | 22 ++++++++++++++++++++++ src/Rpc/NodeGuardService.cs | 19 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/Proto/nodeguard.proto b/src/Proto/nodeguard.proto index a657c6a6..83205746 100644 --- a/src/Proto/nodeguard.proto +++ b/src/Proto/nodeguard.proto @@ -89,6 +89,11 @@ service NodeGuardService { Adds tags to UTXOs under the treasury */ rpc AddTags(AddTagsRequest) returns (AddTagsResponse); + + /* + Sets the fee policy for a channel + */ + rpc SetChannelFeePolicy(SetChannelFeePolicyRequest) returns (SetChannelFeePolicyResponse); } message GetLiquidityRulesRequest { @@ -444,3 +449,20 @@ message AddTagsRequest { message AddTagsResponse { } + +message SetChannelFeePolicyRequest { + string chan_point = 1; + int64 base_fee_msat = 2; + uint32 fee_rate_ppm = 3; + uint32 time_lock_delta = 4; + optional InboundFeePolicy inbound_fee_policy = 5; + string node_pubkey = 6; +} + +message InboundFeePolicy { + int32 base_fee_msat = 1; + int32 fee_rate_ppm = 2; +} + +message SetChannelFeePolicyResponse { +} diff --git a/src/Rpc/NodeGuardService.cs b/src/Rpc/NodeGuardService.cs index c8b0a755..27b7619b 100644 --- a/src/Rpc/NodeGuardService.cs +++ b/src/Rpc/NodeGuardService.cs @@ -54,6 +54,8 @@ Task GetNewWalletAddress(GetNewWalletAddressRequest Task GetChannel(GetChannelRequest request, ServerCallContext context); Task AddTags(AddTagsRequest request, ServerCallContext context); + + Task SetChannelFeePolicy(SetChannelFeePolicyRequest request, ServerCallContext context); } /// @@ -1203,4 +1205,21 @@ private bool ValidateBitcoinAddress(string address) return true; } + + public override async Task SetChannelFeePolicy(SetChannelFeePolicyRequest request, ServerCallContext context) + { + try { + await _lightningService.SetChannelFeePolicy(request.ChanPoint, request.NodePubkey, request.BaseFeeMsat, request.FeeRatePpm, request.TimeLockDelta, request.InboundFeePolicy?.BaseFeeMsat, request.InboundFeePolicy?.FeeRatePpm); + } + catch (ArgumentException e) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, e.Message)); + } + catch (Exception e) + { + throw new RpcException(new Status(StatusCode.Internal, e.Message)); + } + + return new SetChannelFeePolicyResponse(); + } } From af7124129ac8acc84db2cc91668e26cec471a824 Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 6 May 2026 10:06:02 +0200 Subject: [PATCH 5/5] feat: add unit tests for SetChannelFeePolicy and GetByOutpoint methods in ChannelRepository and Lightning services --- .../Repositories/ChannelRepositoryTests.cs | 55 +++++++- .../Rpc/NodeGuardServiceTests.cs | 116 ++++++++++++++++ .../Services/LightningClientServiceTests.cs | 106 ++++++++++++++- .../Services/LightningServiceTests.cs | 127 ++++++++++++++++++ 4 files changed, 402 insertions(+), 2 deletions(-) diff --git a/test/NodeGuard.Tests/Data/Repositories/ChannelRepositoryTests.cs b/test/NodeGuard.Tests/Data/Repositories/ChannelRepositoryTests.cs index 3bdb6d5d..21be2081 100644 --- a/test/NodeGuard.Tests/Data/Repositories/ChannelRepositoryTests.cs +++ b/test/NodeGuard.Tests/Data/Repositories/ChannelRepositoryTests.cs @@ -27,6 +27,7 @@ using Lnrpc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using NBitcoin; using Quartz; using Channel = NodeGuard.Data.Models.Channel; @@ -186,4 +187,56 @@ public async Task MarkAsClosed_Negative_ChannelFound() channel.Status.Should().Be(Channel.ChannelStatus.Open); result.Item2.Should().NotBeNull(); } -} \ No newline at end of file + + [Fact] + public async Task GetByOutpoint_ReturnsMatchingChannel() + { + // Arrange + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: $"ChannelRepositoryTests_{Guid.NewGuid()}") + .Options; + var dbContextFactory = new Mock>(); + dbContextFactory + .Setup(x => x.CreateDbContextAsync(default)) + .ReturnsAsync(() => new ApplicationDbContext(options)); + + var fundingTx = "0000000000000000000000000000000000000000000000000000000000000001"; + await using (var context = new ApplicationDbContext(options)) + { + context.Channels.AddRange( + new Channel + { + Id = 1, + FundingTx = fundingTx, + FundingTxOutputIndex = 2, + ChanId = 100, + Status = Channel.ChannelStatus.Open + }, + new Channel + { + Id = 2, + FundingTx = fundingTx, + FundingTxOutputIndex = 3, + ChanId = 101, + Status = Channel.ChannelStatus.Open + }); + await context.SaveChangesAsync(); + } + + var channelRepository = new ChannelRepository( + new Mock>().Object, + new Mock>().Object, + dbContextFactory.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object); + + // Act + var result = await channelRepository.GetByOutpoint(NBitcoin.OutPoint.Parse($"{fundingTx}:2")); + + // Assert + result.Should().NotBeNull(); + result!.Id.Should().Be(1); + } +} diff --git a/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs b/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs index 8037b704..9fe2c54b 100644 --- a/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs +++ b/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs @@ -1163,6 +1163,122 @@ public async Task GetNodes_RequestNotIncludeUnmanaged_ReturnsManagedNodes() nodeRepositoryMock.Verify(repo => repo.GetAllManagedByNodeGuard(It.IsAny()), Times.Once); } + [Fact] + public async Task SetChannelFeePolicy_ValidRequest_CallsLightningService() + { + // Arrange + var lightningServiceMock = new Mock(); + var service = CreateNodeGuardService(lightningService: lightningServiceMock.Object); + var request = new SetChannelFeePolicyRequest + { + ChanPoint = "0000000000000000000000000000000000000000000000000000000000000001:2", + NodePubkey = "managedPubKey", + BaseFeeMsat = 1000, + FeeRatePpm = 250, + TimeLockDelta = 40, + InboundFeePolicy = new InboundFeePolicy + { + BaseFeeMsat = -100, + FeeRatePpm = -25 + } + }; + + // Act + var response = await service.SetChannelFeePolicy(request, TestServerCallContext.Create()); + + // Assert + response.Should().NotBeNull(); + lightningServiceMock.Verify(x => x.SetChannelFeePolicy( + request.ChanPoint, + request.NodePubkey, + request.BaseFeeMsat, + request.FeeRatePpm, + request.TimeLockDelta, + request.InboundFeePolicy.BaseFeeMsat, + request.InboundFeePolicy.FeeRatePpm), Times.Once); + } + + [Fact] + public async Task SetChannelFeePolicy_WithoutInboundPolicy_PassesNullInboundValues() + { + // Arrange + var lightningServiceMock = new Mock(); + var service = CreateNodeGuardService(lightningService: lightningServiceMock.Object); + var request = new SetChannelFeePolicyRequest + { + ChanPoint = "0000000000000000000000000000000000000000000000000000000000000001:2", + NodePubkey = "managedPubKey", + BaseFeeMsat = 1000, + FeeRatePpm = 250, + TimeLockDelta = 40 + }; + + // Act + await service.SetChannelFeePolicy(request, TestServerCallContext.Create()); + + // Assert + lightningServiceMock.Verify(x => x.SetChannelFeePolicy( + request.ChanPoint, + request.NodePubkey, + request.BaseFeeMsat, + request.FeeRatePpm, + request.TimeLockDelta, + null, + null), Times.Once); + } + + [Fact] + public async Task SetChannelFeePolicy_ArgumentException_ReturnsInvalidArgument() + { + // Arrange + var lightningServiceMock = new Mock(); + lightningServiceMock + .Setup(x => x.SetChannelFeePolicy( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new ArgumentException("invalid chan point")); + var service = CreateNodeGuardService(lightningService: lightningServiceMock.Object); + + // Act + var act = async () => await service.SetChannelFeePolicy(new SetChannelFeePolicyRequest(), TestServerCallContext.Create()); + + // Assert + var exception = await act.Should().ThrowAsync(); + exception.Which.Status.StatusCode.Should().Be(StatusCode.InvalidArgument); + exception.Which.Status.Detail.Should().Be("invalid chan point"); + } + + [Fact] + public async Task SetChannelFeePolicy_Exception_ReturnsInternal() + { + // Arrange + var lightningServiceMock = new Mock(); + lightningServiceMock + .Setup(x => x.SetChannelFeePolicy( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new Exception("lnd update failed")); + var service = CreateNodeGuardService(lightningService: lightningServiceMock.Object); + + // Act + var act = async () => await service.SetChannelFeePolicy(new SetChannelFeePolicyRequest(), TestServerCallContext.Create()); + + // Assert + var exception = await act.Should().ThrowAsync(); + exception.Which.Status.StatusCode.Should().Be(StatusCode.Internal); + exception.Which.Status.Detail.Should().Be("lnd update failed"); + } + [Fact] public async Task GetAvailableWallets_ReturnsTypeCold() diff --git a/test/NodeGuard.Tests/Services/LightningClientServiceTests.cs b/test/NodeGuard.Tests/Services/LightningClientServiceTests.cs index ef4bc27d..824725a1 100644 --- a/test/NodeGuard.Tests/Services/LightningClientServiceTests.cs +++ b/test/NodeGuard.Tests/Services/LightningClientServiceTests.cs @@ -18,7 +18,12 @@ */ using FluentAssertions; +using Grpc.Core; +using Lnrpc; using Microsoft.Extensions.Logging; +using NBitcoin; +using NodeGuard.Data.Models; +using NodeGuard.TestHelpers; namespace NodeGuard.Services; @@ -54,4 +59,103 @@ public void CreateLightningClient_ReturnsLightningClient() // Assert result.Should().NotBeNull(); } -} \ No newline at end of file + + [Fact] + public async Task SetChannelFeePolicy_BuildsPolicyUpdateRequestWithInboundFee() + { + // Arrange + var logger = new Mock>(); + var lightningClientService = new LightningClientService(logger.Object); + var lightningClient = new Mock(); + var chanPoint = NBitcoin.OutPoint.Parse("0000000000000000000000000000000000000000000000000000000000000001:2"); + var node = new Node + { + Endpoint = "127.0.0.1:10009", + ChannelAdminMacaroon = "test-macaroon" + }; + + PolicyUpdateRequest? capturedRequest = null; + Metadata? capturedMetadata = null; + + lightningClient + .Setup(x => x.UpdateChannelPolicyAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((request, metadata, _, _) => + { + capturedRequest = request; + capturedMetadata = metadata; + }) + .Returns(MockHelpers.CreateAsyncUnaryCall(new PolicyUpdateResponse())); + + // Act + var response = await lightningClientService.SetChannelFeePolicy( + node, + chanPoint, + baseFeeMsat: 1000, + feeRatePpm: 250, + timeLockDelta: 40, + inboundBaseFeeMsat: -100, + inboundFeeRatePpm: -25, + lightningClient.Object); + + // Assert + response.Should().NotBeNull(); + capturedRequest.Should().NotBeNull(); + capturedRequest!.ChanPoint.FundingTxidStr.Should().Be(chanPoint.Hash.ToString()); + capturedRequest.ChanPoint.OutputIndex.Should().Be(chanPoint.N); + capturedRequest.BaseFeeMsat.Should().Be(1000); + capturedRequest.FeeRatePpm.Should().Be(250); + capturedRequest.TimeLockDelta.Should().Be(40); + capturedRequest.InboundFee.Should().NotBeNull(); + capturedRequest.InboundFee.BaseFeeMsat.Should().Be(-100); + capturedRequest.InboundFee.FeeRatePpm.Should().Be(-25); + capturedMetadata.Should().ContainSingle(entry => entry.Key == "macaroon" && entry.Value == "test-macaroon"); + } + + [Fact] + public async Task SetChannelFeePolicy_WithoutInboundFee_DoesNotSetInboundFee() + { + // Arrange + var logger = new Mock>(); + var lightningClientService = new LightningClientService(logger.Object); + var lightningClient = new Mock(); + var chanPoint = NBitcoin.OutPoint.Parse("0000000000000000000000000000000000000000000000000000000000000001:2"); + var node = new Node + { + Endpoint = "127.0.0.1:10009", + ChannelAdminMacaroon = "test-macaroon" + }; + + PolicyUpdateRequest? capturedRequest = null; + + lightningClient + .Setup(x => x.UpdateChannelPolicyAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((request, _, _, _) => + { + capturedRequest = request; + }) + .Returns(MockHelpers.CreateAsyncUnaryCall(new PolicyUpdateResponse())); + + // Act + await lightningClientService.SetChannelFeePolicy( + node, + chanPoint, + baseFeeMsat: 1000, + feeRatePpm: 250, + timeLockDelta: 40, + inboundBaseFeeMsat: null, + inboundFeeRatePpm: null, + lightningClient.Object); + + // Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.InboundFee.Should().BeNull(); + } +} diff --git a/test/NodeGuard.Tests/Services/LightningServiceTests.cs b/test/NodeGuard.Tests/Services/LightningServiceTests.cs index 1892bdb8..65023974 100644 --- a/test/NodeGuard.Tests/Services/LightningServiceTests.cs +++ b/test/NodeGuard.Tests/Services/LightningServiceTests.cs @@ -2079,5 +2079,132 @@ public async Task GetChannelsStatus_BothNodesAreManaged_SourceIsNotInitiator() channelStatus[0].LocalBalance.Should().Be(500); channelStatus[0].RemoteBalance.Should().Be(0); } + + [Fact] + public async Task SetChannelFeePolicy_ValidRequest_UpdatesPolicyAndStoresAuditLog() + { + // Arrange + var channelRepository = new Mock(); + var nodeRepository = new Mock(); + var lightningClientService = new Mock(); + var dbContextFactory = new Mock>(); + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: $"SetChannelFeePolicy_{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 = 10, + ChanId = 123, + FundingTx = outPoint.Hash.ToString(), + FundingTxOutputIndex = outPoint.N + }; + var node = new Node + { + Id = 20, + 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.Is(point => point.Hash == outPoint.Hash && point.N == outPoint.N), + 1000, + 250, + 40, + -100, + -25, + null)) + .ReturnsAsync(new PolicyUpdateResponse()); + + var lightningService = new LightningService( + _logger, + null, + nodeRepository.Object, + dbContextFactory.Object, + null, + channelRepository.Object, + null, + null, + null, + lightningClientService.Object, + null); + + // Act + await lightningService.SetChannelFeePolicy( + chanPoint, + node.PubKey, + baseFeeMsat: 1000, + feeRatePpm: 250, + timeLockDelta: 40, + inboundBaseFeeMsat: -100, + inboundFeeRatePpm: -25); + + // Assert + lightningClientService.Verify(x => x.SetChannelFeePolicy( + node, + It.Is(point => point.Hash == outPoint.Hash && point.N == outPoint.N), + 1000, + 250, + 40, + -100, + -25, + null), Times.Once); + + await using var assertContext = new ApplicationDbContext(options); + var auditLog = await assertContext.AuditLogs.SingleAsync(); + auditLog.ActionType.Should().Be(AuditActionType.Update); + auditLog.EventType.Should().Be(AuditEventType.Success); + auditLog.ObjectAffected.Should().Be(AuditObjectType.Channel); + auditLog.ObjectId.Should().Be(channel.Id.ToString()); + auditLog.Username.Should().Be("SYSTEM"); + + using var details = System.Text.Json.JsonDocument.Parse(auditLog.Details!); + details.RootElement.GetProperty("ChanPoint").GetString().Should().Be(chanPoint); + details.RootElement.GetProperty("ChannelId").GetInt32().Should().Be(channel.Id); + details.RootElement.GetProperty("ChanId").GetUInt64().Should().Be(channel.ChanId); + details.RootElement.GetProperty("NodeId").GetInt32().Should().Be(node.Id); + details.RootElement.GetProperty("NodePubKey").GetString().Should().Be(node.PubKey); + details.RootElement.GetProperty("BaseFeeMsat").GetInt64().Should().Be(1000); + details.RootElement.GetProperty("FeeRatePpm").GetUInt32().Should().Be(250); + details.RootElement.GetProperty("TimeLockDelta").GetUInt32().Should().Be(40); + details.RootElement.GetProperty("InboundBaseFeeMsat").GetInt32().Should().Be(-100); + details.RootElement.GetProperty("InboundFeeRatePpm").GetInt32().Should().Be(-25); + } + + [Fact] + public async Task SetChannelFeePolicy_InboundPolicyOnlyPartiallyProvided_ThrowsArgumentException() + { + // Arrange + var lightningService = new LightningService(_logger, null, null, null, null, null, null, null, null, null, null); + + // Act + var act = async () => await lightningService.SetChannelFeePolicy( + "0000000000000000000000000000000000000000000000000000000000000001:2", + "managedPubKey", + baseFeeMsat: 1000, + feeRatePpm: 250, + timeLockDelta: 40, + inboundBaseFeeMsat: -100, + inboundFeeRatePpm: null); + + // Assert + await act.Should() + .ThrowAsync() + .WithMessage("Both inboundBaseFeeMsat and inboundFeeRatePpm must be provided together for inbound fee policy."); + } } }