From e388880a56c981c1a49e5017b33684ccebfe3a85 Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 6 May 2026 09:46:14 +0200 Subject: [PATCH 01/19] 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 02/19] 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 03/19] 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 04/19] 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 05/19] 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."); + } } } From f52022e822f3fc338eea69500d8afbf5f53743be Mon Sep 17 00:00:00 2001 From: Marcos Date: Thu, 14 May 2026 09:51:10 +0200 Subject: [PATCH 06/19] fix: control node is a channel participant --- src/Services/LightningService.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Services/LightningService.cs b/src/Services/LightningService.cs index f09807ac..f40e96da 100644 --- a/src/Services/LightningService.cs +++ b/src/Services/LightningService.cs @@ -1798,6 +1798,11 @@ public async Task SetChannelFeePolicy(string chanPoint, string nodePubKey, long throw new ArgumentException("Node not found for the given nodePubKey.", nameof(nodePubKey)); } + if (channel.SourceNodeId != node.Id && channel.DestinationNodeId != node.Id) + { + throw new ArgumentException("The given nodePubKey is not a participant of the channel.", 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)); From 0a3afb2e9e6ec04b0d9a04e9c14ad5f1a9f059bb Mon Sep 17 00:00:00 2001 From: Marcos Date: Thu, 14 May 2026 11:42:13 +0200 Subject: [PATCH 07/19] test: add test for not participant node --- .../Services/LightningServiceTests.cs | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/test/NodeGuard.Tests/Services/LightningServiceTests.cs b/test/NodeGuard.Tests/Services/LightningServiceTests.cs index 65023974..3e32794f 100644 --- a/test/NodeGuard.Tests/Services/LightningServiceTests.cs +++ b/test/NodeGuard.Tests/Services/LightningServiceTests.cs @@ -2102,7 +2102,8 @@ public async Task SetChannelFeePolicy_ValidRequest_UpdatesPolicyAndStoresAuditLo Id = 10, ChanId = 123, FundingTx = outPoint.Hash.ToString(), - FundingTxOutputIndex = outPoint.N + FundingTxOutputIndex = outPoint.N, + SourceNodeId = 20 }; var node = new Node { @@ -2206,5 +2207,68 @@ await act.Should() .ThrowAsync() .WithMessage("Both inboundBaseFeeMsat and inboundFeeRatePpm must be provided together for inbound fee policy."); } + + [Fact] + public async Task SetChannelFeePolicy_NodeNotParticipant_ThrowsArgumentException() + { + // Arrange + var channelRepository = new Mock(); + var nodeRepository = new Mock(); + 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 = 100, + DestinationNodeId = 101 + }; + + 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); + + var lightningService = new LightningService( + _logger, + null, + nodeRepository.Object, + null, + null, + channelRepository.Object, + null, + null, + null, + new Mock().Object, + null); + + // Act + var act = async () => await lightningService.SetChannelFeePolicy( + chanPoint, + node.PubKey, + baseFeeMsat: 1000, + feeRatePpm: 250, + timeLockDelta: 40, + inboundBaseFeeMsat: null, + inboundFeeRatePpm: null); + + // Assert + await act.Should() + .ThrowAsync() + .WithMessage("The given nodePubKey is not a participant of the channel. (Parameter 'nodePubKey')"); + } } } From d53536cb078bc2fb2493c78599f6834f660da831 Mon Sep 17 00:00:00 2001 From: Marcos Date: Thu, 14 May 2026 11:48:29 +0200 Subject: [PATCH 08/19] feat: add details for the fields --- src/Proto/nodeguard.proto | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Proto/nodeguard.proto b/src/Proto/nodeguard.proto index 19ed5be7..fdce92ee 100644 --- a/src/Proto/nodeguard.proto +++ b/src/Proto/nodeguard.proto @@ -466,16 +466,24 @@ message AddTagsResponse { } message SetChannelFeePolicyRequest { + // Channel point in the format :. string chan_point = 1; + // Outbound base fee charged for forwarded HTLCs, in millisatoshis. int64 base_fee_msat = 2; + // Outbound proportional fee rate in parts-per-million (ppm). uint32 fee_rate_ppm = 3; + // Outbound CLTV delta (final timelock delta) in blocks. uint32 time_lock_delta = 4; + // Optional inbound fee policy (peer->this node direction). optional InboundFeePolicy inbound_fee_policy = 5; + // Managed node pubkey (33-byte hex) where the policy will be applied. string node_pubkey = 6; } message InboundFeePolicy { + // Inbound base fee in millisatoshis. Signed to support discounts/rebates. int32 base_fee_msat = 1; + // Inbound proportional fee rate in ppm. Signed to support discounts/rebates. int32 fee_rate_ppm = 2; } From f894922fcd66f819c1ddf1a0cdac504554e6b765 Mon Sep 17 00:00:00 2001 From: Marcos Date: Thu, 14 May 2026 12:06:54 +0200 Subject: [PATCH 09/19] feat: add validation for inbound fee policy parameters in SetChannelFeePolicy method --- src/Services/LightningService.cs | 13 ++++++ .../Services/LightningServiceTests.cs | 44 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/Services/LightningService.cs b/src/Services/LightningService.cs index f40e96da..cc7b41f1 100644 --- a/src/Services/LightningService.cs +++ b/src/Services/LightningService.cs @@ -1786,6 +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 && inboundFeeRatePpm.HasValue) + { + if (inboundBaseFeeMsat.Value > baseFeeMsat) + { + throw new ArgumentException("inboundBaseFeeMsat must be lower than baseFeeMsat.", nameof(inboundBaseFeeMsat)); + } + + if ((long)inboundFeeRatePpm.Value > feeRatePpm) + { + throw new ArgumentException("inboundFeeRatePpm must be lower than feeRatePpm.", nameof(inboundFeeRatePpm)); + } + } + var channel = await _channelRepository.GetByOutpoint(outPoint); if (channel == null) { diff --git a/test/NodeGuard.Tests/Services/LightningServiceTests.cs b/test/NodeGuard.Tests/Services/LightningServiceTests.cs index 3e32794f..e88ca50d 100644 --- a/test/NodeGuard.Tests/Services/LightningServiceTests.cs +++ b/test/NodeGuard.Tests/Services/LightningServiceTests.cs @@ -2208,6 +2208,50 @@ await act.Should() .WithMessage("Both inboundBaseFeeMsat and inboundFeeRatePpm must be provided together for inbound fee policy."); } + [Fact] + public async Task SetChannelFeePolicy_InboundBaseFeeGreaterThanOutbound_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: 1001, + inboundFeeRatePpm: -25); + + // Assert + await act.Should() + .ThrowAsync() + .WithMessage("inboundBaseFeeMsat must be lower than baseFeeMsat. (Parameter 'inboundBaseFeeMsat')"); + } + + [Fact] + public async Task SetChannelFeePolicy_InboundFeeRateGreaterThanOutbound_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: 251); + + // Assert + await act.Should() + .ThrowAsync() + .WithMessage("inboundFeeRatePpm must be lower than feeRatePpm. (Parameter 'inboundFeeRatePpm')"); + } + [Fact] public async Task SetChannelFeePolicy_NodeNotParticipant_ThrowsArgumentException() { From b11ca570cddf1906c6e3ef088a844c50988b0cf0 Mon Sep 17 00:00:00 2001 From: Marcos Date: Fri, 15 May 2026 14:46:28 +0200 Subject: [PATCH 10/19] feat: add UI to change fee settings for a channel --- src/Pages/Channels.razor | 631 ++++++++++++++++++++++++++++----------- 1 file changed, 459 insertions(+), 172 deletions(-) diff --git a/src/Pages/Channels.razor b/src/Pages/Channels.razor index 95664974..67cf8d09 100644 --- a/src/Pages/Channels.razor +++ b/src/Pages/Channels.razor @@ -311,116 +311,208 @@ { - Liquidity management for channel: @_selectedChannel.ChanId - + Manage channel: @_selectedChannel.ChanId + - - Enable automated liquidity management - - - @if (_selectedChannel.IsAutomatedLiquidityEnabled && _currentLiquidityRule != null) - { - - - - Minimum local balance - Balance % that will fire a reverse swap if the channel balance goes below this percent (0 means optional) - - - The number should be between 0 and 100 and less than minimum remote balance - - - - - - - Minimum remote balance - Balance % that will fire a swap if the channel balance goes above this percent (0 means optional) - - - The number should be between 0 and 100 and bigger than minimum local balance - - - - - - - Target balance - Desired target balance % after a swap operation is performed - - - The number should be between 0 and 100 and in between minimum local and remote balance (0 means optional) - - - - - - - @* Swaps *@ - - - Wallet to use in SWAPS operations - - - A wallet for swaps operation must be selected as target/origin of funds - - - - - - - @* Reverse swaps *@ - - - @(_currentLiquidityRule.IsReverseSwapWalletRule ? "Wallet based liquidity rule" : "Address based liquidity rule") - - @if (_currentLiquidityRule.IsReverseSwapWalletRule) - { - - Wallet to use in REVERSE SWAPS operations - + + + Liquidity + Fee Policy + + + + + Enable automated liquidity management + + + @if (_selectedChannel.IsAutomatedLiquidityEnabled && _currentLiquidityRule != null) + { + + + + Minimum local balance + Balance % that will fire a reverse swap if the channel balance goes below this percent (0 means optional) + + + The number should be between 0 and 100 and less than minimum remote balance + + + + + + + Minimum remote balance + Balance % that will fire a swap if the channel balance goes above this percent (0 means optional) + - A wallet for swaps operation must be selected as target/origin of funds + The number should be between 0 and 100 and bigger than minimum local balance - + - } - else - { - - Address to use in REVERSE SWAPS operations - + + + + Target balance + Desired target balance % after a swap operation is performed + - A valid bitcoin address must be provided + The number should be between 0 and 100 and in between minimum local and remote balance (0 means optional) - + - } + + + + @* Swaps *@ + + + Wallet to use in SWAPS operations + + + A wallet for swaps operation must be selected as target/origin of funds + + + + + + + @* Reverse swaps *@ + + + @(_currentLiquidityRule.IsReverseSwapWalletRule ? "Wallet based liquidity rule" : "Address based liquidity rule") + + @if (_currentLiquidityRule.IsReverseSwapWalletRule) + { + + Wallet to use in REVERSE SWAPS operations + + + A wallet for swaps operation must be selected as target/origin of funds + + + + } + else + { + + Address to use in REVERSE SWAPS operations + + + A valid bitcoin address must be provided + + + + } + + + + + } + + + +

