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