From 93d106def646bfecdc60c9a1cac60da249fe4329 Mon Sep 17 00:00:00 2001 From: Marcos Date: Thu, 24 Sep 2026 20:17:29 +0200 Subject: [PATCH] feat: implement node-specific channel balance retrieval and update related UI logic --- src/Pages/RoutingManagement.razor | 32 +++-- src/Services/LightningService.cs | 58 +++++++-- .../Services/LightningServiceTests.cs | 114 ++++++++++++++++++ 3 files changed, 181 insertions(+), 23 deletions(-) diff --git a/src/Pages/RoutingManagement.razor b/src/Pages/RoutingManagement.razor index b558fb6b..bc4900b7 100644 --- a/src/Pages/RoutingManagement.razor +++ b/src/Pages/RoutingManagement.razor @@ -373,18 +373,7 @@ _channels = allChannels.Where(c => c.Status == Channel.ChannelStatus.Open).ToList(); await LoadRoutingStatesAsync(); - - // Live balances are best-effort — structural stats and the toggles still work without LND. - _live = null; - try - { - var state = await LightningService.GetChannelsState(); - if (state.Count > 0) _live = state; - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Could not fetch live channel balances for the Routing Management page"); - } + await LoadLiveBalancesAsync(); Rebuild(); await LoadRebalanceStatsAsync(); @@ -475,6 +464,24 @@ await LoadRebalanceStatsAsync(); } + // Balances are point-of-view dependent, so they're read from the selected node itself: a channel + // between two managed nodes reports mirrored local/remote on each side. Best-effort — the structural + // stats and the toggles still work without LND. + private async Task LoadLiveBalancesAsync() + { + _live = null; + if (_selectedNode is null) return; + try + { + var state = await LightningService.GetChannelsState(_selectedNode); + if (state.Count > 0) _live = state; + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Could not fetch live channel balances for node {NodeId}", _selectedNode.Id); + } + } + // Budget spend and in-flight count for the rebalance read-out. Best-effort: the page still works without them. private async Task LoadRebalanceStatsAsync() { @@ -500,6 +507,7 @@ _expanded.Clear(); _search = string.Empty; await LoadRoutingStatesAsync(); + await LoadLiveBalancesAsync(); await LoadRebalanceStatsAsync(); Rebuild(); } diff --git a/src/Services/LightningService.cs b/src/Services/LightningService.cs index a382f665..06887dc4 100644 --- a/src/Services/LightningService.cs +++ b/src/Services/LightningService.cs @@ -117,6 +117,14 @@ public interface ILightningService /// public Task> GetChannelsState(); + /// + /// Gets a dictionary of the local and remote balance of the channels of a single node, from that + /// node's own point of view. Use this whenever balances are shown for a specific node. + /// + /// + /// + public Task> GetChannelsState(Node node); + /// /// Cancels a pending channel from LND PSBT-based funding of channels /// @@ -1592,24 +1600,52 @@ public async Task> GetChannelsState() // We skip and wait for the other node to report the channel if (!ChannelOwnershipHelper.IsOwnedByManagedNode(channel, nodes)) continue; - var htlcsLocal = channel.PendingHtlcs.Where(x => x.Incoming == true).Sum(x => x.Amount); - var htlcsRemote = channel.PendingHtlcs.Where(x => x.Incoming == false).Sum(x => x.Amount); + result.TryAdd(channel.ChanId, ToChannelState(channel)); + } + } - var localBalance = channel.LocalBalance + htlcsLocal; - var remoteBalance = channel.RemoteBalance + htlcsRemote; + return result; + } - result.TryAdd(channel.ChanId, new ChannelState() - { - LocalBalance = localBalance, - RemoteBalance = remoteBalance, - Active = channel.Active - }); - } + public async Task> GetChannelsState(Node node) + { + if (node == null) throw new ArgumentNullException(nameof(node)); + + var result = new Dictionary(); + + var listChannelsResponse = await _lightningClientService.ListChannels(node); + if (listChannelsResponse == null) + { + _logger.LogError("Error while getting channels for node: {NodeId}", node.Id); + return result; + } + + // No ownership de-dup here: the caller asked for this node's own point of view, and a + // channel between two managed nodes reports mirrored local/remote on each side. + foreach (var channel in listChannelsResponse.Channels) + { + if (channel == null) continue; + + result.TryAdd(channel.ChanId, ToChannelState(channel)); } return result; } + // Pending HTLCs are credited back to the side that will keep the funds if they settle. + private static ChannelState ToChannelState(Lnrpc.Channel channel) + { + var htlcsLocal = channel.PendingHtlcs.Where(x => x.Incoming == true).Sum(x => x.Amount); + var htlcsRemote = channel.PendingHtlcs.Where(x => x.Incoming == false).Sum(x => x.Amount); + + return new ChannelState() + { + LocalBalance = channel.LocalBalance + htlcsLocal, + RemoteBalance = channel.RemoteBalance + htlcsRemote, + Active = channel.Active + }; + } + public async Task GetBlockHeight(Node node) { if (node == null) throw new ArgumentNullException(nameof(node)); diff --git a/test/NodeGuard.Tests/Services/LightningServiceTests.cs b/test/NodeGuard.Tests/Services/LightningServiceTests.cs index ec074652..bc5f59aa 100644 --- a/test/NodeGuard.Tests/Services/LightningServiceTests.cs +++ b/test/NodeGuard.Tests/Services/LightningServiceTests.cs @@ -2189,6 +2189,120 @@ public async Task GetChannelsStatus_BothNodesAreManaged_SourceIsNotInitiator() channelStatus[0].RemoteBalance.Should().Be(0); } + [Fact] + public async Task GetChannelsStateForNode_BothNodesAreManaged_ReturnsEachNodesOwnPointOfView() + { + // Arrange + var lightningClientService = new Mock(); + + var node1 = new Node { Id = 1, Endpoint = "abc", PubKey = "managedPubKey1" }; + var node2 = new Node { Id = 2, Endpoint = "abc", PubKey = "managedPubKey2" }; + + var listChannelsResponse1 = new ListChannelsResponse + { + Channels = + { + new Lnrpc.Channel + { + ChanId = 1, + LocalBalance = 500, + RemoteBalance = 100, + Initiator = true, + Active = true, + RemotePubkey = "managedPubKey2" + } + } + }; + + var listChannelsResponse2 = new ListChannelsResponse + { + Channels = + { + new Lnrpc.Channel + { + ChanId = 1, + LocalBalance = 100, + RemoteBalance = 500, + Initiator = false, + Active = true, + RemotePubkey = "managedPubKey1" + } + } + }; + + lightningClientService.Setup(x => x.ListChannels(node1, null)).ReturnsAsync(listChannelsResponse1); + lightningClientService.Setup(x => x.ListChannels(node2, null)).ReturnsAsync(listChannelsResponse2); + var lightningService = new LightningService(null, null, null, null, null, null, null, null, null, lightningClientService.Object, null, null); + + // Act + var node1State = await lightningService.GetChannelsState(node1); + var node2State = await lightningService.GetChannelsState(node2); + + // Assert + node1State[1].LocalBalance.Should().Be(500); + node1State[1].RemoteBalance.Should().Be(100); + node2State[1].LocalBalance.Should().Be(100); + node2State[1].RemoteBalance.Should().Be(500); + } + + [Fact] + public async Task GetChannelsStateForNode_PendingHtlcs_AreCreditedToTheirSide() + { + // Arrange + var lightningClientService = new Mock(); + var node = new Node { Id = 1, Endpoint = "abc", PubKey = "managedPubKey1" }; + + var listChannelsResponse = new ListChannelsResponse + { + Channels = + { + new Lnrpc.Channel + { + ChanId = 1, + LocalBalance = 500, + RemoteBalance = 100, + Initiator = true, + Active = true, + RemotePubkey = "externalPubKey", + PendingHtlcs = + { + new HTLC { Incoming = true, Amount = 30 }, + new HTLC { Incoming = false, Amount = 20 } + } + } + } + }; + + lightningClientService.Setup(x => x.ListChannels(node, null)).ReturnsAsync(listChannelsResponse); + var lightningService = new LightningService(null, null, null, null, null, null, null, null, null, lightningClientService.Object, null, null); + + // Act + var state = await lightningService.GetChannelsState(node); + + // Assert + state[1].LocalBalance.Should().Be(530); + state[1].RemoteBalance.Should().Be(120); + state[1].Active.Should().BeTrue(); + } + + [Fact] + public async Task GetChannelsStateForNode_ListChannelsFails_ReturnsEmpty() + { + // Arrange + var lightningClientService = new Mock(); + var node = new Node { Id = 1, Endpoint = "abc", PubKey = "managedPubKey1" }; + + lightningClientService.Setup(x => x.ListChannels(node, null)).ReturnsAsync((ListChannelsResponse?)null); + var logger = new Mock>(); + var lightningService = new LightningService(logger.Object, null, null, null, null, null, null, null, null, lightningClientService.Object, null, null); + + // Act + var state = await lightningService.GetChannelsState(node); + + // Assert + state.Should().BeEmpty(); + } + [Fact] public async Task SetChannelFeePolicy_ValidRequest_UpdatesPolicyAndStoresAuditLog() {