From 77fc7b466cec6bb4e03a2075f7d5e7d77336d219 Mon Sep 17 00:00:00 2001
From: Rodrigo <39995243+RodriFS@users.noreply.github.com>
Date: Tue, 8 Aug 2023 17:51:51 +0200
Subject: [PATCH] Channel availability in channels table (#264)
Channel availability in channels table
---
src/Helpers/ChannelStatus.cs | 8 +
src/Pages/Channels.razor | 131 ++++++-----
src/Services/LightningService.cs | 31 ++-
.../Services/LightningServiceTests.cs | 222 ++++++++++++++++++
4 files changed, 330 insertions(+), 62 deletions(-)
create mode 100644 src/Helpers/ChannelStatus.cs
diff --git a/src/Helpers/ChannelStatus.cs b/src/Helpers/ChannelStatus.cs
new file mode 100644
index 00000000..45f1bb1f
--- /dev/null
+++ b/src/Helpers/ChannelStatus.cs
@@ -0,0 +1,8 @@
+namespace NodeGuard.Helpers;
+
+public class ChannelStatus
+{
+ public long LocalBalance { get; set; }
+ public long RemoteBalance { get; set; }
+ public bool Active { get; set; }
+}
\ No newline at end of file
diff --git a/src/Pages/Channels.razor b/src/Pages/Channels.razor
index b8668161..38651139 100644
--- a/src/Pages/Channels.razor
+++ b/src/Pages/Channels.razor
@@ -5,7 +5,11 @@
@using Channel = NodeGuard.Data.Models.Channel
@attribute [Authorize(Roles = "NodeManager")]
Active Channels
-
Channels
+Channels
+
+
+
+
@@ -61,7 +65,6 @@
{
@(context.SourceNode.Name + " " + StringHelper.TruncateHeadAndTail(context.SourceNode.PubKey, 5))
}
-
@@ -80,7 +83,6 @@
{
@(context.DestinationNode.Name + " " + StringHelper.TruncateHeadAndTail(context.DestinationNode.PubKey, 5))
}
-
@@ -135,7 +137,6 @@
-
@if (!string.IsNullOrEmpty(context.BtcCloseAddress))
{
@@ -145,12 +146,26 @@
}
-
+
+
+
+
+
+
+ @{
+ var isActive = GetAvailability(context.ChanId);
+ }
+ @(isActive ? "Active" : "Inactive")
+
@{
- var balance = Task.Run(() => GetPercentageBalance(context)).Result;
+ var balance = GetPercentageBalance(context);
}
@if (balance >= 0)
@@ -166,7 +181,6 @@
{
Not available
}
-
@@ -187,6 +201,7 @@
+
@@ -224,7 +239,6 @@
{
N/A
}
-
@@ -249,14 +263,11 @@
Enable automated liquidity management
-
-
+
-
@if (_selectedChannel.IsAutomatedLiquidityEnabled && _currentLiquidityRule != null)
{
-
Minimum local balance
@@ -268,7 +279,6 @@
-
Minimum remote balance
@@ -291,8 +301,6 @@
-
-
Wallet to use in Swaps operations
@@ -310,10 +318,8 @@
-
}
-
@@ -346,6 +352,7 @@
private LiquidityRule? _currentLiquidityRule = new LiquidityRule();
private Validations? _channelManagementValidationsRef;
private string _statusFilter = "Open";
+ private int _availabilityFilter = 0;
private int _sourceNodeIdFilter;
private int _destinationNodeIdFilter;
private List _nodes = new List();
@@ -364,8 +371,7 @@
private Dictionary _channelsColumns = new();
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();
- private DateTimeOffset _lastBalanceUpdate = DateTimeOffset.Now;
+ private Dictionary _channelsBalance = new();
public abstract class ChannelsColumnName
{
@@ -377,6 +383,7 @@
public static readonly ColumnDefault CAPACITY = new("Capacity (BTC)");
public static readonly ColumnDefault PRIVATE = new("Private");
public static readonly ColumnDefault CLOSE_ADDRESS = new("Close Address");
+ public static readonly ColumnDefault AVAILABILITY = new("Availability");
public static readonly ColumnDefault CHANNEL_BALANCE = new("Channel Balance");
public static readonly ColumnDefault CREATION_DATE = new("Creation Date");
public static readonly ColumnDefault UPDATE_DATE = new("Update Date");
@@ -410,7 +417,7 @@
_nodes = await NodeRepository.GetAll();
_wallets = await WalletRepository.GetAll();
_channelsDataGridRef?.FilterData();
- _channelsBalance = await LightningService.GetChannelsBalance();
+ _channelsBalance = await LightningService.GetChannelsStatus();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -430,7 +437,15 @@
private async Task ShowConfirmedClose(Channel? channel, bool forceClose = false)
{
- if (await MessageService.Confirm($"Are you sure you want to {(forceClose ? "force " : string.Empty)}close this channel?", "Confirmation"))
+ await RefreshChannelInformation();
+ var isChannelActive = GetAvailability(channel?.ChanId);
+ var preMessage = "";
+ if (forceClose && isChannelActive)
+ {
+ preMessage = "This channel is active!. ";
+ }
+
+ if (await MessageService.Confirm($"{preMessage}Are you sure you want to {(forceClose ? "force " : string.Empty)}close this channel?", "Confirmation"))
{
if (channel != null)
{
@@ -473,44 +488,18 @@
ToastService.ShowSuccess("Text copied");
}
- private async Task GetPercentageBalance(Channel channel)
+ private int GetPercentageBalance(Channel channel)
{
- //If the last update was more than 60 seconds ago, we update the balance dictionary
- if (DateTimeOffset.Now.Subtract(_lastBalanceUpdate).TotalSeconds > 60)
- {
- _channelsBalance = await LightningService.GetChannelsBalance();
- _lastBalanceUpdate = DateTimeOffset.Now;
- }
-
try
{
var result = -1.0;
if (_channelsBalance.TryGetValue(channel.ChanId, out var values))
{
- // Extract the managed node ID from the tuple
- var managedNodeId = values.Item1;
-
// Calculate the capacity as the sum of the second and third values in the tuple
- var capacity = values.Item2 + values.Item3;
-
- // Initialize the remote balance
- long remoteBalance;
-
- // Check if the source node ID of the channel is the same as the managed node ID
- // or if the source node is not managed
- // If either condition is true, set the remote balance to the third value in the tuple
- if (channel.SourceNodeId == managedNodeId || !channel.SourceNode.IsManaged)
- {
- remoteBalance = values.Item3;
- }
- else
- {
- // If neither condition is true, set the remote balance to the second value in the tuple
- remoteBalance = values.Item2;
- }
+ var capacity = values.LocalBalance + values.RemoteBalance;
// Calculate the result as the percentage of the remote balance to the capacity
- result = (remoteBalance / (double) capacity) * 100;
+ result = (values.RemoteBalance / (double) capacity) * 100;
result = Math.Round(result, 2);
@@ -526,6 +515,23 @@
}
}
+ private bool GetAvailability(ulong? channelId)
+ {
+ if (channelId == null) return false;
+ try
+ {
+ if (_channelsBalance.TryGetValue(channelId.Value, out var value))
+ {
+ return value.Active;
+ }
+ }
+ catch (Exception)
+ {
+ ToastService.ShowError($"Channel availability for channel id:{channelId} could not be retrieved");
+ }
+ return false;
+ }
+
private async Task OnSelectedWalletChanged(int arg)
{
if (_currentLiquidityRule != null) _currentLiquidityRule.WalletId = arg;
@@ -622,13 +628,13 @@
if (_channelLiquidityModal != null) await _channelLiquidityModal.Show();
}
- private async Task OnEnableLiquidityMgnmtChanged(bool enabledLiquidityMgnmt)
+ private async Task OnEnableLiquidityMngmtChanged(bool enabledLiquidityMngmt)
{
if (_selectedChannel != null)
{
- _selectedChannel.IsAutomatedLiquidityEnabled = enabledLiquidityMgnmt;
+ _selectedChannel.IsAutomatedLiquidityEnabled = enabledLiquidityMngmt;
- if (!enabledLiquidityMgnmt)
+ if (!enabledLiquidityMngmt)
{
_currentLiquidityRule = null;
}
@@ -823,10 +829,10 @@
private bool CheckDisableCloseChannelButton(Channel channel)
{
var lastRequest = channel.ChannelOperationRequests.LastOrDefault();
-
+
// If the channel is not created by the node guard, we dont disable the button
if (lastRequest == null || channel.CreatedByNodeGuard == false) return false;
-
+
// If the channel is created by the node guard, we disable the button if the channel is closed or if the last request is a close request and is confirmed or is pending confirmation
return channel.Status == Channel.ChannelStatus.Closed || (lastRequest.RequestType == OperationRequestType.Close
&& lastRequest.Status == ChannelOperationRequestStatus.OnChainConfirmed
@@ -847,4 +853,19 @@
return _channelsColumnLayout.IsColumnVisible(column);
}
+ private async Task RefreshChannelInformation()
+ {
+ _channelsBalance = await LightningService.GetChannelsStatus();
+ StateHasChanged();
+ }
+
+ private bool OnAvailabilityFilter(object? itemvalue, object? searchvalue)
+ {
+ if (searchvalue == null || (int)searchvalue == 0) return true;
+ var isActive = GetAvailability((ulong?)itemvalue);
+ if ((int)searchvalue == 1 && isActive) return true;
+ if ((int)searchvalue == -1 && !isActive) return true;
+ return false;
+ }
+
}
\ No newline at end of file
diff --git a/src/Services/LightningService.cs b/src/Services/LightningService.cs
index 511cceb3..2c4007cc 100644
--- a/src/Services/LightningService.cs
+++ b/src/Services/LightningService.cs
@@ -96,7 +96,7 @@ public interface ILightningService
/// Gets a dictionary of the local and remote balance of all the channels managed by NG
///
///
- public Task> GetChannelsBalance();
+ public Task> GetChannelsStatus();
///
/// Cancels a pending channel from LND PSBT-based funding of channels
@@ -1289,11 +1289,11 @@ public async Task CloseChannel(ChannelOperationRequest channelOperationRequest,
return await _lightningClientService.GetNodeInfo(node, pubkey);
}
- public async Task> GetChannelsBalance()
+ public async Task> GetChannelsStatus()
{
var nodes = await _nodeRepository.GetAllManagedByNodeGuard();
- var result = new Dictionary();
+ var result = new Dictionary();
foreach (var node in nodes)
{
var listChannelsResponse = await _lightningClientService.ListChannels(node);
@@ -1302,14 +1302,31 @@ public async Task CloseChannel(ChannelOperationRequest channelOperationRequest,
foreach (var channel in channels)
{
if (channel == null) continue;
+ // If the source node is not the channel initiator, but the remote node is also managed by NodeGuard
+ // We skip and wait for the other node to report the channel
+ if (nodes.Any((n) => !channel.Initiator && n.PubKey == channel.RemotePubkey)) 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);
- //The nodeguard sided node is the one that is managed by nodeguard
- var nodeguardManagedNodeId = node.Id;
- result.TryAdd(channel.ChanId,
- (nodeguardManagedNodeId, channel.LocalBalance + htlcsLocal, channel.RemoteBalance + htlcsRemote));
+ var localBalance = channel.LocalBalance + htlcsLocal;
+ var remoteBalance = channel.RemoteBalance + htlcsRemote;
+
+ // If the channel is not initiated by a NodeGuard node, we need to swap the balances.
+ // the balance is always shown from the NodeGuard's perspective
+ if (!channel.Initiator)
+ {
+ localBalance = channel.RemoteBalance + htlcsRemote;
+ remoteBalance = channel.LocalBalance + htlcsLocal;
+ }
+
+
+ result.TryAdd(channel.ChanId, new ChannelStatus()
+ {
+ LocalBalance = localBalance,
+ RemoteBalance = remoteBalance,
+ Active = channel.Active
+ });
}
}
diff --git a/test/NodeGuard.Tests/Services/LightningServiceTests.cs b/test/NodeGuard.Tests/Services/LightningServiceTests.cs
index b30c7a4d..b346c7c8 100644
--- a/test/NodeGuard.Tests/Services/LightningServiceTests.cs
+++ b/test/NodeGuard.Tests/Services/LightningServiceTests.cs
@@ -1577,5 +1577,227 @@ public async Task CloseChannel_Succeeds()
CloseAddress = "bcrt1q590shaxaf5u08ml8jwlzghz99dup3z9592vxal"
});
}
+
+ [Fact]
+ public async Task GetChannelsStatus_SourceNodeIsManaged_SourceIsInitiator()
+ {
+ // Arrange
+ var nodeRepository = new Mock();
+ var lightningClientService = new Mock();
+
+ nodeRepository.Setup(x => x.GetAllManagedByNodeGuard()).ReturnsAsync(
+ new List()
+ {
+ new()
+ {
+ Id = 1,
+ PubKey = "managedPubKey",
+ Endpoint = "abc", // Is Managed
+ }
+ });
+
+ var listChannelsResponse = new ListChannelsResponse
+ {
+ Channels =
+ {
+ new Lnrpc.Channel
+ {
+ ChanId = 0,
+ LocalBalance = 500,
+ RemoteBalance = 0,
+ Initiator = true,
+ RemotePubkey = "externalPubKey"
+ }
+ }
+ };
+
+ lightningClientService.Setup(x => x.ListChannels(It.IsAny(), null)).ReturnsAsync(listChannelsResponse);
+ var lightningService = new LightningService(null, null, nodeRepository.Object, null, null, null, null, null ,null, lightningClientService.Object);
+
+ // Act
+ var channelStatus = await lightningService.GetChannelsStatus();
+
+ // Assert
+ channelStatus[0].LocalBalance.Should().Be(500);
+ channelStatus[0].RemoteBalance.Should().Be(0);
+ }
+
+ [Fact]
+ public async Task GetChannelsStatus_SourceNodeIsManaged_SourceIsNotInitiator()
+ {
+ // Arrange
+ var nodeRepository = new Mock();
+ var lightningClientService = new Mock();
+
+ nodeRepository.Setup(x => x.GetAllManagedByNodeGuard()).ReturnsAsync(
+ new List()
+ {
+ new()
+ {
+ Id = 1,
+ PubKey = "managedPubKey",
+ Endpoint = "abc", // Is Managed
+ }
+ });
+
+ var listChannelsResponse = new ListChannelsResponse
+ {
+ Channels =
+ {
+ new Lnrpc.Channel
+ {
+ ChanId = 0,
+ LocalBalance = 500,
+ RemoteBalance = 0,
+ Initiator = false,
+ RemotePubkey = "externalPubKey"
+ }
+ }
+ };
+
+ lightningClientService.Setup(x => x.ListChannels(It.IsAny(), null)).ReturnsAsync(listChannelsResponse);
+ var lightningService = new LightningService(null, null, nodeRepository.Object, null, null, null, null, null ,null, lightningClientService.Object);
+
+ // Act
+ var channelStatus = await lightningService.GetChannelsStatus();
+
+ // Assert
+ channelStatus[0].LocalBalance.Should().Be(0);
+ channelStatus[0].RemoteBalance.Should().Be(500);
+ }
+
+ [Fact]
+ public async Task GetChannelsStatus_BothNodesAreManaged_SourceIsInitiator()
+ {
+ // Arrange
+ var nodeRepository = new Mock();
+ var lightningClientService = new Mock();
+
+ nodeRepository.Setup(x => x.GetAllManagedByNodeGuard()).ReturnsAsync(
+ new List()
+ {
+ new()
+ {
+ Id = 1,
+ Endpoint = "abc", // Is Managed
+ PubKey = "managedPubKey1",
+ },
+ new()
+ {
+ Id = 2,
+ Endpoint = "abc", // Is Managed
+ PubKey = "managedPubKey2",
+ }
+ });
+
+ var listChannelsResponse1 = new ListChannelsResponse
+ {
+ Channels =
+ {
+ new Lnrpc.Channel
+ {
+ ChanId = 0,
+ LocalBalance = 500,
+ RemoteBalance = 0,
+ Initiator = true,
+ RemotePubkey = "managedPubKey2"
+ }
+ }
+ };
+
+ var listChannelsResponse2 = new ListChannelsResponse
+ {
+ Channels =
+ {
+ new Lnrpc.Channel
+ {
+ ChanId = 0,
+ LocalBalance = 0,
+ RemoteBalance = 500,
+ Initiator = false,
+ RemotePubkey = "managedPubKey1"
+ }
+ }
+ };
+
+ lightningClientService.SetupSequence(x => x.ListChannels(It.IsAny(), null))
+ .ReturnsAsync(listChannelsResponse1)
+ .ReturnsAsync(listChannelsResponse2);
+ var lightningService = new LightningService(null, null, nodeRepository.Object, null, null, null, null, null ,null, lightningClientService.Object);
+
+ // Act
+ var channelStatus = await lightningService.GetChannelsStatus();
+
+ // Assert
+ channelStatus[0].LocalBalance.Should().Be(500);
+ channelStatus[0].RemoteBalance.Should().Be(0);
+ }
+
+ [Fact]
+ public async Task GetChannelsStatus_BothNodesAreManaged_SourceIsNotInitiator()
+ {
+ // Arrange
+ var nodeRepository = new Mock();
+ var lightningClientService = new Mock();
+
+ nodeRepository.Setup(x => x.GetAllManagedByNodeGuard()).ReturnsAsync(
+ new List()
+ {
+ new()
+ {
+ Id = 1,
+ Endpoint = "abc", // Is Managed
+ PubKey = "managedPubKey1",
+ },
+ new()
+ {
+ Id = 2,
+ Endpoint = "abc", // Is Managed
+ PubKey = "managedPubKey2",
+ }
+ });
+
+ var listChannelsResponse1 = new ListChannelsResponse
+ {
+ Channels =
+ {
+ new Lnrpc.Channel
+ {
+ ChanId = 0,
+ LocalBalance = 0,
+ RemoteBalance = 500,
+ Initiator = false,
+ RemotePubkey = "managedPubKey2"
+ }
+ }
+ };
+
+ var listChannelsResponse2 = new ListChannelsResponse
+ {
+ Channels =
+ {
+ new Lnrpc.Channel
+ {
+ ChanId = 0,
+ LocalBalance = 500,
+ RemoteBalance = 0,
+ Initiator = true,
+ RemotePubkey = "managedPubKey1"
+ }
+ }
+ };
+
+ lightningClientService.SetupSequence(x => x.ListChannels(It.IsAny(), null))
+ .ReturnsAsync(listChannelsResponse1)
+ .ReturnsAsync(listChannelsResponse2);
+ var lightningService = new LightningService(null, null, nodeRepository.Object, null, null, null, null, null ,null, lightningClientService.Object);
+
+ // Act
+ var channelStatus = await lightningService.GetChannelsStatus();
+
+ // Assert
+ channelStatus[0].LocalBalance.Should().Be(500);
+ channelStatus[0].RemoteBalance.Should().Be(0);
+ }
}
}
\ No newline at end of file