Apply a new fee policy to outpoint @(_selectedChannelOutpoint ?? "Unknown") using managed node @(_selectedChannelNodePubKey ?? "Unknown").

+ + + + + Base fee (msat) + Flat outbound fee charged for forwarded payments. + + + + + + + + + + + + Fee rate (ppm) + Outbound proportional fee in parts per million. + + + + + + + + + + + + Time lock delta + CLTV delta advertised for forwarded HTLCs on this channel. + + + + + + - - - -
- } + + Inbound fee policy + Optional. Leave both inbound fields blank to keep the current inbound fee + policy unchanged. + + + + + + Inbound base fee (msat) + Optional inbound base fee. LND usually expects negative values. + + + + + + + + + + + + + Inbound fee rate (ppm) + Optional inbound proportional fee. LND usually expects negative + values. + + + + + + + + + + +
+ +
- +
} @@ -450,6 +542,14 @@ private List _availableWallets = new List(); private LiquidityRule? _currentLiquidityRule = new LiquidityRule(); private Validations? _channelManagementValidationsRef; + private Validations? _channelFeePolicyValidationsRef; + private ChannelFeePolicyForm _channelFeePolicy = new(); + private string _selectedChannelManagementTab = ChannelManagementLiquidityTab; + private string? _selectedChannelOutpoint; + private string? _selectedChannelNodePubKey; + + private const string ChannelManagementLiquidityTab = "Liquidity"; + private const string ChannelManagementFeePolicyTab = "FeePolicy"; // Server-side filter values private string? _statusFilterValue = "Open"; @@ -502,6 +602,15 @@ public static readonly ColumnDefault CHANNEL_ID = new("Channel Id"); } + private sealed class ChannelFeePolicyForm + { + public long? BaseFeeMsat { get; set; } + public long? FeeRatePpm { get; set; } + public long? TimeLockDelta { get; set; } + public int? InboundBaseFeeMsat { get; set; } + public int? InboundFeeRatePpm { get; set; } + } + protected override async Task OnInitializedAsync() { _btcPrice = await PriceConversionService.GetBtcToUsdPrice(); @@ -675,11 +784,11 @@ var result = -1.0; if (_channelsBalance.TryGetValue(channel.ChanId, out var values)) { - // Calculate the capacity as the sum of the second and third values in the tuple + // Calculate the capacity as the sum of the second and third values in the tuple var capacity = values.LocalBalance + values.RemoteBalance; - // Calculate the result as the percentage of the remote balance to the capacity - result = (values.RemoteBalance / (double) capacity) * 100; + // Calculate the result as the percentage of the remote balance to the capacity + result = (values.RemoteBalance / (double)capacity) * 100; result = Math.Round(result, 2); @@ -722,6 +831,12 @@ if (_currentLiquidityRule != null) _currentLiquidityRule.ReverseSwapWalletId = arg; } + private Task OnSelectedChannelManagementTabChanged(string tabName) + { + _selectedChannelManagementTab = tabName; + return Task.CompletedTask; + } + private async Task CloseChannelManagementModal() { if (_channelLiquidityModal != null) await _channelLiquidityModal.Close(CloseReason.UserClosing); @@ -731,94 +846,155 @@ private async Task SaveAndCloseChannelManagementModal() { - if (_channelManagementValidationsRef != null && await _channelManagementValidationsRef.ValidateAll()) + if (_selectedChannelManagementTab == ChannelManagementFeePolicyTab) + { + await SaveAndCloseChannelFeePolicyTab(); + return; + } + + await SaveAndCloseChannelLiquidityTab(); + } + + private async Task SaveAndCloseChannelLiquidityTab() + { + var isValid = _channelManagementValidationsRef == null || await _channelManagementValidationsRef.ValidateAll(); + if (!isValid) + { + ToastService.ShowError("Please fix the errors"); + return; + } + + //Save the channel + if (_selectedChannel != null) { - //Save the channel - if (_selectedChannel != null) + var updateResult = ChannelRepository.Update(_selectedChannel); + if (!updateResult.Item1) + { + ToastService.ShowError("Something went wrong"); + await AuditService.LogAsync(AuditActionType.Update, AuditEventType.Failure, AuditObjectType.Channel, + _selectedChannel.Id.ToString(), + $"Failed to update channel. ChanId: {_selectedChannel.ChanId}"); + } + else { - var updateResult = ChannelRepository.Update(_selectedChannel); - if (!updateResult.Item1) + ToastService.ShowSuccess("Channel updated successfully"); + await AuditService.LogAsync(AuditActionType.Update, AuditEventType.Success, AuditObjectType.Channel, + _selectedChannel.Id.ToString(), + $"Channel updated. ChanId: {_selectedChannel.ChanId}"); + } + } + + //Save the liquidity rule if the liquidity rule id is below or equal zero + if (_currentLiquidityRule != null) + { + if (_currentLiquidityRule.Id <= 0) + { + var liquidityRuleResult = await LiquidityRuleRepository.AddAsync(_currentLiquidityRule); + if (!liquidityRuleResult.Item1) { ToastService.ShowError("Something went wrong"); - await AuditService.LogAsync(AuditActionType.Update, AuditEventType.Failure, AuditObjectType.Channel, - _selectedChannel.Id.ToString(), - $"Failed to update channel. ChanId: {_selectedChannel.ChanId}"); + await AuditService.LogAsync(AuditActionType.Create, AuditEventType.Failure, AuditObjectType.LiquidityRule, + _currentLiquidityRule.ChannelId.ToString(), + $"Failed to add liquidity rule for channel. ChannelId: {_currentLiquidityRule.ChannelId}"); } else { - ToastService.ShowSuccess("Channel updated successfully"); - await AuditService.LogAsync(AuditActionType.Update, AuditEventType.Success, AuditObjectType.Channel, - _selectedChannel.Id.ToString(), - $"Channel updated. ChanId: {_selectedChannel.ChanId}"); + ToastService.ShowSuccess("Liquidity rule added successfully"); + await AuditService.LogAsync(AuditActionType.Create, AuditEventType.Success, AuditObjectType.LiquidityRule, + _currentLiquidityRule.Id.ToString(), + $"Liquidity rule created. ChannelId: {_currentLiquidityRule.ChannelId}, MinLocal: {_currentLiquidityRule.MinimumLocalBalance}%, MinRemote: {_currentLiquidityRule.MinimumRemoteBalance}%, Target: {_currentLiquidityRule.RebalanceTarget}%"); } } - - //Save the liquidity rule if the liquidity rule id is below or equal zero - if (_currentLiquidityRule != null) + else { - if (_currentLiquidityRule.Id <= 0) + var liquidityRuleResult = LiquidityRuleRepository.Update(_currentLiquidityRule); + if (!liquidityRuleResult.Item1) { - var liquidityRuleResult = await LiquidityRuleRepository.AddAsync(_currentLiquidityRule); - if (!liquidityRuleResult.Item1) - { - ToastService.ShowError("Something went wrong"); - await AuditService.LogAsync(AuditActionType.Create, AuditEventType.Failure, AuditObjectType.LiquidityRule, - _currentLiquidityRule.ChannelId.ToString(), - $"Failed to add liquidity rule for channel. ChannelId: {_currentLiquidityRule.ChannelId}"); - } - else - { - ToastService.ShowSuccess("Liquidity rule added successfully"); - await AuditService.LogAsync(AuditActionType.Create, AuditEventType.Success, AuditObjectType.LiquidityRule, - _currentLiquidityRule.Id.ToString(), - $"Liquidity rule created. ChannelId: {_currentLiquidityRule.ChannelId}, MinLocal: {_currentLiquidityRule.MinimumLocalBalance}%, MinRemote: {_currentLiquidityRule.MinimumRemoteBalance}%, Target: {_currentLiquidityRule.RebalanceTarget}%"); - } + ToastService.ShowError("Something went wrong"); + await AuditService.LogAsync(AuditActionType.Update, AuditEventType.Failure, AuditObjectType.LiquidityRule, + _currentLiquidityRule.Id.ToString(), + $"Failed to update liquidity rule. RuleId: {_currentLiquidityRule.Id}"); } else { - var liquidityRuleResult = LiquidityRuleRepository.Update(_currentLiquidityRule); - if (!liquidityRuleResult.Item1) - { - ToastService.ShowError("Something went wrong"); - await AuditService.LogAsync(AuditActionType.Update, AuditEventType.Failure, AuditObjectType.LiquidityRule, - _currentLiquidityRule.Id.ToString(), - $"Failed to update liquidity rule. RuleId: {_currentLiquidityRule.Id}"); - } - else - { - ToastService.ShowSuccess("Liquidity rule updated successfully"); - await AuditService.LogAsync(AuditActionType.Update, AuditEventType.Success, AuditObjectType.LiquidityRule, - _currentLiquidityRule.Id.ToString(), - $"Liquidity rule updated. ChannelId: {_currentLiquidityRule.ChannelId}, MinLocal: {_currentLiquidityRule.MinimumLocalBalance}%, MinRemote: {_currentLiquidityRule.MinimumRemoteBalance}%, Target: {_currentLiquidityRule.RebalanceTarget}%"); - } + ToastService.ShowSuccess("Liquidity rule updated successfully"); + await AuditService.LogAsync(AuditActionType.Update, AuditEventType.Success, AuditObjectType.LiquidityRule, + _currentLiquidityRule.Id.ToString(), + $"Liquidity rule updated. ChannelId: {_currentLiquidityRule.ChannelId}, MinLocal: {_currentLiquidityRule.MinimumLocalBalance}%, MinRemote: {_currentLiquidityRule.MinimumRemoteBalance}%, Target: {_currentLiquidityRule.RebalanceTarget}%"); } } + } + + await CloseChannelManagementModal(); + } + + private async Task SaveAndCloseChannelFeePolicyTab() + { + if (_channelFeePolicyValidationsRef == null || !await _channelFeePolicyValidationsRef.ValidateAll()) + { + ToastService.ShowError("Please fix the errors"); + return; + } + if (_selectedChannel == null || string.IsNullOrWhiteSpace(_selectedChannelOutpoint) || string.IsNullOrWhiteSpace(_selectedChannelNodePubKey)) + { + ToastService.ShowError("The selected channel could not be resolved for fee policy updates."); + return; + } + + try + { + await LightningService.SetChannelFeePolicy( + _selectedChannelOutpoint, + _selectedChannelNodePubKey, + _channelFeePolicy.BaseFeeMsat!.Value, + checked((uint)_channelFeePolicy.FeeRatePpm!.Value), + checked((uint)_channelFeePolicy.TimeLockDelta!.Value), + _channelFeePolicy.InboundBaseFeeMsat, + _channelFeePolicy.InboundFeeRatePpm); + + ToastService.ShowSuccess("Channel fee policy updated successfully"); await CloseChannelManagementModal(); } - else + catch (ArgumentException e) { - ToastService.ShowError("Please fix the errors"); + ToastService.ShowError(e.Message); + } + catch (Exception e) + { + ToastService.ShowError(string.IsNullOrWhiteSpace(e.Message) ? "Could not update the channel fee policy." : e.Message); } } - private async Task ClearManagementModal() + private Task ClearManagementModal() { _selectedChannel = null; _currentLiquidityRule = new LiquidityRule(); + _channelFeePolicy = new ChannelFeePolicyForm(); + _selectedChannelManagementTab = ChannelManagementLiquidityTab; + _selectedChannelOutpoint = null; + _selectedChannelNodePubKey = null; + _channelManagementValidationsRef = null; + _channelFeePolicyValidationsRef = null; + return Task.CompletedTask; } private async Task ShowChannelManagementModal(Channel channel) { await ClearManagementModal(); - _selectedChannel = channel; + var node = GetManagedChannelNode(channel); + if (node == null) + { + ToastService.ShowError("A managed node with channel admin access could not be resolved for this channel."); + return; + } - var destinationNode = channel.DestinationNode; - var sourceNode = channel.SourceNode; - var node = string.IsNullOrEmpty(sourceNode.ChannelAdminMacaroon) ? destinationNode : sourceNode; + _selectedChannel = channel; + _selectedChannelOutpoint = GetChannelOutpoint(channel); + _selectedChannelNodePubKey = node.PubKey; - //If there is a liquidity rule for this channel, we load it, the first one + //If there is a liquidity rule for this channel, we load it, the first one _currentLiquidityRule = _selectedChannel?.LiquidityRules.FirstOrDefault() ?? new LiquidityRule { MinimumLocalBalance = 20, @@ -843,7 +1019,14 @@ } else { - var node = string.IsNullOrEmpty(_selectedChannel.SourceNode.ChannelAdminMacaroon) ? _selectedChannel.DestinationNode : _selectedChannel.SourceNode; + var node = GetManagedChannelNode(_selectedChannel); + if (node == null) + { + _selectedChannel.IsAutomatedLiquidityEnabled = false; + ToastService.ShowError("A managed node with channel admin access could not be resolved for this channel."); + return; + } + _currentLiquidityRule = _selectedChannel.LiquidityRules.FirstOrDefault() ?? new LiquidityRule { MinimumLocalBalance = 20, @@ -876,16 +1059,16 @@ private void ValidateTargetBalance(ValidatorEventArgs arg1) { - if(_currentLiquidityRule == null) return; - //Default validation status + if (_currentLiquidityRule == null) return; + //Default validation status arg1.Status = ValidationStatus.Success; - //If the value is 0 is valid + //If the value is 0 is valid if (_currentLiquidityRule.RebalanceTarget == 0 || _currentLiquidityRule.RebalanceTarget == null) { return; } - //Check that the target balance is between 0 and 100 + //Check that the target balance is between 0 and 100 if (_currentLiquidityRule.RebalanceTarget < 0 || _currentLiquidityRule.RebalanceTarget > 100) { arg1.Status = ValidationStatus.Error; @@ -893,7 +1076,7 @@ } - //Check that the rebalancetarget of the current liquidity rule is between the mininum local and minimum remote balance + //Check that the rebalancetarget of the current liquidity rule is between the mininum local and minimum remote balance if (_currentLiquidityRule.RebalanceTarget < _currentLiquidityRule.MinimumLocalBalance || _currentLiquidityRule.RebalanceTarget > _currentLiquidityRule.MinimumRemoteBalance) { @@ -904,11 +1087,11 @@ private void ValidateRemoteBalance(ValidatorEventArgs arg1) { - if(_currentLiquidityRule == null) return; - //Default validation status + if (_currentLiquidityRule == null) return; + //Default validation status arg1.Status = ValidationStatus.Success; - //If the minimum local balance is 0 this cannot be 0 + //If the minimum local balance is 0 this cannot be 0 if ((_currentLiquidityRule.MinimumLocalBalance == 0 || _currentLiquidityRule.MinimumLocalBalance == null) && (_currentLiquidityRule.MinimumRemoteBalance == 0 || _currentLiquidityRule.MinimumRemoteBalance == null)) { @@ -916,14 +1099,14 @@ arg1.ErrorText = "Minimum remote balance cannot be 0 if the minimum local balance is 0"; } - //If the value is 0 is valid + //If the value is 0 is valid if (_currentLiquidityRule.MinimumRemoteBalance == 0 || _currentLiquidityRule.MinimumRemoteBalance == null) { return; } - //Check that the minimum remote balance is between 0 and 100 + //Check that the minimum remote balance is between 0 and 100 if (_currentLiquidityRule.MinimumRemoteBalance < 0 || _currentLiquidityRule.MinimumRemoteBalance > 100) { arg1.Status = ValidationStatus.Error; @@ -931,7 +1114,7 @@ } - //Check that the Minimum remote balance must be greater than the minimum local balance + //Check that the Minimum remote balance must be greater than the minimum local balance if (_currentLiquidityRule.MinimumRemoteBalance <= _currentLiquidityRule.MinimumLocalBalance) { arg1.Status = ValidationStatus.Error; @@ -942,9 +1125,9 @@ private void ValidateLocalBalance(ValidatorEventArgs arg1) { if (_currentLiquidityRule == null) return; - //Default validation status + //Default validation status arg1.Status = ValidationStatus.Success; - //If the minimum remote balance is 0 this cannot be 0 + //If the minimum remote balance is 0 this cannot be 0 if ((_currentLiquidityRule.MinimumLocalBalance == 0 || _currentLiquidityRule.MinimumLocalBalance == null) && (_currentLiquidityRule.MinimumRemoteBalance == 0 || _currentLiquidityRule.MinimumRemoteBalance == null)) { @@ -952,14 +1135,14 @@ arg1.ErrorText = "Minimum local balance cannot be 0 if the minimum remote balance is 0"; } - //If the value is 0 is valid + //If the value is 0 is valid if (_currentLiquidityRule.MinimumRemoteBalance == 0 || _currentLiquidityRule.MinimumRemoteBalance == null) { return; } - //Check that the balance is between 0 and 100 + //Check that the balance is between 0 and 100 if (_currentLiquidityRule.MinimumLocalBalance < 0 || _currentLiquidityRule.MinimumLocalBalance > 100) { @@ -967,7 +1150,7 @@ arg1.ErrorText = "Minimum local balance must be between 0 and 100"; } - //Check that the Minimum local balance must be less than the minimum remote balance + //Check that the Minimum local balance must be less than the minimum remote balance if (_currentLiquidityRule.MinimumLocalBalance >= _currentLiquidityRule.MinimumRemoteBalance) { arg1.Status = ValidationStatus.Error; @@ -975,6 +1158,110 @@ } } + private void ValidateChannelFeeBaseFeeMsat(ValidatorEventArgs arg1) + { + arg1.Status = ValidationStatus.Success; + + if (_channelFeePolicy.BaseFeeMsat == null) + { + arg1.Status = ValidationStatus.Error; + arg1.ErrorText = "Base fee is required."; + return; + } + + if (_channelFeePolicy.BaseFeeMsat < 0) + { + arg1.Status = ValidationStatus.Error; + arg1.ErrorText = "Base fee must be zero or greater."; + } + } + + private void ValidateChannelFeeRatePpm(ValidatorEventArgs arg1) + { + arg1.Status = ValidationStatus.Success; + + if (_channelFeePolicy.FeeRatePpm == null) + { + arg1.Status = ValidationStatus.Error; + arg1.ErrorText = "Fee rate is required."; + return; + } + + if (_channelFeePolicy.FeeRatePpm < 0 || _channelFeePolicy.FeeRatePpm > uint.MaxValue) + { + arg1.Status = ValidationStatus.Error; + arg1.ErrorText = $"Fee rate must be between 0 and {uint.MaxValue}."; + } + } + + private void ValidateChannelFeeTimeLockDelta(ValidatorEventArgs arg1) + { + arg1.Status = ValidationStatus.Success; + + if (_channelFeePolicy.TimeLockDelta == null) + { + arg1.Status = ValidationStatus.Error; + arg1.ErrorText = "Time lock delta is required."; + return; + } + + if (_channelFeePolicy.TimeLockDelta < 0 || _channelFeePolicy.TimeLockDelta > uint.MaxValue) + { + arg1.Status = ValidationStatus.Error; + arg1.ErrorText = $"Time lock delta must be between 0 and {uint.MaxValue}."; + } + } + + private void ValidateInboundFeePolicy(ValidatorEventArgs arg1) + { + arg1.Status = ValidationStatus.Success; + + if (_channelFeePolicy.InboundBaseFeeMsat.HasValue != _channelFeePolicy.InboundFeeRatePpm.HasValue) + { + arg1.Status = ValidationStatus.Error; + arg1.ErrorText = "Inbound base fee and inbound fee rate must be provided together."; + return; + } + + if (_channelFeePolicy.InboundBaseFeeMsat.HasValue && _channelFeePolicy.BaseFeeMsat.HasValue && + _channelFeePolicy.InboundBaseFeeMsat.Value > _channelFeePolicy.BaseFeeMsat.Value) + { + arg1.Status = ValidationStatus.Error; + arg1.ErrorText = "Inbound base fee cannot be higher than the base fee."; + return; + } + + if (_channelFeePolicy.InboundFeeRatePpm.HasValue && _channelFeePolicy.FeeRatePpm.HasValue && + _channelFeePolicy.InboundFeeRatePpm.Value > _channelFeePolicy.FeeRatePpm.Value) + { + arg1.Status = ValidationStatus.Error; + arg1.ErrorText = "Inbound fee rate cannot be higher than the fee rate."; + } + } + + private static Node? GetManagedChannelNode(Channel channel) + { + if (channel.SourceNode.IsManaged) + return channel.SourceNode; + + if (channel.DestinationNode.IsManaged) + return channel.DestinationNode; + + return null; + } + + private static string GetChannelOutpoint(Channel channel) + { + return $"{channel.FundingTx}:{channel.FundingTxOutputIndex}"; + } + + private string GetChannelManagementSaveLabel() + { + return _selectedChannelManagementTab == ChannelManagementFeePolicyTab + ? "Apply fee policy" + : "Save liquidity settings"; + } + private async Task MarkAsClosed(Channel contextItem) { From 927b09ddb41d391dbc10f48e96b4054b7a0b9a3e Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 19 May 2026 14:49:54 +0200 Subject: [PATCH 11/19] fix: allow only negative values for inbound fees --- src/Pages/Channels.razor | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/Pages/Channels.razor b/src/Pages/Channels.razor index 67cf8d09..9e952e80 100644 --- a/src/Pages/Channels.razor +++ b/src/Pages/Channels.razor @@ -478,9 +478,9 @@ Inbound base fee (msat) - Optional inbound base fee. LND usually expects negative values. + Optional inbound base fee. When set, it must be zero or below. - @@ -493,9 +493,9 @@ Inbound fee rate (ppm) - Optional inbound proportional fee. LND usually expects negative - values. - Optional inbound proportional fee. When set, it must be zero + or below. + @@ -1223,19 +1223,17 @@ return; } - if (_channelFeePolicy.InboundBaseFeeMsat.HasValue && _channelFeePolicy.BaseFeeMsat.HasValue && - _channelFeePolicy.InboundBaseFeeMsat.Value > _channelFeePolicy.BaseFeeMsat.Value) + if (_channelFeePolicy.InboundBaseFeeMsat.HasValue && _channelFeePolicy.InboundBaseFeeMsat.Value > 0) { arg1.Status = ValidationStatus.Error; - arg1.ErrorText = "Inbound base fee cannot be higher than the base fee."; + arg1.ErrorText = "Inbound base fee must be zero or below."; return; } - if (_channelFeePolicy.InboundFeeRatePpm.HasValue && _channelFeePolicy.FeeRatePpm.HasValue && - _channelFeePolicy.InboundFeeRatePpm.Value > _channelFeePolicy.FeeRatePpm.Value) + if (_channelFeePolicy.InboundFeeRatePpm.HasValue && _channelFeePolicy.InboundFeeRatePpm.Value > 0) { arg1.Status = ValidationStatus.Error; - arg1.ErrorText = "Inbound fee rate cannot be higher than the fee rate."; + arg1.ErrorText = "Inbound fee rate must be zero or below."; } } From c8b1e9184327002d7b334990d39d2daa5a96df05 Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 19 May 2026 17:01:13 +0200 Subject: [PATCH 12/19] feat: add default channel fee policy timelock delta blocks --- src/Helpers/Constants.cs | 4 ++++ src/Pages/Channels.razor | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index 5518146d..fece6ee7 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -98,6 +98,7 @@ public class Constants public static readonly decimal MINIMUM_WITHDRAWAL_BTC_AMOUNT = 0.0m; 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 readonly long ANCHOR_CLOSINGS_MINIMUM_SATS; public static readonly long MINIMUM_SWEEP_TRANSACTION_AMOUNT_SATS = 25_000_000; //25M sats public static readonly string DEFAULT_DERIVATION_PATH = "48'/1'"; @@ -354,6 +355,9 @@ static Constants() var transactionConfBlocks = GetEnvironmentalVariableOrThrowIfNotTesting("TRANSACTION_CONFIRMATION_MINIMUM_BLOCKS"); if (transactionConfBlocks != null) TRANSACTION_CONFIRMATION_MINIMUM_BLOCKS = int.Parse(transactionConfBlocks); + var defaultChannelFeePolicyTimelockDeltaBlocks = Environment.GetEnvironmentVariable("DEFAULT_CHANNEL_FEE_POLICY_TIMELOCK_DELTA_BLOCKS"); + if (defaultChannelFeePolicyTimelockDeltaBlocks != null) DEFAULT_CHANNEL_FEE_POLICY_TIMELOCK_DELTA_BLOCKS = int.Parse(defaultChannelFeePolicyTimelockDeltaBlocks); + var anchorClosingMinSats = GetEnvironmentalVariableOrThrowIfNotTesting("ANCHOR_CLOSINGS_MINIMUM_SATS"); if (anchorClosingMinSats != null) ANCHOR_CLOSINGS_MINIMUM_SATS = long.Parse(anchorClosingMinSats); // Check https://github.com/lightningnetwork/lnd/issues/6505#issuecomment-1120364460 to understand, we need 100K+ to support anchor channel closings diff --git a/src/Pages/Channels.razor b/src/Pages/Channels.razor index 9e952e80..d2a9d61d 100644 --- a/src/Pages/Channels.razor +++ b/src/Pages/Channels.razor @@ -606,7 +606,7 @@ { public long? BaseFeeMsat { get; set; } public long? FeeRatePpm { get; set; } - public long? TimeLockDelta { get; set; } + public long? TimeLockDelta { get; set; } = Constants.DEFAULT_CHANNEL_FEE_POLICY_TIMELOCK_DELTA_BLOCKS; public int? InboundBaseFeeMsat { get; set; } public int? InboundFeeRatePpm { get; set; } } From e400cb397972b354ef2a321d50ce6d6e94d98c3d Mon Sep 17 00:00:00 2001 From: Marcos Date: Fri, 22 May 2026 16:01:41 +0200 Subject: [PATCH 13/19] feat: Add Initial Channel Fee Policy to Channel Operation Requests - Introduced a new column `RequestMetadata` in the `ChannelOperationRequests` table to store additional request information. - Updated the `ApplicationDbContextModelSnapshot` to reflect the new `RequestMetadata` column. - Enhanced the Channel Requests page to include fields for setting the initial channel base fee and fee rate. - Implemented logic in the `LightningService` to handle the new fee policy parameters when creating open channel requests. - Added unit tests to verify the creation of open channel requests with the initial channel fee policy. --- src/Data/Models/ChannelOperationRequest.cs | 61 + ...60522125854_AddRequestMetadata.Designer.cs | 1733 +++++++++++++++++ .../20260522125854_AddRequestMetadata.cs | 28 + .../ApplicationDbContextModelSnapshot.cs | 3 + src/Pages/ChannelRequests.razor | 62 +- src/Services/LightningService.cs | 24 + .../Services/LightningServiceTests.cs | 49 + 7 files changed, 1959 insertions(+), 1 deletion(-) create mode 100644 src/Migrations/20260522125854_AddRequestMetadata.Designer.cs create mode 100644 src/Migrations/20260522125854_AddRequestMetadata.cs diff --git a/src/Data/Models/ChannelOperationRequest.cs b/src/Data/Models/ChannelOperationRequest.cs index 17e8e226..548239f4 100644 --- a/src/Data/Models/ChannelOperationRequest.cs +++ b/src/Data/Models/ChannelOperationRequest.cs @@ -18,6 +18,8 @@ */ using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; +using System.Text.Json.Serialization; using NodeGuard.Helpers; using NBitcoin; using NodeGuard.Services; @@ -73,6 +75,15 @@ public enum OperationRequestType public class ChannelOperationRequest : Entity, IEquatable, IBitcoinRequest { + private sealed class InitialChannelFeePolicyMetadata + { + [JsonPropertyName("InitialChannelBaseFeeMsat")] + public long? InitialChannelBaseFeeMsat { get; set; } + + [JsonPropertyName("InitialChannelFeeRatePpm")] + public long? InitialChannelFeeRatePpm { get; set; } + } + /// /// Amount in satoshis /// @@ -116,6 +127,11 @@ public decimal Amount ///
public decimal? FeeRate { get; set; } + /// + /// Serialized request metadata for values that must survive approval and job execution. + /// + public string? RequestMetadata { get; set; } + [Column(TypeName = "jsonb")] public List? StatusLogs { get; set; } = new(); @@ -155,6 +171,20 @@ public decimal Amount /// public bool Changeless { get; set; } + [NotMapped] + public long? InitialChannelBaseFeeMsat + { + get => GetRequestMetadata().InitialChannelBaseFeeMsat; + set => SetInitialChannelFeePolicyMetadata(value, InitialChannelFeeRatePpm); + } + + [NotMapped] + public long? InitialChannelFeeRatePpm + { + get => GetRequestMetadata().InitialChannelFeeRatePpm; + set => SetInitialChannelFeePolicyMetadata(InitialChannelBaseFeeMsat, value); + } + /// /// Check that the number of signatures (not finalised psbt nor internal wallet psbt or template psbt are gathered and increases by one to count on the internal wallet signature /// @@ -187,6 +217,37 @@ private bool CheckSignatures() return result; } + private InitialChannelFeePolicyMetadata GetRequestMetadata() + { + if (string.IsNullOrWhiteSpace(RequestMetadata)) + { + return new InitialChannelFeePolicyMetadata(); + } + + try + { + return JsonSerializer.Deserialize(RequestMetadata) ?? new InitialChannelFeePolicyMetadata(); + } + catch (JsonException) + { + return new InitialChannelFeePolicyMetadata(); + } + } + + private void SetInitialChannelFeePolicyMetadata(long? initialChannelBaseFeeMsat, long? initialChannelFeeRatePpm) + { + if (!initialChannelBaseFeeMsat.HasValue && !initialChannelFeeRatePpm.HasValue) + { + RequestMetadata = null; + return; + } + + var metadata = GetRequestMetadata(); + metadata.InitialChannelBaseFeeMsat = initialChannelBaseFeeMsat; + metadata.InitialChannelFeeRatePpm = initialChannelFeeRatePpm; + RequestMetadata = JsonSerializer.Serialize(metadata); + } + public override bool Equals(object? obj) { if (ReferenceEquals(null, obj)) return false; diff --git a/src/Migrations/20260522125854_AddRequestMetadata.Designer.cs b/src/Migrations/20260522125854_AddRequestMetadata.Designer.cs new file mode 100644 index 00000000..b910d549 --- /dev/null +++ b/src/Migrations/20260522125854_AddRequestMetadata.Designer.cs @@ -0,0 +1,1733 @@ +// +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("20260522125854_AddRequestMetadata")] + partial class AddRequestMetadata + { + /// + 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("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.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("IsChannelPrivate") + .HasColumnType("boolean"); + + b.Property("MempoolRecommendedFeesType") + .HasColumnType("integer"); + + b.Property("RequestMetadata") + .HasColumnType("text"); + + 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.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(1024) + .HasColumnType("character varying(1024)"); + + 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("AutoLiquidityManagementEnabled") + .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("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("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("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("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("PreimageHex") + .HasColumnType("text"); + + b.Property("ProbeBackoffRatio") + .HasColumnType("double precision"); + + 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.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.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/20260522125854_AddRequestMetadata.cs b/src/Migrations/20260522125854_AddRequestMetadata.cs new file mode 100644 index 00000000..377b393f --- /dev/null +++ b/src/Migrations/20260522125854_AddRequestMetadata.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NodeGuard.Migrations +{ + /// + public partial class AddRequestMetadata : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RequestMetadata", + table: "ChannelOperationRequests", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RequestMetadata", + table: "ChannelOperationRequests"); + } + } +} diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index 9c0f9b65..61c58bab 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -474,6 +474,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("MempoolRecommendedFeesType") .HasColumnType("integer"); + b.Property("RequestMetadata") + .HasColumnType("text"); + b.Property("RequestType") .HasColumnType("integer"); diff --git a/src/Pages/ChannelRequests.razor b/src/Pages/ChannelRequests.razor index acf4b3ee..8883655b 100644 --- a/src/Pages/ChannelRequests.razor +++ b/src/Pages/ChannelRequests.razor @@ -205,6 +205,36 @@ @(_selectedMempoolRecommendedFeesType != MempoolRecommendedFeesType.CustomFee ? "Fees may change by the time the request is processed" : "") + + + @GetInitialChannelFeePolicyDisplayText(context) + + + + + + Base fee (msat) + + + + + + + + + + Fee rate (ppm) + + + + + + + + + Optional. When provided, these values are sent to LND as the initial channel routing policy. + + @@ -447,6 +477,11 @@ @(context.FeeRate != null ? $"{context.FeeRate} sat/vbyte" : _selectedMempoolRecommendedFeesType.ToString().Humanize()) + + + @GetInitialChannelFeePolicyDisplayText(context) + + @@ -616,6 +651,8 @@ private List _selectedUTXOs = new(); private MempoolRecommendedFeesType _selectedMempoolRecommendedFeesType; private long _customSatPerVbAmount = 1; + private long? _selectedInitialChannelBaseFeeMsat; + private long? _selectedInitialChannelFeeRatePpm; // New Request integration private List _allWallets = new List(); @@ -665,6 +702,7 @@ public static readonly ColumnDefault SourceOfFunds = new("Source of Funds"); public static readonly ColumnDefault Capacity = new("Capacity"); public static readonly ColumnDefault FeeRates = new("Fee Rate"); + public static readonly ColumnDefault ChannelFeePolicy = new("Channel Fee Policy"); public static readonly ColumnDefault Private = new("Private"); public static readonly ColumnDefault SignaturesCollected = new("Signatures Collected"); public static readonly ColumnDefault Status = new("Status"); @@ -678,6 +716,7 @@ public static readonly ColumnDefault SourceOfFunds = new("Source of Funds"); public static readonly ColumnDefault Capacity = new("Capacity"); public static readonly ColumnDefault FeeRates = new("Fee Rate"); + public static readonly ColumnDefault ChannelFeePolicy = new("Channel Fee Policy"); public static readonly ColumnDefault Private = new("Private"); public static readonly ColumnDefault SignaturesCollected = new("Signatures Collected"); public static readonly ColumnDefault Status = new("Status"); @@ -739,6 +778,8 @@ _destNodeName = ""; _selectedDestNode = null; _selectedPrivate = false; + _selectedInitialChannelBaseFeeMsat = null; + _selectedInitialChannelFeeRatePpm = null; _selectedWalletId = null; _amount = _minimumChannelCapacity; } @@ -848,7 +889,9 @@ IsChannelPrivate = _selectedPrivate, Changeless = _isChangeless, MempoolRecommendedFeesType = _selectedMempoolRecommendedFeesType, - FeeRate = _selectedMempoolRecommendedFeesType == MempoolRecommendedFeesType.CustomFee ? _customSatPerVbAmount : null + FeeRate = _selectedMempoolRecommendedFeesType == MempoolRecommendedFeesType.CustomFee ? _customSatPerVbAmount : null, + InitialChannelBaseFeeMsat = _selectedInitialChannelBaseFeeMsat, + InitialChannelFeeRatePpm = _selectedInitialChannelFeeRatePpm, }; var selectedWallet = await WalletRepository.GetById(_selectedWalletId.Value); @@ -1265,6 +1308,23 @@ _customSatPerVbAmount = (long?)await NBXplorerService.GetFeesByType(_selectedMempoolRecommendedFeesType) ?? 1; } + private static string GetInitialChannelFeePolicyDisplayText(ChannelOperationRequest request) + { + var values = new List(); + + if (request.InitialChannelBaseFeeMsat.HasValue) + { + values.Add($"Base: {request.InitialChannelBaseFeeMsat.Value} msat"); + } + + if (request.InitialChannelFeeRatePpm.HasValue) + { + values.Add($"Rate: {request.InitialChannelFeeRatePpm.Value} ppm"); + } + + return values.Count == 0 ? "Default" : string.Join(" | ", values); + } + private void OnColumnLayoutUpdate() { StateHasChanged(); diff --git a/src/Services/LightningService.cs b/src/Services/LightningService.cs index cfc51409..59d35bf9 100644 --- a/src/Services/LightningService.cs +++ b/src/Services/LightningService.cs @@ -847,6 +847,30 @@ public async Task CreateOpenChannelRequest(ChannelOperationR NodePubkey = ByteString.CopyFrom(Convert.FromHexString(remoteNodeInfo.PubKey)), }; + if (channelOperationRequest.InitialChannelBaseFeeMsat.HasValue) + { + if (channelOperationRequest.InitialChannelBaseFeeMsat.Value < 0) + { + throw new ArgumentOutOfRangeException(nameof(channelOperationRequest.InitialChannelBaseFeeMsat), + "Channel base fee must be zero or greater."); + } + + openChannelRequest.BaseFee = checked((ulong)channelOperationRequest.InitialChannelBaseFeeMsat.Value); + openChannelRequest.UseBaseFee = true; + } + + if (channelOperationRequest.InitialChannelFeeRatePpm.HasValue) + { + if (channelOperationRequest.InitialChannelFeeRatePpm.Value < 0) + { + throw new ArgumentOutOfRangeException(nameof(channelOperationRequest.InitialChannelFeeRatePpm), + "Channel fee rate must be zero or greater."); + } + + openChannelRequest.FeeRate = checked((ulong)channelOperationRequest.InitialChannelFeeRatePpm.Value); + openChannelRequest.UseFeeRate = true; + } + // Check features to see if we need or is allowed to add a close address var upfrontShutdownScriptOpt = remoteNodeInfo.Features.ContainsKey((uint)FeatureBit.UpfrontShutdownScriptOpt); diff --git a/test/NodeGuard.Tests/Services/LightningServiceTests.cs b/test/NodeGuard.Tests/Services/LightningServiceTests.cs index 46a48f5d..d20d0b35 100644 --- a/test/NodeGuard.Tests/Services/LightningServiceTests.cs +++ b/test/NodeGuard.Tests/Services/LightningServiceTests.cs @@ -1858,6 +1858,55 @@ public async Task CloseChannel_Succeeds() }); } + [Fact] + public async Task? CreateOpenChannelRequest_CreatesRequestWithInitialChannelFeePolicy() + { + // Arrange + var wallet = CreateWallet.SingleSig(_internalWallet); + var channelOperationRequest = new ChannelOperationRequest + { + Wallet = wallet, + InitialChannelBaseFeeMsat = 1_000, + InitialChannelFeeRatePpm = 250 + }; + var psbt = + "cHNidP8BAFIBAAAAAeh7YDXyZE11vXb0yRqCkrxY7VpHH1WVMHwaCWYMv/pCAQAAAAD/////AUjf9QUAAAAAFgAULTCtUNMojFQZ8oa6fpbXbDhK2EYAAAAATwEENYfPA325Ro0AAAABg9H86IDUttPPFss+9te+0DByQgbeD7RPXNuVH9mh1qIDnMEWyKA+kvyG038on8+HxI+9AD8r6ZI1dNIDSGC8824Q7QIQyDAAAIABAACAAQAAAAABAR8A4fUFAAAAABYAFOk69QEyo0x+Xs/zV62OLrHh9eszAQMEAgAAAAAA"; + + var combinedPsbt = LightningHelper.CombinePSBTs(new[] { psbt }); + var lightningService = new LightningService(_logger, null, null, null, null, null, null, null, null, null, null); + var pendingChannelId = RandomNumberGenerator.GetBytes(32); + var derivationStrategyBase = LightningService.GetDerivationStrategyBase(channelOperationRequest); + var node = new LightningNode() + { + PubKey = "03650f49929d84d9a6d9b5a66235c603a1a0597dd609f7cd3b15052382cf9bb1b4" + }; + + // Act + var openChannelRequest = await lightningService.CreateOpenChannelRequest(channelOperationRequest, combinedPsbt, node, 1000, pendingChannelId, derivationStrategyBase); + + // Assert + openChannelRequest.Should().Be(new OpenChannelRequest() + { + FundingShim = new FundingShim + { + PsbtShim = new PsbtShim + { + BasePsbt = ByteString.FromBase64(combinedPsbt.ToBase64()), + NoPublish = false, + PendingChanId = ByteString.CopyFrom(pendingChannelId) + } + }, + LocalFundingAmount = 1000, + Private = false, + NodePubkey = ByteString.CopyFrom(Convert.FromHexString("03650f49929d84d9a6d9b5a66235c603a1a0597dd609f7cd3b15052382cf9bb1b4")), + CloseAddress = "", + BaseFee = 1_000, + FeeRate = 250, + UseBaseFee = true, + UseFeeRate = true, + }); + } + [Fact] public async Task GetChannelsStatus_SourceNodeIsManaged_SourceIsInitiator() { From 29b06ae894a85f05ddbda4769f9378bf43e6d73d Mon Sep 17 00:00:00 2001 From: Marcos Date: Mon, 25 May 2026 17:56:56 +0200 Subject: [PATCH 14/19] Add InitialChannelFees migration and update model snapshot - Created a new migration to add InitialChannelBaseFeeMsat and InitialChannelFeeRatePpm columns to the ChannelOperationRequests table. - Updated the ApplicationDbContextModelSnapshot to reflect the new columns in the ChannelOperationRequests entity. --- src/Data/Models/ChannelOperationRequest.cs | 65 ++----------------- .../20260522125854_AddRequestMetadata.cs | 28 -------- ...5155559_AddInitialChannelFees.Designer.cs} | 13 ++-- .../20260525155559_AddInitialChannelFees.cs | 38 +++++++++++ .../ApplicationDbContextModelSnapshot.cs | 9 ++- 5 files changed, 59 insertions(+), 94 deletions(-) delete mode 100644 src/Migrations/20260522125854_AddRequestMetadata.cs rename src/Migrations/{20260522125854_AddRequestMetadata.Designer.cs => 20260525155559_AddInitialChannelFees.Designer.cs} (99%) create mode 100644 src/Migrations/20260525155559_AddInitialChannelFees.cs diff --git a/src/Data/Models/ChannelOperationRequest.cs b/src/Data/Models/ChannelOperationRequest.cs index 548239f4..d2246d8a 100644 --- a/src/Data/Models/ChannelOperationRequest.cs +++ b/src/Data/Models/ChannelOperationRequest.cs @@ -18,8 +18,6 @@ */ using System.ComponentModel.DataAnnotations.Schema; -using System.Text.Json; -using System.Text.Json.Serialization; using NodeGuard.Helpers; using NBitcoin; using NodeGuard.Services; @@ -75,15 +73,6 @@ public enum OperationRequestType public class ChannelOperationRequest : Entity, IEquatable, IBitcoinRequest { - private sealed class InitialChannelFeePolicyMetadata - { - [JsonPropertyName("InitialChannelBaseFeeMsat")] - public long? InitialChannelBaseFeeMsat { get; set; } - - [JsonPropertyName("InitialChannelFeeRatePpm")] - public long? InitialChannelFeeRatePpm { get; set; } - } - /// /// Amount in satoshis /// @@ -128,9 +117,14 @@ public decimal Amount public decimal? FeeRate { get; set; } /// - /// Serialized request metadata for values that must survive approval and job execution. + /// Initial base fee in millisatoshis to be applied when the channel is opened. /// - public string? RequestMetadata { get; set; } + public long? InitialChannelBaseFeeMsat { get; set; } + + /// + /// Initial fee rate in parts per million to be applied when the channel is opened. + /// + public long? InitialChannelFeeRatePpm { get; set; } [Column(TypeName = "jsonb")] public List? StatusLogs { get; set; } = new(); @@ -171,20 +165,6 @@ public decimal Amount /// public bool Changeless { get; set; } - [NotMapped] - public long? InitialChannelBaseFeeMsat - { - get => GetRequestMetadata().InitialChannelBaseFeeMsat; - set => SetInitialChannelFeePolicyMetadata(value, InitialChannelFeeRatePpm); - } - - [NotMapped] - public long? InitialChannelFeeRatePpm - { - get => GetRequestMetadata().InitialChannelFeeRatePpm; - set => SetInitialChannelFeePolicyMetadata(InitialChannelBaseFeeMsat, value); - } - /// /// Check that the number of signatures (not finalised psbt nor internal wallet psbt or template psbt are gathered and increases by one to count on the internal wallet signature /// @@ -217,37 +197,6 @@ private bool CheckSignatures() return result; } - private InitialChannelFeePolicyMetadata GetRequestMetadata() - { - if (string.IsNullOrWhiteSpace(RequestMetadata)) - { - return new InitialChannelFeePolicyMetadata(); - } - - try - { - return JsonSerializer.Deserialize(RequestMetadata) ?? new InitialChannelFeePolicyMetadata(); - } - catch (JsonException) - { - return new InitialChannelFeePolicyMetadata(); - } - } - - private void SetInitialChannelFeePolicyMetadata(long? initialChannelBaseFeeMsat, long? initialChannelFeeRatePpm) - { - if (!initialChannelBaseFeeMsat.HasValue && !initialChannelFeeRatePpm.HasValue) - { - RequestMetadata = null; - return; - } - - var metadata = GetRequestMetadata(); - metadata.InitialChannelBaseFeeMsat = initialChannelBaseFeeMsat; - metadata.InitialChannelFeeRatePpm = initialChannelFeeRatePpm; - RequestMetadata = JsonSerializer.Serialize(metadata); - } - public override bool Equals(object? obj) { if (ReferenceEquals(null, obj)) return false; diff --git a/src/Migrations/20260522125854_AddRequestMetadata.cs b/src/Migrations/20260522125854_AddRequestMetadata.cs deleted file mode 100644 index 377b393f..00000000 --- a/src/Migrations/20260522125854_AddRequestMetadata.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace NodeGuard.Migrations -{ - /// - public partial class AddRequestMetadata : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "RequestMetadata", - table: "ChannelOperationRequests", - type: "text", - nullable: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "RequestMetadata", - table: "ChannelOperationRequests"); - } - } -} diff --git a/src/Migrations/20260522125854_AddRequestMetadata.Designer.cs b/src/Migrations/20260525155559_AddInitialChannelFees.Designer.cs similarity index 99% rename from src/Migrations/20260522125854_AddRequestMetadata.Designer.cs rename to src/Migrations/20260525155559_AddInitialChannelFees.Designer.cs index b910d549..a70b0bc3 100644 --- a/src/Migrations/20260522125854_AddRequestMetadata.Designer.cs +++ b/src/Migrations/20260525155559_AddInitialChannelFees.Designer.cs @@ -14,8 +14,8 @@ namespace NodeGuard.Migrations { [DbContext(typeof(ApplicationDbContext))] - [Migration("20260522125854_AddRequestMetadata")] - partial class AddRequestMetadata + [Migration("20260525155559_AddInitialChannelFees")] + partial class AddInitialChannelFees { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -471,15 +471,18 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) 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("RequestMetadata") - .HasColumnType("text"); - b.Property("RequestType") .HasColumnType("integer"); diff --git a/src/Migrations/20260525155559_AddInitialChannelFees.cs b/src/Migrations/20260525155559_AddInitialChannelFees.cs new file mode 100644 index 00000000..b6a1020c --- /dev/null +++ b/src/Migrations/20260525155559_AddInitialChannelFees.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NodeGuard.Migrations +{ + /// + public partial class AddInitialChannelFees : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "InitialChannelBaseFeeMsat", + table: "ChannelOperationRequests", + type: "bigint", + nullable: true); + + migrationBuilder.AddColumn( + name: "InitialChannelFeeRatePpm", + table: "ChannelOperationRequests", + type: "bigint", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "InitialChannelBaseFeeMsat", + table: "ChannelOperationRequests"); + + migrationBuilder.DropColumn( + name: "InitialChannelFeeRatePpm", + table: "ChannelOperationRequests"); + } + } +} diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index 61c58bab..0708a07c 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -468,15 +468,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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("RequestMetadata") - .HasColumnType("text"); - b.Property("RequestType") .HasColumnType("integer"); From e98375b5822365eb7bfcdace553afb966b5dfadd Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 26 May 2026 12:18:29 +0200 Subject: [PATCH 15/19] feat: enhance channel node management checks with macaroon validation --- src/Pages/Channels.razor | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Pages/Channels.razor b/src/Pages/Channels.razor index d2a9d61d..8fcd5c8d 100644 --- a/src/Pages/Channels.razor +++ b/src/Pages/Channels.razor @@ -1239,10 +1239,10 @@ private static Node? GetManagedChannelNode(Channel channel) { - if (channel.SourceNode.IsManaged) + if (channel.SourceNode.IsManaged && !string.IsNullOrWhiteSpace(channel.SourceNode.ChannelAdminMacaroon)) return channel.SourceNode; - if (channel.DestinationNode.IsManaged) + if (channel.DestinationNode.IsManaged && !string.IsNullOrWhiteSpace(channel.DestinationNode.ChannelAdminMacaroon)) return channel.DestinationNode; return null; From 1694aabc285c187397cb5f0c7b506f94f8c3b8bc Mon Sep 17 00:00:00 2001 From: Marcos Date: Fri, 29 May 2026 17:08:59 +0200 Subject: [PATCH 16/19] feat: add GetChannelFeePolicy method to retrieve channel fee policies --- src/Services/LightningService.cs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/Services/LightningService.cs b/src/Services/LightningService.cs index 59d35bf9..2d1c7dfb 100644 --- a/src/Services/LightningService.cs +++ b/src/Services/LightningService.cs @@ -187,6 +187,7 @@ Task SendPaymentV2Async(Node node, string paymentRequest, long feeLimit /// Task GetLocalOutboundFeeRatePpmByPeerAsync (Node node, string peerPubkey); + /// /// Sets the channel fee policy for a given channel identified by its chanPoint /// @@ -199,6 +200,14 @@ Task SendPaymentV2Async(Node node, string paymentRequest, long feeLimit /// /// public Task SetChannelFeePolicy(string chanPoint, string nodePubKey, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm); + + /// + /// Gets the channel fee policy for a given channel identified by its chanPoint + /// + /// + /// + /// + public Task<(RoutingPolicy?, RoutingPolicy?)> GetChannelFeePolicy(ulong chanId, Node node); } /// @@ -1940,5 +1949,21 @@ await applicationDbContext.AuditLogs.AddAsync(new AuditLog _logger.LogError(e, "Error while saving channel fee policy audit log for chanPoint: {ChanPoint}", chanPoint); } } + + public async Task<(RoutingPolicy?, RoutingPolicy?)> GetChannelFeePolicy(ulong chanId, Node node) + { + var info = await _lightningClientService.GetChanInfo(node, chanId); + if (info == null) + { + _logger.LogError("Channel not found for chanId: {ChanId}", chanId); + throw new ArgumentException("Channel not found for the given chanId.", nameof(chanId)); + } + + var managedNodePolicy = info.Node1Pub == node.PubKey ? info.Node1Policy : info.Node2Policy; + var counterpartyNodePolicy = info.Node1Pub == node.PubKey ? info.Node2Policy : info.Node1Policy; + + return (managedNodePolicy, counterpartyNodePolicy); + + } } } From 7f5df90597402f13ee1c55c5512fd7c6c05ae54d Mon Sep 17 00:00:00 2001 From: Marcos Date: Fri, 29 May 2026 19:42:11 +0200 Subject: [PATCH 17/19] feat: add GetChannelFeePolicy tests for channel fee retrieval and error handling --- .../Services/LightningServiceTests.cs | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/test/NodeGuard.Tests/Services/LightningServiceTests.cs b/test/NodeGuard.Tests/Services/LightningServiceTests.cs index d20d0b35..158abf0f 100644 --- a/test/NodeGuard.Tests/Services/LightningServiceTests.cs +++ b/test/NodeGuard.Tests/Services/LightningServiceTests.cs @@ -2363,5 +2363,132 @@ await act.Should() .ThrowAsync() .WithMessage("The given nodePubKey is not a participant of the channel. (Parameter 'nodePubKey')"); } + + [Fact] + public async Task GetChannelFeePolicy_ReturnsCorrectPolicies_WhenNodeIsNode1() + { + // Arrange + var node = new Node + { + PubKey = "managedPubKey", + Endpoint = "127.0.0.1:10009", + ChannelAdminMacaroon = "test-macaroon" + }; + + var node1Policy = new RoutingPolicy + { + FeeBaseMsat = 1000, + FeeRateMilliMsat = 250, + TimeLockDelta = 40 + }; + var node2Policy = new RoutingPolicy + { + FeeBaseMsat = 2000, + FeeRateMilliMsat = 500, + TimeLockDelta = 80 + }; + + var channelEdge = new ChannelEdge + { + Node1Pub = "managedPubKey", + Node2Pub = "counterpartyPubKey", + Node1Policy = node1Policy, + Node2Policy = node2Policy + }; + + var lightningClientService = new Mock(); + lightningClientService + .Setup(x => x.GetChanInfo(node, 123UL, null)) + .ReturnsAsync(channelEdge); + + var lightningService = new LightningService( + _logger, null, null, null, null, null, null, null, null, + lightningClientService.Object, null); + + // Act + var (managedPolicy, counterpartyPolicy) = await lightningService.GetChannelFeePolicy(123UL, node); + + // Assert + managedPolicy.Should().Be(node1Policy); + counterpartyPolicy.Should().Be(node2Policy); + } + + [Fact] + public async Task GetChannelFeePolicy_ReturnsCorrectPolicies_WhenNodeIsNode2() + { + // Arrange + var node = new Node + { + PubKey = "managedPubKey", + Endpoint = "127.0.0.1:10009", + ChannelAdminMacaroon = "test-macaroon" + }; + + var node1Policy = new RoutingPolicy + { + FeeBaseMsat = 1000, + FeeRateMilliMsat = 250, + TimeLockDelta = 40 + }; + var node2Policy = new RoutingPolicy + { + FeeBaseMsat = 2000, + FeeRateMilliMsat = 500, + TimeLockDelta = 80 + }; + + var channelEdge = new ChannelEdge + { + Node1Pub = "counterpartyPubKey", + Node2Pub = "managedPubKey", + Node1Policy = node1Policy, + Node2Policy = node2Policy + }; + + var lightningClientService = new Mock(); + lightningClientService + .Setup(x => x.GetChanInfo(node, 456UL, null)) + .ReturnsAsync(channelEdge); + + var lightningService = new LightningService( + _logger, null, null, null, null, null, null, null, null, + lightningClientService.Object, null); + + // Act + var (managedPolicy, counterpartyPolicy) = await lightningService.GetChannelFeePolicy(456UL, node); + + // Assert + managedPolicy.Should().Be(node2Policy); + counterpartyPolicy.Should().Be(node1Policy); + } + + [Fact] + public async Task GetChannelFeePolicy_ThrowsArgumentException_WhenChannelNotFound() + { + // Arrange + var node = new Node + { + PubKey = "managedPubKey", + Endpoint = "127.0.0.1:10009", + ChannelAdminMacaroon = "test-macaroon" + }; + + var lightningClientService = new Mock(); + lightningClientService + .Setup(x => x.GetChanInfo(node, 789UL, null)) + .ReturnsAsync((ChannelEdge?)null); + + var lightningService = new LightningService( + _logger, null, null, null, null, null, null, null, null, + lightningClientService.Object, null); + + // Act + var act = async () => await lightningService.GetChannelFeePolicy(789UL, node); + + // Assert + await act.Should() + .ThrowAsync() + .WithMessage("Channel not found for the given chanId. (Parameter 'chanId')"); + } } } From c373ac5a62939e38d4ad46d824e0cd524a9cd8d9 Mon Sep 17 00:00:00 2001 From: Marcos Date: Fri, 29 May 2026 19:56:36 +0200 Subject: [PATCH 18/19] feat: add the current fee rate to management tab and channels datagrid --- src/Pages/Channels.razor | 324 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 321 insertions(+), 3 deletions(-) diff --git a/src/Pages/Channels.razor b/src/Pages/Channels.razor index 8fcd5c8d..280a057b 100644 --- a/src/Pages/Channels.razor +++ b/src/Pages/Channels.razor @@ -3,6 +3,7 @@ @using Humanizer @using NBitcoin @using Blazorise.Components +@using Lnrpc @using Channel = NodeGuard.Data.Models.Channel @attribute [Authorize(Roles = "NodeManager")] Active Channels @@ -245,6 +246,102 @@ + + + @{ + var managedNode = GetManagedChannelNode(context); + var hasCachedPolicy = false; + (RoutingPolicy?, RoutingPolicy?) cachedPolicy = (null, null); + + if (managedNode != null && _channelFeePoliciesCache.TryGetValue(context.ChanId, out var cached)) + { + hasCachedPolicy = true; + cachedPolicy = cached; + } + + @if (managedNode == null) + { + + } + else if (!hasCachedPolicy) + { + + } + else if (cachedPolicy.Item1 != null) + { + + + @($"{cachedPolicy.Item1.FeeRateMilliMsat} ppm") + + + @($"{cachedPolicy.Item1.FeeBaseMsat} msat") + + @if (cachedPolicy.Item1.InboundFeeRateMilliMsat != 0 || cachedPolicy.Item1.InboundFeeBaseMsat != 0) + { + + @($"in: {cachedPolicy.Item1.InboundFeeRateMilliMsat} ppm") + + + @($"in: {cachedPolicy.Item1.InboundFeeBaseMsat} msat") + + } + + } + else + { + + } + } + + + + + @{ + var managedNodeCp = GetManagedChannelNode(context); + var hasCachedPolicyCp = false; + (RoutingPolicy?, RoutingPolicy?) cachedPolicyCp = (null, null); + + if (managedNodeCp != null && _channelFeePoliciesCache.TryGetValue(context.ChanId, out var cachedCp)) + { + hasCachedPolicyCp = true; + cachedPolicyCp = cachedCp; + } + + @if (managedNodeCp == null) + { + + } + else if (!hasCachedPolicyCp) + { + + } + else if (cachedPolicyCp.Item2 != null) + { + + + @($"{cachedPolicyCp.Item2.FeeRateMilliMsat} ppm") + + + @($"{cachedPolicyCp.Item2.FeeBaseMsat} msat") + + @if (cachedPolicyCp.Item2.InboundFeeRateMilliMsat != 0 || cachedPolicyCp.Item2.InboundFeeBaseMsat != 0) + { + + @($"in: {cachedPolicyCp.Item2.InboundFeeRateMilliMsat} ppm") + + + @($"in: {cachedPolicyCp.Item2.InboundFeeBaseMsat} msat") + + } + + } + else + { + + } + } + + @@ -426,6 +523,113 @@

