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()
{