Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 20 additions & 12 deletions src/Pages/RoutingManagement.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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()
{
Expand All @@ -500,6 +507,7 @@
_expanded.Clear();
_search = string.Empty;
await LoadRoutingStatesAsync();
await LoadLiveBalancesAsync();
await LoadRebalanceStatsAsync();
Rebuild();
}
Expand Down
58 changes: 47 additions & 11 deletions src/Services/LightningService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,14 @@ public interface ILightningService
/// <returns></returns>
public Task<Dictionary<ulong, ChannelState>> GetChannelsState();

/// <summary>
/// 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.
/// </summary>
/// <param name="node"></param>
/// <returns></returns>
public Task<Dictionary<ulong, ChannelState>> GetChannelsState(Node node);

/// <summary>
/// Cancels a pending channel from LND PSBT-based funding of channels
/// </summary>
Expand Down Expand Up @@ -1592,24 +1600,52 @@ public async Task<Dictionary<ulong, ChannelState>> 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<Dictionary<ulong, ChannelState>> GetChannelsState(Node node)
{
if (node == null) throw new ArgumentNullException(nameof(node));

var result = new Dictionary<ulong, ChannelState>();

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<uint?> GetBlockHeight(Node node)
{
if (node == null) throw new ArgumentNullException(nameof(node));
Expand Down
114 changes: 114 additions & 0 deletions test/NodeGuard.Tests/Services/LightningServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ILightningClientService>();

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<ILightningClientService>();
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<ILightningClientService>();
var node = new Node { Id = 1, Endpoint = "abc", PubKey = "managedPubKey1" };

lightningClientService.Setup(x => x.ListChannels(node, null)).ReturnsAsync((ListChannelsResponse?)null);
var logger = new Mock<ILogger<LightningService>>();
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()
{
Expand Down
Loading