Apply a new fee policy to outpoint @(_selectedChannelOutpoint ?? "Unknown") using managed node @(_selectedChannelNodePubKey ?? "Unknown").

+ + @if (_currentFeePolicies.IsLoading) + { + + + Loading current fee policies... + + + } + else if (!string.IsNullOrEmpty(_currentFeePolicies.ErrorMessage)) + { + + + ⚠️ Could not load current policies: @_currentFeePolicies.ErrorMessage + + + } + else if (_currentFeePolicies.ManagedNodePolicy != null || _currentFeePolicies.CounterpartyPolicy != null) + { + + + Current Fee Policies (Read-Only) + + + + +
📍 Managed Node (@(_selectedChannelNodePubKey?.Substring(0, 8) ?? "Unknown")...)
+ @if (_currentFeePolicies.ManagedNodePolicy != null) + { + + + + + + + + + + + + + + + @if (_currentFeePolicies.ManagedNodePolicy.InboundFeeRateMilliMsat != 0 || _currentFeePolicies.ManagedNodePolicy.InboundFeeBaseMsat != 0) + { + + + + + + + + + } + +
Base Fee (msat):@_currentFeePolicies.ManagedNodePolicy.FeeBaseMsat
Fee Rate (ppm):@_currentFeePolicies.ManagedNodePolicy.FeeRateMilliMsat
Time Lock Delta:@_currentFeePolicies.ManagedNodePolicy.TimeLockDelta
Inbound Base Fee (msat):@_currentFeePolicies.ManagedNodePolicy.InboundFeeBaseMsat
Inbound Fee Rate (ppm):@_currentFeePolicies.ManagedNodePolicy.InboundFeeRateMilliMsat
+ } + else + { +

No outbound policy found

+ } +
+ +
🔗 Counterparty Node
+ @if (_currentFeePolicies.CounterpartyPolicy != null) + { + + + + + + + + + + + + + + + @if (_currentFeePolicies.CounterpartyPolicy.InboundFeeRateMilliMsat != 0 || _currentFeePolicies.CounterpartyPolicy.InboundFeeBaseMsat != 0) + { + + + + + + + + + } + +
Base Fee (msat):@_currentFeePolicies.CounterpartyPolicy.FeeBaseMsat
Fee Rate (ppm):@_currentFeePolicies.CounterpartyPolicy.FeeRateMilliMsat
Time Lock Delta:@_currentFeePolicies.CounterpartyPolicy.TimeLockDelta
Inbound Base Fee (msat):@_currentFeePolicies.CounterpartyPolicy.InboundFeeBaseMsat
Inbound Fee Rate (ppm):@_currentFeePolicies.CounterpartyPolicy.InboundFeeRateMilliMsat
+ } + else + { +

No inbound policy found

+ } +
+
+
+
+ } + + +
Set New Fee Policy
+ @@ -447,7 +651,7 @@ Fee rate (ppm) Outbound proportional fee in parts per million. + @bind-Value="_channelFeePolicy.FeeRatePpm" > @@ -544,6 +748,7 @@ private Validations? _channelManagementValidationsRef; private Validations? _channelFeePolicyValidationsRef; private ChannelFeePolicyForm _channelFeePolicy = new(); + private CurrentFeePolicies _currentFeePolicies = new() { IsLoading = false }; private string _selectedChannelManagementTab = ChannelManagementLiquidityTab; private string? _selectedChannelOutpoint; private string? _selectedChannelNodePubKey; @@ -584,6 +789,9 @@ private bool _columnsLoaded; // This dictionary is used to store the balance of each channel, key is the channel id, value is a tuple with the node id as local in the pair, local balance, remote balance private Dictionary _channelsBalance = new(); + // Cache for fee policies by channel ID + private Dictionary _channelFeePoliciesCache = new(); + private bool _isLoadingFeePolicies; public abstract class ChannelsColumnName { @@ -600,6 +808,8 @@ public static readonly ColumnDefault CREATION_DATE = new("Creation Date"); public static readonly ColumnDefault UPDATE_DATE = new("Update Date"); public static readonly ColumnDefault CHANNEL_ID = new("Channel Id"); + public static readonly ColumnDefault FEE_POLICY = new("Fee Policy"); + public static readonly ColumnDefault COUNTERPARTY_FEE_POLICY = new("Counterparty Fee Policy"); } private sealed class ChannelFeePolicyForm @@ -611,6 +821,14 @@ public int? InboundFeeRatePpm { get; set; } } + private sealed class CurrentFeePolicies + { + public RoutingPolicy? ManagedNodePolicy { get; set; } + public RoutingPolicy? CounterpartyPolicy { get; set; } + public bool IsLoading { get; set; } + public string? ErrorMessage { get; set; } + } + protected override async Task OnInitializedAsync() { _btcPrice = await PriceConversionService.GetBtcToUsdPrice(); @@ -677,6 +895,7 @@ _channels = channels; _totalItems = totalCount; + _ = Task.Run(async () => await InvokeAsync(LoadAndCacheChannelFeePolicies)); } private async Task OnFiltersChanged() @@ -831,10 +1050,54 @@ if (_currentLiquidityRule != null) _currentLiquidityRule.ReverseSwapWalletId = arg; } - private Task OnSelectedChannelManagementTabChanged(string tabName) + private async Task OnSelectedChannelManagementTabChanged(string tabName) { _selectedChannelManagementTab = tabName; - return Task.CompletedTask; + if (tabName == ChannelManagementFeePolicyTab && _selectedChannel != null) + { + await LoadChannelFeePolicies(); + } + } + + private async Task LoadChannelFeePolicies() + { + if (_selectedChannel?.ChanId == null) + { + _currentFeePolicies = new CurrentFeePolicies { ErrorMessage = "Channel ID not available" }; + return; + } + + var node = GetManagedChannelNode(_selectedChannel); + if (node == null) + { + _currentFeePolicies = new CurrentFeePolicies { ErrorMessage = "Managed node not found" }; + return; + } + + _currentFeePolicies.IsLoading = true; + _currentFeePolicies.ErrorMessage = null; + StateHasChanged(); + + try + { + var result = await LightningService.GetChannelFeePolicy(_selectedChannel.ChanId, node); + _currentFeePolicies = new CurrentFeePolicies + { + ManagedNodePolicy = result.Item1, + CounterpartyPolicy = result.Item2, + IsLoading = false + }; + } + catch (Exception ex) + { + _currentFeePolicies = new CurrentFeePolicies + { + ErrorMessage = $"Failed to load fee policies: {ex.Message}", + IsLoading = false + }; + } + + StateHasChanged(); } private async Task CloseChannelManagementModal() @@ -971,6 +1234,7 @@ _selectedChannel = null; _currentLiquidityRule = new LiquidityRule(); _channelFeePolicy = new ChannelFeePolicyForm(); + _currentFeePolicies = new CurrentFeePolicies { IsLoading = false }; _selectedChannelManagementTab = ChannelManagementLiquidityTab; _selectedChannelOutpoint = null; _selectedChannelNodePubKey = null; @@ -1329,6 +1593,60 @@ } return $"{source} - {destination}"; } + + private string GetFeePolicyTooltip(RoutingPolicy policy) + { + var outbound = $"Outbound — Base: {policy.FeeBaseMsat} msat | Rate: {policy.FeeRateMilliMsat} ppm | Time Lock: {policy.TimeLockDelta} blocks"; + var inbound = (policy.InboundFeeRateMilliMsat != 0 || policy.InboundFeeBaseMsat != 0) + ? $" | Inbound — Base: {policy.InboundFeeBaseMsat} msat | Rate: {policy.InboundFeeRateMilliMsat} ppm" + : ""; + return outbound + inbound; + } + + private async Task LoadAndCacheChannelFeePolicies() + { + if (_isLoadingFeePolicies) return; + _isLoadingFeePolicies = true; + try + { + if (_channels == null || _channels.Count == 0) + return; + + foreach (var channel in _channels) + { + // Skip channels already cached + if (_channelFeePoliciesCache.ContainsKey(channel.ChanId)) + continue; + + var node = GetManagedChannelNode(channel); + if (node == null) + { + _channelFeePoliciesCache[channel.ChanId] = (null, null); + continue; + } + + try + { + var result = await LightningService.GetChannelFeePolicy(channel.ChanId, node); + _channelFeePoliciesCache[channel.ChanId] = result; + await InvokeAsync(StateHasChanged); + } + catch + { + _channelFeePoliciesCache[channel.ChanId] = (null, null); + await InvokeAsync(StateHasChanged); + } + } + } + catch + { + // Silently fail - UI will show spinners + } + finally + { + _isLoadingFeePolicies = false; + } + } private void ValidateBitcoinAddress(ValidatorEventArgs obj) { From 1c551a850d1de16894a935c8a8d0e75926244371 Mon Sep 17 00:00:00 2001 From: Marcos Date: Fri, 29 May 2026 19:58:25 +0200 Subject: [PATCH 19/19] feat: add default values for the channel fees --- src/Helpers/Constants.cs | 8 ++++++++ src/Pages/ChannelRequests.razor | 6 +++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index fece6ee7..84b67ca7 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -99,6 +99,8 @@ 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 = 1000; + public static long DEFAULT_CHANNEL_FEE_POLICY_FEE_RATE_PPM = 500; // 500 ppm = 0.05% public static readonly long ANCHOR_CLOSINGS_MINIMUM_SATS; public static readonly long MINIMUM_SWEEP_TRANSACTION_AMOUNT_SATS = 25_000_000; //25M sats public static readonly string DEFAULT_DERIVATION_PATH = "48'/1'"; @@ -358,6 +360,12 @@ static Constants() var defaultChannelFeePolicyTimelockDeltaBlocks = Environment.GetEnvironmentVariable("DEFAULT_CHANNEL_FEE_POLICY_TIMELOCK_DELTA_BLOCKS"); if (defaultChannelFeePolicyTimelockDeltaBlocks != null) DEFAULT_CHANNEL_FEE_POLICY_TIMELOCK_DELTA_BLOCKS = int.Parse(defaultChannelFeePolicyTimelockDeltaBlocks); + var defaultChannelFeePolicyBaseFeeMsat = Environment.GetEnvironmentVariable("DEFAULT_CHANNEL_FEE_POLICY_BASE_FEE_MSAT"); + if (defaultChannelFeePolicyBaseFeeMsat != null) DEFAULT_CHANNEL_FEE_POLICY_BASE_FEE_MSAT = long.Parse(defaultChannelFeePolicyBaseFeeMsat); + + var defaultChannelFeePolicyFeeRatePpm = Environment.GetEnvironmentVariable("DEFAULT_CHANNEL_FEE_POLICY_FEE_RATE_PPM"); + if (defaultChannelFeePolicyFeeRatePpm != null) DEFAULT_CHANNEL_FEE_POLICY_FEE_RATE_PPM = long.Parse(defaultChannelFeePolicyFeeRatePpm); + var anchorClosingMinSats = GetEnvironmentalVariableOrThrowIfNotTesting("ANCHOR_CLOSINGS_MINIMUM_SATS"); if (anchorClosingMinSats != null) ANCHOR_CLOSINGS_MINIMUM_SATS = long.Parse(anchorClosingMinSats); // Check https://github.com/lightningnetwork/lnd/issues/6505#issuecomment-1120364460 to understand, we need 100K+ to support anchor channel closings diff --git a/src/Pages/ChannelRequests.razor b/src/Pages/ChannelRequests.razor index 8883655b..7177f987 100644 --- a/src/Pages/ChannelRequests.razor +++ b/src/Pages/ChannelRequests.razor @@ -371,7 +371,7 @@ Requestor