From cc1aa3d178aaa848d523362e033955ed8197b26d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADckolas=20Goline?= Date: Thu, 2 Oct 2025 10:56:19 -0300 Subject: [PATCH 01/20] add client to communicate with the daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Níckolas Goline --- NLightning.sln | 52 ++++++- .../Ipc/NamedPipeIpcClient.cs | 128 ++++++++++++++++ .../NLightning.Client.csproj | 19 +++ src/NLightning.Client/Program.cs | 63 ++++++++ src/NLightning.Client/Utils/ClientUtils.cs | 25 +++ .../Constants/NodeConstants.cs | 10 ++ .../Control/NodeInfo.cs | 14 ++ .../Helpers/CommandLineHelper.cs | 75 +++++++++ .../IControlClient.cs | 8 + .../NLightning.Daemon.Contracts.csproj | 17 +++ .../Utilities/ConsoleUtils.cs | 5 +- .../Utilities/NodeUtils.cs | 28 ++++ .../IDaemonContext.cs | 14 ++ .../IDaemonPlugin.cs | 8 + .../NLightning.Daemon.Plugins.csproj | 14 ++ .../AssemblyInfo.cs | 0 .../Extensions/DatabaseExtensions.cs | 2 +- .../Extensions/NodeConfigurationExtensions.cs | 2 +- .../Extensions/NodeServiceExtensions.cs | 60 +++++--- .../GlobalUsings.cs | 0 .../Handlers/NodeInfoIpcHandler.cs | 32 ++++ .../Helpers/AesGcmHelper.cs | 2 +- .../Helpers/ClassNameEnricher.cs | 2 +- .../Interfaces/IIpcAuthenticator.cs | 6 + .../Interfaces/IIpcCommandHandler.cs | 9 ++ .../Interfaces/IIpcFraming.cs | 9 ++ .../Interfaces/IIpcRequestRouter.cs | 8 + .../Interfaces/INodeInfoQueryService.cs | 7 + .../Models/FeeRateCacheData.cs | 11 ++ src/NLightning.Daemon/Models/PluginEntry.cs | 8 + .../NLightning.Daemon.csproj} | 4 + .../Program.cs | 13 +- .../Services/Ipc/CookieFileAuthenticator.cs | 44 ++++++ .../Services/Ipc/IpcFraming.cs | 52 +++++++ .../Services/Ipc/IpcRouting.cs | 53 +++++++ .../Services/Ipc/NamedPipeIpcHostedService.cs | 144 ++++++++++++++++++ .../Services/NltgDaemonService.cs | 2 +- .../Services/NodeInfoQueryService.cs | 60 ++++++++ .../Services/PluginLoaderService.cs | 72 +++++++++ .../Utilities/DaemonUtils.cs | 84 +++++++--- src/NLightning.Infrastructure/AssemblyInfo.cs | 2 +- .../Constants/DaemonConstants.cs | 8 - .../Helpers/CommandLineHelper.cs | 98 ------------ .../Models/FeeRateCacheData.cs | 13 -- src/NLightning.Transport.Ipc/Contracts.cs | 66 ++++++++ .../NLightning.Transport.Ipc.csproj | 10 ++ .../GlobalUsings.cs | 0 .../Models/FeeRateCacheDataTests.cs | 4 +- .../NLightning.Daemon.Tests.csproj | 24 +++ .../Services/FeeServiceTests.cs | 2 +- .../TestCollections/SerialTestCollection.cs | 2 +- .../coverlet.runsettings | 0 .../NLightning.Node.Tests.csproj | 33 +--- 53 files changed, 1216 insertions(+), 212 deletions(-) create mode 100644 src/NLightning.Client/Ipc/NamedPipeIpcClient.cs create mode 100644 src/NLightning.Client/NLightning.Client.csproj create mode 100644 src/NLightning.Client/Program.cs create mode 100644 src/NLightning.Client/Utils/ClientUtils.cs create mode 100644 src/NLightning.Daemon.Contracts/Constants/NodeConstants.cs create mode 100644 src/NLightning.Daemon.Contracts/Control/NodeInfo.cs create mode 100644 src/NLightning.Daemon.Contracts/Helpers/CommandLineHelper.cs create mode 100644 src/NLightning.Daemon.Contracts/IControlClient.cs create mode 100644 src/NLightning.Daemon.Contracts/NLightning.Daemon.Contracts.csproj rename src/{NLightning.Node => NLightning.Daemon.Contracts}/Utilities/ConsoleUtils.cs (85%) create mode 100644 src/NLightning.Daemon.Contracts/Utilities/NodeUtils.cs create mode 100644 src/NLightning.Daemon.Plugins/IDaemonContext.cs create mode 100644 src/NLightning.Daemon.Plugins/IDaemonPlugin.cs create mode 100644 src/NLightning.Daemon.Plugins/NLightning.Daemon.Plugins.csproj rename src/{NLightning.Node => NLightning.Daemon}/AssemblyInfo.cs (100%) rename src/{NLightning.Node => NLightning.Daemon}/Extensions/DatabaseExtensions.cs (97%) rename src/{NLightning.Node => NLightning.Daemon}/Extensions/NodeConfigurationExtensions.cs (99%) rename src/{NLightning.Node => NLightning.Daemon}/Extensions/NodeServiceExtensions.cs (73%) rename src/{NLightning.Node => NLightning.Daemon}/GlobalUsings.cs (100%) create mode 100644 src/NLightning.Daemon/Handlers/NodeInfoIpcHandler.cs rename src/{NLightning.Node => NLightning.Daemon}/Helpers/AesGcmHelper.cs (97%) rename src/{NLightning.Node => NLightning.Daemon}/Helpers/ClassNameEnricher.cs (94%) create mode 100644 src/NLightning.Daemon/Interfaces/IIpcAuthenticator.cs create mode 100644 src/NLightning.Daemon/Interfaces/IIpcCommandHandler.cs create mode 100644 src/NLightning.Daemon/Interfaces/IIpcFraming.cs create mode 100644 src/NLightning.Daemon/Interfaces/IIpcRequestRouter.cs create mode 100644 src/NLightning.Daemon/Interfaces/INodeInfoQueryService.cs create mode 100644 src/NLightning.Daemon/Models/FeeRateCacheData.cs create mode 100644 src/NLightning.Daemon/Models/PluginEntry.cs rename src/{NLightning.Node/NLightning.Node.csproj => NLightning.Daemon/NLightning.Daemon.csproj} (88%) rename src/{NLightning.Node => NLightning.Daemon}/Program.cs (95%) create mode 100644 src/NLightning.Daemon/Services/Ipc/CookieFileAuthenticator.cs create mode 100644 src/NLightning.Daemon/Services/Ipc/IpcFraming.cs create mode 100644 src/NLightning.Daemon/Services/Ipc/IpcRouting.cs create mode 100644 src/NLightning.Daemon/Services/Ipc/NamedPipeIpcHostedService.cs rename src/{NLightning.Node => NLightning.Daemon}/Services/NltgDaemonService.cs (98%) create mode 100644 src/NLightning.Daemon/Services/NodeInfoQueryService.cs create mode 100644 src/NLightning.Daemon/Services/PluginLoaderService.cs rename src/{NLightning.Node => NLightning.Daemon}/Utilities/DaemonUtils.cs (76%) delete mode 100644 src/NLightning.Node/Constants/DaemonConstants.cs delete mode 100644 src/NLightning.Node/Helpers/CommandLineHelper.cs delete mode 100644 src/NLightning.Node/Models/FeeRateCacheData.cs create mode 100644 src/NLightning.Transport.Ipc/Contracts.cs create mode 100644 src/NLightning.Transport.Ipc/NLightning.Transport.Ipc.csproj rename test/{NLightning.Node.Tests => NLightning.Daemon.Tests}/GlobalUsings.cs (100%) rename test/{NLightning.Node.Tests => NLightning.Daemon.Tests}/Models/FeeRateCacheDataTests.cs (95%) create mode 100644 test/NLightning.Daemon.Tests/NLightning.Daemon.Tests.csproj rename test/{NLightning.Node.Tests => NLightning.Daemon.Tests}/Services/FeeServiceTests.cs (99%) rename test/{NLightning.Node.Tests => NLightning.Daemon.Tests}/TestCollections/SerialTestCollection.cs (73%) rename test/{NLightning.Node.Tests => NLightning.Daemon.Tests}/coverlet.runsettings (100%) diff --git a/NLightning.sln b/NLightning.sln index 4074123b..2bf69ff7 100644 --- a/NLightning.sln +++ b/NLightning.sln @@ -8,7 +8,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{123D0631-533 src\Directory.Build.props = src\Directory.Build.props EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NLightning.Node", "src\NLightning.Node\NLightning.Node.csproj", "{A103C727-E983-4510-81FB-301625DC1A7F}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NLightning.Daemon", "src\NLightning.Daemon\NLightning.Daemon.csproj", "{A103C727-E983-4510-81FB-301625DC1A7F}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{AF4411D4-8EE9-423E-8213-1C9D35E47882}" ProjectSection(SolutionItems) = preProject @@ -58,7 +58,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NLightning.Bolt11.Tests", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NLightning.Infrastructure.Serialization.Tests", "test\NLightning.Infrastructure.Serialization.Tests\NLightning.Infrastructure.Serialization.Tests.csproj", "{4550DC12-8EE8-4C35-B438-873EE128DA1A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NLightning.Node.Tests", "test\NLightning.Node.Tests\NLightning.Node.Tests.csproj", "{BC559AD8-72B9-4ABF-A7FF-6305E141AB62}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NLightning.Daemon.Tests", "test\NLightning.Daemon.Tests\NLightning.Daemon.Tests.csproj", "{BC559AD8-72B9-4ABF-A7FF-6305E141AB62}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NLightning.Infrastructure.Repositories", "src\NLightning.Infrastructure.Repositories\NLightning.Infrastructure.Repositories.csproj", "{02639428-3F4E-43A9-9585-A5D90EDCA1FF}" EndProject @@ -118,6 +118,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "coverage-reports", "coverag EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NLightning.Application.Tests", "test\NLightning.Application.Tests\NLightning.Application.Tests.csproj", "{D6EDE7E1-47E4-452C-B36D-D6B0D63959AD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NLightning.Daemon.Contracts", "src\NLightning.Daemon.Contracts\NLightning.Daemon.Contracts.csproj", "{5DC7356B-99D1-44BD-A134-66D1E111D764}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NLightning.Client", "src\NLightning.Client\NLightning.Client.csproj", "{46962F7F-95FB-484C-89DE-0684D03C7845}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NLightning.Daemon.Plugins", "src\NLightning.Daemon.Plugins\NLightning.Daemon.Plugins.csproj", "{0756C587-913D-41B0-9745-20760612FD41}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NLightning.Transport.Ipc", "src\NLightning.Transport.Ipc\NLightning.Transport.Ipc.csproj", "{7C2E2B7B-0C22-4B31-8E1E-6C5E2A2B5E1C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Release|Any CPU = Release|Any CPU @@ -360,6 +368,42 @@ Global {D6EDE7E1-47E4-452C-B36D-D6B0D63959AD}.Debug.Native|Any CPU.Build.0 = Debug|Any CPU {D6EDE7E1-47E4-452C-B36D-D6B0D63959AD}.Debug.Wasm|Any CPU.ActiveCfg = Debug|Any CPU {D6EDE7E1-47E4-452C-B36D-D6B0D63959AD}.Debug.Wasm|Any CPU.Build.0 = Debug|Any CPU + {5DC7356B-99D1-44BD-A134-66D1E111D764}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5DC7356B-99D1-44BD-A134-66D1E111D764}.Release|Any CPU.Build.0 = Release|Any CPU + {5DC7356B-99D1-44BD-A134-66D1E111D764}.Release.Native|Any CPU.ActiveCfg = Debug|Any CPU + {5DC7356B-99D1-44BD-A134-66D1E111D764}.Release.Native|Any CPU.Build.0 = Debug|Any CPU + {5DC7356B-99D1-44BD-A134-66D1E111D764}.Release.Wasm|Any CPU.ActiveCfg = Debug|Any CPU + {5DC7356B-99D1-44BD-A134-66D1E111D764}.Release.Wasm|Any CPU.Build.0 = Debug|Any CPU + {5DC7356B-99D1-44BD-A134-66D1E111D764}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5DC7356B-99D1-44BD-A134-66D1E111D764}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5DC7356B-99D1-44BD-A134-66D1E111D764}.Debug.Native|Any CPU.ActiveCfg = Debug|Any CPU + {5DC7356B-99D1-44BD-A134-66D1E111D764}.Debug.Native|Any CPU.Build.0 = Debug|Any CPU + {5DC7356B-99D1-44BD-A134-66D1E111D764}.Debug.Wasm|Any CPU.ActiveCfg = Debug|Any CPU + {5DC7356B-99D1-44BD-A134-66D1E111D764}.Debug.Wasm|Any CPU.Build.0 = Debug|Any CPU + {46962F7F-95FB-484C-89DE-0684D03C7845}.Release|Any CPU.ActiveCfg = Release|Any CPU + {46962F7F-95FB-484C-89DE-0684D03C7845}.Release|Any CPU.Build.0 = Release|Any CPU + {46962F7F-95FB-484C-89DE-0684D03C7845}.Release.Native|Any CPU.ActiveCfg = Debug|Any CPU + {46962F7F-95FB-484C-89DE-0684D03C7845}.Release.Native|Any CPU.Build.0 = Debug|Any CPU + {46962F7F-95FB-484C-89DE-0684D03C7845}.Release.Wasm|Any CPU.ActiveCfg = Debug|Any CPU + {46962F7F-95FB-484C-89DE-0684D03C7845}.Release.Wasm|Any CPU.Build.0 = Debug|Any CPU + {46962F7F-95FB-484C-89DE-0684D03C7845}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {46962F7F-95FB-484C-89DE-0684D03C7845}.Debug|Any CPU.Build.0 = Debug|Any CPU + {46962F7F-95FB-484C-89DE-0684D03C7845}.Debug.Native|Any CPU.ActiveCfg = Debug|Any CPU + {46962F7F-95FB-484C-89DE-0684D03C7845}.Debug.Native|Any CPU.Build.0 = Debug|Any CPU + {46962F7F-95FB-484C-89DE-0684D03C7845}.Debug.Wasm|Any CPU.ActiveCfg = Debug|Any CPU + {46962F7F-95FB-484C-89DE-0684D03C7845}.Debug.Wasm|Any CPU.Build.0 = Debug|Any CPU + {0756C587-913D-41B0-9745-20760612FD41}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0756C587-913D-41B0-9745-20760612FD41}.Release|Any CPU.Build.0 = Release|Any CPU + {0756C587-913D-41B0-9745-20760612FD41}.Release.Native|Any CPU.ActiveCfg = Debug|Any CPU + {0756C587-913D-41B0-9745-20760612FD41}.Release.Native|Any CPU.Build.0 = Debug|Any CPU + {0756C587-913D-41B0-9745-20760612FD41}.Release.Wasm|Any CPU.ActiveCfg = Debug|Any CPU + {0756C587-913D-41B0-9745-20760612FD41}.Release.Wasm|Any CPU.Build.0 = Debug|Any CPU + {0756C587-913D-41B0-9745-20760612FD41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0756C587-913D-41B0-9745-20760612FD41}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0756C587-913D-41B0-9745-20760612FD41}.Debug.Native|Any CPU.ActiveCfg = Debug|Any CPU + {0756C587-913D-41B0-9745-20760612FD41}.Debug.Native|Any CPU.Build.0 = Debug|Any CPU + {0756C587-913D-41B0-9745-20760612FD41}.Debug.Wasm|Any CPU.ActiveCfg = Debug|Any CPU + {0756C587-913D-41B0-9745-20760612FD41}.Debug.Wasm|Any CPU.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -397,5 +441,9 @@ Global {18F1E97C-8546-4359-93B3-8D1F5B1CC4B4} = {735305B0-B08D-4C48-A1DE-47E8DC2D8032} {02639428-3F4E-43A9-9585-A5D90EDCA1FF} = {123D0631-533C-447F-B7DA-D1D37E5E64BF} {D6EDE7E1-47E4-452C-B36D-D6B0D63959AD} = {AF4411D4-8EE9-423E-8213-1C9D35E47882} + {5DC7356B-99D1-44BD-A134-66D1E111D764} = {123D0631-533C-447F-B7DA-D1D37E5E64BF} + {46962F7F-95FB-484C-89DE-0684D03C7845} = {123D0631-533C-447F-B7DA-D1D37E5E64BF} + {0756C587-913D-41B0-9745-20760612FD41} = {123D0631-533C-447F-B7DA-D1D37E5E64BF} + {7C2E2B7B-0C22-4B31-8E1E-6C5E2A2B5E1C} = {123D0631-533C-447F-B7DA-D1D37E5E64BF} EndGlobalSection EndGlobal diff --git a/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs b/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs new file mode 100644 index 00000000..8882aba2 --- /dev/null +++ b/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs @@ -0,0 +1,128 @@ +using System.Buffers; +using System.IO.Pipes; +using MessagePack; +using NLightning.Daemon.Contracts.Control; + +namespace NLightning.Client.Ipc; + +using Daemon.Contracts; +using Transport.Ipc; + +public sealed class NamedPipeIpcClient : IControlClient, IAsyncDisposable +{ + private readonly string _namedPipeFilePath; + private readonly string _cookieFilePath; + private readonly string? _server; + + public NamedPipeIpcClient(string namedPipeFilePath, string cookieFilePath, string? server = ".") + { + _namedPipeFilePath = namedPipeFilePath; + _cookieFilePath = cookieFilePath; + _server = server; + } + + public async Task GetNodeInfoAsync(CancellationToken ct = default) + { + var req = new NodeInfoRequest(); + var payload = MessagePackSerializer.Serialize(req, cancellationToken: ct); + var env = new IpcEnvelope + { + Version = 1, + Command = NodeIpcCommand.NodeInfo, + CorrelationId = Guid.NewGuid(), + AuthToken = await GetAuthTokenAsync(ct), + Payload = payload, + Kind = 0 + }; + + var respEnv = await SendAsync(env, ct); + if (respEnv.Kind != 2) + { + var transport = + MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); + return new NodeInfoResponse + { + Network = transport.Network, + BestBlockHash = transport.BestBlockHash, + BestBlockHeight = transport.BestBlockHeight, + BestBlockTime = transport.BestBlockTime, + Implementation = transport.Implementation, + Version = transport.Version + }; + } + + var err = MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); + throw new InvalidOperationException($"IPC error {err.Code}: {err.Message}"); + } + + private async Task SendAsync(IpcEnvelope envelope, CancellationToken ct) + { + await using var client = + new NamedPipeClientStream(_server, _namedPipeFilePath, PipeDirection.InOut, PipeOptions.Asynchronous); + + try + { + await client.ConnectAsync(TimeSpan.FromSeconds(2), ct); + } + catch (TimeoutException) + { + throw new IOException( + "Could not connect to NLightning node IPC pipe. Ensure the node is running and listening for IPC."); + } + + // Send request + var bytes = MessagePackSerializer.Serialize(envelope, cancellationToken: ct); + var lenPrefix = BitConverter.GetBytes(bytes.Length); + await client.WriteAsync(lenPrefix, ct); + await client.WriteAsync(bytes, ct); + await client.FlushAsync(ct); + + // Read response length + var header = new byte[4]; + await ReadExactAsync(client, header, ct); + var respLen = BitConverter.ToInt32(header, 0); + if (respLen is <= 0 or > 10_000_000) + throw new IOException("Invalid IPC response length."); + + // Read payload + var respBuf = ArrayPool.Shared.Rent(respLen); + try + { + await ReadExactAsync(client, respBuf.AsMemory(0, respLen), ct); + var env = MessagePackSerializer.Deserialize(respBuf.AsMemory(0, respLen), + cancellationToken: ct); + return env; + } + finally + { + ArrayPool.Shared.Return(respBuf); + } + } + + private static async Task ReadExactAsync(Stream stream, Memory buffer, CancellationToken ct) + { + var total = 0; + while (total < buffer.Length) + { + var read = await stream.ReadAsync(buffer[total..], ct); + if (read == 0) throw new EndOfStreamException(); + total += read; + } + } + + private static async Task ReadExactAsync(Stream stream, byte[] buffer, CancellationToken ct) + => await ReadExactAsync(stream, buffer.AsMemory(), ct); + + private async Task GetAuthTokenAsync(CancellationToken ct) + { + if (!File.Exists(_cookieFilePath)) + throw new IOException( + "Authentication cookie file not found. Ensure the node is running and the cookie file path is correct."); + + var content = await File.ReadAllTextAsync(_cookieFilePath, ct); + return content.Trim(); + } + + public ValueTask DisposeAsync() + => ValueTask.CompletedTask; +} \ No newline at end of file diff --git a/src/NLightning.Client/NLightning.Client.csproj b/src/NLightning.Client/NLightning.Client.csproj new file mode 100644 index 00000000..879e95a3 --- /dev/null +++ b/src/NLightning.Client/NLightning.Client.csproj @@ -0,0 +1,19 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + + diff --git a/src/NLightning.Client/Program.cs b/src/NLightning.Client/Program.cs new file mode 100644 index 00000000..c021ed5b --- /dev/null +++ b/src/NLightning.Client/Program.cs @@ -0,0 +1,63 @@ +using NLightning.Client.Ipc; +using NLightning.Client.Utils; +using NLightning.Daemon.Contracts.Control; +using NLightning.Daemon.Contracts.Helpers; +using NLightning.Daemon.Contracts.Utilities; + +var cts = new CancellationTokenSource(); +Console.CancelKeyPress += (_, e) => +{ + e.Cancel = true; + cts.Cancel(); +}; + +// Get network for the NamedPipe file path +var network = CommandLineHelper.GetNetwork(args); +var namedPipeFilePath = NodeUtils.GetNamedPipeFilePath(network); +var cookieFilePath = NodeUtils.GetCookieFilePath(network); + +var cmd = CommandLineHelper.GetCommand(args) ?? "node-info"; + +try +{ + if (CommandLineHelper.IsHelpRequested(args)) + { + ClientUtils.ShowUsage(); + return 0; + } + + await using var client = new NamedPipeIpcClient(namedPipeFilePath, cookieFilePath); + + switch (cmd) + { + case "node-info": + case "info": + var info = await client.GetNodeInfoAsync(cts.Token); + PrintNodeInfo(info); + break; + default: + Console.Error.WriteLine($"Unknown command: {cmd}"); + ClientUtils.ShowUsage(); + Environment.ExitCode = 2; + break; + } +} +catch (Exception ex) +{ + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; +} + +return 0; + +static void PrintNodeInfo(NodeInfoResponse info) +{ + Console.WriteLine("Node Information:"); + Console.WriteLine($" Network: {info.Network}"); + Console.WriteLine($" Best Block Height: {info.BestBlockHeight}"); + Console.WriteLine($" Best Block Hash: {info.BestBlockHash}"); + if (info.BestBlockTime is not null) + Console.WriteLine($" Best Block Time: {info.BestBlockTime:O}"); + Console.WriteLine($" Implementation: {info.Implementation}"); + Console.WriteLine($" Version: {info.Version}"); +} \ No newline at end of file diff --git a/src/NLightning.Client/Utils/ClientUtils.cs b/src/NLightning.Client/Utils/ClientUtils.cs new file mode 100644 index 00000000..c526f339 --- /dev/null +++ b/src/NLightning.Client/Utils/ClientUtils.cs @@ -0,0 +1,25 @@ +namespace NLightning.Client.Utils; + +public static class ClientUtils +{ + public static void ShowUsage() + { + Console.WriteLine("NLightning Node Client"); + Console.WriteLine("Usage:"); + Console.WriteLine(" nltg [options] [command]"); + Console.WriteLine(); + Console.WriteLine("Options:"); + Console.WriteLine(" --network, -n Network to use (mainnet, testnet, regtest) [default: mainnet]"); + Console.WriteLine(" --cookie, -c Path to cookie file"); + Console.WriteLine(); + Console.WriteLine("Commands:"); + Console.WriteLine(" node-info | info Get node information via IPC"); + Console.WriteLine(" --help, -h, -? Show this help message"); + Console.WriteLine(); + Console.WriteLine("Environment Variables:"); + Console.WriteLine(" NLTG_NETWORK Network to use"); + Console.WriteLine(" NLTG_COOKIE Path to cookie file"); + Console.WriteLine(); + Console.WriteLine("Cookie file location: ~/.nltg/{network}/nltg.ipc"); + } +} \ No newline at end of file diff --git a/src/NLightning.Daemon.Contracts/Constants/NodeConstants.cs b/src/NLightning.Daemon.Contracts/Constants/NodeConstants.cs new file mode 100644 index 00000000..7a779118 --- /dev/null +++ b/src/NLightning.Daemon.Contracts/Constants/NodeConstants.cs @@ -0,0 +1,10 @@ +namespace NLightning.Daemon.Contracts.Constants; + +public static class NodeConstants +{ + public const string DaemonFolder = "nltg"; + public const string KeyFile = "nltg.key.json"; + public const string PidFile = "nltg.pid"; + public const string NamedPipeFile = "nltg.ipc"; + public const string CookieFile = "nltg.cookie"; +} \ No newline at end of file diff --git a/src/NLightning.Daemon.Contracts/Control/NodeInfo.cs b/src/NLightning.Daemon.Contracts/Control/NodeInfo.cs new file mode 100644 index 00000000..dbe506c8 --- /dev/null +++ b/src/NLightning.Daemon.Contracts/Control/NodeInfo.cs @@ -0,0 +1,14 @@ +namespace NLightning.Daemon.Contracts.Control; + +/// +/// Transport-agnostic response for NodeInfo command. +/// +public sealed class NodeInfoResponse +{ + public string Network { get; init; } = string.Empty; + public string BestBlockHash { get; init; } = string.Empty; + public long BestBlockHeight { get; init; } + public DateTimeOffset? BestBlockTime { get; init; } + public string? Implementation { get; init; } = "NLightning"; + public string? Version { get; init; } +} \ No newline at end of file diff --git a/src/NLightning.Daemon.Contracts/Helpers/CommandLineHelper.cs b/src/NLightning.Daemon.Contracts/Helpers/CommandLineHelper.cs new file mode 100644 index 00000000..5ea7ec47 --- /dev/null +++ b/src/NLightning.Daemon.Contracts/Helpers/CommandLineHelper.cs @@ -0,0 +1,75 @@ +namespace NLightning.Daemon.Contracts.Helpers; + +/// +/// Helper class for displaying command line usage information +/// +public static class CommandLineHelper +{ + /// + /// Parse command line arguments to check for help request + /// + public static bool IsHelpRequested(string[] args) + { + return args.Any(arg => + arg.Equals("--help", StringComparison.OrdinalIgnoreCase) + || arg.Equals("-h", StringComparison.OrdinalIgnoreCase)); + } + + public static string? GetCommand(string[] args) + { + for (var i = 0; i < args.Length; i++) + { + if (args[i].StartsWith("-n") + || args[i].StartsWith("--network") + || args[i].StartsWith("-c") + || args[i].StartsWith("--cookie")) + { + i++; + continue; + } + + if (args[i].StartsWith('-') || args[i].StartsWith("--")) + continue; + + return args[i].ToLowerInvariant(); + } + + return null; + } + + public static string GetNetwork(string[] args) + { + var network = "mainnet"; // Default + + // Check command line args + for (var i = 0; i < args.Length; i++) + { + if (args[i].Equals("--network", StringComparison.OrdinalIgnoreCase) || + args[i].Equals("-n", StringComparison.OrdinalIgnoreCase)) + { + if (i + 1 < args.Length) + { + network = args[i + 1]; + break; + } + } + + if (!args[i].StartsWith("--network=", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + network = args[i]["--network=".Length..]; + break; + } + + // Check environment variable if not found in args + var envNetwork = Environment.GetEnvironmentVariable("NLTG_NETWORK"); + if (!string.IsNullOrEmpty(envNetwork)) + { + network = envNetwork; + } + + return network; + } +} \ No newline at end of file diff --git a/src/NLightning.Daemon.Contracts/IControlClient.cs b/src/NLightning.Daemon.Contracts/IControlClient.cs new file mode 100644 index 00000000..84b093c0 --- /dev/null +++ b/src/NLightning.Daemon.Contracts/IControlClient.cs @@ -0,0 +1,8 @@ +using NLightning.Daemon.Contracts.Control; + +namespace NLightning.Daemon.Contracts; + +public interface IControlClient +{ + Task GetNodeInfoAsync(CancellationToken ct = default); +} \ No newline at end of file diff --git a/src/NLightning.Daemon.Contracts/NLightning.Daemon.Contracts.csproj b/src/NLightning.Daemon.Contracts/NLightning.Daemon.Contracts.csproj new file mode 100644 index 00000000..52ee252c --- /dev/null +++ b/src/NLightning.Daemon.Contracts/NLightning.Daemon.Contracts.csproj @@ -0,0 +1,17 @@ + + + + net9.0 + latest + enable + enable + + + + + + + + + + diff --git a/src/NLightning.Node/Utilities/ConsoleUtils.cs b/src/NLightning.Daemon.Contracts/Utilities/ConsoleUtils.cs similarity index 85% rename from src/NLightning.Node/Utilities/ConsoleUtils.cs rename to src/NLightning.Daemon.Contracts/Utilities/ConsoleUtils.cs index 5c3675c0..5b79d298 100644 --- a/src/NLightning.Node/Utilities/ConsoleUtils.cs +++ b/src/NLightning.Daemon.Contracts/Utilities/ConsoleUtils.cs @@ -1,4 +1,4 @@ -namespace NLightning.Node.Utilities; +namespace NLightning.Daemon.Contracts.Utilities; public static class ConsoleUtils { @@ -6,11 +6,10 @@ public static string ReadPassword(string prompt = "Enter password: ") { Console.Write(prompt); var password = string.Empty; - ConsoleKeyInfo key; do { - key = Console.ReadKey(intercept: true); + var key = Console.ReadKey(intercept: true); if (key.Key == ConsoleKey.Enter) break; if (key.Key == ConsoleKey.Backspace && password.Length > 0) diff --git a/src/NLightning.Daemon.Contracts/Utilities/NodeUtils.cs b/src/NLightning.Daemon.Contracts/Utilities/NodeUtils.cs new file mode 100644 index 00000000..15cbc11e --- /dev/null +++ b/src/NLightning.Daemon.Contracts/Utilities/NodeUtils.cs @@ -0,0 +1,28 @@ +namespace NLightning.Daemon.Contracts.Utilities; + +using Constants; + +public static class NodeUtils +{ + /// + /// Gets the path for the Named-Pipe file + /// + public static string GetNamedPipeFilePath(string network) + { + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var networkDir = Path.Combine(homeDir, NodeConstants.DaemonFolder, network); + Directory.CreateDirectory(networkDir); // Ensure directory exists + return Path.Combine(networkDir, NodeConstants.NamedPipeFile); + } + + /// + /// Gets the path for the Named-Pipe file + /// + public static string GetCookieFilePath(string network) + { + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var networkDir = Path.Combine(homeDir, NodeConstants.DaemonFolder, network); + Directory.CreateDirectory(networkDir); // Ensure directory exists + return Path.Combine(networkDir, NodeConstants.CookieFile); + } +} \ No newline at end of file diff --git a/src/NLightning.Daemon.Plugins/IDaemonContext.cs b/src/NLightning.Daemon.Plugins/IDaemonContext.cs new file mode 100644 index 00000000..48b1c832 --- /dev/null +++ b/src/NLightning.Daemon.Plugins/IDaemonContext.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace NLightning.Daemon.Plugins; + +using Contracts; + +public interface IDaemonContext +{ + IServiceProvider Services { get; } + IControlClient Client { get; } + ILoggerFactory LoggerFactory { get; } + IConfiguration Configuration { get; } +} \ No newline at end of file diff --git a/src/NLightning.Daemon.Plugins/IDaemonPlugin.cs b/src/NLightning.Daemon.Plugins/IDaemonPlugin.cs new file mode 100644 index 00000000..3fbe7a98 --- /dev/null +++ b/src/NLightning.Daemon.Plugins/IDaemonPlugin.cs @@ -0,0 +1,8 @@ +namespace NLightning.Daemon.Plugins; + +public interface IDaemonPlugin : IAsyncDisposable +{ + string Name { get; } + Task StartAsync(IDaemonContext context, CancellationToken ct = default); + Task StopAsync(CancellationToken ct = default); +} \ No newline at end of file diff --git a/src/NLightning.Daemon.Plugins/NLightning.Daemon.Plugins.csproj b/src/NLightning.Daemon.Plugins/NLightning.Daemon.Plugins.csproj new file mode 100644 index 00000000..c7e6b2b6 --- /dev/null +++ b/src/NLightning.Daemon.Plugins/NLightning.Daemon.Plugins.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + latest + enable + enable + + + + + + + diff --git a/src/NLightning.Node/AssemblyInfo.cs b/src/NLightning.Daemon/AssemblyInfo.cs similarity index 100% rename from src/NLightning.Node/AssemblyInfo.cs rename to src/NLightning.Daemon/AssemblyInfo.cs diff --git a/src/NLightning.Node/Extensions/DatabaseExtensions.cs b/src/NLightning.Daemon/Extensions/DatabaseExtensions.cs similarity index 97% rename from src/NLightning.Node/Extensions/DatabaseExtensions.cs rename to src/NLightning.Daemon/Extensions/DatabaseExtensions.cs index dc1d5c39..aa326eec 100644 --- a/src/NLightning.Node/Extensions/DatabaseExtensions.cs +++ b/src/NLightning.Daemon/Extensions/DatabaseExtensions.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace NLightning.Node.Extensions; +namespace NLightning.Daemon.Extensions; using Infrastructure.Persistence.Contexts; diff --git a/src/NLightning.Node/Extensions/NodeConfigurationExtensions.cs b/src/NLightning.Daemon/Extensions/NodeConfigurationExtensions.cs similarity index 99% rename from src/NLightning.Node/Extensions/NodeConfigurationExtensions.cs rename to src/NLightning.Daemon/Extensions/NodeConfigurationExtensions.cs index 861e8502..763dac01 100644 --- a/src/NLightning.Node/Extensions/NodeConfigurationExtensions.cs +++ b/src/NLightning.Daemon/Extensions/NodeConfigurationExtensions.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.Hosting; using Serilog; -namespace NLightning.Node.Extensions; +namespace NLightning.Daemon.Extensions; using Helpers; diff --git a/src/NLightning.Node/Extensions/NodeServiceExtensions.cs b/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs similarity index 73% rename from src/NLightning.Node/Extensions/NodeServiceExtensions.cs rename to src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs index 3f9643f1..a46a82d0 100644 --- a/src/NLightning.Node/Extensions/NodeServiceExtensions.cs +++ b/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs @@ -3,30 +3,34 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using NLightning.Application; -using NLightning.Domain.Bitcoin.Interfaces; -using NLightning.Domain.Bitcoin.Transactions.Factories; -using NLightning.Domain.Bitcoin.Transactions.Interfaces; -using NLightning.Domain.Channels.Factories; -using NLightning.Domain.Channels.Interfaces; -using NLightning.Domain.Crypto.Hashes; -using NLightning.Domain.Protocol.Interfaces; -using NLightning.Domain.Protocol.ValueObjects; -using NLightning.Infrastructure; -using NLightning.Infrastructure.Bitcoin; -using NLightning.Infrastructure.Bitcoin.Builders; -using NLightning.Infrastructure.Bitcoin.Managers; -using NLightning.Infrastructure.Bitcoin.Options; -using NLightning.Infrastructure.Bitcoin.Services; -using NLightning.Infrastructure.Bitcoin.Signers; -using NLightning.Infrastructure.Persistence; -using NLightning.Infrastructure.Repositories; -using NLightning.Infrastructure.Serialization; - -namespace NLightning.Node.Extensions; +namespace NLightning.Daemon.Extensions; + +using Application; +using Contracts.Utilities; +using Domain.Bitcoin.Interfaces; +using Domain.Bitcoin.Transactions.Factories; +using Domain.Bitcoin.Transactions.Interfaces; +using Domain.Channels.Factories; +using Domain.Channels.Interfaces; +using Domain.Crypto.Hashes; using Domain.Node.Options; +using Domain.Protocol.Interfaces; +using Domain.Protocol.ValueObjects; +using Handlers; +using Infrastructure; +using Infrastructure.Bitcoin; +using Infrastructure.Bitcoin.Builders; +using Infrastructure.Bitcoin.Managers; +using Infrastructure.Bitcoin.Options; +using Infrastructure.Bitcoin.Services; +using Infrastructure.Bitcoin.Signers; +using Infrastructure.Persistence; +using Infrastructure.Repositories; +using Infrastructure.Serialization; +using Interfaces; using Services; +using Services.Ipc; public static class NodeServiceExtensions { @@ -46,6 +50,20 @@ public static IHostBuilder ConfigureNltgServices(this IHostBuilder hostBuilder, // Register the main daemon service services.AddHostedService(); + // Register IPC server + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => + { + var nodeOptions = sp.GetRequiredService>().Value; + var cookiePath = NodeUtils.GetCookieFilePath(nodeOptions.BitcoinNetwork); + var logger = sp.GetRequiredService>(); + return new CookieFileAuthenticator(cookiePath, logger); + }); + services.AddHostedService(); + // Add HttpClient for FeeService with configuration services.AddHttpClient(client => { diff --git a/src/NLightning.Node/GlobalUsings.cs b/src/NLightning.Daemon/GlobalUsings.cs similarity index 100% rename from src/NLightning.Node/GlobalUsings.cs rename to src/NLightning.Daemon/GlobalUsings.cs diff --git a/src/NLightning.Daemon/Handlers/NodeInfoIpcHandler.cs b/src/NLightning.Daemon/Handlers/NodeInfoIpcHandler.cs new file mode 100644 index 00000000..e5e3d76b --- /dev/null +++ b/src/NLightning.Daemon/Handlers/NodeInfoIpcHandler.cs @@ -0,0 +1,32 @@ +using MessagePack; + +namespace NLightning.Daemon.Handlers; + +using Interfaces; +using Transport.Ipc; + +public sealed class NodeInfoIpcHandler : IIpcCommandHandler +{ + private readonly INodeInfoQueryService _query; + + public NodeInfoIpcHandler(INodeInfoQueryService query) + { + _query = query; + } + + public NodeIpcCommand Command => NodeIpcCommand.NodeInfo; + + public async Task HandleAsync(IpcEnvelope envelope, CancellationToken ct) + { + var resp = await _query.QueryAsync(ct); + var payload = MessagePackSerializer.Serialize(resp, cancellationToken: ct); + return new IpcEnvelope + { + Version = envelope.Version, + Command = envelope.Command, + CorrelationId = envelope.CorrelationId, + Kind = 1, + Payload = payload + }; + } +} \ No newline at end of file diff --git a/src/NLightning.Node/Helpers/AesGcmHelper.cs b/src/NLightning.Daemon/Helpers/AesGcmHelper.cs similarity index 97% rename from src/NLightning.Node/Helpers/AesGcmHelper.cs rename to src/NLightning.Daemon/Helpers/AesGcmHelper.cs index 47676f15..0716be38 100644 --- a/src/NLightning.Node/Helpers/AesGcmHelper.cs +++ b/src/NLightning.Daemon/Helpers/AesGcmHelper.cs @@ -1,6 +1,6 @@ using System.Security.Cryptography; -namespace NLightning.Node.Helpers; +namespace NLightning.Daemon.Helpers; public static class AesGcmHelper { diff --git a/src/NLightning.Node/Helpers/ClassNameEnricher.cs b/src/NLightning.Daemon/Helpers/ClassNameEnricher.cs similarity index 94% rename from src/NLightning.Node/Helpers/ClassNameEnricher.cs rename to src/NLightning.Daemon/Helpers/ClassNameEnricher.cs index e86bb57d..176097ba 100644 --- a/src/NLightning.Node/Helpers/ClassNameEnricher.cs +++ b/src/NLightning.Daemon/Helpers/ClassNameEnricher.cs @@ -1,7 +1,7 @@ using Serilog.Core; using Serilog.Events; -namespace NLightning.Node.Helpers; +namespace NLightning.Daemon.Helpers; public class ClassNameEnricher : ILogEventEnricher { diff --git a/src/NLightning.Daemon/Interfaces/IIpcAuthenticator.cs b/src/NLightning.Daemon/Interfaces/IIpcAuthenticator.cs new file mode 100644 index 00000000..9094c7fe --- /dev/null +++ b/src/NLightning.Daemon/Interfaces/IIpcAuthenticator.cs @@ -0,0 +1,6 @@ +namespace NLightning.Daemon.Interfaces; + +public interface IIpcAuthenticator +{ + Task ValidateAsync(string? token, CancellationToken ct = default); +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Interfaces/IIpcCommandHandler.cs b/src/NLightning.Daemon/Interfaces/IIpcCommandHandler.cs new file mode 100644 index 00000000..fec12e46 --- /dev/null +++ b/src/NLightning.Daemon/Interfaces/IIpcCommandHandler.cs @@ -0,0 +1,9 @@ +namespace NLightning.Daemon.Interfaces; + +using Transport.Ipc; + +public interface IIpcCommandHandler +{ + NodeIpcCommand Command { get; } + Task HandleAsync(IpcEnvelope envelope, CancellationToken ct); +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Interfaces/IIpcFraming.cs b/src/NLightning.Daemon/Interfaces/IIpcFraming.cs new file mode 100644 index 00000000..158b5628 --- /dev/null +++ b/src/NLightning.Daemon/Interfaces/IIpcFraming.cs @@ -0,0 +1,9 @@ +namespace NLightning.Daemon.Interfaces; + +using Transport.Ipc; + +public interface IIpcFraming +{ + Task ReadAsync(Stream stream, CancellationToken ct); + Task WriteAsync(Stream stream, IpcEnvelope envelope, CancellationToken ct); +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Interfaces/IIpcRequestRouter.cs b/src/NLightning.Daemon/Interfaces/IIpcRequestRouter.cs new file mode 100644 index 00000000..880c65e3 --- /dev/null +++ b/src/NLightning.Daemon/Interfaces/IIpcRequestRouter.cs @@ -0,0 +1,8 @@ +namespace NLightning.Daemon.Interfaces; + +using Transport.Ipc; + +public interface IIpcRequestRouter +{ + Task RouteAsync(IpcEnvelope request, CancellationToken ct); +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Interfaces/INodeInfoQueryService.cs b/src/NLightning.Daemon/Interfaces/INodeInfoQueryService.cs new file mode 100644 index 00000000..88b5ab4c --- /dev/null +++ b/src/NLightning.Daemon/Interfaces/INodeInfoQueryService.cs @@ -0,0 +1,7 @@ +using NLightning.Daemon.Contracts.Control; + +namespace NLightning.Daemon.Interfaces; +public interface INodeInfoQueryService +{ + Task QueryAsync(CancellationToken ct); +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Models/FeeRateCacheData.cs b/src/NLightning.Daemon/Models/FeeRateCacheData.cs new file mode 100644 index 00000000..9458aa80 --- /dev/null +++ b/src/NLightning.Daemon/Models/FeeRateCacheData.cs @@ -0,0 +1,11 @@ +using MessagePack; + +namespace NLightning.Daemon.Models; + +[MessagePackObject] +public class FeeRateCacheData +{ + [Key(0)] public ulong FeeRate { get; set; } + + [Key(1)] public DateTime LastFetchTime { get; set; } +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Models/PluginEntry.cs b/src/NLightning.Daemon/Models/PluginEntry.cs new file mode 100644 index 00000000..0fda10ec --- /dev/null +++ b/src/NLightning.Daemon/Models/PluginEntry.cs @@ -0,0 +1,8 @@ +namespace NLightning.Daemon.Models; + +internal sealed record PluginEntry +{ + public string AssemblyPath { get; init; } = ""; + public string? TypeName { get; init; } + public string? ConfigSection { get; init; } +} \ No newline at end of file diff --git a/src/NLightning.Node/NLightning.Node.csproj b/src/NLightning.Daemon/NLightning.Daemon.csproj similarity index 88% rename from src/NLightning.Node/NLightning.Node.csproj rename to src/NLightning.Daemon/NLightning.Daemon.csproj index 05a9c843..a5b507be 100644 --- a/src/NLightning.Node/NLightning.Node.csproj +++ b/src/NLightning.Daemon/NLightning.Daemon.csproj @@ -29,10 +29,12 @@ + + @@ -40,6 +42,8 @@ + + diff --git a/src/NLightning.Node/Program.cs b/src/NLightning.Daemon/Program.cs similarity index 95% rename from src/NLightning.Node/Program.cs rename to src/NLightning.Daemon/Program.cs index 2b6e5034..14f9f3f3 100644 --- a/src/NLightning.Node/Program.cs +++ b/src/NLightning.Daemon/Program.cs @@ -3,14 +3,15 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NBitcoin; +using NLightning.Daemon.Contracts.Helpers; +using NLightning.Daemon.Contracts.Utilities; +using NLightning.Daemon.Extensions; +using NLightning.Daemon.Utilities; using NLightning.Domain.Node.Options; using NLightning.Domain.Protocol.ValueObjects; using NLightning.Infrastructure.Bitcoin.Managers; using NLightning.Infrastructure.Bitcoin.Options; using NLightning.Infrastructure.Bitcoin.Wallet; -using NLightning.Node.Extensions; -using NLightning.Node.Helpers; -using NLightning.Node.Utilities; using Serilog; try @@ -31,14 +32,14 @@ var pidFilePath = DaemonUtils.GetPidFilePath(network); // Check for the stop command - if (CommandLineHelper.IsStopRequested(args)) + if (DaemonUtils.IsStopRequested(args)) { var stopped = DaemonUtils.StopDaemon(pidFilePath, Log.Logger); return stopped ? 0 : 1; } // Check for status command - if (CommandLineHelper.IsStatusRequested(args)) + if (DaemonUtils.IsStatusRequested(args)) { ReportDaemonStatus(pidFilePath); return 0; @@ -47,7 +48,7 @@ // Check if help is requested if (CommandLineHelper.IsHelpRequested(args)) { - CommandLineHelper.ShowUsage(); + DaemonUtils.ShowUsage(); return 0; } diff --git a/src/NLightning.Daemon/Services/Ipc/CookieFileAuthenticator.cs b/src/NLightning.Daemon/Services/Ipc/CookieFileAuthenticator.cs new file mode 100644 index 00000000..d518d240 --- /dev/null +++ b/src/NLightning.Daemon/Services/Ipc/CookieFileAuthenticator.cs @@ -0,0 +1,44 @@ +using System.Security.Cryptography; +using Microsoft.Extensions.Logging; + +namespace NLightning.Daemon.Services.Ipc; + +using Interfaces; + +/// +/// Cookie-file-based authenticator (Bitcoin Core style). Uses constant-time comparison. +/// +public sealed class CookieFileAuthenticator : IIpcAuthenticator +{ + private readonly string _cookieFilePath; + private readonly ILogger _logger; + + public CookieFileAuthenticator(string cookieFilePath, ILogger logger) + { + _cookieFilePath = cookieFilePath; + _logger = logger; + } + + public async Task ValidateAsync(string? token, CancellationToken ct = default) + { + try + { + if (string.IsNullOrEmpty(token)) return false; + if (!File.Exists(_cookieFilePath)) return false; + var expected = (await File.ReadAllTextAsync(_cookieFilePath, ct)).Trim(); + return FixedTimeEquals(expected, token); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Auth validation failed"); + return false; + } + } + + private static bool FixedTimeEquals(string a, string b) + { + var aBytes = System.Text.Encoding.UTF8.GetBytes(a); + var bBytes = System.Text.Encoding.UTF8.GetBytes(b); + return CryptographicOperations.FixedTimeEquals(aBytes, bBytes); + } +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Services/Ipc/IpcFraming.cs b/src/NLightning.Daemon/Services/Ipc/IpcFraming.cs new file mode 100644 index 00000000..eb093ae7 --- /dev/null +++ b/src/NLightning.Daemon/Services/Ipc/IpcFraming.cs @@ -0,0 +1,52 @@ +using System.Buffers; +using MessagePack; + +namespace NLightning.Daemon.Services.Ipc; + +using Interfaces; +using NLightning.Transport.Ipc; + +/// +/// Length-prefixed MessagePack framing for IpcEnvelope. +/// +public sealed class LengthPrefixedIpcFraming : IIpcFraming +{ + public async Task ReadAsync(Stream stream, CancellationToken ct) + { + var header = new byte[4]; + await ReadExactAsync(stream, header, ct); + var len = BitConverter.ToInt32(header, 0); + if (len is <= 0 or > 10_000_000) throw new IOException("Invalid IPC frame length."); + + var buffer = ArrayPool.Shared.Rent(len); + try + { + await ReadExactAsync(stream, buffer.AsMemory(0, len), ct); + return MessagePackSerializer.Deserialize(buffer.AsMemory(0, len), cancellationToken: ct); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public async Task WriteAsync(Stream stream, IpcEnvelope envelope, CancellationToken ct) + { + var payload = MessagePackSerializer.Serialize(envelope, cancellationToken: ct); + var len = BitConverter.GetBytes(payload.Length); + await stream.WriteAsync(len, ct); + await stream.WriteAsync(payload, ct); + await stream.FlushAsync(ct); + } + + private static async Task ReadExactAsync(Stream stream, Memory buffer, CancellationToken ct) + { + var total = 0; + while (total < buffer.Length) + { + var read = await stream.ReadAsync(buffer[total..], ct); + if (read == 0) throw new EndOfStreamException(); + total += read; + } + } +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Services/Ipc/IpcRouting.cs b/src/NLightning.Daemon/Services/Ipc/IpcRouting.cs new file mode 100644 index 00000000..132b2979 --- /dev/null +++ b/src/NLightning.Daemon/Services/Ipc/IpcRouting.cs @@ -0,0 +1,53 @@ +using MessagePack; +using Microsoft.Extensions.Logging; + +namespace NLightning.Daemon.Services.Ipc; + +using Interfaces; +using NLightning.Transport.Ipc; + +/// +/// Default router that uses a map of handlers keyed by command. +/// +public sealed class IpcRequestRouter : IIpcRequestRouter +{ + private readonly IReadOnlyDictionary _handlers; + private readonly ILogger _logger; + + public IpcRequestRouter(IEnumerable handlers, ILogger logger) + { + _handlers = handlers.ToDictionary(h => h.Command); + _logger = logger; + } + + public async Task RouteAsync(IpcEnvelope request, CancellationToken ct) + { + if (!_handlers.TryGetValue(request.Command, out var handler)) + { + return Error(request, "unknown_command", $"Unknown command: {request.Command}"); + } + + try + { + return await handler.HandleAsync(request, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "IPC handler error for {Command}", request.Command); + return Error(request, "server_error", ex.Message); + } + } + + private static IpcEnvelope Error(IpcEnvelope request, string code, string message) + { + var payload = MessagePackSerializer.Serialize(new IpcError { Code = code, Message = message }); + return new IpcEnvelope + { + Version = request.Version, + Command = request.Command, + CorrelationId = request.CorrelationId, + Kind = 2, + Payload = payload + }; + } +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcHostedService.cs b/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcHostedService.cs new file mode 100644 index 00000000..3caaf5f1 --- /dev/null +++ b/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcHostedService.cs @@ -0,0 +1,144 @@ +using System.IO.Pipes; +using MessagePack; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace NLightning.Daemon.Services.Ipc; + +using Contracts.Utilities; +using Domain.Node.Options; +using Interfaces; +using NLightning.Transport.Ipc; + +/// +/// Hosted service that listens to on a named pipe and processes IPC requests using injected components. +/// +public sealed class NamedPipeIpcHostedService : BackgroundService +{ + private readonly ILogger _logger; + private readonly IIpcAuthenticator _authenticator; + private readonly IIpcFraming _framing; + private readonly IIpcRequestRouter _router; + + private readonly string _pipeName; + private readonly string _cookiePath; + + public NamedPipeIpcHostedService(ILogger logger, IIpcAuthenticator authenticator, + IIpcFraming framing, IIpcRequestRouter router, IOptions nodeOptions) + { + _logger = logger; + _authenticator = authenticator; + _framing = framing; + _router = router; + + _pipeName = NodeUtils.GetNamedPipeFilePath(nodeOptions.Value.BitcoinNetwork); + _cookiePath = NodeUtils.GetCookieFilePath(nodeOptions.Value.BitcoinNetwork); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("IPC server starting on pipe {Pipe}", _pipeName); + EnsureCookieExists(); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var server = new NamedPipeServerStream(_pipeName, PipeDirection.InOut, 10, + PipeTransmissionMode.Byte, PipeOptions.Asynchronous); + await server.WaitForConnectionAsync(stoppingToken); + + _ = Task.Run(() => HandleClientAsync(server, stoppingToken), stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "IPC accept loop error"); + await Task.Delay(500, stoppingToken); + } + } + + _logger.LogInformation("IPC server stopped"); + } + + private async Task HandleClientAsync(NamedPipeServerStream stream, CancellationToken ct) + { + try + { + var request = await _framing.ReadAsync(stream, ct); + + if (!await _authenticator.ValidateAsync(request.AuthToken, ct)) + { + var err = Error(request, "auth_failed", "Authentication failed."); + await _framing.WriteAsync(stream, err, ct); + return; + } + + var response = await _router.RouteAsync(request, ct); + await _framing.WriteAsync(stream, response, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "IPC client handling failed"); + try + { + // Try to write a generic error if we still can read an envelope + var env = new IpcEnvelope { Version = 1, CorrelationId = Guid.NewGuid(), Kind = 2 }; + var err = Error(env, "server_error", ex.Message); + await _framing.WriteAsync(stream, err, ct); + } + catch + { + // ignore + } + } + finally + { + try { await stream.DisposeAsync(); } + catch + { + /* ignore */ + } + } + } + + private void EnsureCookieExists() + { + try + { + var dir = Path.GetDirectoryName(_cookiePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + + if (!File.Exists(_cookiePath)) + { + var token = Convert.ToBase64String(Guid.NewGuid().ToByteArray()); + File.WriteAllText(_cookiePath, token); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to ensure IPC cookie exists at {Path}", _cookiePath); + throw; + } + } + + private static IpcEnvelope Error(IpcEnvelope request, string code, string message) + { + var payload = MessagePackSerializer.Serialize(new IpcError { Code = code, Message = message }); + return new IpcEnvelope + { + Version = request.Version, + Command = request.Command, + CorrelationId = request.CorrelationId, + Kind = 2, + Payload = payload + }; + } +} \ No newline at end of file diff --git a/src/NLightning.Node/Services/NltgDaemonService.cs b/src/NLightning.Daemon/Services/NltgDaemonService.cs similarity index 98% rename from src/NLightning.Node/Services/NltgDaemonService.cs rename to src/NLightning.Daemon/Services/NltgDaemonService.cs index 72968bfd..12fb2683 100644 --- a/src/NLightning.Node/Services/NltgDaemonService.cs +++ b/src/NLightning.Daemon/Services/NltgDaemonService.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace NLightning.Node.Services; +namespace NLightning.Daemon.Services; using Domain.Bitcoin.Interfaces; using Domain.Node.Interfaces; diff --git a/src/NLightning.Daemon/Services/NodeInfoQueryService.cs b/src/NLightning.Daemon/Services/NodeInfoQueryService.cs new file mode 100644 index 00000000..8669a131 --- /dev/null +++ b/src/NLightning.Daemon/Services/NodeInfoQueryService.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace NLightning.Daemon.Services; + +using Contracts.Control; +using Domain.Node.Options; +using Domain.Persistence.Interfaces; +using Interfaces; + +public sealed class NodeInfoQueryService : INodeInfoQueryService +{ + private readonly IServiceProvider _services; + private readonly NodeOptions _nodeOptions; + + public NodeInfoQueryService(IServiceProvider services, IOptions nodeOptions) + { + _services = services; + _nodeOptions = nodeOptions.Value; + } + + public async Task QueryAsync(CancellationToken ct) + { + // resolve per-call scope to access repositories + using var scope = _services.CreateScope(); + var uow = scope.ServiceProvider.GetService(); + + var bestHashHex = string.Empty; + long bestHeight = 0; + DateTimeOffset? bestTime = null; + + if (uow is not null) + { + try + { + var state = await uow.BlockchainStateDbRepository.GetStateAsync(); + if (state is not null) + { + bestHeight = state.LastProcessedHeight; + bestHashHex = state.LastProcessedBlockHash.ToString(); + bestTime = state.LastProcessedAt; + } + } + catch + { + // ignore, return defaults + } + } + + return new NodeInfoResponse + { + Network = _nodeOptions.BitcoinNetwork, + BestBlockHash = bestHashHex, + BestBlockHeight = bestHeight, + BestBlockTime = bestTime, + Implementation = "NLightning", + Version = typeof(NodeInfoQueryService).Assembly.GetName().Version?.ToString() + }; + } +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Services/PluginLoaderService.cs b/src/NLightning.Daemon/Services/PluginLoaderService.cs new file mode 100644 index 00000000..163c8405 --- /dev/null +++ b/src/NLightning.Daemon/Services/PluginLoaderService.cs @@ -0,0 +1,72 @@ +using System.Runtime.Loader; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace NLightning.Daemon.Services; + +using Models; +using Plugins; + +public class PluginLoaderService : IHostedService +{ + private readonly IServiceProvider _services; + private readonly IConfiguration _config; + private readonly ILogger _logger; + private readonly List<(IDaemonPlugin Plugin, AssemblyLoadContext Alc)> _plugins = new(); + + public PluginLoaderService(IServiceProvider services, IConfiguration config, ILogger logger) + { + _services = services; + _config = config; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + var entries = _config.GetSection("Plugins").Get>() ?? []; + foreach (var entry in entries) + { + try + { + var alc = new AssemblyLoadContext(Path.GetFileNameWithoutExtension(entry.AssemblyPath), + isCollectible: true); + await using var stream = File.OpenRead(entry.AssemblyPath); + var asm = alc.LoadFromStream(stream); + + var pluginType = string.IsNullOrWhiteSpace(entry.TypeName) + ? asm.ExportedTypes.First(t => typeof(IDaemonPlugin).IsAssignableFrom(t) && + !t.IsAbstract) + : asm.GetType(entry.TypeName!, throwOnError: true)!; + + var plugin = (IDaemonPlugin)ActivatorUtilities.CreateInstance( + _services, pluginType); + + var context = _services.GetRequiredService(); + await plugin.StartAsync(context, cancellationToken); + + _plugins.Add((plugin, alc)); + _logger.LogInformation("Loaded plugin {Plugin}", pluginType.FullName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load plugin from {Path}", entry.AssemblyPath); + } + } + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + foreach (var (plugin, alc) in _plugins) + { + try { await plugin.StopAsync(cancellationToken); } + catch (Exception ex) { _logger.LogWarning(ex, "Error stopping plugin {Name}", plugin.Name); } + + await plugin.DisposeAsync(); + alc.Unload(); + } + + _plugins.Clear(); + } +} \ No newline at end of file diff --git a/src/NLightning.Node/Utilities/DaemonUtils.cs b/src/NLightning.Daemon/Utilities/DaemonUtils.cs similarity index 76% rename from src/NLightning.Node/Utilities/DaemonUtils.cs rename to src/NLightning.Daemon/Utilities/DaemonUtils.cs index 0f81444d..8528251e 100644 --- a/src/NLightning.Node/Utilities/DaemonUtils.cs +++ b/src/NLightning.Daemon/Utilities/DaemonUtils.cs @@ -4,12 +4,56 @@ using Microsoft.Extensions.Configuration; using Serilog; -namespace NLightning.Node.Utilities; +namespace NLightning.Daemon.Utilities; -using Constants; +using Contracts.Constants; public partial class DaemonUtils { + public static void ShowUsage() + { + Console.WriteLine("NLTG - NLightning Daemon"); + Console.WriteLine("Usage:"); + Console.WriteLine(" nltg [options]"); + Console.WriteLine(" nltg --stop Stop a running daemon"); + Console.WriteLine(" nltg --status Show daemon status"); + Console.WriteLine(); + Console.WriteLine("Options:"); + Console.WriteLine(" --network, -n Network to use (mainnet, testnet, regtest) [default: mainnet]"); + Console.WriteLine(" --config, -c Path to custom configuration file"); + Console.WriteLine(" --daemon Run as a daemon [default: false]"); + Console.WriteLine(" --stop Stop a running daemon"); + Console.WriteLine(" --status Show daemon status information"); + Console.WriteLine(" --help, -h, -? Show this help message"); + Console.WriteLine(); + Console.WriteLine("Environment Variables:"); + Console.WriteLine(" NLTG_NETWORK Network to use"); + Console.WriteLine(" NLTG_CONFIG Path to custom configuration file"); + Console.WriteLine(" NLTG_DAEMON Run as a daemon"); + Console.WriteLine(); + Console.WriteLine("Configuration File:"); + Console.WriteLine(" Default path: ~/.nltg/{network}/appsettings.json"); + Console.WriteLine(" Settings:"); + Console.WriteLine(" {"); + Console.WriteLine(" \"Daemon\": true, # Run as a background daemon"); + Console.WriteLine(" ... other settings ..."); + Console.WriteLine(" }"); + Console.WriteLine(); + Console.WriteLine("PID file location: ~/.nltg/{network}/nltg.pid"); + } + + public static bool IsStopRequested(string[] args) + { + return args.Any(arg => + arg.Equals("--stop", StringComparison.OrdinalIgnoreCase)); + } + + public static bool IsStatusRequested(string[] args) + { + return args.Any(arg => + arg.Equals("--status", StringComparison.OrdinalIgnoreCase)); + } + /// /// Starts the application as a daemon process if requested /// @@ -18,18 +62,19 @@ public partial class DaemonUtils /// Path where to store the PID file /// Logger for startup messages /// True if the parent process should exit, false to continue execution - public static bool StartDaemonIfRequested(string[] args, IConfiguration configuration, string pidFilePath, ILogger logger) + public static bool StartDaemonIfRequested(string[] args, IConfiguration configuration, string pidFilePath, + ILogger logger) { // Check if we're already running as a daemon child process if (IsRunningAsDaemon()) { - return false; // Continue execution as daemon child + return false; // Continue execution as a daemon child } - // Check command line args (highest priority) + // Check command line args (the highest priority) var isDaemonRequested = Array.Exists(args, arg => - arg.Equals("--daemon", StringComparison.OrdinalIgnoreCase) || - arg.Equals("--daemon=true", StringComparison.OrdinalIgnoreCase)); + arg.Equals("--daemon", StringComparison.OrdinalIgnoreCase) || + arg.Equals("--daemon=true", StringComparison.OrdinalIgnoreCase)); // Check environment variable (middle priority) if (!isDaemonRequested) @@ -55,10 +100,11 @@ public static bool StartDaemonIfRequested(string[] args, IConfiguration configur // Platform-specific daemon implementation return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? StartWindowsDaemon(args, pidFilePath, logger) - : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - ? StartMacOsDaemon(args, pidFilePath, logger) // Special implementation for macOS to avoid fork() issues - : StartUnixDaemon(pidFilePath, logger); // Linux and other Unix systems + ? StartWindowsDaemon(args, pidFilePath, logger) + : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? StartMacOsDaemon(args, pidFilePath, + logger) // Special implementation for macOS to avoid fork() issues + : StartUnixDaemon(pidFilePath, logger); // Linux and other Unix systems } private static bool StartWindowsDaemon(string[] args, string pidFilePath, ILogger logger) @@ -84,7 +130,7 @@ private static bool StartWindowsDaemon(string[] args, string pidFilePath, ILogge } } - // Add special flag to indicate we're already in daemon mode + // Add a special flag to indicate we're already in daemon mode startInfo.ArgumentList.Add("--daemon-child"); // Start the new process @@ -186,7 +232,7 @@ private static bool StartMacOsDaemon(string[] args, string pidFilePath, ILogger // Ignore cleanup errors } - // Verify PID file was created + // Verify the PID file was created if (File.Exists(pidFilePath)) { var pidContent = File.ReadAllText(pidFilePath).Trim(); @@ -246,7 +292,7 @@ private static bool StartUnixDaemon(string pidFilePath, ILogger logger) Console.SetOut(StreamWriter.Null); Console.SetError(StreamWriter.Null); - // Write PID file + // Write the PID file var currentPid = Environment.ProcessId; File.WriteAllText(pidFilePath, currentPid.ToString()); @@ -265,7 +311,7 @@ private static bool StartUnixDaemon(string pidFilePath, ILogger logger) public static bool IsRunningAsDaemon() { return Array.Exists(Environment.GetCommandLineArgs(), - arg => arg.Equals("--daemon-child", StringComparison.OrdinalIgnoreCase)); + arg => arg.Equals("--daemon-child", StringComparison.OrdinalIgnoreCase)); } /// @@ -274,9 +320,9 @@ public static bool IsRunningAsDaemon() public static string GetPidFilePath(string network) { var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var networkDir = Path.Combine(homeDir, DaemonConstants.DaemonFolder, network); + var networkDir = Path.Combine(homeDir, NodeConstants.DaemonFolder, network); Directory.CreateDirectory(networkDir); // Ensure directory exists - return Path.Combine(networkDir, DaemonConstants.PidFile); + return Path.Combine(networkDir, NodeConstants.PidFile); } /// @@ -325,7 +371,7 @@ public static bool StopDaemon(string pidFilePath, ILogger logger) return true; } - // If graceful shutdown fails, force kill as last resort + // If a graceful shutdown fails, force kill as last resort logger.Warning("Daemon process did not exit gracefully, forcing termination"); process.Kill(); exited = process.WaitForExit(5000); @@ -371,7 +417,7 @@ private static void SendCtrlEvent(Process process) private static int Fork() { - // If not on Unix, simulate fork by returning -1 + // If not on Unix, simulate the fork by returning -1 if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux) && !RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { diff --git a/src/NLightning.Infrastructure/AssemblyInfo.cs b/src/NLightning.Infrastructure/AssemblyInfo.cs index 508a5bd5..42a565a5 100644 --- a/src/NLightning.Infrastructure/AssemblyInfo.cs +++ b/src/NLightning.Infrastructure/AssemblyInfo.cs @@ -5,5 +5,5 @@ [assembly: InternalsVisibleTo("NLightning.Infrastructure.Bitcoin")] [assembly: InternalsVisibleTo("NLightning.Infrastructure.Tests")] [assembly: InternalsVisibleTo("NLightning.Integration.Tests")] -[assembly: InternalsVisibleTo("NLightning.Node")] +[assembly: InternalsVisibleTo("NLightning.Daemon")] [assembly: InternalsVisibleTo("NLightning.Tests.Utils")] \ No newline at end of file diff --git a/src/NLightning.Node/Constants/DaemonConstants.cs b/src/NLightning.Node/Constants/DaemonConstants.cs deleted file mode 100644 index c6f2d02d..00000000 --- a/src/NLightning.Node/Constants/DaemonConstants.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace NLightning.Node.Constants; - -public static class DaemonConstants -{ - public const string DaemonFolder = "nltg"; - public const string KeyFile = "nltg.key.json"; - public const string PidFile = "nltg.pid"; -} \ No newline at end of file diff --git a/src/NLightning.Node/Helpers/CommandLineHelper.cs b/src/NLightning.Node/Helpers/CommandLineHelper.cs deleted file mode 100644 index e9b7f7de..00000000 --- a/src/NLightning.Node/Helpers/CommandLineHelper.cs +++ /dev/null @@ -1,98 +0,0 @@ -namespace NLightning.Node.Helpers; - -/// -/// Helper class for displaying command line usage information -/// -public static class CommandLineHelper -{ - public static void ShowUsage() - { - Console.WriteLine("NLTG - NLightning Daemon"); - Console.WriteLine("Usage:"); - Console.WriteLine(" nltg [options]"); - Console.WriteLine(" nltg --stop Stop a running daemon"); - Console.WriteLine(" nltg --status Show daemon status"); - Console.WriteLine(); - Console.WriteLine("Options:"); - Console.WriteLine(" --network, -n Network to use (mainnet, testnet, regtest) [default: mainnet]"); - Console.WriteLine(" --config, -c Path to custom configuration file"); - Console.WriteLine(" --daemon Run as a daemon [default: false]"); - Console.WriteLine(" --stop Stop a running daemon"); - Console.WriteLine(" --status Show daemon status information"); - Console.WriteLine(" --help, -h, -? Show this help message"); - Console.WriteLine(); - Console.WriteLine("Environment Variables:"); - Console.WriteLine(" NLTG_NETWORK Network to use"); - Console.WriteLine(" NLTG_CONFIG Path to custom configuration file"); - Console.WriteLine(" NLTG_DAEMON Run as a daemon"); - Console.WriteLine(); - Console.WriteLine("Configuration File:"); - Console.WriteLine(" Default path: ~/.nltg/{network}/appsettings.json"); - Console.WriteLine(" Settings:"); - Console.WriteLine(" {"); - Console.WriteLine(" \"Daemon\": true, # Run as a background daemon"); - Console.WriteLine(" ... other settings ..."); - Console.WriteLine(" }"); - Console.WriteLine(); - Console.WriteLine("PID file location: ~/.nltg/{network}/nltg.pid"); - } - - /// - /// Parse command line arguments to check for help request - /// - public static bool IsHelpRequested(string[] args) - { - return args.Any(arg => - arg.Equals("--help", StringComparison.OrdinalIgnoreCase) || - arg.Equals("-h", StringComparison.OrdinalIgnoreCase) || - arg.Equals("--blorg", StringComparison.OrdinalIgnoreCase)); - } - - public static bool IsStopRequested(string[] args) - { - return args.Any(arg => - arg.Equals("--stop", StringComparison.OrdinalIgnoreCase)); - } - - public static bool IsStatusRequested(string[] args) - { - return args.Any(arg => - arg.Equals("--status", StringComparison.OrdinalIgnoreCase)); - } - - public static string GetNetwork(string[] args) - { - var network = "mainnet"; // Default - - // Check command line args - for (var i = 0; i < args.Length; i++) - { - if (args[i].Equals("--network", StringComparison.OrdinalIgnoreCase) || - args[i].Equals("-n", StringComparison.OrdinalIgnoreCase)) - { - if (i + 1 < args.Length) - { - network = args[i + 1]; - break; - } - } - - if (!args[i].StartsWith("--network=", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - network = args[i]["--network=".Length..]; - break; - } - - // Check environment variable if not found in args - var envNetwork = Environment.GetEnvironmentVariable("NLTG_NETWORK"); - if (!string.IsNullOrEmpty(envNetwork)) - { - network = envNetwork; - } - - return network; - } -} \ No newline at end of file diff --git a/src/NLightning.Node/Models/FeeRateCacheData.cs b/src/NLightning.Node/Models/FeeRateCacheData.cs deleted file mode 100644 index 06c30e5a..00000000 --- a/src/NLightning.Node/Models/FeeRateCacheData.cs +++ /dev/null @@ -1,13 +0,0 @@ -using MessagePack; - -namespace NLightning.Node.Models; - -[MessagePackObject] -public class FeeRateCacheData -{ - [Key(0)] - public ulong FeeRate { get; set; } - - [Key(1)] - public DateTime LastFetchTime { get; set; } -} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/Contracts.cs b/src/NLightning.Transport.Ipc/Contracts.cs new file mode 100644 index 00000000..0caed052 --- /dev/null +++ b/src/NLightning.Transport.Ipc/Contracts.cs @@ -0,0 +1,66 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc; + +/// +/// Commands supported by the IPC protocol. +/// +public enum NodeIpcCommand +{ + // Reserve 0 for unknown + Unknown = 0, + NodeInfo = 1, +} + +/// +/// Envelope for all IPC messages, request and response, encoded with MessagePack. +/// +[MessagePackObject] +public sealed class IpcEnvelope +{ + [Key(0)] public int Version { get; init; } = 1; + [Key(1)] public NodeIpcCommand Command { get; init; } + + [Key(2)] public Guid CorrelationId { get; init; } = Guid.NewGuid(); + + // Auth token derived from a local cookie file (only accessible locally) to secure the channel + [Key(3)] public string? AuthToken { get; init; } + + // Raw payload serialized with MessagePack separately for the specific request/response type + [Key(4)] public byte[] Payload { get; init; } = Array.Empty(); + + // 0 = request, 1 = response, 2 = error + [Key(5)] public byte Kind { get; init; } = 0; +} + +/// +/// Empty request for NodeInfo. +/// +[MessagePackObject] +public readonly struct NodeInfoRequest +{ +} + +/// +/// Response for NodeInfo (transport-specific DTO for MessagePack). +/// +[MessagePackObject] +public sealed class NodeInfoIpcResponse +{ + [Key(0)] public string Network { get; init; } = string.Empty; + [Key(1)] public string BestBlockHash { get; init; } = string.Empty; + [Key(2)] public long BestBlockHeight { get; init; } + [Key(3)] public DateTimeOffset? BestBlockTime { get; init; } + [Key(4)] public string? Implementation { get; init; } = "NLightning"; + [Key(5)] public string? Version { get; init; } +} + +/// +/// Error payload +/// +[MessagePackObject] +public sealed class IpcError +{ + [Key(0)] public string Code { get; init; } = string.Empty; + [Key(1)] public string Message { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/NLightning.Transport.Ipc.csproj b/src/NLightning.Transport.Ipc/NLightning.Transport.Ipc.csproj new file mode 100644 index 00000000..39923f60 --- /dev/null +++ b/src/NLightning.Transport.Ipc/NLightning.Transport.Ipc.csproj @@ -0,0 +1,10 @@ + + + net9.0 + enable + enable + + + + + diff --git a/test/NLightning.Node.Tests/GlobalUsings.cs b/test/NLightning.Daemon.Tests/GlobalUsings.cs similarity index 100% rename from test/NLightning.Node.Tests/GlobalUsings.cs rename to test/NLightning.Daemon.Tests/GlobalUsings.cs diff --git a/test/NLightning.Node.Tests/Models/FeeRateCacheDataTests.cs b/test/NLightning.Daemon.Tests/Models/FeeRateCacheDataTests.cs similarity index 95% rename from test/NLightning.Node.Tests/Models/FeeRateCacheDataTests.cs rename to test/NLightning.Daemon.Tests/Models/FeeRateCacheDataTests.cs index 8b690458..045e92be 100644 --- a/test/NLightning.Node.Tests/Models/FeeRateCacheDataTests.cs +++ b/test/NLightning.Daemon.Tests/Models/FeeRateCacheDataTests.cs @@ -1,8 +1,8 @@ using MessagePack; -namespace NLightning.Node.Tests.Models; +namespace NLightning.Daemon.Tests.Models; -using NLightning.Node.Models; +using Daemon.Models; public class FeeRateCacheDataTests { diff --git a/test/NLightning.Daemon.Tests/NLightning.Daemon.Tests.csproj b/test/NLightning.Daemon.Tests/NLightning.Daemon.Tests.csproj new file mode 100644 index 00000000..be566510 --- /dev/null +++ b/test/NLightning.Daemon.Tests/NLightning.Daemon.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/test/NLightning.Node.Tests/Services/FeeServiceTests.cs b/test/NLightning.Daemon.Tests/Services/FeeServiceTests.cs similarity index 99% rename from test/NLightning.Node.Tests/Services/FeeServiceTests.cs rename to test/NLightning.Daemon.Tests/Services/FeeServiceTests.cs index 44c80807..3aee763d 100644 --- a/test/NLightning.Node.Tests/Services/FeeServiceTests.cs +++ b/test/NLightning.Daemon.Tests/Services/FeeServiceTests.cs @@ -6,7 +6,7 @@ using NLightning.Infrastructure.Bitcoin.Options; using NLightning.Infrastructure.Bitcoin.Services; -namespace NLightning.Node.Tests.Services; +namespace NLightning.Daemon.Tests.Services; using Domain.Money; using TestCollections; diff --git a/test/NLightning.Node.Tests/TestCollections/SerialTestCollection.cs b/test/NLightning.Daemon.Tests/TestCollections/SerialTestCollection.cs similarity index 73% rename from test/NLightning.Node.Tests/TestCollections/SerialTestCollection.cs rename to test/NLightning.Daemon.Tests/TestCollections/SerialTestCollection.cs index 4eaf8d84..60a5fcd6 100644 --- a/test/NLightning.Node.Tests/TestCollections/SerialTestCollection.cs +++ b/test/NLightning.Daemon.Tests/TestCollections/SerialTestCollection.cs @@ -1,4 +1,4 @@ -namespace NLightning.Node.Tests.TestCollections; +namespace NLightning.Daemon.Tests.TestCollections; [CollectionDefinition(Name, DisableParallelization = true)] public class SerialTestCollection diff --git a/test/NLightning.Node.Tests/coverlet.runsettings b/test/NLightning.Daemon.Tests/coverlet.runsettings similarity index 100% rename from test/NLightning.Node.Tests/coverlet.runsettings rename to test/NLightning.Daemon.Tests/coverlet.runsettings diff --git a/test/NLightning.Node.Tests/NLightning.Node.Tests.csproj b/test/NLightning.Node.Tests/NLightning.Node.Tests.csproj index 17d679e0..5f282702 100644 --- a/test/NLightning.Node.Tests/NLightning.Node.Tests.csproj +++ b/test/NLightning.Node.Tests/NLightning.Node.Tests.csproj @@ -1,32 +1 @@ - - - - net10.0 - latest - enable - enable - AnyCPU - true - false - true - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - + \ No newline at end of file From 5cd4b27aca2fe31d26945ca4ad183f3c223524bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADckolas=20Goline?= Date: Fri, 10 Oct 2025 14:26:30 -0300 Subject: [PATCH 02/20] upgrade packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Níckolas Goline --- src/NLightning.Client/NLightning.Client.csproj | 2 +- .../NLightning.Daemon.Contracts.csproj | 4 ++-- src/NLightning.Transport.Ipc/NLightning.Transport.Ipc.csproj | 2 +- test/NLightning.Bolt11.Tests/Models/TaggedFieldListTests.cs | 2 +- .../Models/TaggedFields/ExpiryTimeTaggedFieldTests.cs | 4 ++-- .../Models/TaggedFields/MetadataTaggedFieldTests.cs | 4 ++-- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/NLightning.Client/NLightning.Client.csproj b/src/NLightning.Client/NLightning.Client.csproj index 879e95a3..10a935f5 100644 --- a/src/NLightning.Client/NLightning.Client.csproj +++ b/src/NLightning.Client/NLightning.Client.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/NLightning.Daemon.Contracts/NLightning.Daemon.Contracts.csproj b/src/NLightning.Daemon.Contracts/NLightning.Daemon.Contracts.csproj index 52ee252c..c21bf538 100644 --- a/src/NLightning.Daemon.Contracts/NLightning.Daemon.Contracts.csproj +++ b/src/NLightning.Daemon.Contracts/NLightning.Daemon.Contracts.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/src/NLightning.Transport.Ipc/NLightning.Transport.Ipc.csproj b/src/NLightning.Transport.Ipc/NLightning.Transport.Ipc.csproj index 39923f60..fe04dac9 100644 --- a/src/NLightning.Transport.Ipc/NLightning.Transport.Ipc.csproj +++ b/src/NLightning.Transport.Ipc/NLightning.Transport.Ipc.csproj @@ -5,6 +5,6 @@ enable - + diff --git a/test/NLightning.Bolt11.Tests/Models/TaggedFieldListTests.cs b/test/NLightning.Bolt11.Tests/Models/TaggedFieldListTests.cs index e93461b5..a396ced8 100644 --- a/test/NLightning.Bolt11.Tests/Models/TaggedFieldListTests.cs +++ b/test/NLightning.Bolt11.Tests/Models/TaggedFieldListTests.cs @@ -320,7 +320,7 @@ public void Given_ValidList_When_WriteToBitWriter_Then_TagsAndLengthsAreWritten( public void Given_BitReaderReturningNoData_When_FromBitReaderCalled_Then_EmptyListReturned() { // Given - var bitReader = new BitReader([]); // defaults to HasMoreBits = false + var bitReader = BitReader([]); // defaults to HasMoreBits = false // When var list = TaggedFieldList.FromBitReader(bitReader, BitcoinNetwork.Mainnet); diff --git a/test/NLightning.Bolt11.Tests/Models/TaggedFields/ExpiryTimeTaggedFieldTests.cs b/test/NLightning.Bolt11.Tests/Models/TaggedFields/ExpiryTimeTaggedFieldTests.cs index 8550aedc..09e4257b 100644 --- a/test/NLightning.Bolt11.Tests/Models/TaggedFields/ExpiryTimeTaggedFieldTests.cs +++ b/test/NLightning.Bolt11.Tests/Models/TaggedFields/ExpiryTimeTaggedFieldTests.cs @@ -53,7 +53,7 @@ public void WriteToBitWriter_WritesCorrectData(int value, byte[] expectedBytes) public void FromBitReader_CreatesCorrectlyFromBitReader(int expectedValue, short bitLength, byte[] bytes) { // Arrange - var bitReader = new BitReader(bytes); + var bitReader = new Domain.Utils.BitReader(bytes); // Act var taggedField = ExpiryTimeTaggedField.FromBitReader(bitReader, bitLength); @@ -67,7 +67,7 @@ public void FromBitReader_ThrowsArgumentException_ForInvalidLength() { // Arrange var buffer = new byte[50]; - var bitReader = new BitReader(buffer); + var bitReader = new Domain.Utils.BitReader(buffer); // Act & Assert Assert.Throws(() => ExpiryTimeTaggedField.FromBitReader(bitReader, 0)); diff --git a/test/NLightning.Bolt11.Tests/Models/TaggedFields/MetadataTaggedFieldTests.cs b/test/NLightning.Bolt11.Tests/Models/TaggedFields/MetadataTaggedFieldTests.cs index be305db5..e9e9fb36 100644 --- a/test/NLightning.Bolt11.Tests/Models/TaggedFields/MetadataTaggedFieldTests.cs +++ b/test/NLightning.Bolt11.Tests/Models/TaggedFields/MetadataTaggedFieldTests.cs @@ -53,7 +53,7 @@ public void WriteToBitWriter_WritesCorrectData(byte[] metadata, byte[] expectedD public void FromBitReader_CreatesCorrectlyFromBitReader(byte[] expectedMetadata, short bitLength, byte[] bytes) { // Arrange - var bitReader = new BitReader(bytes); + var bitReader = new Domain.Utils.BitReader(bytes); // Act var taggedField = MetadataTaggedField.FromBitReader(bitReader, bitLength); @@ -67,7 +67,7 @@ public void FromBitReader_ThrowsArgumentException_ForInvalidLength() { // Arrange var buffer = new byte[50]; - var bitReader = new BitReader(buffer); + var bitReader = new Domain.Utils.BitReader(buffer); // Act & Assert Assert.Throws(() => MetadataTaggedField.FromBitReader(bitReader, 0)); From 298df11d35d44c0feec86a6c69b9f332b17a3d15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADckolas=20Goline?= Date: Thu, 23 Oct 2025 14:46:20 -0300 Subject: [PATCH 03/20] implement connect to peer for the client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Níckolas Goline --- .gitignore | 2 +- NLightning.sln | 16 +- .../Node/Managers/PeerManager.cs | 21 +- .../Ipc/NamedPipeIpcClient.cs | 52 +- .../NLightning.Client.csproj | 4 +- .../Printers/ConnectPeerPrinter.cs | 17 + src/NLightning.Client/Printers/IPrinter.cs | 6 + .../Printers/NodeInfoPrinter.cs | 18 + src/NLightning.Client/Program.cs | 34 +- src/NLightning.Client/Utils/ClientUtils.cs | 3 +- .../Control/ConnectPeerResponse.cs | 14 + .../{NodeInfo.cs => NodeInfoResponse.cs} | 0 .../Helpers/CommandLineHelper.cs | 34 +- .../Extensions/NodeServiceExtensions.cs | 1 + .../Handlers/ConnectIpcHandler.cs | 93 ++++ .../Handlers/NodeInfoIpcHandler.cs | 13 +- .../NLightning.Daemon.csproj | 2 +- src/NLightning.Daemon/Program.cs | 5 + .../Crypto/ValueObjects/Hash.cs | 11 + src/NLightning.Domain/Node/FeatureSet.cs | 2 +- .../Node/Interfaces/IPeerManager.cs | 3 +- .../Node/Models/PeerModel.cs | 14 +- ...aisStateAndWatchedTransaction.Designer.cs} | 0 ...ddBlockchaisStateAndWatchedTransaction.cs} | 0 .../20251023145101_AddPeerType.Designer.cs | 451 ++++++++++++++++++ .../Migrations/20251023145101_AddPeerType.cs | 29 ++ .../NLightningDbContextModelSnapshot.cs | 7 +- .../20251023145110_AddPeerType.Designer.cs | 367 ++++++++++++++ .../Migrations/20251023145110_AddPeerType.cs | 29 ++ .../NLightningDbContextModelSnapshot.cs | 6 +- ...haisStateAndWatchedTransaction.Designer.cs | 358 ++++++++++++++ ...AddBlockchaisStateAndWatchedTransaction.cs | 117 +++++ .../20251023145106_AddPeerType.Designer.cs | 362 ++++++++++++++ .../Migrations/20251023145106_AddPeerType.cs | 29 ++ .../NLightningDbContextModelSnapshot.cs | 4 + .../Entities/Node/PeerEntity.cs | 1 + .../Node/PeerEntityConfiguration.cs | 1 + .../scripts/add_migration.sh | 5 +- .../scripts/start_postgres.sh | 2 - .../Database/Node/PeerDbRepository.cs | 3 +- src/NLightning.Transport.Ipc/Contracts.cs | 66 --- src/NLightning.Transport.Ipc/IpcEnvelope.cs | 24 + src/NLightning.Transport.Ipc/IpcError.cs | 13 + .../Formatters/BitcoinNetworkFormatter.cs | 21 + .../Formatters/CompactPubKeyFormatter.cs | 20 + .../Formatters/FeatureSetFormatter.cs | 37 ++ .../MessagePack/Formatters/HashFormatter.cs | 20 + .../Formatters/PeerAddressInfoFormatter.cs | 21 + .../NLightningFormatterResolver.cs | 37 ++ .../NLightningMessagePackOptions.cs | 10 + .../NLightning.Transport.Ipc.csproj | 14 +- .../NodeIpcCommand.cs | 12 + .../Requests/ConnectPeerIpcRequest.cs | 14 + .../Requests/NodeInfoIpcRequest.cs | 9 + .../Responses/ConnectPeerIpcResponse.cs | 34 ++ .../Responses/NodeInfoIpcResponse.cs | 34 ++ .../Node/Managers/PeerManagerTests.cs | 3 +- .../Models/TaggedFieldListTests.cs | 8 +- 58 files changed, 2394 insertions(+), 139 deletions(-) create mode 100644 src/NLightning.Client/Printers/ConnectPeerPrinter.cs create mode 100644 src/NLightning.Client/Printers/IPrinter.cs create mode 100644 src/NLightning.Client/Printers/NodeInfoPrinter.cs create mode 100644 src/NLightning.Daemon.Contracts/Control/ConnectPeerResponse.cs rename src/NLightning.Daemon.Contracts/Control/{NodeInfo.cs => NodeInfoResponse.cs} (100%) create mode 100644 src/NLightning.Daemon/Handlers/ConnectIpcHandler.cs rename src/NLightning.Infrastructure.Persistence.Postgres/Migrations/{20251204210605_AddBlockchaisStateAndWatchedTransaction.Designer.cs => 20250612173122_AddBlockchaisStateAndWatchedTransaction.Designer.cs} (100%) rename src/NLightning.Infrastructure.Persistence.Postgres/Migrations/{20251204210605_AddBlockchaisStateAndWatchedTransaction.cs => 20250612173122_AddBlockchaisStateAndWatchedTransaction.cs} (100%) create mode 100644 src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251023145101_AddPeerType.Designer.cs create mode 100644 src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251023145101_AddPeerType.cs create mode 100644 src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251023145110_AddPeerType.Designer.cs create mode 100644 src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251023145110_AddPeerType.cs create mode 100644 src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20250612173134_AddBlockchaisStateAndWatchedTransaction.Designer.cs create mode 100644 src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20250612173134_AddBlockchaisStateAndWatchedTransaction.cs create mode 100644 src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251023145106_AddPeerType.Designer.cs create mode 100644 src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251023145106_AddPeerType.cs delete mode 100644 src/NLightning.Transport.Ipc/Contracts.cs create mode 100644 src/NLightning.Transport.Ipc/IpcEnvelope.cs create mode 100644 src/NLightning.Transport.Ipc/IpcError.cs create mode 100644 src/NLightning.Transport.Ipc/MessagePack/Formatters/BitcoinNetworkFormatter.cs create mode 100644 src/NLightning.Transport.Ipc/MessagePack/Formatters/CompactPubKeyFormatter.cs create mode 100644 src/NLightning.Transport.Ipc/MessagePack/Formatters/FeatureSetFormatter.cs create mode 100644 src/NLightning.Transport.Ipc/MessagePack/Formatters/HashFormatter.cs create mode 100644 src/NLightning.Transport.Ipc/MessagePack/Formatters/PeerAddressInfoFormatter.cs create mode 100644 src/NLightning.Transport.Ipc/MessagePack/NLightningFormatterResolver.cs create mode 100644 src/NLightning.Transport.Ipc/MessagePack/NLightningMessagePackOptions.cs create mode 100644 src/NLightning.Transport.Ipc/NodeIpcCommand.cs create mode 100644 src/NLightning.Transport.Ipc/Requests/ConnectPeerIpcRequest.cs create mode 100644 src/NLightning.Transport.Ipc/Requests/NodeInfoIpcRequest.cs create mode 100644 src/NLightning.Transport.Ipc/Responses/ConnectPeerIpcResponse.cs create mode 100644 src/NLightning.Transport.Ipc/Responses/NodeInfoIpcResponse.cs diff --git a/.gitignore b/.gitignore index b5b0bab4..c8599a2c 100644 --- a/.gitignore +++ b/.gitignore @@ -254,7 +254,7 @@ ClientBin/ *.publishsettings orleans.codegen.cs *.db -*.sqlite + # Including strong name files can present a security risk # (https://github.com/github/gitignore/pull/2483#issue-259490424) diff --git a/NLightning.sln b/NLightning.sln index 2bf69ff7..a07cd827 100644 --- a/NLightning.sln +++ b/NLightning.sln @@ -124,7 +124,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NLightning.Client", "src\NL EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NLightning.Daemon.Plugins", "src\NLightning.Daemon.Plugins\NLightning.Daemon.Plugins.csproj", "{0756C587-913D-41B0-9745-20760612FD41}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NLightning.Transport.Ipc", "src\NLightning.Transport.Ipc\NLightning.Transport.Ipc.csproj", "{7C2E2B7B-0C22-4B31-8E1E-6C5E2A2B5E1C}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NLightning.Transport.Ipc", "src\NLightning.Transport.Ipc\NLightning.Transport.Ipc.csproj", "{A3C18FCE-8C13-4B2F-BD9E-82131750C716}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -404,6 +404,18 @@ Global {0756C587-913D-41B0-9745-20760612FD41}.Debug.Native|Any CPU.Build.0 = Debug|Any CPU {0756C587-913D-41B0-9745-20760612FD41}.Debug.Wasm|Any CPU.ActiveCfg = Debug|Any CPU {0756C587-913D-41B0-9745-20760612FD41}.Debug.Wasm|Any CPU.Build.0 = Debug|Any CPU + {A3C18FCE-8C13-4B2F-BD9E-82131750C716}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A3C18FCE-8C13-4B2F-BD9E-82131750C716}.Release|Any CPU.Build.0 = Release|Any CPU + {A3C18FCE-8C13-4B2F-BD9E-82131750C716}.Release.Native|Any CPU.ActiveCfg = Debug|Any CPU + {A3C18FCE-8C13-4B2F-BD9E-82131750C716}.Release.Native|Any CPU.Build.0 = Debug|Any CPU + {A3C18FCE-8C13-4B2F-BD9E-82131750C716}.Release.Wasm|Any CPU.ActiveCfg = Debug|Any CPU + {A3C18FCE-8C13-4B2F-BD9E-82131750C716}.Release.Wasm|Any CPU.Build.0 = Debug|Any CPU + {A3C18FCE-8C13-4B2F-BD9E-82131750C716}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A3C18FCE-8C13-4B2F-BD9E-82131750C716}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A3C18FCE-8C13-4B2F-BD9E-82131750C716}.Debug.Native|Any CPU.ActiveCfg = Debug|Any CPU + {A3C18FCE-8C13-4B2F-BD9E-82131750C716}.Debug.Native|Any CPU.Build.0 = Debug|Any CPU + {A3C18FCE-8C13-4B2F-BD9E-82131750C716}.Debug.Wasm|Any CPU.ActiveCfg = Debug|Any CPU + {A3C18FCE-8C13-4B2F-BD9E-82131750C716}.Debug.Wasm|Any CPU.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -444,6 +456,6 @@ Global {5DC7356B-99D1-44BD-A134-66D1E111D764} = {123D0631-533C-447F-B7DA-D1D37E5E64BF} {46962F7F-95FB-484C-89DE-0684D03C7845} = {123D0631-533C-447F-B7DA-D1D37E5E64BF} {0756C587-913D-41B0-9745-20760612FD41} = {123D0631-533C-447F-B7DA-D1D37E5E64BF} - {7C2E2B7B-0C22-4B31-8E1E-6C5E2A2B5E1C} = {123D0631-533C-447F-B7DA-D1D37E5E64BF} + {A3C18FCE-8C13-4B2F-BD9E-82131750C716} = {123D0631-533C-447F-B7DA-D1D37E5E64BF} EndGlobalSection EndGlobal diff --git a/src/NLightning.Application/Node/Managers/PeerManager.cs b/src/NLightning.Application/Node/Managers/PeerManager.cs index 9dc6cd4b..7f645127 100644 --- a/src/NLightning.Application/Node/Managers/PeerManager.cs +++ b/src/NLightning.Application/Node/Managers/PeerManager.cs @@ -1,3 +1,4 @@ +using System.Net.Sockets; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -60,7 +61,7 @@ public async Task StartAsync(CancellationToken cancellationToken) var peers = await uow.GetPeersForStartupAsync(); foreach (var peer in peers) { - await ConnectToPeerAsync(peer.PeerAddressInfo, uow); + _ = await ConnectToPeerAsync(peer.PeerAddressInfo, uow); if (!_peers.TryGetValue(peer.NodeId, out _)) { _logger.LogWarning("Unable to connect to peer {PeerId} on startup", peer.NodeId); @@ -115,14 +116,16 @@ public async Task StopAsync() /// /// Thrown when the connection to the peer fails. - public async Task ConnectToPeerAsync(PeerAddressInfo peerAddressInfo) + public async Task ConnectToPeerAsync(PeerAddressInfo peerAddressInfo) { using var scope = _serviceProvider.CreateScope(); using var uow = scope.ServiceProvider.GetRequiredService(); - await ConnectToPeerAsync(peerAddressInfo, uow); + var peer = await ConnectToPeerAsync(peerAddressInfo, uow); await uow.SaveChangesAsync(); + + return peer; } /// @@ -145,7 +148,7 @@ public void DisconnectPeer(CompactPubKey pubKey) } } - private async Task ConnectToPeerAsync(PeerAddressInfo peerAddressInfo, IUnitOfWork uow) + private async Task ConnectToPeerAsync(PeerAddressInfo peerAddressInfo, IUnitOfWork uow) { // Connect to the peer var connectedPeer = await _tcpService.ConnectToPeerAsync(peerAddressInfo); @@ -164,7 +167,8 @@ private async Task ConnectToPeerAsync(PeerAddressInfo peerAddressInfo, IUnitOfWo port = peerService.PreferredPort.Value; } - var peer = new PeerModel(connectedPeer.CompactPubKey, host, port) + var peer = new PeerModel(connectedPeer.CompactPubKey, host, port, + connectedPeer.TcpClient.Client.ProtocolType == ProtocolType.IPv6 ? "IPv6" : "IPv4") { LastSeenAt = DateTime.UtcNow }; @@ -172,7 +176,9 @@ private async Task ConnectToPeerAsync(PeerAddressInfo peerAddressInfo, IUnitOfWo _peers.Add(connectedPeer.CompactPubKey, peer); - uow.PeerDbRepository.AddOrUpdateAsync(peer).GetAwaiter().GetResult(); + await uow.PeerDbRepository.AddOrUpdateAsync(peer); + + return peer; } private void HandleNewPeerConnected(object? _, NewPeerConnectedEventArgs args) @@ -186,7 +192,8 @@ private void HandleNewPeerConnected(object? _, NewPeerConnectedEventArgs args) _logger.LogTrace("PeerService created for peer {PeerPubKey}", peerService.PeerPubKey); - var peer = new PeerModel(peerService.PeerPubKey, args.Host, args.Port) + var peer = new PeerModel(peerService.PeerPubKey, args.Host, args.Port, + args.TcpClient.Client.ProtocolType == ProtocolType.IPv6 ? "IPv6" : "IPv4") { LastSeenAt = DateTime.UtcNow }; diff --git a/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs b/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs index 8882aba2..b1265e4e 100644 --- a/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs +++ b/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs @@ -1,20 +1,23 @@ using System.Buffers; using System.IO.Pipes; using MessagePack; -using NLightning.Daemon.Contracts.Control; +using NLightning.Domain.Node.ValueObjects; namespace NLightning.Client.Ipc; using Daemon.Contracts; +using Daemon.Contracts.Control; using Transport.Ipc; +using Transport.Ipc.Requests; +using Transport.Ipc.Responses; public sealed class NamedPipeIpcClient : IControlClient, IAsyncDisposable { private readonly string _namedPipeFilePath; private readonly string _cookieFilePath; - private readonly string? _server; + private readonly string _server; - public NamedPipeIpcClient(string namedPipeFilePath, string cookieFilePath, string? server = ".") + public NamedPipeIpcClient(string namedPipeFilePath, string cookieFilePath, string server = ".") { _namedPipeFilePath = namedPipeFilePath; _cookieFilePath = cookieFilePath; @@ -23,7 +26,7 @@ public NamedPipeIpcClient(string namedPipeFilePath, string cookieFilePath, strin public async Task GetNodeInfoAsync(CancellationToken ct = default) { - var req = new NodeInfoRequest(); + var req = new NodeInfoIpcRequest(); var payload = MessagePackSerializer.Serialize(req, cancellationToken: ct); var env = new IpcEnvelope { @@ -38,17 +41,38 @@ public async Task GetNodeInfoAsync(CancellationToken ct = defa var respEnv = await SendAsync(env, ct); if (respEnv.Kind != 2) { - var transport = + var ipcResponse = MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); - return new NodeInfoResponse - { - Network = transport.Network, - BestBlockHash = transport.BestBlockHash, - BestBlockHeight = transport.BestBlockHeight, - BestBlockTime = transport.BestBlockTime, - Implementation = transport.Implementation, - Version = transport.Version - }; + return ipcResponse.ToContractResponse(); + } + + var err = MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); + throw new InvalidOperationException($"IPC error {err.Code}: {err.Message}"); + } + + public async Task ConnectPeerAsync(string address, CancellationToken ct = default) + { + var req = new ConnectPeerIpcRequest + { + Address = new PeerAddressInfo(address) + }; + var payload = MessagePackSerializer.Serialize(req, cancellationToken: ct); + var env = new IpcEnvelope + { + Version = 1, + Command = NodeIpcCommand.ConnectPeer, + CorrelationId = Guid.NewGuid(), + AuthToken = await GetAuthTokenAsync(ct), + Payload = payload, + Kind = 0 + }; + + var respEnv = await SendAsync(env, ct); + if (respEnv.Kind != 2) + { + var ipcResponse = + MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); + return ipcResponse.ToContractResponse(); } var err = MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); diff --git a/src/NLightning.Client/NLightning.Client.csproj b/src/NLightning.Client/NLightning.Client.csproj index 10a935f5..fdc59d57 100644 --- a/src/NLightning.Client/NLightning.Client.csproj +++ b/src/NLightning.Client/NLightning.Client.csproj @@ -2,14 +2,12 @@ Exe - net9.0 - enable - enable + diff --git a/src/NLightning.Client/Printers/ConnectPeerPrinter.cs b/src/NLightning.Client/Printers/ConnectPeerPrinter.cs new file mode 100644 index 00000000..2837f9e3 --- /dev/null +++ b/src/NLightning.Client/Printers/ConnectPeerPrinter.cs @@ -0,0 +1,17 @@ +using NLightning.Daemon.Contracts.Control; + +namespace NLightning.Client.Printers; + +public sealed class ConnectPeerPrinter : IPrinter +{ + public void Print(ConnectPeerResponse item) + { + Console.WriteLine("Connected to Peer:"); + Console.WriteLine($" Id: {item.Id}"); + Console.WriteLine($" Features: {item.Features}"); + Console.WriteLine($" Is Initiator: {(item.IsInitiator ? "Yes" : "No")}"); + Console.WriteLine($" Address: {item.Address}"); + Console.WriteLine($" Type: {item.Type}"); + Console.WriteLine($" Port: {item.Port}"); + } +} \ No newline at end of file diff --git a/src/NLightning.Client/Printers/IPrinter.cs b/src/NLightning.Client/Printers/IPrinter.cs new file mode 100644 index 00000000..87584ccb --- /dev/null +++ b/src/NLightning.Client/Printers/IPrinter.cs @@ -0,0 +1,6 @@ +namespace NLightning.Client.Printers; + +public interface IPrinter +{ + void Print(T item); +} \ No newline at end of file diff --git a/src/NLightning.Client/Printers/NodeInfoPrinter.cs b/src/NLightning.Client/Printers/NodeInfoPrinter.cs new file mode 100644 index 00000000..62f24b60 --- /dev/null +++ b/src/NLightning.Client/Printers/NodeInfoPrinter.cs @@ -0,0 +1,18 @@ +using NLightning.Daemon.Contracts.Control; + +namespace NLightning.Client.Printers; + +public sealed class NodeInfoPrinter : IPrinter +{ + public void Print(NodeInfoResponse item) + { + Console.WriteLine("Node Information:"); + Console.WriteLine($" Network: {item.Network}"); + Console.WriteLine($" Best Block Height: {item.BestBlockHeight}"); + Console.WriteLine($" Best Block Hash: {item.BestBlockHash}"); + if (item.BestBlockTime is not null) + Console.WriteLine($" Best Block Time: {item.BestBlockTime:O}"); + Console.WriteLine($" Implementation: {item.Implementation}"); + Console.WriteLine($" Version: {item.Version}"); + } +} \ No newline at end of file diff --git a/src/NLightning.Client/Program.cs b/src/NLightning.Client/Program.cs index c021ed5b..109d2ea2 100644 --- a/src/NLightning.Client/Program.cs +++ b/src/NLightning.Client/Program.cs @@ -1,8 +1,13 @@ +using MessagePack; using NLightning.Client.Ipc; +using NLightning.Client.Printers; using NLightning.Client.Utils; -using NLightning.Daemon.Contracts.Control; using NLightning.Daemon.Contracts.Helpers; using NLightning.Daemon.Contracts.Utilities; +using NLightning.Transport.Ipc.MessagePack; + +// Register the default formatter for MessagePackSerializer +MessagePackSerializer.DefaultOptions = NLightningMessagePackOptions.Options; var cts = new CancellationTokenSource(); Console.CancelKeyPress += (_, e) => @@ -28,12 +33,21 @@ await using var client = new NamedPipeIpcClient(namedPipeFilePath, cookieFilePath); + var commandArgs = CommandLineHelper.GetCommandArguments(cmd, args); + switch (cmd) { - case "node-info": case "info": + case "node-info": var info = await client.GetNodeInfoAsync(cts.Token); - PrintNodeInfo(info); + new NodeInfoPrinter().Print(info); + break; + case "connect": + case "connect-peer": + if (commandArgs.Length == 0) + Console.Error.WriteLine("No arguments specified."); + var response = await client.ConnectPeerAsync(commandArgs[0], cts.Token); + new ConnectPeerPrinter().Print(response); break; default: Console.Error.WriteLine($"Unknown command: {cmd}"); @@ -48,16 +62,4 @@ return 1; } -return 0; - -static void PrintNodeInfo(NodeInfoResponse info) -{ - Console.WriteLine("Node Information:"); - Console.WriteLine($" Network: {info.Network}"); - Console.WriteLine($" Best Block Height: {info.BestBlockHeight}"); - Console.WriteLine($" Best Block Hash: {info.BestBlockHash}"); - if (info.BestBlockTime is not null) - Console.WriteLine($" Best Block Time: {info.BestBlockTime:O}"); - Console.WriteLine($" Implementation: {info.Implementation}"); - Console.WriteLine($" Version: {info.Version}"); -} \ No newline at end of file +return 0; \ No newline at end of file diff --git a/src/NLightning.Client/Utils/ClientUtils.cs b/src/NLightning.Client/Utils/ClientUtils.cs index c526f339..c47766ed 100644 --- a/src/NLightning.Client/Utils/ClientUtils.cs +++ b/src/NLightning.Client/Utils/ClientUtils.cs @@ -11,10 +11,11 @@ public static void ShowUsage() Console.WriteLine("Options:"); Console.WriteLine(" --network, -n Network to use (mainnet, testnet, regtest) [default: mainnet]"); Console.WriteLine(" --cookie, -c Path to cookie file"); + Console.WriteLine(" --help, -h, -? Show this help message"); Console.WriteLine(); Console.WriteLine("Commands:"); Console.WriteLine(" node-info | info Get node information via IPC"); - Console.WriteLine(" --help, -h, -? Show this help message"); + Console.WriteLine(" connect Connect to a peer node"); Console.WriteLine(); Console.WriteLine("Environment Variables:"); Console.WriteLine(" NLTG_NETWORK Network to use"); diff --git a/src/NLightning.Daemon.Contracts/Control/ConnectPeerResponse.cs b/src/NLightning.Daemon.Contracts/Control/ConnectPeerResponse.cs new file mode 100644 index 00000000..b3d355b9 --- /dev/null +++ b/src/NLightning.Daemon.Contracts/Control/ConnectPeerResponse.cs @@ -0,0 +1,14 @@ +namespace NLightning.Daemon.Contracts.Control; + +/// +/// Transport-agnostic response for ConnectPeer command. +/// +public sealed class ConnectPeerResponse +{ + public string Id { get; set; } = string.Empty; + public string Features { get; set; } = string.Empty; + public bool IsInitiator { get; set; } + public string Address { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public uint Port { get; set; } +} \ No newline at end of file diff --git a/src/NLightning.Daemon.Contracts/Control/NodeInfo.cs b/src/NLightning.Daemon.Contracts/Control/NodeInfoResponse.cs similarity index 100% rename from src/NLightning.Daemon.Contracts/Control/NodeInfo.cs rename to src/NLightning.Daemon.Contracts/Control/NodeInfoResponse.cs diff --git a/src/NLightning.Daemon.Contracts/Helpers/CommandLineHelper.cs b/src/NLightning.Daemon.Contracts/Helpers/CommandLineHelper.cs index 5ea7ec47..fb4b561d 100644 --- a/src/NLightning.Daemon.Contracts/Helpers/CommandLineHelper.cs +++ b/src/NLightning.Daemon.Contracts/Helpers/CommandLineHelper.cs @@ -19,24 +19,39 @@ public static bool IsHelpRequested(string[] args) { for (var i = 0; i < args.Length; i++) { - if (args[i].StartsWith("-n") - || args[i].StartsWith("--network") - || args[i].StartsWith("-c") - || args[i].StartsWith("--cookie")) + if (IsOption(args[i])) { i++; continue; } - if (args[i].StartsWith('-') || args[i].StartsWith("--")) - continue; - return args[i].ToLowerInvariant(); } return null; } + public static string[] GetCommandArguments(string command, string[] args) + { + var cmdArgs = new List(); + var cmdFound = false; + + for (var i = 0; i < args.Length; i++) + { + if (!cmdFound) + { + if (args[i].Equals(command, StringComparison.OrdinalIgnoreCase)) + cmdFound = true; + + continue; + } + + cmdArgs.Add(args[i]); + } + + return cmdArgs.ToArray(); + } + public static string GetNetwork(string[] args) { var network = "mainnet"; // Default @@ -72,4 +87,9 @@ public static string GetNetwork(string[] args) return network; } + + private static bool IsOption(string arg) => arg.StartsWith("-n") + || arg.StartsWith("--network") + || arg.StartsWith("-c") + || arg.StartsWith("--cookie"); } \ No newline at end of file diff --git a/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs b/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs index a46a82d0..8b78b953 100644 --- a/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs +++ b/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs @@ -55,6 +55,7 @@ public static IHostBuilder ConfigureNltgServices(this IHostBuilder hostBuilder, services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => { var nodeOptions = sp.GetRequiredService>().Value; diff --git a/src/NLightning.Daemon/Handlers/ConnectIpcHandler.cs b/src/NLightning.Daemon/Handlers/ConnectIpcHandler.cs new file mode 100644 index 00000000..52554da9 --- /dev/null +++ b/src/NLightning.Daemon/Handlers/ConnectIpcHandler.cs @@ -0,0 +1,93 @@ +using MessagePack; +using Microsoft.Extensions.Logging; +using NLightning.Transport.Ipc.Responses; + +namespace NLightning.Daemon.Handlers; + +using Domain.Node.Interfaces; +using Interfaces; +using Transport.Ipc; +using Transport.Ipc.Requests; + +public sealed class ConnectIpcHandler : IIpcCommandHandler +{ + private readonly IPeerManager _peerManager; + private readonly ILogger _logger; + + public ConnectIpcHandler(IPeerManager peerManager, ILogger logger) + { + _peerManager = peerManager; + _logger = logger; + } + + public NodeIpcCommand Command => NodeIpcCommand.ConnectPeer; + + public async Task HandleAsync(IpcEnvelope envelope, CancellationToken ct) + { + try + { + // Deserialize the request + var request = + MessagePackSerializer.Deserialize(envelope.Payload, cancellationToken: ct); + + // Validate the address + if (string.IsNullOrWhiteSpace(request.Address.Address)) + return CreateErrorResponse(envelope, "Invalid address: address cannot be empty"); + + // Parse and connect to the peer + var peer = await _peerManager.ConnectToPeerAsync(request.Address); + + _logger.LogInformation("Successfully connected to peer at {Address}", request.Address); + + // Create a success response + var response = new ConnectPeerIpcResponse + { + Id = peer.NodeId, + Features = peer.Features, + IsInitiator = true, + Address = peer.Host, + Type = peer.Type, + Port = peer.Port + }; + + var payload = MessagePackSerializer.Serialize(response, cancellationToken: ct); + return new IpcEnvelope + { + Version = envelope.Version, + Command = envelope.Command, + CorrelationId = envelope.CorrelationId, + Kind = 1, + Payload = payload + }; + } + catch (FormatException ex) + { + _logger.LogWarning(ex, "Invalid peer address format"); + return CreateErrorResponse(envelope, $"Invalid address format: {ex.Message}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to connect to peer"); + return CreateErrorResponse(envelope, $"Connection failed: {ex.Message}"); + } + } + + private static IpcEnvelope CreateErrorResponse(IpcEnvelope envelope, string errorMessage) + { + var response = new IpcError + { + Code = "1", + Message = errorMessage + }; + + var payload = MessagePackSerializer.Serialize(response); + return new IpcEnvelope + { + Version = envelope.Version, + Command = envelope.Command, + CorrelationId = envelope.CorrelationId, + Kind = 1, + Payload = payload + }; + } +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Handlers/NodeInfoIpcHandler.cs b/src/NLightning.Daemon/Handlers/NodeInfoIpcHandler.cs index e5e3d76b..81f259ea 100644 --- a/src/NLightning.Daemon/Handlers/NodeInfoIpcHandler.cs +++ b/src/NLightning.Daemon/Handlers/NodeInfoIpcHandler.cs @@ -1,9 +1,11 @@ using MessagePack; +using NLightning.Domain.Crypto.ValueObjects; namespace NLightning.Daemon.Handlers; using Interfaces; using Transport.Ipc; +using Transport.Ipc.Responses; public sealed class NodeInfoIpcHandler : IIpcCommandHandler { @@ -19,7 +21,16 @@ public NodeInfoIpcHandler(INodeInfoQueryService query) public async Task HandleAsync(IpcEnvelope envelope, CancellationToken ct) { var resp = await _query.QueryAsync(ct); - var payload = MessagePackSerializer.Serialize(resp, cancellationToken: ct); + var ipcResp = new NodeInfoIpcResponse + { + Network = resp.Network, + BestBlockHash = new Hash(Convert.FromHexString(resp.BestBlockHash)), + BestBlockHeight = resp.BestBlockHeight, + BestBlockTime = resp.BestBlockTime, + Implementation = resp.Implementation, + Version = resp.Version + }; + var payload = MessagePackSerializer.Serialize(ipcResp, cancellationToken: ct); return new IpcEnvelope { Version = envelope.Version, diff --git a/src/NLightning.Daemon/NLightning.Daemon.csproj b/src/NLightning.Daemon/NLightning.Daemon.csproj index a5b507be..0faf968c 100644 --- a/src/NLightning.Daemon/NLightning.Daemon.csproj +++ b/src/NLightning.Daemon/NLightning.Daemon.csproj @@ -34,6 +34,7 @@ + @@ -43,7 +44,6 @@ - diff --git a/src/NLightning.Daemon/Program.cs b/src/NLightning.Daemon/Program.cs index 14f9f3f3..b36fe5c2 100644 --- a/src/NLightning.Daemon/Program.cs +++ b/src/NLightning.Daemon/Program.cs @@ -1,3 +1,4 @@ +using MessagePack; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -12,6 +13,7 @@ using NLightning.Infrastructure.Bitcoin.Managers; using NLightning.Infrastructure.Bitcoin.Options; using NLightning.Infrastructure.Bitcoin.Wallet; +using NLightning.Transport.Ipc.MessagePack; using Serilog; try @@ -129,6 +131,9 @@ return 0; } + // Register the default formatter for MessagePackSerializer + MessagePackSerializer.DefaultOptions = NLightningMessagePackOptions.Options; + Log.Information("Starting NLTG..."); // Create and run host diff --git a/src/NLightning.Domain/Crypto/ValueObjects/Hash.cs b/src/NLightning.Domain/Crypto/ValueObjects/Hash.cs index d30d1e89..f22f7308 100644 --- a/src/NLightning.Domain/Crypto/ValueObjects/Hash.cs +++ b/src/NLightning.Domain/Crypto/ValueObjects/Hash.cs @@ -21,6 +21,7 @@ public Hash(byte[] value) public static implicit operator byte[](Hash hash) => hash._value; public static implicit operator ReadOnlyMemory(Hash hash) => hash._value; + public static implicit operator ReadOnlySpan(Hash hash) => hash._value; public override string ToString() => Convert.ToHexString(_value).ToLowerInvariant(); @@ -50,4 +51,14 @@ public override int GetHashCode() { return _value.GetByteArrayHashCode(); } + + public static bool operator ==(Hash left, Hash right) + { + return left.Equals(right); + } + + public static bool operator !=(Hash left, Hash right) + { + return !(left == right); + } } \ No newline at end of file diff --git a/src/NLightning.Domain/Node/FeatureSet.cs b/src/NLightning.Domain/Node/FeatureSet.cs index 9cb56933..cee587a7 100644 --- a/src/NLightning.Domain/Node/FeatureSet.cs +++ b/src/NLightning.Domain/Node/FeatureSet.cs @@ -1,10 +1,10 @@ using System.Collections; using System.Runtime.Serialization; using System.Text; -using NLightning.Domain.Utils.Interfaces; namespace NLightning.Domain.Node; +using Domain.Utils.Interfaces; using Enums; /// diff --git a/src/NLightning.Domain/Node/Interfaces/IPeerManager.cs b/src/NLightning.Domain/Node/Interfaces/IPeerManager.cs index 71275bb0..2e6e7737 100644 --- a/src/NLightning.Domain/Node/Interfaces/IPeerManager.cs +++ b/src/NLightning.Domain/Node/Interfaces/IPeerManager.cs @@ -1,4 +1,5 @@ using NLightning.Domain.Crypto.ValueObjects; +using NLightning.Domain.Node.Models; using NLightning.Domain.Node.ValueObjects; namespace NLightning.Domain.Node.Interfaces; @@ -26,7 +27,7 @@ public interface IPeerManager /// /// The peer address to connect to. /// A task that represents the asynchronous operation. - Task ConnectToPeerAsync(PeerAddressInfo peerAddressInfo); + Task ConnectToPeerAsync(PeerAddressInfo peerAddressInfo); /// /// Disconnects a peer. diff --git a/src/NLightning.Domain/Node/Models/PeerModel.cs b/src/NLightning.Domain/Node/Models/PeerModel.cs index 05ba09e3..3063290c 100644 --- a/src/NLightning.Domain/Node/Models/PeerModel.cs +++ b/src/NLightning.Domain/Node/Models/PeerModel.cs @@ -16,8 +16,19 @@ public class PeerModel public CompactPubKey NodeId { get; } public string Host { get; } public uint Port { get; } + public string Type { get; } public DateTime LastSeenAt { get; set; } + public FeatureSet Features + { + get + { + return _peerService is null + ? throw new NullReferenceException($"{nameof(PeerModel)}.{nameof(Features)} was null") + : _peerService.Features.GetNodeFeatures(); + } + } + public PeerAddressInfo PeerAddressInfo { get @@ -30,11 +41,12 @@ public PeerAddressInfo PeerAddressInfo public ICollection? Channels { get; set; } - public PeerModel(CompactPubKey nodeId, string host, uint port) + public PeerModel(CompactPubKey nodeId, string host, uint port, string type) { NodeId = nodeId; Host = host; Port = port; + Type = type; } public bool TryGetPeerService([MaybeNullWhen(false)] out IPeerService peerService) diff --git a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251204210605_AddBlockchaisStateAndWatchedTransaction.Designer.cs b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20250612173122_AddBlockchaisStateAndWatchedTransaction.Designer.cs similarity index 100% rename from src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251204210605_AddBlockchaisStateAndWatchedTransaction.Designer.cs rename to src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20250612173122_AddBlockchaisStateAndWatchedTransaction.Designer.cs diff --git a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251204210605_AddBlockchaisStateAndWatchedTransaction.cs b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20250612173122_AddBlockchaisStateAndWatchedTransaction.cs similarity index 100% rename from src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251204210605_AddBlockchaisStateAndWatchedTransaction.cs rename to src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20250612173122_AddBlockchaisStateAndWatchedTransaction.cs diff --git a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251023145101_AddPeerType.Designer.cs b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251023145101_AddPeerType.Designer.cs new file mode 100644 index 00000000..36e0d878 --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251023145101_AddPeerType.Designer.cs @@ -0,0 +1,451 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NLightning.Infrastructure.Persistence.Contexts; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NLightning.Infrastructure.Persistence.Postgres.Migrations +{ + [DbContext(typeof(NLightningDbContext))] + [Migration("20251023145101_AddPeerType")] + partial class AddPeerType + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.BlockchainStateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LastProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_processed_at"); + + b.Property("LastProcessedBlockHash") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("last_processed_block_hash"); + + b.Property("LastProcessedHeight") + .HasColumnType("bigint") + .HasColumnName("last_processed_height"); + + b.HasKey("Id") + .HasName("pk_blockchain_states"); + + b.ToTable("blockchain_states", (string)null); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => + { + b.Property("TransactionId") + .HasColumnType("bytea") + .HasColumnName("transaction_id"); + + b.Property("ChannelId") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("channel_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FirstSeenAtHeight") + .HasColumnType("bigint") + .HasColumnName("first_seen_at_height"); + + b.Property("RequiredDepth") + .HasColumnType("bigint") + .HasColumnName("required_depth"); + + b.Property("TransactionIndex") + .HasColumnType("integer") + .HasColumnName("transaction_index"); + + b.HasKey("TransactionId") + .HasName("pk_watched_transactions"); + + b.HasIndex("ChannelId") + .HasDatabaseName("ix_watched_transactions_channel_id"); + + b.ToTable("watched_transactions", (string)null); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelConfigEntity", b => + { + b.Property("ChannelId") + .HasColumnType("bytea") + .HasColumnName("channel_id"); + + b.Property("ChannelReserveAmountSats") + .HasColumnType("bigint") + .HasColumnName("channel_reserve_amount_sats"); + + b.Property("FeeRatePerKwSatoshis") + .HasColumnType("bigint") + .HasColumnName("fee_rate_per_kw_satoshis"); + + b.Property("HtlcMinimumMsat") + .HasColumnType("numeric(20,0)") + .HasColumnName("htlc_minimum_msat"); + + b.Property("LocalDustLimitAmountSats") + .HasColumnType("bigint") + .HasColumnName("local_dust_limit_amount_sats"); + + b.Property("LocalUpfrontShutdownScript") + .HasColumnType("bytea") + .HasColumnName("local_upfront_shutdown_script"); + + b.Property("MaxAcceptedHtlcs") + .HasColumnType("integer") + .HasColumnName("max_accepted_htlcs"); + + b.Property("MaxHtlcAmountInFlight") + .HasColumnType("numeric(20,0)") + .HasColumnName("max_htlc_amount_in_flight"); + + b.Property("MinimumDepth") + .HasColumnType("bigint") + .HasColumnName("minimum_depth"); + + b.Property("OptionAnchorOutputs") + .HasColumnType("boolean") + .HasColumnName("option_anchor_outputs"); + + b.Property("RemoteDustLimitAmountSats") + .HasColumnType("bigint") + .HasColumnName("remote_dust_limit_amount_sats"); + + b.Property("RemoteUpfrontShutdownScript") + .HasColumnType("bytea") + .HasColumnName("remote_upfront_shutdown_script"); + + b.Property("ToSelfDelay") + .HasColumnType("integer") + .HasColumnName("to_self_delay"); + + b.Property("UseScidAlias") + .HasColumnType("smallint") + .HasColumnName("use_scid_alias"); + + b.HasKey("ChannelId") + .HasName("pk_channel_configs"); + + b.ToTable("channel_configs", (string)null); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", b => + { + b.Property("ChannelId") + .HasColumnType("bytea") + .HasColumnName("channel_id"); + + b.Property("FundingAmountSatoshis") + .HasColumnType("bigint") + .HasColumnName("funding_amount_satoshis"); + + b.Property("FundingCreatedAtBlockHeight") + .HasColumnType("bigint") + .HasColumnName("funding_created_at_block_height"); + + b.Property("FundingOutputIndex") + .HasColumnType("integer") + .HasColumnName("funding_output_index"); + + b.Property("FundingTxId") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("funding_tx_id"); + + b.Property("IsInitiator") + .HasColumnType("boolean") + .HasColumnName("is_initiator"); + + b.Property("LastReceivedSignature") + .HasColumnType("bytea") + .HasColumnName("last_received_signature"); + + b.Property("LastSentSignature") + .HasColumnType("bytea") + .HasColumnName("last_sent_signature"); + + b.Property("LocalBalanceSatoshis") + .HasColumnType("numeric") + .HasColumnName("local_balance_satoshis"); + + b.Property("LocalNextHtlcId") + .HasColumnType("numeric(20,0)") + .HasColumnName("local_next_htlc_id"); + + b.Property("LocalRevocationNumber") + .HasColumnType("numeric(20,0)") + .HasColumnName("local_revocation_number"); + + b.Property("PeerEntityNodeId") + .HasColumnType("bytea") + .HasColumnName("peer_entity_node_id"); + + b.Property("RemoteBalanceSatoshis") + .HasColumnType("numeric") + .HasColumnName("remote_balance_satoshis"); + + b.Property("RemoteNextHtlcId") + .HasColumnType("numeric(20,0)") + .HasColumnName("remote_next_htlc_id"); + + b.Property("RemoteNodeId") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("remote_node_id"); + + b.Property("RemoteRevocationNumber") + .HasColumnType("numeric(20,0)") + .HasColumnName("remote_revocation_number"); + + b.Property("State") + .HasColumnType("smallint") + .HasColumnName("state"); + + b.Property("Version") + .HasColumnType("smallint") + .HasColumnName("version"); + + b.HasKey("ChannelId") + .HasName("pk_channels"); + + b.HasIndex("PeerEntityNodeId") + .HasDatabaseName("ix_channels_peer_entity_node_id"); + + b.ToTable("channels", (string)null); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelKeySetEntity", b => + { + b.Property("ChannelId") + .HasColumnType("bytea") + .HasColumnName("channel_id"); + + b.Property("IsLocal") + .HasColumnType("boolean") + .HasColumnName("is_local"); + + b.Property("CurrentPerCommitmentIndex") + .HasColumnType("numeric(20,0)") + .HasColumnName("current_per_commitment_index"); + + b.Property("CurrentPerCommitmentPoint") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("current_per_commitment_point"); + + b.Property("DelayedPaymentBasepoint") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("delayed_payment_basepoint"); + + b.Property("FundingPubKey") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("funding_pub_key"); + + b.Property("HtlcBasepoint") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("htlc_basepoint"); + + b.Property("KeyIndex") + .HasColumnType("bigint") + .HasColumnName("key_index"); + + b.Property("LastRevealedPerCommitmentSecret") + .HasColumnType("bytea") + .HasColumnName("last_revealed_per_commitment_secret"); + + b.Property("PaymentBasepoint") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("payment_basepoint"); + + b.Property("RevocationBasepoint") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("revocation_basepoint"); + + b.HasKey("ChannelId", "IsLocal") + .HasName("pk_channel_key_sets"); + + b.ToTable("channel_key_sets", (string)null); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.HtlcEntity", b => + { + b.Property("ChannelId") + .HasColumnType("bytea") + .HasColumnName("channel_id"); + + b.Property("HtlcId") + .HasColumnType("numeric(20,0)") + .HasColumnName("htlc_id"); + + b.Property("Direction") + .HasColumnType("smallint") + .HasColumnName("direction"); + + b.Property("AddMessageBytes") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("add_message_bytes"); + + b.Property("AmountMsat") + .HasColumnType("numeric(20,0)") + .HasColumnName("amount_msat"); + + b.Property("CltvExpiry") + .HasColumnType("bigint") + .HasColumnName("cltv_expiry"); + + b.Property("ObscuredCommitmentNumber") + .HasColumnType("numeric(20,0)") + .HasColumnName("obscured_commitment_number"); + + b.Property("PaymentHash") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("payment_hash"); + + b.Property("PaymentPreimage") + .HasColumnType("bytea") + .HasColumnName("payment_preimage"); + + b.Property("Signature") + .HasColumnType("bytea") + .HasColumnName("signature"); + + b.Property("State") + .HasColumnType("smallint") + .HasColumnName("state"); + + b.HasKey("ChannelId", "HtlcId", "Direction") + .HasName("pk_htlcs"); + + b.ToTable("htlcs", (string)null); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Node.PeerEntity", b => + { + b.Property("NodeId") + .HasColumnType("bytea") + .HasColumnName("node_id"); + + b.Property("Host") + .IsRequired() + .HasColumnType("text") + .HasColumnName("host"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_at"); + + b.Property("Port") + .HasColumnType("bigint") + .HasColumnName("port"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("NodeId") + .HasName("pk_peers"); + + b.ToTable("peers", (string)null); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) + .WithMany("WatchedTransactions") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_watched_transactions_channels_channel_id"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelConfigEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) + .WithOne("Config") + .HasForeignKey("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelConfigEntity", "ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_channel_configs_channels_channel_id"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Node.PeerEntity", null) + .WithMany("Channels") + .HasForeignKey("PeerEntityNodeId") + .HasConstraintName("fk_channels_peers_peer_entity_node_id"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelKeySetEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) + .WithMany("KeySets") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_channel_key_sets_channels_channel_id"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.HtlcEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) + .WithMany("Htlcs") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_htlcs_channels_channel_id"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", b => + { + b.Navigation("Config"); + + b.Navigation("Htlcs"); + + b.Navigation("KeySets"); + + b.Navigation("WatchedTransactions"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Node.PeerEntity", b => + { + b.Navigation("Channels"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251023145101_AddPeerType.cs b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251023145101_AddPeerType.cs new file mode 100644 index 00000000..12b15f8a --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251023145101_AddPeerType.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NLightning.Infrastructure.Persistence.Postgres.Migrations +{ + /// + public partial class AddPeerType : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "type", + table: "peers", + type: "text", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "type", + table: "peers"); + } + } +} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/NLightningDbContextModelSnapshot.cs b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/NLightningDbContextModelSnapshot.cs index 55222efa..ae9736d5 100644 --- a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/NLightningDbContextModelSnapshot.cs +++ b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/NLightningDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.12") + .HasAnnotation("ProductVersion", "9.0.9") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -368,6 +368,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("bigint") .HasColumnName("port"); + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + b.HasKey("NodeId") .HasName("pk_peers"); diff --git a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251023145110_AddPeerType.Designer.cs b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251023145110_AddPeerType.Designer.cs new file mode 100644 index 00000000..56cd3425 --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251023145110_AddPeerType.Designer.cs @@ -0,0 +1,367 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NLightning.Infrastructure.Persistence.Contexts; + +#nullable disable + +namespace NLightning.Infrastructure.Persistence.SqlServer.Migrations +{ + [DbContext(typeof(NLightningDbContext))] + [Migration("20251023145110_AddPeerType")] + partial class AddPeerType + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.BlockchainStateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("LastProcessedAt") + .HasColumnType("datetime2"); + + b.Property("LastProcessedBlockHash") + .IsRequired() + .HasColumnType("varbinary(32)"); + + b.Property("LastProcessedHeight") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("BlockchainStates"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => + { + b.Property("TransactionId") + .HasColumnType("varbinary(32)"); + + b.Property("ChannelId") + .IsRequired() + .HasColumnType("varbinary(32)"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("FirstSeenAtHeight") + .HasColumnType("bigint"); + + b.Property("RequiredDepth") + .HasColumnType("bigint"); + + b.Property("TransactionIndex") + .HasColumnType("int"); + + b.HasKey("TransactionId"); + + b.HasIndex("ChannelId"); + + b.ToTable("WatchedTransactions"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelConfigEntity", b => + { + b.Property("ChannelId") + .HasColumnType("varbinary(32)"); + + b.Property("ChannelReserveAmountSats") + .HasColumnType("bigint"); + + b.Property("FeeRatePerKwSatoshis") + .HasColumnType("bigint"); + + b.Property("HtlcMinimumMsat") + .HasColumnType("decimal(20,0)"); + + b.Property("LocalDustLimitAmountSats") + .HasColumnType("bigint"); + + b.Property("LocalUpfrontShutdownScript") + .HasColumnType("varbinary(max)"); + + b.Property("MaxAcceptedHtlcs") + .HasColumnType("int"); + + b.Property("MaxHtlcAmountInFlight") + .HasColumnType("decimal(20,0)"); + + b.Property("MinimumDepth") + .HasColumnType("bigint"); + + b.Property("OptionAnchorOutputs") + .HasColumnType("bit"); + + b.Property("RemoteDustLimitAmountSats") + .HasColumnType("bigint"); + + b.Property("RemoteUpfrontShutdownScript") + .HasColumnType("varbinary(max)"); + + b.Property("ToSelfDelay") + .HasColumnType("int"); + + b.Property("UseScidAlias") + .HasColumnType("tinyint"); + + b.HasKey("ChannelId"); + + b.ToTable("ChannelConfigs"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", b => + { + b.Property("ChannelId") + .HasColumnType("varbinary(32)"); + + b.Property("FundingAmountSatoshis") + .HasColumnType("bigint"); + + b.Property("FundingCreatedAtBlockHeight") + .HasColumnType("bigint"); + + b.Property("FundingOutputIndex") + .HasColumnType("int"); + + b.Property("FundingTxId") + .IsRequired() + .HasColumnType("varbinary(32)"); + + b.Property("IsInitiator") + .HasColumnType("bit"); + + b.Property("LastReceivedSignature") + .HasColumnType("varbinary(64)"); + + b.Property("LastSentSignature") + .HasColumnType("varbinary(64)"); + + b.Property("LocalBalanceSatoshis") + .HasColumnType("bigint"); + + b.Property("LocalNextHtlcId") + .HasColumnType("decimal(20,0)"); + + b.Property("LocalRevocationNumber") + .HasColumnType("decimal(20,0)"); + + b.Property("PeerEntityNodeId") + .HasColumnType("varbinary(33)"); + + b.Property("RemoteBalanceSatoshis") + .HasColumnType("bigint"); + + b.Property("RemoteNextHtlcId") + .HasColumnType("decimal(20,0)"); + + b.Property("RemoteNodeId") + .IsRequired() + .HasColumnType("varbinary(32)"); + + b.Property("RemoteRevocationNumber") + .HasColumnType("decimal(20,0)"); + + b.Property("State") + .HasColumnType("tinyint"); + + b.Property("Version") + .HasColumnType("tinyint"); + + b.HasKey("ChannelId"); + + b.HasIndex("PeerEntityNodeId"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelKeySetEntity", b => + { + b.Property("ChannelId") + .HasColumnType("varbinary(32)"); + + b.Property("IsLocal") + .HasColumnType("bit"); + + b.Property("CurrentPerCommitmentIndex") + .HasColumnType("decimal(20,0)"); + + b.Property("CurrentPerCommitmentPoint") + .IsRequired() + .HasColumnType("varbinary(33)"); + + b.Property("DelayedPaymentBasepoint") + .IsRequired() + .HasColumnType("varbinary(33)"); + + b.Property("FundingPubKey") + .IsRequired() + .HasColumnType("varbinary(33)"); + + b.Property("HtlcBasepoint") + .IsRequired() + .HasColumnType("varbinary(33)"); + + b.Property("KeyIndex") + .HasColumnType("bigint"); + + b.Property("LastRevealedPerCommitmentSecret") + .HasColumnType("varbinary(max)"); + + b.Property("PaymentBasepoint") + .IsRequired() + .HasColumnType("varbinary(33)"); + + b.Property("RevocationBasepoint") + .IsRequired() + .HasColumnType("varbinary(33)"); + + b.HasKey("ChannelId", "IsLocal"); + + b.ToTable("ChannelKeySets"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.HtlcEntity", b => + { + b.Property("ChannelId") + .HasColumnType("varbinary(32)"); + + b.Property("HtlcId") + .HasColumnType("decimal(20,0)"); + + b.Property("Direction") + .HasColumnType("tinyint"); + + b.Property("AddMessageBytes") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("AmountMsat") + .HasColumnType("decimal(20,0)"); + + b.Property("CltvExpiry") + .HasColumnType("bigint"); + + b.Property("ObscuredCommitmentNumber") + .HasColumnType("decimal(20,0)"); + + b.Property("PaymentHash") + .IsRequired() + .HasColumnType("varbinary(32)"); + + b.Property("PaymentPreimage") + .HasColumnType("varbinary(32)"); + + b.Property("Signature") + .HasColumnType("varbinary(max)"); + + b.Property("State") + .HasColumnType("tinyint"); + + b.HasKey("ChannelId", "HtlcId", "Direction"); + + b.ToTable("Htlcs"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Node.PeerEntity", b => + { + b.Property("NodeId") + .HasColumnType("varbinary(33)"); + + b.Property("Host") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2"); + + b.Property("Port") + .HasColumnType("bigint"); + + b.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("NodeId"); + + b.ToTable("Peers"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) + .WithMany("WatchedTransactions") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelConfigEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) + .WithOne("Config") + .HasForeignKey("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelConfigEntity", "ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Node.PeerEntity", null) + .WithMany("Channels") + .HasForeignKey("PeerEntityNodeId"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelKeySetEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) + .WithMany("KeySets") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.HtlcEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) + .WithMany("Htlcs") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", b => + { + b.Navigation("Config"); + + b.Navigation("Htlcs"); + + b.Navigation("KeySets"); + + b.Navigation("WatchedTransactions"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Node.PeerEntity", b => + { + b.Navigation("Channels"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251023145110_AddPeerType.cs b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251023145110_AddPeerType.cs new file mode 100644 index 00000000..59ae1c5c --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251023145110_AddPeerType.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NLightning.Infrastructure.Persistence.SqlServer.Migrations +{ + /// + public partial class AddPeerType : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Type", + table: "Peers", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Type", + table: "Peers"); + } + } +} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/NLightningDbContextModelSnapshot.cs b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/NLightningDbContextModelSnapshot.cs index 0d323cc9..60bc0e75 100644 --- a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/NLightningDbContextModelSnapshot.cs +++ b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/NLightningDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.12") + .HasAnnotation("ProductVersion", "9.0.9") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -291,6 +291,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Port") .HasColumnType("bigint"); + b.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(max)"); + b.HasKey("NodeId"); b.ToTable("Peers"); diff --git a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20250612173134_AddBlockchaisStateAndWatchedTransaction.Designer.cs b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20250612173134_AddBlockchaisStateAndWatchedTransaction.Designer.cs new file mode 100644 index 00000000..538018ae --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20250612173134_AddBlockchaisStateAndWatchedTransaction.Designer.cs @@ -0,0 +1,358 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NLightning.Infrastructure.Persistence.Contexts; + +#nullable disable + +namespace NLightning.Infrastructure.Persistence.Sqlite.Migrations +{ + [DbContext(typeof(NLightningDbContext))] + [Migration("20250612173134_AddBlockchaisStateAndWatchedTransaction")] + partial class AddBlockchaisStateAndWatchedTransaction + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.12"); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.BlockchainStateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("LastProcessedAt") + .HasColumnType("TEXT"); + + b.Property("LastProcessedBlockHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("LastProcessedHeight") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("BlockchainStates"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => + { + b.Property("TransactionId") + .HasColumnType("BLOB"); + + b.Property("ChannelId") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FirstSeenAtHeight") + .HasColumnType("INTEGER"); + + b.Property("RequiredDepth") + .HasColumnType("INTEGER"); + + b.Property("TransactionIndex") + .HasColumnType("INTEGER"); + + b.HasKey("TransactionId"); + + b.HasIndex("ChannelId"); + + b.ToTable("WatchedTransactions"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelConfigEntity", b => + { + b.Property("ChannelId") + .HasColumnType("BLOB"); + + b.Property("ChannelReserveAmountSats") + .HasColumnType("INTEGER"); + + b.Property("FeeRatePerKwSatoshis") + .HasColumnType("INTEGER"); + + b.Property("HtlcMinimumMsat") + .HasColumnType("INTEGER"); + + b.Property("LocalDustLimitAmountSats") + .HasColumnType("INTEGER"); + + b.Property("LocalUpfrontShutdownScript") + .HasColumnType("BLOB"); + + b.Property("MaxAcceptedHtlcs") + .HasColumnType("INTEGER"); + + b.Property("MaxHtlcAmountInFlight") + .HasColumnType("INTEGER"); + + b.Property("MinimumDepth") + .HasColumnType("INTEGER"); + + b.Property("OptionAnchorOutputs") + .HasColumnType("INTEGER"); + + b.Property("RemoteDustLimitAmountSats") + .HasColumnType("INTEGER"); + + b.Property("RemoteUpfrontShutdownScript") + .HasColumnType("BLOB"); + + b.Property("ToSelfDelay") + .HasColumnType("INTEGER"); + + b.Property("UseScidAlias") + .HasColumnType("INTEGER"); + + b.HasKey("ChannelId"); + + b.ToTable("ChannelConfigs"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", b => + { + b.Property("ChannelId") + .HasColumnType("BLOB"); + + b.Property("FundingAmountSatoshis") + .HasColumnType("INTEGER"); + + b.Property("FundingCreatedAtBlockHeight") + .HasColumnType("INTEGER"); + + b.Property("FundingOutputIndex") + .HasColumnType("INTEGER"); + + b.Property("FundingTxId") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("IsInitiator") + .HasColumnType("INTEGER"); + + b.Property("LastReceivedSignature") + .HasColumnType("BLOB"); + + b.Property("LastSentSignature") + .HasColumnType("BLOB"); + + b.Property("LocalBalanceSatoshis") + .HasColumnType("TEXT"); + + b.Property("LocalNextHtlcId") + .HasColumnType("INTEGER"); + + b.Property("LocalRevocationNumber") + .HasColumnType("INTEGER"); + + b.Property("PeerEntityNodeId") + .HasColumnType("BLOB"); + + b.Property("RemoteBalanceSatoshis") + .HasColumnType("TEXT"); + + b.Property("RemoteNextHtlcId") + .HasColumnType("INTEGER"); + + b.Property("RemoteNodeId") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("RemoteRevocationNumber") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("ChannelId"); + + b.HasIndex("PeerEntityNodeId"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelKeySetEntity", b => + { + b.Property("ChannelId") + .HasColumnType("BLOB"); + + b.Property("IsLocal") + .HasColumnType("INTEGER"); + + b.Property("CurrentPerCommitmentIndex") + .HasColumnType("INTEGER"); + + b.Property("CurrentPerCommitmentPoint") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("DelayedPaymentBasepoint") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("FundingPubKey") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("HtlcBasepoint") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("KeyIndex") + .HasColumnType("INTEGER"); + + b.Property("LastRevealedPerCommitmentSecret") + .HasColumnType("BLOB"); + + b.Property("PaymentBasepoint") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("RevocationBasepoint") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("ChannelId", "IsLocal"); + + b.ToTable("ChannelKeySets"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.HtlcEntity", b => + { + b.Property("ChannelId") + .HasColumnType("BLOB"); + + b.Property("HtlcId") + .HasColumnType("INTEGER"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("AddMessageBytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("AmountMsat") + .HasColumnType("INTEGER"); + + b.Property("CltvExpiry") + .HasColumnType("INTEGER"); + + b.Property("ObscuredCommitmentNumber") + .HasColumnType("INTEGER"); + + b.Property("PaymentHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("PaymentPreimage") + .HasColumnType("BLOB"); + + b.Property("Signature") + .HasColumnType("BLOB"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.HasKey("ChannelId", "HtlcId", "Direction"); + + b.ToTable("Htlcs"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Node.PeerEntity", b => + { + b.Property("NodeId") + .HasColumnType("BLOB"); + + b.Property("Host") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.HasKey("NodeId"); + + b.ToTable("Peers"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) + .WithMany("WatchedTransactions") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelConfigEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) + .WithOne("Config") + .HasForeignKey("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelConfigEntity", "ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Node.PeerEntity", null) + .WithMany("Channels") + .HasForeignKey("PeerEntityNodeId"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelKeySetEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) + .WithMany("KeySets") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.HtlcEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) + .WithMany("Htlcs") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", b => + { + b.Navigation("Config"); + + b.Navigation("Htlcs"); + + b.Navigation("KeySets"); + + b.Navigation("WatchedTransactions"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Node.PeerEntity", b => + { + b.Navigation("Channels"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20250612173134_AddBlockchaisStateAndWatchedTransaction.cs b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20250612173134_AddBlockchaisStateAndWatchedTransaction.cs new file mode 100644 index 00000000..80d5c295 --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20250612173134_AddBlockchaisStateAndWatchedTransaction.cs @@ -0,0 +1,117 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NLightning.Infrastructure.Persistence.Sqlite.Migrations +{ + /// + public partial class AddBlockchaisStateAndWatchedTransaction : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PeerEntityNodeId", + table: "Channels", + type: "BLOB", + nullable: true); + + migrationBuilder.CreateTable( + name: "BlockchainStates", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + LastProcessedHeight = table.Column(type: "INTEGER", nullable: false), + LastProcessedBlockHash = table.Column(type: "BLOB", nullable: false), + LastProcessedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BlockchainStates", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Peers", + columns: table => new + { + NodeId = table.Column(type: "BLOB", nullable: false), + Host = table.Column(type: "TEXT", nullable: false), + Port = table.Column(type: "INTEGER", nullable: false), + LastSeenAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Peers", x => x.NodeId); + }); + + migrationBuilder.CreateTable( + name: "WatchedTransactions", + columns: table => new + { + TransactionId = table.Column(type: "BLOB", nullable: false), + ChannelId = table.Column(type: "BLOB", nullable: false), + RequiredDepth = table.Column(type: "INTEGER", nullable: false), + FirstSeenAtHeight = table.Column(type: "INTEGER", nullable: true), + TransactionIndex = table.Column(type: "INTEGER", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + CompletedAt = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_WatchedTransactions", x => x.TransactionId); + table.ForeignKey( + name: "FK_WatchedTransactions_Channels_ChannelId", + column: x => x.ChannelId, + principalTable: "Channels", + principalColumn: "ChannelId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Channels_PeerEntityNodeId", + table: "Channels", + column: "PeerEntityNodeId"); + + migrationBuilder.CreateIndex( + name: "IX_WatchedTransactions_ChannelId", + table: "WatchedTransactions", + column: "ChannelId"); + + migrationBuilder.Sql("PRAGMA foreign_keys = 0;", suppressTransaction: true); + + migrationBuilder.AddForeignKey( + name: "FK_Channels_Peers_PeerEntityNodeId", + table: "Channels", + column: "PeerEntityNodeId", + principalTable: "Peers", + principalColumn: "NodeId"); + + migrationBuilder.Sql("PRAGMA foreign_keys = 1;", suppressTransaction: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Channels_Peers_PeerEntityNodeId", + table: "Channels"); + + migrationBuilder.DropTable( + name: "BlockchainStates"); + + migrationBuilder.DropTable( + name: "Peers"); + + migrationBuilder.DropTable( + name: "WatchedTransactions"); + + migrationBuilder.DropIndex( + name: "IX_Channels_PeerEntityNodeId", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "PeerEntityNodeId", + table: "Channels"); + } + } +} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251023145106_AddPeerType.Designer.cs b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251023145106_AddPeerType.Designer.cs new file mode 100644 index 00000000..181d4b19 --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251023145106_AddPeerType.Designer.cs @@ -0,0 +1,362 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NLightning.Infrastructure.Persistence.Contexts; + +#nullable disable + +namespace NLightning.Infrastructure.Persistence.Sqlite.Migrations +{ + [DbContext(typeof(NLightningDbContext))] + [Migration("20251023145106_AddPeerType")] + partial class AddPeerType + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.9"); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.BlockchainStateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("LastProcessedAt") + .HasColumnType("TEXT"); + + b.Property("LastProcessedBlockHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("LastProcessedHeight") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("BlockchainStates"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => + { + b.Property("TransactionId") + .HasColumnType("BLOB"); + + b.Property("ChannelId") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FirstSeenAtHeight") + .HasColumnType("INTEGER"); + + b.Property("RequiredDepth") + .HasColumnType("INTEGER"); + + b.Property("TransactionIndex") + .HasColumnType("INTEGER"); + + b.HasKey("TransactionId"); + + b.HasIndex("ChannelId"); + + b.ToTable("WatchedTransactions"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelConfigEntity", b => + { + b.Property("ChannelId") + .HasColumnType("BLOB"); + + b.Property("ChannelReserveAmountSats") + .HasColumnType("INTEGER"); + + b.Property("FeeRatePerKwSatoshis") + .HasColumnType("INTEGER"); + + b.Property("HtlcMinimumMsat") + .HasColumnType("INTEGER"); + + b.Property("LocalDustLimitAmountSats") + .HasColumnType("INTEGER"); + + b.Property("LocalUpfrontShutdownScript") + .HasColumnType("BLOB"); + + b.Property("MaxAcceptedHtlcs") + .HasColumnType("INTEGER"); + + b.Property("MaxHtlcAmountInFlight") + .HasColumnType("INTEGER"); + + b.Property("MinimumDepth") + .HasColumnType("INTEGER"); + + b.Property("OptionAnchorOutputs") + .HasColumnType("INTEGER"); + + b.Property("RemoteDustLimitAmountSats") + .HasColumnType("INTEGER"); + + b.Property("RemoteUpfrontShutdownScript") + .HasColumnType("BLOB"); + + b.Property("ToSelfDelay") + .HasColumnType("INTEGER"); + + b.Property("UseScidAlias") + .HasColumnType("INTEGER"); + + b.HasKey("ChannelId"); + + b.ToTable("ChannelConfigs"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", b => + { + b.Property("ChannelId") + .HasColumnType("BLOB"); + + b.Property("FundingAmountSatoshis") + .HasColumnType("INTEGER"); + + b.Property("FundingCreatedAtBlockHeight") + .HasColumnType("INTEGER"); + + b.Property("FundingOutputIndex") + .HasColumnType("INTEGER"); + + b.Property("FundingTxId") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("IsInitiator") + .HasColumnType("INTEGER"); + + b.Property("LastReceivedSignature") + .HasColumnType("BLOB"); + + b.Property("LastSentSignature") + .HasColumnType("BLOB"); + + b.Property("LocalBalanceSatoshis") + .HasColumnType("TEXT"); + + b.Property("LocalNextHtlcId") + .HasColumnType("INTEGER"); + + b.Property("LocalRevocationNumber") + .HasColumnType("INTEGER"); + + b.Property("PeerEntityNodeId") + .HasColumnType("BLOB"); + + b.Property("RemoteBalanceSatoshis") + .HasColumnType("TEXT"); + + b.Property("RemoteNextHtlcId") + .HasColumnType("INTEGER"); + + b.Property("RemoteNodeId") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("RemoteRevocationNumber") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("ChannelId"); + + b.HasIndex("PeerEntityNodeId"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelKeySetEntity", b => + { + b.Property("ChannelId") + .HasColumnType("BLOB"); + + b.Property("IsLocal") + .HasColumnType("INTEGER"); + + b.Property("CurrentPerCommitmentIndex") + .HasColumnType("INTEGER"); + + b.Property("CurrentPerCommitmentPoint") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("DelayedPaymentBasepoint") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("FundingPubKey") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("HtlcBasepoint") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("KeyIndex") + .HasColumnType("INTEGER"); + + b.Property("LastRevealedPerCommitmentSecret") + .HasColumnType("BLOB"); + + b.Property("PaymentBasepoint") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("RevocationBasepoint") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("ChannelId", "IsLocal"); + + b.ToTable("ChannelKeySets"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.HtlcEntity", b => + { + b.Property("ChannelId") + .HasColumnType("BLOB"); + + b.Property("HtlcId") + .HasColumnType("INTEGER"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("AddMessageBytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("AmountMsat") + .HasColumnType("INTEGER"); + + b.Property("CltvExpiry") + .HasColumnType("INTEGER"); + + b.Property("ObscuredCommitmentNumber") + .HasColumnType("INTEGER"); + + b.Property("PaymentHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("PaymentPreimage") + .HasColumnType("BLOB"); + + b.Property("Signature") + .HasColumnType("BLOB"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.HasKey("ChannelId", "HtlcId", "Direction"); + + b.ToTable("Htlcs"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Node.PeerEntity", b => + { + b.Property("NodeId") + .HasColumnType("BLOB"); + + b.Property("Host") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("NodeId"); + + b.ToTable("Peers"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) + .WithMany("WatchedTransactions") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelConfigEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) + .WithOne("Config") + .HasForeignKey("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelConfigEntity", "ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Node.PeerEntity", null) + .WithMany("Channels") + .HasForeignKey("PeerEntityNodeId"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelKeySetEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) + .WithMany("KeySets") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.HtlcEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) + .WithMany("Htlcs") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", b => + { + b.Navigation("Config"); + + b.Navigation("Htlcs"); + + b.Navigation("KeySets"); + + b.Navigation("WatchedTransactions"); + }); + + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Node.PeerEntity", b => + { + b.Navigation("Channels"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251023145106_AddPeerType.cs b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251023145106_AddPeerType.cs new file mode 100644 index 00000000..ba342dcc --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251023145106_AddPeerType.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NLightning.Infrastructure.Persistence.Sqlite.Migrations +{ + /// + public partial class AddPeerType : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Type", + table: "Peers", + type: "TEXT", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Type", + table: "Peers"); + } + } +} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/NLightningDbContextModelSnapshot.cs b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/NLightningDbContextModelSnapshot.cs index 3eb61e3a..e4da6953 100644 --- a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/NLightningDbContextModelSnapshot.cs +++ b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/NLightningDbContextModelSnapshot.cs @@ -286,6 +286,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Port") .HasColumnType("INTEGER"); + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + b.HasKey("NodeId"); b.ToTable("Peers"); diff --git a/src/NLightning.Infrastructure.Persistence/Entities/Node/PeerEntity.cs b/src/NLightning.Infrastructure.Persistence/Entities/Node/PeerEntity.cs index 6fc89828..ffff3b7b 100644 --- a/src/NLightning.Infrastructure.Persistence/Entities/Node/PeerEntity.cs +++ b/src/NLightning.Infrastructure.Persistence/Entities/Node/PeerEntity.cs @@ -8,6 +8,7 @@ public class PeerEntity public required CompactPubKey NodeId { get; set; } public required string Host { get; set; } public required uint Port { get; set; } + public required string Type { get; set; } public required DateTime LastSeenAt { get; set; } public virtual ICollection? Channels { get; set; } diff --git a/src/NLightning.Infrastructure.Persistence/EntityConfiguration/Node/PeerEntityConfiguration.cs b/src/NLightning.Infrastructure.Persistence/EntityConfiguration/Node/PeerEntityConfiguration.cs index 4f233ed6..c628723a 100644 --- a/src/NLightning.Infrastructure.Persistence/EntityConfiguration/Node/PeerEntityConfiguration.cs +++ b/src/NLightning.Infrastructure.Persistence/EntityConfiguration/Node/PeerEntityConfiguration.cs @@ -20,6 +20,7 @@ public static void ConfigurePeerEntity(this ModelBuilder modelBuilder, DatabaseT // Set required props entity.Property(e => e.Host).IsRequired(); entity.Property(e => e.Port).IsRequired(); + entity.Property(e => e.Type).IsRequired(); entity.Property(e => e.LastSeenAt).IsRequired(); // Required byte[] properties diff --git a/src/NLightning.Infrastructure.Persistence/scripts/add_migration.sh b/src/NLightning.Infrastructure.Persistence/scripts/add_migration.sh index d090d8c2..86066ad7 100755 --- a/src/NLightning.Infrastructure.Persistence/scripts/add_migration.sh +++ b/src/NLightning.Infrastructure.Persistence/scripts/add_migration.sh @@ -11,6 +11,7 @@ echo "Postgres" export NLIGHTNING_POSTGRES=${NLIGHTNING_POSTGRES:-'User ID=superuser;Password=superuser;Server=localhost;Port=15432;Database=nlightning;'} unset NLIGHTNING_SQLITE unset NLIGHTNING_SQLSERVER +dotnet ef database update dotnet ef migrations add $MigrationName \ --project ../NLightning.Infrastructure.Persistence.Postgres dotnet ef database update @@ -18,14 +19,16 @@ dotnet ef database update echo "Sqlite" unset NLIGHTNING_POSTGRES export NLIGHTNING_SQLITE=${NLIGHTNING_SQLITE:-'Data Source=./nltg.db;Cache=Shared'} +dotnet ef database update dotnet ef migrations add $MigrationName \ --project ../NLightning.Infrastructure.Persistence.Sqlite dotnet ef database update - + echo "SqlServer" unset NLIGHTNING_POSTGRES unset NLIGHTNING_SQLITE export NLIGHTNING_SQLSERVER=${NLIGHTNING_SQLSERVER:-'Server=localhost;Database=nlightning;User Id=sa;Password=Superuser1234*;Encrypt=false;'} +dotnet ef database update dotnet ef migrations add $MigrationName \ --project ../NLightning.Infrastructure.Persistence.SqlServer dotnet ef database update diff --git a/src/NLightning.Infrastructure.Persistence/scripts/start_postgres.sh b/src/NLightning.Infrastructure.Persistence/scripts/start_postgres.sh index a7a76ae9..a19e715b 100755 --- a/src/NLightning.Infrastructure.Persistence/scripts/start_postgres.sh +++ b/src/NLightning.Infrastructure.Persistence/scripts/start_postgres.sh @@ -4,5 +4,3 @@ docker run --rm -d --name postgres_ef_gen -p 15432:5432 \ -e "POSTGRES_USER=superuser" \ -e "POSTGRES_DB=nlightning" \ postgres:16.2-alpine - - \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Repositories/Database/Node/PeerDbRepository.cs b/src/NLightning.Infrastructure.Repositories/Database/Node/PeerDbRepository.cs index 784da267..e4309cae 100644 --- a/src/NLightning.Infrastructure.Repositories/Database/Node/PeerDbRepository.cs +++ b/src/NLightning.Infrastructure.Repositories/Database/Node/PeerDbRepository.cs @@ -68,13 +68,14 @@ private static PeerEntity MapDomainToEntity(PeerModel peerModel) NodeId = peerModel.NodeId, Host = peerModel.Host, Port = peerModel.Port, + Type = peerModel.Type, LastSeenAt = peerModel.LastSeenAt }; } private static PeerModel MapEntityToDomain(PeerEntity peerEntity) { - return new PeerModel(peerEntity.NodeId, peerEntity.Host, peerEntity.Port) + return new PeerModel(peerEntity.NodeId, peerEntity.Host, peerEntity.Port, peerEntity.Type) { LastSeenAt = peerEntity.LastSeenAt }; diff --git a/src/NLightning.Transport.Ipc/Contracts.cs b/src/NLightning.Transport.Ipc/Contracts.cs deleted file mode 100644 index 0caed052..00000000 --- a/src/NLightning.Transport.Ipc/Contracts.cs +++ /dev/null @@ -1,66 +0,0 @@ -using MessagePack; - -namespace NLightning.Transport.Ipc; - -/// -/// Commands supported by the IPC protocol. -/// -public enum NodeIpcCommand -{ - // Reserve 0 for unknown - Unknown = 0, - NodeInfo = 1, -} - -/// -/// Envelope for all IPC messages, request and response, encoded with MessagePack. -/// -[MessagePackObject] -public sealed class IpcEnvelope -{ - [Key(0)] public int Version { get; init; } = 1; - [Key(1)] public NodeIpcCommand Command { get; init; } - - [Key(2)] public Guid CorrelationId { get; init; } = Guid.NewGuid(); - - // Auth token derived from a local cookie file (only accessible locally) to secure the channel - [Key(3)] public string? AuthToken { get; init; } - - // Raw payload serialized with MessagePack separately for the specific request/response type - [Key(4)] public byte[] Payload { get; init; } = Array.Empty(); - - // 0 = request, 1 = response, 2 = error - [Key(5)] public byte Kind { get; init; } = 0; -} - -/// -/// Empty request for NodeInfo. -/// -[MessagePackObject] -public readonly struct NodeInfoRequest -{ -} - -/// -/// Response for NodeInfo (transport-specific DTO for MessagePack). -/// -[MessagePackObject] -public sealed class NodeInfoIpcResponse -{ - [Key(0)] public string Network { get; init; } = string.Empty; - [Key(1)] public string BestBlockHash { get; init; } = string.Empty; - [Key(2)] public long BestBlockHeight { get; init; } - [Key(3)] public DateTimeOffset? BestBlockTime { get; init; } - [Key(4)] public string? Implementation { get; init; } = "NLightning"; - [Key(5)] public string? Version { get; init; } -} - -/// -/// Error payload -/// -[MessagePackObject] -public sealed class IpcError -{ - [Key(0)] public string Code { get; init; } = string.Empty; - [Key(1)] public string Message { get; init; } = string.Empty; -} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/IpcEnvelope.cs b/src/NLightning.Transport.Ipc/IpcEnvelope.cs new file mode 100644 index 00000000..a63caae8 --- /dev/null +++ b/src/NLightning.Transport.Ipc/IpcEnvelope.cs @@ -0,0 +1,24 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc; + +/// +/// Envelope for all IPC messages, request and response, encoded with MessagePack. +/// +[MessagePackObject] +public sealed class IpcEnvelope +{ + [Key(0)] public int Version { get; set; } = 1; + [Key(1)] public NodeIpcCommand Command { get; init; } + + [Key(2)] public Guid CorrelationId { get; set; } = Guid.NewGuid(); + + // Auth token derived from a local cookie file (only accessible locally) to secure the channel + [Key(3)] public string? AuthToken { get; init; } + + // Raw payload serialized with MessagePack separately for the specific request/response type + [Key(4)] public byte[] Payload { get; set; } = Array.Empty(); + + // 0 = request, 1 = response, 2 = error + [Key(5)] public byte Kind { get; init; } +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/IpcError.cs b/src/NLightning.Transport.Ipc/IpcError.cs new file mode 100644 index 00000000..66e9bb2c --- /dev/null +++ b/src/NLightning.Transport.Ipc/IpcError.cs @@ -0,0 +1,13 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc; + +/// +/// Error payload +/// +[MessagePackObject] +public sealed class IpcError +{ + [Key(0)] public string Code { get; set; } = string.Empty; + [Key(1)] public string Message { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/MessagePack/Formatters/BitcoinNetworkFormatter.cs b/src/NLightning.Transport.Ipc/MessagePack/Formatters/BitcoinNetworkFormatter.cs new file mode 100644 index 00000000..db01d9ad --- /dev/null +++ b/src/NLightning.Transport.Ipc/MessagePack/Formatters/BitcoinNetworkFormatter.cs @@ -0,0 +1,21 @@ +using System.Runtime.Serialization; +using MessagePack; +using MessagePack.Formatters; + +namespace NLightning.Transport.Ipc.MessagePack.Formatters; + +using Domain.Protocol.ValueObjects; + +public class BitcoinNetworkFormatter : IMessagePackFormatter +{ + public void Serialize(ref MessagePackWriter writer, BitcoinNetwork value, MessagePackSerializerOptions options) + { + writer.Write(value.Name); + } + + public BitcoinNetwork Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + return new BitcoinNetwork(reader.ReadString() ?? + throw new SerializationException($"Error deserializing {nameof(BitcoinNetwork)}")); + } +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/MessagePack/Formatters/CompactPubKeyFormatter.cs b/src/NLightning.Transport.Ipc/MessagePack/Formatters/CompactPubKeyFormatter.cs new file mode 100644 index 00000000..4d220cde --- /dev/null +++ b/src/NLightning.Transport.Ipc/MessagePack/Formatters/CompactPubKeyFormatter.cs @@ -0,0 +1,20 @@ +using MessagePack; +using MessagePack.Formatters; + +namespace NLightning.Transport.Ipc.MessagePack.Formatters; + +using Domain.Crypto.Constants; +using Domain.Crypto.ValueObjects; + +public class CompactPubKeyFormatter : IMessagePackFormatter +{ + public void Serialize(ref MessagePackWriter writer, CompactPubKey value, MessagePackSerializerOptions options) + { + writer.WriteRaw(value); + } + + public CompactPubKey Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + return reader.ReadRaw(CryptoConstants.CompactPubkeyLen).FirstSpan.ToArray(); + } +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/MessagePack/Formatters/FeatureSetFormatter.cs b/src/NLightning.Transport.Ipc/MessagePack/Formatters/FeatureSetFormatter.cs new file mode 100644 index 00000000..54b1b4ea --- /dev/null +++ b/src/NLightning.Transport.Ipc/MessagePack/Formatters/FeatureSetFormatter.cs @@ -0,0 +1,37 @@ +using System.Runtime.Serialization; +using MessagePack; +using MessagePack.Formatters; + +namespace NLightning.Transport.Ipc.MessagePack.Formatters; + +using Domain.Node; +using Domain.Utils; + +public class FeatureSetFormatter : IMessagePackFormatter +{ + public void Serialize(ref MessagePackWriter writer, FeatureSet? value, MessagePackSerializerOptions options) + { + if (value is null) + { + writer.WriteNil(); + return; + } + + using var bitWriter = new BitWriter(value.SizeInBits); + value.WriteToBitWriter(bitWriter, value.SizeInBits, false); + writer.Write(value.SizeInBits); + writer.Write(bitWriter.ToArray()); + } + + public FeatureSet? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + if (reader.TryReadNil()) + return null; + + var sizeInBits = reader.ReadInt32(); + var bytes = reader.ReadBytes() ?? + throw new SerializationException($"Error deserializing {nameof(FeatureSet)})"); + var bitReader = new BitReader(bytes.FirstSpan.ToArray()); + return FeatureSet.DeserializeFromBitReader(bitReader, sizeInBits, false); + } +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/MessagePack/Formatters/HashFormatter.cs b/src/NLightning.Transport.Ipc/MessagePack/Formatters/HashFormatter.cs new file mode 100644 index 00000000..76dc046a --- /dev/null +++ b/src/NLightning.Transport.Ipc/MessagePack/Formatters/HashFormatter.cs @@ -0,0 +1,20 @@ +using MessagePack; +using MessagePack.Formatters; + +namespace NLightning.Transport.Ipc.MessagePack.Formatters; + +using Domain.Crypto.Constants; +using Domain.Crypto.ValueObjects; + +public class HashFormatter : IMessagePackFormatter +{ + public void Serialize(ref MessagePackWriter writer, Hash value, MessagePackSerializerOptions options) + { + writer.WriteRaw(value); + } + + public Hash Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + return reader.ReadRaw(CryptoConstants.Sha256HashLen).FirstSpan.ToArray(); + } +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/MessagePack/Formatters/PeerAddressInfoFormatter.cs b/src/NLightning.Transport.Ipc/MessagePack/Formatters/PeerAddressInfoFormatter.cs new file mode 100644 index 00000000..cdf5689b --- /dev/null +++ b/src/NLightning.Transport.Ipc/MessagePack/Formatters/PeerAddressInfoFormatter.cs @@ -0,0 +1,21 @@ +using System.Runtime.Serialization; +using MessagePack; +using MessagePack.Formatters; + +namespace NLightning.Transport.Ipc.MessagePack.Formatters; + +using Domain.Node.ValueObjects; + +public class PeerAddressInfoFormatter : IMessagePackFormatter +{ + public void Serialize(ref MessagePackWriter writer, PeerAddressInfo value, MessagePackSerializerOptions options) + { + writer.Write(value.Address); + } + + public PeerAddressInfo Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + return new PeerAddressInfo(reader.ReadString() ?? + throw new SerializationException($"Error deserializing {nameof(PeerAddressInfo)}")); + } +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/MessagePack/NLightningFormatterResolver.cs b/src/NLightning.Transport.Ipc/MessagePack/NLightningFormatterResolver.cs new file mode 100644 index 00000000..169029d4 --- /dev/null +++ b/src/NLightning.Transport.Ipc/MessagePack/NLightningFormatterResolver.cs @@ -0,0 +1,37 @@ +using MessagePack; +using MessagePack.Formatters; +using MessagePack.Resolvers; +using NLightning.Domain.Node; +using NLightning.Domain.Node.ValueObjects; +using NLightning.Domain.Protocol.ValueObjects; + +namespace NLightning.Transport.Ipc.MessagePack; + +using Domain.Crypto.ValueObjects; +using Formatters; + +public class NLightningFormatterResolver : IFormatterResolver +{ + private readonly Dictionary _formatters = new(); + + public static readonly IFormatterResolver Instance = new NLightningFormatterResolver(); + + private NLightningFormatterResolver() + { + _formatters[typeof(Hash)] = new HashFormatter(); + _formatters[typeof(BitcoinNetwork)] = new BitcoinNetworkFormatter(); + _formatters[typeof(PeerAddressInfo)] = new PeerAddressInfoFormatter(); + _formatters[typeof(CompactPubKey)] = new CompactPubKeyFormatter(); + _formatters[typeof(FeatureSet)] = new FeatureSetFormatter(); + } + + public IMessagePackFormatter? GetFormatter() + { + if (_formatters.TryGetValue(typeof(T), out var formatter)) + { + return (IMessagePackFormatter)formatter; + } + + return StandardResolver.Instance.GetFormatter(); + } +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/MessagePack/NLightningMessagePackOptions.cs b/src/NLightning.Transport.Ipc/MessagePack/NLightningMessagePackOptions.cs new file mode 100644 index 00000000..f62cf316 --- /dev/null +++ b/src/NLightning.Transport.Ipc/MessagePack/NLightningMessagePackOptions.cs @@ -0,0 +1,10 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc.MessagePack; + +public static class NLightningMessagePackOptions +{ + public static MessagePackSerializerOptions Options => + MessagePackSerializerOptions.Standard.WithResolver(NLightningFormatterResolver.Instance) + .WithCompression(MessagePackCompression.Lz4BlockArray); +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/NLightning.Transport.Ipc.csproj b/src/NLightning.Transport.Ipc/NLightning.Transport.Ipc.csproj index fe04dac9..751fec87 100644 --- a/src/NLightning.Transport.Ipc/NLightning.Transport.Ipc.csproj +++ b/src/NLightning.Transport.Ipc/NLightning.Transport.Ipc.csproj @@ -1,10 +1,12 @@ - - - net9.0 - enable - enable - + + + + + + + + diff --git a/src/NLightning.Transport.Ipc/NodeIpcCommand.cs b/src/NLightning.Transport.Ipc/NodeIpcCommand.cs new file mode 100644 index 00000000..20288a31 --- /dev/null +++ b/src/NLightning.Transport.Ipc/NodeIpcCommand.cs @@ -0,0 +1,12 @@ +namespace NLightning.Transport.Ipc; + +/// +/// Commands supported by the IPC protocol. +/// +public enum NodeIpcCommand +{ + // Reserve 0 for unknown + Unknown = 0, + NodeInfo = 1, + ConnectPeer = 2, +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/Requests/ConnectPeerIpcRequest.cs b/src/NLightning.Transport.Ipc/Requests/ConnectPeerIpcRequest.cs new file mode 100644 index 00000000..9d39c4a4 --- /dev/null +++ b/src/NLightning.Transport.Ipc/Requests/ConnectPeerIpcRequest.cs @@ -0,0 +1,14 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc.Requests; + +using Domain.Node.ValueObjects; + +/// +/// Request for Connect command +/// +[MessagePackObject] +public sealed class ConnectPeerIpcRequest +{ + [Key(0)] public required PeerAddressInfo Address { get; init; } // {pubkey}@{ip}:{port} +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/Requests/NodeInfoIpcRequest.cs b/src/NLightning.Transport.Ipc/Requests/NodeInfoIpcRequest.cs new file mode 100644 index 00000000..ed17c4bc --- /dev/null +++ b/src/NLightning.Transport.Ipc/Requests/NodeInfoIpcRequest.cs @@ -0,0 +1,9 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc.Requests; + +/// +/// Empty request for NodeInfo. +/// +[MessagePackObject] +public readonly struct NodeInfoIpcRequest; \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/Responses/ConnectPeerIpcResponse.cs b/src/NLightning.Transport.Ipc/Responses/ConnectPeerIpcResponse.cs new file mode 100644 index 00000000..e5ff3392 --- /dev/null +++ b/src/NLightning.Transport.Ipc/Responses/ConnectPeerIpcResponse.cs @@ -0,0 +1,34 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc.Responses; + +using Daemon.Contracts.Control; +using Domain.Crypto.ValueObjects; +using Domain.Node; + +/// +/// Response for Connect command +/// +[MessagePackObject] +public sealed class ConnectPeerIpcResponse +{ + [Key(0)] public CompactPubKey Id { get; set; } + [Key(1)] public required FeatureSet Features { get; set; } + [Key(2)] public bool IsInitiator { get; set; } + [Key(3)] public required string Address { get; set; } + [Key(4)] public string Type { get; set; } = string.Empty; + [Key(5)] public uint Port { get; set; } + + public ConnectPeerResponse ToContractResponse() + { + return new ConnectPeerResponse + { + Id = Id.ToString(), + Features = Features.ToString(), + IsInitiator = IsInitiator, + Address = Address, + Type = Type, + Port = Port + }; + } +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/Responses/NodeInfoIpcResponse.cs b/src/NLightning.Transport.Ipc/Responses/NodeInfoIpcResponse.cs new file mode 100644 index 00000000..b20fece9 --- /dev/null +++ b/src/NLightning.Transport.Ipc/Responses/NodeInfoIpcResponse.cs @@ -0,0 +1,34 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc.Responses; + +using Daemon.Contracts.Control; +using Domain.Crypto.ValueObjects; +using Domain.Protocol.ValueObjects; + +/// +/// Response for NodeInfo (transport-specific DTO for MessagePack). +/// +[MessagePackObject] +public sealed class NodeInfoIpcResponse +{ + [Key(0)] public BitcoinNetwork Network { get; init; } + [Key(1)] public Hash BestBlockHash { get; init; } + [Key(2)] public long BestBlockHeight { get; init; } + [Key(3)] public DateTimeOffset? BestBlockTime { get; init; } + [Key(4)] public string? Implementation { get; set; } = "NLightning"; + [Key(5)] public string? Version { get; init; } + + public NodeInfoResponse ToContractResponse() + { + return new NodeInfoResponse + { + Network = Network, + BestBlockHash = BestBlockHash.ToString(), + BestBlockHeight = BestBlockHeight, + BestBlockTime = BestBlockTime, + Implementation = Implementation, + Version = Version + }; + } +} \ No newline at end of file diff --git a/test/NLightning.Application.Tests/Node/Managers/PeerManagerTests.cs b/test/NLightning.Application.Tests/Node/Managers/PeerManagerTests.cs index 8492d76f..26f60580 100644 --- a/test/NLightning.Application.Tests/Node/Managers/PeerManagerTests.cs +++ b/test/NLightning.Application.Tests/Node/Managers/PeerManagerTests.cs @@ -42,6 +42,7 @@ public class PeerManagerTests private const string ExpectedHost = "127.0.0.1"; private const int ExpectedPort = 9735; + private const string ExpectedType = "IPv4"; public PeerManagerTests() { @@ -50,7 +51,7 @@ public PeerManagerTests() _mockPeerService.SetupGet(p => p.Features).Returns(new FeatureOptions()); // Set up the mock peer model - _mockPeerModel = new PeerModel(_compactPubKey, ExpectedHost, ExpectedPort) + _mockPeerModel = new PeerModel(_compactPubKey, ExpectedHost, ExpectedPort, ExpectedType) { LastSeenAt = DateTime.UtcNow }; diff --git a/test/NLightning.Bolt11.Tests/Models/TaggedFieldListTests.cs b/test/NLightning.Bolt11.Tests/Models/TaggedFieldListTests.cs index a396ced8..c3c35930 100644 --- a/test/NLightning.Bolt11.Tests/Models/TaggedFieldListTests.cs +++ b/test/NLightning.Bolt11.Tests/Models/TaggedFieldListTests.cs @@ -41,7 +41,7 @@ public void Given_TaggedFieldListWithExistingField_When_AddSameType_Then_Argumen // When / Then var ex = Assert.Throws(() => list.Add(new MockTaggedField - { Type = TaggedFieldTypes.Description }) + { Type = TaggedFieldTypes.Description }) ); Assert.Contains("already contains a tagged field of type Description", ex.Message); @@ -56,7 +56,7 @@ public void Given_TaggedFieldListWithDescription_When_AddDescriptionHash_Then_Ar // When / Then var ex = Assert.Throws(() => list.Add(new MockTaggedField - { Type = TaggedFieldTypes.DescriptionHash }) + { Type = TaggedFieldTypes.DescriptionHash }) ); Assert.Contains("already contains a tagged field of type DescriptionHash", ex.Message); @@ -71,7 +71,7 @@ public void Given_TaggedFieldListWithDescriptionHash_When_AddDescription_Then_Ar // When / Then var ex = Assert.Throws(() => list.Add(new MockTaggedField - { Type = TaggedFieldTypes.Description }) + { Type = TaggedFieldTypes.Description }) ); Assert.Contains("already contains a tagged field of type Description", ex.Message); @@ -320,7 +320,7 @@ public void Given_ValidList_When_WriteToBitWriter_Then_TagsAndLengthsAreWritten( public void Given_BitReaderReturningNoData_When_FromBitReaderCalled_Then_EmptyListReturned() { // Given - var bitReader = BitReader([]); // defaults to HasMoreBits = false + var bitReader = new BitReader([]); // defaults to HasMoreBits = false // When var list = TaggedFieldList.FromBitReader(bitReader, BitcoinNetwork.Mainnet); From 0c3900b3cda390a7c858ce50b54a3c6f4ac3523a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADckolas=20Goline?= Date: Thu, 23 Oct 2025 21:50:01 -0300 Subject: [PATCH 04/20] fix messagepack warnings and save connected peer data to db MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Níckolas Goline --- .../Node/Managers/PeerManager.cs | 39 ++++++++++++++----- .../Node/Constants/NodeConstants.cs | 6 +++ 2 files changed, 35 insertions(+), 10 deletions(-) create mode 100644 src/NLightning.Domain/Node/Constants/NodeConstants.cs diff --git a/src/NLightning.Application/Node/Managers/PeerManager.cs b/src/NLightning.Application/Node/Managers/PeerManager.cs index 7f645127..8d1ede53 100644 --- a/src/NLightning.Application/Node/Managers/PeerManager.cs +++ b/src/NLightning.Application/Node/Managers/PeerManager.cs @@ -1,6 +1,7 @@ using System.Net.Sockets; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using NLightning.Domain.Node.Constants; namespace NLightning.Application.Node.Managers; @@ -158,16 +159,17 @@ private async Task ConnectToPeerAsync(PeerAddressInfo peerAddressInfo peerService.OnDisconnect += HandlePeerDisconnection; peerService.OnChannelMessageReceived += HandlePeerChannelMessage; - // Check if the peer wants us to use a different host and port - var host = connectedPeer.Host; - var port = connectedPeer.Port; // Default port for Lightning Network - if (peerService.PreferredHost is not null && peerService.PreferredPort.HasValue) - { - host = peerService.PreferredHost; - port = peerService.PreferredPort.Value; - } + var preferredHost = connectedPeer.Host; + var preferredPort = connectedPeer.Port; + + // Check if the node has set it's preferred address + if (peerService.PreferredHost is not null) + preferredHost = peerService.PreferredHost; - var peer = new PeerModel(connectedPeer.CompactPubKey, host, port, + if (peerService.PreferredPort is not null) + preferredPort = peerService.PreferredPort.Value; + + var peer = new PeerModel(connectedPeer.CompactPubKey, preferredHost, preferredPort, connectedPeer.TcpClient.Client.ProtocolType == ProtocolType.IPv6 ? "IPv6" : "IPv4") { LastSeenAt = DateTime.UtcNow @@ -192,13 +194,30 @@ private void HandleNewPeerConnected(object? _, NewPeerConnectedEventArgs args) _logger.LogTrace("PeerService created for peer {PeerPubKey}", peerService.PeerPubKey); - var peer = new PeerModel(peerService.PeerPubKey, args.Host, args.Port, + var preferredHost = args.Host; + var preferredPort = NodeConstants.DefaultPort; + + // Check if the node has set it's preferred address + if (peerService.PreferredHost is not null) + preferredHost = peerService.PreferredHost; + + if (peerService.PreferredPort is not null) + preferredPort = peerService.PreferredPort.Value; + + var peer = new PeerModel(peerService.PeerPubKey, preferredHost, preferredPort, args.TcpClient.Client.ProtocolType == ProtocolType.IPv6 ? "IPv6" : "IPv4") { LastSeenAt = DateTime.UtcNow }; peer.SetPeerService(peerService); + // Get a context to save the peer to the database + using var scope = _serviceProvider.CreateScope(); + using var uow = scope.ServiceProvider.GetRequiredService(); + + uow.PeerDbRepository.AddOrUpdateAsync(peer); + uow.SaveChanges(); + _peers.Add(peerService.PeerPubKey, peer); } catch (Exception e) diff --git a/src/NLightning.Domain/Node/Constants/NodeConstants.cs b/src/NLightning.Domain/Node/Constants/NodeConstants.cs new file mode 100644 index 00000000..c75cdd38 --- /dev/null +++ b/src/NLightning.Domain/Node/Constants/NodeConstants.cs @@ -0,0 +1,6 @@ +namespace NLightning.Domain.Node.Constants; + +public static class NodeConstants +{ + public const uint DefaultPort = 9735; +} \ No newline at end of file From 9ec6675501b839d783378a6671775fc1c7077b6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADckolas=20Goline?= Date: Fri, 24 Oct 2025 12:32:25 -0300 Subject: [PATCH 05/20] add listpeer command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Níckolas Goline --- .../Node/Managers/PeerManager.cs | 33 +++++-- .../Ipc/NamedPipeIpcClient.cs | 46 ++++++--- .../Printers/ConnectPeerPrinter.cs | 8 +- .../Printers/ListPeersPrinter.cs | 27 ++++++ .../Printers/NodeInfoPrinter.cs | 10 +- src/NLightning.Client/Program.cs | 9 +- .../Extensions/NodeServiceExtensions.cs | 3 +- .../Handlers/ConnectIpcHandler.cs | 93 ------------------- .../Handlers/ConnectPeerIpcHandler.cs | 92 ++++++++++++++++++ .../Handlers/ListPeersIpcHandler.cs | 66 +++++++++++++ .../Handlers/NodeInfoIpcHandler.cs | 55 ++++++----- .../Services/Ipc/Factories/IpcErrorFactory.cs | 21 +++++ .../Services/Ipc/IpcRouting.cs | 2 +- .../Services/Ipc/NamedPipeIpcHostedService.cs | 27 ++---- .../Node/Interfaces/IPeerManager.cs | 2 + .../Node/Services/PeerCommunicationService.cs | 2 +- .../Protocol/Models/PeerAddress.cs | 23 ++++- .../Transport/Interfaces/ITcpService.cs | 7 +- .../Transport/Services/TcpService.cs | 5 +- .../Constants/ErrorCodes.cs | 10 ++ src/NLightning.Transport.Ipc/IpcEnvelope.cs | 2 +- .../IpcEnvelopeKind.cs | 8 ++ .../NodeIpcCommand.cs | 1 + .../Requests/ConnectPeerIpcRequest.cs | 4 +- .../Requests/ListPeersIpcRequest.cs | 9 ++ .../Responses/ConnectPeerIpcResponse.cs | 26 ++---- .../Responses/ListPeersIpcResponse.cs | 12 +++ .../Responses/NodeInfoIpcResponse.cs | 14 --- .../Responses/PeerInfoIpcResponse.cs | 18 ++++ .../Node/Managers/PeerManagerTests.cs | 9 +- 30 files changed, 426 insertions(+), 218 deletions(-) create mode 100644 src/NLightning.Client/Printers/ListPeersPrinter.cs delete mode 100644 src/NLightning.Daemon/Handlers/ConnectIpcHandler.cs create mode 100644 src/NLightning.Daemon/Handlers/ConnectPeerIpcHandler.cs create mode 100644 src/NLightning.Daemon/Handlers/ListPeersIpcHandler.cs create mode 100644 src/NLightning.Daemon/Services/Ipc/Factories/IpcErrorFactory.cs create mode 100644 src/NLightning.Transport.Ipc/Constants/ErrorCodes.cs create mode 100644 src/NLightning.Transport.Ipc/IpcEnvelopeKind.cs create mode 100644 src/NLightning.Transport.Ipc/Requests/ListPeersIpcRequest.cs create mode 100644 src/NLightning.Transport.Ipc/Responses/ListPeersIpcResponse.cs create mode 100644 src/NLightning.Transport.Ipc/Responses/PeerInfoIpcResponse.cs diff --git a/src/NLightning.Application/Node/Managers/PeerManager.cs b/src/NLightning.Application/Node/Managers/PeerManager.cs index 8d1ede53..0d9e350b 100644 --- a/src/NLightning.Application/Node/Managers/PeerManager.cs +++ b/src/NLightning.Application/Node/Managers/PeerManager.cs @@ -1,7 +1,6 @@ using System.Net.Sockets; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using NLightning.Domain.Node.Constants; namespace NLightning.Application.Node.Managers; @@ -10,6 +9,7 @@ namespace NLightning.Application.Node.Managers; using Domain.Channels.Interfaces; using Domain.Crypto.ValueObjects; using Domain.Exceptions; +using Domain.Node.Constants; using Domain.Node.Events; using Domain.Node.Interfaces; using Domain.Node.Models; @@ -17,6 +17,7 @@ namespace NLightning.Application.Node.Managers; using Domain.Persistence.Interfaces; using Domain.Protocol.Constants; using Domain.Protocol.Interfaces; +using Infrastructure.Protocol.Models; using Infrastructure.Transport.Events; using Infrastructure.Transport.Interfaces; @@ -117,6 +118,7 @@ public async Task StopAsync() /// /// Thrown when the connection to the peer fails. + /// Thrown when the connection to the peer already exists. public async Task ConnectToPeerAsync(PeerAddressInfo peerAddressInfo) { using var scope = _serviceProvider.CreateScope(); @@ -149,10 +151,24 @@ public void DisconnectPeer(CompactPubKey pubKey) } } + public List ListPeers() + { + return _peers.Values.ToList(); + } + private async Task ConnectToPeerAsync(PeerAddressInfo peerAddressInfo, IUnitOfWork uow) { + // Convert and validate the address + var peerAddress = new PeerAddress(peerAddressInfo.Address); + + // Check if we're already connected to the peer + if (_peers.ContainsKey(peerAddress.PubKey)) + { + throw new InvalidOperationException($"Already connected to peer {peerAddress.PubKey}"); + } + // Connect to the peer - var connectedPeer = await _tcpService.ConnectToPeerAsync(peerAddressInfo); + var connectedPeer = await _tcpService.ConnectToPeerAsync(peerAddress); var peerService = await _peerServiceFactory.CreateConnectedPeerAsync(connectedPeer.CompactPubKey, connectedPeer.TcpClient); @@ -211,12 +227,15 @@ private void HandleNewPeerConnected(object? _, NewPeerConnectedEventArgs args) }; peer.SetPeerService(peerService); - // Get a context to save the peer to the database - using var scope = _serviceProvider.CreateScope(); - using var uow = scope.ServiceProvider.GetRequiredService(); + if (preferredHost != "127.0.0.1") + { + // Get a context to save the peer to the database + using var scope = _serviceProvider.CreateScope(); + using var uow = scope.ServiceProvider.GetRequiredService(); - uow.PeerDbRepository.AddOrUpdateAsync(peer); - uow.SaveChanges(); + uow.PeerDbRepository.AddOrUpdateAsync(peer); + uow.SaveChanges(); + } _peers.Add(peerService.PeerPubKey, peer); } diff --git a/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs b/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs index b1265e4e..0981a866 100644 --- a/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs +++ b/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs @@ -1,17 +1,15 @@ using System.Buffers; using System.IO.Pipes; using MessagePack; -using NLightning.Domain.Node.ValueObjects; namespace NLightning.Client.Ipc; -using Daemon.Contracts; -using Daemon.Contracts.Control; +using Domain.Node.ValueObjects; using Transport.Ipc; using Transport.Ipc.Requests; using Transport.Ipc.Responses; -public sealed class NamedPipeIpcClient : IControlClient, IAsyncDisposable +public sealed class NamedPipeIpcClient : IAsyncDisposable { private readonly string _namedPipeFilePath; private readonly string _cookieFilePath; @@ -24,7 +22,7 @@ public NamedPipeIpcClient(string namedPipeFilePath, string cookieFilePath, strin _server = server; } - public async Task GetNodeInfoAsync(CancellationToken ct = default) + public async Task GetNodeInfoAsync(CancellationToken ct = default) { var req = new NodeInfoIpcRequest(); var payload = MessagePackSerializer.Serialize(req, cancellationToken: ct); @@ -39,18 +37,16 @@ public async Task GetNodeInfoAsync(CancellationToken ct = defa }; var respEnv = await SendAsync(env, ct); - if (respEnv.Kind != 2) + if (respEnv.Kind != IpcEnvelopeKind.Error) { - var ipcResponse = - MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); - return ipcResponse.ToContractResponse(); + return MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); } var err = MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); throw new InvalidOperationException($"IPC error {err.Code}: {err.Message}"); } - public async Task ConnectPeerAsync(string address, CancellationToken ct = default) + public async Task ConnectPeerAsync(string address, CancellationToken ct = default) { var req = new ConnectPeerIpcRequest { @@ -68,11 +64,33 @@ public async Task ConnectPeerAsync(string address, Cancella }; var respEnv = await SendAsync(env, ct); - if (respEnv.Kind != 2) + if (respEnv.Kind == IpcEnvelopeKind.Error) { - var ipcResponse = - MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); - return ipcResponse.ToContractResponse(); + return MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); + } + + var err = MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); + throw new InvalidOperationException($"IPC error {err.Code}: {err.Message}"); + } + + public async Task ListPeersAsync(CancellationToken ct = default) + { + var req = new ListPeersIpcRequest(); + var payload = MessagePackSerializer.Serialize(req, cancellationToken: ct); + var env = new IpcEnvelope + { + Version = 1, + Command = NodeIpcCommand.ListPeers, + CorrelationId = Guid.NewGuid(), + AuthToken = await GetAuthTokenAsync(ct), + Payload = payload, + Kind = IpcEnvelopeKind.Request + }; + + var respEnv = await SendAsync(env, ct); + if (respEnv.Kind != IpcEnvelopeKind.Error) + { + return MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); } var err = MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); diff --git a/src/NLightning.Client/Printers/ConnectPeerPrinter.cs b/src/NLightning.Client/Printers/ConnectPeerPrinter.cs index 2837f9e3..bf273039 100644 --- a/src/NLightning.Client/Printers/ConnectPeerPrinter.cs +++ b/src/NLightning.Client/Printers/ConnectPeerPrinter.cs @@ -1,10 +1,10 @@ -using NLightning.Daemon.Contracts.Control; - namespace NLightning.Client.Printers; -public sealed class ConnectPeerPrinter : IPrinter +using Transport.Ipc.Responses; + +public sealed class ConnectPeerPrinter : IPrinter { - public void Print(ConnectPeerResponse item) + public void Print(ConnectPeerIpcResponse item) { Console.WriteLine("Connected to Peer:"); Console.WriteLine($" Id: {item.Id}"); diff --git a/src/NLightning.Client/Printers/ListPeersPrinter.cs b/src/NLightning.Client/Printers/ListPeersPrinter.cs new file mode 100644 index 00000000..65b081b7 --- /dev/null +++ b/src/NLightning.Client/Printers/ListPeersPrinter.cs @@ -0,0 +1,27 @@ +namespace NLightning.Client.Printers; + +using Transport.Ipc.Responses; + +public sealed class ListPeersPrinter : IPrinter +{ + public void Print(ListPeersIpcResponse item) + { + Console.WriteLine("Peers:"); + if (item.Peers is null) + Console.WriteLine(" None"); + else + { + Console.WriteLine("----------------------------------------------------------------------------------"); + + foreach (var peer in item.Peers) + { + Console.WriteLine($" Id: {peer.Id}"); + Console.WriteLine($" Connected: {(peer.Connected ? "Yes" : "No")}"); + Console.WriteLine($" Channel Qty: {peer.ChannelQty}"); + Console.WriteLine($" Address: {peer.Address}"); + Console.WriteLine($" Features: {peer.Features}"); + Console.WriteLine("----------------------------------------------------------------------------------"); + } + } + } +} \ No newline at end of file diff --git a/src/NLightning.Client/Printers/NodeInfoPrinter.cs b/src/NLightning.Client/Printers/NodeInfoPrinter.cs index 62f24b60..c6b8946e 100644 --- a/src/NLightning.Client/Printers/NodeInfoPrinter.cs +++ b/src/NLightning.Client/Printers/NodeInfoPrinter.cs @@ -1,13 +1,13 @@ -using NLightning.Daemon.Contracts.Control; - namespace NLightning.Client.Printers; -public sealed class NodeInfoPrinter : IPrinter +using NLightning.Transport.Ipc.Responses; + +public sealed class NodeInfoPrinter : IPrinter { - public void Print(NodeInfoResponse item) + public void Print(NodeInfoIpcResponse item) { Console.WriteLine("Node Information:"); - Console.WriteLine($" Network: {item.Network}"); + Console.WriteLine($" Network: {item.Network}"); Console.WriteLine($" Best Block Height: {item.BestBlockHeight}"); Console.WriteLine($" Best Block Hash: {item.BestBlockHash}"); if (item.BestBlockTime is not null) diff --git a/src/NLightning.Client/Program.cs b/src/NLightning.Client/Program.cs index 109d2ea2..f4d78c78 100644 --- a/src/NLightning.Client/Program.cs +++ b/src/NLightning.Client/Program.cs @@ -46,8 +46,13 @@ case "connect-peer": if (commandArgs.Length == 0) Console.Error.WriteLine("No arguments specified."); - var response = await client.ConnectPeerAsync(commandArgs[0], cts.Token); - new ConnectPeerPrinter().Print(response); + var connect = await client.ConnectPeerAsync(commandArgs[0], cts.Token); + new ConnectPeerPrinter().Print(connect); + break; + case "listpeers": + case "list-peers": + var listPeers = await client.ListPeersAsync(cts.Token); + new ListPeersPrinter().Print(listPeers); break; default: Console.Error.WriteLine($"Unknown command: {cmd}"); diff --git a/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs b/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs index 8b78b953..b0edbdb5 100644 --- a/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs +++ b/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs @@ -55,7 +55,8 @@ public static IHostBuilder ConfigureNltgServices(this IHostBuilder hostBuilder, services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => { var nodeOptions = sp.GetRequiredService>().Value; diff --git a/src/NLightning.Daemon/Handlers/ConnectIpcHandler.cs b/src/NLightning.Daemon/Handlers/ConnectIpcHandler.cs deleted file mode 100644 index 52554da9..00000000 --- a/src/NLightning.Daemon/Handlers/ConnectIpcHandler.cs +++ /dev/null @@ -1,93 +0,0 @@ -using MessagePack; -using Microsoft.Extensions.Logging; -using NLightning.Transport.Ipc.Responses; - -namespace NLightning.Daemon.Handlers; - -using Domain.Node.Interfaces; -using Interfaces; -using Transport.Ipc; -using Transport.Ipc.Requests; - -public sealed class ConnectIpcHandler : IIpcCommandHandler -{ - private readonly IPeerManager _peerManager; - private readonly ILogger _logger; - - public ConnectIpcHandler(IPeerManager peerManager, ILogger logger) - { - _peerManager = peerManager; - _logger = logger; - } - - public NodeIpcCommand Command => NodeIpcCommand.ConnectPeer; - - public async Task HandleAsync(IpcEnvelope envelope, CancellationToken ct) - { - try - { - // Deserialize the request - var request = - MessagePackSerializer.Deserialize(envelope.Payload, cancellationToken: ct); - - // Validate the address - if (string.IsNullOrWhiteSpace(request.Address.Address)) - return CreateErrorResponse(envelope, "Invalid address: address cannot be empty"); - - // Parse and connect to the peer - var peer = await _peerManager.ConnectToPeerAsync(request.Address); - - _logger.LogInformation("Successfully connected to peer at {Address}", request.Address); - - // Create a success response - var response = new ConnectPeerIpcResponse - { - Id = peer.NodeId, - Features = peer.Features, - IsInitiator = true, - Address = peer.Host, - Type = peer.Type, - Port = peer.Port - }; - - var payload = MessagePackSerializer.Serialize(response, cancellationToken: ct); - return new IpcEnvelope - { - Version = envelope.Version, - Command = envelope.Command, - CorrelationId = envelope.CorrelationId, - Kind = 1, - Payload = payload - }; - } - catch (FormatException ex) - { - _logger.LogWarning(ex, "Invalid peer address format"); - return CreateErrorResponse(envelope, $"Invalid address format: {ex.Message}"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to connect to peer"); - return CreateErrorResponse(envelope, $"Connection failed: {ex.Message}"); - } - } - - private static IpcEnvelope CreateErrorResponse(IpcEnvelope envelope, string errorMessage) - { - var response = new IpcError - { - Code = "1", - Message = errorMessage - }; - - var payload = MessagePackSerializer.Serialize(response); - return new IpcEnvelope - { - Version = envelope.Version, - Command = envelope.Command, - CorrelationId = envelope.CorrelationId, - Kind = 1, - Payload = payload - }; - } -} \ No newline at end of file diff --git a/src/NLightning.Daemon/Handlers/ConnectPeerIpcHandler.cs b/src/NLightning.Daemon/Handlers/ConnectPeerIpcHandler.cs new file mode 100644 index 00000000..a536ca06 --- /dev/null +++ b/src/NLightning.Daemon/Handlers/ConnectPeerIpcHandler.cs @@ -0,0 +1,92 @@ +using MessagePack; +using Microsoft.Extensions.Logging; + +namespace NLightning.Daemon.Handlers; + +using Domain.Exceptions; +using Domain.Node.Interfaces; +using Interfaces; +using Services.Ipc.Factories; +using Transport.Ipc; +using Transport.Ipc.Constants; +using Transport.Ipc.Requests; +using Transport.Ipc.Responses; + +public sealed class ConnectPeerIpcHandler : IIpcCommandHandler +{ + private readonly IPeerManager _peerManager; + private readonly ILogger _logger; + + public NodeIpcCommand Command => NodeIpcCommand.ConnectPeer; + + public ConnectPeerIpcHandler(IPeerManager peerManager, ILogger logger) + { + _peerManager = peerManager; + _logger = logger; + } + + public async Task HandleAsync(IpcEnvelope envelope, CancellationToken ct) + { + try + { + // Deserialize the request + var request = + MessagePackSerializer.Deserialize(envelope.Payload, cancellationToken: ct); + + // Validate the address + if (string.IsNullOrWhiteSpace(request.Address.Address)) + return IpcErrorFactory.CreateErrorEnvelope(envelope, ErrorCodes.InvalidAddress, + "Invalid address: address cannot be empty"); + + // Parse and connect to the peer + var peer = await _peerManager.ConnectToPeerAsync(request.Address); + + _logger.LogInformation("Successfully connected to peer at {Address}", request.Address); + + // Create a success response + var response = new ConnectPeerIpcResponse + { + Id = peer.NodeId, + Features = peer.Features, + IsInitiator = true, + Address = peer.Host, + Type = peer.Type, + Port = peer.Port + }; + + var payload = MessagePackSerializer.Serialize(response, cancellationToken: ct); + return new IpcEnvelope + { + Version = envelope.Version, + Command = envelope.Command, + CorrelationId = envelope.CorrelationId, + Kind = IpcEnvelopeKind.Response, + Payload = payload + }; + } + catch (FormatException fe) + { + _logger.LogWarning(fe, "Invalid peer address format"); + return IpcErrorFactory.CreateErrorEnvelope(envelope, ErrorCodes.InvalidAddress, + $"Invalid address format: {fe.Message}"); + } + catch (InvalidOperationException oe) + { + _logger.LogInformation(oe, "The operation could not be completed"); + return IpcErrorFactory.CreateErrorEnvelope(envelope, ErrorCodes.InvalidOperation, + $"The operation could not be completed: {oe.Message}"); + } + catch (ConnectionException ce) + { + _logger.LogError(ce, "Failed to connect to peer"); + return IpcErrorFactory.CreateErrorEnvelope(envelope, ErrorCodes.ConnectionError, + $"Connection failed: {ce.Message}"); + } + catch (Exception e) + { + _logger.LogError(e, "Error connecting to peer"); + return IpcErrorFactory.CreateErrorEnvelope(envelope, ErrorCodes.ServerError, + $"Error connecting to peer: {e.Message}"); + } + } +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Handlers/ListPeersIpcHandler.cs b/src/NLightning.Daemon/Handlers/ListPeersIpcHandler.cs new file mode 100644 index 00000000..b797465f --- /dev/null +++ b/src/NLightning.Daemon/Handlers/ListPeersIpcHandler.cs @@ -0,0 +1,66 @@ +using MessagePack; +using Microsoft.Extensions.Logging; + +namespace NLightning.Daemon.Handlers; + +using Domain.Node.Interfaces; +using Interfaces; +using Services.Ipc.Factories; +using Transport.Ipc; +using Transport.Ipc.Constants; +using Transport.Ipc.Responses; + +public class ListPeersIpcHandler : IIpcCommandHandler +{ + private readonly IPeerManager _peerManager; + private readonly ILogger _logger; + + public NodeIpcCommand Command => NodeIpcCommand.ListPeers; + + public ListPeersIpcHandler(IPeerManager peerManager, ILogger logger) + { + _peerManager = peerManager; + _logger = logger; + } + + public Task HandleAsync(IpcEnvelope envelope, CancellationToken ct) + { + try + { + var resp = _peerManager.ListPeers(); + var ipcResp = new ListPeersIpcResponse(); + + if (resp.Count > 0) + { + ipcResp.Peers = new List(resp.Count); + foreach (var peer in resp) + { + ipcResp.Peers.Add(new PeerInfoIpcResponse + { + Address = $"{peer.Host}:{peer.Port}", + Connected = true, + Features = peer.Features, + Id = peer.NodeId, + ChannelQty = (uint)(peer.Channels?.Count ?? 0) + }); + } + } + + var payload = MessagePackSerializer.Serialize(ipcResp, cancellationToken: ct); + var responseEnvelope = new IpcEnvelope + { + Version = envelope.Version, + Command = envelope.Command, + CorrelationId = envelope.CorrelationId, + Kind = IpcEnvelopeKind.Response, + Payload = payload + }; + return Task.FromResult(responseEnvelope); + } + catch (Exception e) + { + _logger.LogError(e, "Error listing peers"); + return Task.FromResult(IpcErrorFactory.CreateErrorEnvelope(envelope, ErrorCodes.ServerError, e.Message)); + } + } +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Handlers/NodeInfoIpcHandler.cs b/src/NLightning.Daemon/Handlers/NodeInfoIpcHandler.cs index 81f259ea..6c75d0cf 100644 --- a/src/NLightning.Daemon/Handlers/NodeInfoIpcHandler.cs +++ b/src/NLightning.Daemon/Handlers/NodeInfoIpcHandler.cs @@ -1,43 +1,56 @@ using MessagePack; -using NLightning.Domain.Crypto.ValueObjects; +using Microsoft.Extensions.Logging; namespace NLightning.Daemon.Handlers; +using Domain.Crypto.ValueObjects; using Interfaces; +using Services.Ipc.Factories; using Transport.Ipc; +using Transport.Ipc.Constants; using Transport.Ipc.Responses; public sealed class NodeInfoIpcHandler : IIpcCommandHandler { private readonly INodeInfoQueryService _query; + private readonly ILogger _logger; - public NodeInfoIpcHandler(INodeInfoQueryService query) + public NodeIpcCommand Command => NodeIpcCommand.NodeInfo; + + public NodeInfoIpcHandler(INodeInfoQueryService query, ILogger logger) { _query = query; + _logger = logger; } - public NodeIpcCommand Command => NodeIpcCommand.NodeInfo; - public async Task HandleAsync(IpcEnvelope envelope, CancellationToken ct) { - var resp = await _query.QueryAsync(ct); - var ipcResp = new NodeInfoIpcResponse + try { - Network = resp.Network, - BestBlockHash = new Hash(Convert.FromHexString(resp.BestBlockHash)), - BestBlockHeight = resp.BestBlockHeight, - BestBlockTime = resp.BestBlockTime, - Implementation = resp.Implementation, - Version = resp.Version - }; - var payload = MessagePackSerializer.Serialize(ipcResp, cancellationToken: ct); - return new IpcEnvelope + var resp = await _query.QueryAsync(ct); + var ipcResp = new NodeInfoIpcResponse + { + Network = resp.Network, + BestBlockHash = new Hash(Convert.FromHexString(resp.BestBlockHash)), + BestBlockHeight = resp.BestBlockHeight, + BestBlockTime = resp.BestBlockTime, + Implementation = resp.Implementation, + Version = resp.Version + }; + var payload = MessagePackSerializer.Serialize(ipcResp, cancellationToken: ct); + return new IpcEnvelope + { + Version = envelope.Version, + Command = envelope.Command, + CorrelationId = envelope.CorrelationId, + Kind = IpcEnvelopeKind.Response, + Payload = payload + }; + } + catch (Exception e) { - Version = envelope.Version, - Command = envelope.Command, - CorrelationId = envelope.CorrelationId, - Kind = 1, - Payload = payload - }; + _logger.LogError(e, "Error handling node info"); + return IpcErrorFactory.CreateErrorEnvelope(envelope, ErrorCodes.ServerError, e.Message); + } } } \ No newline at end of file diff --git a/src/NLightning.Daemon/Services/Ipc/Factories/IpcErrorFactory.cs b/src/NLightning.Daemon/Services/Ipc/Factories/IpcErrorFactory.cs new file mode 100644 index 00000000..fba8ddcb --- /dev/null +++ b/src/NLightning.Daemon/Services/Ipc/Factories/IpcErrorFactory.cs @@ -0,0 +1,21 @@ +using MessagePack; + +namespace NLightning.Daemon.Services.Ipc.Factories; + +using Transport.Ipc; + +public static class IpcErrorFactory +{ + public static IpcEnvelope CreateErrorEnvelope(IpcEnvelope originalEnvelope, string errorCode, string errorMessage) + { + var payload = MessagePackSerializer.Serialize(new IpcError { Code = errorCode, Message = errorMessage }); + return new IpcEnvelope + { + Version = originalEnvelope.Version, + Command = originalEnvelope.Command, + CorrelationId = originalEnvelope.CorrelationId, + Kind = IpcEnvelopeKind.Error, + Payload = payload + }; + } +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Services/Ipc/IpcRouting.cs b/src/NLightning.Daemon/Services/Ipc/IpcRouting.cs index 132b2979..b949bff4 100644 --- a/src/NLightning.Daemon/Services/Ipc/IpcRouting.cs +++ b/src/NLightning.Daemon/Services/Ipc/IpcRouting.cs @@ -46,7 +46,7 @@ private static IpcEnvelope Error(IpcEnvelope request, string code, string messag Version = request.Version, Command = request.Command, CorrelationId = request.CorrelationId, - Kind = 2, + Kind = IpcEnvelopeKind.Error, Payload = payload }; } diff --git a/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcHostedService.cs b/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcHostedService.cs index 3caaf5f1..da1945a0 100644 --- a/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcHostedService.cs +++ b/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcHostedService.cs @@ -1,15 +1,16 @@ using System.IO.Pipes; -using MessagePack; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using NLightning.Daemon.Services.Ipc.Factories; namespace NLightning.Daemon.Services.Ipc; using Contracts.Utilities; using Domain.Node.Options; using Interfaces; -using NLightning.Transport.Ipc; +using Transport.Ipc; +using Transport.Ipc.Constants; /// /// Hosted service that listens to on a named pipe and processes IPC requests using injected components. @@ -73,7 +74,8 @@ private async Task HandleClientAsync(NamedPipeServerStream stream, CancellationT if (!await _authenticator.ValidateAsync(request.AuthToken, ct)) { - var err = Error(request, "auth_failed", "Authentication failed."); + var err = IpcErrorFactory.CreateErrorEnvelope(request, ErrorCodes.AuthenticationFailure, + "Authentication failed."); await _framing.WriteAsync(stream, err, ct); return; } @@ -87,8 +89,8 @@ private async Task HandleClientAsync(NamedPipeServerStream stream, CancellationT try { // Try to write a generic error if we still can read an envelope - var env = new IpcEnvelope { Version = 1, CorrelationId = Guid.NewGuid(), Kind = 2 }; - var err = Error(env, "server_error", ex.Message); + var env = new IpcEnvelope { Version = 1, CorrelationId = Guid.NewGuid(), Kind = IpcEnvelopeKind.Error }; + var err = IpcErrorFactory.CreateErrorEnvelope(env, ErrorCodes.ServerError, ex.Message); await _framing.WriteAsync(stream, err, ct); } catch @@ -112,9 +114,7 @@ private void EnsureCookieExists() { var dir = Path.GetDirectoryName(_cookiePath); if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) - { Directory.CreateDirectory(dir); - } if (!File.Exists(_cookiePath)) { @@ -128,17 +128,4 @@ private void EnsureCookieExists() throw; } } - - private static IpcEnvelope Error(IpcEnvelope request, string code, string message) - { - var payload = MessagePackSerializer.Serialize(new IpcError { Code = code, Message = message }); - return new IpcEnvelope - { - Version = request.Version, - Command = request.Command, - CorrelationId = request.CorrelationId, - Kind = 2, - Payload = payload - }; - } } \ No newline at end of file diff --git a/src/NLightning.Domain/Node/Interfaces/IPeerManager.cs b/src/NLightning.Domain/Node/Interfaces/IPeerManager.cs index 2e6e7737..4e058a24 100644 --- a/src/NLightning.Domain/Node/Interfaces/IPeerManager.cs +++ b/src/NLightning.Domain/Node/Interfaces/IPeerManager.cs @@ -34,4 +34,6 @@ public interface IPeerManager /// /// CompactPubKey of the peer void DisconnectPeer(CompactPubKey compactPubKey); + + List ListPeers(); } \ No newline at end of file diff --git a/src/NLightning.Infrastructure/Node/Services/PeerCommunicationService.cs b/src/NLightning.Infrastructure/Node/Services/PeerCommunicationService.cs index eb16c1dd..8fbe020a 100644 --- a/src/NLightning.Infrastructure/Node/Services/PeerCommunicationService.cs +++ b/src/NLightning.Infrastructure/Node/Services/PeerCommunicationService.cs @@ -81,7 +81,7 @@ public async Task InitializeAsync(TimeSpan networkTimeout) if (!task.IsCanceled && !_isInitialized) { RaiseException( - new ConnectionException($"Peer {PeerCompactPubKey} did not send init message after timeout")); + new ConnectionException($"Peer {PeerCompactPubKey} did not send an init message before timeout")); } }); diff --git a/src/NLightning.Infrastructure/Protocol/Models/PeerAddress.cs b/src/NLightning.Infrastructure/Protocol/Models/PeerAddress.cs index af6ab5ec..382b048b 100644 --- a/src/NLightning.Infrastructure/Protocol/Models/PeerAddress.cs +++ b/src/NLightning.Infrastructure/Protocol/Models/PeerAddress.cs @@ -9,7 +9,7 @@ namespace NLightning.Infrastructure.Protocol.Models; /// /// Represents a peer address. /// -public sealed partial class PeerAddress +public sealed partial class PeerAddress : IEquatable { [GeneratedRegex(@"\d+")] private static partial Regex OnlyDigitsRegex(); @@ -117,4 +117,25 @@ public override string ToString() { return $"{PubKey}@{Host}:{Port}"; } + + public bool Equals(PeerAddress? other) + { + if (other is null) + return false; + + if (ReferenceEquals(this, other)) + return true; + + return PubKey.Equals(other.PubKey) && Host.Equals(other.Host) && Port == other.Port; + } + + public override bool Equals(object? obj) + { + return ReferenceEquals(this, obj) || obj is PeerAddress other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(PubKey, Host, Port); + } } \ No newline at end of file diff --git a/src/NLightning.Infrastructure/Transport/Interfaces/ITcpService.cs b/src/NLightning.Infrastructure/Transport/Interfaces/ITcpService.cs index 937d864f..6c79a582 100644 --- a/src/NLightning.Infrastructure/Transport/Interfaces/ITcpService.cs +++ b/src/NLightning.Infrastructure/Transport/Interfaces/ITcpService.cs @@ -1,9 +1,8 @@ -using NLightning.Domain.Node.ValueObjects; -using NLightning.Infrastructure.Node.ValueObjects; - namespace NLightning.Infrastructure.Transport.Interfaces; using Events; +using Node.ValueObjects; +using Protocol.Models; public interface ITcpService { @@ -34,5 +33,5 @@ public interface ITcpService /// /// The address information of the peer to connect to. /// A task representing the asynchronous operation. The result contains a object representing the connected peer. - Task ConnectToPeerAsync(PeerAddressInfo peerAddressInfo); + Task ConnectToPeerAsync(PeerAddress peerAddress); } \ No newline at end of file diff --git a/src/NLightning.Infrastructure/Transport/Services/TcpService.cs b/src/NLightning.Infrastructure/Transport/Services/TcpService.cs index f06b62f4..047de458 100644 --- a/src/NLightning.Infrastructure/Transport/Services/TcpService.cs +++ b/src/NLightning.Infrastructure/Transport/Services/TcpService.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NLightning.Domain.Exceptions; -using NLightning.Domain.Node.ValueObjects; using NLightning.Infrastructure.Node.ValueObjects; using NLightning.Infrastructure.Protocol.Models; @@ -95,10 +94,8 @@ public async Task StopListeningAsync() /// /// Thrown when the connection to the peer fails. - public async Task ConnectToPeerAsync(PeerAddressInfo peerAddressInfo) + public async Task ConnectToPeerAsync(PeerAddress peerAddress) { - var peerAddress = new PeerAddress(peerAddressInfo.Address); - var tcpClient = new TcpClient(); try { diff --git a/src/NLightning.Transport.Ipc/Constants/ErrorCodes.cs b/src/NLightning.Transport.Ipc/Constants/ErrorCodes.cs new file mode 100644 index 00000000..c06d8688 --- /dev/null +++ b/src/NLightning.Transport.Ipc/Constants/ErrorCodes.cs @@ -0,0 +1,10 @@ +namespace NLightning.Transport.Ipc.Constants; + +public static class ErrorCodes +{ + public const string AuthenticationFailure = "auth_failed"; + public const string InvalidAddress = "invalid_address"; + public const string InvalidOperation = "invalid_operation"; + public const string ConnectionError = "connection_error"; + public const string ServerError = "server_error"; +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/IpcEnvelope.cs b/src/NLightning.Transport.Ipc/IpcEnvelope.cs index a63caae8..31b6d6ac 100644 --- a/src/NLightning.Transport.Ipc/IpcEnvelope.cs +++ b/src/NLightning.Transport.Ipc/IpcEnvelope.cs @@ -20,5 +20,5 @@ public sealed class IpcEnvelope [Key(4)] public byte[] Payload { get; set; } = Array.Empty(); // 0 = request, 1 = response, 2 = error - [Key(5)] public byte Kind { get; init; } + [Key(5)] public IpcEnvelopeKind Kind { get; init; } } \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/IpcEnvelopeKind.cs b/src/NLightning.Transport.Ipc/IpcEnvelopeKind.cs new file mode 100644 index 00000000..8dd31c59 --- /dev/null +++ b/src/NLightning.Transport.Ipc/IpcEnvelopeKind.cs @@ -0,0 +1,8 @@ +namespace NLightning.Transport.Ipc; + +public enum IpcEnvelopeKind : byte +{ + Request = 0, + Response = 1, + Error = 2 +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/NodeIpcCommand.cs b/src/NLightning.Transport.Ipc/NodeIpcCommand.cs index 20288a31..13f371ab 100644 --- a/src/NLightning.Transport.Ipc/NodeIpcCommand.cs +++ b/src/NLightning.Transport.Ipc/NodeIpcCommand.cs @@ -9,4 +9,5 @@ public enum NodeIpcCommand Unknown = 0, NodeInfo = 1, ConnectPeer = 2, + ListPeers = 3, } \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/Requests/ConnectPeerIpcRequest.cs b/src/NLightning.Transport.Ipc/Requests/ConnectPeerIpcRequest.cs index 9d39c4a4..5a80476f 100644 --- a/src/NLightning.Transport.Ipc/Requests/ConnectPeerIpcRequest.cs +++ b/src/NLightning.Transport.Ipc/Requests/ConnectPeerIpcRequest.cs @@ -5,10 +5,10 @@ namespace NLightning.Transport.Ipc.Requests; using Domain.Node.ValueObjects; /// -/// Request for Connect command +/// Request for Connect Peer command /// [MessagePackObject] public sealed class ConnectPeerIpcRequest { - [Key(0)] public required PeerAddressInfo Address { get; init; } // {pubkey}@{ip}:{port} + [Key(0)] public required PeerAddressInfo Address { get; init; } } \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/Requests/ListPeersIpcRequest.cs b/src/NLightning.Transport.Ipc/Requests/ListPeersIpcRequest.cs new file mode 100644 index 00000000..046d0809 --- /dev/null +++ b/src/NLightning.Transport.Ipc/Requests/ListPeersIpcRequest.cs @@ -0,0 +1,9 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc.Requests; + +/// +/// Empty request for ListPeers. +/// +[MessagePackObject] +public readonly struct ListPeersIpcRequest; \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/Responses/ConnectPeerIpcResponse.cs b/src/NLightning.Transport.Ipc/Responses/ConnectPeerIpcResponse.cs index e5ff3392..aa5d2d51 100644 --- a/src/NLightning.Transport.Ipc/Responses/ConnectPeerIpcResponse.cs +++ b/src/NLightning.Transport.Ipc/Responses/ConnectPeerIpcResponse.cs @@ -2,7 +2,6 @@ namespace NLightning.Transport.Ipc.Responses; -using Daemon.Contracts.Control; using Domain.Crypto.ValueObjects; using Domain.Node; @@ -12,23 +11,10 @@ namespace NLightning.Transport.Ipc.Responses; [MessagePackObject] public sealed class ConnectPeerIpcResponse { - [Key(0)] public CompactPubKey Id { get; set; } - [Key(1)] public required FeatureSet Features { get; set; } - [Key(2)] public bool IsInitiator { get; set; } - [Key(3)] public required string Address { get; set; } - [Key(4)] public string Type { get; set; } = string.Empty; - [Key(5)] public uint Port { get; set; } - - public ConnectPeerResponse ToContractResponse() - { - return new ConnectPeerResponse - { - Id = Id.ToString(), - Features = Features.ToString(), - IsInitiator = IsInitiator, - Address = Address, - Type = Type, - Port = Port - }; - } + [Key(0)] public CompactPubKey Id { get; init; } + [Key(1)] public required FeatureSet Features { get; init; } + [Key(2)] public bool IsInitiator { get; init; } + [Key(3)] public required string Address { get; init; } + [Key(4)] public required string Type { get; init; } + [Key(5)] public uint Port { get; init; } } \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/Responses/ListPeersIpcResponse.cs b/src/NLightning.Transport.Ipc/Responses/ListPeersIpcResponse.cs new file mode 100644 index 00000000..ba48570e --- /dev/null +++ b/src/NLightning.Transport.Ipc/Responses/ListPeersIpcResponse.cs @@ -0,0 +1,12 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc.Responses; + +/// +/// Response for List Peers command +/// +[MessagePackObject] +public sealed class ListPeersIpcResponse +{ + [Key(0)] public List? Peers { get; set; } +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/Responses/NodeInfoIpcResponse.cs b/src/NLightning.Transport.Ipc/Responses/NodeInfoIpcResponse.cs index b20fece9..050fe9e4 100644 --- a/src/NLightning.Transport.Ipc/Responses/NodeInfoIpcResponse.cs +++ b/src/NLightning.Transport.Ipc/Responses/NodeInfoIpcResponse.cs @@ -2,7 +2,6 @@ namespace NLightning.Transport.Ipc.Responses; -using Daemon.Contracts.Control; using Domain.Crypto.ValueObjects; using Domain.Protocol.ValueObjects; @@ -18,17 +17,4 @@ public sealed class NodeInfoIpcResponse [Key(3)] public DateTimeOffset? BestBlockTime { get; init; } [Key(4)] public string? Implementation { get; set; } = "NLightning"; [Key(5)] public string? Version { get; init; } - - public NodeInfoResponse ToContractResponse() - { - return new NodeInfoResponse - { - Network = Network, - BestBlockHash = BestBlockHash.ToString(), - BestBlockHeight = BestBlockHeight, - BestBlockTime = BestBlockTime, - Implementation = Implementation, - Version = Version - }; - } } \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/Responses/PeerInfoIpcResponse.cs b/src/NLightning.Transport.Ipc/Responses/PeerInfoIpcResponse.cs new file mode 100644 index 00000000..8a19600e --- /dev/null +++ b/src/NLightning.Transport.Ipc/Responses/PeerInfoIpcResponse.cs @@ -0,0 +1,18 @@ +using MessagePack; +using NLightning.Domain.Crypto.ValueObjects; +using NLightning.Domain.Node; + +namespace NLightning.Transport.Ipc.Responses; + +/// +/// Response for Peer Info command +/// +[MessagePackObject] +public class PeerInfoIpcResponse +{ + [Key(0)] public CompactPubKey Id { get; init; } + [Key(1)] public bool Connected { get; init; } + [Key(2)] public uint ChannelQty { get; init; } + [Key(3)] public required string Address { get; init; } + [Key(4)] public required FeatureSet Features { get; init; } +} \ No newline at end of file diff --git a/test/NLightning.Application.Tests/Node/Managers/PeerManagerTests.cs b/test/NLightning.Application.Tests/Node/Managers/PeerManagerTests.cs index 26f60580..3a799794 100644 --- a/test/NLightning.Application.Tests/Node/Managers/PeerManagerTests.cs +++ b/test/NLightning.Application.Tests/Node/Managers/PeerManagerTests.cs @@ -2,6 +2,7 @@ using System.Reflection; using Microsoft.Extensions.Logging; using NBitcoin; +using NLightning.Infrastructure.Protocol.Models; using NLightning.Tests.Utils.Mocks; namespace NLightning.Application.Tests.Node.Managers; @@ -94,11 +95,12 @@ public async Task Given_ValidPeerAddress_When_ConnectToPeerAsync_IsCalled_Then_P _mockPeerServiceFactory.Object, _mockTcpService.Object, _fakeServiceProvider); var peerAddressInfo = new PeerAddressInfo($"{_compactPubKey}@127.0.0.1:9735"); + var peerAddress = new PeerAddress(peerAddressInfo); // Mock the TCP service to return a connected peer var mockTcpClient = new Mock(); var mockConnectedPeer = new ConnectedPeer(_compactPubKey, ExpectedHost, ExpectedPort, mockTcpClient.Object); - _mockTcpService.Setup(t => t.ConnectToPeerAsync(peerAddressInfo)) + _mockTcpService.Setup(t => t.ConnectToPeerAsync(peerAddress)) .ReturnsAsync(mockConnectedPeer); // Setup PeerDbRepository.AddOrUpdateAsync to match the pattern @@ -113,7 +115,7 @@ public async Task Given_ValidPeerAddress_When_ConnectToPeerAsync_IsCalled_Then_P Assert.True(peers.ContainsKey(_compactPubKey)); // Verify the TCP service was called - _mockTcpService.Verify(t => t.ConnectToPeerAsync(peerAddressInfo), Times.Once); + _mockTcpService.Verify(t => t.ConnectToPeerAsync(peerAddress), Times.Once); // Verify peer service factory was called _mockPeerServiceFactory.Verify(f => f.CreateConnectedPeerAsync(_compactPubKey, mockTcpClient.Object), @@ -138,11 +140,12 @@ public async Task Given_ConnectionError_When_ConnectToPeerAsync_IsCalled_Then_Ex _mockPeerServiceFactory.Object, _mockTcpService.Object, _fakeServiceProvider); var peerAddressInfo = new PeerAddressInfo($"{_compactPubKey}@127.0.0.1:9735"); + var peerAddress = new PeerAddress(peerAddressInfo); var expectedError = new ConnectionException("Failed to connect to peer 127.0.0.1:9735"); // Mock TCP service to throw a connection exception - _mockTcpService.Setup(t => t.ConnectToPeerAsync(peerAddressInfo)) + _mockTcpService.Setup(t => t.ConnectToPeerAsync(peerAddress)) .ThrowsAsync(expectedError); // When & Then From 5cbbec36c769f66ed13c7d0084412092dd4557b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADckolas=20Goline?= Date: Mon, 27 Oct 2025 15:01:16 -0300 Subject: [PATCH 06/20] add getaddress command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Níckolas Goline --- .../Ipc/NamedPipeIpcClient.cs | 38 +++++ .../Printers/GetAddressPrinter.cs | 16 ++ src/NLightning.Client/Program.cs | 5 + .../Extensions/NodeServiceExtensions.cs | 3 +- .../Handlers/GetAddressIpcHandler.cs | 77 ++++++++++ src/NLightning.Daemon/Program.cs | 4 +- .../Addresses/Models/WalletAddressModel.cs | 31 ++++ .../Bitcoin/Constants/KeyConstants.cs | 8 + .../Bitcoin/Enums/AddressType.cs | 8 + .../IWalletAddressesDbRepository.cs | 13 ++ .../Models/WatchedTransactionModel.cs | 2 +- .../Persistence/Interfaces/IUnitOfWork.cs | 1 + .../Protocol/Interfaces/ISecureKeyManager.cs | 8 +- .../DependencyInjection.cs | 5 +- .../Managers/SecureKeyManager.cs | 51 +++++-- .../Signers/LocalLightningSigner.cs | 10 +- .../Wallet/BitcoinChainService.cs | 111 ++++++++++++++ .../Wallet/BitcoinWalletService.cs | 137 +++++++----------- .../Wallet/BlockchainMonitorService.cs | 87 +++++++++-- ...tcoinWallet.cs => IBitcoinChainService.cs} | 2 +- .../Interfaces/IBitcoinWalletService.cs | 8 + .../Wallet/Interfaces/IBlockchainMonitor.cs | 2 + .../Migrations/20251023145101_AddPeerType.cs | 29 ---- ...AddPeerTypeAndWalletAddresses.Designer.cs} | 35 ++++- ...027154736_AddPeerTypeAndWalletAddresses.cs | 47 ++++++ .../NLightningDbContextModelSnapshot.cs | 31 ++++ .../Migrations/20251023145110_AddPeerType.cs | 29 ---- ...AddPeerTypeAndWalletAddresses.Designer.cs} | 29 +++- ...027154749_AddPeerTypeAndWalletAddresses.cs | 47 ++++++ .../NLightningDbContextModelSnapshot.cs | 25 ++++ .../Migrations/20251023145106_AddPeerType.cs | 29 ---- ...AddPeerTypeAndWalletAddresses.Designer.cs} | 29 +++- ...027154742_AddPeerTypeAndWalletAddresses.cs | 47 ++++++ .../NLightningDbContextModelSnapshot.cs | 25 ++++ .../Contexts/NLightningDbContext.cs | 2 + .../Entities/Bitcoin/WalletAddressEntity.cs | 12 ++ .../WalletAddressEntityConfiguration.cs | 25 ++++ .../Bitcoin/RevocationWatchDbRepository.cs | 8 +- .../Bitcoin/WalletAddressesDbRepository.cs | 73 ++++++++++ .../UnitOfWork.cs | 4 + .../NodeIpcCommand.cs | 1 + .../Requests/GetAddressIpcRequest.cs | 14 ++ .../Responses/GetAddressIpcResponse.cs | 13 ++ .../Wallet/BlockchainMonitorServiceTests.cs | 53 +++---- .../Docker/Mock/FakeSecureKeyManager.cs | 22 ++- 45 files changed, 1010 insertions(+), 246 deletions(-) create mode 100644 src/NLightning.Client/Printers/GetAddressPrinter.cs create mode 100644 src/NLightning.Daemon/Handlers/GetAddressIpcHandler.cs create mode 100644 src/NLightning.Domain/Bitcoin/Addresses/Models/WalletAddressModel.cs create mode 100644 src/NLightning.Domain/Bitcoin/Constants/KeyConstants.cs create mode 100644 src/NLightning.Domain/Bitcoin/Enums/AddressType.cs create mode 100644 src/NLightning.Domain/Bitcoin/Interfaces/IWalletAddressesDbRepository.cs create mode 100644 src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinChainService.cs rename src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/{IBitcoinWallet.cs => IBitcoinChainService.cs} (90%) create mode 100644 src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBitcoinWalletService.cs delete mode 100644 src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251023145101_AddPeerType.cs rename src/NLightning.Infrastructure.Persistence.Postgres/Migrations/{20251023145101_AddPeerType.Designer.cs => 20251027154736_AddPeerTypeAndWalletAddresses.Designer.cs} (93%) create mode 100644 src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027154736_AddPeerTypeAndWalletAddresses.cs delete mode 100644 src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251023145110_AddPeerType.cs rename src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/{20251023145110_AddPeerType.Designer.cs => 20251027154749_AddPeerTypeAndWalletAddresses.Designer.cs} (93%) create mode 100644 src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027154749_AddPeerTypeAndWalletAddresses.cs delete mode 100644 src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251023145106_AddPeerType.cs rename src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/{20251023145106_AddPeerType.Designer.cs => 20251027154742_AddPeerTypeAndWalletAddresses.Designer.cs} (92%) create mode 100644 src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027154742_AddPeerTypeAndWalletAddresses.cs create mode 100644 src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/WalletAddressEntity.cs create mode 100644 src/NLightning.Infrastructure.Persistence/EntityConfiguration/Bitcoin/WalletAddressEntityConfiguration.cs create mode 100644 src/NLightning.Infrastructure.Repositories/Database/Bitcoin/WalletAddressesDbRepository.cs create mode 100644 src/NLightning.Transport.Ipc/Requests/GetAddressIpcRequest.cs create mode 100644 src/NLightning.Transport.Ipc/Responses/GetAddressIpcResponse.cs diff --git a/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs b/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs index 0981a866..09ad368c 100644 --- a/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs +++ b/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs @@ -1,6 +1,7 @@ using System.Buffers; using System.IO.Pipes; using MessagePack; +using NLightning.Domain.Bitcoin.Enums; namespace NLightning.Client.Ipc; @@ -97,6 +98,43 @@ public async Task ListPeersAsync(CancellationToken ct = de throw new InvalidOperationException($"IPC error {err.Code}: {err.Message}"); } + public async Task GetAddressAsync(string? addressTypeString, CancellationToken ct = default) + { + var addressType = AddressType.P2Tr; + if (!string.IsNullOrWhiteSpace(addressTypeString)) + { + addressType = addressTypeString.ToLowerInvariant() switch + { + "p2tr" => AddressType.P2Tr, + "p2wpkh" => AddressType.P2Wpkh, + "all" => AddressType.P2Tr | AddressType.P2Wpkh, + _ => throw new ArgumentOutOfRangeException(nameof(addressTypeString), addressTypeString, + "Address has to be `p2tr`, `p2wpkh`, or `all`.") + }; + } + + var req = new GetAddressIpcRequest { AddressType = addressType }; + var payload = MessagePackSerializer.Serialize(req, cancellationToken: ct); + var env = new IpcEnvelope + { + Version = 1, + Command = NodeIpcCommand.GetAddress, + CorrelationId = Guid.NewGuid(), + AuthToken = await GetAuthTokenAsync(ct), + Payload = payload, + Kind = IpcEnvelopeKind.Request + }; + + var respEnv = await SendAsync(env, ct); + if (respEnv.Kind != IpcEnvelopeKind.Error) + { + return MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); + } + + var err = MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); + throw new InvalidOperationException($"IPC error {err.Code}: {err.Message}"); + } + private async Task SendAsync(IpcEnvelope envelope, CancellationToken ct) { await using var client = diff --git a/src/NLightning.Client/Printers/GetAddressPrinter.cs b/src/NLightning.Client/Printers/GetAddressPrinter.cs new file mode 100644 index 00000000..bc9362c6 --- /dev/null +++ b/src/NLightning.Client/Printers/GetAddressPrinter.cs @@ -0,0 +1,16 @@ +namespace NLightning.Client.Printers; + +using Transport.Ipc.Responses; + +public sealed class GetAddressPrinter : IPrinter +{ + public void Print(GetAddressIpcResponse item) + { + Console.WriteLine("Address:"); + if (item.AddressP2Tr is not null) + Console.WriteLine(" P2TR: {0}", item.AddressP2Tr); + + if (item.AddressP2Wsh is not null) + Console.WriteLine(" P2WSH: {0}", item.AddressP2Wsh); + } +} \ No newline at end of file diff --git a/src/NLightning.Client/Program.cs b/src/NLightning.Client/Program.cs index f4d78c78..a521a64e 100644 --- a/src/NLightning.Client/Program.cs +++ b/src/NLightning.Client/Program.cs @@ -54,6 +54,11 @@ var listPeers = await client.ListPeersAsync(cts.Token); new ListPeersPrinter().Print(listPeers); break; + case "getaddress": + case "get-address": + var addresses = await client.GetAddressAsync(commandArgs[0], cts.Token); + new GetAddressPrinter().Print(addresses); + break; default: Console.Error.WriteLine($"Unknown command: {cmd}"); ClientUtils.ShowUsage(); diff --git a/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs b/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs index b0edbdb5..e7c0f779 100644 --- a/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs +++ b/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs @@ -50,13 +50,14 @@ public static IHostBuilder ConfigureNltgServices(this IHostBuilder hostBuilder, // Register the main daemon service services.AddHostedService(); - // Register IPC server + // Register IPC server and handlers services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => { var nodeOptions = sp.GetRequiredService>().Value; diff --git a/src/NLightning.Daemon/Handlers/GetAddressIpcHandler.cs b/src/NLightning.Daemon/Handlers/GetAddressIpcHandler.cs new file mode 100644 index 00000000..12133a29 --- /dev/null +++ b/src/NLightning.Daemon/Handlers/GetAddressIpcHandler.cs @@ -0,0 +1,77 @@ +using MessagePack; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace NLightning.Daemon.Handlers; + +using Domain.Bitcoin.Enums; +using Infrastructure.Bitcoin.Wallet.Interfaces; +using Interfaces; +using Services.Ipc.Factories; +using Transport.Ipc; +using Transport.Ipc.Constants; +using Transport.Ipc.Requests; +using Transport.Ipc.Responses; + +public class GetAddressIpcHandler : IIpcCommandHandler +{ + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + + public NodeIpcCommand Command => NodeIpcCommand.GetAddress; + + public GetAddressIpcHandler(ILogger logger, IServiceProvider serviceProvider) + { + _logger = logger; + _serviceProvider = serviceProvider; + } + + public async Task HandleAsync(IpcEnvelope envelope, CancellationToken ct) + { + try + { + // Deserialize the request + var request = + MessagePackSerializer.Deserialize(envelope.Payload, cancellationToken: ct); + + string? p2Tr = null; + string? p2Wpkh = null; + + // Create a scope for this call + using var scope = _serviceProvider.CreateScope(); + var walletAddressService = scope.ServiceProvider.GetService() ?? + throw new NullReferenceException( + $"Error activating service {nameof(IBitcoinWalletService)}"); + + // Get unused addresses by type + if (request.AddressType.HasFlag(AddressType.P2Tr)) + p2Tr = await walletAddressService.GetUnusedAddressAsync(AddressType.P2Tr, false); + + if (request.AddressType.HasFlag(AddressType.P2Wpkh)) + p2Wpkh = await walletAddressService.GetUnusedAddressAsync(AddressType.P2Wpkh, false); + + // Create a success response + var response = new GetAddressIpcResponse + { + AddressP2Tr = p2Tr, + AddressP2Wsh = p2Wpkh + }; + + var payload = MessagePackSerializer.Serialize(response, cancellationToken: ct); + return new IpcEnvelope + { + Version = envelope.Version, + Command = envelope.Command, + CorrelationId = envelope.CorrelationId, + Kind = IpcEnvelopeKind.Response, + Payload = payload + }; + } + catch (Exception e) + { + _logger.LogError(e, "Error getting a unused address"); + return IpcErrorFactory.CreateErrorEnvelope(envelope, ErrorCodes.ServerError, + $"Error getting a unused address: {e.Message}"); + } + } +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Program.cs b/src/NLightning.Daemon/Program.cs index b36fe5c2..3765f342 100644 --- a/src/NLightning.Daemon/Program.cs +++ b/src/NLightning.Daemon/Program.cs @@ -87,7 +87,7 @@ { // Create logger for the wallet service using Serilog var loggerFactory = LoggerFactory.Create(b => b.AddSerilog(Log.Logger, dispose: false)); - var walletLogger = loggerFactory.CreateLogger(); + var walletLogger = loggerFactory.CreateLogger(); // Bind options from initialConfig var bitcoinOptions = initialConfig.GetSection("Bitcoin").Get() @@ -97,7 +97,7 @@ ?? throw new InvalidOperationException("Node configuration section is missing or invalid."); // Instantiate the service - var bitcoinWalletService = new BitcoinWalletService( + var bitcoinWalletService = new BitcoinChainService( Options.Create(bitcoinOptions), walletLogger, Options.Create(nodeOptions) diff --git a/src/NLightning.Domain/Bitcoin/Addresses/Models/WalletAddressModel.cs b/src/NLightning.Domain/Bitcoin/Addresses/Models/WalletAddressModel.cs new file mode 100644 index 00000000..cf1c62e5 --- /dev/null +++ b/src/NLightning.Domain/Bitcoin/Addresses/Models/WalletAddressModel.cs @@ -0,0 +1,31 @@ +namespace NLightning.Domain.Bitcoin.Addresses.Models; + +using Enums; + +public sealed class WalletAddressModel +{ + public AddressType AddressType { get; } + public uint Index { get; } + public bool IsChange { get; } + public string Address { get; } + public uint UtxoQty { get; private set; } + + public WalletAddressModel(AddressType addressType, uint index, bool isChange, string address, uint utxoQty = 0) + { + AddressType = addressType; + Index = index; + IsChange = isChange; + Address = address; + UtxoQty = utxoQty; + } + + public void IncrementUtxoQty() + { + UtxoQty++; + } + + public override string ToString() + { + return Address; + } +} \ No newline at end of file diff --git a/src/NLightning.Domain/Bitcoin/Constants/KeyConstants.cs b/src/NLightning.Domain/Bitcoin/Constants/KeyConstants.cs new file mode 100644 index 00000000..ab347aef --- /dev/null +++ b/src/NLightning.Domain/Bitcoin/Constants/KeyConstants.cs @@ -0,0 +1,8 @@ +namespace NLightning.Domain.Bitcoin.Constants; + +public static class KeyConstants +{ + public const string ChannelKeyPathString = "m/6425'/0'/0'/0"; + public const string P2TrKeyPathString = "m/86'/0'/0'"; + public const string P2WpkhKeyPathString = "m/84'/0'/0'"; +} \ No newline at end of file diff --git a/src/NLightning.Domain/Bitcoin/Enums/AddressType.cs b/src/NLightning.Domain/Bitcoin/Enums/AddressType.cs new file mode 100644 index 00000000..cec325ef --- /dev/null +++ b/src/NLightning.Domain/Bitcoin/Enums/AddressType.cs @@ -0,0 +1,8 @@ +namespace NLightning.Domain.Bitcoin.Enums; + +[Flags] +public enum AddressType : byte +{ + P2Tr = 1, + P2Wpkh = 2 +} \ No newline at end of file diff --git a/src/NLightning.Domain/Bitcoin/Interfaces/IWalletAddressesDbRepository.cs b/src/NLightning.Domain/Bitcoin/Interfaces/IWalletAddressesDbRepository.cs new file mode 100644 index 00000000..ef0bb73d --- /dev/null +++ b/src/NLightning.Domain/Bitcoin/Interfaces/IWalletAddressesDbRepository.cs @@ -0,0 +1,13 @@ +namespace NLightning.Domain.Bitcoin.Interfaces; + +using Addresses.Models; +using Enums; + +public interface IWalletAddressesDbRepository +{ + Task GetUnusedAddressAsync(AddressType type, bool isChange); + Task GetLastUsedAddressIndex(AddressType addressType, bool isChange); + void AddRange(List addresses); + void UpdateAsync(WalletAddressModel address); + IEnumerable GetAllAddresses(); +} \ No newline at end of file diff --git a/src/NLightning.Domain/Bitcoin/Transactions/Models/WatchedTransactionModel.cs b/src/NLightning.Domain/Bitcoin/Transactions/Models/WatchedTransactionModel.cs index fde2bdb1..c3abdf2d 100644 --- a/src/NLightning.Domain/Bitcoin/Transactions/Models/WatchedTransactionModel.cs +++ b/src/NLightning.Domain/Bitcoin/Transactions/Models/WatchedTransactionModel.cs @@ -3,7 +3,7 @@ namespace NLightning.Domain.Bitcoin.Transactions.Models; using Channels.ValueObjects; using ValueObjects; -public class WatchedTransactionModel +public sealed class WatchedTransactionModel { public ChannelId ChannelId { get; } public TxId TransactionId { get; } diff --git a/src/NLightning.Domain/Persistence/Interfaces/IUnitOfWork.cs b/src/NLightning.Domain/Persistence/Interfaces/IUnitOfWork.cs index 2b989b88..9637044e 100644 --- a/src/NLightning.Domain/Persistence/Interfaces/IUnitOfWork.cs +++ b/src/NLightning.Domain/Persistence/Interfaces/IUnitOfWork.cs @@ -11,6 +11,7 @@ public interface IUnitOfWork : IDisposable // Bitcoin repositories IBlockchainStateDbRepository BlockchainStateDbRepository { get; } IWatchedTransactionDbRepository WatchedTransactionDbRepository { get; } + IWalletAddressesDbRepository WalletAddressesDbRepository { get; } // Chanel repositories IChannelConfigDbRepository ChannelConfigDbRepository { get; } diff --git a/src/NLightning.Domain/Protocol/Interfaces/ISecureKeyManager.cs b/src/NLightning.Domain/Protocol/Interfaces/ISecureKeyManager.cs index b9af5cab..aadaacf4 100644 --- a/src/NLightning.Domain/Protocol/Interfaces/ISecureKeyManager.cs +++ b/src/NLightning.Domain/Protocol/Interfaces/ISecureKeyManager.cs @@ -5,11 +5,13 @@ namespace NLightning.Domain.Protocol.Interfaces; public interface ISecureKeyManager { - BitcoinKeyPath KeyPath { get; } + BitcoinKeyPath ChannelKeyPath { get; } uint HeightOfBirth { get; } - ExtPrivKey GetNextKey(out uint index); - ExtPrivKey GetKeyAtIndex(uint index); + ExtPrivKey GetNextChannelKey(out uint index); + ExtPrivKey GetChannelKeyAtIndex(uint index); + ExtPrivKey GetDepositP2TrKeyAtIndex(uint index, bool isChange); + ExtPrivKey GetDepositP2WpkhKeyAtIndex(uint index, bool isChange); CryptoKeyPair GetNodeKeyPair(); CompactPubKey GetNodePubKey(); } \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Bitcoin/DependencyInjection.cs b/src/NLightning.Infrastructure.Bitcoin/DependencyInjection.cs index 907dcdfe..30097371 100644 --- a/src/NLightning.Infrastructure.Bitcoin/DependencyInjection.cs +++ b/src/NLightning.Infrastructure.Bitcoin/DependencyInjection.cs @@ -25,7 +25,7 @@ public static class DependencyInjection public static IServiceCollection AddBitcoinInfrastructure(this IServiceCollection services) { // Register Singletons - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -34,6 +34,9 @@ public static IServiceCollection AddBitcoinInfrastructure(this IServiceCollectio services.AddSingleton(); services.AddSingleton(); + // Register Scoped Services + services.AddScoped(); + return services; } } \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Bitcoin/Managers/SecureKeyManager.cs b/src/NLightning.Infrastructure.Bitcoin/Managers/SecureKeyManager.cs index fd3b714c..ca746403 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Managers/SecureKeyManager.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Managers/SecureKeyManager.cs @@ -6,6 +6,7 @@ namespace NLightning.Infrastructure.Bitcoin.Managers; +using Domain.Bitcoin.Constants; using Domain.Bitcoin.ValueObjects; using Domain.Crypto.Constants; using Domain.Crypto.ValueObjects; @@ -32,15 +33,25 @@ public class SecureKeyManager : ISecureKeyManager, IDisposable private readonly string _filePath; private readonly object _lastUsedIndexLock = new(); private readonly Network _network; - private readonly KeyPath _keyPath = new("m/6425'/0'/0'/0"); + private readonly KeyPath _channelKeyPath = new(KeyConstants.ChannelKeyPathString); + private readonly KeyPath _depositP2TrKeyPath = new(KeyConstants.P2TrKeyPathString); + private readonly KeyPath _depositP2WpkhKeyPath = new(KeyConstants.P2WpkhKeyPathString); private uint _lastUsedIndex; private ulong _privateKeyLength; private IntPtr _securePrivateKeyPtr; - public BitcoinKeyPath KeyPath => _keyPath.ToBytes(); + public BitcoinKeyPath ChannelKeyPath => _channelKeyPath.ToBytes(); + public BitcoinKeyPath DepositP2TrKeyPath => _depositP2TrKeyPath.ToBytes(); + public BitcoinKeyPath DepositP2WpkhKeyPath => _depositP2WpkhKeyPath.ToBytes(); - public string OutputDescriptor { get; init; } + public string OutputChannelDescriptor { get; init; } + public string OutputDepositP2TrDescriptor { get; init; } + + public string OutputDepositP2WshDescriptor { get; init; } + public string OutputChangeP2TrDescriptor { get; init; } + + public string OutputChangeP2WshDescriptor { get; init; } public uint HeightOfBirth { get; init; } @@ -75,7 +86,11 @@ public SecureKeyManager(byte[] privateKey, BitcoinNetwork network, string filePa var xpub = extKey.Neuter().ToString(_network); var fingerprint = extKey.GetPublicKey().GetHDFingerPrint(); - OutputDescriptor = $"wpkh([{fingerprint}/{KeyPath}/*]{xpub}/0/*)"; + OutputChannelDescriptor = $"wpkh([{fingerprint}/{ChannelKeyPath}/*]{xpub}/0/*)"; + OutputDepositP2TrDescriptor = $"tr([{fingerprint}/{DepositP2TrKeyPath}]{xpub}/0/*)"; + OutputChangeP2TrDescriptor = $"tr([{fingerprint}/{DepositP2TrKeyPath}]{xpub}/1/*)"; + OutputDepositP2WshDescriptor = $"wpkh([{fingerprint}/{DepositP2WpkhKeyPath}]{xpub}/0/*)"; + OutputChangeP2WshDescriptor = $"wpkh([{fingerprint}/{DepositP2WpkhKeyPath}]{xpub}/1/*)"; // Securely wipe the original key from regular memory cryptoProvider.MemoryZero(Marshal.UnsafeAddrOfPinnedArrayElement(privateKey, 0), _privateKeyLength); @@ -84,7 +99,7 @@ public SecureKeyManager(byte[] privateKey, BitcoinNetwork network, string filePa HeightOfBirth = heightOfBirth; } - public ExtPrivKey GetNextKey(out uint index) + public ExtPrivKey GetNextChannelKey(out uint index) { lock (_lastUsedIndexLock) { @@ -94,9 +109,9 @@ public ExtPrivKey GetNextKey(out uint index) // Derive the key at m/6425'/0'/0'/0/index var masterKey = GetMasterKey(); - var derivedKey = masterKey.Derive(_keyPath.Derive(index)); + var derivedKey = masterKey.Derive(_channelKeyPath.Derive(index)); - _ = UpdateLastUsedIndexOnFile().ContinueWith(task => + _ = UpdateLastUsedChannelIndexOnFile().ContinueWith(task => { if (task.IsFaulted) Console.Error.WriteLine($"Failed to update last used index on file: {task.Exception.Message}"); @@ -105,10 +120,22 @@ public ExtPrivKey GetNextKey(out uint index) return derivedKey.ToBytes(); } - public ExtPrivKey GetKeyAtIndex(uint index) + public ExtPrivKey GetChannelKeyAtIndex(uint index) + { + var masterKey = GetMasterKey(); + return masterKey.Derive(_channelKeyPath.Derive(index)).ToBytes(); + } + + public ExtPrivKey GetDepositP2TrKeyAtIndex(uint index, bool isChange) + { + var masterKey = GetMasterKey(); + return masterKey.Derive(_depositP2TrKeyPath.Derive(isChange ? "1" : "0")).Derive(index).ToBytes(); + } + + public ExtPrivKey GetDepositP2WpkhKeyAtIndex(uint index, bool isChange) { var masterKey = GetMasterKey(); - return masterKey.Derive(_keyPath.Derive(index)).ToBytes(); + return masterKey.Derive(_depositP2WpkhKeyPath.Derive(isChange ? "1" : "0")).Derive(index).ToBytes(); } public CryptoKeyPair GetNodeKeyPair() @@ -123,7 +150,7 @@ public CompactPubKey GetNodePubKey() return masterKey.PrivateKey.PubKey.ToBytes(); } - public async Task UpdateLastUsedIndexOnFile() + public async Task UpdateLastUsedChannelIndexOnFile() { var jsonString = await File.ReadAllTextAsync(_filePath); var data = JsonSerializer.Deserialize(jsonString) @@ -160,7 +187,7 @@ public void SaveToFile(string password) { Network = _network.ToString(), LastUsedIndex = _lastUsedIndex, - Descriptor = OutputDescriptor, + Descriptor = OutputChannelDescriptor, EncryptedExtKey = Convert.ToBase64String(cipherText), HeightOfBirth = HeightOfBirth }; @@ -209,7 +236,7 @@ public static SecureKeyManager FromFilePath(string filePath, BitcoinNetwork expe return new SecureKeyManager(extKey.PrivateKey.ToBytes(), expectedNetwork, filePath, data.HeightOfBirth) { _lastUsedIndex = data.LastUsedIndex, - OutputDescriptor = data.Descriptor + OutputChannelDescriptor = data.Descriptor }; } diff --git a/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs b/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs index fcb008bb..e24bcf99 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs @@ -51,7 +51,7 @@ public LocalLightningSigner(IFundingOutputBuilder fundingOutputBuilder, IKeyDeri public uint CreateNewChannel(out ChannelBasepoints basepoints, out CompactPubKey firstPerCommitmentPoint) { // Generate a new key for this channel - var channelPrivExtKey = _secureKeyManager.GetNextKey(out var index); + var channelPrivExtKey = _secureKeyManager.GetNextChannelKey(out var index); var channelKey = ExtKey.CreateFromBytes(channelPrivExtKey); // Generate Lightning basepoints using proper BIP32 derivation paths @@ -86,7 +86,7 @@ public ChannelBasepoints GetChannelBasepoints(uint channelKeyIndex) _logger.LogTrace("Generating channel basepoints for key index {ChannelKeyIndex}", channelKeyIndex); // Recreate the basepoints from the channel key index - var channelExtKey = _secureKeyManager.GetKeyAtIndex(channelKeyIndex); + var channelExtKey = _secureKeyManager.GetChannelKeyAtIndex(channelKeyIndex); var channelKey = ExtKey.CreateFromBytes(channelExtKey); using var localFundingSecret = channelKey.Derive(FundingDerivationIndex, true).PrivateKey; @@ -126,7 +126,7 @@ public CompactPubKey GetPerCommitmentPoint(uint channelKeyIndex, ulong commitmen channelKeyIndex, commitmentNumber); // Derive the per-commitment seed from the channel key - var channelExtKey = _secureKeyManager.GetKeyAtIndex(channelKeyIndex); + var channelExtKey = _secureKeyManager.GetChannelKeyAtIndex(channelKeyIndex); var channelKey = ExtKey.CreateFromBytes(channelExtKey); using var perCommitmentSeed = channelKey.Derive(5).PrivateKey; @@ -162,7 +162,7 @@ public Secret ReleasePerCommitmentSecret(uint channelKeyIndex, ulong commitmentN channelKeyIndex, commitmentNumber); // Derive the per-commitment seed from the channel key - var channelExtKey = _secureKeyManager.GetKeyAtIndex(channelKeyIndex); + var channelExtKey = _secureKeyManager.GetChannelKeyAtIndex(channelKeyIndex); var channelKey = ExtKey.CreateFromBytes(channelExtKey); using var perCommitmentSeed = channelKey.Derive(5).PrivateKey; @@ -303,7 +303,7 @@ public void ValidateSignature(ChannelId channelId, CompactSignature signature, protected virtual Key GenerateFundingPrivateKey(uint channelKeyIndex) { - var channelExtKey = _secureKeyManager.GetKeyAtIndex(channelKeyIndex); + var channelExtKey = _secureKeyManager.GetChannelKeyAtIndex(channelKeyIndex); var channelKey = ExtKey.CreateFromBytes(channelExtKey); return GenerateFundingPrivateKey(channelKey); diff --git a/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinChainService.cs b/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinChainService.cs new file mode 100644 index 00000000..44cbf638 --- /dev/null +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinChainService.cs @@ -0,0 +1,111 @@ +using System.Net; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NBitcoin; +using NBitcoin.RPC; + +namespace NLightning.Infrastructure.Bitcoin.Wallet; + +using Domain.Node.Options; +using Interfaces; +using Options; + +public class BitcoinChainService : IBitcoinChainService +{ + private readonly RPCClient _rpcClient; + private readonly ILogger _logger; + + public BitcoinChainService(IOptions bitcoinOptions, ILogger logger, + IOptions nodeOptions) + { + _logger = logger; + var network = Network.GetNetwork(nodeOptions.Value.BitcoinNetwork) ?? Network.Main; + + var rpcCredentials = new RPCCredentialString + { + UserPassword = new NetworkCredential(bitcoinOptions.Value.RpcUser, bitcoinOptions.Value.RpcPassword) + }; + + _rpcClient = new RPCClient(rpcCredentials, bitcoinOptions.Value.RpcEndpoint, network); + _rpcClient.GetBlockchainInfo(); + } + + public async Task SendTransactionAsync(Transaction transaction) + { + try + { + _logger.LogInformation("Broadcasting transaction {TxId}", transaction.GetHash()); + var result = await _rpcClient.SendRawTransactionAsync(transaction); + _logger.LogInformation("Successfully broadcast transaction {TxId}", result); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to broadcast transaction {TxId}", transaction.GetHash()); + throw; + } + } + + public async Task GetTransactionAsync(uint256 txId) + { + try + { + return await _rpcClient.GetRawTransactionAsync(new uint256(txId), false); + } + catch (RPCException ex) when (ex.RPCCode == RPCErrorCode.RPC_INVALID_ADDRESS_OR_KEY) + { + return null; // Transaction not found + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get transaction {TxId}", txId); + throw; + } + } + + public async Task GetCurrentBlockHeightAsync() + { + try + { + var blockCount = await _rpcClient.GetBlockCountAsync(); + return (uint)blockCount; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get current block height"); + throw; + } + } + + public async Task GetBlockAsync(uint height) + { + try + { + var blockHash = await _rpcClient.GetBlockHashAsync((int)height); + return await _rpcClient.GetBlockAsync(blockHash); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get block at height {Height}", height); + throw; + } + } + + public async Task GetTransactionConfirmationsAsync(uint256 txId) + { + try + { + var txInfo = await _rpcClient.GetRawTransactionInfoAsync(new uint256(txId)); + return txInfo.Confirmations; + } + catch (RPCException ex) when (ex.RPCCode == RPCErrorCode.RPC_INVALID_ADDRESS_OR_KEY) + { + return 0; // Transaction not found + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get confirmations for transaction {TxId}", txId); + throw; + } + } +} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinWalletService.cs b/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinWalletService.cs index 2f42138a..a7e471ed 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinWalletService.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinWalletService.cs @@ -1,110 +1,75 @@ -using System.Net; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NBitcoin; -using NBitcoin.RPC; using NLightning.Domain.Node.Options; -using NLightning.Infrastructure.Bitcoin.Options; -using NLightning.Infrastructure.Bitcoin.Wallet.Interfaces; namespace NLightning.Infrastructure.Bitcoin.Wallet; -public class BitcoinWalletService : IBitcoinWallet +using Domain.Bitcoin.Addresses.Models; +using Domain.Bitcoin.Enums; +using Domain.Bitcoin.ValueObjects; +using Domain.Persistence.Interfaces; +using Domain.Protocol.Interfaces; +using Interfaces; + +public class BitcoinWalletService : IBitcoinWalletService { - private readonly RPCClient _rpcClient; private readonly ILogger _logger; + private readonly Network _network; + private readonly ISecureKeyManager _secureKeyManager; + private readonly IUnitOfWork _uow; - public BitcoinWalletService(IOptions bitcoinOptions, ILogger logger, - IOptions nodeOptions) + public BitcoinWalletService(ILogger logger, IOptions nodeOptions, + ISecureKeyManager secureKeyManager, IUnitOfWork uow) { _logger = logger; - var network = Network.GetNetwork(nodeOptions.Value.BitcoinNetwork) ?? Network.Main; - - var rpcCredentials = new RPCCredentialString - { - UserPassword = new NetworkCredential(bitcoinOptions.Value.RpcUser, bitcoinOptions.Value.RpcPassword) - }; + _secureKeyManager = secureKeyManager; + _uow = uow; - _rpcClient = new RPCClient(rpcCredentials, bitcoinOptions.Value.RpcEndpoint, network); - _rpcClient.GetBlockchainInfo(); + _network = Network.GetNetwork(nodeOptions.Value.BitcoinNetwork) ?? Network.Main; } - public async Task SendTransactionAsync(Transaction transaction) + public async Task GetUnusedAddressAsync(AddressType addressType, bool isChange) { - try - { - _logger.LogInformation("Broadcasting transaction {TxId}", transaction.GetHash()); - var result = await _rpcClient.SendRawTransactionAsync(transaction); - _logger.LogInformation("Successfully broadcast transaction {TxId}", result); - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to broadcast transaction {TxId}", transaction.GetHash()); - throw; - } - } + if ((int)addressType > 2) + throw new InvalidOperationException( + "You cannot use flags for this method. Please select only one address type."); - public async Task GetTransactionAsync(uint256 txId) - { - try - { - return await _rpcClient.GetRawTransactionAsync(new uint256(txId), false); - } - catch (RPCException ex) when (ex.RPCCode == RPCErrorCode.RPC_INVALID_ADDRESS_OR_KEY) - { - return null; // Transaction not found - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get transaction {TxId}", txId); - throw; - } - } + // Find an unused address in the DB + var addressModel = await _uow.WalletAddressesDbRepository.GetUnusedAddressAsync(addressType, isChange); - public async Task GetCurrentBlockHeightAsync() - { - try - { - var blockCount = await _rpcClient.GetBlockCountAsync(); - return (uint)blockCount; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get current block height"); - throw; - } - } + if (addressModel is not null) + return addressModel.Address; - public async Task GetBlockAsync(uint height) - { - try - { - var blockHash = await _rpcClient.GetBlockHashAsync((int)height); - return await _rpcClient.GetBlockAsync(blockHash); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get block at height {Height}", height); - throw; - } - } + // If there's none, get the last used index from db + var lastUsedIndex = await _uow.WalletAddressesDbRepository.GetLastUsedAddressIndex(addressType, isChange); - public async Task GetTransactionConfirmationsAsync(uint256 txId) - { - try - { - var txInfo = await _rpcClient.GetRawTransactionInfoAsync(new uint256(txId)); - return txInfo.Confirmations; - } - catch (RPCException ex) when (ex.RPCCode == RPCErrorCode.RPC_INVALID_ADDRESS_OR_KEY) - { - return 0; // Transaction not found - } - catch (Exception ex) + // Generate 10 new addresses + var addressList = new List(10); + for (var i = lastUsedIndex; i < lastUsedIndex + 10; i++) { - _logger.LogError(ex, "Failed to get confirmations for transaction {TxId}", txId); - throw; + ExtPrivKey extPrivKey; + if (addressType == AddressType.P2Tr) + { + extPrivKey = _secureKeyManager.GetDepositP2TrKeyAtIndex(i, isChange); + var extKey = ExtKey.CreateFromBytes(extPrivKey); + var address = extKey.Neuter().PubKey.GetAddress(ScriptPubKeyType.TaprootBIP86, _network); + + addressList.Add(new WalletAddressModel(addressType, i, isChange, address.ToString())); + } + else + { + extPrivKey = _secureKeyManager.GetDepositP2WpkhKeyAtIndex(i, isChange); + var extKey = ExtKey.CreateFromBytes(extPrivKey); + var address = extKey.Neuter().PubKey.GetAddress(ScriptPubKeyType.Segwit, _network); + + addressList.Add(new WalletAddressModel(addressType, i, isChange, address.ToString())); + } } + + _uow.WalletAddressesDbRepository.AddRange(addressList); + await _uow.SaveChangesAsync(); + + return addressList[0].Address; } } \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs b/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs index 7bb0ecb1..0c7f3409 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs @@ -8,6 +8,7 @@ namespace NLightning.Infrastructure.Bitcoin.Wallet; +using Domain.Bitcoin.Addresses.Models; using Domain.Bitcoin.Events; using Domain.Bitcoin.Transactions.Models; using Domain.Bitcoin.ValueObjects; @@ -21,13 +22,14 @@ namespace NLightning.Infrastructure.Bitcoin.Wallet; public class BlockchainMonitorService : IBlockchainMonitor { private readonly BitcoinOptions _bitcoinOptions; - private readonly IBitcoinWallet _bitcoinWallet; + private readonly IBitcoinChainService _bitcoinChainService; private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; private readonly Network _network; private readonly SemaphoreSlim _newBlockSemaphore = new(1, 1); private readonly SemaphoreSlim _blockBacklogSemaphore = new(1, 1); private readonly ConcurrentDictionary _watchedTransactions = new(); + private readonly ConcurrentDictionary _watchedAddresses = new(); private readonly OrderedDictionary _blocksToProcess = new(); private BlockchainState _blockchainState = new(0, Hash.Empty, DateTime.UtcNow); @@ -40,12 +42,14 @@ public class BlockchainMonitorService : IBlockchainMonitor public event EventHandler? OnNewBlockDetected; public event EventHandler? OnTransactionConfirmed; - public BlockchainMonitorService(IOptions bitcoinOptions, IBitcoinWallet bitcoinWallet, + public uint LastProcessedBlockHeight => _lastProcessedBlockHeight; + + public BlockchainMonitorService(IOptions bitcoinOptions, IBitcoinChainService bitcoinChainService, ILogger logger, IOptions nodeOptions, IServiceProvider serviceProvider) { _bitcoinOptions = bitcoinOptions.Value; - _bitcoinWallet = bitcoinWallet; + _bitcoinChainService = bitcoinChainService; _logger = logger; _serviceProvider = serviceProvider; _network = Network.GetNetwork(nodeOptions.Value.BitcoinNetwork) ?? Network.Main; @@ -61,6 +65,9 @@ public async Task StartAsync(uint heightOfBirth, CancellationToken cancellationT // Load pending transactions await LoadPendingWatchedTransactionsAsync(uow); + // Load existing addresses + LoadBitcoinAddresses(uow); + // Get the current state or create a new one if it doesn't exist var currentBlockchainState = await uow.BlockchainStateDbRepository.GetStateAsync(); if (currentBlockchainState is null) @@ -80,10 +87,10 @@ public async Task StartAsync(uint heightOfBirth, CancellationToken cancellationT } // Get the current block height from the wallet - var currentBlockHeight = await _bitcoinWallet.GetCurrentBlockHeightAsync(); + var currentBlockHeight = await _bitcoinChainService.GetCurrentBlockHeightAsync(); // Add the current block to the processing queue - var currentBlock = await _bitcoinWallet.GetBlockAsync(_lastProcessedBlockHeight); + var currentBlock = await _bitcoinChainService.GetBlockAsync(_lastProcessedBlockHeight); if (currentBlock is not null) _blocksToProcess[_lastProcessedBlockHeight] = currentBlock; @@ -144,6 +151,13 @@ public async Task WatchTransactionAsync(ChannelId channelId, TxId txId, uint req await uow.SaveChangesAsync(); } + public void WatchBitcoinAddress(WalletAddressModel walletAddress) + { + _logger.LogInformation("Watching bitcoin address {walletAddress} for deposits", walletAddress); + + _watchedAddresses[walletAddress.Address] = walletAddress; + } + // public Task WatchForRevocationAsync(TxId commitmentTxId, SignedTransaction penaltyTx) // { // _logger.LogInformation("Watching for revocation of commitment transaction {CommitmentTxId}", commitmentTxId); @@ -180,10 +194,10 @@ private async Task MonitorBlockchainAsync(CancellationToken cancellationToken) if (!coinbaseHeight.HasValue) { // Get the current height from the wallet - var currentHeight = await _bitcoinWallet.GetCurrentBlockHeightAsync(); + var currentHeight = await _bitcoinChainService.GetCurrentBlockHeightAsync(); // Get the block from the wallet - var blockAtHeight = await _bitcoinWallet.GetBlockAsync(currentHeight); + var blockAtHeight = await _bitcoinChainService.GetBlockAsync(currentHeight); if (blockAtHeight is null) { _logger.LogError("Failed to retrieve block at height {Height}", currentHeight); @@ -310,7 +324,7 @@ private async Task AddMissingBlocksToProcessAsync(uint currentHeight) continue; // Add missing block to process queue - var blockAtHeight = await _bitcoinWallet.GetBlockAsync(height); + var blockAtHeight = await _bitcoinChainService.GetBlockAsync(height); if (blockAtHeight is not null) { _blocksToProcess[height] = blockAtHeight; @@ -376,7 +390,10 @@ private void ProcessBlock(Block block, uint height, IUnitOfWork uow) OnNewBlockDetected?.Invoke(this, new NewBlockEventArgs(height, blockHash.ToBytes())); // Check if watched transactions are included in this block - CheckWatchedTransactionsForBlock(block.Transactions, height, uow); + CheckBlockForWatchedTransactions(block.Transactions, height, uow); + + // Check for deposits in this block + CheckBlockForDeposits(block.Transactions, height, uow); // Update blockchain state _blockchainState.UpdateState(blockHash.ToBytes(), height); @@ -410,7 +427,7 @@ private void ConfirmTransaction(uint blockHeight, IUnitOfWork uow, WatchedTransa _watchedTransactions.TryRemove(new uint256(watchedTransaction.TransactionId), out _); } - private void CheckWatchedTransactionsForBlock(List blockTransactions, uint blockHeight, + private void CheckBlockForWatchedTransactions(List blockTransactions, uint blockHeight, IUnitOfWork uow) { _logger.LogDebug( @@ -447,6 +464,45 @@ private void CheckWatchedTransactionsForBlock(List blockTransaction } } + private void CheckBlockForDeposits(List transactions, uint blockHeight, IUnitOfWork uow) + { + if (_watchedAddresses.IsEmpty) + return; + + _logger.LogDebug("Checking {AddressCount} watched addresses for deposits in block {Height}", + _watchedAddresses.Count, blockHeight); + + foreach (var transaction in transactions) + { + var txId = transaction.GetHash(); + + // Check each output + for (var i = 0; i < transaction.Outputs.Count; i++) + { + var output = transaction.Outputs[i]; + var destinationAddress = output.ScriptPubKey.GetDestinationAddress(_network); + if (destinationAddress == null) + continue; + + if (!_watchedAddresses.TryGetValue(destinationAddress.ToString(), out var watchedAddress)) + continue; + + _logger.LogInformation( + "Deposit detected: {amount} to address {destinationAddress} in tx {txId} at block {height}", + output.Value, destinationAddress, txId, blockHeight); + + watchedAddress.IncrementUtxoQty(); + uow.WalletAddressesDbRepository.UpdateAsync(watchedAddress); + + // Save Utxo to the database + + if (!_watchedAddresses.TryRemove(destinationAddress.ToString(), out _)) + _logger.LogError("Unable to remove watched address {DestinationAddress} from the list", + destinationAddress); + } + } + } + private void CheckWatchedTransactionsDepth(IUnitOfWork uow) { foreach (var (txId, watchedTransaction) in _watchedTransactions) @@ -474,4 +530,15 @@ private async Task LoadPendingWatchedTransactionsAsync(IUnitOfWork uow) _watchedTransactions[new uint256(watchedTransaction.TransactionId)] = watchedTransaction; } } + + private void LoadBitcoinAddresses(IUnitOfWork uow) + { + _logger.LogInformation("Loading bitcoin addresses from database"); + + var bitcoinAddresses = uow.WalletAddressesDbRepository.GetAllAddresses(); + foreach (var bitcoinAddress in bitcoinAddresses) + { + _watchedAddresses[bitcoinAddress.Address] = bitcoinAddress; + } + } } \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBitcoinWallet.cs b/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBitcoinChainService.cs similarity index 90% rename from src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBitcoinWallet.cs rename to src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBitcoinChainService.cs index 63bbf5ed..b604ec20 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBitcoinWallet.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBitcoinChainService.cs @@ -2,7 +2,7 @@ namespace NLightning.Infrastructure.Bitcoin.Wallet.Interfaces; -public interface IBitcoinWallet +public interface IBitcoinChainService { Task SendTransactionAsync(Transaction transaction); Task GetTransactionAsync(uint256 txId); diff --git a/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBitcoinWalletService.cs b/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBitcoinWalletService.cs new file mode 100644 index 00000000..56869a7d --- /dev/null +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBitcoinWalletService.cs @@ -0,0 +1,8 @@ +namespace NLightning.Infrastructure.Bitcoin.Wallet.Interfaces; + +using Domain.Bitcoin.Enums; + +public interface IBitcoinWalletService +{ + Task GetUnusedAddressAsync(AddressType addressType, bool isChange); +} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBlockchainMonitor.cs b/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBlockchainMonitor.cs index 582b103a..15c5e8c9 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBlockchainMonitor.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBlockchainMonitor.cs @@ -1,5 +1,6 @@ namespace NLightning.Infrastructure.Bitcoin.Wallet.Interfaces; +using Domain.Bitcoin.Addresses.Models; using Domain.Bitcoin.Events; using Domain.Bitcoin.ValueObjects; using Domain.Channels.ValueObjects; @@ -7,6 +8,7 @@ namespace NLightning.Infrastructure.Bitcoin.Wallet.Interfaces; public interface IBlockchainMonitor { Task WatchTransactionAsync(ChannelId channelId, TxId txId, uint requiredDepth); + void WatchBitcoinAddress(WalletAddressModel walletAddress); event EventHandler OnNewBlockDetected; event EventHandler OnTransactionConfirmed; diff --git a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251023145101_AddPeerType.cs b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251023145101_AddPeerType.cs deleted file mode 100644 index 12b15f8a..00000000 --- a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251023145101_AddPeerType.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace NLightning.Infrastructure.Persistence.Postgres.Migrations -{ - /// - public partial class AddPeerType : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "type", - table: "peers", - type: "text", - nullable: false, - defaultValue: ""); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "type", - table: "peers"); - } - } -} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251023145101_AddPeerType.Designer.cs b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027154736_AddPeerTypeAndWalletAddresses.Designer.cs similarity index 93% rename from src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251023145101_AddPeerType.Designer.cs rename to src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027154736_AddPeerTypeAndWalletAddresses.Designer.cs index 36e0d878..3ceb35c1 100644 --- a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251023145101_AddPeerType.Designer.cs +++ b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027154736_AddPeerTypeAndWalletAddresses.Designer.cs @@ -12,8 +12,8 @@ namespace NLightning.Infrastructure.Persistence.Postgres.Migrations { [DbContext(typeof(NLightningDbContext))] - [Migration("20251023145101_AddPeerType")] - partial class AddPeerType + [Migration("20251027154736_AddPeerTypeAndWalletAddresses")] + partial class AddPeerTypeAndWalletAddresses { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -51,6 +51,37 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("blockchain_states", (string)null); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", b => + { + b.Property("Index") + .HasColumnType("bigint") + .HasColumnName("index"); + + b.Property("IsChange") + .HasColumnType("boolean") + .HasColumnName("is_change"); + + b.Property("AddressType") + .HasColumnType("smallint") + .HasColumnName("address_type"); + + b.Property("Address") + .IsRequired() + .HasColumnType("text") + .HasColumnName("address"); + + b.Property("UtxoQty") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("utxo_qty"); + + b.HasKey("Index", "IsChange", "AddressType") + .HasName("pk_wallet_addresses"); + + b.ToTable("wallet_addresses", (string)null); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => { b.Property("TransactionId") diff --git a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027154736_AddPeerTypeAndWalletAddresses.cs b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027154736_AddPeerTypeAndWalletAddresses.cs new file mode 100644 index 00000000..a1320fd0 --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027154736_AddPeerTypeAndWalletAddresses.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NLightning.Infrastructure.Persistence.Postgres.Migrations +{ + /// + public partial class AddPeerTypeAndWalletAddresses : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "type", + table: "peers", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "wallet_addresses", + columns: table => new + { + index = table.Column(type: "bigint", nullable: false), + is_change = table.Column(type: "boolean", nullable: false), + address_type = table.Column(type: "smallint", nullable: false), + address = table.Column(type: "text", nullable: false), + utxo_qty = table.Column(type: "bigint", nullable: false, defaultValue: 0L) + }, + constraints: table => + { + table.PrimaryKey("pk_wallet_addresses", x => new { x.index, x.is_change, x.address_type }); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "wallet_addresses"); + + migrationBuilder.DropColumn( + name: "type", + table: "peers"); + } + } +} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/NLightningDbContextModelSnapshot.cs b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/NLightningDbContextModelSnapshot.cs index ae9736d5..9c273415 100644 --- a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/NLightningDbContextModelSnapshot.cs +++ b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/NLightningDbContextModelSnapshot.cs @@ -48,6 +48,37 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("blockchain_states", (string)null); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", b => + { + b.Property("Index") + .HasColumnType("bigint") + .HasColumnName("index"); + + b.Property("IsChange") + .HasColumnType("boolean") + .HasColumnName("is_change"); + + b.Property("AddressType") + .HasColumnType("smallint") + .HasColumnName("address_type"); + + b.Property("Address") + .IsRequired() + .HasColumnType("text") + .HasColumnName("address"); + + b.Property("UtxoQty") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("utxo_qty"); + + b.HasKey("Index", "IsChange", "AddressType") + .HasName("pk_wallet_addresses"); + + b.ToTable("wallet_addresses", (string)null); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => { b.Property("TransactionId") diff --git a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251023145110_AddPeerType.cs b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251023145110_AddPeerType.cs deleted file mode 100644 index 59ae1c5c..00000000 --- a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251023145110_AddPeerType.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace NLightning.Infrastructure.Persistence.SqlServer.Migrations -{ - /// - public partial class AddPeerType : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "Type", - table: "Peers", - type: "nvarchar(max)", - nullable: false, - defaultValue: ""); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "Type", - table: "Peers"); - } - } -} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251023145110_AddPeerType.Designer.cs b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027154749_AddPeerTypeAndWalletAddresses.Designer.cs similarity index 93% rename from src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251023145110_AddPeerType.Designer.cs rename to src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027154749_AddPeerTypeAndWalletAddresses.Designer.cs index 56cd3425..ab98575c 100644 --- a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251023145110_AddPeerType.Designer.cs +++ b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027154749_AddPeerTypeAndWalletAddresses.Designer.cs @@ -12,8 +12,8 @@ namespace NLightning.Infrastructure.Persistence.SqlServer.Migrations { [DbContext(typeof(NLightningDbContext))] - [Migration("20251023145110_AddPeerType")] - partial class AddPeerType + [Migration("20251027154749_AddPeerTypeAndWalletAddresses")] + partial class AddPeerTypeAndWalletAddresses { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -46,6 +46,31 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("BlockchainStates"); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", b => + { + b.Property("Index") + .HasColumnType("bigint"); + + b.Property("IsChange") + .HasColumnType("bit"); + + b.Property("AddressType") + .HasColumnType("tinyint"); + + b.Property("Address") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UtxoQty") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Index", "IsChange", "AddressType"); + + b.ToTable("WalletAddresses"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => { b.Property("TransactionId") diff --git a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027154749_AddPeerTypeAndWalletAddresses.cs b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027154749_AddPeerTypeAndWalletAddresses.cs new file mode 100644 index 00000000..39712229 --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027154749_AddPeerTypeAndWalletAddresses.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NLightning.Infrastructure.Persistence.SqlServer.Migrations +{ + /// + public partial class AddPeerTypeAndWalletAddresses : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Type", + table: "Peers", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "WalletAddresses", + columns: table => new + { + Index = table.Column(type: "bigint", nullable: false), + IsChange = table.Column(type: "bit", nullable: false), + AddressType = table.Column(type: "tinyint", nullable: false), + Address = table.Column(type: "nvarchar(max)", nullable: false), + UtxoQty = table.Column(type: "bigint", nullable: false, defaultValue: 0L) + }, + constraints: table => + { + table.PrimaryKey("PK_WalletAddresses", x => new { x.Index, x.IsChange, x.AddressType }); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "WalletAddresses"); + + migrationBuilder.DropColumn( + name: "Type", + table: "Peers"); + } + } +} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/NLightningDbContextModelSnapshot.cs b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/NLightningDbContextModelSnapshot.cs index 60bc0e75..3a8e6359 100644 --- a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/NLightningDbContextModelSnapshot.cs +++ b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/NLightningDbContextModelSnapshot.cs @@ -43,6 +43,31 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("BlockchainStates"); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", b => + { + b.Property("Index") + .HasColumnType("bigint"); + + b.Property("IsChange") + .HasColumnType("bit"); + + b.Property("AddressType") + .HasColumnType("tinyint"); + + b.Property("Address") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UtxoQty") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Index", "IsChange", "AddressType"); + + b.ToTable("WalletAddresses"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => { b.Property("TransactionId") diff --git a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251023145106_AddPeerType.cs b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251023145106_AddPeerType.cs deleted file mode 100644 index ba342dcc..00000000 --- a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251023145106_AddPeerType.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace NLightning.Infrastructure.Persistence.Sqlite.Migrations -{ - /// - public partial class AddPeerType : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "Type", - table: "Peers", - type: "TEXT", - nullable: false, - defaultValue: ""); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "Type", - table: "Peers"); - } - } -} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251023145106_AddPeerType.Designer.cs b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027154742_AddPeerTypeAndWalletAddresses.Designer.cs similarity index 92% rename from src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251023145106_AddPeerType.Designer.cs rename to src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027154742_AddPeerTypeAndWalletAddresses.Designer.cs index 181d4b19..0f8dc1a8 100644 --- a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251023145106_AddPeerType.Designer.cs +++ b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027154742_AddPeerTypeAndWalletAddresses.Designer.cs @@ -11,8 +11,8 @@ namespace NLightning.Infrastructure.Persistence.Sqlite.Migrations { [DbContext(typeof(NLightningDbContext))] - [Migration("20251023145106_AddPeerType")] - partial class AddPeerType + [Migration("20251027154742_AddPeerTypeAndWalletAddresses")] + partial class AddPeerTypeAndWalletAddresses { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -41,6 +41,31 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("BlockchainStates"); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", b => + { + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsChange") + .HasColumnType("INTEGER"); + + b.Property("AddressType") + .HasColumnType("INTEGER"); + + b.Property("Address") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UtxoQty") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0u); + + b.HasKey("Index", "IsChange", "AddressType"); + + b.ToTable("WalletAddresses"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => { b.Property("TransactionId") diff --git a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027154742_AddPeerTypeAndWalletAddresses.cs b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027154742_AddPeerTypeAndWalletAddresses.cs new file mode 100644 index 00000000..6a5131ed --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027154742_AddPeerTypeAndWalletAddresses.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NLightning.Infrastructure.Persistence.Sqlite.Migrations +{ + /// + public partial class AddPeerTypeAndWalletAddresses : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Type", + table: "Peers", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "WalletAddresses", + columns: table => new + { + Index = table.Column(type: "INTEGER", nullable: false), + IsChange = table.Column(type: "INTEGER", nullable: false), + AddressType = table.Column(type: "INTEGER", nullable: false), + Address = table.Column(type: "TEXT", nullable: false), + UtxoQty = table.Column(type: "INTEGER", nullable: false, defaultValue: 0u) + }, + constraints: table => + { + table.PrimaryKey("PK_WalletAddresses", x => new { x.Index, x.IsChange, x.AddressType }); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "WalletAddresses"); + + migrationBuilder.DropColumn( + name: "Type", + table: "Peers"); + } + } +} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/NLightningDbContextModelSnapshot.cs b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/NLightningDbContextModelSnapshot.cs index e4da6953..dd26be76 100644 --- a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/NLightningDbContextModelSnapshot.cs +++ b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/NLightningDbContextModelSnapshot.cs @@ -38,6 +38,31 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("BlockchainStates"); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", b => + { + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsChange") + .HasColumnType("INTEGER"); + + b.Property("AddressType") + .HasColumnType("INTEGER"); + + b.Property("Address") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UtxoQty") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0u); + + b.HasKey("Index", "IsChange", "AddressType"); + + b.ToTable("WalletAddresses"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => { b.Property("TransactionId") diff --git a/src/NLightning.Infrastructure.Persistence/Contexts/NLightningDbContext.cs b/src/NLightning.Infrastructure.Persistence/Contexts/NLightningDbContext.cs index 41efaaa5..0dd73043 100644 --- a/src/NLightning.Infrastructure.Persistence/Contexts/NLightningDbContext.cs +++ b/src/NLightning.Infrastructure.Persistence/Contexts/NLightningDbContext.cs @@ -24,6 +24,7 @@ public NLightningDbContext(DbContextOptions options, Databa // Bitcoin DbSets public DbSet BlockchainStates { get; set; } public DbSet WatchedTransactions { get; set; } + public DbSet WalletAddresses { get; set; } // Channel DbSets public DbSet Channels { get; set; } @@ -41,6 +42,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // Bitcoin entities modelBuilder.ConfigureBlockchainStateEntity(_databaseType); modelBuilder.ConfigureWatchedTransactionEntity(_databaseType); + modelBuilder.ConfigureWalletAddressEntity(_databaseType); // Channel entities modelBuilder.ConfigureChannelEntity(_databaseType); diff --git a/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/WalletAddressEntity.cs b/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/WalletAddressEntity.cs new file mode 100644 index 00000000..7fbdca8f --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/WalletAddressEntity.cs @@ -0,0 +1,12 @@ +namespace NLightning.Infrastructure.Persistence.Entities.Bitcoin; + +using Domain.Bitcoin.Enums; + +public class WalletAddressEntity +{ + public uint Index { get; set; } + public bool IsChange { get; set; } + public required AddressType AddressType { get; set; } + public required string Address { get; set; } + public uint UtxoQty { get; set; } +} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence/EntityConfiguration/Bitcoin/WalletAddressEntityConfiguration.cs b/src/NLightning.Infrastructure.Persistence/EntityConfiguration/Bitcoin/WalletAddressEntityConfiguration.cs new file mode 100644 index 00000000..786d7f3b --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence/EntityConfiguration/Bitcoin/WalletAddressEntityConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; + +namespace NLightning.Infrastructure.Persistence.EntityConfiguration.Bitcoin; + +using Entities.Bitcoin; +using Enums; + +public static class WalletAddressEntityConfiguration +{ + public static void ConfigureWalletAddressEntity(this ModelBuilder modelBuilder, DatabaseType databaseType) + { + modelBuilder.Entity(entity => + { + // Set Primary Key + entity.HasKey(e => new { e.Index, e.IsChange, e.AddressType }); + + // Set Required props + entity.Property(e => e.Address) + .IsRequired(); + entity.Property(e => e.UtxoQty) + .IsRequired() + .HasDefaultValue(0); + }); + } +} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/RevocationWatchDbRepository.cs b/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/RevocationWatchDbRepository.cs index c90f950d..d40c38a3 100644 --- a/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/RevocationWatchDbRepository.cs +++ b/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/RevocationWatchDbRepository.cs @@ -1,9 +1,9 @@ -using NLightning.Domain.Bitcoin.Interfaces; -using NLightning.Infrastructure.Persistence.Contexts; -using NLightning.Infrastructure.Persistence.Entities.Bitcoin; - namespace NLightning.Infrastructure.Repositories.Database.Bitcoin; +using Domain.Bitcoin.Interfaces; +using Persistence.Contexts; +using Persistence.Entities.Bitcoin; + public class RevocationWatchDbRepository(NLightningDbContext context) : BaseDbRepository(context), IRevocationWatchDbRepository { diff --git a/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/WalletAddressesDbRepository.cs b/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/WalletAddressesDbRepository.cs new file mode 100644 index 00000000..a4ee3052 --- /dev/null +++ b/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/WalletAddressesDbRepository.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore; + +namespace NLightning.Infrastructure.Repositories.Database.Bitcoin; + +using Domain.Bitcoin.Addresses.Models; +using Domain.Bitcoin.Enums; +using Domain.Bitcoin.Interfaces; +using Persistence.Contexts; +using Persistence.Entities.Bitcoin; + +public class WalletAddressesDbRepository(NLightningDbContext context) + : BaseDbRepository(context), IWalletAddressesDbRepository +{ + public async Task GetUnusedAddressAsync(AddressType type, bool isChange) + { + var walletAddressEntity = await DbSet + .AsNoTracking() + .Where(x => x.AddressType.Equals(type) + && x.IsChange.Equals(isChange) + && x.UtxoQty.Equals(0)) + .OrderBy(x => x.UtxoQty) + .FirstOrDefaultAsync(); + + return walletAddressEntity is null ? null : MapEntityToModel(walletAddressEntity); + } + + public async Task GetLastUsedAddressIndex(AddressType addressType, bool isChange) + { + var walletAddressEntity = await DbSet + .AsNoTracking() + .Where(x => x.AddressType.Equals(addressType) + && x.IsChange.Equals(isChange)) + .OrderByDescending(x => x.Index) + .FirstOrDefaultAsync(); + + return walletAddressEntity?.Index ?? 0; + } + + public void AddRange(List addresses) + { + var walletAddressEntities = addresses.Select(MapDomainToEntity); + DbSet.AddRange(walletAddressEntities); + } + + public void UpdateAsync(WalletAddressModel address) + { + var walletAddressEntity = MapDomainToEntity(address); + Update(walletAddressEntity); + } + + public IEnumerable GetAllAddresses() + { + return DbSet.AsNoTracking().AsEnumerable().Select(MapEntityToModel); + } + + private static WalletAddressEntity MapDomainToEntity(WalletAddressModel model) + { + return new WalletAddressEntity + { + Index = model.Index, + IsChange = model.IsChange, + AddressType = model.AddressType, + Address = model.Address, + UtxoQty = model.UtxoQty + }; + } + + private static WalletAddressModel MapEntityToModel(WalletAddressEntity entity) + { + return new WalletAddressModel(entity.AddressType, entity.Index, entity.IsChange, entity.Address, + entity.UtxoQty); + } +} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Repositories/UnitOfWork.cs b/src/NLightning.Infrastructure.Repositories/UnitOfWork.cs index e3ba74ed..4f8a1e93 100644 --- a/src/NLightning.Infrastructure.Repositories/UnitOfWork.cs +++ b/src/NLightning.Infrastructure.Repositories/UnitOfWork.cs @@ -22,6 +22,7 @@ public class UnitOfWork : IUnitOfWork // Bitcoin repositories private BlockchainStateDbRepository? _blockchainStateDbRepository; private WatchedTransactionDbRepository? _watchedTransactionDbRepository; + private WalletAddressesDbRepository? _walletAddressesDbRepository; // Channel repositories private ChannelConfigDbRepository? _channelConfigDbRepository; @@ -38,6 +39,9 @@ public class UnitOfWork : IUnitOfWork public IWatchedTransactionDbRepository WatchedTransactionDbRepository => _watchedTransactionDbRepository ??= new WatchedTransactionDbRepository(_context); + public IWalletAddressesDbRepository WalletAddressesDbRepository => + _walletAddressesDbRepository ??= new WalletAddressesDbRepository(_context); + public IChannelConfigDbRepository ChannelConfigDbRepository => _channelConfigDbRepository ??= new ChannelConfigDbRepository(_context); diff --git a/src/NLightning.Transport.Ipc/NodeIpcCommand.cs b/src/NLightning.Transport.Ipc/NodeIpcCommand.cs index 13f371ab..74477b80 100644 --- a/src/NLightning.Transport.Ipc/NodeIpcCommand.cs +++ b/src/NLightning.Transport.Ipc/NodeIpcCommand.cs @@ -10,4 +10,5 @@ public enum NodeIpcCommand NodeInfo = 1, ConnectPeer = 2, ListPeers = 3, + GetAddress = 4 } \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/Requests/GetAddressIpcRequest.cs b/src/NLightning.Transport.Ipc/Requests/GetAddressIpcRequest.cs new file mode 100644 index 00000000..b5dd3fc7 --- /dev/null +++ b/src/NLightning.Transport.Ipc/Requests/GetAddressIpcRequest.cs @@ -0,0 +1,14 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc.Requests; + +using Domain.Bitcoin.Enums; + +/// +/// Request for Get Address command +/// +[MessagePackObject] +public class GetAddressIpcRequest +{ + [Key(0)] public AddressType AddressType { get; set; } = AddressType.P2Wpkh; +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/Responses/GetAddressIpcResponse.cs b/src/NLightning.Transport.Ipc/Responses/GetAddressIpcResponse.cs new file mode 100644 index 00000000..042df7a2 --- /dev/null +++ b/src/NLightning.Transport.Ipc/Responses/GetAddressIpcResponse.cs @@ -0,0 +1,13 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc.Responses; + +/// +/// Response for List Peers command +/// +[MessagePackObject] +public class GetAddressIpcResponse +{ + [Key(0)] public string? AddressP2Tr { get; set; } + [Key(1)] public string? AddressP2Wsh { get; set; } +} \ No newline at end of file diff --git a/test/NLightning.Infrastructure.Tests/Bitcoin/Wallet/BlockchainMonitorServiceTests.cs b/test/NLightning.Infrastructure.Tests/Bitcoin/Wallet/BlockchainMonitorServiceTests.cs index 488bdd64..1bba9078 100644 --- a/test/NLightning.Infrastructure.Tests/Bitcoin/Wallet/BlockchainMonitorServiceTests.cs +++ b/test/NLightning.Infrastructure.Tests/Bitcoin/Wallet/BlockchainMonitorServiceTests.cs @@ -17,7 +17,11 @@ namespace NLightning.Infrastructure.Tests.Bitcoin.Wallet; public class BlockchainMonitorServiceTests { - private readonly Mock _mockBitcoinWallet; + private readonly Mock> _mockBitcoinOptions; + private readonly Mock _mockBitcoinWallet; + private readonly Mock> _mockLogger; + private readonly Mock> _mockNodeOptions; + private readonly FakeServiceProvider _fakeServiceProvider; private readonly Mock _mockUnitOfWork; private readonly Mock _mockBlockchainStateRepository; private readonly Mock _mockWatchedTransactionRepository; @@ -26,10 +30,9 @@ public class BlockchainMonitorServiceTests public BlockchainMonitorServiceTests() { - var mockBitcoinOptions = - // Set up mock dependencies - new Mock>(); - mockBitcoinOptions.Setup(x => x.Value).Returns(new BitcoinOptions + // Set up mock dependencies + _mockBitcoinOptions = new Mock>(); + _mockBitcoinOptions.Setup(x => x.Value).Returns(new BitcoinOptions { RpcEndpoint = "", RpcUser = "", @@ -39,18 +42,18 @@ public BlockchainMonitorServiceTests() ZmqTxPort = 28333 }); - _mockBitcoinWallet = new Mock(); - var mockLogger = new Mock>(); + _mockBitcoinWallet = new Mock(); + _mockLogger = new Mock>(); - var mockNodeOptions = new Mock>(); - mockNodeOptions.Setup(x => x.Value).Returns(new Domain.Node.Options.NodeOptions + _mockNodeOptions = new Mock>(); + _mockNodeOptions.Setup(x => x.Value).Returns(new Domain.Node.Options.NodeOptions { BitcoinNetwork = "regtest" }); _mockUnitOfWork = new Mock(); - var fakeServiceProvider = new FakeServiceProvider(); - fakeServiceProvider.AddService(typeof(IUnitOfWork), _mockUnitOfWork.Object); + _fakeServiceProvider = new FakeServiceProvider(); + _fakeServiceProvider.AddService(typeof(IUnitOfWork), _mockUnitOfWork.Object); _mockBlockchainStateRepository = new Mock(); _mockWatchedTransactionRepository = new Mock(); @@ -60,11 +63,11 @@ public BlockchainMonitorServiceTests() // Create the service _service = new BlockchainMonitorService( - mockBitcoinOptions.Object, + _mockBitcoinOptions.Object, _mockBitcoinWallet.Object, - mockLogger.Object, - mockNodeOptions.Object, - fakeServiceProvider); + _mockLogger.Object, + _mockNodeOptions.Object, + _fakeServiceProvider); } [Fact] @@ -87,7 +90,7 @@ public async Task StartAsync_WithExistingBlockchainState_LoadsStateAndPendingTra .ReturnsAsync(110u); _mockBitcoinWallet.Setup(x => x.GetBlockAsync(It.IsAny())) - .ReturnsAsync(Consensus.Main.ConsensusFactory.CreateBlock()); + .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); // Act await _service.StartAsync(0, CancellationToken.None); @@ -114,7 +117,7 @@ public async Task StartAsync_WithNoBlockchainState_CreatesNewState() .ReturnsAsync(100u); _mockBitcoinWallet.Setup(x => x.GetBlockAsync(It.IsAny())) - .ReturnsAsync(Consensus.Main.ConsensusFactory.CreateBlock()); + .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); // Act await _service.StartAsync(0, CancellationToken.None); @@ -137,9 +140,9 @@ public async Task WatchTransactionAsync_AddsTransactionToDbAndInMemory() // Assert _mockWatchedTransactionRepository.Verify( x => x.Add( - It.Is(t => t.ChannelId.Equals(channelId) && - t.TransactionId.Equals(txId) && - t.RequiredDepth == requiredDepth)), + It.Is(t => t.ChannelId.Equals(channelId) + && t.TransactionId.Equals(txId) + && t.RequiredDepth == requiredDepth)), Times.Once); _mockUnitOfWork.Verify(x => x.SaveChangesAsync(), Times.Once); @@ -149,7 +152,7 @@ public async Task WatchTransactionAsync_AddsTransactionToDbAndInMemory() public async Task ProcessNewBlock_AddsMissingBlocksAndProcessesThem() { // Arrange - var currentBlockHeight = 110u; + const uint currentBlockHeight = 110u; var block = Consensus.Main.ConsensusFactory.CreateBlock(); // Setup to simulate blockchain state at height 100 @@ -334,7 +337,7 @@ public async Task StartAsync_WithHeightOfBirth_CreatesStateAtSpecifiedHeight() .ReturnsAsync(100u); _mockBitcoinWallet.Setup(x => x.GetBlockAsync(It.IsAny())) - .ReturnsAsync(Consensus.Main.ConsensusFactory.CreateBlock()); + .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); // Act await _service.StartAsync(heightOfBirth, CancellationToken.None); @@ -364,7 +367,7 @@ public async Task StartAsync_WithExistingStateAndHeightOfBirth_UsesExistingState .ReturnsAsync(110u); _mockBitcoinWallet.Setup(x => x.GetBlockAsync(It.IsAny())) - .ReturnsAsync(Consensus.Main.ConsensusFactory.CreateBlock()); + .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); // Act await _service.StartAsync(heightOfBirth, CancellationToken.None); @@ -397,7 +400,7 @@ public async Task StartAsync_WithHigherHeightOfBirth_ProcessesMissingBlocks() .ReturnsAsync(55u); // The current height is higher than the height of birth _mockBitcoinWallet.Setup(x => x.GetBlockAsync(It.IsAny())) - .ReturnsAsync(Consensus.Main.ConsensusFactory.CreateBlock()); + .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); // Act await _service.StartAsync(heightOfBirth, CancellationToken.None); @@ -433,7 +436,7 @@ public async Task StartAsync_WithHeightOfBirthZero_StartsFromGenesis() .ReturnsAsync(5u); _mockBitcoinWallet.Setup(x => x.GetBlockAsync(It.IsAny())) - .ReturnsAsync(Consensus.Main.ConsensusFactory.CreateBlock()); + .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); // Act await _service.StartAsync(heightOfBirth, CancellationToken.None); diff --git a/test/NLightning.Integration.Tests/Docker/Mock/FakeSecureKeyManager.cs b/test/NLightning.Integration.Tests/Docker/Mock/FakeSecureKeyManager.cs index d611f70c..6752a37c 100644 --- a/test/NLightning.Integration.Tests/Docker/Mock/FakeSecureKeyManager.cs +++ b/test/NLightning.Integration.Tests/Docker/Mock/FakeSecureKeyManager.cs @@ -9,27 +9,45 @@ namespace NLightning.Integration.Tests.Docker.Mock; public class FakeSecureKeyManager : ISecureKeyManager { private readonly ExtKey _nodeKey; + private readonly ExtKey _p2TrKey; + private readonly ExtKey _p2WpkhKey; public BitcoinKeyPath KeyPath => new BitcoinKeyPath([]); + + // ReSharper disable once UnassignedGetOnlyAutoProperty + public BitcoinKeyPath ChannelKeyPath { get; } + // ReSharper disable once UnassignedGetOnlyAutoProperty public uint HeightOfBirth { get; } public FakeSecureKeyManager() { _nodeKey = new ExtKey(new Key(), Network.RegTest.GenesisHash.ToBytes()); + _p2TrKey = new ExtKey(new Key(), Network.RegTest.GenesisHash.ToBytes()); + _p2WpkhKey = new ExtKey(new Key(), Network.RegTest.GenesisHash.ToBytes()); } - public ExtPrivKey GetNextKey(out uint index) + public ExtPrivKey GetNextChannelKey(out uint index) { index = 0; return _nodeKey.ToBytes(); } - public ExtPrivKey GetKeyAtIndex(uint index) + public ExtPrivKey GetChannelKeyAtIndex(uint index) { return _nodeKey.ToBytes(); } + public ExtPrivKey GetDepositP2TrKeyAtIndex(uint index, bool isChange) + { + return _p2TrKey.ToBytes(); + } + + public ExtPrivKey GetDepositP2WpkhKeyAtIndex(uint index, bool isChange) + { + return _p2WpkhKey.ToBytes(); + } + public CryptoKeyPair GetNodeKeyPair() { return new CryptoKeyPair(_nodeKey.PrivateKey.ToBytes(), _nodeKey.PrivateKey.PubKey.ToBytes()); From 90303fa088f5c6f6e87cdfb4d092517ba77d970b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADckolas=20Goline?= Date: Mon, 27 Oct 2025 17:01:33 -0300 Subject: [PATCH 07/20] add getwalletbalance method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Níckolas Goline --- .../Ipc/NamedPipeIpcClient.cs | 24 +++++++ .../Printers/ConnectPeerPrinter.cs | 12 ++-- .../Printers/ListPeersPrinter.cs | 10 +-- .../Printers/NodeInfoPrinter.cs | 10 +-- .../Printers/WalletBalancePrinter.cs | 15 +++++ src/NLightning.Client/Program.cs | 5 ++ .../Extensions/NodeServiceExtensions.cs | 1 + .../Handlers/GetWalletBalanceIpcHandler.cs | 63 +++++++++++++++++++ .../Bitcoin/Interfaces/IUtxoDbRepository.cs | 10 +++ .../Interfaces/IUtxoMemoryRepository.cs | 14 +++++ .../IWalletAddressesDbRepository.cs | 3 +- .../Bitcoin/Wallet/Models/UtxoModel.cs | 20 ++++++ .../Models/WalletAddressModel.cs | 4 +- src/NLightning.Domain/Money/LightningMoney.cs | 2 +- .../Persistence/Interfaces/IUnitOfWork.cs | 3 + .../Wallet/BitcoinWalletService.cs | 4 +- .../Wallet/BlockchainMonitorService.cs | 42 ++++++++++--- .../Wallet/Interfaces/IBlockchainMonitor.cs | 8 ++- ...erTypeWalletAddressesAndUtxos.Designer.cs} | 28 ++++++++- ...251_AddPeerTypeWalletAddressesAndUtxos.cs} | 19 +++++- .../NLightningDbContextModelSnapshot.cs | 24 +++++++ ...erTypeWalletAddressesAndUtxos.Designer.cs} | 23 ++++++- ...304_AddPeerTypeWalletAddressesAndUtxos.cs} | 19 +++++- .../NLightningDbContextModelSnapshot.cs | 19 ++++++ ...erTypeWalletAddressesAndUtxos.Designer.cs} | 23 ++++++- ...258_AddPeerTypeWalletAddressesAndUtxos.cs} | 19 +++++- .../NLightningDbContextModelSnapshot.cs | 19 ++++++ .../Contexts/NLightningDbContext.cs | 2 + .../Entities/Bitcoin/BlockchainStateEntity.cs | 4 +- .../Entities/Bitcoin/RevocationWatchEntity.cs | 4 +- .../Entities/Bitcoin/UtxoEntity.cs | 14 +++++ .../Entities/Bitcoin/WalletAddressEntity.cs | 3 + .../Bitcoin/WatchedTransactionEntity.cs | 4 +- .../Bitcoin/UtxoEntityConfiguration.cs | 39 ++++++++++++ .../Database/Bitcoin/UtxoDbRepository.cs | 49 +++++++++++++++ .../Bitcoin/WalletAddressesDbRepository.cs | 2 +- .../DependencyInjection.cs | 6 +- .../Memory/UtxoMemoryRepository.cs | 45 +++++++++++++ .../UnitOfWork.cs | 63 ++++++++++++++++++- .../Formatters/LightningMoneyFormatter.cs | 25 ++++++++ .../NLightningFormatterResolver.cs | 8 ++- .../NodeIpcCommand.cs | 3 +- .../Requests/WalletBalanceIpcRequest.cs | 9 +++ .../Responses/WalletBalanceIpcResponse.cs | 15 +++++ 44 files changed, 678 insertions(+), 60 deletions(-) create mode 100644 src/NLightning.Client/Printers/WalletBalancePrinter.cs create mode 100644 src/NLightning.Daemon/Handlers/GetWalletBalanceIpcHandler.cs create mode 100644 src/NLightning.Domain/Bitcoin/Interfaces/IUtxoDbRepository.cs create mode 100644 src/NLightning.Domain/Bitcoin/Interfaces/IUtxoMemoryRepository.cs create mode 100644 src/NLightning.Domain/Bitcoin/Wallet/Models/UtxoModel.cs rename src/NLightning.Domain/Bitcoin/{Addresses => Wallet}/Models/WalletAddressModel.cs (87%) rename src/NLightning.Infrastructure.Persistence.Postgres/Migrations/{20251027154736_AddPeerTypeAndWalletAddresses.Designer.cs => 20251027190251_AddPeerTypeWalletAddressesAndUtxos.Designer.cs} (95%) rename src/NLightning.Infrastructure.Persistence.Postgres/Migrations/{20251027154736_AddPeerTypeAndWalletAddresses.cs => 20251027190251_AddPeerTypeWalletAddressesAndUtxos.cs} (66%) rename src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/{20251027154749_AddPeerTypeAndWalletAddresses.Designer.cs => 20251027190304_AddPeerTypeWalletAddressesAndUtxos.Designer.cs} (95%) rename src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/{20251027154749_AddPeerTypeAndWalletAddresses.cs => 20251027190304_AddPeerTypeWalletAddressesAndUtxos.cs} (66%) rename src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/{20251027154742_AddPeerTypeAndWalletAddresses.Designer.cs => 20251027190258_AddPeerTypeWalletAddressesAndUtxos.Designer.cs} (94%) rename src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/{20251027154742_AddPeerTypeAndWalletAddresses.cs => 20251027190258_AddPeerTypeWalletAddressesAndUtxos.cs} (66%) create mode 100644 src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/UtxoEntity.cs create mode 100644 src/NLightning.Infrastructure.Persistence/EntityConfiguration/Bitcoin/UtxoEntityConfiguration.cs create mode 100644 src/NLightning.Infrastructure.Repositories/Database/Bitcoin/UtxoDbRepository.cs create mode 100644 src/NLightning.Infrastructure.Repositories/Memory/UtxoMemoryRepository.cs create mode 100644 src/NLightning.Transport.Ipc/MessagePack/Formatters/LightningMoneyFormatter.cs create mode 100644 src/NLightning.Transport.Ipc/Requests/WalletBalanceIpcRequest.cs create mode 100644 src/NLightning.Transport.Ipc/Responses/WalletBalanceIpcResponse.cs diff --git a/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs b/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs index 09ad368c..54d65f05 100644 --- a/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs +++ b/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs @@ -135,6 +135,30 @@ public async Task GetAddressAsync(string? addressTypeStri throw new InvalidOperationException($"IPC error {err.Code}: {err.Message}"); } + public async Task GetWalletBalance(CancellationToken ct) + { + var req = new WalletBalanceIpcRequest(); + var payload = MessagePackSerializer.Serialize(req, cancellationToken: ct); + var env = new IpcEnvelope + { + Version = 1, + Command = NodeIpcCommand.WalletBalance, + CorrelationId = Guid.NewGuid(), + AuthToken = await GetAuthTokenAsync(ct), + Payload = payload, + Kind = 0 + }; + + var respEnv = await SendAsync(env, ct); + if (respEnv.Kind != IpcEnvelopeKind.Error) + { + return MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); + } + + var err = MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); + throw new InvalidOperationException($"IPC error {err.Code}: {err.Message}"); + } + private async Task SendAsync(IpcEnvelope envelope, CancellationToken ct) { await using var client = diff --git a/src/NLightning.Client/Printers/ConnectPeerPrinter.cs b/src/NLightning.Client/Printers/ConnectPeerPrinter.cs index bf273039..5fb239b0 100644 --- a/src/NLightning.Client/Printers/ConnectPeerPrinter.cs +++ b/src/NLightning.Client/Printers/ConnectPeerPrinter.cs @@ -7,11 +7,11 @@ public sealed class ConnectPeerPrinter : IPrinter public void Print(ConnectPeerIpcResponse item) { Console.WriteLine("Connected to Peer:"); - Console.WriteLine($" Id: {item.Id}"); - Console.WriteLine($" Features: {item.Features}"); - Console.WriteLine($" Is Initiator: {(item.IsInitiator ? "Yes" : "No")}"); - Console.WriteLine($" Address: {item.Address}"); - Console.WriteLine($" Type: {item.Type}"); - Console.WriteLine($" Port: {item.Port}"); + Console.WriteLine(" Id: {0}", item.Id); + Console.WriteLine(" Features: {0}", item.Features); + Console.WriteLine(" Is Initiator: {0}", item.IsInitiator ? "Yes" : "No"); + Console.WriteLine(" Address: {0}", item.Address); + Console.WriteLine(" Type: {0}", item.Type); + Console.WriteLine(" Port: {0}", item.Port); } } \ No newline at end of file diff --git a/src/NLightning.Client/Printers/ListPeersPrinter.cs b/src/NLightning.Client/Printers/ListPeersPrinter.cs index 65b081b7..447bec78 100644 --- a/src/NLightning.Client/Printers/ListPeersPrinter.cs +++ b/src/NLightning.Client/Printers/ListPeersPrinter.cs @@ -15,11 +15,11 @@ public void Print(ListPeersIpcResponse item) foreach (var peer in item.Peers) { - Console.WriteLine($" Id: {peer.Id}"); - Console.WriteLine($" Connected: {(peer.Connected ? "Yes" : "No")}"); - Console.WriteLine($" Channel Qty: {peer.ChannelQty}"); - Console.WriteLine($" Address: {peer.Address}"); - Console.WriteLine($" Features: {peer.Features}"); + Console.WriteLine(" Id: {0}", peer.Id); + Console.WriteLine(" Connected: {0}", peer.Connected ? "Yes" : "No"); + Console.WriteLine(" Channel Qty: {0}", peer.ChannelQty); + Console.WriteLine(" Address: {0}", peer.Address); + Console.WriteLine(" Features: {0}", peer.Features); Console.WriteLine("----------------------------------------------------------------------------------"); } } diff --git a/src/NLightning.Client/Printers/NodeInfoPrinter.cs b/src/NLightning.Client/Printers/NodeInfoPrinter.cs index c6b8946e..9151d4f7 100644 --- a/src/NLightning.Client/Printers/NodeInfoPrinter.cs +++ b/src/NLightning.Client/Printers/NodeInfoPrinter.cs @@ -7,12 +7,12 @@ public sealed class NodeInfoPrinter : IPrinter public void Print(NodeInfoIpcResponse item) { Console.WriteLine("Node Information:"); - Console.WriteLine($" Network: {item.Network}"); - Console.WriteLine($" Best Block Height: {item.BestBlockHeight}"); - Console.WriteLine($" Best Block Hash: {item.BestBlockHash}"); + Console.WriteLine(" Network: {0}", item.Network); + Console.WriteLine(" Best Block Height: {0}", item.BestBlockHeight); + Console.WriteLine(" Best Block Hash: {0}", item.BestBlockHash); if (item.BestBlockTime is not null) Console.WriteLine($" Best Block Time: {item.BestBlockTime:O}"); - Console.WriteLine($" Implementation: {item.Implementation}"); - Console.WriteLine($" Version: {item.Version}"); + Console.WriteLine(" Implementation: {0}", item.Implementation); + Console.WriteLine(" Version: {0}", item.Version); } } \ No newline at end of file diff --git a/src/NLightning.Client/Printers/WalletBalancePrinter.cs b/src/NLightning.Client/Printers/WalletBalancePrinter.cs new file mode 100644 index 00000000..2bb9267c --- /dev/null +++ b/src/NLightning.Client/Printers/WalletBalancePrinter.cs @@ -0,0 +1,15 @@ +using NLightning.Transport.Ipc.Responses; + +namespace NLightning.Client.Printers; + +public sealed class WalletBalancePrinter : IPrinter +{ + public void Print(WalletBalanceIpcResponse item) + { + Console.WriteLine("Balances:"); + Console.WriteLine(" Confirmed: {0} sats", item.ConfirmedBalance.Satoshi); + Console.WriteLine(" {0} Bitcoin", item.ConfirmedBalance); + Console.WriteLine(" Unconfirmed: {0} sats", item.UnconfirmedBalance.Satoshi); + Console.WriteLine(" {0} Bitcoin", item.UnconfirmedBalance); + } +} \ No newline at end of file diff --git a/src/NLightning.Client/Program.cs b/src/NLightning.Client/Program.cs index a521a64e..505e7fc0 100644 --- a/src/NLightning.Client/Program.cs +++ b/src/NLightning.Client/Program.cs @@ -59,6 +59,11 @@ var addresses = await client.GetAddressAsync(commandArgs[0], cts.Token); new GetAddressPrinter().Print(addresses); break; + case "walletbalance": + case "wallet-balance": + var balance = await client.GetWalletBalance(cts.Token); + new WalletBalancePrinter().Print(balance); + break; default: Console.Error.WriteLine($"Unknown command: {cmd}"); ClientUtils.ShowUsage(); diff --git a/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs b/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs index e7c0f779..f89581cb 100644 --- a/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs +++ b/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs @@ -58,6 +58,7 @@ public static IHostBuilder ConfigureNltgServices(this IHostBuilder hostBuilder, services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => { var nodeOptions = sp.GetRequiredService>().Value; diff --git a/src/NLightning.Daemon/Handlers/GetWalletBalanceIpcHandler.cs b/src/NLightning.Daemon/Handlers/GetWalletBalanceIpcHandler.cs new file mode 100644 index 00000000..233ebd41 --- /dev/null +++ b/src/NLightning.Daemon/Handlers/GetWalletBalanceIpcHandler.cs @@ -0,0 +1,63 @@ +using MessagePack; +using Microsoft.Extensions.Logging; + +namespace NLightning.Daemon.Handlers; + +using Domain.Bitcoin.Interfaces; +using Infrastructure.Bitcoin.Wallet.Interfaces; +using Interfaces; +using Services.Ipc.Factories; +using Transport.Ipc; +using Transport.Ipc.Constants; +using Transport.Ipc.Responses; + +public class GetWalletBalanceIpcHandler : IIpcCommandHandler +{ + private readonly IBlockchainMonitor _blockchainMonitor; + private readonly ILogger _logger; + private readonly IUtxoMemoryRepository _utxoMemoryRepository; + public NodeIpcCommand Command => NodeIpcCommand.WalletBalance; + + public GetWalletBalanceIpcHandler(IBlockchainMonitor blockchainMonitor, ILogger logger, + IUtxoMemoryRepository utxoMemoryRepository) + { + _blockchainMonitor = blockchainMonitor; + _logger = logger; + _utxoMemoryRepository = utxoMemoryRepository; + } + + public Task HandleAsync(IpcEnvelope envelope, CancellationToken ct) + { + try + { + var currentBlockHeight = _blockchainMonitor.LastProcessedBlockHeight; + var confirmedBalance = _utxoMemoryRepository.GetConfirmedBalance(currentBlockHeight); + var unconfirmedBalance = _utxoMemoryRepository.GetUnconfirmedBalance(currentBlockHeight); + + // Create a success response + var response = new WalletBalanceIpcResponse + { + ConfirmedBalance = confirmedBalance, + UnconfirmedBalance = unconfirmedBalance + }; + + var payload = MessagePackSerializer.Serialize(response, cancellationToken: ct); + var respEnvelope = new IpcEnvelope + { + Version = envelope.Version, + Command = envelope.Command, + CorrelationId = envelope.CorrelationId, + Kind = IpcEnvelopeKind.Response, + Payload = payload + }; + + return Task.FromResult(respEnvelope); + } + catch (Exception e) + { + _logger.LogError(e, "Error getting a unused address"); + return Task.FromResult(IpcErrorFactory.CreateErrorEnvelope(envelope, ErrorCodes.ServerError, + $"Error getting a unused address: {e.Message}")); + } + } +} \ No newline at end of file diff --git a/src/NLightning.Domain/Bitcoin/Interfaces/IUtxoDbRepository.cs b/src/NLightning.Domain/Bitcoin/Interfaces/IUtxoDbRepository.cs new file mode 100644 index 00000000..04968ecc --- /dev/null +++ b/src/NLightning.Domain/Bitcoin/Interfaces/IUtxoDbRepository.cs @@ -0,0 +1,10 @@ +namespace NLightning.Domain.Bitcoin.Interfaces; + +using Wallet.Models; + +public interface IUtxoDbRepository +{ + void Add(UtxoModel utxoModel); + void Spend(UtxoModel utxoModel); + Task> GetAllAsync(); +} \ No newline at end of file diff --git a/src/NLightning.Domain/Bitcoin/Interfaces/IUtxoMemoryRepository.cs b/src/NLightning.Domain/Bitcoin/Interfaces/IUtxoMemoryRepository.cs new file mode 100644 index 00000000..a21aae36 --- /dev/null +++ b/src/NLightning.Domain/Bitcoin/Interfaces/IUtxoMemoryRepository.cs @@ -0,0 +1,14 @@ +using NLightning.Domain.Money; + +namespace NLightning.Domain.Bitcoin.Interfaces; + +using Wallet.Models; + +public interface IUtxoMemoryRepository +{ + void Add(UtxoModel utxoModel); + void Spend(UtxoModel utxoModel); + LightningMoney GetConfirmedBalance(uint currentBlockHeight); + LightningMoney GetUnconfirmedBalance(uint currentBlockHeight); + void Load(List utxoSet); +} \ No newline at end of file diff --git a/src/NLightning.Domain/Bitcoin/Interfaces/IWalletAddressesDbRepository.cs b/src/NLightning.Domain/Bitcoin/Interfaces/IWalletAddressesDbRepository.cs index ef0bb73d..e90e7285 100644 --- a/src/NLightning.Domain/Bitcoin/Interfaces/IWalletAddressesDbRepository.cs +++ b/src/NLightning.Domain/Bitcoin/Interfaces/IWalletAddressesDbRepository.cs @@ -1,6 +1,7 @@ +using NLightning.Domain.Bitcoin.Wallet.Models; + namespace NLightning.Domain.Bitcoin.Interfaces; -using Addresses.Models; using Enums; public interface IWalletAddressesDbRepository diff --git a/src/NLightning.Domain/Bitcoin/Wallet/Models/UtxoModel.cs b/src/NLightning.Domain/Bitcoin/Wallet/Models/UtxoModel.cs new file mode 100644 index 00000000..aebf232e --- /dev/null +++ b/src/NLightning.Domain/Bitcoin/Wallet/Models/UtxoModel.cs @@ -0,0 +1,20 @@ +namespace NLightning.Domain.Bitcoin.Wallet.Models; + +using Money; +using ValueObjects; + +public sealed class UtxoModel +{ + public TxId TxId { get; } + public uint Index { get; } + public LightningMoney Amount { get; } + public uint BlockHeight { get; } + + public UtxoModel(TxId txId, uint index, LightningMoney amount, uint blockHeight) + { + TxId = txId; + Index = index; + Amount = amount; + BlockHeight = blockHeight; + } +} \ No newline at end of file diff --git a/src/NLightning.Domain/Bitcoin/Addresses/Models/WalletAddressModel.cs b/src/NLightning.Domain/Bitcoin/Wallet/Models/WalletAddressModel.cs similarity index 87% rename from src/NLightning.Domain/Bitcoin/Addresses/Models/WalletAddressModel.cs rename to src/NLightning.Domain/Bitcoin/Wallet/Models/WalletAddressModel.cs index cf1c62e5..fec14e07 100644 --- a/src/NLightning.Domain/Bitcoin/Addresses/Models/WalletAddressModel.cs +++ b/src/NLightning.Domain/Bitcoin/Wallet/Models/WalletAddressModel.cs @@ -1,6 +1,6 @@ -namespace NLightning.Domain.Bitcoin.Addresses.Models; +using NLightning.Domain.Bitcoin.Enums; -using Enums; +namespace NLightning.Domain.Bitcoin.Wallet.Models; public sealed class WalletAddressModel { diff --git a/src/NLightning.Domain/Money/LightningMoney.cs b/src/NLightning.Domain/Money/LightningMoney.cs index 81531516..22764308 100644 --- a/src/NLightning.Domain/Money/LightningMoney.cs +++ b/src/NLightning.Domain/Money/LightningMoney.cs @@ -439,7 +439,7 @@ public override int GetHashCode() /// public override string ToString() { - return ToString(false); + return ToString(true); } /// diff --git a/src/NLightning.Domain/Persistence/Interfaces/IUnitOfWork.cs b/src/NLightning.Domain/Persistence/Interfaces/IUnitOfWork.cs index 9637044e..3799c08e 100644 --- a/src/NLightning.Domain/Persistence/Interfaces/IUnitOfWork.cs +++ b/src/NLightning.Domain/Persistence/Interfaces/IUnitOfWork.cs @@ -1,3 +1,4 @@ +using NLightning.Domain.Bitcoin.Wallet.Models; using NLightning.Domain.Node.Models; namespace NLightning.Domain.Persistence.Interfaces; @@ -12,6 +13,7 @@ public interface IUnitOfWork : IDisposable IBlockchainStateDbRepository BlockchainStateDbRepository { get; } IWatchedTransactionDbRepository WatchedTransactionDbRepository { get; } IWalletAddressesDbRepository WalletAddressesDbRepository { get; } + IUtxoDbRepository UtxoDbRepository { get; } // Chanel repositories IChannelConfigDbRepository ChannelConfigDbRepository { get; } @@ -23,6 +25,7 @@ public interface IUnitOfWork : IDisposable IPeerDbRepository PeerDbRepository { get; } Task> GetPeersForStartupAsync(); + void AddUtxo(UtxoModel utxoModel); void SaveChanges(); Task SaveChangesAsync(); diff --git a/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinWalletService.cs b/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinWalletService.cs index a7e471ed..dc6d7962 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinWalletService.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinWalletService.cs @@ -1,13 +1,13 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NBitcoin; -using NLightning.Domain.Node.Options; namespace NLightning.Infrastructure.Bitcoin.Wallet; -using Domain.Bitcoin.Addresses.Models; using Domain.Bitcoin.Enums; using Domain.Bitcoin.ValueObjects; +using Domain.Bitcoin.Wallet.Models; +using Domain.Node.Options; using Domain.Persistence.Interfaces; using Domain.Protocol.Interfaces; using Interfaces; diff --git a/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs b/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs index 0c7f3409..bc6fb367 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs @@ -8,12 +8,14 @@ namespace NLightning.Infrastructure.Bitcoin.Wallet; -using Domain.Bitcoin.Addresses.Models; using Domain.Bitcoin.Events; +using Domain.Bitcoin.Interfaces; using Domain.Bitcoin.Transactions.Models; using Domain.Bitcoin.ValueObjects; +using Domain.Bitcoin.Wallet.Models; using Domain.Channels.ValueObjects; using Domain.Crypto.ValueObjects; +using Domain.Money; using Domain.Node.Options; using Domain.Persistence.Interfaces; using Interfaces; @@ -68,6 +70,9 @@ public async Task StartAsync(uint heightOfBirth, CancellationToken cancellationT // Load existing addresses LoadBitcoinAddresses(uow); + // Load UtxoSet + await LoadUtxoSetAsync(uow); + // Get the current state or create a new one if it doesn't exist var currentBlockchainState = await uow.BlockchainStateDbRepository.GetStateAsync(); if (currentBlockchainState is null) @@ -89,14 +94,17 @@ public async Task StartAsync(uint heightOfBirth, CancellationToken cancellationT // Get the current block height from the wallet var currentBlockHeight = await _bitcoinChainService.GetCurrentBlockHeightAsync(); - // Add the current block to the processing queue - var currentBlock = await _bitcoinChainService.GetBlockAsync(_lastProcessedBlockHeight); - if (currentBlock is not null) - _blocksToProcess[_lastProcessedBlockHeight] = currentBlock; + if (currentBlockHeight > _lastProcessedBlockHeight) + { + // Add the current block to the processing queue + var currentBlock = await _bitcoinChainService.GetBlockAsync(_lastProcessedBlockHeight); + if (currentBlock is not null) + _blocksToProcess[_lastProcessedBlockHeight] = currentBlock; - // Add missing blocks to the processing queue and process any pending blocks - await AddMissingBlocksToProcessAsync(currentBlockHeight); - await ProcessPendingBlocksAsync(uow); + // Add missing blocks to the processing queue and process any pending blocks + await AddMissingBlocksToProcessAsync(currentBlockHeight); + await ProcessPendingBlocksAsync(uow); + } await uow.SaveChangesAsync(); @@ -323,7 +331,7 @@ private async Task AddMissingBlocksToProcessAsync(uint currentHeight) if (_blocksToProcess.ContainsKey(height)) continue; - // Add missing block to process queue + // Add the missing block to the process queue var blockAtHeight = await _bitcoinChainService.GetBlockAsync(height); if (blockAtHeight is not null) { @@ -495,6 +503,9 @@ private void CheckBlockForDeposits(List transactions, uint blockHei uow.WalletAddressesDbRepository.UpdateAsync(watchedAddress); // Save Utxo to the database + var utxo = new UtxoModel(txId.ToBytes(), (uint)i, LightningMoney.Satoshis(output.Value.Satoshi), + blockHeight); + uow.AddUtxo(utxo); if (!_watchedAddresses.TryRemove(destinationAddress.ToString(), out _)) _logger.LogError("Unable to remove watched address {DestinationAddress} from the list", @@ -541,4 +552,17 @@ private void LoadBitcoinAddresses(IUnitOfWork uow) _watchedAddresses[bitcoinAddress.Address] = bitcoinAddress; } } + + private async Task LoadUtxoSetAsync(IUnitOfWork uow) + { + _logger.LogInformation("Loading Utxo set"); + + var utxoSet = (await uow.UtxoDbRepository.GetAllAsync()).ToList(); + if (utxoSet.Count > 0) + { + var utxoMemoryRepository = _serviceProvider.GetService() + ?? throw new InvalidOperationException("UtxoMemoryRepository not found"); + utxoMemoryRepository.Load(utxoSet); + } + } } \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBlockchainMonitor.cs b/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBlockchainMonitor.cs index 15c5e8c9..9f613dfd 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBlockchainMonitor.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBlockchainMonitor.cs @@ -1,17 +1,19 @@ namespace NLightning.Infrastructure.Bitcoin.Wallet.Interfaces; -using Domain.Bitcoin.Addresses.Models; using Domain.Bitcoin.Events; using Domain.Bitcoin.ValueObjects; +using Domain.Bitcoin.Wallet.Models; using Domain.Channels.ValueObjects; public interface IBlockchainMonitor { - Task WatchTransactionAsync(ChannelId channelId, TxId txId, uint requiredDepth); - void WatchBitcoinAddress(WalletAddressModel walletAddress); + uint LastProcessedBlockHeight { get; } event EventHandler OnNewBlockDetected; event EventHandler OnTransactionConfirmed; + Task WatchTransactionAsync(ChannelId channelId, TxId txId, uint requiredDepth); + void WatchBitcoinAddress(WalletAddressModel walletAddress); + /// /// Starts a background task to periodically refresh the fee rate /// diff --git a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027154736_AddPeerTypeAndWalletAddresses.Designer.cs b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027190251_AddPeerTypeWalletAddressesAndUtxos.Designer.cs similarity index 95% rename from src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027154736_AddPeerTypeAndWalletAddresses.Designer.cs rename to src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027190251_AddPeerTypeWalletAddressesAndUtxos.Designer.cs index 3ceb35c1..3396507a 100644 --- a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027154736_AddPeerTypeAndWalletAddresses.Designer.cs +++ b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027190251_AddPeerTypeWalletAddressesAndUtxos.Designer.cs @@ -12,8 +12,8 @@ namespace NLightning.Infrastructure.Persistence.Postgres.Migrations { [DbContext(typeof(NLightningDbContext))] - [Migration("20251027154736_AddPeerTypeAndWalletAddresses")] - partial class AddPeerTypeAndWalletAddresses + [Migration("20251027190251_AddPeerTypeWalletAddressesAndUtxos")] + partial class AddPeerTypeWalletAddressesAndUtxos { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -51,6 +51,30 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("blockchain_states", (string)null); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.UtxoEntity", b => + { + b.Property("TransactionId") + .HasColumnType("bytea") + .HasColumnName("transaction_id"); + + b.Property("Index") + .HasColumnType("bigint") + .HasColumnName("index"); + + b.Property("AmountSats") + .HasColumnType("bigint") + .HasColumnName("amount_sats"); + + b.Property("BlockHeight") + .HasColumnType("bigint") + .HasColumnName("block_height"); + + b.HasKey("TransactionId", "Index") + .HasName("pk_utxos"); + + b.ToTable("utxos", (string)null); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", b => { b.Property("Index") diff --git a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027154736_AddPeerTypeAndWalletAddresses.cs b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027190251_AddPeerTypeWalletAddressesAndUtxos.cs similarity index 66% rename from src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027154736_AddPeerTypeAndWalletAddresses.cs rename to src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027190251_AddPeerTypeWalletAddressesAndUtxos.cs index a1320fd0..1b356f15 100644 --- a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027154736_AddPeerTypeAndWalletAddresses.cs +++ b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027190251_AddPeerTypeWalletAddressesAndUtxos.cs @@ -5,7 +5,7 @@ namespace NLightning.Infrastructure.Persistence.Postgres.Migrations { /// - public partial class AddPeerTypeAndWalletAddresses : Migration + public partial class AddPeerTypeWalletAddressesAndUtxos : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -17,6 +17,20 @@ protected override void Up(MigrationBuilder migrationBuilder) nullable: false, defaultValue: ""); + migrationBuilder.CreateTable( + name: "utxos", + columns: table => new + { + transaction_id = table.Column(type: "bytea", nullable: false), + index = table.Column(type: "bigint", nullable: false), + amount_sats = table.Column(type: "bigint", nullable: false), + block_height = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_utxos", x => new { x.transaction_id, x.index }); + }); + migrationBuilder.CreateTable( name: "wallet_addresses", columns: table => new @@ -36,6 +50,9 @@ protected override void Up(MigrationBuilder migrationBuilder) /// protected override void Down(MigrationBuilder migrationBuilder) { + migrationBuilder.DropTable( + name: "utxos"); + migrationBuilder.DropTable( name: "wallet_addresses"); diff --git a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/NLightningDbContextModelSnapshot.cs b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/NLightningDbContextModelSnapshot.cs index 9c273415..2ed9a129 100644 --- a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/NLightningDbContextModelSnapshot.cs +++ b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/NLightningDbContextModelSnapshot.cs @@ -48,6 +48,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("blockchain_states", (string)null); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.UtxoEntity", b => + { + b.Property("TransactionId") + .HasColumnType("bytea") + .HasColumnName("transaction_id"); + + b.Property("Index") + .HasColumnType("bigint") + .HasColumnName("index"); + + b.Property("AmountSats") + .HasColumnType("bigint") + .HasColumnName("amount_sats"); + + b.Property("BlockHeight") + .HasColumnType("bigint") + .HasColumnName("block_height"); + + b.HasKey("TransactionId", "Index") + .HasName("pk_utxos"); + + b.ToTable("utxos", (string)null); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", b => { b.Property("Index") diff --git a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027154749_AddPeerTypeAndWalletAddresses.Designer.cs b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027190304_AddPeerTypeWalletAddressesAndUtxos.Designer.cs similarity index 95% rename from src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027154749_AddPeerTypeAndWalletAddresses.Designer.cs rename to src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027190304_AddPeerTypeWalletAddressesAndUtxos.Designer.cs index ab98575c..2d60d47e 100644 --- a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027154749_AddPeerTypeAndWalletAddresses.Designer.cs +++ b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027190304_AddPeerTypeWalletAddressesAndUtxos.Designer.cs @@ -12,8 +12,8 @@ namespace NLightning.Infrastructure.Persistence.SqlServer.Migrations { [DbContext(typeof(NLightningDbContext))] - [Migration("20251027154749_AddPeerTypeAndWalletAddresses")] - partial class AddPeerTypeAndWalletAddresses + [Migration("20251027190304_AddPeerTypeWalletAddressesAndUtxos")] + partial class AddPeerTypeWalletAddressesAndUtxos { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -46,6 +46,25 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("BlockchainStates"); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.UtxoEntity", b => + { + b.Property("TransactionId") + .HasColumnType("varbinary(32)"); + + b.Property("Index") + .HasColumnType("bigint"); + + b.Property("AmountSats") + .HasColumnType("bigint"); + + b.Property("BlockHeight") + .HasColumnType("bigint"); + + b.HasKey("TransactionId", "Index"); + + b.ToTable("Utxos"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", b => { b.Property("Index") diff --git a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027154749_AddPeerTypeAndWalletAddresses.cs b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027190304_AddPeerTypeWalletAddressesAndUtxos.cs similarity index 66% rename from src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027154749_AddPeerTypeAndWalletAddresses.cs rename to src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027190304_AddPeerTypeWalletAddressesAndUtxos.cs index 39712229..7720a73f 100644 --- a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027154749_AddPeerTypeAndWalletAddresses.cs +++ b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027190304_AddPeerTypeWalletAddressesAndUtxos.cs @@ -5,7 +5,7 @@ namespace NLightning.Infrastructure.Persistence.SqlServer.Migrations { /// - public partial class AddPeerTypeAndWalletAddresses : Migration + public partial class AddPeerTypeWalletAddressesAndUtxos : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -17,6 +17,20 @@ protected override void Up(MigrationBuilder migrationBuilder) nullable: false, defaultValue: ""); + migrationBuilder.CreateTable( + name: "Utxos", + columns: table => new + { + TransactionId = table.Column(type: "varbinary(32)", nullable: false), + Index = table.Column(type: "bigint", nullable: false), + AmountSats = table.Column(type: "bigint", nullable: false), + BlockHeight = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Utxos", x => new { x.TransactionId, x.Index }); + }); + migrationBuilder.CreateTable( name: "WalletAddresses", columns: table => new @@ -36,6 +50,9 @@ protected override void Up(MigrationBuilder migrationBuilder) /// protected override void Down(MigrationBuilder migrationBuilder) { + migrationBuilder.DropTable( + name: "Utxos"); + migrationBuilder.DropTable( name: "WalletAddresses"); diff --git a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/NLightningDbContextModelSnapshot.cs b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/NLightningDbContextModelSnapshot.cs index 3a8e6359..c19d5773 100644 --- a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/NLightningDbContextModelSnapshot.cs +++ b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/NLightningDbContextModelSnapshot.cs @@ -43,6 +43,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("BlockchainStates"); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.UtxoEntity", b => + { + b.Property("TransactionId") + .HasColumnType("varbinary(32)"); + + b.Property("Index") + .HasColumnType("bigint"); + + b.Property("AmountSats") + .HasColumnType("bigint"); + + b.Property("BlockHeight") + .HasColumnType("bigint"); + + b.HasKey("TransactionId", "Index"); + + b.ToTable("Utxos"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", b => { b.Property("Index") diff --git a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027154742_AddPeerTypeAndWalletAddresses.Designer.cs b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027190258_AddPeerTypeWalletAddressesAndUtxos.Designer.cs similarity index 94% rename from src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027154742_AddPeerTypeAndWalletAddresses.Designer.cs rename to src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027190258_AddPeerTypeWalletAddressesAndUtxos.Designer.cs index 0f8dc1a8..580472c4 100644 --- a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027154742_AddPeerTypeAndWalletAddresses.Designer.cs +++ b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027190258_AddPeerTypeWalletAddressesAndUtxos.Designer.cs @@ -11,8 +11,8 @@ namespace NLightning.Infrastructure.Persistence.Sqlite.Migrations { [DbContext(typeof(NLightningDbContext))] - [Migration("20251027154742_AddPeerTypeAndWalletAddresses")] - partial class AddPeerTypeAndWalletAddresses + [Migration("20251027190258_AddPeerTypeWalletAddressesAndUtxos")] + partial class AddPeerTypeWalletAddressesAndUtxos { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -41,6 +41,25 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("BlockchainStates"); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.UtxoEntity", b => + { + b.Property("TransactionId") + .HasColumnType("BLOB"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("AmountSats") + .HasColumnType("INTEGER"); + + b.Property("BlockHeight") + .HasColumnType("INTEGER"); + + b.HasKey("TransactionId", "Index"); + + b.ToTable("Utxos"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", b => { b.Property("Index") diff --git a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027154742_AddPeerTypeAndWalletAddresses.cs b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027190258_AddPeerTypeWalletAddressesAndUtxos.cs similarity index 66% rename from src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027154742_AddPeerTypeAndWalletAddresses.cs rename to src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027190258_AddPeerTypeWalletAddressesAndUtxos.cs index 6a5131ed..8f68d4cb 100644 --- a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027154742_AddPeerTypeAndWalletAddresses.cs +++ b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027190258_AddPeerTypeWalletAddressesAndUtxos.cs @@ -5,7 +5,7 @@ namespace NLightning.Infrastructure.Persistence.Sqlite.Migrations { /// - public partial class AddPeerTypeAndWalletAddresses : Migration + public partial class AddPeerTypeWalletAddressesAndUtxos : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -17,6 +17,20 @@ protected override void Up(MigrationBuilder migrationBuilder) nullable: false, defaultValue: ""); + migrationBuilder.CreateTable( + name: "Utxos", + columns: table => new + { + TransactionId = table.Column(type: "BLOB", nullable: false), + Index = table.Column(type: "INTEGER", nullable: false), + AmountSats = table.Column(type: "INTEGER", nullable: false), + BlockHeight = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Utxos", x => new { x.TransactionId, x.Index }); + }); + migrationBuilder.CreateTable( name: "WalletAddresses", columns: table => new @@ -36,6 +50,9 @@ protected override void Up(MigrationBuilder migrationBuilder) /// protected override void Down(MigrationBuilder migrationBuilder) { + migrationBuilder.DropTable( + name: "Utxos"); + migrationBuilder.DropTable( name: "WalletAddresses"); diff --git a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/NLightningDbContextModelSnapshot.cs b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/NLightningDbContextModelSnapshot.cs index dd26be76..952cc27f 100644 --- a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/NLightningDbContextModelSnapshot.cs +++ b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/NLightningDbContextModelSnapshot.cs @@ -38,6 +38,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("BlockchainStates"); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.UtxoEntity", b => + { + b.Property("TransactionId") + .HasColumnType("BLOB"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("AmountSats") + .HasColumnType("INTEGER"); + + b.Property("BlockHeight") + .HasColumnType("INTEGER"); + + b.HasKey("TransactionId", "Index"); + + b.ToTable("Utxos"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", b => { b.Property("Index") diff --git a/src/NLightning.Infrastructure.Persistence/Contexts/NLightningDbContext.cs b/src/NLightning.Infrastructure.Persistence/Contexts/NLightningDbContext.cs index 0dd73043..255d5b32 100644 --- a/src/NLightning.Infrastructure.Persistence/Contexts/NLightningDbContext.cs +++ b/src/NLightning.Infrastructure.Persistence/Contexts/NLightningDbContext.cs @@ -25,6 +25,7 @@ public NLightningDbContext(DbContextOptions options, Databa public DbSet BlockchainStates { get; set; } public DbSet WatchedTransactions { get; set; } public DbSet WalletAddresses { get; set; } + public DbSet Utxos { get; set; } // Channel DbSets public DbSet Channels { get; set; } @@ -43,6 +44,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ConfigureBlockchainStateEntity(_databaseType); modelBuilder.ConfigureWatchedTransactionEntity(_databaseType); modelBuilder.ConfigureWalletAddressEntity(_databaseType); + modelBuilder.ConfigureUtxoEntity(_databaseType); // Channel entities modelBuilder.ConfigureChannelEntity(_databaseType); diff --git a/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/BlockchainStateEntity.cs b/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/BlockchainStateEntity.cs index 4aee3eaf..b4ec73df 100644 --- a/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/BlockchainStateEntity.cs +++ b/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/BlockchainStateEntity.cs @@ -10,9 +10,7 @@ public class BlockchainStateEntity public required DateTime LastProcessedAt { get; set; } // Default constructor for EF Core - internal BlockchainStateEntity() - { - } + internal BlockchainStateEntity() { } public override bool Equals(object? obj) { diff --git a/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/RevocationWatchEntity.cs b/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/RevocationWatchEntity.cs index e415b283..7a7ccd44 100644 --- a/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/RevocationWatchEntity.cs +++ b/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/RevocationWatchEntity.cs @@ -16,7 +16,5 @@ public class RevocationWatchEntity public DateTime? IncludedAt { get; set; } // Default constructor for EF Core - internal RevocationWatchEntity() - { - } + internal RevocationWatchEntity() { } } \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/UtxoEntity.cs b/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/UtxoEntity.cs new file mode 100644 index 00000000..d3c01804 --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/UtxoEntity.cs @@ -0,0 +1,14 @@ +namespace NLightning.Infrastructure.Persistence.Entities.Bitcoin; + +using Domain.Bitcoin.ValueObjects; + +public class UtxoEntity +{ + public required TxId TransactionId { get; set; } + public uint Index { get; set; } + public long AmountSats { get; set; } + public uint BlockHeight { get; set; } + + // Default constructor for EF Core + internal UtxoEntity() { } +} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/WalletAddressEntity.cs b/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/WalletAddressEntity.cs index 7fbdca8f..b3c23b9c 100644 --- a/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/WalletAddressEntity.cs +++ b/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/WalletAddressEntity.cs @@ -9,4 +9,7 @@ public class WalletAddressEntity public required AddressType AddressType { get; set; } public required string Address { get; set; } public uint UtxoQty { get; set; } + + // Default constructor for EF Core + internal WalletAddressEntity() { } } \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/WatchedTransactionEntity.cs b/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/WatchedTransactionEntity.cs index 14aef44b..7a3f7640 100644 --- a/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/WatchedTransactionEntity.cs +++ b/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/WatchedTransactionEntity.cs @@ -14,7 +14,5 @@ public class WatchedTransactionEntity public DateTime? CompletedAt { get; set; } // Default constructor for EF Core - internal WatchedTransactionEntity() - { - } + internal WatchedTransactionEntity() { } } \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence/EntityConfiguration/Bitcoin/UtxoEntityConfiguration.cs b/src/NLightning.Infrastructure.Persistence/EntityConfiguration/Bitcoin/UtxoEntityConfiguration.cs new file mode 100644 index 00000000..e61319ac --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence/EntityConfiguration/Bitcoin/UtxoEntityConfiguration.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace NLightning.Infrastructure.Persistence.EntityConfiguration.Bitcoin; + +using Domain.Crypto.Constants; +using Entities.Bitcoin; +using Enums; +using ValueConverters; + +public static class UtxoEntityConfiguration +{ + public static void ConfigureUtxoEntity(this ModelBuilder modelBuilder, DatabaseType databaseType) + { + modelBuilder.Entity(entity => + { + // Set Primary Key + entity.HasKey(e => new { e.TransactionId, e.Index }); + + // Set Required props + entity.Property(e => e.AmountSats) + .IsRequired(); + entity.Property(e => e.BlockHeight) + .IsRequired(); + + // Set converters + entity.Property(x => x.TransactionId) + .HasConversion(); + + if (databaseType == DatabaseType.MicrosoftSql) + OptimizeConfigurationForSqlServer(entity); + }); + } + + private static void OptimizeConfigurationForSqlServer(EntityTypeBuilder entity) + { + entity.Property(e => e.TransactionId).HasColumnType($"varbinary({CryptoConstants.Sha256HashLen})"); + } +} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/UtxoDbRepository.cs b/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/UtxoDbRepository.cs new file mode 100644 index 00000000..4cce3b44 --- /dev/null +++ b/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/UtxoDbRepository.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore; + +namespace NLightning.Infrastructure.Repositories.Database.Bitcoin; + +using Domain.Bitcoin.Interfaces; +using Domain.Bitcoin.Wallet.Models; +using Domain.Money; +using Persistence.Contexts; +using Persistence.Entities.Bitcoin; + +public class UtxoDbRepository(NLightningDbContext context) + : BaseDbRepository(context), IUtxoDbRepository +{ + public void Add(UtxoModel utxoModel) + { + var utxoEntity = MapDomainToEntity(utxoModel); + Insert(utxoEntity); + } + + public void Spend(UtxoModel utxoModel) + { + var utxoEntity = MapDomainToEntity(utxoModel); + Delete(utxoEntity); + } + + public async Task> GetAllAsync() + { + var utxoSet = await Get(asNoTracking: true).ToListAsync(); + + return utxoSet.Select(MapEntityToModel); + } + + private UtxoEntity MapDomainToEntity(UtxoModel model) + { + return new UtxoEntity + { + TransactionId = model.TxId, + Index = model.Index, + AmountSats = model.Amount.Satoshi, + BlockHeight = model.BlockHeight + }; + } + + private UtxoModel MapEntityToModel(UtxoEntity entity) + { + return new UtxoModel(entity.TransactionId, entity.Index, LightningMoney.Satoshis(entity.AmountSats), + entity.BlockHeight); + } +} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/WalletAddressesDbRepository.cs b/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/WalletAddressesDbRepository.cs index a4ee3052..d388c2d6 100644 --- a/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/WalletAddressesDbRepository.cs +++ b/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/WalletAddressesDbRepository.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; +using NLightning.Domain.Bitcoin.Wallet.Models; namespace NLightning.Infrastructure.Repositories.Database.Bitcoin; -using Domain.Bitcoin.Addresses.Models; using Domain.Bitcoin.Enums; using Domain.Bitcoin.Interfaces; using Persistence.Contexts; diff --git a/src/NLightning.Infrastructure.Repositories/DependencyInjection.cs b/src/NLightning.Infrastructure.Repositories/DependencyInjection.cs index 430decac..d66816b8 100644 --- a/src/NLightning.Infrastructure.Repositories/DependencyInjection.cs +++ b/src/NLightning.Infrastructure.Repositories/DependencyInjection.cs @@ -1,10 +1,11 @@ using Microsoft.Extensions.DependencyInjection; -using NLightning.Domain.Channels.Interfaces; -using NLightning.Infrastructure.Repositories.Memory; namespace NLightning.Infrastructure.Repositories; +using Domain.Bitcoin.Interfaces; +using Domain.Channels.Interfaces; using Domain.Persistence.Interfaces; +using Memory; /// /// Extension methods for setting up Persistence infrastructure services in an IServiceCollection. @@ -23,6 +24,7 @@ public static IServiceCollection AddRepositoriesInfrastructureServices(this ISer // Register memory repositories services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/src/NLightning.Infrastructure.Repositories/Memory/UtxoMemoryRepository.cs b/src/NLightning.Infrastructure.Repositories/Memory/UtxoMemoryRepository.cs new file mode 100644 index 00000000..de170101 --- /dev/null +++ b/src/NLightning.Infrastructure.Repositories/Memory/UtxoMemoryRepository.cs @@ -0,0 +1,45 @@ +using System.Collections.Concurrent; + +namespace NLightning.Infrastructure.Repositories.Memory; + +using Domain.Bitcoin.Interfaces; +using Domain.Bitcoin.ValueObjects; +using Domain.Bitcoin.Wallet.Models; +using Domain.Money; + +public class UtxoMemoryRepository : IUtxoMemoryRepository +{ + private readonly ConcurrentDictionary<(TxId, uint), UtxoModel> _utxoSet = []; + + public void Add(UtxoModel utxoModel) + { + if (!_utxoSet.TryAdd((utxoModel.TxId, utxoModel.Index), utxoModel)) + throw new InvalidOperationException("Cannot add Utxo"); + } + + public void Spend(UtxoModel utxoModel) + { + if (!_utxoSet.TryRemove((utxoModel.TxId, utxoModel.Index), out _)) + throw new InvalidOperationException("Cannot remove Utxo"); + } + + public LightningMoney GetConfirmedBalance(uint currentBlockHeight) + { + return LightningMoney.Satoshis(_utxoSet.Values + .Where(x => x.BlockHeight + 3 <= currentBlockHeight) + .Sum(x => x.Amount.Satoshi)); + } + + public LightningMoney GetUnconfirmedBalance(uint currentBlockHeight) + { + return LightningMoney.Satoshis(_utxoSet.Values + .Where(x => x.BlockHeight + 3 > currentBlockHeight) + .Sum(x => x.Amount.Satoshi)); + } + + public void Load(List utxoSet) + { + foreach (var utxoModel in utxoSet) + _utxoSet.TryAdd((utxoModel.TxId, utxoModel.Index), utxoModel); + } +} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Repositories/UnitOfWork.cs b/src/NLightning.Infrastructure.Repositories/UnitOfWork.cs index 4f8a1e93..91cc57f7 100644 --- a/src/NLightning.Infrastructure.Repositories/UnitOfWork.cs +++ b/src/NLightning.Infrastructure.Repositories/UnitOfWork.cs @@ -1,3 +1,6 @@ +using Microsoft.Extensions.Logging; +using NLightning.Domain.Bitcoin.Wallet.Models; + namespace NLightning.Infrastructure.Repositories; using Database.Bitcoin; @@ -16,13 +19,16 @@ namespace NLightning.Infrastructure.Repositories; public class UnitOfWork : IUnitOfWork { private readonly NLightningDbContext _context; + private readonly ILogger _logger; private readonly IMessageSerializer _messageSerializer; private readonly ISha256 _sha256; + private readonly IUtxoMemoryRepository _utxoMemoryRepository; // Bitcoin repositories private BlockchainStateDbRepository? _blockchainStateDbRepository; private WatchedTransactionDbRepository? _watchedTransactionDbRepository; private WalletAddressesDbRepository? _walletAddressesDbRepository; + private UtxoDbRepository? _utxoDbRepository; // Channel repositories private ChannelConfigDbRepository? _channelConfigDbRepository; @@ -42,6 +48,8 @@ public class UnitOfWork : IUnitOfWork public IWalletAddressesDbRepository WalletAddressesDbRepository => _walletAddressesDbRepository ??= new WalletAddressesDbRepository(_context); + public IUtxoDbRepository UtxoDbRepository => _utxoDbRepository ??= new UtxoDbRepository(_context); + public IChannelConfigDbRepository ChannelConfigDbRepository => _channelConfigDbRepository ??= new ChannelConfigDbRepository(_context); @@ -57,11 +65,14 @@ public class UnitOfWork : IUnitOfWork public IPeerDbRepository PeerDbRepository => _peerDbRepository ??= new PeerDbRepository(_context); - public UnitOfWork(NLightningDbContext context, IMessageSerializer messageSerializer, ISha256 sha256) + public UnitOfWork(NLightningDbContext context, ILogger logger, IMessageSerializer messageSerializer, + ISha256 sha256, IUtxoMemoryRepository utxoMemoryRepository) { _context = context ?? throw new ArgumentNullException(nameof(context)); + _logger = logger; _messageSerializer = messageSerializer; _sha256 = sha256; + _utxoMemoryRepository = utxoMemoryRepository; } public async Task> GetPeersForStartupAsync() @@ -79,6 +90,56 @@ public async Task> GetPeersForStartupAsync() return peerList; } + public void AddUtxo(UtxoModel utxoModel) + { + try + { + _utxoMemoryRepository.Add(utxoModel); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to add Utxo to memory repository"); + throw; + } + + try + { + UtxoDbRepository.Add(utxoModel); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to add Utxo to the database"); + + // Rollback memory repository operation + _utxoMemoryRepository.Spend(utxoModel); + } + } + + public void SpendUtxo(UtxoModel utxoModel) + { + try + { + _utxoMemoryRepository.Spend(utxoModel); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to spend Utxo from memory repository"); + throw; + } + + try + { + UtxoDbRepository.Spend(utxoModel); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to spend Utxo from the database"); + + // Rollback memory repository operation + _utxoMemoryRepository.Add(utxoModel); + } + } + public void SaveChanges() { _context.SaveChanges(); diff --git a/src/NLightning.Transport.Ipc/MessagePack/Formatters/LightningMoneyFormatter.cs b/src/NLightning.Transport.Ipc/MessagePack/Formatters/LightningMoneyFormatter.cs new file mode 100644 index 00000000..8a48eb91 --- /dev/null +++ b/src/NLightning.Transport.Ipc/MessagePack/Formatters/LightningMoneyFormatter.cs @@ -0,0 +1,25 @@ +using MessagePack; +using MessagePack.Formatters; + +namespace NLightning.Transport.Ipc.MessagePack.Formatters; + +using Domain.Money; + +public class LightningMoneyFormatter : IMessagePackFormatter +{ + public void Serialize(ref MessagePackWriter writer, LightningMoney? value, MessagePackSerializerOptions options) + { + if (value is null) + { + writer.WriteNil(); + return; + } + + writer.Write(value.MilliSatoshi); + } + + public LightningMoney? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + return reader.TryReadNil() ? null : LightningMoney.MilliSatoshis(reader.ReadUInt64()); + } +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/MessagePack/NLightningFormatterResolver.cs b/src/NLightning.Transport.Ipc/MessagePack/NLightningFormatterResolver.cs index 169029d4..9f21f7ee 100644 --- a/src/NLightning.Transport.Ipc/MessagePack/NLightningFormatterResolver.cs +++ b/src/NLightning.Transport.Ipc/MessagePack/NLightningFormatterResolver.cs @@ -1,13 +1,14 @@ using MessagePack; using MessagePack.Formatters; using MessagePack.Resolvers; -using NLightning.Domain.Node; -using NLightning.Domain.Node.ValueObjects; -using NLightning.Domain.Protocol.ValueObjects; namespace NLightning.Transport.Ipc.MessagePack; using Domain.Crypto.ValueObjects; +using Domain.Money; +using Domain.Node; +using Domain.Node.ValueObjects; +using Domain.Protocol.ValueObjects; using Formatters; public class NLightningFormatterResolver : IFormatterResolver @@ -23,6 +24,7 @@ private NLightningFormatterResolver() _formatters[typeof(PeerAddressInfo)] = new PeerAddressInfoFormatter(); _formatters[typeof(CompactPubKey)] = new CompactPubKeyFormatter(); _formatters[typeof(FeatureSet)] = new FeatureSetFormatter(); + _formatters[typeof(LightningMoney)] = new LightningMoneyFormatter(); } public IMessagePackFormatter? GetFormatter() diff --git a/src/NLightning.Transport.Ipc/NodeIpcCommand.cs b/src/NLightning.Transport.Ipc/NodeIpcCommand.cs index 74477b80..3f3537b2 100644 --- a/src/NLightning.Transport.Ipc/NodeIpcCommand.cs +++ b/src/NLightning.Transport.Ipc/NodeIpcCommand.cs @@ -10,5 +10,6 @@ public enum NodeIpcCommand NodeInfo = 1, ConnectPeer = 2, ListPeers = 3, - GetAddress = 4 + GetAddress = 4, + WalletBalance = 5 } \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/Requests/WalletBalanceIpcRequest.cs b/src/NLightning.Transport.Ipc/Requests/WalletBalanceIpcRequest.cs new file mode 100644 index 00000000..9dcb1f79 --- /dev/null +++ b/src/NLightning.Transport.Ipc/Requests/WalletBalanceIpcRequest.cs @@ -0,0 +1,9 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc.Requests; + +/// +/// Empty request for WalletBalance. +/// +[MessagePackObject] +public readonly struct WalletBalanceIpcRequest; \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/Responses/WalletBalanceIpcResponse.cs b/src/NLightning.Transport.Ipc/Responses/WalletBalanceIpcResponse.cs new file mode 100644 index 00000000..5f57d73e --- /dev/null +++ b/src/NLightning.Transport.Ipc/Responses/WalletBalanceIpcResponse.cs @@ -0,0 +1,15 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc.Responses; + +using Domain.Money; + +/// +/// Response for Wallet Balance command +/// +[MessagePackObject] +public class WalletBalanceIpcResponse +{ + [Key(0)] public required LightningMoney ConfirmedBalance { get; init; } + [Key(1)] public required LightningMoney UnconfirmedBalance { get; init; } +} \ No newline at end of file From 6f5f7910188954273ae19763fa9db5ac9bbee5f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADckolas=20Goline?= Date: Wed, 19 Nov 2025 11:23:17 -0300 Subject: [PATCH 08/20] add openchannel ipc command --- .../Handlers/AcceptChannel1MessageHandler.cs | 250 +++++++++++++++++ ...r.cs => FundingConfirmedMessageHandler.cs} | 17 +- .../Handlers/FundingCreatedMessageHandler.cs | 17 +- .../Handlers/FundingSignedMessageHandler.cs | 132 +++++++++ .../Channels/Managers/ChannelManager.cs | 11 +- .../DependencyInjection.cs | 2 +- .../Node/Managers/PeerManager.cs | 42 ++- .../Protocol/Factories/MessageFactory.cs | 6 +- .../Ipc/NamedPipeIpcClient.cs | 53 ++-- .../Printers/OpenChannelPrinter.cs | 15 + src/NLightning.Client/Program.cs | 11 +- src/NLightning.Client/Utils/ClientUtils.cs | 8 +- .../Helpers/CommandLineHelper.cs | 90 ++++-- .../Utilities/NodeUtils.cs | 14 +- .../Extensions/NodeConfigurationExtensions.cs | 47 ++-- .../Extensions/NodeServiceExtensions.cs | 43 ++- .../Handlers/OpenChannelClientHandler.cs | 203 ++++++++++++++ .../Interfaces/IClientCommandHandler.cs | 9 + .../Interfaces/IIpcCommandHandler.cs | 9 - .../Handlers/ConnectPeerIpcHandler.cs | 13 +- .../Handlers/GetAddressIpcHandler.cs | 13 +- .../Handlers/GetWalletBalanceIpcHandler.cs | 9 +- .../{ => Ipc}/Handlers/ListPeersIpcHandler.cs | 9 +- .../{ => Ipc}/Handlers/NodeInfoIpcHandler.cs | 10 +- .../Ipc/Handlers/OpenChannelIpcHandler.cs | 99 +++++++ .../{ => Ipc}/Interfaces/IIpcAuthenticator.cs | 4 +- .../Ipc/Interfaces/IIpcCommandHandler.cs | 10 + .../{ => Ipc}/Interfaces/IIpcFraming.cs | 4 +- .../{ => Ipc}/Interfaces/IIpcRequestRouter.cs | 4 +- src/NLightning.Daemon/Program.cs | 23 +- .../Services/Ipc/CookieFileAuthenticator.cs | 2 +- .../Services/Ipc/IpcFraming.cs | 4 +- .../Services/Ipc/IpcRouting.cs | 9 +- ...ostedService.cs => NamedPipeIpcService.cs} | 97 +++++-- .../Services/NltgDaemonService.cs | 13 +- .../Utilities/DaemonUtils.cs | 7 +- .../Bitcoin/Interfaces/ILightningSigner.cs | 12 +- .../Bitcoin/Interfaces/IUtxoDbRepository.cs | 5 +- .../Interfaces/IUtxoMemoryRepository.cs | 11 +- .../Transactions/Constants/WeightConstants.cs | 5 +- .../CommitmentTransactionModelFactory.cs | 6 +- .../FundingTransactionModelFactory.cs | 78 ++++++ .../IFundingTransactionModelFactory.cs | 10 + .../Models/FundingTransactionModel.cs | 44 +++ .../Bitcoin/Wallet/Models/UtxoModel.cs | 32 ++- .../Wallet/Models/WalletAddressModel.cs | 13 +- .../Channels/Factories/ChannelFactory.cs | 263 ++++++++---------- .../Channels/Interfaces/IChannelFactory.cs | 5 + .../Interfaces/IChannelOpenValidator.cs | 52 ++++ .../Channels/Models/ChannelModel.cs | 66 ++++- .../Validators/ChannelOpenValidator.cs | 148 ++++++++++ ...hannelOpenMandatoryValidationParameters.cs | 61 ++++ ...ChannelOpenOptionalValidationParameters.cs | 56 ++++ .../Channels/ValueObjects/ChannelConfig.cs | 4 +- .../Channels/ValueObjects/ChannelId.cs | 1 + .../Client}/Constants/ErrorCodes.cs | 3 +- .../Client/Enums/ClientCommand.cs} | 9 +- .../Client/Exceptions/ClientException.cs | 16 ++ .../Client/Interfaces/INamedPipeIpcService.cs | 20 ++ .../Requests/OpenChannelClientRequest.cs | 24 ++ .../Responses/OpenChannelClientResponse.cs | 18 ++ src/NLightning.Domain/Node/FeatureSet.cs | 11 + .../Node/Interfaces/IPeerManager.cs | 9 +- .../Node/Models/PeerModel.cs | 11 + .../Node/Options/NodeOptions.cs | 1 - .../Persistence/Interfaces/IUnitOfWork.cs | 7 +- .../Protocol/Interfaces/IChannelIdFactory.cs | 1 + .../Protocol/Interfaces/IMessageFactory.cs | 6 +- .../Builders/FundingTransactionBuilder.cs | 73 +++++ .../Interfaces/IFundingTransactionBuilder.cs | 14 + .../DependencyInjection.cs | 7 +- .../Managers/SecureKeyManager.cs | 7 +- .../Signers/LocalLightningSigner.cs | 261 ++++++++++++++++- .../Transactions/BaseTransaction.cs | 2 +- .../Transactions/FundingTransaction.cs | 9 +- .../Wallet/BitcoinWalletService.cs | 11 +- .../Wallet/BlockchainMonitorService.cs | 43 ++- .../Interfaces/IBitcoinWalletService.cs | 3 +- .../Wallet/Interfaces/IBlockchainMonitor.cs | 1 + ...0251_AddPeerTypeWalletAddressesAndUtxos.cs | 64 ----- ...94247_AddFieldsForChannelOpen.Designer.cs} | 89 +++++- .../20251106194247_AddFieldsForChannelOpen.cs | 158 +++++++++++ .../NLightningDbContextModelSnapshot.cs | 85 +++++- ...0304_AddPeerTypeWalletAddressesAndUtxos.cs | 64 ----- ...94300_AddFieldsForChannelOpen.Designer.cs} | 72 ++++- .../20251106194300_AddFieldsForChannelOpen.cs | 158 +++++++++++ .../NLightningDbContextModelSnapshot.cs | 68 ++++- ...0258_AddPeerTypeWalletAddressesAndUtxos.cs | 64 ----- ...94254_AddFieldsForChannelOpen.Designer.cs} | 68 ++++- .../20251106194254_AddFieldsForChannelOpen.cs | 154 ++++++++++ .../NLightningDbContextModelSnapshot.cs | 64 ++++- .../DependencyInjection.cs | 4 + .../Entities/Bitcoin/UtxoEntity.cs | 9 + .../Entities/Bitcoin/WalletAddressEntity.cs | 3 +- .../Entities/Channel/ChannelEntity.cs | 9 + .../Bitcoin/UtxoEntityConfiguration.cs | 58 +++- .../WalletAddressEntityConfiguration.cs | 11 +- .../Database/Bitcoin/UtxoDbRepository.cs | 45 ++- .../Bitcoin/WalletAddressesDbRepository.cs | 36 ++- .../Memory/UtxoMemoryRepository.cs | 158 ++++++++++- .../UnitOfWork.cs | 9 +- .../Payloads/OpenChannel1PayloadSerializer.cs | 6 +- .../Protocol/Factories/ChannelIdFactory.cs | 8 + src/NLightning.Transport.Ipc/IpcEnvelope.cs | 4 +- .../Formatters/ChannelIdFormatter.cs | 21 ++ .../Formatters/CompactPubKeyFormatter.cs | 8 +- .../CompactPubKeyNullableFormatter.cs | 31 +++ .../Formatters/PeerAddressInfoFormatter.cs | 1 + .../PeerAddressInfoNullableFormatter.cs | 31 +++ .../Formatters/SignedTransactionFormatter.cs | 33 +++ .../MessagePack/Formatters/TxIdFormatter.cs | 20 ++ .../NLightningFormatterResolver.cs | 7 + .../Requests/GetAddressIpcRequest.cs | 2 +- .../Requests/OpenChannelIpcRequest.cs | 21 ++ .../Responses/GetAddressIpcResponse.cs | 2 +- .../Responses/NodeInfoIpcResponse.cs | 2 +- .../Responses/OpenChannelIpcResponse.cs | 28 ++ .../Responses/PeerInfoIpcResponse.cs | 7 +- .../Responses/WalletBalanceIpcResponse.cs | 2 +- .../FundingCreatedMessageHandlerTests.cs | 52 ++-- .../Signers/LocalLightningSignerTests.cs | 15 +- .../Wallet/BlockchainMonitorServiceTests.cs | 150 ++++------ .../BOLT3/Bolt3IntegrationTests.cs | 34 +-- .../BOLT3/Mocks/Bolt3TestLightningSigner.cs | 2 +- .../Docker/AbcNetworkTests.cs | 8 +- 125 files changed, 3830 insertions(+), 863 deletions(-) create mode 100644 src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs rename src/NLightning.Application/Channels/Handlers/{FundingConfirmedHandler.cs => FundingConfirmedMessageHandler.cs} (86%) create mode 100644 src/NLightning.Application/Channels/Handlers/FundingSignedMessageHandler.cs create mode 100644 src/NLightning.Client/Printers/OpenChannelPrinter.cs create mode 100644 src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs create mode 100644 src/NLightning.Daemon/Interfaces/IClientCommandHandler.cs delete mode 100644 src/NLightning.Daemon/Interfaces/IIpcCommandHandler.cs rename src/NLightning.Daemon/{ => Ipc}/Handlers/ConnectPeerIpcHandler.cs (90%) rename src/NLightning.Daemon/{ => Ipc}/Handlers/GetAddressIpcHandler.cs (84%) rename src/NLightning.Daemon/{ => Ipc}/Handlers/GetWalletBalanceIpcHandler.cs (90%) rename src/NLightning.Daemon/{ => Ipc}/Handlers/ListPeersIpcHandler.cs (90%) rename src/NLightning.Daemon/{ => Ipc}/Handlers/NodeInfoIpcHandler.cs (86%) create mode 100644 src/NLightning.Daemon/Ipc/Handlers/OpenChannelIpcHandler.cs rename src/NLightning.Daemon/{ => Ipc}/Interfaces/IIpcAuthenticator.cs (50%) create mode 100644 src/NLightning.Daemon/Ipc/Interfaces/IIpcCommandHandler.cs rename src/NLightning.Daemon/{ => Ipc}/Interfaces/IIpcFraming.cs (70%) rename src/NLightning.Daemon/{ => Ipc}/Interfaces/IIpcRequestRouter.cs (55%) rename src/NLightning.Daemon/Services/Ipc/{NamedPipeIpcHostedService.cs => NamedPipeIpcService.cs} (51%) create mode 100644 src/NLightning.Domain/Bitcoin/Transactions/Factories/FundingTransactionModelFactory.cs create mode 100644 src/NLightning.Domain/Bitcoin/Transactions/Interfaces/IFundingTransactionModelFactory.cs create mode 100644 src/NLightning.Domain/Bitcoin/Transactions/Models/FundingTransactionModel.cs create mode 100644 src/NLightning.Domain/Channels/Interfaces/IChannelOpenValidator.cs create mode 100644 src/NLightning.Domain/Channels/Validators/ChannelOpenValidator.cs create mode 100644 src/NLightning.Domain/Channels/Validators/Parameters/ChannelOpenMandatoryValidationParameters.cs create mode 100644 src/NLightning.Domain/Channels/Validators/Parameters/ChannelOpenOptionalValidationParameters.cs rename src/{NLightning.Transport.Ipc => NLightning.Domain/Client}/Constants/ErrorCodes.cs (75%) rename src/{NLightning.Transport.Ipc/NodeIpcCommand.cs => NLightning.Domain/Client/Enums/ClientCommand.cs} (52%) create mode 100644 src/NLightning.Domain/Client/Exceptions/ClientException.cs create mode 100644 src/NLightning.Domain/Client/Interfaces/INamedPipeIpcService.cs create mode 100644 src/NLightning.Domain/Client/Requests/OpenChannelClientRequest.cs create mode 100644 src/NLightning.Domain/Client/Responses/OpenChannelClientResponse.cs create mode 100644 src/NLightning.Infrastructure.Bitcoin/Builders/FundingTransactionBuilder.cs create mode 100644 src/NLightning.Infrastructure.Bitcoin/Builders/Interfaces/IFundingTransactionBuilder.cs delete mode 100644 src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027190251_AddPeerTypeWalletAddressesAndUtxos.cs rename src/NLightning.Infrastructure.Persistence.Postgres/Migrations/{20251027190251_AddPeerTypeWalletAddressesAndUtxos.Designer.cs => 20251106194247_AddFieldsForChannelOpen.Designer.cs} (84%) create mode 100644 src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251106194247_AddFieldsForChannelOpen.cs delete mode 100644 src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027190304_AddPeerTypeWalletAddressesAndUtxos.cs rename src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/{20251027190304_AddPeerTypeWalletAddressesAndUtxos.Designer.cs => 20251106194300_AddFieldsForChannelOpen.Designer.cs} (84%) create mode 100644 src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251106194300_AddFieldsForChannelOpen.cs delete mode 100644 src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027190258_AddPeerTypeWalletAddressesAndUtxos.cs rename src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/{20251027190258_AddPeerTypeWalletAddressesAndUtxos.Designer.cs => 20251106194254_AddFieldsForChannelOpen.Designer.cs} (85%) create mode 100644 src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251106194254_AddFieldsForChannelOpen.cs create mode 100644 src/NLightning.Transport.Ipc/MessagePack/Formatters/ChannelIdFormatter.cs create mode 100644 src/NLightning.Transport.Ipc/MessagePack/Formatters/CompactPubKeyNullableFormatter.cs create mode 100644 src/NLightning.Transport.Ipc/MessagePack/Formatters/PeerAddressInfoNullableFormatter.cs create mode 100644 src/NLightning.Transport.Ipc/MessagePack/Formatters/SignedTransactionFormatter.cs create mode 100644 src/NLightning.Transport.Ipc/MessagePack/Formatters/TxIdFormatter.cs create mode 100644 src/NLightning.Transport.Ipc/Requests/OpenChannelIpcRequest.cs create mode 100644 src/NLightning.Transport.Ipc/Responses/OpenChannelIpcResponse.cs rename test/{NLightning.Infrastructure.Tests/Bitcoin => NLightning.Infrastructure.Bitcoin.Tests}/Wallet/BlockchainMonitorServiceTests.cs (71%) diff --git a/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs b/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs new file mode 100644 index 00000000..0733e57e --- /dev/null +++ b/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs @@ -0,0 +1,250 @@ +using Microsoft.Extensions.Logging; + +namespace NLightning.Application.Channels.Handlers; + +using Domain.Bitcoin.Enums; +using Domain.Bitcoin.Interfaces; +using Domain.Bitcoin.Transactions.Enums; +using Domain.Bitcoin.Transactions.Interfaces; +using Domain.Bitcoin.Transactions.Outputs; +using Domain.Bitcoin.ValueObjects; +using Domain.Channels.Enums; +using Domain.Channels.Interfaces; +using Domain.Channels.Models; +using Domain.Channels.Validators.Parameters; +using Domain.Channels.ValueObjects; +using Domain.Crypto.Hashes; +using Domain.Crypto.ValueObjects; +using Domain.Enums; +using Domain.Exceptions; +using Domain.Node.Options; +using Domain.Persistence.Interfaces; +using Domain.Protocol.Interfaces; +using Domain.Protocol.Messages; +using Domain.Protocol.Models; +using Infrastructure.Bitcoin.Builders.Interfaces; +using Infrastructure.Bitcoin.Wallet.Interfaces; +using Interfaces; + +public class AcceptChannel1MessageHandler : IChannelMessageHandler +{ + private readonly IBitcoinWalletService _bitcoinWalletService; + private readonly IChannelIdFactory _channelIdFactory; + private readonly IChannelMemoryRepository _channelMemoryRepository; + private readonly IChannelOpenValidator _channelOpenValidator; + private readonly ICommitmentTransactionBuilder _commitmentTransactionBuilder; + private readonly ICommitmentTransactionModelFactory _commitmentTransactionModelFactory; + private readonly IFundingTransactionBuilder _fundingTransactionBuilder; + private readonly IFundingTransactionModelFactory _fundingTransactionModelFactory; + private readonly ILightningSigner _lightningSigner; + private readonly ILogger _logger; + private readonly IMessageFactory _messageFactory; + private readonly ISha256 _sha256; + private readonly IUnitOfWork _unitOfWork; + private readonly IUtxoMemoryRepository _utxoMemoryRepository; + + public AcceptChannel1MessageHandler(IBitcoinWalletService bitcoinWalletService, IChannelIdFactory channelIdFactory, + IChannelMemoryRepository channelMemoryRepository, + IChannelOpenValidator channelOpenValidator, + ICommitmentTransactionBuilder commitmentTransactionBuilder, + ICommitmentTransactionModelFactory commitmentTransactionModelFactory, + IFundingTransactionBuilder fundingTransactionBuilder, + IFundingTransactionModelFactory fundingTransactionModelFactory, + ILightningSigner lightningSigner, ILogger logger, + IMessageFactory messageFactory, ISha256 sha256, IUnitOfWork unitOfWork, + IUtxoMemoryRepository utxoMemoryRepository) + { + _bitcoinWalletService = bitcoinWalletService; + _channelIdFactory = channelIdFactory; + _channelMemoryRepository = channelMemoryRepository; + _channelOpenValidator = channelOpenValidator; + _commitmentTransactionBuilder = commitmentTransactionBuilder; + _commitmentTransactionModelFactory = commitmentTransactionModelFactory; + _fundingTransactionBuilder = fundingTransactionBuilder; + _fundingTransactionModelFactory = fundingTransactionModelFactory; + _lightningSigner = lightningSigner; + _logger = logger; + _messageFactory = messageFactory; + _sha256 = sha256; + _unitOfWork = unitOfWork; + _utxoMemoryRepository = utxoMemoryRepository; + } + + public async Task HandleAsync(AcceptChannel1Message message, ChannelState currentState, + FeatureOptions negotiatedFeatures, CompactPubKey peerPubKey) + { + _logger.LogTrace("Processing AcceptChannel1Message with ChannelId: {ChannelId} from Peer: {PeerPubKey}", + message.Payload.ChannelId, peerPubKey); + + var payload = message.Payload; + + if (currentState != ChannelState.None) + throw new ChannelErrorException("A channel with this id already exists", payload.ChannelId); + + // Check if there's a temporary channel for this peer + if (_channelMemoryRepository.TryGetTemporaryChannelState(peerPubKey, payload.ChannelId, out currentState)) + { + if (currentState != ChannelState.V1Opening) + { + throw new ChannelErrorException("Channel had the wrong state", payload.ChannelId, + "This channel is already being negotiated with peer"); + } + } + + // Get the temporary channel + if (!_channelMemoryRepository.TryGetTemporaryChannel(peerPubKey, payload.ChannelId, out var tempChannel)) + throw new ChannelErrorException("Temporary channel not found", payload.ChannelId); + + // Check if the channel type was negotiated and the channel type is present + if (message.ChannelTypeTlv is not null && negotiatedFeatures.ChannelType == FeatureSupport.Compulsory) + throw new ChannelErrorException("Channel type was negotiated but not provided"); + + // Perform optional checks for the channel + _channelOpenValidator.PerformOptionalChecks( + ChannelOpenOptionalValidationParameters.FromAcceptChannel1Payload( + payload, tempChannel.ChannelConfig.ChannelReserveAmount)); + + // Perform mandatory checks for the channel + _channelOpenValidator.PerformMandatoryChecks(ChannelOpenMandatoryValidationParameters.FromAcceptChannel1Payload( + message.ChannelTypeTlv, + tempChannel.ChannelConfig.FeeRateAmountPerKw, + negotiatedFeatures, payload), out var minimumDepth); + + if (minimumDepth != tempChannel.ChannelConfig.MinimumDepth) + throw new ChannelErrorException("Minimum depth is not acceptable", payload.ChannelId); + + // Check for the upfront shutdown script + if (message.UpfrontShutdownScriptTlv is null + && (negotiatedFeatures.UpfrontShutdownScript > FeatureSupport.No || message.ChannelTypeTlv is not null)) + throw new ChannelErrorException("Upfront shutdown script is required but not provided"); + + BitcoinScript? remoteUpfrontShutdownScript = null; + if (message.UpfrontShutdownScriptTlv is not null && message.UpfrontShutdownScriptTlv.Value.Length > 0) + remoteUpfrontShutdownScript = message.UpfrontShutdownScriptTlv.Value; + + // Create the remote key set from the message + var remoteKeySet = ChannelKeySetModel.CreateForRemote(message.Payload.FundingPubKey, + message.Payload.RevocationBasepoint, + message.Payload.PaymentBasepoint, + message.Payload.DelayedPaymentBasepoint, + message.Payload.HtlcBasepoint, + message.Payload.FirstPerCommitmentPoint); + + tempChannel.AddRemoteKeySet(remoteKeySet); + + // Create a new ChannelConfig with the remote provided values + var channelConfig = new ChannelConfig(tempChannel.ChannelConfig.ChannelReserveAmount, + tempChannel.ChannelConfig.FeeRateAmountPerKw, + tempChannel.ChannelConfig.HtlcMinimumAmount, + tempChannel.ChannelConfig.LocalDustLimitAmount, + tempChannel.ChannelConfig.MaxAcceptedHtlcs, + tempChannel.ChannelConfig.MaxHtlcAmountInFlight, + tempChannel.ChannelConfig.MinimumDepth, + tempChannel.ChannelConfig.OptionAnchorOutputs, + payload.DustLimitAmount, tempChannel.ChannelConfig.ToSelfDelay, + tempChannel.ChannelConfig.UseScidAlias, + tempChannel.ChannelConfig.LocalUpfrontShutdownScript, + remoteUpfrontShutdownScript); + + tempChannel.UpdateChannelConfig(channelConfig); + + // Generate the correct commitment number + var commitmentNumber = new CommitmentNumber(tempChannel.LocalKeySet.PaymentCompactBasepoint, + remoteKeySet.PaymentCompactBasepoint, _sha256); + + tempChannel.AddCommitmentNumber(commitmentNumber); + + try + { + var fundingAmount = tempChannel.LocalBalance + tempChannel.RemoteBalance; + var fundingOutput = new FundingOutputInfo(fundingAmount, tempChannel.LocalKeySet.FundingCompactPubKey, + remoteKeySet.FundingCompactPubKey); + + tempChannel.AddFundingOutput(fundingOutput); + + // Get the utxos to create the funding transaction + var utxos = _utxoMemoryRepository.GetLockedUtxosForChannel(tempChannel.ChannelId); + + // Get a change address in case we need one + var walletAddress = await _bitcoinWalletService.GetUnusedAddressAsync(AddressType.P2Wpkh, true); + + // Create the funding transaction + var fundingTransactionModel = _fundingTransactionModelFactory.Create(tempChannel, utxos, walletAddress); + _ = _fundingTransactionBuilder.Build(fundingTransactionModel); + if (fundingOutput.TransactionId is null || fundingOutput.Index is null) + throw new ChannelErrorException("Error building the funding transaction"); + + // If a change was needed, save the change data to the channel + if (fundingTransactionModel.ChangeAddress is not null) + tempChannel.ChangeAddress = fundingTransactionModel.ChangeAddress; + + // Create a new channelId + var oldChannelId = tempChannel.ChannelId; + tempChannel.UpdateChannelId( + _channelIdFactory.CreateV1(fundingOutput.TransactionId.Value, fundingOutput.Index.Value)); + + // Register the channel with the signer + _lightningSigner.RegisterChannel(tempChannel.ChannelId, tempChannel.GetSigningInfo()); + + // Generate the base commitment transactions + var remoteCommitmentTransaction = + _commitmentTransactionModelFactory.CreateCommitmentTransactionModel(tempChannel, CommitmentSide.Remote); + + // Build the output and the transactions + var remoteUnsignedCommitmentTransaction = _commitmentTransactionBuilder.Build(remoteCommitmentTransaction); + + // Sign our remote commitment transaction + var ourSignature = + _lightningSigner.SignChannelTransaction(tempChannel.ChannelId, remoteUnsignedCommitmentTransaction); + + // Update the channel with the new signature and the new state + tempChannel.UpdateLastSentSignature(ourSignature); + tempChannel.UpdateState(ChannelState.V1FundingCreated); + + // Save to the database + await PersistChannelAsync(tempChannel); + + // Create the funding created message + var fundingCreatedMessage = + _messageFactory.CreateFundingCreatedMessage(oldChannelId, fundingOutput.TransactionId.Value, + fundingOutput.Index.Value, ourSignature); + + // Add the channel to the dictionary + _channelMemoryRepository.AddChannel(tempChannel); + + // Remove the temporary channel + _channelMemoryRepository.RemoveTemporaryChannel(peerPubKey, oldChannelId); + + return fundingCreatedMessage; + } + catch (Exception e) + { + throw new ChannelErrorException("Error creating commitment transaction", e); + } + } + + /// + /// Persists a channel to the database using a scoped Unit of Work + /// + private async Task PersistChannelAsync(ChannelModel channel) + { + try + { + // Check if the channel already exists + var existingChannel = await _unitOfWork.ChannelDbRepository.GetByIdAsync(channel.ChannelId); + if (existingChannel is not null) + throw new ChannelWarningException("Channel already exists", channel.ChannelId, + "This channel is already in our database"); + + await _unitOfWork.ChannelDbRepository.AddAsync(channel); + await _unitOfWork.SaveChangesAsync(); + + _logger.LogDebug("Successfully persisted channel {ChannelId} to database", channel.ChannelId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to persist channel {ChannelId} to database", channel.ChannelId); + throw; + } + } +} \ No newline at end of file diff --git a/src/NLightning.Application/Channels/Handlers/FundingConfirmedHandler.cs b/src/NLightning.Application/Channels/Handlers/FundingConfirmedMessageHandler.cs similarity index 86% rename from src/NLightning.Application/Channels/Handlers/FundingConfirmedHandler.cs rename to src/NLightning.Application/Channels/Handlers/FundingConfirmedMessageHandler.cs index 3ad6238a..988cb8b7 100644 --- a/src/NLightning.Application/Channels/Handlers/FundingConfirmedHandler.cs +++ b/src/NLightning.Application/Channels/Handlers/FundingConfirmedMessageHandler.cs @@ -12,19 +12,21 @@ namespace NLightning.Application.Channels.Handlers; using Domain.Persistence.Interfaces; using Domain.Protocol.Interfaces; -public class FundingConfirmedHandler +public class FundingConfirmedMessageHandler { private readonly IChannelMemoryRepository _channelMemoryRepository; private readonly ILightningSigner _lightningSigner; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IMessageFactory _messageFactory; private readonly IUnitOfWork _uow; public event EventHandler? OnMessageReady; - public FundingConfirmedHandler(IChannelMemoryRepository channelMemoryRepository, ILightningSigner lightningSigner, - ILogger logger, IMessageFactory messageFactory, - IUnitOfWork uow) + public FundingConfirmedMessageHandler(IChannelMemoryRepository channelMemoryRepository, + ILightningSigner lightningSigner, + ILogger logger, + IMessageFactory messageFactory, + IUnitOfWork uow) { _channelMemoryRepository = channelMemoryRepository; _lightningSigner = lightningSigner; @@ -40,8 +42,9 @@ public async Task HandleAsync(ChannelModel channel) // Check if the channel is in the right state if (channel.State is not (ChannelState.V1FundingSigned or ChannelState.ReadyForThem)) - _logger.LogError("Received funding confirmation, but channel {ChannelId} had a wrong state: {State}", - channel.ChannelId, Enum.GetName(channel.State)); + _logger.LogError( + "Received funding confirmation, but the channel {ChannelId} had a wrong state: {State}", + channel.ChannelId, Enum.GetName(channel.State)); var mustUseScidAlias = channel.ChannelConfig.UseScidAlias > FeatureSupport.No; diff --git a/src/NLightning.Application/Channels/Handlers/FundingCreatedMessageHandler.cs b/src/NLightning.Application/Channels/Handlers/FundingCreatedMessageHandler.cs index 07fb5699..7dd13c4f 100644 --- a/src/NLightning.Application/Channels/Handlers/FundingCreatedMessageHandler.cs +++ b/src/NLightning.Application/Channels/Handlers/FundingCreatedMessageHandler.cs @@ -1,12 +1,10 @@ using Microsoft.Extensions.Logging; -using NLightning.Domain.Bitcoin.Transactions.Enums; -using NLightning.Domain.Bitcoin.Transactions.Interfaces; -using NLightning.Infrastructure.Bitcoin.Builders.Interfaces; -using NLightning.Infrastructure.Bitcoin.Wallet.Interfaces; namespace NLightning.Application.Channels.Handlers; using Domain.Bitcoin.Interfaces; +using Domain.Bitcoin.Transactions.Enums; +using Domain.Bitcoin.Transactions.Interfaces; using Domain.Channels.Enums; using Domain.Channels.Interfaces; using Domain.Channels.Models; @@ -16,6 +14,8 @@ namespace NLightning.Application.Channels.Handlers; using Domain.Persistence.Interfaces; using Domain.Protocol.Interfaces; using Domain.Protocol.Messages; +using Infrastructure.Bitcoin.Builders.Interfaces; +using Infrastructure.Bitcoin.Wallet.Interfaces; using Interfaces; public class FundingCreatedMessageHandler : IChannelMessageHandler @@ -95,15 +95,20 @@ public FundingCreatedMessageHandler(IBlockchainMonitor blockchainMonitor, IChann _lightningSigner.ValidateSignature(channel.ChannelId, payload.Signature, localUnsignedCommitmentTransaction); // Sign our remote commitment transaction - var ourSignature = _lightningSigner.SignTransaction(channel.ChannelId, remoteUnsignedCommitmentTransaction); + var ourSignature = + _lightningSigner.SignChannelTransaction(channel.ChannelId, remoteUnsignedCommitmentTransaction); + // Update the channel with the new signatures and the new state + channel.UpdateLastReceivedSignature(payload.Signature); + channel.UpdateLastSentSignature(ourSignature); channel.UpdateState(ChannelState.V1FundingSigned); + // Save to the database await PersistChannelAsync(channel); // Create the funding signed message var fundingSignedMessage = - _messageFactory.CreatedFundingSignedMessage(channel.ChannelId, ourSignature); + _messageFactory.CreateFundingSignedMessage(channel.ChannelId, ourSignature); // Add the channel to the dictionary _channelMemoryRepository.AddChannel(channel); diff --git a/src/NLightning.Application/Channels/Handlers/FundingSignedMessageHandler.cs b/src/NLightning.Application/Channels/Handlers/FundingSignedMessageHandler.cs new file mode 100644 index 00000000..70dbc149 --- /dev/null +++ b/src/NLightning.Application/Channels/Handlers/FundingSignedMessageHandler.cs @@ -0,0 +1,132 @@ +using Microsoft.Extensions.Logging; + +namespace NLightning.Application.Channels.Handlers; + +using Domain.Bitcoin.Interfaces; +using Domain.Bitcoin.Transactions.Enums; +using Domain.Bitcoin.Transactions.Interfaces; +using Domain.Channels.Enums; +using Domain.Channels.Interfaces; +using Domain.Channels.Models; +using Domain.Crypto.ValueObjects; +using Domain.Exceptions; +using Domain.Node.Options; +using Domain.Persistence.Interfaces; +using Domain.Protocol.Interfaces; +using Domain.Protocol.Messages; +using Infrastructure.Bitcoin.Builders.Interfaces; +using Infrastructure.Bitcoin.Wallet.Interfaces; +using Interfaces; + +public class FundingSignedMessageHandler : IChannelMessageHandler +{ + private readonly IBlockchainMonitor _blockchainMonitor; + private readonly IBitcoinWalletService _bitcoinWalletService; + private readonly IChannelMemoryRepository _channelMemoryRepository; + private readonly ICommitmentTransactionBuilder _commitmentTransactionBuilder; + private readonly ICommitmentTransactionModelFactory _commitmentTransactionModelFactory; + private readonly IFundingTransactionBuilder _fundingTransactionBuilder; + private readonly IFundingTransactionModelFactory _fundingTransactionModelFactory; + private readonly ILightningSigner _lightningSigner; + private readonly ILogger _logger; + private readonly IUnitOfWork _unitOfWork; + private readonly IUtxoMemoryRepository _utxoMemoryRepository; + + public FundingSignedMessageHandler(IBlockchainMonitor blockchainMonitor, IBitcoinWalletService bitcoinWalletService, + IChannelMemoryRepository channelMemoryRepository, + ICommitmentTransactionBuilder commitmentTransactionBuilder, + ICommitmentTransactionModelFactory commitmentTransactionModelFactory, + IFundingTransactionBuilder fundingTransactionBuilder, + IFundingTransactionModelFactory fundingTransactionModelFactory, + ILightningSigner lightningSigner, ILogger logger, + IUnitOfWork unitOfWork, IUtxoMemoryRepository utxoMemoryRepository) + { + _blockchainMonitor = blockchainMonitor; + _bitcoinWalletService = bitcoinWalletService; + _channelMemoryRepository = channelMemoryRepository; + _commitmentTransactionBuilder = commitmentTransactionBuilder; + _commitmentTransactionModelFactory = commitmentTransactionModelFactory; + _fundingTransactionBuilder = fundingTransactionBuilder; + _fundingTransactionModelFactory = fundingTransactionModelFactory; + _lightningSigner = lightningSigner; + _logger = logger; + _unitOfWork = unitOfWork; + _utxoMemoryRepository = utxoMemoryRepository; + } + + public async Task HandleAsync(FundingSignedMessage message, ChannelState currentState, + FeatureOptions negotiatedFeatures, CompactPubKey peerPubKey) + { + _logger.LogTrace("Processing FundingCreatedMessage with ChannelId: {ChannelId} from Peer: {PeerPubKey}", + message.Payload.ChannelId, peerPubKey); + + var payload = message.Payload; + + if (currentState != ChannelState.V1FundingCreated) + throw new ChannelErrorException( + $"Received funding signed, but the channel {payload.ChannelId} had the wrong state: {Enum.GetName(currentState)}"); + + // Check if there's a temporary channel for this peer + if (!_channelMemoryRepository.TryGetChannel(payload.ChannelId, out var channel)) + throw new ChannelErrorException("This channel has never been negotiated", payload.ChannelId); + + // Generate the base commitment transactions + var localCommitmentTransaction = + _commitmentTransactionModelFactory.CreateCommitmentTransactionModel(channel, CommitmentSide.Local); + + // Build the output and the transactions + var localUnsignedCommitmentTransaction = _commitmentTransactionBuilder.Build(localCommitmentTransaction); + + // Validate remote signature for our local commitment transaction + _lightningSigner.ValidateSignature(channel.ChannelId, payload.Signature, localUnsignedCommitmentTransaction); + + // Update the channel with the new signatures and the new state + channel.UpdateLastReceivedSignature(payload.Signature); + channel.UpdateState(ChannelState.V1FundingSigned); + + // Save to the database + await PersistChannelAsync(channel); + + // Get the locked utxos to create the funding transaction + var utxos = _utxoMemoryRepository.GetLockedUtxosForChannel(channel.ChannelId); + + // Get a change address in case we need one + var fundingTransactionModel = _fundingTransactionModelFactory.Create(channel, utxos, channel.ChangeAddress); + var unsignedFundingTransaction = _fundingTransactionBuilder.Build(fundingTransactionModel); + + // Sign the transaction + var allSigned = _lightningSigner.SignFundingTransaction(channel.ChannelId, unsignedFundingTransaction); + if (!allSigned) + throw new ChannelErrorException("Unable to sign all inputs for the funding transaction"); + + await _blockchainMonitor.PublishAndWatchTransactionAsync(channel.ChannelId, unsignedFundingTransaction, + channel.ChannelConfig.MinimumDepth); + + return null; + } + + /// + /// Persists a channel to the database using the scoped Unit of Work + /// + private async Task PersistChannelAsync(ChannelModel channel) + { + try + { + // Check if the channel already exists + var existingChannel = await _unitOfWork.ChannelDbRepository.GetByIdAsync(channel.ChannelId); + if (existingChannel is not null) + throw new ChannelWarningException("Channel already exists", channel.ChannelId, + "This channel is already in our database"); + + await _unitOfWork.ChannelDbRepository.AddAsync(channel); + await _unitOfWork.SaveChangesAsync(); + + _logger.LogDebug("Successfully persisted channel {ChannelId} to database", channel.ChannelId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to persist channel {ChannelId} to database", channel.ChannelId); + throw; + } + } +} \ No newline at end of file diff --git a/src/NLightning.Application/Channels/Managers/ChannelManager.cs b/src/NLightning.Application/Channels/Managers/ChannelManager.cs index f74a88d4..d8ae069b 100644 --- a/src/NLightning.Application/Channels/Managers/ChannelManager.cs +++ b/src/NLightning.Application/Channels/Managers/ChannelManager.cs @@ -95,6 +95,15 @@ public Task RegisterExistingChannelAsync(ChannelModel channel) return await GetChannelMessageHandler(scope) .HandleAsync(openChannel1Message, currentState, negotiatedFeatures, peerPubKey); + case MessageTypes.AcceptChannel: + // Handle the accept channel message + var acceptChannel1Message = message as AcceptChannel1Message + ?? throw new ChannelErrorException( + "Error boxing message to AcceptChannel1Message", + "Sorry, we had an internal error"); + return await GetChannelMessageHandler(scope) + .HandleAsync(acceptChannel1Message, currentState, negotiatedFeatures, peerPubKey); + case MessageTypes.FundingCreated: // Handle the funding-created message var fundingCreatedMessage = message as FundingCreatedMessage @@ -288,7 +297,7 @@ private void HandleFundingConfirmationAsync(object? sender, TransactionConfirmed _channelMemoryRepository.AddChannel(channel); } - var fundingConfirmedHandler = scope.ServiceProvider.GetRequiredService(); + var fundingConfirmedHandler = scope.ServiceProvider.GetRequiredService(); // If we get a response, raise the event with the message fundingConfirmedHandler.OnMessageReady += (_, message) => diff --git a/src/NLightning.Application/DependencyInjection.cs b/src/NLightning.Application/DependencyInjection.cs index 96e5414f..968dc821 100644 --- a/src/NLightning.Application/DependencyInjection.cs +++ b/src/NLightning.Application/DependencyInjection.cs @@ -44,7 +44,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddChannelMessageHandlers(); // Add scoped services - services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/NLightning.Application/Node/Managers/PeerManager.cs b/src/NLightning.Application/Node/Managers/PeerManager.cs index 0d9e350b..368885d8 100644 --- a/src/NLightning.Application/Node/Managers/PeerManager.cs +++ b/src/NLightning.Application/Node/Managers/PeerManager.cs @@ -63,22 +63,33 @@ public async Task StartAsync(CancellationToken cancellationToken) var peers = await uow.GetPeersForStartupAsync(); foreach (var peer in peers) { - _ = await ConnectToPeerAsync(peer.PeerAddressInfo, uow); - if (!_peers.TryGetValue(peer.NodeId, out _)) + try + { + _ = await ConnectToPeerAsync(peer.PeerAddressInfo, uow); + if (!_peers.TryGetValue(peer.NodeId, out _)) + { + _logger.LogWarning("Unable to connect to peer {PeerId} on startup", peer.NodeId); + // TODO: Handle this case, maybe retry or log more details + continue; + } + + // Register channels with peer + if (peer.Channels is not { Count: > 0 }) + continue; + + // Only register channels that are not closed or stale + foreach (var channel in peer.Channels.Where(c => c.State != ChannelState.Closed)) + // We don't care about the result here, as we just want to register the existing channels + _ = _channelManager.RegisterExistingChannelAsync(channel); + } + catch (ConnectionException) { _logger.LogWarning("Unable to connect to peer {PeerId} on startup", peer.NodeId); - // TODO: Handle this case, maybe retry or log more details - continue; } - - // Register channels with peer - if (peer.Channels is not { Count: > 0 }) - continue; - - // Only register channels that are not closed or stale - foreach (var channel in peer.Channels.Where(c => c.State != ChannelState.Closed)) - // We don't care about the result here, as we just want to register the existing channels - _ = _channelManager.RegisterExistingChannelAsync(channel); + catch (Exception e) + { + _logger.LogError(e, "Error connecting to peer {PeerId} on startup", peer.NodeId); + } } await uow.SaveChangesAsync(); @@ -156,6 +167,11 @@ public List ListPeers() return _peers.Values.ToList(); } + public PeerModel? GetPeer(CompactPubKey peerId) + { + return _peers.GetValueOrDefault(peerId); + } + private async Task ConnectToPeerAsync(PeerAddressInfo peerAddressInfo, IUnitOfWork uow) { // Convert and validate the address diff --git a/src/NLightning.Application/Protocol/Factories/MessageFactory.cs b/src/NLightning.Application/Protocol/Factories/MessageFactory.cs index c269e717..da49c13c 100644 --- a/src/NLightning.Application/Protocol/Factories/MessageFactory.cs +++ b/src/NLightning.Application/Protocol/Factories/MessageFactory.cs @@ -628,8 +628,8 @@ channelType is null /// /// /// - public FundingCreatedMessage CreatedFundingCreatedMessage(ChannelId temporaryChannelId, TxId fundingTxId, - ushort fundingOutputIndex, CompactSignature signature) + public FundingCreatedMessage CreateFundingCreatedMessage(ChannelId temporaryChannelId, TxId fundingTxId, + ushort fundingOutputIndex, CompactSignature signature) { var payload = new FundingCreatedPayload(temporaryChannelId, fundingTxId, fundingOutputIndex, signature); @@ -646,7 +646,7 @@ public FundingCreatedMessage CreatedFundingCreatedMessage(ChannelId temporaryCha /// /// /// - public FundingSignedMessage CreatedFundingSignedMessage(ChannelId channelId, CompactSignature signature) + public FundingSignedMessage CreateFundingSignedMessage(ChannelId channelId, CompactSignature signature) { var payload = new FundingSignedPayload(channelId, signature); diff --git a/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs b/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs index 54d65f05..8841c421 100644 --- a/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs +++ b/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs @@ -1,10 +1,12 @@ using System.Buffers; using System.IO.Pipes; using MessagePack; -using NLightning.Domain.Bitcoin.Enums; namespace NLightning.Client.Ipc; +using Domain.Bitcoin.Enums; +using Domain.Client.Enums; +using Domain.Money; using Domain.Node.ValueObjects; using Transport.Ipc; using Transport.Ipc.Requests; @@ -30,7 +32,7 @@ public async Task GetNodeInfoAsync(CancellationToken ct = d var env = new IpcEnvelope { Version = 1, - Command = NodeIpcCommand.NodeInfo, + Command = ClientCommand.NodeInfo, CorrelationId = Guid.NewGuid(), AuthToken = await GetAuthTokenAsync(ct), Payload = payload, @@ -39,9 +41,7 @@ public async Task GetNodeInfoAsync(CancellationToken ct = d var respEnv = await SendAsync(env, ct); if (respEnv.Kind != IpcEnvelopeKind.Error) - { return MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); - } var err = MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); throw new InvalidOperationException($"IPC error {err.Code}: {err.Message}"); @@ -57,7 +57,7 @@ public async Task ConnectPeerAsync(string address, Cance var env = new IpcEnvelope { Version = 1, - Command = NodeIpcCommand.ConnectPeer, + Command = ClientCommand.ConnectPeer, CorrelationId = Guid.NewGuid(), AuthToken = await GetAuthTokenAsync(ct), Payload = payload, @@ -65,10 +65,8 @@ public async Task ConnectPeerAsync(string address, Cance }; var respEnv = await SendAsync(env, ct); - if (respEnv.Kind == IpcEnvelopeKind.Error) - { + if (respEnv.Kind != IpcEnvelopeKind.Error) return MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); - } var err = MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); throw new InvalidOperationException($"IPC error {err.Code}: {err.Message}"); @@ -81,7 +79,7 @@ public async Task ListPeersAsync(CancellationToken ct = de var env = new IpcEnvelope { Version = 1, - Command = NodeIpcCommand.ListPeers, + Command = ClientCommand.ListPeers, CorrelationId = Guid.NewGuid(), AuthToken = await GetAuthTokenAsync(ct), Payload = payload, @@ -90,9 +88,7 @@ public async Task ListPeersAsync(CancellationToken ct = de var respEnv = await SendAsync(env, ct); if (respEnv.Kind != IpcEnvelopeKind.Error) - { return MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); - } var err = MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); throw new InvalidOperationException($"IPC error {err.Code}: {err.Message}"); @@ -118,7 +114,7 @@ public async Task GetAddressAsync(string? addressTypeStri var env = new IpcEnvelope { Version = 1, - Command = NodeIpcCommand.GetAddress, + Command = ClientCommand.GetAddress, CorrelationId = Guid.NewGuid(), AuthToken = await GetAuthTokenAsync(ct), Payload = payload, @@ -127,9 +123,7 @@ public async Task GetAddressAsync(string? addressTypeStri var respEnv = await SendAsync(env, ct); if (respEnv.Kind != IpcEnvelopeKind.Error) - { return MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); - } var err = MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); throw new InvalidOperationException($"IPC error {err.Code}: {err.Message}"); @@ -142,7 +136,7 @@ public async Task GetWalletBalance(CancellationToken c var env = new IpcEnvelope { Version = 1, - Command = NodeIpcCommand.WalletBalance, + Command = ClientCommand.WalletBalance, CorrelationId = Guid.NewGuid(), AuthToken = await GetAuthTokenAsync(ct), Payload = payload, @@ -151,9 +145,34 @@ public async Task GetWalletBalance(CancellationToken c var respEnv = await SendAsync(env, ct); if (respEnv.Kind != IpcEnvelopeKind.Error) - { return MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); - } + + var err = MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); + throw new InvalidOperationException($"IPC error {err.Code}: {err.Message}"); + } + + public async Task OpenChannelAsync(string nodeInfo, string amountSats, + CancellationToken ct = default) + { + var req = new OpenChannelIpcRequest + { + NodeInfo = nodeInfo, + Amount = LightningMoney.Satoshis(Convert.ToInt64(amountSats)) + }; + var payload = MessagePackSerializer.Serialize(req, cancellationToken: ct); + var env = new IpcEnvelope + { + Version = 1, + Command = ClientCommand.OpenChannel, + CorrelationId = Guid.NewGuid(), + AuthToken = await GetAuthTokenAsync(ct), + Payload = payload, + Kind = 0 + }; + + var respEnv = await SendAsync(env, ct); + if (respEnv.Kind != IpcEnvelopeKind.Error) + return MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); var err = MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); throw new InvalidOperationException($"IPC error {err.Code}: {err.Message}"); diff --git a/src/NLightning.Client/Printers/OpenChannelPrinter.cs b/src/NLightning.Client/Printers/OpenChannelPrinter.cs new file mode 100644 index 00000000..f5ea88e5 --- /dev/null +++ b/src/NLightning.Client/Printers/OpenChannelPrinter.cs @@ -0,0 +1,15 @@ +namespace NLightning.Client.Printers; + +using Transport.Ipc.Responses; + +public sealed class OpenChannelPrinter : IPrinter +{ + public void Print(OpenChannelIpcResponse item) + { + Console.WriteLine("Channel opened:"); + Console.WriteLine(" Tx Bytes: {0}", Convert.ToHexString(item.Transaction.RawTxBytes).ToLowerInvariant()); + Console.WriteLine(" Tx Id: {0}", Convert.ToHexString(item.Transaction.TxId).ToLowerInvariant()); + Console.WriteLine(" Index: {0}", item.Index); + Console.WriteLine(" ChannelId: {0}", item.ChannelId); + } +} \ No newline at end of file diff --git a/src/NLightning.Client/Program.cs b/src/NLightning.Client/Program.cs index 505e7fc0..41546ced 100644 --- a/src/NLightning.Client/Program.cs +++ b/src/NLightning.Client/Program.cs @@ -17,9 +17,9 @@ }; // Get network for the NamedPipe file path -var network = CommandLineHelper.GetNetwork(args); -var namedPipeFilePath = NodeUtils.GetNamedPipeFilePath(network); -var cookieFilePath = NodeUtils.GetCookieFilePath(network); +var cookiePath = CommandLineHelper.GetCookiePath(args); +var namedPipeFilePath = NodeUtils.GetNamedPipeFilePath(cookiePath); +var cookieFilePath = NodeUtils.GetCookieFilePath(cookiePath); var cmd = CommandLineHelper.GetCommand(args) ?? "node-info"; @@ -64,6 +64,11 @@ var balance = await client.GetWalletBalance(cts.Token); new WalletBalancePrinter().Print(balance); break; + case "openchannel": + case "open-channel": + var channel = await client.OpenChannelAsync(commandArgs[0], commandArgs[1], cts.Token); + new OpenChannelPrinter().Print(channel); + break; default: Console.Error.WriteLine($"Unknown command: {cmd}"); ClientUtils.ShowUsage(); diff --git a/src/NLightning.Client/Utils/ClientUtils.cs b/src/NLightning.Client/Utils/ClientUtils.cs index c47766ed..363af26b 100644 --- a/src/NLightning.Client/Utils/ClientUtils.cs +++ b/src/NLightning.Client/Utils/ClientUtils.cs @@ -14,8 +14,12 @@ public static void ShowUsage() Console.WriteLine(" --help, -h, -? Show this help message"); Console.WriteLine(); Console.WriteLine("Commands:"); - Console.WriteLine(" node-info | info Get node information via IPC"); - Console.WriteLine(" connect Connect to a peer node"); + Console.WriteLine(" info Get node information via IPC"); + Console.WriteLine(" connect Connect to a peer node"); + Console.WriteLine(" listpeers List all connected peers"); + Console.WriteLine(" getaddress Gets a unused address of the requested type"); + Console.WriteLine(" walletbalance Gets the wallet balance"); + Console.WriteLine(" openchannel Open a channel to peer"); Console.WriteLine(); Console.WriteLine("Environment Variables:"); Console.WriteLine(" NLTG_NETWORK Network to use"); diff --git a/src/NLightning.Daemon.Contracts/Helpers/CommandLineHelper.cs b/src/NLightning.Daemon.Contracts/Helpers/CommandLineHelper.cs index fb4b561d..80ca8161 100644 --- a/src/NLightning.Daemon.Contracts/Helpers/CommandLineHelper.cs +++ b/src/NLightning.Daemon.Contracts/Helpers/CommandLineHelper.cs @@ -5,14 +5,23 @@ namespace NLightning.Daemon.Contracts.Helpers; /// public static class CommandLineHelper { + public const string DashH = "-h"; + public const string DashDashHelp = "--help"; + public const string DashN = "-n"; + public const string DashDashNetwork = "--network"; + public const string DashDashNetworkEquals = "--network="; + public const string DashC = "-c"; + public const string DashDashCookie = "--cookie"; + public const string DashDashCookieEquals = "--cookie="; + /// /// Parse command line arguments to check for help request /// public static bool IsHelpRequested(string[] args) { return args.Any(arg => - arg.Equals("--help", StringComparison.OrdinalIgnoreCase) - || arg.Equals("-h", StringComparison.OrdinalIgnoreCase)); + arg.Equals(DashDashHelp, StringComparison.OrdinalIgnoreCase) + || arg.Equals(DashH, StringComparison.OrdinalIgnoreCase)); } public static string? GetCommand(string[] args) @@ -52,40 +61,87 @@ public static string[] GetCommandArguments(string command, string[] args) return cmdArgs.ToArray(); } - public static string GetNetwork(string[] args) + public static string GetCookiePath(string[] args) { - var network = "mainnet"; // Default + string? network = null; + string? cookiePath = null; // Check command line args for (var i = 0; i < args.Length; i++) { - if (args[i].Equals("--network", StringComparison.OrdinalIgnoreCase) || - args[i].Equals("-n", StringComparison.OrdinalIgnoreCase)) + // Check for network + if (args[i].StartsWith(DashN) || args[i].StartsWith(DashDashNetwork, StringComparison.OrdinalIgnoreCase)) { - if (i + 1 < args.Length) + if ((args[i].Equals(DashDashNetwork, StringComparison.OrdinalIgnoreCase) || args[i].Equals(DashN)) + && i + 1 < args.Length) { network = args[i + 1]; + } + else if (args[i].StartsWith(DashDashNetworkEquals, StringComparison.OrdinalIgnoreCase)) + { + network = args[i][DashDashNetworkEquals.Length..]; + } + + if (network is not null) break; + } + else if (args[i].StartsWith(DashC) || // Check for cookie + args[i].StartsWith(DashDashCookie, StringComparison.OrdinalIgnoreCase)) + { + if ((args[i].Equals(DashDashCookie, StringComparison.OrdinalIgnoreCase) || args[i].Equals(DashC)) + && i + 1 < args.Length) + { + cookiePath = args[i]; + } + else if (args[i].StartsWith(DashDashCookieEquals, StringComparison.OrdinalIgnoreCase)) + { + cookiePath = args[i][DashDashCookieEquals.Length..]; } + + if (cookiePath is not null) + break; } + } - if (!args[i].StartsWith("--network=", StringComparison.OrdinalIgnoreCase)) + // Check the environment if no args provided + if (cookiePath is null && network is null) + { + var envNetwork = Environment.GetEnvironmentVariable("NLTG_NETWORK"); + if (!string.IsNullOrEmpty(envNetwork)) { - continue; + network = envNetwork; + } + else + { + var envCookie = Environment.GetEnvironmentVariable("NLTG_COOKIE"); + if (!string.IsNullOrEmpty(envCookie)) + { + cookiePath = envCookie; + } } - - network = args[i]["--network=".Length..]; - break; } - // Check environment variable if not found in args - var envNetwork = Environment.GetEnvironmentVariable("NLTG_NETWORK"); - if (!string.IsNullOrEmpty(envNetwork)) + // Go with default paths if no environments provided + if (cookiePath is not null) + return ExtractDirectoryFromCookiePath(cookiePath); + + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + cookiePath = Path.Combine(homeDir, ".nltg", network ?? "mainnet"); + return Directory.Exists(cookiePath) ? cookiePath : throw new InvalidOperationException("Cookie not found"); + } + + private static string ExtractDirectoryFromCookiePath(string cookiePath) + { + cookiePath = Path.GetFullPath(cookiePath); + if (cookiePath.EndsWith(".cookie", StringComparison.OrdinalIgnoreCase)) { - network = envNetwork; + cookiePath = Path.GetDirectoryName(cookiePath) ?? + throw new InvalidOperationException("Cookie not found"); } + else if (cookiePath.EndsWith(Path.DirectorySeparatorChar)) + cookiePath = cookiePath[..^1]; - return network; + return Directory.Exists(cookiePath) ? cookiePath : throw new InvalidOperationException("Cookie not found"); } private static bool IsOption(string arg) => arg.StartsWith("-n") diff --git a/src/NLightning.Daemon.Contracts/Utilities/NodeUtils.cs b/src/NLightning.Daemon.Contracts/Utilities/NodeUtils.cs index 15cbc11e..0cc2cfa2 100644 --- a/src/NLightning.Daemon.Contracts/Utilities/NodeUtils.cs +++ b/src/NLightning.Daemon.Contracts/Utilities/NodeUtils.cs @@ -7,22 +7,16 @@ public static class NodeUtils /// /// Gets the path for the Named-Pipe file /// - public static string GetNamedPipeFilePath(string network) + public static string GetNamedPipeFilePath(string cookiePath) { - var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var networkDir = Path.Combine(homeDir, NodeConstants.DaemonFolder, network); - Directory.CreateDirectory(networkDir); // Ensure directory exists - return Path.Combine(networkDir, NodeConstants.NamedPipeFile); + return Path.Combine(cookiePath, NodeConstants.NamedPipeFile); } /// /// Gets the path for the Named-Pipe file /// - public static string GetCookieFilePath(string network) + public static string GetCookieFilePath(string cookiePath) { - var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var networkDir = Path.Combine(homeDir, NodeConstants.DaemonFolder, network); - Directory.CreateDirectory(networkDir); // Ensure directory exists - return Path.Combine(networkDir, NodeConstants.CookieFile); + return Path.Combine(cookiePath, NodeConstants.CookieFile); } } \ No newline at end of file diff --git a/src/NLightning.Daemon/Extensions/NodeConfigurationExtensions.cs b/src/NLightning.Daemon/Extensions/NodeConfigurationExtensions.cs index 763dac01..3a809592 100644 --- a/src/NLightning.Daemon/Extensions/NodeConfigurationExtensions.cs +++ b/src/NLightning.Daemon/Extensions/NodeConfigurationExtensions.cs @@ -33,7 +33,7 @@ public static IHostBuilder ConfigureNltg(this IHostBuilder hostBuilder, IConfigu /// public static IHostBuilder ConfigureNltg(this IHostBuilder hostBuilder, string[] args) { - var config = ReadInitialConfiguration(args); + var (config, _, _) = ReadInitialConfiguration(args); // Configure the host builder return hostBuilder @@ -50,7 +50,7 @@ public static IHostBuilder ConfigureNltg(this IHostBuilder hostBuilder, string[] }); } - public static IConfiguration ReadInitialConfiguration(string[] args) + public static (IConfiguration, string, string) ReadInitialConfiguration(string[] args) { // Get network from the command line or environment variable first var initialConfig = new ConfigurationBuilder() @@ -61,33 +61,43 @@ public static IConfiguration ReadInitialConfiguration(string[] args) // Check for a custom config path first var configPath = initialConfig["config"] ?? initialConfig["c"]; + var configFile = configPath; var usingCustomConfig = !string.IsNullOrEmpty(configPath); if (usingCustomConfig) { configPath = Path.GetFullPath(configPath!); - if (!File.Exists(configPath)) + if (!configPath.EndsWith("json", StringComparison.OrdinalIgnoreCase)) + configFile = Path.Combine(configPath, "appsettings.json"); + else + configPath = Path.GetDirectoryName(configPath); + + if (!File.Exists(configFile)) { - Log.Warning("Custom configuration file not found at {ConfigPath}", configPath); + Log.Warning("Custom configuration file not found at {configFile}", configFile); usingCustomConfig = false; } + + initialConfig = new ConfigurationBuilder() + .AddJsonFile(configFile!, optional: false, reloadOnChange: false) + .Build(); + + network = initialConfig["Node:Network"] ?? "mainnet"; } // If no custom path, use default ~/.nltg/{network}/appsettings.json if (!usingCustomConfig) { var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var configDir = Path.Combine(homeDir, ".nltg", network); - configPath = Path.Combine(configDir, "appsettings.json"); + configPath = Path.Combine(homeDir, ".nltg", network); + configFile = Path.Combine(configPath, "appsettings.json"); // Ensure directory exists - Directory.CreateDirectory(configDir); + Directory.CreateDirectory(configPath); // Create default config if none exists - if (!File.Exists(configPath)) - { - File.WriteAllText(configPath, CreateDefaultConfigJson()); - } + if (!File.Exists(configFile)) + File.WriteAllText(configFile, CreateDefaultConfigJson()); } // Log startup info using bootstrap logger @@ -97,11 +107,13 @@ public static IConfiguration ReadInitialConfiguration(string[] args) var config = new ConfigurationBuilder(); config.Sources.Clear(); - return config - .AddJsonFile(configPath!, optional: false, reloadOnChange: false) - .AddEnvironmentVariables("NLTG_") - .AddCommandLine(args) - .Build(); + var configuration = config + .AddJsonFile(configFile!, optional: false, reloadOnChange: false) + .AddEnvironmentVariables("NLTG_") + .AddCommandLine(args) + .Build(); + + return (configuration, network, configPath!); } /// @@ -166,7 +178,8 @@ private static string CreateDefaultConfigJson() "Database": { "Provider": "Sqlite", "ConnectionString": "Data Source=nltg.db;Cache=Shared", - "RunMigrations": false + "RunMigrations": false, + "EnableSensitiveQueryLogging": false }, "Bitcoin": { "RpcEndpoint": "http://localhost:8332", diff --git a/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs b/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs index f89581cb..f8f77540 100644 --- a/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs +++ b/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs @@ -8,11 +8,17 @@ namespace NLightning.Daemon.Extensions; using Application; using Contracts.Utilities; +using Daemon.Ipc.Handlers; +using Daemon.Ipc.Interfaces; using Domain.Bitcoin.Interfaces; using Domain.Bitcoin.Transactions.Factories; using Domain.Bitcoin.Transactions.Interfaces; using Domain.Channels.Factories; using Domain.Channels.Interfaces; +using Domain.Channels.Validators; +using Domain.Client.Interfaces; +using Domain.Client.Requests; +using Domain.Client.Responses; using Domain.Crypto.Hashes; using Domain.Node.Options; using Domain.Protocol.Interfaces; @@ -37,7 +43,8 @@ public static class NodeServiceExtensions /// /// Registers all NLTG application services for dependency injection /// - public static IHostBuilder ConfigureNltgServices(this IHostBuilder hostBuilder, SecureKeyManager secureKeyManager) + public static IHostBuilder ConfigureNltgServices(this IHostBuilder hostBuilder, SecureKeyManager secureKeyManager, + string configPath) { return hostBuilder.ConfigureServices((hostContext, services) => { @@ -50,7 +57,22 @@ public static IHostBuilder ConfigureNltgServices(this IHostBuilder hostBuilder, // Register the main daemon service services.AddHostedService(); + // Register Client Handlers + services + .AddScoped, + OpenChannelClientHandler>(); + // Register IPC server and handlers + services.AddSingleton(sp => + { + var ipcAuthenticator = sp.GetRequiredService(); + var ipcFraming = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + var nodeOptions = sp.GetRequiredService>(); + var ipcRequestRouter = sp.GetRequiredService(); + return new NamedPipeIpcService(ipcAuthenticator, configPath, ipcFraming, logger, nodeOptions, + ipcRequestRouter); + }); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -59,14 +81,13 @@ public static IHostBuilder ConfigureNltgServices(this IHostBuilder hostBuilder, services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => { - var nodeOptions = sp.GetRequiredService>().Value; - var cookiePath = NodeUtils.GetCookieFilePath(nodeOptions.BitcoinNetwork); + var cookiePath = NodeUtils.GetCookieFilePath(configPath); var logger = sp.GetRequiredService>(); return new CookieFileAuthenticator(cookiePath, logger); }); - services.AddHostedService(); // Add HttpClient for FeeService with configuration services.AddHttpClient(client => @@ -76,16 +97,25 @@ public static IHostBuilder ConfigureNltgServices(this IHostBuilder hostBuilder, }); // Singleton services (one instance throughout the application) + services.AddSingleton(sp => + { + var nodeOptions = sp.GetRequiredService>().Value; + return new ChannelOpenValidator(nodeOptions); + }); services.AddSingleton(secureKeyManager); services.AddSingleton(sp => { + var channelIdFactory = sp.GetRequiredService(); + var channelOpenValidator = sp.GetRequiredService(); var feeService = sp.GetRequiredService(); var lightningSigner = sp.GetRequiredService(); var nodeOptions = sp.GetRequiredService>().Value; var sha256 = sp.GetRequiredService(); - return new ChannelFactory(feeService, lightningSigner, nodeOptions, sha256); + return new ChannelFactory(channelIdFactory, channelOpenValidator, feeService, lightningSigner, + nodeOptions, sha256); }); services.AddSingleton(); + services.AddSingleton(); // Add the Signer services.AddSingleton(serviceProvider => @@ -94,10 +124,11 @@ public static IHostBuilder ConfigureNltgServices(this IHostBuilder hostBuilder, var keyDerivationService = serviceProvider.GetRequiredService(); var logger = serviceProvider.GetRequiredService>(); var nodeOptions = serviceProvider.GetRequiredService>().Value; + var utxoMemoryRepository = serviceProvider.GetRequiredService(); // Create the signer with the correct network return new LocalLightningSigner(fundingOutputBuilder, keyDerivationService, logger, nodeOptions, - secureKeyManager); + secureKeyManager, utxoMemoryRepository); }); // Add the Application services diff --git a/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs b/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs new file mode 100644 index 00000000..3520da37 --- /dev/null +++ b/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs @@ -0,0 +1,203 @@ +using Microsoft.Extensions.Logging; + +namespace NLightning.Daemon.Handlers; + +using Domain.Bitcoin.Interfaces; +using Domain.Channels.Interfaces; +using Domain.Channels.ValueObjects; +using Domain.Client.Constants; +using Domain.Client.Enums; +using Domain.Client.Exceptions; +using Domain.Client.Requests; +using Domain.Client.Responses; +using Domain.Crypto.ValueObjects; +using Domain.Enums; +using Domain.Node; +using Domain.Node.Events; +using Domain.Node.Interfaces; +using Domain.Node.ValueObjects; +using Domain.Persistence.Interfaces; +using Domain.Protocol.Constants; +using Domain.Protocol.Interfaces; +using Domain.Protocol.Tlv; +using Infrastructure.Bitcoin.Wallet.Interfaces; +using Infrastructure.Protocol.Models; +using Interfaces; + +public sealed class OpenChannelClientHandler + : IClientCommandHandler +{ + private readonly IBlockchainMonitor _blockchainMonitor; + private readonly IChannelMemoryRepository _channelMemoryRepository; + private readonly IChannelFactory _channelFactory; + private readonly ILogger _logger; + private readonly IMessageFactory _messageFactory; + private readonly IPeerManager _peerManager; + private readonly IUnitOfWork _unitOfWork; + private readonly IUtxoMemoryRepository _utxoMemoryRepository; + + public ClientCommand Command => ClientCommand.OpenChannel; + + public OpenChannelClientHandler(IBlockchainMonitor blockchainMonitor, + IChannelMemoryRepository channelMemoryRepository, IChannelFactory channelFactory, + ILogger logger, IMessageFactory messageFactory, + IPeerManager peerManager, IUnitOfWork unitOfWork, + IUtxoMemoryRepository utxoMemoryRepository) + { + _blockchainMonitor = blockchainMonitor; + _channelMemoryRepository = channelMemoryRepository; + _channelFactory = channelFactory; + _logger = logger; + _messageFactory = messageFactory; + _peerManager = peerManager; + _unitOfWork = unitOfWork; + _utxoMemoryRepository = utxoMemoryRepository; + } + + public async Task HandleAsync(OpenChannelClientRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.NodeInfo)) + throw new ClientException(ErrorCodes.InvalidAddress, "Address cannot be empty"); + + // Check if either a PeerAddressInfo or a CompactPubKey was provided + var isPeerAddressInfo = request.NodeInfo.Contains('@') && request.NodeInfo.Contains(':'); + CompactPubKey peerId; + + if (isPeerAddressInfo) + peerId = new PeerAddress(request.NodeInfo).PubKey; + + // Check if we're connected to the peer + var peer = _peerManager.GetPeer(peerId) + ?? await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(request.NodeInfo)); + + // Let's check if we have enough funds to open this channel + var currentHeight = _blockchainMonitor.LastProcessedBlockHeight; + if (_utxoMemoryRepository.GetConfirmedBalance(currentHeight) < request.FundingAmount) + throw new ClientException(ErrorCodes.NotEnoughBalance, "We don't have enough balance to open this channel"); + + // Since we're connected, let's open the channel + var channel = + await _channelFactory.CreateChannelV1AsInitiatorAsync(request, peer.NegotiatedFeatures, peerId); + + _logger.LogTrace("Created Channel {id} with fundingPubKey: {fundingPubKey}", channel.ChannelId, + channel.LocalKeySet.FundingCompactPubKey); + + try + { + // TODO: Set the channel reserve as 1% of the channel or at least 354 sats + // Add the channel to dictionaries + _channelMemoryRepository.AddTemporaryChannel(peerId, channel); + + // Select UTXOs and mark them as toSpend for this channel + var utxos = _utxoMemoryRepository.LockUtxosToSpendOnChannel(request.FundingAmount, channel.ChannelId); + + // Create a FeatureSet for the ChannelTypeTlv + var featureSet = new FeatureSet(); + featureSet.SetFeature(Feature.VarOnionOptin, false, false); + + // Set StaticRemoteKey if needed + if (peer.NegotiatedFeatures.StaticRemoteKey == FeatureSupport.Compulsory) + featureSet.SetFeature(Feature.OptionStaticRemoteKey, true); + + // Set OptionAnchorOutputs if needed + if (peer.NegotiatedFeatures.AnchorOutputs == FeatureSupport.Compulsory) + featureSet.SetFeature(Feature.OptionAnchorOutputs, true); + + // Create UpfrontShutdownScriptTlv if needed + UpfrontShutdownScriptTlv? upfrontShutdownScriptTlv = null; + if (channel.LocalUpfrontShutdownScript is not null) + upfrontShutdownScriptTlv = new UpfrontShutdownScriptTlv(channel.LocalUpfrontShutdownScript.Value); + + // Create the ChannelFlags + var channelFlags = new ChannelFlags(ChannelFlag.None); + if (peer.Features.IsFeatureSet(Feature.OptionScidAlias, true)) + { + featureSet.SetFeature(Feature.OptionScidAlias, true); + channelFlags = new ChannelFlags(ChannelFlag.AnnounceChannel); + } + + // Create the ChannelTypeTlv + ChannelTypeTlv? channelTypeTlv = null; + var featureSetBytes = featureSet.GetBytes(); + if (featureSetBytes is not null) + channelTypeTlv = new ChannelTypeTlv(featureSetBytes); + + // Create the openChannel message + var openChannel1Message = _messageFactory.CreateOpenChannel1Message( + channel.ChannelId, channel.LocalBalance, channel.LocalKeySet.FundingCompactPubKey, + channel.RemoteBalance, channel.ChannelConfig.ChannelReserveAmount!, + channel.ChannelConfig.FeeRateAmountPerKw, + channel.ChannelConfig.MaxAcceptedHtlcs, channel.LocalKeySet.RevocationCompactBasepoint, + channel.LocalKeySet.PaymentCompactBasepoint, channel.LocalKeySet.DelayedPaymentCompactBasepoint, + channel.LocalKeySet.HtlcCompactBasepoint, channel.LocalKeySet.CurrentPerCommitmentCompactPoint, + channelFlags, upfrontShutdownScriptTlv, channelTypeTlv); + + if (!peer.TryGetPeerService(out var peerService)) + throw new ClientException(ErrorCodes.InvalidOperation, "Error getting peerService from peer"); + + var tsc = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + peerService.OnChannelMessageReceived += ChannelMessageHandlerEnvelope; + + try + { + await peerService.SendMessageAsync(openChannel1Message); + } + catch + { + //Unsubscribe from the event so we don't have dangling memory + peerService.OnChannelMessageReceived -= ChannelMessageHandlerEnvelope; + + throw; + } + + // Since everything went ok so far, let's update the locked utxos on the database + foreach (var utxo in utxos) + _unitOfWork.UtxoDbRepository.Update(utxo); + + await _unitOfWork.SaveChangesAsync(); + + var response = await tsc.Task; + + // Unsubscribe from the event + peerService.OnChannelMessageReceived -= ChannelMessageHandlerEnvelope; + + return response; + + // + void ChannelMessageHandlerEnvelope(object? _, ChannelMessageEventArgs args) + { + HandleChannelMessage(args, channel.ChannelId, tsc); + } + } + catch + { + var utxos = _utxoMemoryRepository.ReturnUtxosNotSpentOnChannel(channel.ChannelId); + + // Since something went wrong, let's unlock the utxos on the database + foreach (var utxo in utxos) + _unitOfWork.UtxoDbRepository.Update(utxo); + + await _unitOfWork.SaveChangesAsync(); + + throw; + } + } + + private void HandleChannelMessage(ChannelMessageEventArgs args, ChannelId channelId, + TaskCompletionSource tcs) + { + if (args.Message.Type == MessageTypes.AcceptChannel) + { + Console.WriteLine("Channel accepted"); + } + else if (args.Message.Type == MessageTypes.FundingSigned) + { + Console.WriteLine("Funding signed"); + } + else + { + Console.WriteLine("Unknown message type: {0}", Enum.GetName(args.Message.Type)); + } + } +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Interfaces/IClientCommandHandler.cs b/src/NLightning.Daemon/Interfaces/IClientCommandHandler.cs new file mode 100644 index 00000000..f1c4ca4a --- /dev/null +++ b/src/NLightning.Daemon/Interfaces/IClientCommandHandler.cs @@ -0,0 +1,9 @@ +namespace NLightning.Daemon.Interfaces; + +using NLightning.Domain.Client.Enums; + +public interface IClientCommandHandler +{ + ClientCommand Command { get; } + Task HandleAsync(TRequest request, CancellationToken ct); +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Interfaces/IIpcCommandHandler.cs b/src/NLightning.Daemon/Interfaces/IIpcCommandHandler.cs deleted file mode 100644 index fec12e46..00000000 --- a/src/NLightning.Daemon/Interfaces/IIpcCommandHandler.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace NLightning.Daemon.Interfaces; - -using Transport.Ipc; - -public interface IIpcCommandHandler -{ - NodeIpcCommand Command { get; } - Task HandleAsync(IpcEnvelope envelope, CancellationToken ct); -} \ No newline at end of file diff --git a/src/NLightning.Daemon/Handlers/ConnectPeerIpcHandler.cs b/src/NLightning.Daemon/Ipc/Handlers/ConnectPeerIpcHandler.cs similarity index 90% rename from src/NLightning.Daemon/Handlers/ConnectPeerIpcHandler.cs rename to src/NLightning.Daemon/Ipc/Handlers/ConnectPeerIpcHandler.cs index a536ca06..fc9524cc 100644 --- a/src/NLightning.Daemon/Handlers/ConnectPeerIpcHandler.cs +++ b/src/NLightning.Daemon/Ipc/Handlers/ConnectPeerIpcHandler.cs @@ -1,25 +1,26 @@ using MessagePack; using Microsoft.Extensions.Logging; -namespace NLightning.Daemon.Handlers; +namespace NLightning.Daemon.Ipc.Handlers; +using Domain.Client.Constants; +using Domain.Client.Enums; using Domain.Exceptions; using Domain.Node.Interfaces; using Interfaces; using Services.Ipc.Factories; using Transport.Ipc; -using Transport.Ipc.Constants; using Transport.Ipc.Requests; using Transport.Ipc.Responses; -public sealed class ConnectPeerIpcHandler : IIpcCommandHandler +internal sealed class ConnectPeerIpcHandler : IIpcCommandHandler { - private readonly IPeerManager _peerManager; private readonly ILogger _logger; + private readonly IPeerManager _peerManager; - public NodeIpcCommand Command => NodeIpcCommand.ConnectPeer; + public ClientCommand Command => ClientCommand.ConnectPeer; - public ConnectPeerIpcHandler(IPeerManager peerManager, ILogger logger) + public ConnectPeerIpcHandler(ILogger logger, IPeerManager peerManager) { _peerManager = peerManager; _logger = logger; diff --git a/src/NLightning.Daemon/Handlers/GetAddressIpcHandler.cs b/src/NLightning.Daemon/Ipc/Handlers/GetAddressIpcHandler.cs similarity index 84% rename from src/NLightning.Daemon/Handlers/GetAddressIpcHandler.cs rename to src/NLightning.Daemon/Ipc/Handlers/GetAddressIpcHandler.cs index 12133a29..fc09b0c4 100644 --- a/src/NLightning.Daemon/Handlers/GetAddressIpcHandler.cs +++ b/src/NLightning.Daemon/Ipc/Handlers/GetAddressIpcHandler.cs @@ -2,23 +2,24 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace NLightning.Daemon.Handlers; +namespace NLightning.Daemon.Ipc.Handlers; using Domain.Bitcoin.Enums; +using Domain.Client.Constants; +using Domain.Client.Enums; using Infrastructure.Bitcoin.Wallet.Interfaces; using Interfaces; using Services.Ipc.Factories; using Transport.Ipc; -using Transport.Ipc.Constants; using Transport.Ipc.Requests; using Transport.Ipc.Responses; -public class GetAddressIpcHandler : IIpcCommandHandler +internal class GetAddressIpcHandler : IIpcCommandHandler { private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; - public NodeIpcCommand Command => NodeIpcCommand.GetAddress; + public ClientCommand Command => ClientCommand.GetAddress; public GetAddressIpcHandler(ILogger logger, IServiceProvider serviceProvider) { @@ -45,10 +46,10 @@ public async Task HandleAsync(IpcEnvelope envelope, CancellationTok // Get unused addresses by type if (request.AddressType.HasFlag(AddressType.P2Tr)) - p2Tr = await walletAddressService.GetUnusedAddressAsync(AddressType.P2Tr, false); + p2Tr = (await walletAddressService.GetUnusedAddressAsync(AddressType.P2Tr, false)).Address; if (request.AddressType.HasFlag(AddressType.P2Wpkh)) - p2Wpkh = await walletAddressService.GetUnusedAddressAsync(AddressType.P2Wpkh, false); + p2Wpkh = (await walletAddressService.GetUnusedAddressAsync(AddressType.P2Wpkh, false)).Address; // Create a success response var response = new GetAddressIpcResponse diff --git a/src/NLightning.Daemon/Handlers/GetWalletBalanceIpcHandler.cs b/src/NLightning.Daemon/Ipc/Handlers/GetWalletBalanceIpcHandler.cs similarity index 90% rename from src/NLightning.Daemon/Handlers/GetWalletBalanceIpcHandler.cs rename to src/NLightning.Daemon/Ipc/Handlers/GetWalletBalanceIpcHandler.cs index 233ebd41..c3ae9ee3 100644 --- a/src/NLightning.Daemon/Handlers/GetWalletBalanceIpcHandler.cs +++ b/src/NLightning.Daemon/Ipc/Handlers/GetWalletBalanceIpcHandler.cs @@ -1,22 +1,23 @@ using MessagePack; using Microsoft.Extensions.Logging; -namespace NLightning.Daemon.Handlers; +namespace NLightning.Daemon.Ipc.Handlers; using Domain.Bitcoin.Interfaces; +using Domain.Client.Constants; +using Domain.Client.Enums; using Infrastructure.Bitcoin.Wallet.Interfaces; using Interfaces; using Services.Ipc.Factories; using Transport.Ipc; -using Transport.Ipc.Constants; using Transport.Ipc.Responses; -public class GetWalletBalanceIpcHandler : IIpcCommandHandler +internal class GetWalletBalanceIpcHandler : IIpcCommandHandler { private readonly IBlockchainMonitor _blockchainMonitor; private readonly ILogger _logger; private readonly IUtxoMemoryRepository _utxoMemoryRepository; - public NodeIpcCommand Command => NodeIpcCommand.WalletBalance; + public ClientCommand Command => ClientCommand.WalletBalance; public GetWalletBalanceIpcHandler(IBlockchainMonitor blockchainMonitor, ILogger logger, IUtxoMemoryRepository utxoMemoryRepository) diff --git a/src/NLightning.Daemon/Handlers/ListPeersIpcHandler.cs b/src/NLightning.Daemon/Ipc/Handlers/ListPeersIpcHandler.cs similarity index 90% rename from src/NLightning.Daemon/Handlers/ListPeersIpcHandler.cs rename to src/NLightning.Daemon/Ipc/Handlers/ListPeersIpcHandler.cs index b797465f..9a143094 100644 --- a/src/NLightning.Daemon/Handlers/ListPeersIpcHandler.cs +++ b/src/NLightning.Daemon/Ipc/Handlers/ListPeersIpcHandler.cs @@ -1,21 +1,22 @@ using MessagePack; using Microsoft.Extensions.Logging; -namespace NLightning.Daemon.Handlers; +namespace NLightning.Daemon.Ipc.Handlers; +using Domain.Client.Constants; +using Domain.Client.Enums; using Domain.Node.Interfaces; using Interfaces; using Services.Ipc.Factories; using Transport.Ipc; -using Transport.Ipc.Constants; using Transport.Ipc.Responses; -public class ListPeersIpcHandler : IIpcCommandHandler +internal class ListPeersIpcHandler : IIpcCommandHandler { private readonly IPeerManager _peerManager; private readonly ILogger _logger; - public NodeIpcCommand Command => NodeIpcCommand.ListPeers; + public ClientCommand Command => ClientCommand.ListPeers; public ListPeersIpcHandler(IPeerManager peerManager, ILogger logger) { diff --git a/src/NLightning.Daemon/Handlers/NodeInfoIpcHandler.cs b/src/NLightning.Daemon/Ipc/Handlers/NodeInfoIpcHandler.cs similarity index 86% rename from src/NLightning.Daemon/Handlers/NodeInfoIpcHandler.cs rename to src/NLightning.Daemon/Ipc/Handlers/NodeInfoIpcHandler.cs index 6c75d0cf..332804f0 100644 --- a/src/NLightning.Daemon/Handlers/NodeInfoIpcHandler.cs +++ b/src/NLightning.Daemon/Ipc/Handlers/NodeInfoIpcHandler.cs @@ -1,21 +1,23 @@ using MessagePack; using Microsoft.Extensions.Logging; -namespace NLightning.Daemon.Handlers; +namespace NLightning.Daemon.Ipc.Handlers; +using Daemon.Interfaces; +using Domain.Client.Constants; +using Domain.Client.Enums; using Domain.Crypto.ValueObjects; using Interfaces; using Services.Ipc.Factories; using Transport.Ipc; -using Transport.Ipc.Constants; using Transport.Ipc.Responses; -public sealed class NodeInfoIpcHandler : IIpcCommandHandler +internal sealed class NodeInfoIpcHandler : IIpcCommandHandler { private readonly INodeInfoQueryService _query; private readonly ILogger _logger; - public NodeIpcCommand Command => NodeIpcCommand.NodeInfo; + public ClientCommand Command => ClientCommand.NodeInfo; public NodeInfoIpcHandler(INodeInfoQueryService query, ILogger logger) { diff --git a/src/NLightning.Daemon/Ipc/Handlers/OpenChannelIpcHandler.cs b/src/NLightning.Daemon/Ipc/Handlers/OpenChannelIpcHandler.cs new file mode 100644 index 00000000..d36837d2 --- /dev/null +++ b/src/NLightning.Daemon/Ipc/Handlers/OpenChannelIpcHandler.cs @@ -0,0 +1,99 @@ +using MessagePack; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace NLightning.Daemon.Ipc.Handlers; + +using Daemon.Handlers; +using Daemon.Interfaces; +using Domain.Client.Constants; +using Domain.Client.Enums; +using Domain.Client.Exceptions; +using Domain.Client.Requests; +using Domain.Client.Responses; +using Domain.Exceptions; +using Interfaces; +using Services.Ipc.Factories; +using Transport.Ipc; +using Transport.Ipc.Requests; +using Transport.Ipc.Responses; + +internal sealed class OpenChannelIpcHandler : IIpcCommandHandler +{ + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + + public ClientCommand Command => ClientCommand.OpenChannel; + + public OpenChannelIpcHandler(ILogger logger, IServiceProvider serviceProvider) + { + _logger = logger; + _serviceProvider = serviceProvider; + } + + public async Task HandleAsync(IpcEnvelope envelope, CancellationToken ct) + { + try + { + // Deserialize the request + var request = + MessagePackSerializer.Deserialize(envelope.Payload, cancellationToken: ct); + + // Get the client handler + using var scope = _serviceProvider.CreateScope(); + var openChannelClientHandler = + scope.ServiceProvider.GetService( + typeof(IClientCommandHandler)) as + OpenChannelClientHandler ?? + throw new InvalidOperationException($"Unable to get service {nameof(OpenChannelClientHandler)}"); + + var clientResponse = await openChannelClientHandler.HandleAsync(request.ToClientRequest(), ct); + + var payload = MessagePackSerializer.Serialize(OpenChannelIpcResponse.FromClientResponse(clientResponse), + cancellationToken: ct); + return new IpcEnvelope + { + Version = envelope.Version, + Command = envelope.Command, + CorrelationId = envelope.CorrelationId, + Kind = IpcEnvelopeKind.Response, + Payload = payload + }; + } + catch (ClientException ce) + { + _logger.LogError(ce, "Error while handling OpenChannel"); + return IpcErrorFactory.CreateErrorEnvelope(envelope, ce.Message, ce.Message); + } + catch (FormatException fe) + { + _logger.LogWarning(fe, "Invalid peer address format"); + return IpcErrorFactory.CreateErrorEnvelope(envelope, ErrorCodes.InvalidAddress, + $"Invalid address format: {fe.Message}"); + } + catch (InvalidOperationException oe) + { + _logger.LogError(oe, "The operation could not be completed"); + return IpcErrorFactory.CreateErrorEnvelope(envelope, ErrorCodes.InvalidOperation, + $"The operation could not be completed: {oe.Message}"); + } + catch (ConnectionException ce) + { + _logger.LogError(ce, "Failed to connect to peer"); + return IpcErrorFactory.CreateErrorEnvelope(envelope, ErrorCodes.ConnectionError, + $"Connection failed: {ce.Message}"); + } + catch (ChannelErrorException cee) + { + _logger.LogError(cee, "Error opening Channel"); + return IpcErrorFactory.CreateErrorEnvelope(envelope, ErrorCodes.ConnectionError, + $"Channel Error: {cee.Message}"); + } + catch (Exception e) + { + _logger.LogError(e, "Error connecting to peer"); + return IpcErrorFactory.CreateErrorEnvelope(envelope, ErrorCodes.ServerError, + $"Error connecting to peer: {e.Message}"); + } + } +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Interfaces/IIpcAuthenticator.cs b/src/NLightning.Daemon/Ipc/Interfaces/IIpcAuthenticator.cs similarity index 50% rename from src/NLightning.Daemon/Interfaces/IIpcAuthenticator.cs rename to src/NLightning.Daemon/Ipc/Interfaces/IIpcAuthenticator.cs index 9094c7fe..4e9d7efc 100644 --- a/src/NLightning.Daemon/Interfaces/IIpcAuthenticator.cs +++ b/src/NLightning.Daemon/Ipc/Interfaces/IIpcAuthenticator.cs @@ -1,6 +1,6 @@ -namespace NLightning.Daemon.Interfaces; +namespace NLightning.Daemon.Ipc.Interfaces; -public interface IIpcAuthenticator +internal interface IIpcAuthenticator { Task ValidateAsync(string? token, CancellationToken ct = default); } \ No newline at end of file diff --git a/src/NLightning.Daemon/Ipc/Interfaces/IIpcCommandHandler.cs b/src/NLightning.Daemon/Ipc/Interfaces/IIpcCommandHandler.cs new file mode 100644 index 00000000..8db60cdb --- /dev/null +++ b/src/NLightning.Daemon/Ipc/Interfaces/IIpcCommandHandler.cs @@ -0,0 +1,10 @@ +namespace NLightning.Daemon.Ipc.Interfaces; + +using Domain.Client.Enums; +using Transport.Ipc; + +internal interface IIpcCommandHandler +{ + ClientCommand Command { get; } + Task HandleAsync(IpcEnvelope envelope, CancellationToken ct); +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Interfaces/IIpcFraming.cs b/src/NLightning.Daemon/Ipc/Interfaces/IIpcFraming.cs similarity index 70% rename from src/NLightning.Daemon/Interfaces/IIpcFraming.cs rename to src/NLightning.Daemon/Ipc/Interfaces/IIpcFraming.cs index 158b5628..56eaaf2b 100644 --- a/src/NLightning.Daemon/Interfaces/IIpcFraming.cs +++ b/src/NLightning.Daemon/Ipc/Interfaces/IIpcFraming.cs @@ -1,8 +1,8 @@ -namespace NLightning.Daemon.Interfaces; +namespace NLightning.Daemon.Ipc.Interfaces; using Transport.Ipc; -public interface IIpcFraming +internal interface IIpcFraming { Task ReadAsync(Stream stream, CancellationToken ct); Task WriteAsync(Stream stream, IpcEnvelope envelope, CancellationToken ct); diff --git a/src/NLightning.Daemon/Interfaces/IIpcRequestRouter.cs b/src/NLightning.Daemon/Ipc/Interfaces/IIpcRequestRouter.cs similarity index 55% rename from src/NLightning.Daemon/Interfaces/IIpcRequestRouter.cs rename to src/NLightning.Daemon/Ipc/Interfaces/IIpcRequestRouter.cs index 880c65e3..c7649724 100644 --- a/src/NLightning.Daemon/Interfaces/IIpcRequestRouter.cs +++ b/src/NLightning.Daemon/Ipc/Interfaces/IIpcRequestRouter.cs @@ -1,8 +1,8 @@ -namespace NLightning.Daemon.Interfaces; +namespace NLightning.Daemon.Ipc.Interfaces; using Transport.Ipc; -public interface IIpcRequestRouter +internal interface IIpcRequestRouter { Task RouteAsync(IpcEnvelope request, CancellationToken ct); } \ No newline at end of file diff --git a/src/NLightning.Daemon/Program.cs b/src/NLightning.Daemon/Program.cs index 3765f342..97cf00d9 100644 --- a/src/NLightning.Daemon/Program.cs +++ b/src/NLightning.Daemon/Program.cs @@ -29,9 +29,11 @@ Log.Logger.Error("An unhandled exception occurred: {exception}", exception); }; - // Get network for the PID file path - var network = CommandLineHelper.GetNetwork(args); - var pidFilePath = DaemonUtils.GetPidFilePath(network); + // Read the configuration file to check for daemon setting + var (initialConfig, network, configPath) = NodeConfigurationExtensions.ReadInitialConfiguration(args); + + // Get PID file path + var pidFilePath = DaemonUtils.GetPidFilePath(configPath); // Check for the stop command if (DaemonUtils.IsStopRequested(args)) @@ -54,9 +56,6 @@ return 0; } - // Read the configuration file to check for daemon setting - var initialConfig = NodeConfigurationExtensions.ReadInitialConfiguration(args); - string? password = null; // Try to get password from args or prompt @@ -79,13 +78,13 @@ } SecureKeyManager keyManager; - var keyFilePath = SecureKeyManager.GetKeyFilePath(network); + var keyFilePath = SecureKeyManager.GetKeyFilePath(configPath); if (!File.Exists(keyFilePath)) { // Get current Block Height for key birth try { - // Create logger for the wallet service using Serilog + // Create the logger for the wallet service using Serilog var loggerFactory = LoggerFactory.Create(b => b.AddSerilog(Log.Logger, dispose: false)); var walletLogger = loggerFactory.CreateLogger(); @@ -97,10 +96,8 @@ ?? throw new InvalidOperationException("Node configuration section is missing or invalid."); // Instantiate the service - var bitcoinWalletService = new BitcoinChainService( - Options.Create(bitcoinOptions), - walletLogger, - Options.Create(nodeOptions) + var bitcoinWalletService = new BitcoinChainService(Options.Create(bitcoinOptions), walletLogger, + Options.Create(nodeOptions) ); var heightOfBirth = await bitcoinWalletService.GetCurrentBlockHeightAsync(); @@ -139,7 +136,7 @@ // Create and run host var host = Host.CreateDefaultBuilder(args) .ConfigureNltg(initialConfig) - .ConfigureNltgServices(keyManager) + .ConfigureNltgServices(keyManager, configPath) .Build(); // Run migrations if configured diff --git a/src/NLightning.Daemon/Services/Ipc/CookieFileAuthenticator.cs b/src/NLightning.Daemon/Services/Ipc/CookieFileAuthenticator.cs index d518d240..bd0a9395 100644 --- a/src/NLightning.Daemon/Services/Ipc/CookieFileAuthenticator.cs +++ b/src/NLightning.Daemon/Services/Ipc/CookieFileAuthenticator.cs @@ -3,7 +3,7 @@ namespace NLightning.Daemon.Services.Ipc; -using Interfaces; +using Daemon.Ipc.Interfaces; /// /// Cookie-file-based authenticator (Bitcoin Core style). Uses constant-time comparison. diff --git a/src/NLightning.Daemon/Services/Ipc/IpcFraming.cs b/src/NLightning.Daemon/Services/Ipc/IpcFraming.cs index eb093ae7..596e73f7 100644 --- a/src/NLightning.Daemon/Services/Ipc/IpcFraming.cs +++ b/src/NLightning.Daemon/Services/Ipc/IpcFraming.cs @@ -3,8 +3,8 @@ namespace NLightning.Daemon.Services.Ipc; -using Interfaces; -using NLightning.Transport.Ipc; +using Daemon.Ipc.Interfaces; +using Transport.Ipc; /// /// Length-prefixed MessagePack framing for IpcEnvelope. diff --git a/src/NLightning.Daemon/Services/Ipc/IpcRouting.cs b/src/NLightning.Daemon/Services/Ipc/IpcRouting.cs index b949bff4..cba0a28e 100644 --- a/src/NLightning.Daemon/Services/Ipc/IpcRouting.cs +++ b/src/NLightning.Daemon/Services/Ipc/IpcRouting.cs @@ -3,15 +3,16 @@ namespace NLightning.Daemon.Services.Ipc; -using Interfaces; -using NLightning.Transport.Ipc; +using Daemon.Ipc.Interfaces; +using Domain.Client.Enums; +using Transport.Ipc; /// /// Default router that uses a map of handlers keyed by command. /// -public sealed class IpcRequestRouter : IIpcRequestRouter +internal sealed class IpcRequestRouter : IIpcRequestRouter { - private readonly IReadOnlyDictionary _handlers; + private readonly IReadOnlyDictionary _handlers; private readonly ILogger _logger; public IpcRequestRouter(IEnumerable handlers, ILogger logger) diff --git a/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcHostedService.cs b/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcService.cs similarity index 51% rename from src/NLightning.Daemon/Services/Ipc/NamedPipeIpcHostedService.cs rename to src/NLightning.Daemon/Services/Ipc/NamedPipeIpcService.cs index da1945a0..8cbb365f 100644 --- a/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcHostedService.cs +++ b/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcService.cs @@ -1,69 +1,106 @@ using System.IO.Pipes; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using NLightning.Daemon.Services.Ipc.Factories; namespace NLightning.Daemon.Services.Ipc; using Contracts.Utilities; +using Daemon.Ipc.Interfaces; +using Domain.Client.Constants; +using Domain.Client.Interfaces; using Domain.Node.Options; -using Interfaces; +using Factories; using Transport.Ipc; -using Transport.Ipc.Constants; /// /// Hosted service that listens to on a named pipe and processes IPC requests using injected components. /// -public sealed class NamedPipeIpcHostedService : BackgroundService +internal sealed class NamedPipeIpcService : INamedPipeIpcService { - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IIpcAuthenticator _authenticator; private readonly IIpcFraming _framing; private readonly IIpcRequestRouter _router; - private readonly string _pipeName; private readonly string _cookiePath; - public NamedPipeIpcHostedService(ILogger logger, IIpcAuthenticator authenticator, - IIpcFraming framing, IIpcRequestRouter router, IOptions nodeOptions) + private CancellationTokenSource? _cts; + private Task? _listenerTask; + + public NamedPipeIpcService(IIpcAuthenticator authenticator, string configPath, IIpcFraming framing, + ILogger logger, IOptions nodeOptions, + IIpcRequestRouter router) { _logger = logger; _authenticator = authenticator; _framing = framing; _router = router; - _pipeName = NodeUtils.GetNamedPipeFilePath(nodeOptions.Value.BitcoinNetwork); - _cookiePath = NodeUtils.GetCookieFilePath(nodeOptions.Value.BitcoinNetwork); + _pipeName = NodeUtils.GetNamedPipeFilePath(configPath); + _cookiePath = NodeUtils.GetCookieFilePath(configPath); } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) + public Task StartAsync(CancellationToken cancellationToken) { - _logger.LogInformation("IPC server starting on pipe {Pipe}", _pipeName); + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + EnsureCookieExists(); - while (!stoppingToken.IsCancellationRequested) + _listenerTask = ListenToIpcClientAsync(cancellationToken); + + return Task.CompletedTask; + } + + public async Task StopAsync() + { + if (_cts is null) + throw new InvalidOperationException("Service is not running"); + + await _cts.CancelAsync(); + + if (_listenerTask is not null) { try { - var server = new NamedPipeServerStream(_pipeName, PipeDirection.InOut, 10, - PipeTransmissionMode.Byte, PipeOptions.Asynchronous); - await server.WaitForConnectionAsync(stoppingToken); - - _ = Task.Run(() => HandleClientAsync(server, stoppingToken), stoppingToken); + await _listenerTask; } catch (OperationCanceledException) { - break; + // Expected during cancellation } - catch (Exception ex) + } + } + + private async Task ListenToIpcClientAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) { - _logger.LogError(ex, "IPC accept loop error"); - await Task.Delay(500, stoppingToken); + try + { + var server = new NamedPipeServerStream(_pipeName, PipeDirection.InOut, 10, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous); + await server.WaitForConnectionAsync(cancellationToken); + + _ = Task.Run(() => HandleClientAsync(server, cancellationToken), cancellationToken); + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogError(ex, "IPC server accept loop error"); + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + } } } - - _logger.LogInformation("IPC server stopped"); + catch (OperationCanceledException) + { + _logger.LogInformation("IPC server loop cancelled"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Fatal error in IPC server loop"); + } } private async Task HandleClientAsync(NamedPipeServerStream stream, CancellationToken ct) @@ -116,11 +153,11 @@ private void EnsureCookieExists() if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); - if (!File.Exists(_cookiePath)) - { - var token = Convert.ToBase64String(Guid.NewGuid().ToByteArray()); - File.WriteAllText(_cookiePath, token); - } + if (File.Exists(_cookiePath)) + return; + + var token = Convert.ToBase64String(Guid.NewGuid().ToByteArray()); + File.WriteAllText(_cookiePath, token); } catch (Exception ex) { diff --git a/src/NLightning.Daemon/Services/NltgDaemonService.cs b/src/NLightning.Daemon/Services/NltgDaemonService.cs index 12fb2683..efd15159 100644 --- a/src/NLightning.Daemon/Services/NltgDaemonService.cs +++ b/src/NLightning.Daemon/Services/NltgDaemonService.cs @@ -6,6 +6,7 @@ namespace NLightning.Daemon.Services; using Domain.Bitcoin.Interfaces; +using Domain.Client.Interfaces; using Domain.Node.Interfaces; using Domain.Node.Options; using Domain.Protocol.Interfaces; @@ -17,18 +18,21 @@ public class NltgDaemonService : BackgroundService private readonly IConfiguration _configuration; private readonly IFeeService _feeService; private readonly ILogger _logger; + private readonly INamedPipeIpcService _namedPipeIpcService; private readonly IPeerManager _peerManager; private readonly NodeOptions _nodeOptions; private readonly ISecureKeyManager _secureKeyManager; public NltgDaemonService(IBlockchainMonitor blockchainMonitor, IConfiguration configuration, IFeeService feeService, - ILogger logger, IOptions nodeOptions, - IPeerManager peerManager, ISecureKeyManager secureKeyManager) + ILogger logger, INamedPipeIpcService namedPipeIpcService, + IOptions nodeOptions, IPeerManager peerManager, + ISecureKeyManager secureKeyManager) { _blockchainMonitor = blockchainMonitor; _configuration = configuration; _feeService = feeService; _logger = logger; + _namedPipeIpcService = namedPipeIpcService; _peerManager = peerManager; _nodeOptions = nodeOptions.Value; _secureKeyManager = secureKeyManager; @@ -58,6 +62,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) // Start the blockchain monitor service await _blockchainMonitor.StartAsync(_secureKeyManager.HeightOfBirth, stoppingToken); + // Start the IPC server + await _namedPipeIpcService.StartAsync(stoppingToken); + while (!stoppingToken.IsCancellationRequested) await Task.Delay(1000, stoppingToken); } @@ -72,7 +79,7 @@ public override async Task StopAsync(CancellationToken cancellationToken) _logger.LogInformation("NLTG shutdown requested"); await Task.WhenAll(_blockchainMonitor.StopAsync(), _feeService.StopAsync(), _peerManager.StopAsync(), - base.StopAsync(cancellationToken)); + _namedPipeIpcService.StopAsync(), base.StopAsync(cancellationToken)); _logger.LogInformation("NLTG daemon service stopped"); } diff --git a/src/NLightning.Daemon/Utilities/DaemonUtils.cs b/src/NLightning.Daemon/Utilities/DaemonUtils.cs index 8528251e..46ad063c 100644 --- a/src/NLightning.Daemon/Utilities/DaemonUtils.cs +++ b/src/NLightning.Daemon/Utilities/DaemonUtils.cs @@ -317,12 +317,9 @@ public static bool IsRunningAsDaemon() /// /// Gets the path for the PID file /// - public static string GetPidFilePath(string network) + public static string GetPidFilePath(string configPath) { - var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var networkDir = Path.Combine(homeDir, NodeConstants.DaemonFolder, network); - Directory.CreateDirectory(networkDir); // Ensure directory exists - return Path.Combine(networkDir, NodeConstants.PidFile); + return Path.Combine(configPath, NodeConstants.PidFile); } /// diff --git a/src/NLightning.Domain/Bitcoin/Interfaces/ILightningSigner.cs b/src/NLightning.Domain/Bitcoin/Interfaces/ILightningSigner.cs index e71dc75c..033f7c55 100644 --- a/src/NLightning.Domain/Bitcoin/Interfaces/ILightningSigner.cs +++ b/src/NLightning.Domain/Bitcoin/Interfaces/ILightningSigner.cs @@ -55,10 +55,20 @@ public interface ILightningSigner /// Secret ReleasePerCommitmentSecret(ChannelId channelId, ulong commitmentNumber); + /// + /// Sign a general transaction using the wallet signing context + /// + bool SignWalletTransaction(SignedTransaction unsignedTransaction); + + /// + /// Sign a funding transaction using the wallet signing context and validating using the channel context + /// + bool SignFundingTransaction(ChannelId channelId, SignedTransaction unsignedTransaction); + /// /// Sign a transaction using the channel's signing context /// - CompactSignature SignTransaction(ChannelId channelId, SignedTransaction unsignedTransaction); + CompactSignature SignChannelTransaction(ChannelId channelId, SignedTransaction unsignedTransaction); /// /// Verify a signature against a transaction diff --git a/src/NLightning.Domain/Bitcoin/Interfaces/IUtxoDbRepository.cs b/src/NLightning.Domain/Bitcoin/Interfaces/IUtxoDbRepository.cs index 04968ecc..daa6f8ce 100644 --- a/src/NLightning.Domain/Bitcoin/Interfaces/IUtxoDbRepository.cs +++ b/src/NLightning.Domain/Bitcoin/Interfaces/IUtxoDbRepository.cs @@ -1,10 +1,13 @@ namespace NLightning.Domain.Bitcoin.Interfaces; +using ValueObjects; using Wallet.Models; public interface IUtxoDbRepository { void Add(UtxoModel utxoModel); + Task GetByIdAsync(TxId txId, uint index, bool includeWalletAddress = false); + Task> GetUnspentAsync(bool includeWalletAddress = false); void Spend(UtxoModel utxoModel); - Task> GetAllAsync(); + void Update(UtxoModel utxoModel); } \ No newline at end of file diff --git a/src/NLightning.Domain/Bitcoin/Interfaces/IUtxoMemoryRepository.cs b/src/NLightning.Domain/Bitcoin/Interfaces/IUtxoMemoryRepository.cs index a21aae36..bbd9934d 100644 --- a/src/NLightning.Domain/Bitcoin/Interfaces/IUtxoMemoryRepository.cs +++ b/src/NLightning.Domain/Bitcoin/Interfaces/IUtxoMemoryRepository.cs @@ -1,14 +1,23 @@ -using NLightning.Domain.Money; +using System.Diagnostics.CodeAnalysis; namespace NLightning.Domain.Bitcoin.Interfaces; +using Channels.ValueObjects; +using Money; +using ValueObjects; using Wallet.Models; public interface IUtxoMemoryRepository { void Add(UtxoModel utxoModel); void Spend(UtxoModel utxoModel); + bool TryGetUtxo(TxId txId, uint index, [MaybeNullWhen(false)] out UtxoModel utxoModel); LightningMoney GetConfirmedBalance(uint currentBlockHeight); LightningMoney GetUnconfirmedBalance(uint currentBlockHeight); + LightningMoney GetLockedBalance(); void Load(List utxoSet); + List LockUtxosToSpendOnChannel(LightningMoney requestFundingAmount, ChannelId channelId); + List GetLockedUtxosForChannel(ChannelId channelId); + List ReturnUtxosNotSpentOnChannel(ChannelId channelId); + void ConfirmSpendOnChannel(ChannelId channelId); } \ No newline at end of file diff --git a/src/NLightning.Domain/Bitcoin/Transactions/Constants/WeightConstants.cs b/src/NLightning.Domain/Bitcoin/Transactions/Constants/WeightConstants.cs index 8ddd3d17..3e3d395d 100644 --- a/src/NLightning.Domain/Bitcoin/Transactions/Constants/WeightConstants.cs +++ b/src/NLightning.Domain/Bitcoin/Transactions/Constants/WeightConstants.cs @@ -15,11 +15,14 @@ public static class WeightConstants public const int P2PkhInputWeight = 148; // At Least public const int P2ShInputWeight = 148; // At Least public const int P2WpkhInputWeight = 41; // At Least + public const int P2TrInputWeight = P2WpkhInputWeight; public const int P2WshInputWeight = P2WpkhInputWeight; - public const int P2UnknownSInputWeight = P2WpkhInputWeight; + public const int P2UnknownInputWeight = P2WpkhInputWeight; public const int WitnessHeader = 2; // flag, marker public const int MultisigWitnessWeight = 222; // 1 byte for each signature + public const int SingleSigWitnessWeight = 107; + public const int TaprootSigWitnessWeight = 66; public const int HtlcOutputWeight = P2WshOutputWeight; public const int AnchorOutputWeight = P2WshOutputWeight; diff --git a/src/NLightning.Domain/Bitcoin/Transactions/Factories/CommitmentTransactionModelFactory.cs b/src/NLightning.Domain/Bitcoin/Transactions/Factories/CommitmentTransactionModelFactory.cs index 8c4eac7b..c4f310e5 100644 --- a/src/NLightning.Domain/Bitcoin/Transactions/Factories/CommitmentTransactionModelFactory.cs +++ b/src/NLightning.Domain/Bitcoin/Transactions/Factories/CommitmentTransactionModelFactory.cs @@ -42,7 +42,7 @@ public CommitmentTransactionModel CreateCommitmentTransactionModel(ChannelModel // Get basepoints from the signer instead of the old key set model var localBasepoints = _lightningSigner.GetChannelBasepoints(channel.LocalKeySet.KeyIndex); - var remoteBasepoints = new ChannelBasepoints(channel.RemoteKeySet.FundingCompactPubKey, + var remoteBasepoints = new ChannelBasepoints(channel.RemoteKeySet!.FundingCompactPubKey, channel.RemoteKeySet.RevocationCompactBasepoint, channel.RemoteKeySet.PaymentCompactBasepoint, channel.RemoteKeySet.DelayedPaymentCompactBasepoint, @@ -66,7 +66,7 @@ public CommitmentTransactionModel CreateCommitmentTransactionModel(ChannelModel // Calculate base weight var weight = WeightConstants.TransactionBaseWeight + TransactionConstants.CommitmentTransactionInputWeight - // + htlcs.Count * WeightConstants.HtlcOutputWeight + // + htlcs.Count * WeightConstants.HtlcOutputWeight + WeightConstants.P2WshOutputWeight; // To Local Output // Set initial amounts for to_local and to_remote outputs @@ -208,7 +208,7 @@ public CommitmentTransactionModel CreateCommitmentTransactionModel(ChannelModel } // Create and return the commitment transaction model - return new CommitmentTransactionModel(channel.CommitmentNumber, fee, channel.FundingOutput, + return new CommitmentTransactionModel(channel.CommitmentNumber!, fee, channel.FundingOutput!, localAnchorOutput, remoteAnchorOutput, toLocalOutput, toRemoteOutput, offeredHtlcOutputs, receivedHtlcOutputs); } diff --git a/src/NLightning.Domain/Bitcoin/Transactions/Factories/FundingTransactionModelFactory.cs b/src/NLightning.Domain/Bitcoin/Transactions/Factories/FundingTransactionModelFactory.cs new file mode 100644 index 00000000..f44ef25d --- /dev/null +++ b/src/NLightning.Domain/Bitcoin/Transactions/Factories/FundingTransactionModelFactory.cs @@ -0,0 +1,78 @@ +namespace NLightning.Domain.Bitcoin.Transactions.Factories; + +using Bitcoin.Enums; +using Channels.Models; +using Constants; +using Interfaces; +using Models; +using Money; +using Wallet.Models; + +public class FundingTransactionModelFactory : IFundingTransactionModelFactory +{ + public FundingTransactionModel Create(ChannelModel channel, List utxos, + WalletAddressModel? changeAddress) + { + if (utxos.Count == 0) + throw new ArgumentException("UTXO list cannot be empty", nameof(utxos)); + + var fundingOutput = channel.FundingOutput ?? + throw new NullReferenceException($"{nameof(channel.FundingOutput)} cannot be null"); + + // Calculate the total input amount + var totalInputAmount = LightningMoney.Satoshis(utxos.Sum(u => u.Amount.Satoshi)); + + // Calculate the weight based on the input types + // Starting with base transaction weight + var weight = WeightConstants.TransactionBaseWeight; + + // Add weight for each input (assuming P2WPKH for now, which is most common) + foreach (var utxo in utxos) + { + if (utxo.AddressType == AddressType.P2Wpkh) + { + weight += WeightConstants.P2WpkhInputWeight * 4 + + WeightConstants.SingleSigWitnessWeight; + } + else if (utxo.AddressType == AddressType.P2Tr) + { + weight += WeightConstants.P2TrInputWeight * 4 + + WeightConstants.TaprootSigWitnessWeight; + } + else + { + throw new NotSupportedException($"Unsupported utxo type {utxo.AddressType}"); + } + } + + // Add weight for the funding output (P2WSH) + weight += WeightConstants.P2WshOutputWeight; + + // Calculate fee based on the channel's fee rate + var fee = LightningMoney.MilliSatoshis(weight * channel.ChannelConfig.FeeRateAmountPerKw.Satoshi); + + // Calculate what's left after funding output and fee + var fundingAmount = fundingOutput.Amount; + var remainingAmount = totalInputAmount - fundingAmount - fee; + + // Create the funding transaction model + var fundingTransactionModel = new FundingTransactionModel(utxos, fundingOutput, fee); + + // If there's a remaining amount, we need a change output + if (remainingAmount.Satoshi <= 0) + return fundingTransactionModel; + + // Add change output weight to recalculate fee + weight += WeightConstants.P2WpkhOutputWeight; + fee = LightningMoney.MilliSatoshis(weight * channel.ChannelConfig.FeeRateAmountPerKw.Satoshi); + + // Recalculate remaining amount with updated fee + fundingTransactionModel.ChangeAmount = totalInputAmount - fundingAmount - fee; + fundingTransactionModel.ChangeAddress = changeAddress ?? + throw new ArgumentNullException( + nameof(changeAddress), + "We need a change address but none was provided."); + + return fundingTransactionModel; + } +} \ No newline at end of file diff --git a/src/NLightning.Domain/Bitcoin/Transactions/Interfaces/IFundingTransactionModelFactory.cs b/src/NLightning.Domain/Bitcoin/Transactions/Interfaces/IFundingTransactionModelFactory.cs new file mode 100644 index 00000000..ca4109f0 --- /dev/null +++ b/src/NLightning.Domain/Bitcoin/Transactions/Interfaces/IFundingTransactionModelFactory.cs @@ -0,0 +1,10 @@ +namespace NLightning.Domain.Bitcoin.Transactions.Interfaces; + +using Channels.Models; +using Models; +using Wallet.Models; + +public interface IFundingTransactionModelFactory +{ + FundingTransactionModel Create(ChannelModel channel, List utxos, WalletAddressModel? changeAddress); +} \ No newline at end of file diff --git a/src/NLightning.Domain/Bitcoin/Transactions/Models/FundingTransactionModel.cs b/src/NLightning.Domain/Bitcoin/Transactions/Models/FundingTransactionModel.cs new file mode 100644 index 00000000..32f34d8b --- /dev/null +++ b/src/NLightning.Domain/Bitcoin/Transactions/Models/FundingTransactionModel.cs @@ -0,0 +1,44 @@ +namespace NLightning.Domain.Bitcoin.Transactions.Models; + +using Money; +using Outputs; +using ValueObjects; +using Wallet.Models; + +/// +/// Represents a funding transaction in the domain model. +/// This class encapsulates the logical structure of a Lightning Network funding transaction +/// as defined by BOLT specifications, without dependencies on specific Bitcoin libraries. +/// +public class FundingTransactionModel +{ + /// + /// Gets the outputs to be spent by this transaction. + /// + public IEnumerable Utxos { get; } + + /// + /// Gets the funding output that this transaction pays to. + /// + public FundingOutputInfo FundingOutput { get; } + + /// + /// Gets or sets the transaction ID after the transaction is constructed. + /// + public TxId? TransactionId { get; set; } + + /// + /// Gets the total fee for this transaction. + /// + public LightningMoney Fee { get; } + + public WalletAddressModel? ChangeAddress { get; set; } + public LightningMoney? ChangeAmount { get; set; } + + public FundingTransactionModel(IEnumerable utxos, FundingOutputInfo fundingOutput, LightningMoney fee) + { + Utxos = utxos; + FundingOutput = fundingOutput; + Fee = fee; + } +} \ No newline at end of file diff --git a/src/NLightning.Domain/Bitcoin/Wallet/Models/UtxoModel.cs b/src/NLightning.Domain/Bitcoin/Wallet/Models/UtxoModel.cs index aebf232e..4c5b769c 100644 --- a/src/NLightning.Domain/Bitcoin/Wallet/Models/UtxoModel.cs +++ b/src/NLightning.Domain/Bitcoin/Wallet/Models/UtxoModel.cs @@ -1,5 +1,7 @@ namespace NLightning.Domain.Bitcoin.Wallet.Models; +using Channels.ValueObjects; +using Enums; using Money; using ValueObjects; @@ -9,12 +11,40 @@ public sealed class UtxoModel public uint Index { get; } public LightningMoney Amount { get; } public uint BlockHeight { get; } + public uint AddressIndex { get; private set; } + public bool IsAddressChange { get; private set; } + public AddressType AddressType { get; private set; } + public ChannelId? LockedToChannelId { get; set; } - public UtxoModel(TxId txId, uint index, LightningMoney amount, uint blockHeight) + public WalletAddressModel? WalletAddress { get; private set; } + + public UtxoModel(TxId txId, uint index, LightningMoney amount, uint blockHeight, uint addressIndex, + bool isAddressChange, AddressType addressType) + { + TxId = txId; + Index = index; + Amount = amount; + BlockHeight = blockHeight; + AddressIndex = addressIndex; + IsAddressChange = isAddressChange; + AddressType = addressType; + } + + public UtxoModel(TxId txId, uint index, LightningMoney amount, uint blockHeight, WalletAddressModel walletAddress) { TxId = txId; Index = index; Amount = amount; BlockHeight = blockHeight; + SetWalletAddress(walletAddress); + } + + public void SetWalletAddress(WalletAddressModel walletAddress) + { + WalletAddress = walletAddress; + + AddressIndex = walletAddress.Index; + IsAddressChange = walletAddress.IsChange; + AddressType = walletAddress.AddressType; } } \ No newline at end of file diff --git a/src/NLightning.Domain/Bitcoin/Wallet/Models/WalletAddressModel.cs b/src/NLightning.Domain/Bitcoin/Wallet/Models/WalletAddressModel.cs index fec14e07..f8d07784 100644 --- a/src/NLightning.Domain/Bitcoin/Wallet/Models/WalletAddressModel.cs +++ b/src/NLightning.Domain/Bitcoin/Wallet/Models/WalletAddressModel.cs @@ -1,27 +1,20 @@ -using NLightning.Domain.Bitcoin.Enums; - namespace NLightning.Domain.Bitcoin.Wallet.Models; +using Enums; + public sealed class WalletAddressModel { public AddressType AddressType { get; } public uint Index { get; } public bool IsChange { get; } public string Address { get; } - public uint UtxoQty { get; private set; } - public WalletAddressModel(AddressType addressType, uint index, bool isChange, string address, uint utxoQty = 0) + public WalletAddressModel(AddressType addressType, uint index, bool isChange, string address) { AddressType = addressType; Index = index; IsChange = isChange; Address = address; - UtxoQty = utxoQty; - } - - public void IncrementUtxoQty() - { - UtxoQty++; } public override string ToString() diff --git a/src/NLightning.Domain/Channels/Factories/ChannelFactory.cs b/src/NLightning.Domain/Channels/Factories/ChannelFactory.cs index b6a61d15..902bb9d0 100644 --- a/src/NLightning.Domain/Channels/Factories/ChannelFactory.cs +++ b/src/NLightning.Domain/Channels/Factories/ChannelFactory.cs @@ -1,11 +1,10 @@ -using NLightning.Domain.Bitcoin.Transactions.Constants; -using NLightning.Domain.Bitcoin.Transactions.Outputs; -using NLightning.Domain.Protocol.Models; - namespace NLightning.Domain.Channels.Factories; using Bitcoin.Interfaces; +using Bitcoin.Transactions.Constants; +using Bitcoin.Transactions.Outputs; using Bitcoin.ValueObjects; +using Client.Requests; using Constants; using Crypto.Hashes; using Crypto.ValueObjects; @@ -16,21 +15,27 @@ namespace NLightning.Domain.Channels.Factories; using Models; using Money; using Node.Options; +using Protocol.Interfaces; using Protocol.Messages; -using Protocol.Payloads; -using Protocol.Tlv; +using Protocol.Models; +using Validators.Parameters; using ValueObjects; public class ChannelFactory : IChannelFactory { + private readonly IChannelIdFactory _channelIdFactory; + private readonly IChannelOpenValidator _channelOpenValidator; private readonly IFeeService _feeService; private readonly ILightningSigner _lightningSigner; private readonly NodeOptions _nodeOptions; private readonly ISha256 _sha256; - public ChannelFactory(IFeeService feeService, ILightningSigner lightningSigner, NodeOptions nodeOptions, + public ChannelFactory(IChannelIdFactory channelIdFactory, IChannelOpenValidator channelOpenValidator, + IFeeService feeService, ILightningSigner lightningSigner, NodeOptions nodeOptions, ISha256 sha256) { + _channelIdFactory = channelIdFactory; + _channelOpenValidator = channelOpenValidator; _feeService = feeService; _lightningSigner = lightningSigner; _nodeOptions = nodeOptions; @@ -52,11 +57,15 @@ public async Task CreateChannelV1AsNonInitiatorAsync(OpenChannel1M throw new ChannelErrorException("Channel type was negotiated but not provided"); // Perform optional checks for the channel - PerformOptionalChecks(payload); + var ourChannelReserveAmount = GetOurChannelReserveFromFundingAmount(payload.FundingAmount); + _channelOpenValidator.PerformOptionalChecks( + ChannelOpenOptionalValidationParameters.FromOpenChannel1Payload(payload, ourChannelReserveAmount)); // Perform mandatory checks for the channel var currentFee = await _feeService.GetFeeRatePerKwAsync(); - PerformMandatoryChecks(message.ChannelTypeTlv, currentFee, negotiatedFeatures, payload, out var minimumDepth); + _channelOpenValidator.PerformMandatoryChecks( + ChannelOpenMandatoryValidationParameters.FromOpenChannel1Payload( + message.ChannelTypeTlv, currentFee, negotiatedFeatures, payload), out var minimumDepth); // Check for the upfront shutdown script if (message.UpfrontShutdownScriptTlv is null @@ -114,7 +123,7 @@ public async Task CreateChannelV1AsNonInitiatorAsync(OpenChannel1M payload.DustLimitAmount, payload.ToSelfDelay, useScidAlias, localUpfrontShutdownScript, remoteUpfrontShutdownScript); - // Generate the commitment numbers + // Generate the commitment number var commitmentNumber = new CommitmentNumber(remoteKeySet.PaymentCompactBasepoint, localKeySet.PaymentCompactBasepoint, _sha256); @@ -134,158 +143,122 @@ public async Task CreateChannelV1AsNonInitiatorAsync(OpenChannel1M } } - /// - /// Conducts optional validation checks on channel parameters to ensure compliance with acceptable ranges - /// and configurations beyond the mandatory requirements. - /// - /// - /// This method verifies that optional configuration parameters meet recommended safety and usability thresholds: - /// - Validates that the funding amount meets the minimum channel size threshold. - /// - Checks that the HTLC minimum amount is not excessively large relative to the node's configured minimum value. - /// - Validates that the maximum HTLC value in flight is enough relative to the channel funds. - /// - Ensures the channel reserve amount is not excessively high relative to the node's channel reserve configuration. - /// - Verifies that the maximum number of accepted HTLCs meets a minimum threshold. - /// - Confirms that the dust limit is not excessively large relative to the node's configured dust limit. - /// - /// The payload containing the channel's configuration parameters, including funding amount, HTLC limits, and related settings. - /// - /// Thrown when one of the optional checks fails, including missing channel type when required, insufficient funding, - /// excessively high or low HTLC value limits, or incompatible reserve and dust limits. - /// - private void PerformOptionalChecks(OpenChannel1Payload payload) - { - // Check if Funding Satoshis is too small - if (payload.FundingAmount < _nodeOptions.MinimumChannelSize) - throw new ChannelErrorException($"Funding amount is too small: {payload.FundingAmount}"); - - // Check if we consider htlc_minimum_msat too large. IE. 20% bigger than our htlc minimum amount - if (payload.HtlcMinimumAmount > _nodeOptions.HtlcMinimumAmount * 1.2M) - throw new ChannelErrorException($"Htlc minimum amount is too large: {payload.HtlcMinimumAmount}"); - - // Check if we consider max_htlc_value_in_flight_msat too small. IE. 20% smaller than our maximum htlc value - var maxHtlcValueInFlight = - LightningMoney.Satoshis(_nodeOptions.AllowUpToPercentageOfChannelFundsInFlight * - payload.FundingAmount.Satoshi / 100M); - if (payload.MaxHtlcValueInFlight < maxHtlcValueInFlight * 0.8M) - throw new ChannelErrorException($"Max htlc value in flight is too small: {payload.MaxHtlcValueInFlight}"); - - // Check if we consider channel_reserve_satoshis too large. IE. 20% bigger than our channel reserve - if (payload.ChannelReserveAmount > _nodeOptions.ChannelReserveAmount * 1.2M) - throw new ChannelErrorException($"Channel reserve amount is too large: {payload.ChannelReserveAmount}"); - - // Check if we consider max_accepted_htlcs too small. IE. 20% smaller than our max-accepted htlcs - if (payload.MaxAcceptedHtlcs < (ushort)(_nodeOptions.MaxAcceptedHtlcs * 0.8M)) - throw new ChannelErrorException($"Max accepted htlcs is too small: {payload.MaxAcceptedHtlcs}"); - - // Check if we consider dust_limit_satoshis too large. IE. 75% bigger than our dust limit - if (payload.DustLimitAmount > _nodeOptions.DustLimitAmount * 1.75M) - throw new ChannelErrorException($"Dust limit amount is too large: {payload.DustLimitAmount}"); - } - - /// - /// Enforce mandatory checks when establishing a new Lightning Network channel. - /// - /// - /// The method validates channel parameters to ensure they comply with predefined safety and compatibility checks: - /// - ChainHash must be compatible with the node's network. - /// - Push amount must not exceed 1000 times the funding amount. - /// - To_self_delay must not be unreasonably large compared to the node's configured value. - /// - Max_accepted_htlcs must not exceed the allowed maximum. - /// - Fee rate per kw must fall within acceptable limits. - /// - Dust limit must be lower than or equal to the channel reserve amount and adhere to minimum thresholds. - /// - Funding amount must be sufficient to cover fees and the channel reserve. - /// - Large channels must only be supported if negotiated features include support for them. - /// - Additional validation may apply to channel types based on negotiated options. - /// - /// Optional TLV data specifying the channel type, which may impose additional constraints. - /// The current network fee rate per kiloweight, used for fee validation. - /// Negotiated feature options between the participating nodes, affecting channel setup constraints. - /// The payload containing the channel's configuration parameters and constraints. - /// The minimum number of confirmations required for the channel to be considered operational. - /// - /// Thrown when any of the mandatory checks fail, such as invalid chain hash, excessive push amount, unreasonably large delay, - /// invalid funding amount, unsupported large channel, or mismatched channel type. - /// - private void PerformMandatoryChecks(ChannelTypeTlv? channelTypeTlv, LightningMoney currentFeeRatePerKw, - FeatureOptions negotiatedFeatures, OpenChannel1Payload payload, - out uint minimumDepth) + public async Task CreateChannelV1AsInitiatorAsync(OpenChannelClientRequest request, + FeatureOptions negotiatedFeatures, + CompactPubKey remoteNodeId) { - // Check if ChainHash is compatible - if (payload.ChainHash != _nodeOptions.BitcoinNetwork.ChainHash) - throw new ChannelErrorException("ChainHash is not compatible"); - - // Check if the push amount is too large - if (payload.PushAmount > 1_000 * payload.FundingAmount) - throw new ChannelErrorException($"Push amount is too large: {payload.PushAmount}"); - - // Check if we consider to_self_delay unreasonably large. IE. 50% bigger than our to_self_delay - if (payload.ToSelfDelay > _nodeOptions.ToSelfDelay * 1.5M) - throw new ChannelErrorException($"To self delay is too large: {payload.ToSelfDelay}"); + // If dual fund is negotiated fail the channel + if (negotiatedFeatures.DualFund == FeatureSupport.Compulsory) + throw new ChannelErrorException("We can only open dual fund channels to this peer"); - // Check max_accepted_htlcs is too large - if (payload.MaxAcceptedHtlcs > ChannelConstants.MaxAcceptedHtlcs) - throw new ChannelErrorException($"Max accepted htlcs is too small: {payload.MaxAcceptedHtlcs}"); + // Check if the FundingAmount is too small + if (request.FundingAmount < _nodeOptions.MinimumChannelSize) + throw new ChannelErrorException( + $"Funding amount is smaller than our MinimumChannelSize: {request.FundingAmount} < {_nodeOptions.MinimumChannelSize}"); - // Check if we consider fee_rate_per_kw too large - if (payload.FeeRatePerKw > ChannelConstants.MaxFeePerKw) - throw new ChannelErrorException($"Fee rate per kw is too large: {payload.FeeRatePerKw}"); + // Check if our fee is too big + if (request.FeeRatePerKw is not null && request.FeeRatePerKw > ChannelConstants.MaxFeePerKw) + throw new ChannelErrorException($"Fee rate per kw is too large: {request.FeeRatePerKw}"); - // Check if we consider fee_rate_per_kw too small. IE. 20% smaller than our fee rate - if (payload.FeeRatePerKw < ChannelConstants.MinFeePerKw || payload.FeeRatePerKw < currentFeeRatePerKw * 0.8M) - throw new ChannelErrorException( - $"Fee rate per kw is too small: {payload.FeeRatePerKw}, currentFee{currentFeeRatePerKw}"); + // Check if the dust limit is greater than the channel reserve amount + var channelReserveAmount = GetOurChannelReserveFromFundingAmount(request.FundingAmount); + if (request.ChannelReserveAmount is not null && request.ChannelReserveAmount > channelReserveAmount) + channelReserveAmount = request.ChannelReserveAmount; - // Check if the dust limit is greater than the channel reserve amount - if (payload.DustLimitAmount > payload.ChannelReserveAmount) - throw new ChannelErrorException( - $"Dust limit({payload.DustLimitAmount}) is greater than channel reserve({payload.ChannelReserveAmount})"); + if (request.DustLimitAmount is not null) + { + if (request.DustLimitAmount > channelReserveAmount) + throw new ChannelErrorException( + $"Dust limit({request.DustLimitAmount}) is greater than channel reserve({channelReserveAmount})"); - // Check if dust_limit_satoshis is too small - if (payload.DustLimitAmount < ChannelConstants.MinDustLimitAmount) - throw new ChannelErrorException($"Dust limit amount is too small: {payload.DustLimitAmount}"); + // Check if dust_limit_satoshis is too small + if (request.DustLimitAmount < ChannelConstants.MinDustLimitAmount) + throw new ChannelErrorException($"Dust limit amount is too small: {request.DustLimitAmount}"); + } // Check if there are enough funds to pay for fees + var currentFeeRatePerKw = await _feeService.GetFeeRatePerKwAsync(); var expectedWeight = negotiatedFeatures.AnchorOutputs > FeatureSupport.No ? TransactionConstants.InitialCommitmentTransactionWeightNoAnchor : TransactionConstants.InitialCommitmentTransactionWeightWithAnchor; var expectedFee = LightningMoney.Satoshis(expectedWeight * currentFeeRatePerKw.Satoshi / 1000); - if (payload.FundingAmount < expectedFee + payload.ChannelReserveAmount) - throw new ChannelErrorException($"Funding amount is too small to cover fees: {payload.FundingAmount}"); + if (request.FundingAmount < expectedFee + channelReserveAmount) + throw new ChannelErrorException($"Funding amount is too small to cover fees: {request.FundingAmount}"); // Check if this is a large channel and if we support it - if (payload.FundingAmount >= ChannelConstants.LargeChannelAmount && + if (request.FundingAmount >= ChannelConstants.LargeChannelAmount && negotiatedFeatures.LargeChannels == FeatureSupport.No) - throw new ChannelErrorException("We don't support large channels"); + throw new ChannelErrorException("The peer don't support large channels"); - // Check ChannelType against negotiated options - minimumDepth = _nodeOptions.MinimumDepth; - if (channelTypeTlv is not null) + // Check if we want zeroconf and if it's negotiated + var minimumDepth = _nodeOptions.MinimumDepth; + if (request.IsZeroConfChannel) { - // Check if it set any non-negotiated features - if (channelTypeTlv.Features.IsFeatureSet(Feature.OptionStaticRemoteKey, true)) - { - if (negotiatedFeatures.StaticRemoteKey == FeatureSupport.No) - throw new ChannelErrorException("Static remote key feature is not supported but requested by peer"); - - if (channelTypeTlv.Features.IsFeatureSet(Feature.OptionAnchorOutputs, true) - && negotiatedFeatures.AnchorOutputs == FeatureSupport.No) - throw new ChannelErrorException("Anchor outputs feature is not supported but requested by peer"); - - if (channelTypeTlv.Features.IsFeatureSet(Feature.OptionScidAlias, true)) - { - if (payload.ChannelFlags.AnnounceChannel) - throw new ChannelErrorException("Invalid channel flags for OPTION_SCID_ALIAS"); - } - - // Check for ZeroConf feature - if (channelTypeTlv.Features.IsFeatureSet(Feature.OptionZeroconf, true)) - { - if (_nodeOptions.Features.ZeroConf == FeatureSupport.No) - throw new ChannelErrorException("ZeroConf feature not supported but requested by peer"); - - minimumDepth = 0U; - } - } + if (_nodeOptions.Features.ZeroConf == FeatureSupport.No) + throw new ChannelErrorException( + "ZeroConf feature not supported, change our configuration and try again"); + + if (negotiatedFeatures.ZeroConf == FeatureSupport.No) + throw new ChannelErrorException("ZeroConf not supported by our peer"); + + minimumDepth = 0U; } + + // Calculate the amounts + var toRemoteAmount = request.PushAmount ?? LightningMoney.Zero; + var toLocalAmount = request.FundingAmount - toRemoteAmount; + + // Generate our MaxHtlcValueInFlight if not provided + var maxHtlcValueInFlight = request.MaxHtlcValueInFlight + ?? LightningMoney.Satoshis(_nodeOptions.AllowUpToPercentageOfChannelFundsInFlight * + request.FundingAmount.Satoshi / 100M); + + // Generate local keys through the signer + var localKeyIndex = _lightningSigner.CreateNewChannel(out var localBasepoints, out var firstPerCommitmentPoint); + + // Create the local key set + var localKeySet = new ChannelKeySetModel(localKeyIndex, localBasepoints.FundingPubKey, + localBasepoints.RevocationBasepoint, localBasepoints.PaymentBasepoint, + localBasepoints.DelayedPaymentBasepoint, localBasepoints.HtlcBasepoint, + firstPerCommitmentPoint); + + BitcoinScript? localUpfrontShutdownScript = null; + // Generate our upfront shutdown script + if (negotiatedFeatures.UpfrontShutdownScript == FeatureSupport.Compulsory) + throw new ChannelErrorException("Upfront shutdown script is compulsory but we are not able to send it"); + + if (_nodeOptions.Features.UpfrontShutdownScript > FeatureSupport.No) + { + // Generate our upfront shutdown script + // TODO: Generate a script from the local key set + // localUpfrontShutdownScript = ; + } + + // Generate the channel configuration + var channelConfig = new ChannelConfig(channelReserveAmount, request.FeeRatePerKw ?? currentFeeRatePerKw, + request.HtlcMinimumAmount ?? _nodeOptions.HtlcMinimumAmount, + request.DustLimitAmount ?? _nodeOptions.DustLimitAmount, + request.MaxAcceptedHtlcs ?? _nodeOptions.MaxAcceptedHtlcs, + maxHtlcValueInFlight, minimumDepth, + negotiatedFeatures.AnchorOutputs != FeatureSupport.No, + LightningMoney.Zero, request.ToSelfDelay ?? _nodeOptions.ToSelfDelay, + negotiatedFeatures.ScidAlias, localUpfrontShutdownScript); + + try + { + // Create the channel using only our data + return new ChannelModel(channelConfig, _channelIdFactory.CreateTemporaryChannelId(), null, + null, true, null, null, toLocalAmount, localKeySet, 1, 0, toRemoteAmount, + null, 1, remoteNodeId, 0, ChannelState.V1Opening, ChannelVersion.V1); + } + catch (Exception e) + { + throw new ChannelErrorException("Error creating commitment transaction", e); + } + } + + private LightningMoney GetOurChannelReserveFromFundingAmount(LightningMoney fundingAmount) + { + return fundingAmount * 0.01M; } } \ No newline at end of file diff --git a/src/NLightning.Domain/Channels/Interfaces/IChannelFactory.cs b/src/NLightning.Domain/Channels/Interfaces/IChannelFactory.cs index 9a419752..f0ee91e9 100644 --- a/src/NLightning.Domain/Channels/Interfaces/IChannelFactory.cs +++ b/src/NLightning.Domain/Channels/Interfaces/IChannelFactory.cs @@ -1,5 +1,6 @@ namespace NLightning.Domain.Channels.Interfaces; +using Client.Requests; using Crypto.ValueObjects; using Models; using Node.Options; @@ -10,4 +11,8 @@ public interface IChannelFactory Task CreateChannelV1AsNonInitiatorAsync(OpenChannel1Message message, FeatureOptions negotiatedFeatures, CompactPubKey remoteNodeId); + + Task CreateChannelV1AsInitiatorAsync(OpenChannelClientRequest request, + FeatureOptions negotiatedFeatures, + CompactPubKey remoteNodeId); } \ No newline at end of file diff --git a/src/NLightning.Domain/Channels/Interfaces/IChannelOpenValidator.cs b/src/NLightning.Domain/Channels/Interfaces/IChannelOpenValidator.cs new file mode 100644 index 00000000..cdfa75ab --- /dev/null +++ b/src/NLightning.Domain/Channels/Interfaces/IChannelOpenValidator.cs @@ -0,0 +1,52 @@ +namespace NLightning.Domain.Channels.Interfaces; + +using Validators.Parameters; + +public interface IChannelOpenValidator +{ + /// + /// Conducts optional validation checks on channel parameters to ensure compliance with acceptable ranges + /// and configurations beyond the mandatory requirements. + /// + /// + /// This method verifies that optional configuration parameters meet recommended safety and usability thresholds: + /// - Validates that the funding amount meets the minimum channel size threshold. + /// - Checks that the HTLC minimum amount is not excessively large relative to the node's configured minimum value. + /// - Validates that the maximum HTLC value in flight is enough relative to the channel funds. + /// - Ensures the channel reserve amount is not excessively high relative to the node's channel reserve configuration. + /// - Verifies that the maximum number of accepted HTLCs meets a minimum threshold. + /// - Confirms that the dust limit is not excessively large relative to the node's configured dust limit. + /// + /// The parameters containing the channel's configuration parameters, including funding amount, HTLC limits, and related settings. + /// + /// Thrown when one of the optional checks fails, including missing channel type when required, insufficient funding, + /// excessively high or low HTLC value limits, or incompatible reserve and dust limits. + /// + void PerformOptionalChecks(ChannelOpenOptionalValidationParameters parameters); + + /// + /// Enforce mandatory checks when establishing a new Lightning Network channel. + /// + /// + /// The method validates channel parameters to ensure they comply with predefined safety and compatibility checks: + /// - ChainHash must be compatible with the node's network. + /// - Push amount must not exceed 1000 times the funding amount. + /// - To_self_delay must not be unreasonably large compared to the node's configured value. + /// - Max_accepted_htlcs must not exceed the allowed maximum. + /// - Fee rate per kw must fall within acceptable limits. + /// - Dust limit must be lower than or equal to the channel reserve amount and adhere to minimum thresholds. + /// - Funding amount must be sufficient to cover fees and the channel reserve. + /// - Large channels must only be supported if negotiated features include support for them. + /// - Additional validation may apply to channel types based on negotiated options. + /// + /// Optional TLV data specifying the channel type, which may impose additional constraints. + /// The current network fee rate per kiloweight, used for fee validation. + /// Negotiated feature options between the participating nodes, affecting channel setup constraints. + /// The payload containing the channel's configuration parameters and constraints. + /// The minimum number of confirmations required for the channel to be considered operational. + /// + /// Thrown when any of the mandatory checks fail, such as invalid chain hash, excessive push amount, unreasonably large delay, + /// invalid funding amount, unsupported large channel, or mismatched channel type. + /// + void PerformMandatoryChecks(ChannelOpenMandatoryValidationParameters parameters, out uint minimumDepth); +} \ No newline at end of file diff --git a/src/NLightning.Domain/Channels/Models/ChannelModel.cs b/src/NLightning.Domain/Channels/Models/ChannelModel.cs index b9a6a824..44e2bbfd 100644 --- a/src/NLightning.Domain/Channels/Models/ChannelModel.cs +++ b/src/NLightning.Domain/Channels/Models/ChannelModel.cs @@ -1,10 +1,10 @@ -using NLightning.Domain.Protocol.Models; - namespace NLightning.Domain.Channels.Models; using Bitcoin.Transactions.Outputs; using Bitcoin.ValueObjects; using Crypto.ValueObjects; +using Domain.Bitcoin.Wallet.Models; +using Domain.Protocol.Models; using Enums; using Money; using ValueObjects; @@ -13,23 +13,24 @@ public class ChannelModel { #region Base Properties - public ChannelConfig ChannelConfig { get; } + public ChannelConfig ChannelConfig { get; private set; } public ChannelId ChannelId { get; private set; } public ShortChannelId ShortChannelId { get; set; } - public CommitmentNumber CommitmentNumber { get; } + public CommitmentNumber? CommitmentNumber { get; private set; } public uint FundingCreatedAtBlockHeight { get; set; } - public FundingOutputInfo FundingOutput { get; } + public FundingOutputInfo? FundingOutput { get; private set; } public bool IsInitiator { get; } public CompactPubKey RemoteNodeId { get; } public ChannelState State { get; private set; } public ChannelVersion Version { get; } + public WalletAddressModel? ChangeAddress { get; set; } #endregion #region Signatures - public CompactSignature? LastSentSignature { get; } - public CompactSignature? LastReceivedSignature { get; } + public CompactSignature? LastSentSignature { get; private set; } + public CompactSignature? LastReceivedSignature { get; private set; } #endregion @@ -51,7 +52,7 @@ public class ChannelModel public ShortChannelId? RemoteAlias { get; set; } public LightningMoney RemoteBalance { get; } - public ChannelKeySetModel RemoteKeySet { get; } + public ChannelKeySetModel? RemoteKeySet { get; private set; } public ulong RemoteNextHtlcId { get; } public ulong RemoteRevocationNumber { get; } public ICollection? RemoteFulfilledHtlcs { get; } @@ -61,11 +62,11 @@ public class ChannelModel #endregion - public ChannelModel(ChannelConfig channelConfig, ChannelId channelId, CommitmentNumber commitmentNumber, - FundingOutputInfo fundingOutput, bool isInitiator, CompactSignature? lastSentSignature, + public ChannelModel(ChannelConfig channelConfig, ChannelId channelId, CommitmentNumber? commitmentNumber, + FundingOutputInfo? fundingOutput, bool isInitiator, CompactSignature? lastSentSignature, CompactSignature? lastReceivedSignature, LightningMoney localBalance, ChannelKeySetModel localKeySet, ulong localNextHtlcId, ulong localRevocationNumber, - LightningMoney remoteBalance, ChannelKeySetModel remoteKeySet, ulong remoteNextHtlcId, + LightningMoney remoteBalance, ChannelKeySetModel? remoteKeySet, ulong remoteNextHtlcId, CompactPubKey remoteNodeId, ulong remoteRevocationNumber, ChannelState state, ChannelVersion version, ICollection? localOfferedHtlcs = null, ICollection? localFulfilledHtlcs = null, ICollection? localOldHtlcs = null, @@ -121,10 +122,49 @@ public void UpdateChannelId(ChannelId newChannelId) ChannelId = newChannelId; } + public void UpdateChannelConfig(ChannelConfig channelConfig) + { + ChannelConfig = channelConfig; + } + + public void AddRemoteKeySet(ChannelKeySetModel remoteKeySet) + { + if (RemoteKeySet is not null) + throw new InvalidOperationException("Remote key set already set"); + + RemoteKeySet = remoteKeySet; + } + + public void AddCommitmentNumber(CommitmentNumber commitmentNumber) + { + if (CommitmentNumber is not null) + throw new InvalidOperationException("Commitment number already set"); + + CommitmentNumber = commitmentNumber; + } + + public void AddFundingOutput(FundingOutputInfo fundingOutput) + { + if (FundingOutput is not null) + throw new InvalidOperationException("Funding output already set"); + + FundingOutput = fundingOutput; + } + + public void UpdateLastSentSignature(CompactSignature lastSentSignature) + { + LastSentSignature = lastSentSignature; + } + + public void UpdateLastReceivedSignature(CompactSignature lastReceivedSignature) + { + LastReceivedSignature = lastReceivedSignature; + } + public ChannelSigningInfo GetSigningInfo() { - return new ChannelSigningInfo(FundingOutput.TransactionId!.Value, FundingOutput.Index!.Value, + return new ChannelSigningInfo(FundingOutput!.TransactionId!.Value, FundingOutput.Index!.Value, FundingOutput.Amount, LocalKeySet.FundingCompactPubKey, - RemoteKeySet.FundingCompactPubKey, LocalKeySet.KeyIndex); + RemoteKeySet!.FundingCompactPubKey, LocalKeySet.KeyIndex); } } \ No newline at end of file diff --git a/src/NLightning.Domain/Channels/Validators/ChannelOpenValidator.cs b/src/NLightning.Domain/Channels/Validators/ChannelOpenValidator.cs new file mode 100644 index 00000000..8debaa13 --- /dev/null +++ b/src/NLightning.Domain/Channels/Validators/ChannelOpenValidator.cs @@ -0,0 +1,148 @@ +namespace NLightning.Domain.Channels.Validators; + +using Bitcoin.Transactions.Constants; +using Constants; +using Domain.Enums; +using Exceptions; +using Interfaces; +using Money; +using Node.Options; +using Parameters; + +public class ChannelOpenValidator : IChannelOpenValidator +{ + private readonly NodeOptions _nodeOptions; + + public ChannelOpenValidator(NodeOptions nodeOptions) + { + _nodeOptions = nodeOptions; + } + + /// + public void PerformOptionalChecks(ChannelOpenOptionalValidationParameters parameters) + { + // Check if Funding Satoshis is too small + if (parameters.FundingAmount is not null && parameters.FundingAmount < _nodeOptions.MinimumChannelSize) + throw new ChannelErrorException($"Funding amount is too small: {parameters.FundingAmount}"); + + // Check if we consider htlc_minimum_msat too large. IE. 20% bigger than our htlc minimum amount + if (parameters.HtlcMinimumAmount is not null + && parameters.HtlcMinimumAmount > _nodeOptions.HtlcMinimumAmount * 1.2M) + throw new ChannelErrorException($"Htlc minimum amount is too large: {parameters.HtlcMinimumAmount}"); + + // Check if we consider max_htlc_value_in_flight_msat too small. IE. 20% smaller than our maximum htlc value + if (parameters.FundingAmount is not null && parameters.MaxHtlcValueInFlight is not null) + { + var ourMaxHtlcValueInFlight = + LightningMoney.Satoshis(_nodeOptions.AllowUpToPercentageOfChannelFundsInFlight * + parameters.FundingAmount.Satoshi / 100M); + if (parameters.MaxHtlcValueInFlight < ourMaxHtlcValueInFlight * 0.8M) + throw new ChannelErrorException( + $"Max htlc value in flight is too small: {parameters.MaxHtlcValueInFlight}"); + } + + // Check if we consider channel_reserve_satoshis too large. IE. 20% bigger than our 1% channel reserve + if (parameters.ChannelReserveAmount > parameters.OurChannelReserveAmount * 1.2M) + throw new ChannelErrorException($"Channel reserve amount is too large: {parameters.ChannelReserveAmount}"); + + // Check if we consider max_accepted_htlcs too small. IE. 20% smaller than our max-accepted htlcs + if (parameters.MaxAcceptedHtlcs < (ushort)(_nodeOptions.MaxAcceptedHtlcs * 0.8M)) + throw new ChannelErrorException($"Max accepted htlcs is too small: {parameters.MaxAcceptedHtlcs}"); + + // Check if we consider dust_limit_satoshis too large. IE. 75% bigger than our dust limit + if (parameters.DustLimitAmount > _nodeOptions.DustLimitAmount * 1.75M) + throw new ChannelErrorException($"Dust limit amount is too large: {parameters.DustLimitAmount}"); + } + + /// + public void PerformMandatoryChecks(ChannelOpenMandatoryValidationParameters parameters, + out uint minimumDepth) + { + // Check if ChainHash is compatible + if (parameters.ChainHash is not null && parameters.ChainHash != _nodeOptions.BitcoinNetwork.ChainHash) + throw new ChannelErrorException("ChainHash is not compatible"); + + // Check if we consider to_self_delay unreasonably large. IE. 50% bigger than our to_self_delay + if (parameters.ToSelfDelay > _nodeOptions.ToSelfDelay * 1.5M) + throw new ChannelErrorException($"To self delay is too large: {parameters.ToSelfDelay}"); + + // Check max_accepted_htlcs is too large + if (parameters.MaxAcceptedHtlcs > ChannelConstants.MaxAcceptedHtlcs) + throw new ChannelErrorException($"Max accepted htlcs is too small: {parameters.MaxAcceptedHtlcs}"); + + if (parameters.FeeRatePerKw is not null) + { + // Check if we consider fee_rate_per_kw too large + if (parameters.FeeRatePerKw > ChannelConstants.MaxFeePerKw) + throw new ChannelErrorException($"Fee rate per kw is too large: {parameters.FeeRatePerKw}"); + + // Check if we consider fee_rate_per_kw too small. IE. 20% smaller than our fee rate + if (parameters.FeeRatePerKw < ChannelConstants.MinFeePerKw || + parameters.FeeRatePerKw < parameters.CurrentFeeRatePerKw * 0.8M) + throw new ChannelErrorException( + $"Fee rate per kw is too small: {parameters.FeeRatePerKw}, currentFee{parameters.CurrentFeeRatePerKw}"); + } + + // Check if the dust limit is greater than the channel reserve amount + if (parameters.DustLimitAmount > parameters.ChannelReserveAmount) + throw new ChannelErrorException( + $"Dust limit({parameters.DustLimitAmount}) is greater than channel reserve({parameters.ChannelReserveAmount})"); + + // Check if dust_limit_satoshis is too small + if (parameters.DustLimitAmount < ChannelConstants.MinDustLimitAmount) + throw new ChannelErrorException($"Dust limit amount is too small: {parameters.DustLimitAmount}"); + + if (parameters.FundingAmount is not null) + { + // Check if the push amount is too large + if (parameters.PushAmount is not null + && parameters.PushAmount > 1_000 * parameters.FundingAmount) + throw new ChannelErrorException($"Push amount is too large: {parameters.PushAmount}"); + + // Check if there are enough funds to pay for fees + var expectedWeight = parameters.NegotiatedFeatures.AnchorOutputs > FeatureSupport.No + ? TransactionConstants.InitialCommitmentTransactionWeightNoAnchor + : TransactionConstants.InitialCommitmentTransactionWeightWithAnchor; + var expectedFee = LightningMoney.Satoshis(expectedWeight * parameters.CurrentFeeRatePerKw.Satoshi / 1000); + if (parameters.FundingAmount < expectedFee + parameters.ChannelReserveAmount) + throw new ChannelErrorException( + $"Funding amount is too small to cover fees: {parameters.FundingAmount}"); + + // Check if this is a large channel and if we support it + if (parameters.FundingAmount >= ChannelConstants.LargeChannelAmount && + parameters.NegotiatedFeatures.LargeChannels == FeatureSupport.No) + throw new ChannelErrorException("We don't support large channels"); + } + + // Check ChannelType against negotiated options + minimumDepth = _nodeOptions.MinimumDepth; + if (parameters.ChannelTypeTlv is not null) + { + // Check if it set any non-negotiated features + if (parameters.ChannelTypeTlv.Features.IsFeatureSet(Feature.OptionStaticRemoteKey, true)) + { + if (parameters.NegotiatedFeatures.StaticRemoteKey == FeatureSupport.No) + throw new ChannelErrorException("Static remote key feature is not supported but requested by peer"); + + if (parameters.ChannelTypeTlv.Features.IsFeatureSet(Feature.OptionAnchorOutputs, true) + && parameters.NegotiatedFeatures.AnchorOutputs == FeatureSupport.No) + throw new ChannelErrorException("Anchor outputs feature is not supported but requested by peer"); + + if (parameters.ChannelTypeTlv.Features.IsFeatureSet(Feature.OptionScidAlias, true)) + { + if (parameters.ChannelFlags is not null && parameters.ChannelFlags.Value.AnnounceChannel) + throw new ChannelErrorException("Invalid channel flags for OPTION_SCID_ALIAS"); + } + + // Check for ZeroConf feature + if (parameters.ChannelTypeTlv.Features.IsFeatureSet(Feature.OptionZeroconf, true)) + { + if (_nodeOptions.Features.ZeroConf == FeatureSupport.No) + throw new ChannelErrorException("ZeroConf feature not supported but requested by peer"); + + minimumDepth = 0U; + } + } + } + } +} \ No newline at end of file diff --git a/src/NLightning.Domain/Channels/Validators/Parameters/ChannelOpenMandatoryValidationParameters.cs b/src/NLightning.Domain/Channels/Validators/Parameters/ChannelOpenMandatoryValidationParameters.cs new file mode 100644 index 00000000..51910ebb --- /dev/null +++ b/src/NLightning.Domain/Channels/Validators/Parameters/ChannelOpenMandatoryValidationParameters.cs @@ -0,0 +1,61 @@ +namespace NLightning.Domain.Channels.Validators.Parameters; + +using Money; +using Node.Options; +using Protocol.Payloads; +using Protocol.Tlv; +using Protocol.ValueObjects; +using ValueObjects; + +public sealed class ChannelOpenMandatoryValidationParameters +{ + public ChannelTypeTlv? ChannelTypeTlv { get; init; } + public required LightningMoney CurrentFeeRatePerKw { get; init; } + public required FeatureOptions NegotiatedFeatures { get; init; } + public ChainHash? ChainHash { get; init; } + public LightningMoney? PushAmount { get; init; } + public LightningMoney? FundingAmount { get; init; } + public ushort ToSelfDelay { get; init; } + public uint MaxAcceptedHtlcs { get; init; } + public LightningMoney? FeeRatePerKw { get; init; } + public required LightningMoney DustLimitAmount { get; init; } + public required LightningMoney ChannelReserveAmount { get; init; } + public ChannelFlags? ChannelFlags { get; init; } + + public static ChannelOpenMandatoryValidationParameters FromOpenChannel1Payload( + ChannelTypeTlv? channelTypeTlv, LightningMoney currentFeeRatePerKw, FeatureOptions negotiatedFeatures, + OpenChannel1Payload payload) + { + return new ChannelOpenMandatoryValidationParameters + { + ChannelTypeTlv = channelTypeTlv, + CurrentFeeRatePerKw = currentFeeRatePerKw, + NegotiatedFeatures = negotiatedFeatures, + ChainHash = payload.ChainHash, + PushAmount = payload.PushAmount, + FundingAmount = payload.FundingAmount, + ToSelfDelay = payload.ToSelfDelay, + MaxAcceptedHtlcs = payload.MaxAcceptedHtlcs, + FeeRatePerKw = payload.FeeRatePerKw, + DustLimitAmount = payload.DustLimitAmount, + ChannelReserveAmount = payload.ChannelReserveAmount, + ChannelFlags = payload.ChannelFlags, + }; + } + + public static ChannelOpenMandatoryValidationParameters FromAcceptChannel1Payload( + ChannelTypeTlv? channelTypeTlv, LightningMoney feeRateAmountPerKw, + FeatureOptions negotiatedFeatures, AcceptChannel1Payload payload) + { + return new ChannelOpenMandatoryValidationParameters + { + ChannelTypeTlv = channelTypeTlv, + CurrentFeeRatePerKw = feeRateAmountPerKw, + NegotiatedFeatures = negotiatedFeatures, + ToSelfDelay = payload.ToSelfDelay, + MaxAcceptedHtlcs = payload.MaxAcceptedHtlcs, + DustLimitAmount = payload.DustLimitAmount, + ChannelReserveAmount = payload.ChannelReserveAmount + }; + } +} \ No newline at end of file diff --git a/src/NLightning.Domain/Channels/Validators/Parameters/ChannelOpenOptionalValidationParameters.cs b/src/NLightning.Domain/Channels/Validators/Parameters/ChannelOpenOptionalValidationParameters.cs new file mode 100644 index 00000000..c4f3f386 --- /dev/null +++ b/src/NLightning.Domain/Channels/Validators/Parameters/ChannelOpenOptionalValidationParameters.cs @@ -0,0 +1,56 @@ +namespace NLightning.Domain.Channels.Validators.Parameters; + +using Money; +using Protocol.Payloads; +using Protocol.ValueObjects; + +public sealed class ChannelOpenOptionalValidationParameters +{ + public ChainHash? ChainHash { get; init; } + public LightningMoney? FundingAmount { get; init; } + public LightningMoney? PushAmount { get; init; } + public required LightningMoney HtlcMinimumAmount { get; init; } + public LightningMoney? MaxHtlcValueInFlight { get; init; } + public required LightningMoney ChannelReserveAmount { get; init; } + public required LightningMoney OurChannelReserveAmount { get; init; } + public required ushort MaxAcceptedHtlcs { get; init; } + public required LightningMoney DustLimitAmount { get; init; } + public required ushort ToSelfDelay { get; init; } + public LightningMoney? FeeRatePerKw { get; init; } + + /// + /// Creates validation parameters from an incoming OpenChannel1Payload. + /// + public static ChannelOpenOptionalValidationParameters FromOpenChannel1Payload( + OpenChannel1Payload payload, LightningMoney ourChannelReserveAmount) + { + return new ChannelOpenOptionalValidationParameters + { + ChainHash = payload.ChainHash, + FundingAmount = payload.FundingAmount, + PushAmount = payload.PushAmount, + HtlcMinimumAmount = payload.HtlcMinimumAmount, + MaxHtlcValueInFlight = payload.MaxHtlcValueInFlight, + ChannelReserveAmount = payload.ChannelReserveAmount, + OurChannelReserveAmount = ourChannelReserveAmount, + MaxAcceptedHtlcs = payload.MaxAcceptedHtlcs, + DustLimitAmount = payload.DustLimitAmount, + ToSelfDelay = payload.ToSelfDelay, + FeeRatePerKw = payload.FeeRatePerKw + }; + } + + public static ChannelOpenOptionalValidationParameters FromAcceptChannel1Payload( + AcceptChannel1Payload payload, LightningMoney ourChannelReserveAmount) + { + return new ChannelOpenOptionalValidationParameters + { + HtlcMinimumAmount = payload.HtlcMinimumAmount, + ChannelReserveAmount = payload.ChannelReserveAmount, + OurChannelReserveAmount = ourChannelReserveAmount, + MaxAcceptedHtlcs = payload.MaxAcceptedHtlcs, + DustLimitAmount = payload.DustLimitAmount, + ToSelfDelay = payload.ToSelfDelay + }; + } +} \ No newline at end of file diff --git a/src/NLightning.Domain/Channels/ValueObjects/ChannelConfig.cs b/src/NLightning.Domain/Channels/ValueObjects/ChannelConfig.cs index cdf6e45d..864add80 100644 --- a/src/NLightning.Domain/Channels/ValueObjects/ChannelConfig.cs +++ b/src/NLightning.Domain/Channels/ValueObjects/ChannelConfig.cs @@ -6,7 +6,7 @@ namespace NLightning.Domain.Channels.ValueObjects; public readonly record struct ChannelConfig { - public LightningMoney? ChannelReserveAmount { get; } + public LightningMoney ChannelReserveAmount { get; } public LightningMoney LocalDustLimitAmount { get; } public LightningMoney FeeRateAmountPerKw { get; } public LightningMoney HtlcMinimumAmount { get; } @@ -20,7 +20,7 @@ public readonly record struct ChannelConfig public BitcoinScript? LocalUpfrontShutdownScript { get; } public BitcoinScript? RemoteShutdownScriptPubKey { get; } - public ChannelConfig(LightningMoney? channelReserveAmount, LightningMoney feeRateAmountPerKw, + public ChannelConfig(LightningMoney channelReserveAmount, LightningMoney feeRateAmountPerKw, LightningMoney htlcMinimumAmount, LightningMoney localDustLimitAmount, ushort maxAcceptedHtlcs, LightningMoney maxHtlcAmountInFlight, uint minimumDepth, bool optionAnchorOutputs, LightningMoney remoteDustLimitAmount, ushort toSelfDelay, diff --git a/src/NLightning.Domain/Channels/ValueObjects/ChannelId.cs b/src/NLightning.Domain/Channels/ValueObjects/ChannelId.cs index 75ea3e6e..f328af71 100644 --- a/src/NLightning.Domain/Channels/ValueObjects/ChannelId.cs +++ b/src/NLightning.Domain/Channels/ValueObjects/ChannelId.cs @@ -64,6 +64,7 @@ public override int GetHashCode() public static implicit operator byte[](ChannelId c) => c._value; public static implicit operator ReadOnlyMemory(ChannelId c) => c._value; + public static implicit operator ReadOnlySpan(ChannelId c) => c._value; public static implicit operator ChannelId(byte[] value) => new(value); public static implicit operator ChannelId(Span value) => new(value); diff --git a/src/NLightning.Transport.Ipc/Constants/ErrorCodes.cs b/src/NLightning.Domain/Client/Constants/ErrorCodes.cs similarity index 75% rename from src/NLightning.Transport.Ipc/Constants/ErrorCodes.cs rename to src/NLightning.Domain/Client/Constants/ErrorCodes.cs index c06d8688..a23dd5d3 100644 --- a/src/NLightning.Transport.Ipc/Constants/ErrorCodes.cs +++ b/src/NLightning.Domain/Client/Constants/ErrorCodes.cs @@ -1,4 +1,4 @@ -namespace NLightning.Transport.Ipc.Constants; +namespace NLightning.Domain.Client.Constants; public static class ErrorCodes { @@ -7,4 +7,5 @@ public static class ErrorCodes public const string InvalidOperation = "invalid_operation"; public const string ConnectionError = "connection_error"; public const string ServerError = "server_error"; + public const string NotEnoughBalance = "not_enough_balance"; } \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/NodeIpcCommand.cs b/src/NLightning.Domain/Client/Enums/ClientCommand.cs similarity index 52% rename from src/NLightning.Transport.Ipc/NodeIpcCommand.cs rename to src/NLightning.Domain/Client/Enums/ClientCommand.cs index 3f3537b2..14315466 100644 --- a/src/NLightning.Transport.Ipc/NodeIpcCommand.cs +++ b/src/NLightning.Domain/Client/Enums/ClientCommand.cs @@ -1,9 +1,9 @@ -namespace NLightning.Transport.Ipc; +namespace NLightning.Domain.Client.Enums; /// -/// Commands supported by the IPC protocol. +/// Commands sent by a client. /// -public enum NodeIpcCommand +public enum ClientCommand { // Reserve 0 for unknown Unknown = 0, @@ -11,5 +11,6 @@ public enum NodeIpcCommand ConnectPeer = 2, ListPeers = 3, GetAddress = 4, - WalletBalance = 5 + WalletBalance = 5, + OpenChannel = 6 } \ No newline at end of file diff --git a/src/NLightning.Domain/Client/Exceptions/ClientException.cs b/src/NLightning.Domain/Client/Exceptions/ClientException.cs new file mode 100644 index 00000000..e8bfcc4e --- /dev/null +++ b/src/NLightning.Domain/Client/Exceptions/ClientException.cs @@ -0,0 +1,16 @@ +namespace NLightning.Domain.Client.Exceptions; + +public class ClientException : Exception +{ + public string ErrorCode { get; set; } + + public ClientException(string errorCode, string message) : base(message) + { + ErrorCode = errorCode; + } + + public ClientException(string errorCode, string message, Exception innerException) : base(message, innerException) + { + ErrorCode = errorCode; + } +} \ No newline at end of file diff --git a/src/NLightning.Domain/Client/Interfaces/INamedPipeIpcService.cs b/src/NLightning.Domain/Client/Interfaces/INamedPipeIpcService.cs new file mode 100644 index 00000000..1dfe75cf --- /dev/null +++ b/src/NLightning.Domain/Client/Interfaces/INamedPipeIpcService.cs @@ -0,0 +1,20 @@ +namespace NLightning.Domain.Client.Interfaces; + +/// +/// Interface for the named pipe ipc service +/// +public interface INamedPipeIpcService +{ + /// + /// Starts the ipc server asynchronously. + /// + /// The cancellation token to monitor for cancellation requests. + /// A task that represents the asynchronous operation. + Task StartAsync(CancellationToken cancellationToken); + + /// + /// Stops the ipc server asynchronously. + /// + /// A task that represents the asynchronous operation. + Task StopAsync(); +} \ No newline at end of file diff --git a/src/NLightning.Domain/Client/Requests/OpenChannelClientRequest.cs b/src/NLightning.Domain/Client/Requests/OpenChannelClientRequest.cs new file mode 100644 index 00000000..a54fa0f6 --- /dev/null +++ b/src/NLightning.Domain/Client/Requests/OpenChannelClientRequest.cs @@ -0,0 +1,24 @@ +namespace NLightning.Domain.Client.Requests; + +using Money; + +public sealed class OpenChannelClientRequest +{ + public string NodeInfo { get; set; } + public LightningMoney FundingAmount { get; set; } + public LightningMoney? HtlcMinimumAmount { get; set; } + public LightningMoney? MaxHtlcValueInFlight { get; set; } + public LightningMoney? ChannelReserveAmount { get; set; } + public ushort? MaxAcceptedHtlcs { get; set; } + public LightningMoney? DustLimitAmount { get; set; } + public LightningMoney? PushAmount { get; set; } + public ushort? ToSelfDelay { get; set; } + public LightningMoney? FeeRatePerKw { get; set; } + public bool IsZeroConfChannel { get; set; } + + public OpenChannelClientRequest(string nodeInfo, LightningMoney fundingAmount) + { + NodeInfo = nodeInfo; + FundingAmount = fundingAmount; + } +} \ No newline at end of file diff --git a/src/NLightning.Domain/Client/Responses/OpenChannelClientResponse.cs b/src/NLightning.Domain/Client/Responses/OpenChannelClientResponse.cs new file mode 100644 index 00000000..027153e6 --- /dev/null +++ b/src/NLightning.Domain/Client/Responses/OpenChannelClientResponse.cs @@ -0,0 +1,18 @@ +namespace NLightning.Domain.Client.Responses; + +using Bitcoin.ValueObjects; +using Channels.ValueObjects; + +public sealed class OpenChannelClientResponse +{ + public SignedTransaction Transaction { get; } + public uint Index { get; } + public ChannelId ChannelId { get; } + + public OpenChannelClientResponse(SignedTransaction transaction, uint index, ChannelId channelId) + { + Transaction = transaction; + Index = index; + ChannelId = channelId; + } +} \ No newline at end of file diff --git a/src/NLightning.Domain/Node/FeatureSet.cs b/src/NLightning.Domain/Node/FeatureSet.cs index cee587a7..c3975645 100644 --- a/src/NLightning.Domain/Node/FeatureSet.cs +++ b/src/NLightning.Domain/Node/FeatureSet.cs @@ -276,6 +276,17 @@ public void WriteToBitWriter(IBitWriter bitWriter, int length, bool shouldPad) /// public bool HasFeature(Feature feature) => IsFeatureSet(feature, false) || IsFeatureSet(feature, true); + public byte[]? GetBytes() + { + var lastIndexOfOne = GetLastIndexOfOne(FeatureFlags); + if (lastIndexOfOne == -1) + return null; + + var bytes = new byte[lastIndexOfOne]; + FeatureFlags.CopyTo(bytes, 0); + return bytes; + } + /// /// Deserializes the features from a byte array. /// diff --git a/src/NLightning.Domain/Node/Interfaces/IPeerManager.cs b/src/NLightning.Domain/Node/Interfaces/IPeerManager.cs index 4e058a24..aa0d9a6e 100644 --- a/src/NLightning.Domain/Node/Interfaces/IPeerManager.cs +++ b/src/NLightning.Domain/Node/Interfaces/IPeerManager.cs @@ -1,9 +1,9 @@ -using NLightning.Domain.Crypto.ValueObjects; -using NLightning.Domain.Node.Models; -using NLightning.Domain.Node.ValueObjects; - namespace NLightning.Domain.Node.Interfaces; +using Crypto.ValueObjects; +using Models; +using ValueObjects; + /// /// Interface for the peer manager. /// @@ -36,4 +36,5 @@ public interface IPeerManager void DisconnectPeer(CompactPubKey compactPubKey); List ListPeers(); + PeerModel? GetPeer(CompactPubKey peerId); } \ No newline at end of file diff --git a/src/NLightning.Domain/Node/Models/PeerModel.cs b/src/NLightning.Domain/Node/Models/PeerModel.cs index 3063290c..b28c24a0 100644 --- a/src/NLightning.Domain/Node/Models/PeerModel.cs +++ b/src/NLightning.Domain/Node/Models/PeerModel.cs @@ -5,6 +5,7 @@ namespace NLightning.Domain.Node.Models; using Channels.Models; using Crypto.ValueObjects; using Interfaces; +using Options; using ValueObjects; public class PeerModel @@ -29,6 +30,16 @@ public FeatureSet Features } } + public FeatureOptions NegotiatedFeatures + { + get + { + return _peerService is null + ? throw new NullReferenceException($"{nameof(PeerModel)}.{nameof(Features)} was null") + : _peerService.Features; + } + } + public PeerAddressInfo PeerAddressInfo { get diff --git a/src/NLightning.Domain/Node/Options/NodeOptions.cs b/src/NLightning.Domain/Node/Options/NodeOptions.cs index 2edd6e8e..7dd7d49e 100644 --- a/src/NLightning.Domain/Node/Options/NodeOptions.cs +++ b/src/NLightning.Domain/Node/Options/NodeOptions.cs @@ -57,5 +57,4 @@ public class NodeOptions public uint AllowUpToPercentageOfChannelFundsInFlight { get; set; } = 80; public uint MinimumDepth { get; set; } = 3; public LightningMoney MinimumChannelSize { get; set; } = LightningMoney.Satoshis(20_000); - public LightningMoney ChannelReserveAmount { get; set; } = LightningMoney.Satoshis(546); } \ No newline at end of file diff --git a/src/NLightning.Domain/Persistence/Interfaces/IUnitOfWork.cs b/src/NLightning.Domain/Persistence/Interfaces/IUnitOfWork.cs index 3799c08e..686afd34 100644 --- a/src/NLightning.Domain/Persistence/Interfaces/IUnitOfWork.cs +++ b/src/NLightning.Domain/Persistence/Interfaces/IUnitOfWork.cs @@ -1,11 +1,11 @@ -using NLightning.Domain.Bitcoin.Wallet.Models; -using NLightning.Domain.Node.Models; - namespace NLightning.Domain.Persistence.Interfaces; using Bitcoin.Interfaces; +using Bitcoin.ValueObjects; +using Bitcoin.Wallet.Models; using Channels.Interfaces; using Node.Interfaces; +using Node.Models; public interface IUnitOfWork : IDisposable { @@ -26,6 +26,7 @@ public interface IUnitOfWork : IDisposable Task> GetPeersForStartupAsync(); void AddUtxo(UtxoModel utxoModel); + void TrySpendUtxo(TxId transactionId, uint index); void SaveChanges(); Task SaveChangesAsync(); diff --git a/src/NLightning.Domain/Protocol/Interfaces/IChannelIdFactory.cs b/src/NLightning.Domain/Protocol/Interfaces/IChannelIdFactory.cs index 12d3c610..b9f61c7e 100644 --- a/src/NLightning.Domain/Protocol/Interfaces/IChannelIdFactory.cs +++ b/src/NLightning.Domain/Protocol/Interfaces/IChannelIdFactory.cs @@ -6,6 +6,7 @@ namespace NLightning.Domain.Protocol.Interfaces; public interface IChannelIdFactory { + ChannelId CreateTemporaryChannelId(); ChannelId CreateV1(TxId fundingTxId, ushort fundingOutputIndex); ChannelId CreateV2(CompactPubKey lesserRevocationBasepoint, CompactPubKey greaterRevocationBasepoint); } \ No newline at end of file diff --git a/src/NLightning.Domain/Protocol/Interfaces/IMessageFactory.cs b/src/NLightning.Domain/Protocol/Interfaces/IMessageFactory.cs index 3b37fba5..f24dcda9 100644 --- a/src/NLightning.Domain/Protocol/Interfaces/IMessageFactory.cs +++ b/src/NLightning.Domain/Protocol/Interfaces/IMessageFactory.cs @@ -87,10 +87,10 @@ AcceptChannel2Message CreateAcceptChannel2Message(ChannelId temporaryChannelId, BitcoinScript? shutdownScriptPubkey = null, byte[]? channelType = null, bool requireConfirmedInputs = false); - FundingCreatedMessage CreatedFundingCreatedMessage(ChannelId temporaryChannelId, TxId fundingTxId, - ushort fundingOutputIndex, CompactSignature signature); + FundingCreatedMessage CreateFundingCreatedMessage(ChannelId temporaryChannelId, TxId fundingTxId, + ushort fundingOutputIndex, CompactSignature signature); - FundingSignedMessage CreatedFundingSignedMessage(ChannelId channelId, CompactSignature signature); + FundingSignedMessage CreateFundingSignedMessage(ChannelId channelId, CompactSignature signature); UpdateAddHtlcMessage CreateUpdateAddHtlcMessage(ChannelId channelId, ulong id, ulong amountMsat, ReadOnlyMemory paymentHash, uint cltvExpiry, diff --git a/src/NLightning.Infrastructure.Bitcoin/Builders/FundingTransactionBuilder.cs b/src/NLightning.Infrastructure.Bitcoin/Builders/FundingTransactionBuilder.cs new file mode 100644 index 00000000..9059dbf5 --- /dev/null +++ b/src/NLightning.Infrastructure.Bitcoin/Builders/FundingTransactionBuilder.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NBitcoin; + +namespace NLightning.Infrastructure.Bitcoin.Builders; + +using Domain.Bitcoin.Transactions.Constants; +using Domain.Bitcoin.Transactions.Models; +using Domain.Bitcoin.ValueObjects; +using Domain.Node.Options; +using Interfaces; +using Outputs; + +public class FundingTransactionBuilder : IFundingTransactionBuilder +{ + private readonly Network _network; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public FundingTransactionBuilder(IOptions nodeOptions, IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + _network = Network.GetNetwork(nodeOptions.Value.BitcoinNetwork) ?? + throw new ArgumentException("Invalid Bitcoin network specified", nameof(nodeOptions)); + } + + public SignedTransaction Build(FundingTransactionModel transaction) + { + var coins = transaction.Utxos.ToArray(); + if (coins.Length == 0) + throw new ArgumentException("UTXO set cannot be empty"); + + var totalInputAmount = coins.Sum(x => x.Amount); + + _logger.LogTrace("Building funding transaction with {UtxoCount} UTXOs for amount {FundingAmount}", + coins.Length, transaction.FundingOutput.Amount); + + // Create a new Bitcoin transaction + var tx = Transaction.Create(_network); + + // Set the transaction version as per BOLT spec + tx.Version = TransactionConstants.FundingTransactionVersion; + + // Add all inputs from the UTXO set + foreach (var coin in coins) + tx.Inputs.Add(new OutPoint(new uint256(coin.TxId), coin.Index)); + + // Convert and add the funding output + var fundingOutput = new FundingOutput(transaction.FundingOutput.Amount, + new PubKey(transaction.FundingOutput.LocalFundingPubKey), + new PubKey(transaction.FundingOutput.RemoteFundingPubKey)); + tx.Outputs.Add(fundingOutput.ToTxOut()); + + // Check if we are paying a change address + if (transaction.ChangeAddress is not null) + { + var changeAmount = totalInputAmount - transaction.Fee - fundingOutput.Amount; + tx.Outputs.Add(new TxOut(new Money(changeAmount.Satoshi), + _network.CreateBitcoinAddress(transaction.ChangeAddress.Address))); + } + + // Update the funding output info with transaction details + transaction.FundingOutput.TransactionId = tx.GetHash().ToBytes(); + transaction.FundingOutput.Index = 0; + + _logger.LogInformation("Built funding transaction {TxId} with funding output at index 0", tx.GetHash()); + + // Return as SignedTransaction (note: needs to be signed by the signer afterwards) + return new SignedTransaction(tx.GetHash().ToBytes(), tx.ToBytes()); + } +} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Bitcoin/Builders/Interfaces/IFundingTransactionBuilder.cs b/src/NLightning.Infrastructure.Bitcoin/Builders/Interfaces/IFundingTransactionBuilder.cs new file mode 100644 index 00000000..5a7779d7 --- /dev/null +++ b/src/NLightning.Infrastructure.Bitcoin/Builders/Interfaces/IFundingTransactionBuilder.cs @@ -0,0 +1,14 @@ +namespace NLightning.Infrastructure.Bitcoin.Builders.Interfaces; + +using Domain.Bitcoin.Transactions.Models; +using Domain.Bitcoin.ValueObjects; + +public interface IFundingTransactionBuilder +{ + /// + /// Builds a funding transaction from UTXOs + /// + /// The funding transaction model + /// A signed transaction with the funding output + SignedTransaction Build(FundingTransactionModel transaction); +} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Bitcoin/DependencyInjection.cs b/src/NLightning.Infrastructure.Bitcoin/DependencyInjection.cs index 30097371..b9361029 100644 --- a/src/NLightning.Infrastructure.Bitcoin/DependencyInjection.cs +++ b/src/NLightning.Infrastructure.Bitcoin/DependencyInjection.cs @@ -1,16 +1,16 @@ using Microsoft.Extensions.DependencyInjection; -using NLightning.Infrastructure.Bitcoin.Builders.Interfaces; -using NLightning.Infrastructure.Bitcoin.Wallet; -using NLightning.Infrastructure.Bitcoin.Wallet.Interfaces; namespace NLightning.Infrastructure.Bitcoin; using Builders; +using Builders.Interfaces; using Crypto.Functions; using Domain.Protocol.Interfaces; using Infrastructure.Crypto.Interfaces; using Protocol.Factories; using Services; +using Wallet; +using Wallet.Interfaces; /// /// Extension methods for setting up Bitcoin infrastructure services in an IServiceCollection. @@ -31,6 +31,7 @@ public static IServiceCollection AddBitcoinInfrastructure(this IServiceCollectio services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/NLightning.Infrastructure.Bitcoin/Managers/SecureKeyManager.cs b/src/NLightning.Infrastructure.Bitcoin/Managers/SecureKeyManager.cs index ca746403..8b289372 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Managers/SecureKeyManager.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Managers/SecureKeyManager.cs @@ -243,12 +243,9 @@ public static SecureKeyManager FromFilePath(string filePath, BitcoinNetwork expe /// /// Gets the path for the Key file /// - public static string GetKeyFilePath(string network) + public static string GetKeyFilePath(string configPath) { - var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var networkDir = Path.Combine(homeDir, ".nltg", network); - Directory.CreateDirectory(networkDir); // Ensure directory exists - return Path.Combine(networkDir, "nltg.key.json"); //DaemonConstants.KeyFile); + return Path.Combine(configPath, "nltg.key.json"); } private ExtKey GetMasterKey() diff --git a/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs b/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs index e24bcf99..c7ebb161 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs @@ -2,13 +2,15 @@ using Microsoft.Extensions.Logging; using NBitcoin; using NBitcoin.Crypto; -using NLightning.Domain.Bitcoin.Transactions.Outputs; namespace NLightning.Infrastructure.Bitcoin.Signers; using Builders; +using Domain.Bitcoin.Enums; using Domain.Bitcoin.Interfaces; +using Domain.Bitcoin.Transactions.Outputs; using Domain.Bitcoin.ValueObjects; +using Domain.Bitcoin.Wallet.Models; using Domain.Channels.ValueObjects; using Domain.Crypto.Constants; using Domain.Crypto.ValueObjects; @@ -26,20 +28,23 @@ public class LocalLightningSigner : ILightningSigner private const int PerCommitmentSeedDerivationIndex = 5; // m/5' is the per-commitment seed private readonly ISecureKeyManager _secureKeyManager; + private readonly IUtxoMemoryRepository _utxoMemoryRepository; private readonly IFundingOutputBuilder _fundingOutputBuilder; private readonly IKeyDerivationService _keyDerivationService; private readonly ConcurrentDictionary _channelSigningInfo = new(); private readonly ILogger _logger; private readonly Network _network; - public LocalLightningSigner(IFundingOutputBuilder fundingOutputBuilder, IKeyDerivationService keyDerivationService, - ILogger logger, NodeOptions nodeOptions, - ISecureKeyManager secureKeyManager) + public LocalLightningSigner(IFundingOutputBuilder fundingOutputBuilder, + IKeyDerivationService keyDerivationService, ILogger logger, + NodeOptions nodeOptions, ISecureKeyManager secureKeyManager, + IUtxoMemoryRepository utxoMemoryRepository) { _fundingOutputBuilder = fundingOutputBuilder; _keyDerivationService = keyDerivationService; _logger = logger; _secureKeyManager = secureKeyManager; + _utxoMemoryRepository = utxoMemoryRepository; _network = Network.GetNetwork(nodeOptions.BitcoinNetwork) ?? throw new ArgumentException("Invalid Bitcoin network specified", nameof(nodeOptions)); @@ -110,7 +115,7 @@ public ChannelBasepoints GetChannelBasepoints(ChannelId channelId) _logger.LogTrace("Retrieving channel basepoints for channel {ChannelId}", channelId); if (!_channelSigningInfo.TryGetValue(channelId, out var signingInfo)) - throw new InvalidOperationException($"Channel {channelId} not registered"); + throw new SignerException($"Channel {channelId} not registered", channelId); return GetChannelBasepoints(signingInfo.ChannelKeyIndex); } @@ -141,7 +146,7 @@ public CompactPubKey GetPerCommitmentPoint(uint channelKeyIndex, ulong commitmen public CompactPubKey GetPerCommitmentPoint(ChannelId channelId, ulong commitmentNumber) { if (!_channelSigningInfo.TryGetValue(channelId, out var signingInfo)) - throw new InvalidOperationException($"Channel {channelId} not registered"); + throw new SignerException($"Channel {channelId} not registered", channelId); return GetPerCommitmentPoint(signingInfo.ChannelKeyIndex, commitmentNumber); } @@ -174,13 +179,206 @@ public Secret ReleasePerCommitmentSecret(uint channelKeyIndex, ulong commitmentN public Secret ReleasePerCommitmentSecret(ChannelId channelId, ulong commitmentNumber) { if (!_channelSigningInfo.TryGetValue(channelId, out var signingInfo)) - throw new InvalidOperationException($"Channel {channelId} not registered"); + throw new SignerException($"Channel {channelId} not registered", channelId); return ReleasePerCommitmentSecret(signingInfo.ChannelKeyIndex, commitmentNumber); } + public bool SignWalletTransaction(SignedTransaction unsignedTransaction) + { + throw new NotImplementedException(); + } + + public bool SignFundingTransaction(ChannelId channelId, SignedTransaction unsignedTransaction) + { + _logger.LogTrace("Signing funding transaction for channel {ChannelId} with TxId {TxId}", channelId, + unsignedTransaction.TxId); + + if (!_channelSigningInfo.TryGetValue(channelId, out var signingInfo)) + throw new SignerException($"Channel {channelId} not registered with signer", channelId); + + Transaction nBitcoinTx; + try + { + nBitcoinTx = Transaction.Load(unsignedTransaction.RawTxBytes, _network); + } + catch (Exception ex) + { + throw new ArgumentException( + $"Failed to load transaction from RawTxBytes. TxId hint: {unsignedTransaction.TxId}", ex); + } + + try + { + // Verify the funding output exists and is correct + if (signingInfo.FundingOutputIndex >= nBitcoinTx.Outputs.Count) + throw new SignerException($"Funding output index {signingInfo.FundingOutputIndex} is out of range", + channelId); + + // Build the funding output using the channel's signing info + var fundingOutputInfo = new FundingOutputInfo(signingInfo.FundingSatoshis, signingInfo.LocalFundingPubKey, + signingInfo.RemoteFundingPubKey, signingInfo.FundingTxId, + signingInfo.FundingOutputIndex); + + var expectedFundingOutput = _fundingOutputBuilder.Build(fundingOutputInfo); + var expectedTxOut = expectedFundingOutput.ToTxOut(); + + // Validate the transaction output matches what we expect + var actualTxOut = nBitcoinTx.Outputs[signingInfo.FundingOutputIndex]; + if (!actualTxOut.ToBytes().SequenceEqual(expectedTxOut.ToBytes())) + throw new SignerException("Funding output script does not match expected script", channelId); + + if (actualTxOut.Value != expectedTxOut.Value) + throw new SignerException( + $"Funding output amount {actualTxOut.Value} does not match expected amount {expectedTxOut.Value}", + channelId); + + _logger.LogDebug("Funding output validation passed for channel {ChannelId}", channelId); + + // Check transaction structure + if (nBitcoinTx.Inputs.Count == 0) + throw new SignerException("Funding transaction has no inputs", channelId); + + // Get the utxoSet for the channel + var utxoModels = _utxoMemoryRepository.GetLockedUtxosForChannel(channelId); + + var signedInputCount = 0; + var prevOuts = new TxOut[nBitcoinTx.Inputs.Count]; + var signingKeys = new Key[nBitcoinTx.Inputs.Count]; + var utxos = new UtxoModel[nBitcoinTx.Inputs.Count]; + + // Sign each input + for (var i = 0; i < nBitcoinTx.Inputs.Count; i++) + { + var input = nBitcoinTx.Inputs[i]; + + // Try to get the address being spent + var utxo = utxoModels.FirstOrDefault(x => x.TxId.Equals(new TxId(input.PrevOut.Hash.ToBytes())) + && x.Index.Equals(input.PrevOut.N)); + if (utxo is null) + { + _logger.LogWarning("Could not find UTXO for input {InputIndex} in funding transaction", i); + continue; + } + + if (utxo.WalletAddress is null) + { + _logger.LogWarning( + "UTXO did not have a WalletAddress for input {InputIndex} in funding transaction", i); + continue; + } + + utxos[i] = utxo; + + try + { + // Create the scriptPubKey and previous output based on address type + Script scriptPubKey; + ExtPrivKey signingExtKey; + Key signingKey; + + switch (utxo.AddressType) + { + case AddressType.P2Wpkh: + // Derive the key for this specific UTXO + signingExtKey = + _secureKeyManager.GetDepositP2WpkhKeyAtIndex( + utxo.WalletAddress.Index, utxo.WalletAddress.IsChange); + signingKey = ExtKey.CreateFromBytes(signingExtKey).PrivateKey; + // For P2WPKH: OP_0 <20-byte-pubkey-hash> + scriptPubKey = signingKey.PubKey.WitHash.ScriptPubKey; + break; + + case AddressType.P2Tr: + // Derive the key for this specific UTXO + signingExtKey = + _secureKeyManager.GetDepositP2TrKeyAtIndex( + utxo.WalletAddress.Index, utxo.WalletAddress.IsChange); + signingKey = ExtKey.CreateFromBytes(signingExtKey).PrivateKey; + // For P2TR (Taproot): OP_1 <32-byte-taproot-output> + scriptPubKey = signingKey.PubKey.GetTaprootFullPubKey().ScriptPubKey; + break; + + default: + throw new SignerException($"Unsupported address type {utxo.AddressType} for input {i}", + channelId); + } + + signingKeys[i] = signingKey; + prevOuts[i] = new TxOut(new Money(utxo.Amount.Satoshi), scriptPubKey); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to sign input {InputIndex} in funding transaction", i); + throw new SignerException( + $"Failed to sign input {i}", + channelId, ex, "Signing error"); + } + } + + for (var i = 0; i < nBitcoinTx.Inputs.Count; i++) + { + try + { + var utxo = utxos[i]; + var signingKey = signingKeys[i]; + var prevOut = prevOuts[i]; + + switch (utxo.AddressType) + { + // Sign based on the address type + case AddressType.P2Wpkh: + // Sign P2WPKH input + SignP2WpkhInput(nBitcoinTx, i, signingKey, prevOut); + break; + case AddressType.P2Tr: + // Sign P2TR (Taproot) input - key path spend + SignP2TrInput(nBitcoinTx, i, signingKey, prevOuts); + break; + default: + throw new SignerException($"Unsupported address type {utxo.AddressType} for input {i}", + channelId); + } + + signedInputCount++; + + _logger.LogTrace("Signed input {InputIndex} for funding transaction", i); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to sign input {InputIndex} in funding transaction", i); + throw new SignerException( + $"Failed to sign input {i}", + channelId, ex, "Signing error"); + } + } + + if (signedInputCount == 0) + throw new SignerException("No inputs were successfully signed", channelId, "Signing failed"); + + // Update the transaction bytes in the SignedTransaction + var signedBytes = nBitcoinTx.ToBytes(); + Array.Copy(signedBytes, unsignedTransaction.RawTxBytes, signedBytes.Length); + + _logger.LogInformation( + "Successfully signed {SignedCount}/{TotalCount} inputs for funding transaction {TxId}", + signedInputCount, nBitcoinTx.Inputs.Count, nBitcoinTx.GetHash()); + + return signedInputCount == nBitcoinTx.Inputs.Count; + } + catch (SignerException) + { + throw; + } + catch (Exception e) + { + throw new SignerException($"Exception during funding transaction signing for TxId {nBitcoinTx.GetHash()}", + channelId, e); + } + } + /// - public CompactSignature SignTransaction(ChannelId channelId, SignedTransaction unsignedTransaction) + public CompactSignature SignChannelTransaction(ChannelId channelId, SignedTransaction unsignedTransaction) { _logger.LogTrace("Signing transaction for channel {ChannelId} with TxId {TxId}", channelId, unsignedTransaction.TxId); @@ -210,9 +408,8 @@ public CompactSignature SignTransaction(ChannelId channelId, SignedTransaction u var spentOutput = fundingOutput.ToTxOut(); // Get the signature hash for SegWit - var signatureHash = nBitcoinTx.GetSignatureHash(fundingOutput.RedeemScript, - signingInfo.FundingOutputIndex, SigHash.All, - spentOutput, HashVersion.WitnessV0); + var signatureHash = nBitcoinTx.GetSignatureHash(fundingOutput.RedeemScript, signingInfo.FundingOutputIndex, + SigHash.All, spentOutput, HashVersion.WitnessV0); // Get the funding private key using var fundingPrivateKey = GenerateFundingPrivateKey(signingInfo.ChannelKeyIndex); @@ -309,8 +506,48 @@ protected virtual Key GenerateFundingPrivateKey(uint channelKeyIndex) return GenerateFundingPrivateKey(channelKey); } - private Key GenerateFundingPrivateKey(ExtKey extKey) + private static Key GenerateFundingPrivateKey(ExtKey extKey) { return extKey.Derive(FundingDerivationIndex, true).PrivateKey; } + + /// + /// Sign a P2WPKH (Pay-to-Witness-PubKey-Hash) input + /// + private void SignP2WpkhInput(Transaction tx, int inputIndex, Key signingKey, TxOut prevOut) + { + // Get the signature hash for SegWit v0 + var sigHash = + tx.GetSignatureHash(prevOut.ScriptPubKey, inputIndex, SigHash.All, prevOut, HashVersion.WitnessV0); + + // Sign the hash + var signature = signingKey.Sign(sigHash, new SigningOptions(SigHash.All, false)); + + // For P2WPKH, witness is: + var witness = new WitScript( + Op.GetPushOp(signature.Signature.ToDER()), + Op.GetPushOp(signingKey.PubKey.ToBytes())); + + tx.Inputs[inputIndex].WitScript = witness; + } + + /// + /// Sign a P2TR (Pay-to-Taproot) input using the key path spend + /// + /// For Taproot, we use BIP341 signing + private static void SignP2TrInput(Transaction tx, int inputIndex, Key signingKey, TxOut[] prevOuts) + { + // Create the TaprootExecutionData + // var taprootPubKey = signingKey.PubKey.GetTaprootFullPubKey(); + var taprootExecutionData = new TaprootExecutionData(inputIndex); + + // Calculate the signature hash using Taproot rules (BIP341) + var sigHash = tx.GetSignatureHashTaproot(prevOuts.ToArray(), taprootExecutionData); + + // Sign with Schnorr signature (BIP340) + var taprootSignature = signingKey.SignTaprootKeySpend(sigHash, TaprootSigHash.All); + + // For key path spend, witness is just: + tx.Inputs[inputIndex].WitScript = new WitScript(Op.GetPushOp(taprootSignature.ToBytes())); + } } \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Bitcoin/Transactions/BaseTransaction.cs b/src/NLightning.Infrastructure.Bitcoin/Transactions/BaseTransaction.cs index 26e3fd26..16357f68 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Transactions/BaseTransaction.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Transactions/BaseTransaction.cs @@ -212,7 +212,7 @@ // } // else // { -// inputWeight += 4 * Math.Max(WeightConstants.P2UnknownSInputWeight, input.ToBytes().Length); +// inputWeight += 4 * Math.Max(WeightConstants.P2UnknownInputWeight, input.ToBytes().Length); // inputWeight += input.WitScript.ToBytes().Length; // } // } diff --git a/src/NLightning.Infrastructure.Bitcoin/Transactions/FundingTransaction.cs b/src/NLightning.Infrastructure.Bitcoin/Transactions/FundingTransaction.cs index b0a1be38..1c63a752 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Transactions/FundingTransaction.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Transactions/FundingTransaction.cs @@ -48,6 +48,7 @@ // AddOutput(FundingOutput); // AddOutput(ChangeOutput); // } +// // internal FundingTransaction(LightningMoney dustLimitAmount, bool hasAnchorOutput, Network network, PubKey pubkey1, // PubKey pubkey2, LightningMoney amountSats, Script redeemScript, Script changeScript, // params Coin[] coins) @@ -100,10 +101,10 @@ // var changeIndex = Outputs.IndexOf(ChangeOutput); // // FundingOutput.Index = hasChange -// ? changeIndex == 0 -// ? 1 -// : 0 -// : 0; +// ? changeIndex == 0 +// ? 1 +// : 0 +// : 0; // // if (hasChange) // { diff --git a/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinWalletService.cs b/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinWalletService.cs index dc6d7962..e636e670 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinWalletService.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinWalletService.cs @@ -29,9 +29,9 @@ public BitcoinWalletService(ILogger logger, IOptions GetUnusedAddressAsync(AddressType addressType, bool isChange) + public async Task GetUnusedAddressAsync(AddressType addressType, bool isChange) { - if ((int)addressType > 2) + if (addressType is not (AddressType.P2Wpkh or AddressType.P2Tr)) throw new InvalidOperationException( "You cannot use flags for this method. Please select only one address type."); @@ -39,11 +39,14 @@ public async Task GetUnusedAddressAsync(AddressType addressType, bool is var addressModel = await _uow.WalletAddressesDbRepository.GetUnusedAddressAsync(addressType, isChange); if (addressModel is not null) - return addressModel.Address; + return addressModel; // If there's none, get the last used index from db var lastUsedIndex = await _uow.WalletAddressesDbRepository.GetLastUsedAddressIndex(addressType, isChange); + _logger.LogInformation("Generating 10 new {addressType} {change}addresses and saving to the database.", + Enum.GetName(addressType), isChange ? "change " : string.Empty); + // Generate 10 new addresses var addressList = new List(10); for (var i = lastUsedIndex; i < lastUsedIndex + 10; i++) @@ -70,6 +73,6 @@ public async Task GetUnusedAddressAsync(AddressType addressType, bool is _uow.WalletAddressesDbRepository.AddRange(addressList); await _uow.SaveChangesAsync(); - return addressList[0].Address; + return addressList[0]; } } \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs b/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs index bc6fb367..f49a6611 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs @@ -120,9 +120,7 @@ public async Task StartAsync(uint heightOfBirth, CancellationToken cancellationT public async Task StopAsync() { if (_cts is null) - { throw new InvalidOperationException("Service is not running"); - } await _cts.CancelAsync(); @@ -141,6 +139,23 @@ public async Task StopAsync() CleanupZmqSockets(); } + public async Task PublishAndWatchTransactionAsync(ChannelId channelId, SignedTransaction signedTransaction, + uint requiredDepth) + { + _logger.LogInformation( + "Publishing transaction {TxId} for {RequiredDepth} confirmations for channel {channelId}", + signedTransaction.TxId, requiredDepth, channelId); + + // Convert the tx + var transaction = Transaction.Load(signedTransaction.RawTxBytes, _network); + + // Start watching the tx + await WatchTransactionAsync(channelId, signedTransaction.TxId, requiredDepth); + + // Publish the tx + await _bitcoinChainService.SendTransactionAsync(transaction); + } + public async Task WatchTransactionAsync(ChannelId channelId, TxId txId, uint requiredDepth) { _logger.LogInformation("Watching transaction {TxId} for {RequiredDepth} confirmations for channel {channelId}", @@ -401,7 +416,7 @@ private void ProcessBlock(Block block, uint height, IUnitOfWork uow) CheckBlockForWatchedTransactions(block.Transactions, height, uow); // Check for deposits in this block - CheckBlockForDeposits(block.Transactions, height, uow); + CheckBlockForWalletMovement(block.Transactions, height, uow); // Update blockchain state _blockchainState.UpdateState(blockHash.ToBytes(), height); @@ -472,19 +487,19 @@ private void CheckBlockForWatchedTransactions(List blockTransaction } } - private void CheckBlockForDeposits(List transactions, uint blockHeight, IUnitOfWork uow) + private void CheckBlockForWalletMovement(List transactions, uint blockHeight, IUnitOfWork uow) { if (_watchedAddresses.IsEmpty) return; - _logger.LogDebug("Checking {AddressCount} watched addresses for deposits in block {Height}", + _logger.LogDebug("Checking {AddressCount} watched addresses for deposits/spends in block {Height}", _watchedAddresses.Count, blockHeight); foreach (var transaction in transactions) { var txId = transaction.GetHash(); - // Check each output + // Check each output for deposits for (var i = 0; i < transaction.Outputs.Count; i++) { var output = transaction.Outputs[i]; @@ -499,18 +514,19 @@ private void CheckBlockForDeposits(List transactions, uint blockHei "Deposit detected: {amount} to address {destinationAddress} in tx {txId} at block {height}", output.Value, destinationAddress, txId, blockHeight); - watchedAddress.IncrementUtxoQty(); - uow.WalletAddressesDbRepository.UpdateAsync(watchedAddress); - // Save Utxo to the database var utxo = new UtxoModel(txId.ToBytes(), (uint)i, LightningMoney.Satoshis(output.Value.Satoshi), - blockHeight); + blockHeight, watchedAddress); uow.AddUtxo(utxo); if (!_watchedAddresses.TryRemove(destinationAddress.ToString(), out _)) _logger.LogError("Unable to remove watched address {DestinationAddress} from the list", destinationAddress); } + + // Check each input for spent utxos + foreach (var input in transaction.Inputs) + uow.TrySpendUtxo(new TxId(input.PrevOut.Hash.ToBytes()), input.PrevOut.N); } } @@ -557,11 +573,12 @@ private async Task LoadUtxoSetAsync(IUnitOfWork uow) { _logger.LogInformation("Loading Utxo set"); - var utxoSet = (await uow.UtxoDbRepository.GetAllAsync()).ToList(); + var utxoSet = (await uow.UtxoDbRepository.GetUnspentAsync()).ToList(); if (utxoSet.Count > 0) { - var utxoMemoryRepository = _serviceProvider.GetService() - ?? throw new InvalidOperationException("UtxoMemoryRepository not found"); + var utxoMemoryRepository = _serviceProvider.GetService() ?? + throw new InvalidOperationException( + $"Error getting required service {nameof(IUtxoMemoryRepository)}"); utxoMemoryRepository.Load(utxoSet); } } diff --git a/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBitcoinWalletService.cs b/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBitcoinWalletService.cs index 56869a7d..5f3b9590 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBitcoinWalletService.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBitcoinWalletService.cs @@ -1,8 +1,9 @@ namespace NLightning.Infrastructure.Bitcoin.Wallet.Interfaces; using Domain.Bitcoin.Enums; +using Domain.Bitcoin.Wallet.Models; public interface IBitcoinWalletService { - Task GetUnusedAddressAsync(AddressType addressType, bool isChange); + Task GetUnusedAddressAsync(AddressType addressType, bool isChange); } \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBlockchainMonitor.cs b/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBlockchainMonitor.cs index 9f613dfd..47817b7c 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBlockchainMonitor.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBlockchainMonitor.cs @@ -11,6 +11,7 @@ public interface IBlockchainMonitor event EventHandler OnNewBlockDetected; event EventHandler OnTransactionConfirmed; + Task PublishAndWatchTransactionAsync(ChannelId channelId, SignedTransaction signedTransaction, uint requiredDepth); Task WatchTransactionAsync(ChannelId channelId, TxId txId, uint requiredDepth); void WatchBitcoinAddress(WalletAddressModel walletAddress); diff --git a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027190251_AddPeerTypeWalletAddressesAndUtxos.cs b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027190251_AddPeerTypeWalletAddressesAndUtxos.cs deleted file mode 100644 index 1b356f15..00000000 --- a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027190251_AddPeerTypeWalletAddressesAndUtxos.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace NLightning.Infrastructure.Persistence.Postgres.Migrations -{ - /// - public partial class AddPeerTypeWalletAddressesAndUtxos : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "type", - table: "peers", - type: "text", - nullable: false, - defaultValue: ""); - - migrationBuilder.CreateTable( - name: "utxos", - columns: table => new - { - transaction_id = table.Column(type: "bytea", nullable: false), - index = table.Column(type: "bigint", nullable: false), - amount_sats = table.Column(type: "bigint", nullable: false), - block_height = table.Column(type: "bigint", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("pk_utxos", x => new { x.transaction_id, x.index }); - }); - - migrationBuilder.CreateTable( - name: "wallet_addresses", - columns: table => new - { - index = table.Column(type: "bigint", nullable: false), - is_change = table.Column(type: "boolean", nullable: false), - address_type = table.Column(type: "smallint", nullable: false), - address = table.Column(type: "text", nullable: false), - utxo_qty = table.Column(type: "bigint", nullable: false, defaultValue: 0L) - }, - constraints: table => - { - table.PrimaryKey("pk_wallet_addresses", x => new { x.index, x.is_change, x.address_type }); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "utxos"); - - migrationBuilder.DropTable( - name: "wallet_addresses"); - - migrationBuilder.DropColumn( - name: "type", - table: "peers"); - } - } -} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027190251_AddPeerTypeWalletAddressesAndUtxos.Designer.cs b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251106194247_AddFieldsForChannelOpen.Designer.cs similarity index 84% rename from src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027190251_AddPeerTypeWalletAddressesAndUtxos.Designer.cs rename to src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251106194247_AddFieldsForChannelOpen.Designer.cs index 3396507a..cfd02f94 100644 --- a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251027190251_AddPeerTypeWalletAddressesAndUtxos.Designer.cs +++ b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251106194247_AddFieldsForChannelOpen.Designer.cs @@ -12,8 +12,8 @@ namespace NLightning.Infrastructure.Persistence.Postgres.Migrations { [DbContext(typeof(NLightningDbContext))] - [Migration("20251027190251_AddPeerTypeWalletAddressesAndUtxos")] - partial class AddPeerTypeWalletAddressesAndUtxos + [Migration("20251106194247_AddFieldsForChannelOpen")] + partial class AddFieldsForChannelOpen { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -61,6 +61,14 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("bigint") .HasColumnName("index"); + b.Property("AddressIndex") + .HasColumnType("bigint") + .HasColumnName("address_index"); + + b.Property("AddressType") + .HasColumnType("smallint") + .HasColumnName("address_type"); + b.Property("AmountSats") .HasColumnType("bigint") .HasColumnName("amount_sats"); @@ -69,9 +77,37 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("bigint") .HasColumnName("block_height"); + b.Property("IsAddressChange") + .HasColumnType("boolean") + .HasColumnName("is_address_change"); + + b.Property("LockedToChannelId") + .HasColumnType("bytea") + .HasColumnName("locked_to_channel_id"); + + b.Property("UsedInTransactionId") + .HasColumnType("bytea") + .HasColumnName("used_in_transaction_id"); + b.HasKey("TransactionId", "Index") .HasName("pk_utxos"); + b.HasIndex("AddressType") + .HasDatabaseName("ix_utxos_address_type") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.HasIndex("LockedToChannelId") + .HasDatabaseName("ix_utxos_locked_to_channel_id") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.HasIndex("UsedInTransactionId") + .HasDatabaseName("ix_utxos_used_in_transaction_id") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.HasIndex("AddressIndex", "IsAddressChange", "AddressType") + .HasDatabaseName("ix_utxos_address_index_is_address_change_address_type") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + b.ToTable("utxos", (string)null); }); @@ -94,12 +130,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("address"); - b.Property("UtxoQty") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasDefaultValue(0L) - .HasColumnName("utxo_qty"); - b.HasKey("Index", "IsChange", "AddressType") .HasName("pk_wallet_addresses"); @@ -216,6 +246,22 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("bytea") .HasColumnName("channel_id"); + b.Property("ChangeAddressAddressType") + .HasColumnType("smallint") + .HasColumnName("change_address_address_type"); + + b.Property("ChangeAddressIndex") + .HasColumnType("bigint") + .HasColumnName("change_address_index"); + + b.Property("ChangeAddressIsChange") + .HasColumnType("boolean") + .HasColumnName("change_address_is_change"); + + b.Property("ChangeAddressType") + .HasColumnType("smallint") + .HasColumnName("change_address_type"); + b.Property("FundingAmountSatoshis") .HasColumnType("bigint") .HasColumnName("funding_amount_satoshis"); @@ -292,6 +338,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("PeerEntityNodeId") .HasDatabaseName("ix_channels_peer_entity_node_id"); + b.HasIndex("ChangeAddressIndex", "ChangeAddressIsChange", "ChangeAddressAddressType") + .HasDatabaseName("ix_channels_change_address_index_change_address_is_change_chan"); + b.ToTable("channels", (string)null); }); @@ -437,6 +486,18 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("peers", (string)null); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.UtxoEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", "WalletAddress") + .WithMany("Utxos") + .HasForeignKey("AddressIndex", "IsAddressChange", "AddressType") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_utxos_wallet_addresses_address_index_is_address_change_addr"); + + b.Navigation("WalletAddress"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => { b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) @@ -463,6 +524,13 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .WithMany("Channels") .HasForeignKey("PeerEntityNodeId") .HasConstraintName("fk_channels_peers_peer_entity_node_id"); + + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", "ChangeAddress") + .WithMany() + .HasForeignKey("ChangeAddressIndex", "ChangeAddressIsChange", "ChangeAddressAddressType") + .HasConstraintName("fk_channels_wallet_addresses_change_address_index_change_addre"); + + b.Navigation("ChangeAddress"); }); modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelKeySetEntity", b => @@ -485,6 +553,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasConstraintName("fk_htlcs_channels_channel_id"); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", b => + { + b.Navigation("Utxos"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", b => { b.Navigation("Config"); diff --git a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251106194247_AddFieldsForChannelOpen.cs b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251106194247_AddFieldsForChannelOpen.cs new file mode 100644 index 00000000..e016f1b7 --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251106194247_AddFieldsForChannelOpen.cs @@ -0,0 +1,158 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NLightning.Infrastructure.Persistence.Postgres.Migrations +{ + /// + public partial class AddFieldsForChannelOpen : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "type", + table: "peers", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "change_address_address_type", + table: "channels", + type: "smallint", + nullable: true); + + migrationBuilder.AddColumn( + name: "change_address_index", + table: "channels", + type: "bigint", + nullable: true); + + migrationBuilder.AddColumn( + name: "change_address_is_change", + table: "channels", + type: "boolean", + nullable: true); + + migrationBuilder.AddColumn( + name: "change_address_type", + table: "channels", + type: "smallint", + nullable: true); + + migrationBuilder.CreateTable( + name: "wallet_addresses", + columns: table => new + { + index = table.Column(type: "bigint", nullable: false), + is_change = table.Column(type: "boolean", nullable: false), + address_type = table.Column(type: "smallint", nullable: false), + address = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_wallet_addresses", x => new { x.index, x.is_change, x.address_type }); + }); + + migrationBuilder.CreateTable( + name: "utxos", + columns: table => new + { + transaction_id = table.Column(type: "bytea", nullable: false), + index = table.Column(type: "bigint", nullable: false), + amount_sats = table.Column(type: "bigint", nullable: false), + block_height = table.Column(type: "bigint", nullable: false), + address_index = table.Column(type: "bigint", nullable: false), + is_address_change = table.Column(type: "boolean", nullable: false), + address_type = table.Column(type: "smallint", nullable: false), + locked_to_channel_id = table.Column(type: "bytea", nullable: true), + used_in_transaction_id = table.Column(type: "bytea", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_utxos", x => new { x.transaction_id, x.index }); + table.ForeignKey( + name: "fk_utxos_wallet_addresses_address_index_is_address_change_addr", + columns: x => new { x.address_index, x.is_address_change, x.address_type }, + principalTable: "wallet_addresses", + principalColumns: new[] { "index", "is_change", "address_type" }, + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_channels_change_address_index_change_address_is_change_chan", + table: "channels", + columns: new[] { "change_address_index", "change_address_is_change", "change_address_address_type" }); + + migrationBuilder.CreateIndex( + name: "ix_utxos_address_index_is_address_change_address_type", + table: "utxos", + columns: new[] { "address_index", "is_address_change", "address_type" }) + .Annotation("Npgsql:CreatedConcurrently", true); + + migrationBuilder.CreateIndex( + name: "ix_utxos_address_type", + table: "utxos", + column: "address_type") + .Annotation("Npgsql:CreatedConcurrently", true); + + migrationBuilder.CreateIndex( + name: "ix_utxos_locked_to_channel_id", + table: "utxos", + column: "locked_to_channel_id") + .Annotation("Npgsql:CreatedConcurrently", true); + + migrationBuilder.CreateIndex( + name: "ix_utxos_used_in_transaction_id", + table: "utxos", + column: "used_in_transaction_id") + .Annotation("Npgsql:CreatedConcurrently", true); + + migrationBuilder.AddForeignKey( + name: "fk_channels_wallet_addresses_change_address_index_change_addre", + table: "channels", + columns: new[] { "change_address_index", "change_address_is_change", "change_address_address_type" }, + principalTable: "wallet_addresses", + principalColumns: new[] { "index", "is_change", "address_type" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_channels_wallet_addresses_change_address_index_change_addre", + table: "channels"); + + migrationBuilder.DropTable( + name: "utxos"); + + migrationBuilder.DropTable( + name: "wallet_addresses"); + + migrationBuilder.DropIndex( + name: "ix_channels_change_address_index_change_address_is_change_chan", + table: "channels"); + + migrationBuilder.DropColumn( + name: "type", + table: "peers"); + + migrationBuilder.DropColumn( + name: "change_address_address_type", + table: "channels"); + + migrationBuilder.DropColumn( + name: "change_address_index", + table: "channels"); + + migrationBuilder.DropColumn( + name: "change_address_is_change", + table: "channels"); + + migrationBuilder.DropColumn( + name: "change_address_type", + table: "channels"); + } + } +} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/NLightningDbContextModelSnapshot.cs b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/NLightningDbContextModelSnapshot.cs index 2ed9a129..2b53704b 100644 --- a/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/NLightningDbContextModelSnapshot.cs +++ b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/NLightningDbContextModelSnapshot.cs @@ -58,6 +58,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("bigint") .HasColumnName("index"); + b.Property("AddressIndex") + .HasColumnType("bigint") + .HasColumnName("address_index"); + + b.Property("AddressType") + .HasColumnType("smallint") + .HasColumnName("address_type"); + b.Property("AmountSats") .HasColumnType("bigint") .HasColumnName("amount_sats"); @@ -66,9 +74,37 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("bigint") .HasColumnName("block_height"); + b.Property("IsAddressChange") + .HasColumnType("boolean") + .HasColumnName("is_address_change"); + + b.Property("LockedToChannelId") + .HasColumnType("bytea") + .HasColumnName("locked_to_channel_id"); + + b.Property("UsedInTransactionId") + .HasColumnType("bytea") + .HasColumnName("used_in_transaction_id"); + b.HasKey("TransactionId", "Index") .HasName("pk_utxos"); + b.HasIndex("AddressType") + .HasDatabaseName("ix_utxos_address_type") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.HasIndex("LockedToChannelId") + .HasDatabaseName("ix_utxos_locked_to_channel_id") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.HasIndex("UsedInTransactionId") + .HasDatabaseName("ix_utxos_used_in_transaction_id") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + + b.HasIndex("AddressIndex", "IsAddressChange", "AddressType") + .HasDatabaseName("ix_utxos_address_index_is_address_change_address_type") + .HasAnnotation("Npgsql:CreatedConcurrently", true); + b.ToTable("utxos", (string)null); }); @@ -91,12 +127,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("address"); - b.Property("UtxoQty") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasDefaultValue(0L) - .HasColumnName("utxo_qty"); - b.HasKey("Index", "IsChange", "AddressType") .HasName("pk_wallet_addresses"); @@ -213,6 +243,22 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("bytea") .HasColumnName("channel_id"); + b.Property("ChangeAddressAddressType") + .HasColumnType("smallint") + .HasColumnName("change_address_address_type"); + + b.Property("ChangeAddressIndex") + .HasColumnType("bigint") + .HasColumnName("change_address_index"); + + b.Property("ChangeAddressIsChange") + .HasColumnType("boolean") + .HasColumnName("change_address_is_change"); + + b.Property("ChangeAddressType") + .HasColumnType("smallint") + .HasColumnName("change_address_type"); + b.Property("FundingAmountSatoshis") .HasColumnType("bigint") .HasColumnName("funding_amount_satoshis"); @@ -289,6 +335,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("PeerEntityNodeId") .HasDatabaseName("ix_channels_peer_entity_node_id"); + b.HasIndex("ChangeAddressIndex", "ChangeAddressIsChange", "ChangeAddressAddressType") + .HasDatabaseName("ix_channels_change_address_index_change_address_is_change_chan"); + b.ToTable("channels", (string)null); }); @@ -434,6 +483,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("peers", (string)null); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.UtxoEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", "WalletAddress") + .WithMany("Utxos") + .HasForeignKey("AddressIndex", "IsAddressChange", "AddressType") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_utxos_wallet_addresses_address_index_is_address_change_addr"); + + b.Navigation("WalletAddress"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => { b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) @@ -460,6 +521,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .WithMany("Channels") .HasForeignKey("PeerEntityNodeId") .HasConstraintName("fk_channels_peers_peer_entity_node_id"); + + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", "ChangeAddress") + .WithMany() + .HasForeignKey("ChangeAddressIndex", "ChangeAddressIsChange", "ChangeAddressAddressType") + .HasConstraintName("fk_channels_wallet_addresses_change_address_index_change_addre"); + + b.Navigation("ChangeAddress"); }); modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelKeySetEntity", b => @@ -482,6 +550,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasConstraintName("fk_htlcs_channels_channel_id"); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", b => + { + b.Navigation("Utxos"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", b => { b.Navigation("Config"); diff --git a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027190304_AddPeerTypeWalletAddressesAndUtxos.cs b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027190304_AddPeerTypeWalletAddressesAndUtxos.cs deleted file mode 100644 index 7720a73f..00000000 --- a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027190304_AddPeerTypeWalletAddressesAndUtxos.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace NLightning.Infrastructure.Persistence.SqlServer.Migrations -{ - /// - public partial class AddPeerTypeWalletAddressesAndUtxos : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "Type", - table: "Peers", - type: "nvarchar(max)", - nullable: false, - defaultValue: ""); - - migrationBuilder.CreateTable( - name: "Utxos", - columns: table => new - { - TransactionId = table.Column(type: "varbinary(32)", nullable: false), - Index = table.Column(type: "bigint", nullable: false), - AmountSats = table.Column(type: "bigint", nullable: false), - BlockHeight = table.Column(type: "bigint", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Utxos", x => new { x.TransactionId, x.Index }); - }); - - migrationBuilder.CreateTable( - name: "WalletAddresses", - columns: table => new - { - Index = table.Column(type: "bigint", nullable: false), - IsChange = table.Column(type: "bit", nullable: false), - AddressType = table.Column(type: "tinyint", nullable: false), - Address = table.Column(type: "nvarchar(max)", nullable: false), - UtxoQty = table.Column(type: "bigint", nullable: false, defaultValue: 0L) - }, - constraints: table => - { - table.PrimaryKey("PK_WalletAddresses", x => new { x.Index, x.IsChange, x.AddressType }); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Utxos"); - - migrationBuilder.DropTable( - name: "WalletAddresses"); - - migrationBuilder.DropColumn( - name: "Type", - table: "Peers"); - } - } -} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027190304_AddPeerTypeWalletAddressesAndUtxos.Designer.cs b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251106194300_AddFieldsForChannelOpen.Designer.cs similarity index 84% rename from src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027190304_AddPeerTypeWalletAddressesAndUtxos.Designer.cs rename to src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251106194300_AddFieldsForChannelOpen.Designer.cs index 2d60d47e..f0028595 100644 --- a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251027190304_AddPeerTypeWalletAddressesAndUtxos.Designer.cs +++ b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251106194300_AddFieldsForChannelOpen.Designer.cs @@ -12,8 +12,8 @@ namespace NLightning.Infrastructure.Persistence.SqlServer.Migrations { [DbContext(typeof(NLightningDbContext))] - [Migration("20251027190304_AddPeerTypeWalletAddressesAndUtxos")] - partial class AddPeerTypeWalletAddressesAndUtxos + [Migration("20251106194300_AddFieldsForChannelOpen")] + partial class AddFieldsForChannelOpen { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -54,14 +54,41 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Index") .HasColumnType("bigint"); + b.Property("AddressIndex") + .HasColumnType("bigint"); + + b.Property("AddressType") + .HasColumnType("tinyint"); + b.Property("AmountSats") .HasColumnType("bigint"); b.Property("BlockHeight") .HasColumnType("bigint"); + b.Property("IsAddressChange") + .HasColumnType("bit"); + + b.Property("LockedToChannelId") + .HasColumnType("varbinary(32)"); + + b.Property("UsedInTransactionId") + .HasColumnType("varbinary(900)"); + b.HasKey("TransactionId", "Index"); + b.HasIndex("AddressType") + .HasAnnotation("SqlServer:Online", true); + + b.HasIndex("LockedToChannelId") + .HasAnnotation("SqlServer:Online", true); + + b.HasIndex("UsedInTransactionId") + .HasAnnotation("SqlServer:Online", true); + + b.HasIndex("AddressIndex", "IsAddressChange", "AddressType") + .HasAnnotation("SqlServer:Online", true); + b.ToTable("Utxos"); }); @@ -80,11 +107,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("nvarchar(max)"); - b.Property("UtxoQty") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasDefaultValue(0L); - b.HasKey("Index", "IsChange", "AddressType"); b.ToTable("WalletAddresses"); @@ -175,6 +197,18 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("ChannelId") .HasColumnType("varbinary(32)"); + b.Property("ChangeAddressAddressType") + .HasColumnType("tinyint"); + + b.Property("ChangeAddressIndex") + .HasColumnType("bigint"); + + b.Property("ChangeAddressIsChange") + .HasColumnType("bit"); + + b.Property("ChangeAddressType") + .HasColumnType("tinyint"); + b.Property("FundingAmountSatoshis") .HasColumnType("bigint"); @@ -232,6 +266,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("PeerEntityNodeId"); + b.HasIndex("ChangeAddressIndex", "ChangeAddressIsChange", "ChangeAddressAddressType"); + b.ToTable("Channels"); }); @@ -347,6 +383,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Peers"); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.UtxoEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", "WalletAddress") + .WithMany("Utxos") + .HasForeignKey("AddressIndex", "IsAddressChange", "AddressType") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WalletAddress"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => { b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) @@ -370,6 +417,12 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasOne("NLightning.Infrastructure.Persistence.Entities.Node.PeerEntity", null) .WithMany("Channels") .HasForeignKey("PeerEntityNodeId"); + + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", "ChangeAddress") + .WithMany() + .HasForeignKey("ChangeAddressIndex", "ChangeAddressIsChange", "ChangeAddressAddressType"); + + b.Navigation("ChangeAddress"); }); modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelKeySetEntity", b => @@ -390,6 +443,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", b => + { + b.Navigation("Utxos"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", b => { b.Navigation("Config"); diff --git a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251106194300_AddFieldsForChannelOpen.cs b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251106194300_AddFieldsForChannelOpen.cs new file mode 100644 index 00000000..36d28b30 --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251106194300_AddFieldsForChannelOpen.cs @@ -0,0 +1,158 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NLightning.Infrastructure.Persistence.SqlServer.Migrations +{ + /// + public partial class AddFieldsForChannelOpen : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Type", + table: "Peers", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "ChangeAddressAddressType", + table: "Channels", + type: "tinyint", + nullable: true); + + migrationBuilder.AddColumn( + name: "ChangeAddressIndex", + table: "Channels", + type: "bigint", + nullable: true); + + migrationBuilder.AddColumn( + name: "ChangeAddressIsChange", + table: "Channels", + type: "bit", + nullable: true); + + migrationBuilder.AddColumn( + name: "ChangeAddressType", + table: "Channels", + type: "tinyint", + nullable: true); + + migrationBuilder.CreateTable( + name: "WalletAddresses", + columns: table => new + { + Index = table.Column(type: "bigint", nullable: false), + IsChange = table.Column(type: "bit", nullable: false), + AddressType = table.Column(type: "tinyint", nullable: false), + Address = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WalletAddresses", x => new { x.Index, x.IsChange, x.AddressType }); + }); + + migrationBuilder.CreateTable( + name: "Utxos", + columns: table => new + { + TransactionId = table.Column(type: "varbinary(32)", nullable: false), + Index = table.Column(type: "bigint", nullable: false), + AmountSats = table.Column(type: "bigint", nullable: false), + BlockHeight = table.Column(type: "bigint", nullable: false), + AddressIndex = table.Column(type: "bigint", nullable: false), + IsAddressChange = table.Column(type: "bit", nullable: false), + AddressType = table.Column(type: "tinyint", nullable: false), + LockedToChannelId = table.Column(type: "varbinary(32)", nullable: true), + UsedInTransactionId = table.Column(type: "varbinary(900)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Utxos", x => new { x.TransactionId, x.Index }); + table.ForeignKey( + name: "FK_Utxos_WalletAddresses_AddressIndex_IsAddressChange_AddressType", + columns: x => new { x.AddressIndex, x.IsAddressChange, x.AddressType }, + principalTable: "WalletAddresses", + principalColumns: new[] { "Index", "IsChange", "AddressType" }, + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Channels_ChangeAddressIndex_ChangeAddressIsChange_ChangeAddressAddressType", + table: "Channels", + columns: new[] { "ChangeAddressIndex", "ChangeAddressIsChange", "ChangeAddressAddressType" }); + + migrationBuilder.CreateIndex( + name: "IX_Utxos_AddressIndex_IsAddressChange_AddressType", + table: "Utxos", + columns: new[] { "AddressIndex", "IsAddressChange", "AddressType" }) + .Annotation("SqlServer:Online", true); + + migrationBuilder.CreateIndex( + name: "IX_Utxos_AddressType", + table: "Utxos", + column: "AddressType") + .Annotation("SqlServer:Online", true); + + migrationBuilder.CreateIndex( + name: "IX_Utxos_LockedToChannelId", + table: "Utxos", + column: "LockedToChannelId") + .Annotation("SqlServer:Online", true); + + migrationBuilder.CreateIndex( + name: "IX_Utxos_UsedInTransactionId", + table: "Utxos", + column: "UsedInTransactionId") + .Annotation("SqlServer:Online", true); + + migrationBuilder.AddForeignKey( + name: "FK_Channels_WalletAddresses_ChangeAddressIndex_ChangeAddressIsChange_ChangeAddressAddressType", + table: "Channels", + columns: new[] { "ChangeAddressIndex", "ChangeAddressIsChange", "ChangeAddressAddressType" }, + principalTable: "WalletAddresses", + principalColumns: new[] { "Index", "IsChange", "AddressType" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Channels_WalletAddresses_ChangeAddressIndex_ChangeAddressIsChange_ChangeAddressAddressType", + table: "Channels"); + + migrationBuilder.DropTable( + name: "Utxos"); + + migrationBuilder.DropTable( + name: "WalletAddresses"); + + migrationBuilder.DropIndex( + name: "IX_Channels_ChangeAddressIndex_ChangeAddressIsChange_ChangeAddressAddressType", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "Type", + table: "Peers"); + + migrationBuilder.DropColumn( + name: "ChangeAddressAddressType", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "ChangeAddressIndex", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "ChangeAddressIsChange", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "ChangeAddressType", + table: "Channels"); + } + } +} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/NLightningDbContextModelSnapshot.cs b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/NLightningDbContextModelSnapshot.cs index c19d5773..b62d1d88 100644 --- a/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/NLightningDbContextModelSnapshot.cs +++ b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/NLightningDbContextModelSnapshot.cs @@ -51,14 +51,41 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Index") .HasColumnType("bigint"); + b.Property("AddressIndex") + .HasColumnType("bigint"); + + b.Property("AddressType") + .HasColumnType("tinyint"); + b.Property("AmountSats") .HasColumnType("bigint"); b.Property("BlockHeight") .HasColumnType("bigint"); + b.Property("IsAddressChange") + .HasColumnType("bit"); + + b.Property("LockedToChannelId") + .HasColumnType("varbinary(32)"); + + b.Property("UsedInTransactionId") + .HasColumnType("varbinary(900)"); + b.HasKey("TransactionId", "Index"); + b.HasIndex("AddressType") + .HasAnnotation("SqlServer:Online", true); + + b.HasIndex("LockedToChannelId") + .HasAnnotation("SqlServer:Online", true); + + b.HasIndex("UsedInTransactionId") + .HasAnnotation("SqlServer:Online", true); + + b.HasIndex("AddressIndex", "IsAddressChange", "AddressType") + .HasAnnotation("SqlServer:Online", true); + b.ToTable("Utxos"); }); @@ -77,11 +104,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("nvarchar(max)"); - b.Property("UtxoQty") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasDefaultValue(0L); - b.HasKey("Index", "IsChange", "AddressType"); b.ToTable("WalletAddresses"); @@ -172,6 +194,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ChannelId") .HasColumnType("varbinary(32)"); + b.Property("ChangeAddressAddressType") + .HasColumnType("tinyint"); + + b.Property("ChangeAddressIndex") + .HasColumnType("bigint"); + + b.Property("ChangeAddressIsChange") + .HasColumnType("bit"); + + b.Property("ChangeAddressType") + .HasColumnType("tinyint"); + b.Property("FundingAmountSatoshis") .HasColumnType("bigint"); @@ -229,6 +263,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("PeerEntityNodeId"); + b.HasIndex("ChangeAddressIndex", "ChangeAddressIsChange", "ChangeAddressAddressType"); + b.ToTable("Channels"); }); @@ -344,6 +380,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Peers"); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.UtxoEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", "WalletAddress") + .WithMany("Utxos") + .HasForeignKey("AddressIndex", "IsAddressChange", "AddressType") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WalletAddress"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => { b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) @@ -367,6 +414,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("NLightning.Infrastructure.Persistence.Entities.Node.PeerEntity", null) .WithMany("Channels") .HasForeignKey("PeerEntityNodeId"); + + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", "ChangeAddress") + .WithMany() + .HasForeignKey("ChangeAddressIndex", "ChangeAddressIsChange", "ChangeAddressAddressType"); + + b.Navigation("ChangeAddress"); }); modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelKeySetEntity", b => @@ -387,6 +440,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", b => + { + b.Navigation("Utxos"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", b => { b.Navigation("Config"); diff --git a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027190258_AddPeerTypeWalletAddressesAndUtxos.cs b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027190258_AddPeerTypeWalletAddressesAndUtxos.cs deleted file mode 100644 index 8f68d4cb..00000000 --- a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027190258_AddPeerTypeWalletAddressesAndUtxos.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace NLightning.Infrastructure.Persistence.Sqlite.Migrations -{ - /// - public partial class AddPeerTypeWalletAddressesAndUtxos : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "Type", - table: "Peers", - type: "TEXT", - nullable: false, - defaultValue: ""); - - migrationBuilder.CreateTable( - name: "Utxos", - columns: table => new - { - TransactionId = table.Column(type: "BLOB", nullable: false), - Index = table.Column(type: "INTEGER", nullable: false), - AmountSats = table.Column(type: "INTEGER", nullable: false), - BlockHeight = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Utxos", x => new { x.TransactionId, x.Index }); - }); - - migrationBuilder.CreateTable( - name: "WalletAddresses", - columns: table => new - { - Index = table.Column(type: "INTEGER", nullable: false), - IsChange = table.Column(type: "INTEGER", nullable: false), - AddressType = table.Column(type: "INTEGER", nullable: false), - Address = table.Column(type: "TEXT", nullable: false), - UtxoQty = table.Column(type: "INTEGER", nullable: false, defaultValue: 0u) - }, - constraints: table => - { - table.PrimaryKey("PK_WalletAddresses", x => new { x.Index, x.IsChange, x.AddressType }); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Utxos"); - - migrationBuilder.DropTable( - name: "WalletAddresses"); - - migrationBuilder.DropColumn( - name: "Type", - table: "Peers"); - } - } -} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027190258_AddPeerTypeWalletAddressesAndUtxos.Designer.cs b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251106194254_AddFieldsForChannelOpen.Designer.cs similarity index 85% rename from src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027190258_AddPeerTypeWalletAddressesAndUtxos.Designer.cs rename to src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251106194254_AddFieldsForChannelOpen.Designer.cs index 580472c4..093699d4 100644 --- a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251027190258_AddPeerTypeWalletAddressesAndUtxos.Designer.cs +++ b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251106194254_AddFieldsForChannelOpen.Designer.cs @@ -11,8 +11,8 @@ namespace NLightning.Infrastructure.Persistence.Sqlite.Migrations { [DbContext(typeof(NLightningDbContext))] - [Migration("20251027190258_AddPeerTypeWalletAddressesAndUtxos")] - partial class AddPeerTypeWalletAddressesAndUtxos + [Migration("20251106194254_AddFieldsForChannelOpen")] + partial class AddFieldsForChannelOpen { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -49,14 +49,37 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Index") .HasColumnType("INTEGER"); + b.Property("AddressIndex") + .HasColumnType("INTEGER"); + + b.Property("AddressType") + .HasColumnType("INTEGER"); + b.Property("AmountSats") .HasColumnType("INTEGER"); b.Property("BlockHeight") .HasColumnType("INTEGER"); + b.Property("IsAddressChange") + .HasColumnType("INTEGER"); + + b.Property("LockedToChannelId") + .HasColumnType("BLOB"); + + b.Property("UsedInTransactionId") + .HasColumnType("BLOB"); + b.HasKey("TransactionId", "Index"); + b.HasIndex("AddressType"); + + b.HasIndex("LockedToChannelId"); + + b.HasIndex("UsedInTransactionId"); + + b.HasIndex("AddressIndex", "IsAddressChange", "AddressType"); + b.ToTable("Utxos"); }); @@ -75,11 +98,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); - b.Property("UtxoQty") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(0u); - b.HasKey("Index", "IsChange", "AddressType"); b.ToTable("WalletAddresses"); @@ -170,6 +188,18 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("ChannelId") .HasColumnType("BLOB"); + b.Property("ChangeAddressAddressType") + .HasColumnType("INTEGER"); + + b.Property("ChangeAddressIndex") + .HasColumnType("INTEGER"); + + b.Property("ChangeAddressIsChange") + .HasColumnType("INTEGER"); + + b.Property("ChangeAddressType") + .HasColumnType("INTEGER"); + b.Property("FundingAmountSatoshis") .HasColumnType("INTEGER"); @@ -227,6 +257,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("PeerEntityNodeId"); + b.HasIndex("ChangeAddressIndex", "ChangeAddressIsChange", "ChangeAddressAddressType"); + b.ToTable("Channels"); }); @@ -342,6 +374,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Peers"); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.UtxoEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", "WalletAddress") + .WithMany("Utxos") + .HasForeignKey("AddressIndex", "IsAddressChange", "AddressType") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WalletAddress"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => { b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) @@ -365,6 +408,12 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasOne("NLightning.Infrastructure.Persistence.Entities.Node.PeerEntity", null) .WithMany("Channels") .HasForeignKey("PeerEntityNodeId"); + + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", "ChangeAddress") + .WithMany() + .HasForeignKey("ChangeAddressIndex", "ChangeAddressIsChange", "ChangeAddressAddressType"); + + b.Navigation("ChangeAddress"); }); modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelKeySetEntity", b => @@ -385,6 +434,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", b => + { + b.Navigation("Utxos"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", b => { b.Navigation("Config"); diff --git a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251106194254_AddFieldsForChannelOpen.cs b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251106194254_AddFieldsForChannelOpen.cs new file mode 100644 index 00000000..4a622818 --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251106194254_AddFieldsForChannelOpen.cs @@ -0,0 +1,154 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NLightning.Infrastructure.Persistence.Sqlite.Migrations +{ + /// + public partial class AddFieldsForChannelOpen : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Type", + table: "Peers", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "ChangeAddressAddressType", + table: "Channels", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "ChangeAddressIndex", + table: "Channels", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "ChangeAddressIsChange", + table: "Channels", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "ChangeAddressType", + table: "Channels", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateTable( + name: "WalletAddresses", + columns: table => new + { + Index = table.Column(type: "INTEGER", nullable: false), + IsChange = table.Column(type: "INTEGER", nullable: false), + AddressType = table.Column(type: "INTEGER", nullable: false), + Address = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WalletAddresses", x => new { x.Index, x.IsChange, x.AddressType }); + }); + + migrationBuilder.CreateTable( + name: "Utxos", + columns: table => new + { + TransactionId = table.Column(type: "BLOB", nullable: false), + Index = table.Column(type: "INTEGER", nullable: false), + AmountSats = table.Column(type: "INTEGER", nullable: false), + BlockHeight = table.Column(type: "INTEGER", nullable: false), + AddressIndex = table.Column(type: "INTEGER", nullable: false), + IsAddressChange = table.Column(type: "INTEGER", nullable: false), + AddressType = table.Column(type: "INTEGER", nullable: false), + LockedToChannelId = table.Column(type: "BLOB", nullable: true), + UsedInTransactionId = table.Column(type: "BLOB", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Utxos", x => new { x.TransactionId, x.Index }); + table.ForeignKey( + name: "FK_Utxos_WalletAddresses_AddressIndex_IsAddressChange_AddressType", + columns: x => new { x.AddressIndex, x.IsAddressChange, x.AddressType }, + principalTable: "WalletAddresses", + principalColumns: new[] { "Index", "IsChange", "AddressType" }, + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Channels_ChangeAddressIndex_ChangeAddressIsChange_ChangeAddressAddressType", + table: "Channels", + columns: new[] { "ChangeAddressIndex", "ChangeAddressIsChange", "ChangeAddressAddressType" }); + + migrationBuilder.CreateIndex( + name: "IX_Utxos_AddressIndex_IsAddressChange_AddressType", + table: "Utxos", + columns: new[] { "AddressIndex", "IsAddressChange", "AddressType" }); + + migrationBuilder.CreateIndex( + name: "IX_Utxos_AddressType", + table: "Utxos", + column: "AddressType"); + + migrationBuilder.CreateIndex( + name: "IX_Utxos_LockedToChannelId", + table: "Utxos", + column: "LockedToChannelId"); + + migrationBuilder.CreateIndex( + name: "IX_Utxos_UsedInTransactionId", + table: "Utxos", + column: "UsedInTransactionId"); + + migrationBuilder.AddForeignKey( + name: "FK_Channels_WalletAddresses_ChangeAddressIndex_ChangeAddressIsChange_ChangeAddressAddressType", + table: "Channels", + columns: new[] { "ChangeAddressIndex", "ChangeAddressIsChange", "ChangeAddressAddressType" }, + principalTable: "WalletAddresses", + principalColumns: new[] { "Index", "IsChange", "AddressType" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Channels_WalletAddresses_ChangeAddressIndex_ChangeAddressIsChange_ChangeAddressAddressType", + table: "Channels"); + + migrationBuilder.DropTable( + name: "Utxos"); + + migrationBuilder.DropTable( + name: "WalletAddresses"); + + migrationBuilder.DropIndex( + name: "IX_Channels_ChangeAddressIndex_ChangeAddressIsChange_ChangeAddressAddressType", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "Type", + table: "Peers"); + + migrationBuilder.DropColumn( + name: "ChangeAddressAddressType", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "ChangeAddressIndex", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "ChangeAddressIsChange", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "ChangeAddressType", + table: "Channels"); + } + } +} \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/NLightningDbContextModelSnapshot.cs b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/NLightningDbContextModelSnapshot.cs index 952cc27f..05270ca1 100644 --- a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/NLightningDbContextModelSnapshot.cs +++ b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/NLightningDbContextModelSnapshot.cs @@ -46,14 +46,37 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Index") .HasColumnType("INTEGER"); + b.Property("AddressIndex") + .HasColumnType("INTEGER"); + + b.Property("AddressType") + .HasColumnType("INTEGER"); + b.Property("AmountSats") .HasColumnType("INTEGER"); b.Property("BlockHeight") .HasColumnType("INTEGER"); + b.Property("IsAddressChange") + .HasColumnType("INTEGER"); + + b.Property("LockedToChannelId") + .HasColumnType("BLOB"); + + b.Property("UsedInTransactionId") + .HasColumnType("BLOB"); + b.HasKey("TransactionId", "Index"); + b.HasIndex("AddressType"); + + b.HasIndex("LockedToChannelId"); + + b.HasIndex("UsedInTransactionId"); + + b.HasIndex("AddressIndex", "IsAddressChange", "AddressType"); + b.ToTable("Utxos"); }); @@ -72,11 +95,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); - b.Property("UtxoQty") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(0u); - b.HasKey("Index", "IsChange", "AddressType"); b.ToTable("WalletAddresses"); @@ -167,6 +185,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ChannelId") .HasColumnType("BLOB"); + b.Property("ChangeAddressAddressType") + .HasColumnType("INTEGER"); + + b.Property("ChangeAddressIndex") + .HasColumnType("INTEGER"); + + b.Property("ChangeAddressIsChange") + .HasColumnType("INTEGER"); + + b.Property("ChangeAddressType") + .HasColumnType("INTEGER"); + b.Property("FundingAmountSatoshis") .HasColumnType("INTEGER"); @@ -224,6 +254,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("PeerEntityNodeId"); + b.HasIndex("ChangeAddressIndex", "ChangeAddressIsChange", "ChangeAddressAddressType"); + b.ToTable("Channels"); }); @@ -339,6 +371,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Peers"); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.UtxoEntity", b => + { + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", "WalletAddress") + .WithMany("Utxos") + .HasForeignKey("AddressIndex", "IsAddressChange", "AddressType") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WalletAddress"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => { b.HasOne("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", null) @@ -362,6 +405,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("NLightning.Infrastructure.Persistence.Entities.Node.PeerEntity", null) .WithMany("Channels") .HasForeignKey("PeerEntityNodeId"); + + b.HasOne("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", "ChangeAddress") + .WithMany() + .HasForeignKey("ChangeAddressIndex", "ChangeAddressIsChange", "ChangeAddressAddressType"); + + b.Navigation("ChangeAddress"); }); modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelKeySetEntity", b => @@ -382,6 +431,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WalletAddressEntity", b => + { + b.Navigation("Utxos"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Channel.ChannelEntity", b => { b.Navigation("Config"); diff --git a/src/NLightning.Infrastructure.Persistence/DependencyInjection.cs b/src/NLightning.Infrastructure.Persistence/DependencyInjection.cs index 292b70dc..48784ecf 100644 --- a/src/NLightning.Infrastructure.Persistence/DependencyInjection.cs +++ b/src/NLightning.Infrastructure.Persistence/DependencyInjection.cs @@ -52,6 +52,10 @@ public static IServiceCollection AddPersistenceInfrastructureServices(this IServ services.AddDbContext((_, optionsBuilder) => { + // Check if we should be logging sensible data (i.e., query values) + if ((configuration["Database:EnableSensitiveQueryLogging"]?.ToLowerInvariant() ?? "false") == "true") + optionsBuilder.EnableSensitiveDataLogging(); + switch (resolvedDatabaseType) { case DatabaseType.PostgreSql: diff --git a/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/UtxoEntity.cs b/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/UtxoEntity.cs index d3c01804..ebd327e9 100644 --- a/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/UtxoEntity.cs +++ b/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/UtxoEntity.cs @@ -1,6 +1,8 @@ namespace NLightning.Infrastructure.Persistence.Entities.Bitcoin; +using Domain.Bitcoin.Enums; using Domain.Bitcoin.ValueObjects; +using Domain.Channels.ValueObjects; public class UtxoEntity { @@ -8,6 +10,13 @@ public class UtxoEntity public uint Index { get; set; } public long AmountSats { get; set; } public uint BlockHeight { get; set; } + public uint AddressIndex { get; set; } + public bool IsAddressChange { get; set; } + public AddressType AddressType { get; set; } + public ChannelId? LockedToChannelId { get; set; } + public TxId? UsedInTransactionId { get; set; } + + public virtual WalletAddressEntity? WalletAddress { get; set; } // Default constructor for EF Core internal UtxoEntity() { } diff --git a/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/WalletAddressEntity.cs b/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/WalletAddressEntity.cs index b3c23b9c..3014cc07 100644 --- a/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/WalletAddressEntity.cs +++ b/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/WalletAddressEntity.cs @@ -8,7 +8,8 @@ public class WalletAddressEntity public bool IsChange { get; set; } public required AddressType AddressType { get; set; } public required string Address { get; set; } - public uint UtxoQty { get; set; } + + public virtual IEnumerable? Utxos { get; set; } // Default constructor for EF Core internal WalletAddressEntity() { } diff --git a/src/NLightning.Infrastructure.Persistence/Entities/Channel/ChannelEntity.cs b/src/NLightning.Infrastructure.Persistence/Entities/Channel/ChannelEntity.cs index 0c0c9f85..3051fa9c 100644 --- a/src/NLightning.Infrastructure.Persistence/Entities/Channel/ChannelEntity.cs +++ b/src/NLightning.Infrastructure.Persistence/Entities/Channel/ChannelEntity.cs @@ -3,6 +3,7 @@ namespace NLightning.Infrastructure.Persistence.Entities.Channel; using Bitcoin; +using Domain.Bitcoin.Enums; using Domain.Bitcoin.ValueObjects; using Domain.Channels.ValueObjects; using Domain.Crypto.ValueObjects; @@ -99,6 +100,14 @@ public class ChannelEntity /// public required decimal RemoteBalanceSatoshis { get; set; } + public AddressType? ChangeAddressType { get; set; } + public uint? ChangeAddressIndex { get; set; } + + /// + /// The change address used by the funding transaction, if there's one + /// + public virtual WalletAddressEntity? ChangeAddress { get; set; } + /// /// Represents the configuration settings associated with the Lightning Network payment channel, /// defining operational parameters such as limits, timeouts, and other key configurations. diff --git a/src/NLightning.Infrastructure.Persistence/EntityConfiguration/Bitcoin/UtxoEntityConfiguration.cs b/src/NLightning.Infrastructure.Persistence/EntityConfiguration/Bitcoin/UtxoEntityConfiguration.cs index e61319ac..8a7cc09a 100644 --- a/src/NLightning.Infrastructure.Persistence/EntityConfiguration/Bitcoin/UtxoEntityConfiguration.cs +++ b/src/NLightning.Infrastructure.Persistence/EntityConfiguration/Bitcoin/UtxoEntityConfiguration.cs @@ -3,6 +3,7 @@ namespace NLightning.Infrastructure.Persistence.EntityConfiguration.Bitcoin; +using Domain.Channels.Constants; using Domain.Crypto.Constants; using Entities.Bitcoin; using Enums; @@ -22,18 +23,71 @@ public static void ConfigureUtxoEntity(this ModelBuilder modelBuilder, DatabaseT .IsRequired(); entity.Property(e => e.BlockHeight) .IsRequired(); + entity.Property(e => e.AddressIndex) + .IsRequired(); + entity.Property(e => e.IsAddressChange) + .IsRequired(); + entity.Property(e => e.AddressType) + .IsRequired(); + + // Set Optional props + entity.Property(e => e.LockedToChannelId) + .IsRequired(false) + .HasConversion(); + entity.Property(e => e.UsedInTransactionId) + .IsRequired(false) + .HasConversion(); // Set converters entity.Property(x => x.TransactionId) .HasConversion(); - if (databaseType == DatabaseType.MicrosoftSql) - OptimizeConfigurationForSqlServer(entity); + // Set indexes + entity.HasIndex(x => x.AddressType); + entity.HasIndex(x => new { x.AddressIndex, x.IsAddressChange, x.AddressType }); + entity.HasIndex(x => x.LockedToChannelId); + entity.HasIndex(x => x.UsedInTransactionId); + + switch (databaseType) + { + case DatabaseType.MicrosoftSql: + OptimizeConfigurationForSqlServer(entity); + break; + case DatabaseType.PostgreSql: + OptimizeConfigurationForPostgres(entity); + break; + case DatabaseType.Sqlite: + default: + // Nothing to be done + break; + } }); } private static void OptimizeConfigurationForSqlServer(EntityTypeBuilder entity) { entity.Property(e => e.TransactionId).HasColumnType($"varbinary({CryptoConstants.Sha256HashLen})"); + entity.Property(e => e.LockedToChannelId).HasColumnType($"varbinary({ChannelConstants.ChannelIdLength})"); + + entity.HasIndex(x => x.AddressType) + .IsCreatedOnline(); + entity.HasIndex(x => new { x.AddressIndex, x.IsAddressChange, x.AddressType }) + .IsCreatedOnline(); + entity.HasIndex(x => x.LockedToChannelId) + .IsCreatedOnline(); + entity.HasIndex(x => x.UsedInTransactionId) + .IsCreatedOnline(); + } + + private static void OptimizeConfigurationForPostgres(EntityTypeBuilder entity) + { + entity.HasIndex(x => x.AddressType) + .IsCreatedConcurrently(); + entity.HasIndex(x => new { x.AddressIndex, x.IsAddressChange, x.AddressType }) + .IsCreatedConcurrently(); + entity.HasIndex(x => x.LockedToChannelId) + .IsCreatedConcurrently(); + entity.HasIndex(x => x.UsedInTransactionId) + .IsCreatedConcurrently(); } } \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Persistence/EntityConfiguration/Bitcoin/WalletAddressEntityConfiguration.cs b/src/NLightning.Infrastructure.Persistence/EntityConfiguration/Bitcoin/WalletAddressEntityConfiguration.cs index 786d7f3b..71c5a723 100644 --- a/src/NLightning.Infrastructure.Persistence/EntityConfiguration/Bitcoin/WalletAddressEntityConfiguration.cs +++ b/src/NLightning.Infrastructure.Persistence/EntityConfiguration/Bitcoin/WalletAddressEntityConfiguration.cs @@ -7,7 +7,7 @@ namespace NLightning.Infrastructure.Persistence.EntityConfiguration.Bitcoin; public static class WalletAddressEntityConfiguration { - public static void ConfigureWalletAddressEntity(this ModelBuilder modelBuilder, DatabaseType databaseType) + public static void ConfigureWalletAddressEntity(this ModelBuilder modelBuilder, DatabaseType _) { modelBuilder.Entity(entity => { @@ -17,9 +17,12 @@ public static void ConfigureWalletAddressEntity(this ModelBuilder modelBuilder, // Set Required props entity.Property(e => e.Address) .IsRequired(); - entity.Property(e => e.UtxoQty) - .IsRequired() - .HasDefaultValue(0); + + // Set relations + entity.HasMany(x => x.Utxos) + .WithOne(x => x.WalletAddress) + .HasForeignKey(x => new { x.AddressIndex, x.IsAddressChange, x.AddressType }) + .OnDelete(DeleteBehavior.Cascade); }); } } \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/UtxoDbRepository.cs b/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/UtxoDbRepository.cs index 4cce3b44..4a67661e 100644 --- a/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/UtxoDbRepository.cs +++ b/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/UtxoDbRepository.cs @@ -1,8 +1,10 @@ +using System.Linq.Expressions; using Microsoft.EntityFrameworkCore; namespace NLightning.Infrastructure.Repositories.Database.Bitcoin; using Domain.Bitcoin.Interfaces; +using Domain.Bitcoin.ValueObjects; using Domain.Bitcoin.Wallet.Models; using Domain.Money; using Persistence.Contexts; @@ -23,13 +25,34 @@ public void Spend(UtxoModel utxoModel) Delete(utxoEntity); } - public async Task> GetAllAsync() + public void Update(UtxoModel utxoModel) { - var utxoSet = await Get(asNoTracking: true).ToListAsync(); + var utxoEntity = MapDomainToEntity(utxoModel); + Update(utxoEntity); + } + + public async Task> GetUnspentAsync(bool includeWalletAddress = false) + { + var query = Get(asNoTracking: true).AsQueryable(); + if (includeWalletAddress) + query.Include(x => x.WalletAddress); + + var utxoSet = await query.ToListAsync(); return utxoSet.Select(MapEntityToModel); } + public async Task GetByIdAsync(TxId txId, uint index, bool includeWalletAddress = false) + { + Expression>? include = includeWalletAddress + ? entity => entity.WalletAddress! + : null; + var utxoEntity = await GetByIdAsync(new { txId, index }, true, include); + return utxoEntity is null + ? null + : MapEntityToModel(utxoEntity); + } + private UtxoEntity MapDomainToEntity(UtxoModel model) { return new UtxoEntity @@ -37,13 +60,25 @@ private UtxoEntity MapDomainToEntity(UtxoModel model) TransactionId = model.TxId, Index = model.Index, AmountSats = model.Amount.Satoshi, - BlockHeight = model.BlockHeight + BlockHeight = model.BlockHeight, + AddressIndex = model.AddressIndex, + IsAddressChange = model.IsAddressChange, + AddressType = model.AddressType }; } private UtxoModel MapEntityToModel(UtxoEntity entity) { - return new UtxoModel(entity.TransactionId, entity.Index, LightningMoney.Satoshis(entity.AmountSats), - entity.BlockHeight); + var utxoModel = new UtxoModel(entity.TransactionId, entity.Index, LightningMoney.Satoshis(entity.AmountSats), + entity.BlockHeight, entity.AddressIndex, entity.IsAddressChange, + entity.AddressType); + + if (entity.WalletAddress is not null) + { + var walletAddressModel = WalletAddressesDbRepository.MapEntityToModel(entity.WalletAddress); + utxoModel.SetWalletAddress(walletAddressModel); + } + + return utxoModel; } } \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/WalletAddressesDbRepository.cs b/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/WalletAddressesDbRepository.cs index d388c2d6..d8116761 100644 --- a/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/WalletAddressesDbRepository.cs +++ b/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/WalletAddressesDbRepository.cs @@ -1,10 +1,10 @@ using Microsoft.EntityFrameworkCore; -using NLightning.Domain.Bitcoin.Wallet.Models; namespace NLightning.Infrastructure.Repositories.Database.Bitcoin; using Domain.Bitcoin.Enums; using Domain.Bitcoin.Interfaces; +using Domain.Bitcoin.Wallet.Models; using Persistence.Contexts; using Persistence.Entities.Bitcoin; @@ -13,25 +13,25 @@ public class WalletAddressesDbRepository(NLightningDbContext context) { public async Task GetUnusedAddressAsync(AddressType type, bool isChange) { - var walletAddressEntity = await DbSet - .AsNoTracking() - .Where(x => x.AddressType.Equals(type) - && x.IsChange.Equals(isChange) - && x.UtxoQty.Equals(0)) - .OrderBy(x => x.UtxoQty) - .FirstOrDefaultAsync(); + var walletAddressEntity = await DbSet.AsNoTracking() + .Include(x => x.Utxos) + .Where(x => x.AddressType.Equals(type) + && x.IsChange.Equals(isChange)) + .Where(x => x.Utxos != null + && x.Utxos.Count().Equals(0)) + .OrderBy(x => x.Index) + .FirstOrDefaultAsync(); return walletAddressEntity is null ? null : MapEntityToModel(walletAddressEntity); } public async Task GetLastUsedAddressIndex(AddressType addressType, bool isChange) { - var walletAddressEntity = await DbSet - .AsNoTracking() - .Where(x => x.AddressType.Equals(addressType) - && x.IsChange.Equals(isChange)) - .OrderByDescending(x => x.Index) - .FirstOrDefaultAsync(); + var walletAddressEntity = await DbSet.AsNoTracking() + .Where(x => x.AddressType.Equals(addressType) + && x.IsChange.Equals(isChange)) + .OrderByDescending(x => x.Index) + .FirstOrDefaultAsync(); return walletAddressEntity?.Index ?? 0; } @@ -60,14 +60,12 @@ private static WalletAddressEntity MapDomainToEntity(WalletAddressModel model) Index = model.Index, IsChange = model.IsChange, AddressType = model.AddressType, - Address = model.Address, - UtxoQty = model.UtxoQty + Address = model.Address }; } - private static WalletAddressModel MapEntityToModel(WalletAddressEntity entity) + internal static WalletAddressModel MapEntityToModel(WalletAddressEntity entity) { - return new WalletAddressModel(entity.AddressType, entity.Index, entity.IsChange, entity.Address, - entity.UtxoQty); + return new WalletAddressModel(entity.AddressType, entity.Index, entity.IsChange, entity.Address); } } \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Repositories/Memory/UtxoMemoryRepository.cs b/src/NLightning.Infrastructure.Repositories/Memory/UtxoMemoryRepository.cs index de170101..8ee4db04 100644 --- a/src/NLightning.Infrastructure.Repositories/Memory/UtxoMemoryRepository.cs +++ b/src/NLightning.Infrastructure.Repositories/Memory/UtxoMemoryRepository.cs @@ -1,10 +1,12 @@ using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; namespace NLightning.Infrastructure.Repositories.Memory; using Domain.Bitcoin.Interfaces; using Domain.Bitcoin.ValueObjects; using Domain.Bitcoin.Wallet.Models; +using Domain.Channels.ValueObjects; using Domain.Money; public class UtxoMemoryRepository : IUtxoMemoryRepository @@ -19,8 +21,12 @@ public void Add(UtxoModel utxoModel) public void Spend(UtxoModel utxoModel) { - if (!_utxoSet.TryRemove((utxoModel.TxId, utxoModel.Index), out _)) - throw new InvalidOperationException("Cannot remove Utxo"); + _utxoSet.TryRemove((utxoModel.TxId, utxoModel.Index), out _); + } + + public bool TryGetUtxo(TxId txId, uint index, [MaybeNullWhen(false)] out UtxoModel utxoModel) + { + return _utxoSet.TryGetValue((txId, index), out utxoModel); } public LightningMoney GetConfirmedBalance(uint currentBlockHeight) @@ -37,9 +43,157 @@ public LightningMoney GetUnconfirmedBalance(uint currentBlockHeight) .Sum(x => x.Amount.Satoshi)); } + public LightningMoney GetLockedBalance() + { + return LightningMoney.Satoshis(_utxoSet.Values + .Where(x => x.LockedToChannelId is not null) + .Sum(x => x.Amount.Satoshi)); + } + public void Load(List utxoSet) { foreach (var utxoModel in utxoSet) _utxoSet.TryAdd((utxoModel.TxId, utxoModel.Index), utxoModel); } + + public List LockUtxosToSpendOnChannel(LightningMoney requestFundingAmount, ChannelId channelId) + { + // Get available UTXOs (not already locked for other channels) + var availableUtxos = _utxoSet.Values + .Where(utxo => utxo.LockedToChannelId is null) + .OrderByDescending(utxo => utxo.Amount.Satoshi) + .ToList(); + + if (availableUtxos.Count == 0) + throw new InvalidOperationException("No available UTXOs"); + + // Try Branch and Bound to find an exact match or minimize inputs + var selectedUtxos = BranchAndBound(availableUtxos, requestFundingAmount); + + if (selectedUtxos == null || selectedUtxos.Count == 0) + throw new InvalidOperationException("Insufficient funds"); + + // Lock the selected UTXOs for this channel + foreach (var selectedUtxo in selectedUtxos) + { + selectedUtxo.LockedToChannelId = channelId; + _utxoSet[(selectedUtxo.TxId, selectedUtxo.Index)] = selectedUtxo; + } + + return selectedUtxos; + } + + public List GetLockedUtxosForChannel(ChannelId channelId) + { + return _utxoSet.Values.Where(x => x.LockedToChannelId.Equals(channelId)).ToList(); + } + + public List ReturnUtxosNotSpentOnChannel(ChannelId channelId) + { + var utxos = _utxoSet.Values.Where(x => x.LockedToChannelId.Equals(channelId)).ToList(); + foreach (var utxo in utxos) + { + utxo.LockedToChannelId = null; + _utxoSet[(utxo.TxId, utxo.Index)] = utxo; + } + + return utxos; + } + + public void ConfirmSpendOnChannel(ChannelId channelId) + { + var utxos = _utxoSet.Values.Where(x => x.LockedToChannelId.Equals(channelId)); + foreach (var utxo in utxos) + _utxoSet.TryRemove((utxo.TxId, utxo.Index), out _); + } + + private static List? BranchAndBound(List utxos, LightningMoney targetAmount) + { + const int maxTries = 100_000; + var tries = 0; + + // Best solution found so far + List? bestSelection = null; + var bestWaste = long.MaxValue; + + // Current selection being explored + var targetSatoshis = targetAmount.Satoshi; + + // Stack for depth-first search: (index, includeUtxo) + var stack = new Stack<(int index, bool include, List selection, long value)>(); + stack.Push((0, true, [], 0)); + stack.Push((0, false, [], 0)); + + while (stack.Count > 0 && tries < maxTries) + { + tries++; + var (index, include, selection, value) = stack.Pop(); + + if (include && index < utxos.Count) + { + selection = new List(selection) { utxos[index] }; + value += utxos[index].Amount.Satoshi; + } + + // Check if we found a valid solution + if (value >= targetSatoshis) + { + var waste = value - targetSatoshis; + + // Perfect match (changeless transaction) + if (waste == 0) + return selection; + + // Better solution than the current best + if (waste < bestWaste || + (waste == bestWaste && selection.Count < (bestSelection?.Count ?? int.MaxValue))) + { + bestSelection = new List(selection); + bestWaste = waste; + } + + continue; // Prune this branch + } + + // Move to the next UTXO + var nextIndex = index + 1; + if (nextIndex >= utxos.Count) + continue; + + // Calculate upper bound (current value + all remaining UTXOs) + var upperBound = value; + for (var i = nextIndex; i < utxos.Count; i++) + upperBound += utxos[i].Amount.Satoshi; + + // Prune if we can't reach the target even with all remaining UTXOs + if (upperBound < targetSatoshis) + continue; + + // Explore both branches: include and exclude the next UTXO + stack.Push((nextIndex, false, [.. selection], value)); + stack.Push((nextIndex, true, [.. selection], value)); + } + + // If no exact match found, return the best solution or fallback to greedy + // Fallback: simple greedy approach if BnB didn't find a solution + return bestSelection ?? GreedySelection(utxos, targetAmount); + } + + private static List? GreedySelection(List utxos, LightningMoney targetAmount) + { + var selected = new List(); + long currentSum = 0; + var targetSatoshis = targetAmount.Satoshi; + + foreach (var utxo in utxos) + { + selected.Add(utxo); + currentSum += utxo.Amount.Satoshi; + + if (currentSum >= targetSatoshis) + return selected; + } + + return null; // Insufficient funds + } } \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Repositories/UnitOfWork.cs b/src/NLightning.Infrastructure.Repositories/UnitOfWork.cs index 91cc57f7..24cce6bc 100644 --- a/src/NLightning.Infrastructure.Repositories/UnitOfWork.cs +++ b/src/NLightning.Infrastructure.Repositories/UnitOfWork.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.Logging; -using NLightning.Domain.Bitcoin.Wallet.Models; namespace NLightning.Infrastructure.Repositories; @@ -7,6 +6,8 @@ namespace NLightning.Infrastructure.Repositories; using Database.Channel; using Database.Node; using Domain.Bitcoin.Interfaces; +using Domain.Bitcoin.ValueObjects; +using Domain.Bitcoin.Wallet.Models; using Domain.Channels.Interfaces; using Domain.Channels.Models; using Domain.Crypto.Hashes; @@ -115,8 +116,12 @@ public void AddUtxo(UtxoModel utxoModel) } } - public void SpendUtxo(UtxoModel utxoModel) + public void TrySpendUtxo(TxId transactionId, uint index) { + // Check if utxo exists in memory + if (!_utxoMemoryRepository.TryGetUtxo(transactionId, index, out var utxoModel)) + return; + try { _utxoMemoryRepository.Spend(utxoModel); diff --git a/src/NLightning.Infrastructure.Serialization/Payloads/OpenChannel1PayloadSerializer.cs b/src/NLightning.Infrastructure.Serialization/Payloads/OpenChannel1PayloadSerializer.cs index d85f6252..e13048bb 100644 --- a/src/NLightning.Infrastructure.Serialization/Payloads/OpenChannel1PayloadSerializer.cs +++ b/src/NLightning.Infrastructure.Serialization/Payloads/OpenChannel1PayloadSerializer.cs @@ -1,7 +1,5 @@ using System.Buffers; using System.Runtime.Serialization; -using NLightning.Domain.Protocol.Interfaces; -using NLightning.Domain.Serialization.Interfaces; namespace NLightning.Infrastructure.Serialization.Payloads; @@ -10,8 +8,10 @@ namespace NLightning.Infrastructure.Serialization.Payloads; using Domain.Crypto.Constants; using Domain.Crypto.ValueObjects; using Domain.Money; +using Domain.Protocol.Interfaces; using Domain.Protocol.Payloads; using Domain.Protocol.ValueObjects; +using Domain.Serialization.Interfaces; using Exceptions; public class OpenChannel1PayloadSerializer : IPayloadSerializer @@ -59,7 +59,7 @@ await stream .GetBytesBigEndian((ulong)openChannel1Payload.ChannelReserveAmount.Satoshi)); await stream .WriteAsync(EndianBitConverter.GetBytesBigEndian(openChannel1Payload.HtlcMinimumAmount.MilliSatoshi)); - await stream.WriteAsync(EndianBitConverter.GetBytesBigEndian((ulong)openChannel1Payload.FeeRatePerKw.Satoshi)); + await stream.WriteAsync(EndianBitConverter.GetBytesBigEndian((uint)openChannel1Payload.FeeRatePerKw.Satoshi)); await stream.WriteAsync(EndianBitConverter.GetBytesBigEndian(openChannel1Payload.ToSelfDelay)); await stream.WriteAsync(EndianBitConverter.GetBytesBigEndian(openChannel1Payload.MaxAcceptedHtlcs)); await stream.WriteAsync(openChannel1Payload.FundingPubKey); diff --git a/src/NLightning.Infrastructure/Protocol/Factories/ChannelIdFactory.cs b/src/NLightning.Infrastructure/Protocol/Factories/ChannelIdFactory.cs index f801e906..4711f238 100644 --- a/src/NLightning.Infrastructure/Protocol/Factories/ChannelIdFactory.cs +++ b/src/NLightning.Infrastructure/Protocol/Factories/ChannelIdFactory.cs @@ -1,13 +1,21 @@ +using System.Security.Cryptography; + namespace NLightning.Infrastructure.Protocol.Factories; using Crypto.Hashes; using Domain.Bitcoin.ValueObjects; +using Domain.Channels.Constants; using Domain.Channels.ValueObjects; using Domain.Crypto.ValueObjects; using Domain.Protocol.Interfaces; public class ChannelIdFactory : IChannelIdFactory { + public ChannelId CreateTemporaryChannelId() + { + return new ChannelId(RandomNumberGenerator.GetBytes(ChannelConstants.ChannelIdLength)); + } + public ChannelId CreateV1(TxId fundingTxId, ushort fundingOutputIndex) { Span channelId = stackalloc byte[32]; diff --git a/src/NLightning.Transport.Ipc/IpcEnvelope.cs b/src/NLightning.Transport.Ipc/IpcEnvelope.cs index 31b6d6ac..e8fd2ba8 100644 --- a/src/NLightning.Transport.Ipc/IpcEnvelope.cs +++ b/src/NLightning.Transport.Ipc/IpcEnvelope.cs @@ -2,6 +2,8 @@ namespace NLightning.Transport.Ipc; +using Domain.Client.Enums; + /// /// Envelope for all IPC messages, request and response, encoded with MessagePack. /// @@ -9,7 +11,7 @@ namespace NLightning.Transport.Ipc; public sealed class IpcEnvelope { [Key(0)] public int Version { get; set; } = 1; - [Key(1)] public NodeIpcCommand Command { get; init; } + [Key(1)] public ClientCommand Command { get; init; } [Key(2)] public Guid CorrelationId { get; set; } = Guid.NewGuid(); diff --git a/src/NLightning.Transport.Ipc/MessagePack/Formatters/ChannelIdFormatter.cs b/src/NLightning.Transport.Ipc/MessagePack/Formatters/ChannelIdFormatter.cs new file mode 100644 index 00000000..b63f8664 --- /dev/null +++ b/src/NLightning.Transport.Ipc/MessagePack/Formatters/ChannelIdFormatter.cs @@ -0,0 +1,21 @@ +using System.Runtime.Serialization; +using MessagePack; +using MessagePack.Formatters; + +namespace NLightning.Transport.Ipc.MessagePack.Formatters; + +using Domain.Channels.ValueObjects; + +public class ChannelIdFormatter : IMessagePackFormatter +{ + public void Serialize(ref MessagePackWriter writer, ChannelId value, MessagePackSerializerOptions options) + { + writer.Write((byte[])value); + } + + public ChannelId Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + return reader.ReadBytes()?.FirstSpan.ToArray() ?? + throw new SerializationException($"Error deserializing {nameof(ChannelId)})"); + } +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/MessagePack/Formatters/CompactPubKeyFormatter.cs b/src/NLightning.Transport.Ipc/MessagePack/Formatters/CompactPubKeyFormatter.cs index 4d220cde..cc9452f2 100644 --- a/src/NLightning.Transport.Ipc/MessagePack/Formatters/CompactPubKeyFormatter.cs +++ b/src/NLightning.Transport.Ipc/MessagePack/Formatters/CompactPubKeyFormatter.cs @@ -1,20 +1,22 @@ +using System.Runtime.Serialization; using MessagePack; using MessagePack.Formatters; namespace NLightning.Transport.Ipc.MessagePack.Formatters; -using Domain.Crypto.Constants; using Domain.Crypto.ValueObjects; +[ExcludeFormatterFromSourceGeneratedResolver] public class CompactPubKeyFormatter : IMessagePackFormatter { public void Serialize(ref MessagePackWriter writer, CompactPubKey value, MessagePackSerializerOptions options) { - writer.WriteRaw(value); + writer.Write((byte[])value); } public CompactPubKey Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { - return reader.ReadRaw(CryptoConstants.CompactPubkeyLen).FirstSpan.ToArray(); + return reader.ReadBytes()?.FirstSpan.ToArray() ?? + throw new SerializationException($"Error deserializing {nameof(CompactPubKey)})"); } } \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/MessagePack/Formatters/CompactPubKeyNullableFormatter.cs b/src/NLightning.Transport.Ipc/MessagePack/Formatters/CompactPubKeyNullableFormatter.cs new file mode 100644 index 00000000..5fdb837c --- /dev/null +++ b/src/NLightning.Transport.Ipc/MessagePack/Formatters/CompactPubKeyNullableFormatter.cs @@ -0,0 +1,31 @@ +using System.Runtime.Serialization; +using MessagePack; +using MessagePack.Formatters; + +namespace NLightning.Transport.Ipc.MessagePack.Formatters; + +using Domain.Crypto.ValueObjects; + +[ExcludeFormatterFromSourceGeneratedResolver] +public class CompactPubKeyNullableFormatter : IMessagePackFormatter +{ + public void Serialize(ref MessagePackWriter writer, CompactPubKey? value, MessagePackSerializerOptions options) + { + if (value is null) + { + writer.WriteNil(); + return; + } + + writer.Write((byte[])value.Value); + } + + public CompactPubKey? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + if (reader.TryReadNil()) + return null; + + return reader.ReadBytes()?.FirstSpan.ToArray() ?? + throw new SerializationException($"Error deserializing {nameof(CompactPubKey)})"); + } +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/MessagePack/Formatters/PeerAddressInfoFormatter.cs b/src/NLightning.Transport.Ipc/MessagePack/Formatters/PeerAddressInfoFormatter.cs index cdf5689b..9bb542af 100644 --- a/src/NLightning.Transport.Ipc/MessagePack/Formatters/PeerAddressInfoFormatter.cs +++ b/src/NLightning.Transport.Ipc/MessagePack/Formatters/PeerAddressInfoFormatter.cs @@ -6,6 +6,7 @@ namespace NLightning.Transport.Ipc.MessagePack.Formatters; using Domain.Node.ValueObjects; +[ExcludeFormatterFromSourceGeneratedResolver] public class PeerAddressInfoFormatter : IMessagePackFormatter { public void Serialize(ref MessagePackWriter writer, PeerAddressInfo value, MessagePackSerializerOptions options) diff --git a/src/NLightning.Transport.Ipc/MessagePack/Formatters/PeerAddressInfoNullableFormatter.cs b/src/NLightning.Transport.Ipc/MessagePack/Formatters/PeerAddressInfoNullableFormatter.cs new file mode 100644 index 00000000..e7087927 --- /dev/null +++ b/src/NLightning.Transport.Ipc/MessagePack/Formatters/PeerAddressInfoNullableFormatter.cs @@ -0,0 +1,31 @@ +using System.Runtime.Serialization; +using MessagePack; +using MessagePack.Formatters; + +namespace NLightning.Transport.Ipc.MessagePack.Formatters; + +using Domain.Node.ValueObjects; + +[ExcludeFormatterFromSourceGeneratedResolver] +public class PeerAddressInfoNullableFormatter : IMessagePackFormatter +{ + public void Serialize(ref MessagePackWriter writer, PeerAddressInfo? value, MessagePackSerializerOptions options) + { + if (value is null) + { + writer.WriteNil(); + return; + } + + writer.Write(value.Value.Address); + } + + public PeerAddressInfo? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + if (reader.TryReadNil()) + return null; + + return new PeerAddressInfo(reader.ReadString() ?? + throw new SerializationException($"Error deserializing {nameof(PeerAddressInfo)}")); + } +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/MessagePack/Formatters/SignedTransactionFormatter.cs b/src/NLightning.Transport.Ipc/MessagePack/Formatters/SignedTransactionFormatter.cs new file mode 100644 index 00000000..19741809 --- /dev/null +++ b/src/NLightning.Transport.Ipc/MessagePack/Formatters/SignedTransactionFormatter.cs @@ -0,0 +1,33 @@ +using System.Runtime.Serialization; +using MessagePack; +using MessagePack.Formatters; + +namespace NLightning.Transport.Ipc.MessagePack.Formatters; + +using Domain.Bitcoin.ValueObjects; + +public class SignedTransactionFormatter : IMessagePackFormatter +{ + public void Serialize(ref MessagePackWriter writer, SignedTransaction? value, MessagePackSerializerOptions options) + { + writer.WriteArrayHeader(2); + writer.Write(value.TxId); + writer.Write(value.RawTxBytes); + } + + public SignedTransaction Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + if (reader.ReadArrayHeader() != 2) + throw new SerializationException($"Error deserializing {nameof(SignedTransaction)}"); + + var txIdFormatter = options.Resolver.GetFormatterWithVerify(); + var txId = txIdFormatter.Deserialize(ref reader, options); + + // Read RawTxBytes + var rawTxBytes = reader.ReadBytes()?.FirstSpan.ToArray() ?? + throw new SerializationException( + $"Error deserializing {nameof(SignedTransaction)}.{nameof(SignedTransaction.RawTxBytes)}"); + + return new SignedTransaction(txId, rawTxBytes); + } +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/MessagePack/Formatters/TxIdFormatter.cs b/src/NLightning.Transport.Ipc/MessagePack/Formatters/TxIdFormatter.cs new file mode 100644 index 00000000..13b12186 --- /dev/null +++ b/src/NLightning.Transport.Ipc/MessagePack/Formatters/TxIdFormatter.cs @@ -0,0 +1,20 @@ +using MessagePack; +using MessagePack.Formatters; + +namespace NLightning.Transport.Ipc.MessagePack.Formatters; + +using Domain.Bitcoin.ValueObjects; +using Domain.Crypto.Constants; + +public class TxIdFormatter : IMessagePackFormatter +{ + public void Serialize(ref MessagePackWriter writer, TxId value, MessagePackSerializerOptions options) + { + writer.WriteRaw(value); + } + + public TxId Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + return reader.ReadRaw(CryptoConstants.Sha256HashLen).FirstSpan.ToArray(); + } +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/MessagePack/NLightningFormatterResolver.cs b/src/NLightning.Transport.Ipc/MessagePack/NLightningFormatterResolver.cs index 9f21f7ee..e96948b0 100644 --- a/src/NLightning.Transport.Ipc/MessagePack/NLightningFormatterResolver.cs +++ b/src/NLightning.Transport.Ipc/MessagePack/NLightningFormatterResolver.cs @@ -4,6 +4,8 @@ namespace NLightning.Transport.Ipc.MessagePack; +using Domain.Bitcoin.ValueObjects; +using Domain.Channels.ValueObjects; using Domain.Crypto.ValueObjects; using Domain.Money; using Domain.Node; @@ -21,10 +23,15 @@ private NLightningFormatterResolver() { _formatters[typeof(Hash)] = new HashFormatter(); _formatters[typeof(BitcoinNetwork)] = new BitcoinNetworkFormatter(); + _formatters[typeof(PeerAddressInfo?)] = new PeerAddressInfoNullableFormatter(); _formatters[typeof(PeerAddressInfo)] = new PeerAddressInfoFormatter(); + _formatters[typeof(CompactPubKey?)] = new CompactPubKeyNullableFormatter(); _formatters[typeof(CompactPubKey)] = new CompactPubKeyFormatter(); _formatters[typeof(FeatureSet)] = new FeatureSetFormatter(); _formatters[typeof(LightningMoney)] = new LightningMoneyFormatter(); + _formatters[typeof(SignedTransaction)] = new SignedTransactionFormatter(); + _formatters[typeof(ChannelId)] = new ChannelIdFormatter(); + _formatters[typeof(TxId)] = new TxIdFormatter(); } public IMessagePackFormatter? GetFormatter() diff --git a/src/NLightning.Transport.Ipc/Requests/GetAddressIpcRequest.cs b/src/NLightning.Transport.Ipc/Requests/GetAddressIpcRequest.cs index b5dd3fc7..dc901c77 100644 --- a/src/NLightning.Transport.Ipc/Requests/GetAddressIpcRequest.cs +++ b/src/NLightning.Transport.Ipc/Requests/GetAddressIpcRequest.cs @@ -8,7 +8,7 @@ namespace NLightning.Transport.Ipc.Requests; /// Request for Get Address command /// [MessagePackObject] -public class GetAddressIpcRequest +public sealed class GetAddressIpcRequest { [Key(0)] public AddressType AddressType { get; set; } = AddressType.P2Wpkh; } \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/Requests/OpenChannelIpcRequest.cs b/src/NLightning.Transport.Ipc/Requests/OpenChannelIpcRequest.cs new file mode 100644 index 00000000..be709111 --- /dev/null +++ b/src/NLightning.Transport.Ipc/Requests/OpenChannelIpcRequest.cs @@ -0,0 +1,21 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc.Requests; + +using Domain.Client.Requests; +using Domain.Money; + +/// +/// Empty request for OpenChannel. +/// +[MessagePackObject] +public sealed class OpenChannelIpcRequest +{ + [Key(0)] public required string NodeInfo { get; init; } + [Key(2)] public required LightningMoney Amount { get; init; } + + public OpenChannelClientRequest ToClientRequest() + { + return new OpenChannelClientRequest(NodeInfo, Amount); + } +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/Responses/GetAddressIpcResponse.cs b/src/NLightning.Transport.Ipc/Responses/GetAddressIpcResponse.cs index 042df7a2..5be954d3 100644 --- a/src/NLightning.Transport.Ipc/Responses/GetAddressIpcResponse.cs +++ b/src/NLightning.Transport.Ipc/Responses/GetAddressIpcResponse.cs @@ -6,7 +6,7 @@ namespace NLightning.Transport.Ipc.Responses; /// Response for List Peers command /// [MessagePackObject] -public class GetAddressIpcResponse +public sealed class GetAddressIpcResponse { [Key(0)] public string? AddressP2Tr { get; set; } [Key(1)] public string? AddressP2Wsh { get; set; } diff --git a/src/NLightning.Transport.Ipc/Responses/NodeInfoIpcResponse.cs b/src/NLightning.Transport.Ipc/Responses/NodeInfoIpcResponse.cs index 050fe9e4..37257309 100644 --- a/src/NLightning.Transport.Ipc/Responses/NodeInfoIpcResponse.cs +++ b/src/NLightning.Transport.Ipc/Responses/NodeInfoIpcResponse.cs @@ -6,7 +6,7 @@ namespace NLightning.Transport.Ipc.Responses; using Domain.Protocol.ValueObjects; /// -/// Response for NodeInfo (transport-specific DTO for MessagePack). +/// Response for NodeInfo command /// [MessagePackObject] public sealed class NodeInfoIpcResponse diff --git a/src/NLightning.Transport.Ipc/Responses/OpenChannelIpcResponse.cs b/src/NLightning.Transport.Ipc/Responses/OpenChannelIpcResponse.cs new file mode 100644 index 00000000..fbcb395c --- /dev/null +++ b/src/NLightning.Transport.Ipc/Responses/OpenChannelIpcResponse.cs @@ -0,0 +1,28 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc.Responses; + +using Domain.Bitcoin.ValueObjects; +using Domain.Channels.ValueObjects; +using Domain.Client.Responses; + +/// +/// Response for OpenChannel command +/// +[MessagePackObject] +public sealed class OpenChannelIpcResponse +{ + [Key(0)] public required SignedTransaction Transaction { get; init; } + [Key(2)] public uint Index { get; init; } + [Key(3)] public ChannelId ChannelId { get; init; } + + public static OpenChannelIpcResponse FromClientResponse(OpenChannelClientResponse clientResponse) + { + return new OpenChannelIpcResponse + { + Transaction = clientResponse.Transaction, + Index = clientResponse.Index, + ChannelId = clientResponse.ChannelId + }; + } +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/Responses/PeerInfoIpcResponse.cs b/src/NLightning.Transport.Ipc/Responses/PeerInfoIpcResponse.cs index 8a19600e..90992cfa 100644 --- a/src/NLightning.Transport.Ipc/Responses/PeerInfoIpcResponse.cs +++ b/src/NLightning.Transport.Ipc/Responses/PeerInfoIpcResponse.cs @@ -1,14 +1,15 @@ using MessagePack; -using NLightning.Domain.Crypto.ValueObjects; -using NLightning.Domain.Node; namespace NLightning.Transport.Ipc.Responses; +using Domain.Crypto.ValueObjects; +using Domain.Node; + /// /// Response for Peer Info command /// [MessagePackObject] -public class PeerInfoIpcResponse +public sealed class PeerInfoIpcResponse { [Key(0)] public CompactPubKey Id { get; init; } [Key(1)] public bool Connected { get; init; } diff --git a/src/NLightning.Transport.Ipc/Responses/WalletBalanceIpcResponse.cs b/src/NLightning.Transport.Ipc/Responses/WalletBalanceIpcResponse.cs index 5f57d73e..f973f941 100644 --- a/src/NLightning.Transport.Ipc/Responses/WalletBalanceIpcResponse.cs +++ b/src/NLightning.Transport.Ipc/Responses/WalletBalanceIpcResponse.cs @@ -8,7 +8,7 @@ namespace NLightning.Transport.Ipc.Responses; /// Response for Wallet Balance command /// [MessagePackObject] -public class WalletBalanceIpcResponse +public sealed class WalletBalanceIpcResponse { [Key(0)] public required LightningMoney ConfirmedBalance { get; init; } [Key(1)] public required LightningMoney UnconfirmedBalance { get; init; } diff --git a/test/NLightning.Application.Tests/Channels/Handlers/FundingCreatedMessageHandlerTests.cs b/test/NLightning.Application.Tests/Channels/Handlers/FundingCreatedMessageHandlerTests.cs index 2a0de274..fbc596c7 100644 --- a/test/NLightning.Application.Tests/Channels/Handlers/FundingCreatedMessageHandlerTests.cs +++ b/test/NLightning.Application.Tests/Channels/Handlers/FundingCreatedMessageHandlerTests.cs @@ -30,8 +30,13 @@ namespace NLightning.Application.Tests.Channels.Handlers; public class FundingCreatedMessageHandlerTests { private readonly Mock _mockBlockchainMonitor; + private readonly Mock _mockChannelIdFactory; private readonly Mock _mockChannelMemoryRepository; + private readonly Mock _mockCommitmentTransactionBuilder; + private readonly Mock _mockCommitmentTransactionModelFactory; private readonly Mock _mockLightningSigner; + private readonly Mock> _mockLogger; + private readonly Mock _mockMessageFactory; private readonly Mock _mockUnitOfWork; private readonly Mock _mockChannelDbRepository; private readonly FundingCreatedMessageHandler _handler; @@ -44,28 +49,29 @@ public class FundingCreatedMessageHandlerTests private readonly TxId _fundingTxId; private readonly ushort _fundingOutputIndex; private readonly CompactSignature _remoteSignature; + private readonly CompactSignature _localSignature; public FundingCreatedMessageHandlerTests() { _mockBlockchainMonitor = new Mock(); - var mockChannelIdFactory = new Mock(); + _mockChannelIdFactory = new Mock(); _mockChannelMemoryRepository = new Mock(); - var mockCommitmentTransactionBuilder = new Mock(); - var mockCommitmentTransactionModelFactory = new Mock(); + _mockCommitmentTransactionBuilder = new Mock(); + _mockCommitmentTransactionModelFactory = new Mock(); _mockLightningSigner = new Mock(); - var mockLogger = new Mock>(); - var mockMessageFactory = new Mock(); + _mockLogger = new Mock>(); + _mockMessageFactory = new Mock(); _mockUnitOfWork = new Mock(); _mockChannelDbRepository = new Mock(); _mockUnitOfWork.Setup(x => x.ChannelDbRepository).Returns(_mockChannelDbRepository.Object); - _handler = new FundingCreatedMessageHandler(_mockBlockchainMonitor.Object, mockChannelIdFactory.Object, + _handler = new FundingCreatedMessageHandler(_mockBlockchainMonitor.Object, _mockChannelIdFactory.Object, _mockChannelMemoryRepository.Object, - mockCommitmentTransactionBuilder.Object, - mockCommitmentTransactionModelFactory.Object, - _mockLightningSigner.Object, mockLogger.Object, - mockMessageFactory.Object, _mockUnitOfWork.Object); + _mockCommitmentTransactionBuilder.Object, + _mockCommitmentTransactionModelFactory.Object, + _mockLightningSigner.Object, _mockLogger.Object, + _mockMessageFactory.Object, _mockUnitOfWork.Object); // Setup test data CompactPubKey emptyPubKey = new byte[] { @@ -89,7 +95,7 @@ public FundingCreatedMessageHandlerTests() _remoteSignature = new CompactSignature(new byte[64]); byte[] localSignatureBytes = _remoteSignature; localSignatureBytes[0] = 1; - var localSignature = new CompactSignature(localSignatureBytes); + _localSignature = new CompactSignature(localSignatureBytes); var fundingAmount = LightningMoney.Satoshis(10_000); var commitmentNumber = new CommitmentNumber(emptyPubKey, emptyPubKey, new FakeSha256()); var fundingOutputInfo = new FundingOutputInfo(fundingAmount, emptyPubKey, emptyPubKey) @@ -113,8 +119,8 @@ public FundingCreatedMessageHandlerTests() _peerPubKey, 0, ChannelState.V1Opening, ChannelVersion.V1); // Setup ChannelIdFactory - mockChannelIdFactory.Setup(x => x.CreateV1(It.IsAny(), It.IsAny())) - .Returns(_newChannelId); + _mockChannelIdFactory.Setup(x => x.CreateV1(It.IsAny(), It.IsAny())) + .Returns(_newChannelId); // Setup mock commitment transactions var mockLocalCommitmentTx = @@ -122,11 +128,11 @@ public FundingCreatedMessageHandlerTests() var mockRemoteCommitmentTx = new CommitmentTransactionModel(commitmentNumber, LightningMoney.Zero, fundingOutputInfo); - mockCommitmentTransactionModelFactory + _mockCommitmentTransactionModelFactory .Setup(x => x.CreateCommitmentTransactionModel(It.IsAny(), CommitmentSide.Local)) .Returns(mockLocalCommitmentTx); - mockCommitmentTransactionModelFactory + _mockCommitmentTransactionModelFactory .Setup(x => x.CreateCommitmentTransactionModel(It.IsAny(), CommitmentSide.Remote)) .Returns(mockRemoteCommitmentTx); @@ -134,23 +140,23 @@ public FundingCreatedMessageHandlerTests() var mockLocalUnsignedTx = new SignedTransaction(TxId.Zero, [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]); var mockRemoteUnsignedTx = new SignedTransaction(TxId.One, [0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]); - mockCommitmentTransactionBuilder + _mockCommitmentTransactionBuilder .Setup(x => x.Build(mockLocalCommitmentTx)) .Returns(mockLocalUnsignedTx); - mockCommitmentTransactionBuilder + _mockCommitmentTransactionBuilder .Setup(x => x.Build(mockRemoteCommitmentTx)) .Returns(mockRemoteUnsignedTx); // Setup LightningSigner _mockLightningSigner - .Setup(x => x.SignTransaction(It.IsAny(), It.IsAny())) - .Returns(localSignature); + .Setup(x => x.SignChannelTransaction(It.IsAny(), It.IsAny())) + .Returns(_localSignature); // Setup MessageFactory - mockMessageFactory - .Setup(x => x.CreatedFundingSignedMessage(It.IsAny(), It.IsAny())) - .Returns(new FundingSignedMessage(new FundingSignedPayload(_newChannelId, localSignature))); + _mockMessageFactory + .Setup(x => x.CreateFundingSignedMessage(It.IsAny(), It.IsAny())) + .Returns(new FundingSignedMessage(new FundingSignedPayload(_newChannelId, _localSignature))); // Setup ChannelDbRepository _mockChannelDbRepository @@ -208,7 +214,7 @@ public async Task HandleAsync_ValidMessage_ProcessesChannelAndReturnsFundingSign // Verify our signature was generated _mockLightningSigner.Verify( - x => x.SignTransaction(_newChannelId, It.IsAny()), + x => x.SignChannelTransaction(_newChannelId, It.IsAny()), Times.Once); // Verify channel state was updated diff --git a/test/NLightning.Infrastructure.Bitcoin.Tests/Signers/LocalLightningSignerTests.cs b/test/NLightning.Infrastructure.Bitcoin.Tests/Signers/LocalLightningSignerTests.cs index b2662eb8..77cced3b 100644 --- a/test/NLightning.Infrastructure.Bitcoin.Tests/Signers/LocalLightningSignerTests.cs +++ b/test/NLightning.Infrastructure.Bitcoin.Tests/Signers/LocalLightningSignerTests.cs @@ -1,13 +1,14 @@ using Microsoft.Extensions.Logging; using NBitcoin; -using NLightning.Domain.Bitcoin.Transactions.Outputs; -using NLightning.Domain.Channels.ValueObjects; -using NLightning.Domain.Exceptions; using NLightning.Tests.Utils.Vectors; namespace NLightning.Infrastructure.Bitcoin.Tests.Signers; +using Domain.Bitcoin.Interfaces; +using Domain.Bitcoin.Transactions.Outputs; using Domain.Bitcoin.ValueObjects; +using Domain.Channels.ValueObjects; +using Domain.Exceptions; using Domain.Node.Options; using Domain.Protocol.Interfaces; using Infrastructure.Bitcoin.Builders; @@ -34,6 +35,7 @@ public void Given_ValidParameters_When_ValidatingSignature_Then_ReturnsTrue() var loggerMock = new Mock>(); var nodeOptions = new NodeOptions(); var secureKeyManagerMock = new Mock(); + var utxoMemoryRepository = new Mock(); var testChannelId = ChannelId.Zero; var channelSigningInfo = new ChannelSigningInfo(Bolt3AppendixBVectors.ExpectedTxId.ToBytes(), 0, Bolt3AppendixBVectors.FundingSatoshis, @@ -41,7 +43,8 @@ public void Given_ValidParameters_When_ValidatingSignature_Then_ReturnsTrue() Bolt3AppendixCVectors.NodeBFundingPubkey.ToBytes(), 0); var localSigner = new LocalLightningSigner(fundingOutputBuilderMock.Object, keyDerivationServiceMock.Object, - loggerMock.Object, nodeOptions, secureKeyManagerMock.Object); + loggerMock.Object, nodeOptions, secureKeyManagerMock.Object, + utxoMemoryRepository.Object); localSigner.RegisterChannel(testChannelId, channelSigningInfo); var tx = Bolt3AppendixCVectors.ExpectedCommitTx0; @@ -66,9 +69,11 @@ public void Given_InvalidChannelId_When_ValidatingSignature_Then_ThrowsException var loggerMock = new Mock>(); var nodeOptions = new NodeOptions(); var secureKeyManagerMock = new Mock(); + var utxoMemoryRepository = new Mock(); var localSigner = new LocalLightningSigner(fundingOutputBuilderMock.Object, keyDerivationServiceMock.Object, - loggerMock.Object, nodeOptions, secureKeyManagerMock.Object); + loggerMock.Object, nodeOptions, secureKeyManagerMock.Object, + utxoMemoryRepository.Object); var unregisteredChannelId = ChannelId.Zero; var tx = Bolt3AppendixCVectors.ExpectedCommitTx0; diff --git a/test/NLightning.Infrastructure.Tests/Bitcoin/Wallet/BlockchainMonitorServiceTests.cs b/test/NLightning.Infrastructure.Bitcoin.Tests/Wallet/BlockchainMonitorServiceTests.cs similarity index 71% rename from test/NLightning.Infrastructure.Tests/Bitcoin/Wallet/BlockchainMonitorServiceTests.cs rename to test/NLightning.Infrastructure.Bitcoin.Tests/Wallet/BlockchainMonitorServiceTests.cs index 1bba9078..cfb29c9c 100644 --- a/test/NLightning.Infrastructure.Tests/Bitcoin/Wallet/BlockchainMonitorServiceTests.cs +++ b/test/NLightning.Infrastructure.Bitcoin.Tests/Wallet/BlockchainMonitorServiceTests.cs @@ -4,27 +4,29 @@ using NBitcoin; using NLightning.Tests.Utils.Mocks; -namespace NLightning.Infrastructure.Tests.Bitcoin.Wallet; +namespace NLightning.Infrastructure.Bitcoin.Tests.Wallet; +using Bitcoin.Wallet; +using Bitcoin.Wallet.Interfaces; using Domain.Bitcoin.Interfaces; using Domain.Bitcoin.Transactions.Models; using Domain.Bitcoin.ValueObjects; using Domain.Channels.ValueObjects; using Domain.Persistence.Interfaces; -using Infrastructure.Bitcoin.Options; -using Infrastructure.Bitcoin.Wallet; -using Infrastructure.Bitcoin.Wallet.Interfaces; +using Options; public class BlockchainMonitorServiceTests { private readonly Mock> _mockBitcoinOptions; - private readonly Mock _mockBitcoinWallet; + private readonly Mock _mockBitcoinChainService; private readonly Mock> _mockLogger; private readonly Mock> _mockNodeOptions; private readonly FakeServiceProvider _fakeServiceProvider; private readonly Mock _mockUnitOfWork; private readonly Mock _mockBlockchainStateRepository; private readonly Mock _mockWatchedTransactionRepository; + private readonly Mock _mockWalletAddressesDbRepository; + private readonly Mock _mockUtxoDbRepository; private readonly BlockchainMonitorService _service; @@ -42,7 +44,7 @@ public BlockchainMonitorServiceTests() ZmqTxPort = 28333 }); - _mockBitcoinWallet = new Mock(); + _mockBitcoinChainService = new Mock(); _mockLogger = new Mock>(); _mockNodeOptions = new Mock>(); @@ -56,15 +58,19 @@ public BlockchainMonitorServiceTests() _fakeServiceProvider.AddService(typeof(IUnitOfWork), _mockUnitOfWork.Object); _mockBlockchainStateRepository = new Mock(); _mockWatchedTransactionRepository = new Mock(); + _mockWalletAddressesDbRepository = new Mock(); + _mockUtxoDbRepository = new Mock(); // Set up unit of work to return repositories _mockUnitOfWork.Setup(x => x.BlockchainStateDbRepository).Returns(_mockBlockchainStateRepository.Object); _mockUnitOfWork.Setup(x => x.WatchedTransactionDbRepository).Returns(_mockWatchedTransactionRepository.Object); + _mockUnitOfWork.Setup(x => x.WalletAddressesDbRepository).Returns(_mockWalletAddressesDbRepository.Object); + _mockUnitOfWork.Setup(x => x.UtxoDbRepository).Returns(_mockUtxoDbRepository.Object); // Create the service _service = new BlockchainMonitorService( _mockBitcoinOptions.Object, - _mockBitcoinWallet.Object, + _mockBitcoinChainService.Object, _mockLogger.Object, _mockNodeOptions.Object, _fakeServiceProvider); @@ -86,11 +92,11 @@ public async Task StartAsync_WithExistingBlockchainState_LoadsStateAndPendingTra _mockWatchedTransactionRepository.Setup(x => x.GetAllPendingAsync()) .ReturnsAsync(pendingTransactions); - _mockBitcoinWallet.Setup(x => x.GetCurrentBlockHeightAsync()) - .ReturnsAsync(110u); + _mockBitcoinChainService.Setup(x => x.GetCurrentBlockHeightAsync()) + .ReturnsAsync(110u); - _mockBitcoinWallet.Setup(x => x.GetBlockAsync(It.IsAny())) - .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); + _mockBitcoinChainService.Setup(x => x.GetBlockAsync(It.IsAny())) + .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); // Act await _service.StartAsync(0, CancellationToken.None); @@ -98,8 +104,8 @@ public async Task StartAsync_WithExistingBlockchainState_LoadsStateAndPendingTra // Assert _mockBlockchainStateRepository.Verify(x => x.GetStateAsync(), Times.Once); _mockWatchedTransactionRepository.Verify(x => x.GetAllPendingAsync(), Times.Once); - _mockBitcoinWallet.Verify(x => x.GetCurrentBlockHeightAsync(), Times.Once); - _mockBitcoinWallet.Verify(x => x.GetBlockAsync(100), Times.Once); + _mockBitcoinChainService.Verify(x => x.GetCurrentBlockHeightAsync(), Times.Once); + _mockBitcoinChainService.Verify(x => x.GetBlockAsync(100), Times.Once); } [Fact] @@ -113,11 +119,11 @@ public async Task StartAsync_WithNoBlockchainState_CreatesNewState() .ReturnsAsync( new List()); - _mockBitcoinWallet.Setup(x => x.GetCurrentBlockHeightAsync()) - .ReturnsAsync(100u); + _mockBitcoinChainService.Setup(x => x.GetCurrentBlockHeightAsync()) + .ReturnsAsync(100u); - _mockBitcoinWallet.Setup(x => x.GetBlockAsync(It.IsAny())) - .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); + _mockBitcoinChainService.Setup(x => x.GetBlockAsync(It.IsAny())) + .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); // Act await _service.StartAsync(0, CancellationToken.None); @@ -164,11 +170,11 @@ public async Task ProcessNewBlock_AddsMissingBlocksAndProcessesThem() .ReturnsAsync( new List()); - _mockBitcoinWallet.Setup(x => x.GetCurrentBlockHeightAsync()) - .ReturnsAsync(currentBlockHeight); + _mockBitcoinChainService.Setup(x => x.GetCurrentBlockHeightAsync()) + .ReturnsAsync(currentBlockHeight); - _mockBitcoinWallet.Setup(x => x.GetBlockAsync(It.IsAny())) - .ReturnsAsync(block); + _mockBitcoinChainService.Setup(x => x.GetBlockAsync(It.IsAny())) + .ReturnsAsync(block); await _service.StartAsync(0, CancellationToken.None); @@ -261,60 +267,6 @@ public void OnTransactionConfirmed_RaisedWhenTransactionReachesRequiredDepth() t.IsCompleted)), Times.Once); } - [Fact] - public void CheckWatchedTransactionsForBlock_IdentifiesAndUpdatesTransactions() - { - // Arrange - var channelId = new ChannelId(new byte[32]); - var txId = new TxId(new byte[32]); - const uint requiredDepth = 6; - const uint blockHeight = 100; - - var watchedTx = new WatchedTransactionModel(channelId, txId, requiredDepth); - - // Create a transaction for the block - var transaction = Transaction.Create(Network.Main); - transaction.Inputs.Add(new OutPoint()); - var txHash = transaction.GetHash(); - - var blockTransactions = new List { transaction }; - - // Use reflection to access private methods/fields - var checkWatchedTransactionsForBlockMethod = typeof(BlockchainMonitorService).GetMethod( - "CheckWatchedTransactionsForBlock", - System.Reflection.BindingFlags.NonPublic | - System.Reflection.BindingFlags.Instance) - ?? throw new NullReferenceException( - "Can't find CheckWatchedTransactionsForBlock method"); - - var watchedTransactionsField = typeof(BlockchainMonitorService).GetField("_watchedTransactions", - System.Reflection.BindingFlags.NonPublic | - System.Reflection.BindingFlags.Instance) ?? - throw new NullReferenceException("Can't find watchedTransactions field"); - - // Set the watched transactions field - var watchedTransactions = - watchedTransactionsField.GetValue(_service) as ConcurrentDictionary ?? - throw new InvalidCastException("Can't get watchedTransactions field"); - watchedTransactions[txHash] = watchedTx; - - // Act - checkWatchedTransactionsForBlockMethod.Invoke( - _service, [blockTransactions, blockHeight, _mockUnitOfWork.Object]); - - // Assert - _mockWatchedTransactionRepository.Verify( - x => x.Update( - It.Is(t => t.ChannelId.Equals(channelId) && - t.FirstSeenAtHeight == blockHeight)), Times.Once); - - // If the required depth is 0, it should also mark the transaction as completed - if (watchedTx.RequiredDepth == 0) - { - Assert.True(watchedTx.IsCompleted); - } - } - [Fact] public async Task StartAsync_WithHeightOfBirth_CreatesStateAtSpecifiedHeight() { @@ -333,11 +285,11 @@ public async Task StartAsync_WithHeightOfBirth_CreatesStateAtSpecifiedHeight() .ReturnsAsync( new List()); - _mockBitcoinWallet.Setup(x => x.GetCurrentBlockHeightAsync()) - .ReturnsAsync(100u); + _mockBitcoinChainService.Setup(x => x.GetCurrentBlockHeightAsync()) + .ReturnsAsync(100u); - _mockBitcoinWallet.Setup(x => x.GetBlockAsync(It.IsAny())) - .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); + _mockBitcoinChainService.Setup(x => x.GetBlockAsync(It.IsAny())) + .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); // Act await _service.StartAsync(heightOfBirth, CancellationToken.None); @@ -363,11 +315,11 @@ public async Task StartAsync_WithExistingStateAndHeightOfBirth_UsesExistingState .ReturnsAsync( new List()); - _mockBitcoinWallet.Setup(x => x.GetCurrentBlockHeightAsync()) - .ReturnsAsync(110u); + _mockBitcoinChainService.Setup(x => x.GetCurrentBlockHeightAsync()) + .ReturnsAsync(110u); - _mockBitcoinWallet.Setup(x => x.GetBlockAsync(It.IsAny())) - .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); + _mockBitcoinChainService.Setup(x => x.GetBlockAsync(It.IsAny())) + .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); // Act await _service.StartAsync(heightOfBirth, CancellationToken.None); @@ -379,8 +331,8 @@ public async Task StartAsync_WithExistingStateAndHeightOfBirth_UsesExistingState Times.Never); // Should use the existing height, not the height of birth - _mockBitcoinWallet.Verify(x => x.GetBlockAsync(existingHeight), Times.Once); - _mockBitcoinWallet.Verify(x => x.GetBlockAsync(heightOfBirth), Times.Never); + _mockBitcoinChainService.Verify(x => x.GetBlockAsync(existingHeight), Times.Once); + _mockBitcoinChainService.Verify(x => x.GetBlockAsync(heightOfBirth), Times.Never); } [Fact] @@ -396,22 +348,22 @@ public async Task StartAsync_WithHigherHeightOfBirth_ProcessesMissingBlocks() .ReturnsAsync( new List()); - _mockBitcoinWallet.Setup(x => x.GetCurrentBlockHeightAsync()) - .ReturnsAsync(55u); // The current height is higher than the height of birth + _mockBitcoinChainService.Setup(x => x.GetCurrentBlockHeightAsync()) + .ReturnsAsync(55u); // The current height is higher than the height of birth - _mockBitcoinWallet.Setup(x => x.GetBlockAsync(It.IsAny())) - .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); + _mockBitcoinChainService.Setup(x => x.GetBlockAsync(It.IsAny())) + .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); // Act await _service.StartAsync(heightOfBirth, CancellationToken.None); // Assert // Should fetch blocks from heightOfBirth to current height - _mockBitcoinWallet.Verify(x => x.GetBlockAsync(heightOfBirth), Times.Once); - _mockBitcoinWallet.Verify(x => x.GetBlockAsync(51), Times.Once); - _mockBitcoinWallet.Verify(x => x.GetBlockAsync(52), Times.Once); - _mockBitcoinWallet.Verify(x => x.GetBlockAsync(53), Times.Once); - _mockBitcoinWallet.Verify(x => x.GetBlockAsync(54), Times.Once); + _mockBitcoinChainService.Verify(x => x.GetBlockAsync(heightOfBirth), Times.Once); + _mockBitcoinChainService.Verify(x => x.GetBlockAsync(51), Times.Once); + _mockBitcoinChainService.Verify(x => x.GetBlockAsync(52), Times.Once); + _mockBitcoinChainService.Verify(x => x.GetBlockAsync(53), Times.Once); + _mockBitcoinChainService.Verify(x => x.GetBlockAsync(54), Times.Once); } [Fact] @@ -432,11 +384,11 @@ public async Task StartAsync_WithHeightOfBirthZero_StartsFromGenesis() .ReturnsAsync( new List()); - _mockBitcoinWallet.Setup(x => x.GetCurrentBlockHeightAsync()) - .ReturnsAsync(5u); + _mockBitcoinChainService.Setup(x => x.GetCurrentBlockHeightAsync()) + .ReturnsAsync(5u); - _mockBitcoinWallet.Setup(x => x.GetBlockAsync(It.IsAny())) - .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); + _mockBitcoinChainService.Setup(x => x.GetBlockAsync(It.IsAny())) + .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); // Act await _service.StartAsync(heightOfBirth, CancellationToken.None); @@ -446,6 +398,6 @@ public async Task StartAsync_WithHeightOfBirthZero_StartsFromGenesis() Assert.Equal(heightOfBirth, capturedStateHeight); // Should fetch blocks from genesis (0) onwards - _mockBitcoinWallet.Verify(x => x.GetBlockAsync(0), Times.Once); + _mockBitcoinChainService.Verify(x => x.GetBlockAsync(0), Times.Once); } } \ No newline at end of file diff --git a/test/NLightning.Integration.Tests/BOLT3/Bolt3IntegrationTests.cs b/test/NLightning.Integration.Tests/BOLT3/Bolt3IntegrationTests.cs index 93cec43b..925b375a 100644 --- a/test/NLightning.Integration.Tests/BOLT3/Bolt3IntegrationTests.cs +++ b/test/NLightning.Integration.Tests/BOLT3/Bolt3IntegrationTests.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using NLightning.Domain.Protocol.Models; using NLightning.Tests.Utils.Vectors; #pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. @@ -17,6 +16,7 @@ namespace NLightning.Integration.Tests.BOLT3; using Domain.Enums; using Domain.Money; using Domain.Node.Options; +using Domain.Protocol.Models; using Infrastructure.Bitcoin.Builders; using Infrastructure.Bitcoin.Services; using Infrastructure.Bitcoin.Signers; @@ -118,7 +118,7 @@ public void Given_Bolt3Specifications_When_CreatingCommitmentTransaction_Then_Sh var exception = Record.Exception(() => testLightningSigner.ValidateSignature( ChannelId.Zero, Bolt3AppendixCVectors.NodeBSignature0.ToCompact(), unsignedTransaction)); - var signature = testLightningSigner.SignTransaction(ChannelId.Zero, unsignedTransaction); + var signature = testLightningSigner.SignChannelTransaction(ChannelId.Zero, unsignedTransaction); // Then Assert.Null(exception); @@ -150,7 +150,7 @@ public void var exception = Record.Exception(() => testLightningSigner.ValidateSignature( ChannelId.Zero, Bolt3AppendixCVectors.NodeBSignature1.ToCompact(), unsignedTransaction)); - var signature = testLightningSigner.SignTransaction(ChannelId.Zero, unsignedTransaction); + var signature = testLightningSigner.SignChannelTransaction(ChannelId.Zero, unsignedTransaction); // Then Assert.Null(exception); @@ -182,7 +182,7 @@ public void var exception = Record.Exception(() => testLightningSigner.ValidateSignature( ChannelId.Zero, Bolt3AppendixCVectors.NodeBSignature2.ToCompact(), unsignedTransaction)); - var signature = testLightningSigner.SignTransaction(ChannelId.Zero, unsignedTransaction); + var signature = testLightningSigner.SignChannelTransaction(ChannelId.Zero, unsignedTransaction); // Then Assert.Null(exception); @@ -215,7 +215,7 @@ public void var exception = Record.Exception(() => testLightningSigner.ValidateSignature( ChannelId.Zero, Bolt3AppendixCVectors.NodeBSignature3.ToCompact(), unsignedTransaction)); - var signature = testLightningSigner.SignTransaction(ChannelId.Zero, unsignedTransaction); + var signature = testLightningSigner.SignChannelTransaction(ChannelId.Zero, unsignedTransaction); // Then Assert.Null(exception); @@ -248,7 +248,7 @@ public void var exception = Record.Exception(() => testLightningSigner.ValidateSignature( ChannelId.Zero, Bolt3AppendixCVectors.NodeBSignature4.ToCompact(), unsignedTransaction)); - var signature = testLightningSigner.SignTransaction(ChannelId.Zero, unsignedTransaction); + var signature = testLightningSigner.SignChannelTransaction(ChannelId.Zero, unsignedTransaction); // Then Assert.Null(exception); @@ -281,7 +281,7 @@ public void var exception = Record.Exception(() => testLightningSigner.ValidateSignature( ChannelId.Zero, Bolt3AppendixCVectors.NodeBSignature5.ToCompact(), unsignedTransaction)); - var signature = testLightningSigner.SignTransaction(ChannelId.Zero, unsignedTransaction); + var signature = testLightningSigner.SignChannelTransaction(ChannelId.Zero, unsignedTransaction); // Then Assert.Null(exception); @@ -314,7 +314,7 @@ public void var exception = Record.Exception(() => testLightningSigner.ValidateSignature( ChannelId.Zero, Bolt3AppendixCVectors.NodeBSignature6.ToCompact(), unsignedTransaction)); - var signature = testLightningSigner.SignTransaction(ChannelId.Zero, unsignedTransaction); + var signature = testLightningSigner.SignChannelTransaction(ChannelId.Zero, unsignedTransaction); // Then Assert.Null(exception); @@ -347,7 +347,7 @@ public void var exception = Record.Exception(() => testLightningSigner.ValidateSignature( ChannelId.Zero, Bolt3AppendixCVectors.NodeBSignature7.ToCompact(), unsignedTransaction)); - var signature = testLightningSigner.SignTransaction(ChannelId.Zero, unsignedTransaction); + var signature = testLightningSigner.SignChannelTransaction(ChannelId.Zero, unsignedTransaction); // Then Assert.Null(exception); @@ -380,7 +380,7 @@ public void var exception = Record.Exception(() => testLightningSigner.ValidateSignature( ChannelId.Zero, Bolt3AppendixCVectors.NodeBSignature8.ToCompact(), unsignedTransaction)); - var signature = testLightningSigner.SignTransaction(ChannelId.Zero, unsignedTransaction); + var signature = testLightningSigner.SignChannelTransaction(ChannelId.Zero, unsignedTransaction); // Then Assert.Null(exception); @@ -413,7 +413,7 @@ public void var exception = Record.Exception(() => testLightningSigner.ValidateSignature( ChannelId.Zero, Bolt3AppendixCVectors.NodeBSignature9.ToCompact(), unsignedTransaction)); - var signature = testLightningSigner.SignTransaction(ChannelId.Zero, unsignedTransaction); + var signature = testLightningSigner.SignChannelTransaction(ChannelId.Zero, unsignedTransaction); // Then Assert.Null(exception); @@ -446,7 +446,7 @@ public void var exception = Record.Exception(() => testLightningSigner.ValidateSignature( ChannelId.Zero, Bolt3AppendixCVectors.NodeBSignature10.ToCompact(), unsignedTransaction)); - var signature = testLightningSigner.SignTransaction(ChannelId.Zero, unsignedTransaction); + var signature = testLightningSigner.SignChannelTransaction(ChannelId.Zero, unsignedTransaction); // Then Assert.Null(exception); @@ -479,7 +479,7 @@ public void var exception = Record.Exception(() => testLightningSigner.ValidateSignature( ChannelId.Zero, Bolt3AppendixCVectors.NodeBSignature11.ToCompact(), unsignedTransaction)); - var signature = testLightningSigner.SignTransaction(ChannelId.Zero, unsignedTransaction); + var signature = testLightningSigner.SignChannelTransaction(ChannelId.Zero, unsignedTransaction); // Then Assert.Null(exception); @@ -512,7 +512,7 @@ public void var exception = Record.Exception(() => testLightningSigner.ValidateSignature( ChannelId.Zero, Bolt3AppendixCVectors.NodeBSignature12.ToCompact(), unsignedTransaction)); - var signature = testLightningSigner.SignTransaction(ChannelId.Zero, unsignedTransaction); + var signature = testLightningSigner.SignChannelTransaction(ChannelId.Zero, unsignedTransaction); // Then Assert.Null(exception); @@ -545,7 +545,7 @@ public void var exception = Record.Exception(() => testLightningSigner.ValidateSignature( ChannelId.Zero, Bolt3AppendixCVectors.NodeBSignature13.ToCompact(), unsignedTransaction)); - var signature = testLightningSigner.SignTransaction(ChannelId.Zero, unsignedTransaction); + var signature = testLightningSigner.SignChannelTransaction(ChannelId.Zero, unsignedTransaction); // Then Assert.Null(exception); @@ -578,7 +578,7 @@ public void var exception = Record.Exception(() => testLightningSigner.ValidateSignature( ChannelId.Zero, Bolt3AppendixCVectors.NodeBSignature14.ToCompact(), unsignedTransaction)); - var signature = testLightningSigner.SignTransaction(ChannelId.Zero, unsignedTransaction); + var signature = testLightningSigner.SignChannelTransaction(ChannelId.Zero, unsignedTransaction); // Then Assert.Null(exception); @@ -614,7 +614,7 @@ public void var exception = Record.Exception(() => testLightningSigner.ValidateSignature( ChannelId.Zero, Bolt3AppendixCVectors.NodeBSignature15.ToCompact(), unsignedTransaction)); - var signature = testLightningSigner.SignTransaction(ChannelId.Zero, unsignedTransaction); + var signature = testLightningSigner.SignChannelTransaction(ChannelId.Zero, unsignedTransaction); // Then Assert.Null(exception); diff --git a/test/NLightning.Integration.Tests/BOLT3/Mocks/Bolt3TestLightningSigner.cs b/test/NLightning.Integration.Tests/BOLT3/Mocks/Bolt3TestLightningSigner.cs index 748f6d9c..4a905c3a 100644 --- a/test/NLightning.Integration.Tests/BOLT3/Mocks/Bolt3TestLightningSigner.cs +++ b/test/NLightning.Integration.Tests/BOLT3/Mocks/Bolt3TestLightningSigner.cs @@ -17,7 +17,7 @@ namespace NLightning.Integration.Tests.BOLT3.Mocks; public class Bolt3TestLightningSigner : LocalLightningSigner, ILightningSigner { public Bolt3TestLightningSigner(NodeOptions nodeOptions, ILogger logger) - : base(new FundingOutputBuilder(), null, logger, nodeOptions, null) + : base(new FundingOutputBuilder(), null, logger, nodeOptions, null, null) { } diff --git a/test/NLightning.Integration.Tests/Docker/AbcNetworkTests.cs b/test/NLightning.Integration.Tests/Docker/AbcNetworkTests.cs index 1068498c..78a32506 100644 --- a/test/NLightning.Integration.Tests/Docker/AbcNetworkTests.cs +++ b/test/NLightning.Integration.Tests/Docker/AbcNetworkTests.cs @@ -109,11 +109,14 @@ public AbcNetworkTests(LightningRegtestNetworkFixture fixture, ITestOutputHelper services.AddSingleton(_secureKeyManager); services.AddSingleton(sp => { + var channelIdFactory = sp.GetRequiredService(); + var channelOpenValidator = sp.GetRequiredService(); var feeService = sp.GetRequiredService(); var lightningSigner = sp.GetRequiredService(); var nodeOptions = sp.GetRequiredService>().Value; var sha256 = sp.GetRequiredService(); - return new ChannelFactory(feeService, lightningSigner, nodeOptions, sha256); + return new ChannelFactory(channelIdFactory, channelOpenValidator, feeService, lightningSigner, nodeOptions, + sha256); }); services.AddSingleton(); services.AddSingleton(serviceProvider => @@ -122,10 +125,11 @@ public AbcNetworkTests(LightningRegtestNetworkFixture fixture, ITestOutputHelper var keyDerivationService = serviceProvider.GetRequiredService(); var logger = serviceProvider.GetRequiredService>(); var nodeOptions = serviceProvider.GetRequiredService>().Value; + var utxoMemoryRepository = serviceProvider.GetRequiredService(); // Create the signer with the correct network return new LocalLightningSigner(fundingOutputBuilder, keyDerivationService, logger, nodeOptions, - _secureKeyManager); + _secureKeyManager, utxoMemoryRepository); }); services.AddApplicationServices(); services.AddInfrastructureServices(); From 2383d350227d0a07da6e1d2a872eee0722bac165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADckolas=20Goline?= Date: Wed, 19 Nov 2025 11:28:44 -0300 Subject: [PATCH 09/20] fix formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Níckolas Goline --- src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs | 4 ++-- src/NLightning.Daemon/Services/Ipc/NamedPipeIpcService.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs b/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs index 3520da37..6cb9a13e 100644 --- a/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs +++ b/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs @@ -184,8 +184,8 @@ void ChannelMessageHandlerEnvelope(object? _, ChannelMessageEventArgs args) } } - private void HandleChannelMessage(ChannelMessageEventArgs args, ChannelId channelId, - TaskCompletionSource tcs) + private void HandleChannelMessage(ChannelMessageEventArgs args, ChannelId _, + TaskCompletionSource __) { if (args.Message.Type == MessageTypes.AcceptChannel) { diff --git a/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcService.cs b/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcService.cs index 8cbb365f..401f3945 100644 --- a/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcService.cs +++ b/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcService.cs @@ -28,7 +28,7 @@ internal sealed class NamedPipeIpcService : INamedPipeIpcService private Task? _listenerTask; public NamedPipeIpcService(IIpcAuthenticator authenticator, string configPath, IIpcFraming framing, - ILogger logger, IOptions nodeOptions, + ILogger logger, IOptions _, IIpcRequestRouter router) { _logger = logger; From d4d75c021700ac6742e26234f9caf6d74650426f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADckolas=20Goline?= Date: Sat, 14 Mar 2026 00:45:35 -0300 Subject: [PATCH 10/20] typos, fixes, Et al. --- .../Bitcoin/Events/WalletMovementEventArgs.cs | 20 ++ .../CommitmentTransactionModelFactory.cs | 18 +- .../FundingTransactionModelFactory.cs | 2 +- .../Node/Events/AttentionMessageEventArgs.cs | 18 + .../ICommitmentKeyDerivationService.cs | 7 +- .../Builders/FundingTransactionBuilder.cs | 4 +- .../CommitmentKeyDerivationService.cs | 4 +- .../Signers/LocalLightningSigner.cs | 4 +- .../Wallet/BlockchainMonitorService.cs | 7 + .../Memory/UtxoMemoryRepository.cs | 6 +- .../Node/FeatureSetSerializer.cs | 9 +- .../Transport/Services/TransportService.cs | 2 +- .../NLightning.Blazor.Tests.csproj | 2 +- .../Channels/ChannelOpeningFlowTests.cs | 324 ++++++++++++++++++ 14 files changed, 401 insertions(+), 26 deletions(-) create mode 100644 src/NLightning.Domain/Bitcoin/Events/WalletMovementEventArgs.cs create mode 100644 src/NLightning.Domain/Node/Events/AttentionMessageEventArgs.cs create mode 100644 test/NLightning.Integration.Tests/Channels/ChannelOpeningFlowTests.cs diff --git a/src/NLightning.Domain/Bitcoin/Events/WalletMovementEventArgs.cs b/src/NLightning.Domain/Bitcoin/Events/WalletMovementEventArgs.cs new file mode 100644 index 00000000..94514a18 --- /dev/null +++ b/src/NLightning.Domain/Bitcoin/Events/WalletMovementEventArgs.cs @@ -0,0 +1,20 @@ +namespace NLightning.Domain.Bitcoin.Events; + +using Money; +using ValueObjects; + +public class WalletMovementEventArgs : EventArgs +{ + public string WalletAddress { get; } + public LightningMoney Amount { get; } + public TxId TxId { get; } + public uint BlockHeight { get; } + + public WalletMovementEventArgs(string walletAddress, LightningMoney amount, TxId txId, uint blockHeight) + { + WalletAddress = walletAddress; + Amount = amount; + TxId = txId; + BlockHeight = blockHeight; + } +} \ No newline at end of file diff --git a/src/NLightning.Domain/Bitcoin/Transactions/Factories/CommitmentTransactionModelFactory.cs b/src/NLightning.Domain/Bitcoin/Transactions/Factories/CommitmentTransactionModelFactory.cs index c4f310e5..3e90e185 100644 --- a/src/NLightning.Domain/Bitcoin/Transactions/Factories/CommitmentTransactionModelFactory.cs +++ b/src/NLightning.Domain/Bitcoin/Transactions/Factories/CommitmentTransactionModelFactory.cs @@ -27,6 +27,21 @@ public CommitmentTransactionModelFactory(ICommitmentKeyDerivationService commitm public CommitmentTransactionModel CreateCommitmentTransactionModel(ChannelModel channel, CommitmentSide side) { + // Guarantee we have a RemoteKeySet + if (channel.RemoteKeySet is null) + throw new InvalidOperationException( + "Channel must have a RemoteKeySet to create a commitment transaction model"); + + // Guarantee we have a CommitmentNumber + if (channel.CommitmentNumber is null) + throw new InvalidOperationException( + "Channel must have a CommitmentNumber to create a commitment transaction model"); + + // Guarantee we have a FundingOutput + if (channel.FundingOutput is null) + throw new InvalidOperationException( + "Channel must have a FundingOutput to create a commitment transaction model"); + // Create base output information ToLocalOutputInfo? toLocalOutput = null; ToRemoteOutputInfo? toRemoteOutput = null; @@ -56,8 +71,7 @@ public CommitmentTransactionModel CreateCommitmentTransactionModel(ChannelModel channel.LocalKeySet.CurrentPerCommitmentIndex), CommitmentSide.Remote => _commitmentKeyDerivationService.DeriveRemoteCommitmentKeys( - channel.LocalKeySet.KeyIndex, localBasepoints, remoteBasepoints, - channel.RemoteKeySet.CurrentPerCommitmentCompactPoint, channel.RemoteKeySet.CurrentPerCommitmentIndex), + localBasepoints, remoteBasepoints, channel.RemoteKeySet.CurrentPerCommitmentCompactPoint), _ => throw new ArgumentOutOfRangeException(nameof(side), side, "You should use either Local or Remote commitment side.") diff --git a/src/NLightning.Domain/Bitcoin/Transactions/Factories/FundingTransactionModelFactory.cs b/src/NLightning.Domain/Bitcoin/Transactions/Factories/FundingTransactionModelFactory.cs index f44ef25d..fb1a7e19 100644 --- a/src/NLightning.Domain/Bitcoin/Transactions/Factories/FundingTransactionModelFactory.cs +++ b/src/NLightning.Domain/Bitcoin/Transactions/Factories/FundingTransactionModelFactory.cs @@ -66,7 +66,7 @@ public FundingTransactionModel Create(ChannelModel channel, List utxo weight += WeightConstants.P2WpkhOutputWeight; fee = LightningMoney.MilliSatoshis(weight * channel.ChannelConfig.FeeRateAmountPerKw.Satoshi); - // Recalculate remaining amount with updated fee + // Recalculate the remaining amount with updated fee fundingTransactionModel.ChangeAmount = totalInputAmount - fundingAmount - fee; fundingTransactionModel.ChangeAddress = changeAddress ?? throw new ArgumentNullException( diff --git a/src/NLightning.Domain/Node/Events/AttentionMessageEventArgs.cs b/src/NLightning.Domain/Node/Events/AttentionMessageEventArgs.cs new file mode 100644 index 00000000..51b1952f --- /dev/null +++ b/src/NLightning.Domain/Node/Events/AttentionMessageEventArgs.cs @@ -0,0 +1,18 @@ +namespace NLightning.Domain.Node.Events; + +using Channels.ValueObjects; +using Crypto.ValueObjects; + +public class AttentionMessageEventArgs : EventArgs +{ + public string Message { get; } + public CompactPubKey PeerPubKey { get; } + public ChannelId? ChannelId { get; } + + public AttentionMessageEventArgs(string message, CompactPubKey peerPubKey, ChannelId? channelId = null) + { + Message = message; + PeerPubKey = peerPubKey; + ChannelId = channelId; + } +} \ No newline at end of file diff --git a/src/NLightning.Domain/Protocol/Interfaces/ICommitmentKeyDerivationService.cs b/src/NLightning.Domain/Protocol/Interfaces/ICommitmentKeyDerivationService.cs index 556e31f7..32302120 100644 --- a/src/NLightning.Domain/Protocol/Interfaces/ICommitmentKeyDerivationService.cs +++ b/src/NLightning.Domain/Protocol/Interfaces/ICommitmentKeyDerivationService.cs @@ -20,13 +20,10 @@ CommitmentKeys DeriveLocalCommitmentKeys(uint localChannelKeyIndex, ChannelBasep /// Derives the remote commitment keys based on the provided parameters, including local and remote basepoints, /// the remote per-commitment point, and the commitment number. /// - /// An index representing the local channel key for deriving remote commitment keys. /// The set of cryptographic basepoints associated with the local channel. /// The set of cryptographic basepoints associated with the remote channel. /// The per-commitment point provided by the remote party, used for key derivation. - /// A numeric identifier representing the specific commitment. /// A instance containing the derived keys for the remote commitment. - CommitmentKeys DeriveRemoteCommitmentKeys(uint localChannelKeyIndex, ChannelBasepoints localBasepoints, - ChannelBasepoints remoteBasepoints, - CompactPubKey remotePerCommitmentPoint, ulong commitmentNumber); + CommitmentKeys DeriveRemoteCommitmentKeys(ChannelBasepoints localBasepoints, ChannelBasepoints remoteBasepoints, + CompactPubKey remotePerCommitmentPoint); } \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Bitcoin/Builders/FundingTransactionBuilder.cs b/src/NLightning.Infrastructure.Bitcoin/Builders/FundingTransactionBuilder.cs index 9059dbf5..e9f58780 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Builders/FundingTransactionBuilder.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Builders/FundingTransactionBuilder.cs @@ -57,8 +57,8 @@ public SignedTransaction Build(FundingTransactionModel transaction) if (transaction.ChangeAddress is not null) { var changeAmount = totalInputAmount - transaction.Fee - fundingOutput.Amount; - tx.Outputs.Add(new TxOut(new Money(changeAmount.Satoshi), - _network.CreateBitcoinAddress(transaction.ChangeAddress.Address))); + var bitcoinAddress = BitcoinAddress.Create(transaction.ChangeAddress.Address, _network); + tx.Outputs.Add(new TxOut(new Money(changeAmount.Satoshi), bitcoinAddress)); } // Update the funding output info with transaction details diff --git a/src/NLightning.Infrastructure.Bitcoin/Services/CommitmentKeyDerivationService.cs b/src/NLightning.Infrastructure.Bitcoin/Services/CommitmentKeyDerivationService.cs index 56d0db03..03edb2dd 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Services/CommitmentKeyDerivationService.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Services/CommitmentKeyDerivationService.cs @@ -58,9 +58,9 @@ public CommitmentKeys DeriveLocalCommitmentKeys(uint localChannelKeyIndex, Chann } /// - public CommitmentKeys DeriveRemoteCommitmentKeys(uint localChannelKeyIndex, ChannelBasepoints localBasepoints, + public CommitmentKeys DeriveRemoteCommitmentKeys(ChannelBasepoints localBasepoints, ChannelBasepoints remoteBasepoints, - CompactPubKey remotePerCommitmentPoint, ulong commitmentNumber) + CompactPubKey remotePerCommitmentPoint) { // For their commitment transaction, we use their provided per-commitment point // they should provide this via commitment_signed or update messages diff --git a/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs b/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs index c7ebb161..8bf8b755 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs @@ -133,7 +133,7 @@ public CompactPubKey GetPerCommitmentPoint(uint channelKeyIndex, ulong commitmen // Derive the per-commitment seed from the channel key var channelExtKey = _secureKeyManager.GetChannelKeyAtIndex(channelKeyIndex); var channelKey = ExtKey.CreateFromBytes(channelExtKey); - using var perCommitmentSeed = channelKey.Derive(5).PrivateKey; + using var perCommitmentSeed = channelKey.Derive(PerCommitmentSeedDerivationIndex, true).PrivateKey; var perCommitmentSecret = _keyDerivationService.GeneratePerCommitmentSecret(perCommitmentSeed.ToBytes(), commitmentNumber); @@ -169,7 +169,7 @@ public Secret ReleasePerCommitmentSecret(uint channelKeyIndex, ulong commitmentN // Derive the per-commitment seed from the channel key var channelExtKey = _secureKeyManager.GetChannelKeyAtIndex(channelKeyIndex); var channelKey = ExtKey.CreateFromBytes(channelExtKey); - using var perCommitmentSeed = channelKey.Derive(5).PrivateKey; + using var perCommitmentSeed = channelKey.Derive(PerCommitmentSeedDerivationIndex, true).PrivateKey; return _keyDerivationService.GeneratePerCommitmentSecret( perCommitmentSeed.ToBytes(), commitmentNumber); diff --git a/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs b/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs index f49a6611..412a7c04 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs @@ -43,6 +43,7 @@ public class BlockchainMonitorService : IBlockchainMonitor public event EventHandler? OnNewBlockDetected; public event EventHandler? OnTransactionConfirmed; + public event EventHandler? OnWalletMovementDetected; public uint LastProcessedBlockHeight => _lastProcessedBlockHeight; @@ -522,6 +523,12 @@ private void CheckBlockForWalletMovement(List transactions, uint bl if (!_watchedAddresses.TryRemove(destinationAddress.ToString(), out _)) _logger.LogError("Unable to remove watched address {DestinationAddress} from the list", destinationAddress); + + OnWalletMovementDetected + ?.Invoke(this, new WalletMovementEventArgs(destinationAddress.ToString(), + LightningMoney.Satoshis(output.Value.Satoshi), + txId.ToBytes(), + blockHeight)); } // Check each input for spent utxos diff --git a/src/NLightning.Infrastructure.Repositories/Memory/UtxoMemoryRepository.cs b/src/NLightning.Infrastructure.Repositories/Memory/UtxoMemoryRepository.cs index 8ee4db04..ea033993 100644 --- a/src/NLightning.Infrastructure.Repositories/Memory/UtxoMemoryRepository.cs +++ b/src/NLightning.Infrastructure.Repositories/Memory/UtxoMemoryRepository.cs @@ -85,12 +85,12 @@ public List LockUtxosToSpendOnChannel(LightningMoney requestFundingAm public List GetLockedUtxosForChannel(ChannelId channelId) { - return _utxoSet.Values.Where(x => x.LockedToChannelId.Equals(channelId)).ToList(); + return _utxoSet.Values.Where(x => x.LockedToChannelId.HasValue && x.LockedToChannelId.Value.Equals(channelId)).ToList(); } public List ReturnUtxosNotSpentOnChannel(ChannelId channelId) { - var utxos = _utxoSet.Values.Where(x => x.LockedToChannelId.Equals(channelId)).ToList(); + var utxos = _utxoSet.Values.Where(x => x.LockedToChannelId.HasValue && x.LockedToChannelId.Value.Equals(channelId)).ToList(); foreach (var utxo in utxos) { utxo.LockedToChannelId = null; @@ -102,7 +102,7 @@ public List ReturnUtxosNotSpentOnChannel(ChannelId channelId) public void ConfirmSpendOnChannel(ChannelId channelId) { - var utxos = _utxoSet.Values.Where(x => x.LockedToChannelId.Equals(channelId)); + var utxos = _utxoSet.Values.Where(x => x.LockedToChannelId.HasValue && x.LockedToChannelId.Value.Equals(channelId)); foreach (var utxo in utxos) _utxoSet.TryRemove((utxo.TxId, utxo.Index), out _); } diff --git a/src/NLightning.Infrastructure.Serialization/Node/FeatureSetSerializer.cs b/src/NLightning.Infrastructure.Serialization/Node/FeatureSetSerializer.cs index c4d4f6d1..d3f1e95c 100644 --- a/src/NLightning.Infrastructure.Serialization/Node/FeatureSetSerializer.cs +++ b/src/NLightning.Infrastructure.Serialization/Node/FeatureSetSerializer.cs @@ -25,13 +25,8 @@ public class FeatureSetSerializer : IFeatureSetSerializer public async Task SerializeAsync(FeatureSet featureSet, Stream stream, bool asGlobal = false, bool includeLength = true) { - // If it's a global feature, cut out any bit greater than 13 - if (asGlobal) - featureSet.FeatureFlags.Length = 13; - - // Convert BitArray to byte array - var bytes = new byte[(featureSet.FeatureFlags.Length + 7) / 8]; - featureSet.FeatureFlags.CopyTo(bytes, 0); + // Convert BitArray to a byte array + var bytes = featureSet.GetBytes(asGlobal) ?? throw new SerializationException("Feature set is empty"); // Set bytes as big endian if (BitConverter.IsLittleEndian) diff --git a/src/NLightning.Infrastructure/Transport/Services/TransportService.cs b/src/NLightning.Infrastructure/Transport/Services/TransportService.cs index f8a81de1..4e72558a 100644 --- a/src/NLightning.Infrastructure/Transport/Services/TransportService.cs +++ b/src/NLightning.Infrastructure/Transport/Services/TransportService.cs @@ -200,7 +200,7 @@ public async Task WriteMessageAsync(IMessage message, CancellationToken cancella using var messageStream = new MemoryStream(); await _messageSerializer.SerializeAsync(message, messageStream); - // Encrypt message + // Encrypt the message var buffer = ArrayPool.Shared.Rent(ProtocolConstants.MaxMessageLength); var size = _transport.WriteMessage(messageStream.ToArray(), buffer.AsSpan()[..ProtocolConstants.MaxMessageLength]); diff --git a/test/BlazorTests/NLightning.Blazor.Tests/NLightning.Blazor.Tests.csproj b/test/BlazorTests/NLightning.Blazor.Tests/NLightning.Blazor.Tests.csproj index b1bb6c28..b079455d 100644 --- a/test/BlazorTests/NLightning.Blazor.Tests/NLightning.Blazor.Tests.csproj +++ b/test/BlazorTests/NLightning.Blazor.Tests/NLightning.Blazor.Tests.csproj @@ -23,7 +23,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/NLightning.Integration.Tests/Channels/ChannelOpeningFlowTests.cs b/test/NLightning.Integration.Tests/Channels/ChannelOpeningFlowTests.cs new file mode 100644 index 00000000..e7caefa0 --- /dev/null +++ b/test/NLightning.Integration.Tests/Channels/ChannelOpeningFlowTests.cs @@ -0,0 +1,324 @@ +using System.Net; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq.Protected; +using NBitcoin; +using NLightning.Daemon.Handlers; +using NLightning.Daemon.Interfaces; +using NLightning.Domain.Bitcoin.Events; +using NLightning.Domain.Bitcoin.Interfaces; +using NLightning.Domain.Bitcoin.Transactions.Factories; +using NLightning.Domain.Bitcoin.Transactions.Interfaces; +using NLightning.Domain.Channels.Factories; +using NLightning.Domain.Channels.Interfaces; +using NLightning.Domain.Channels.Validators; +using NLightning.Domain.Client.Requests; +using NLightning.Domain.Client.Responses; +using NLightning.Domain.Crypto.Hashes; +using NLightning.Domain.Enums; +using NLightning.Domain.Money; +using NLightning.Domain.Node.Interfaces; +using NLightning.Domain.Node.Options; +using NLightning.Domain.Node.ValueObjects; +using NLightning.Domain.Protocol.Constants; +using NLightning.Domain.Protocol.Interfaces; +using NLightning.Domain.Protocol.ValueObjects; +using NLightning.Infrastructure.Bitcoin.Builders; +using NLightning.Infrastructure.Bitcoin.Options; +using NLightning.Infrastructure.Bitcoin.Services; +using NLightning.Infrastructure.Bitcoin.Signers; +using NLightning.Infrastructure.Persistence.Contexts; +using NLightning.Integration.Tests.Docker.Mock; +using NLightning.Integration.Tests.Docker.Utils; +using NLightning.Tests.Utils; +using ServiceStack; +using Xunit.Abstractions; + +namespace NLightning.Integration.Tests.Channels; + +using Application; +using Fixtures; +using Infrastructure; +using Infrastructure.Bitcoin; +using Infrastructure.Bitcoin.Wallet.Interfaces; +using Infrastructure.Persistence; +using Infrastructure.Repositories; +using Infrastructure.Serialization; +using TestCollections; + +[Collection(LightningRegtestNetworkFixtureCollection.Name)] +public class ChannelOpeningFlowTests : IDisposable +{ + private readonly LightningRegtestNetworkFixture _lightningRegtestNetworkFixture; + private readonly IPeerManager _peerManager; + private readonly IChannelMemoryRepository _channelMemoryRepository; + private readonly IBlockchainMonitor _blockchainMonitor; + private readonly int _port; + private readonly string _databaseFilePath = $"nlightning_channel_test_{Guid.NewGuid()}.db"; + private readonly IServiceProvider _serviceProvider; + private readonly ITestOutputHelper _output; + + public ChannelOpeningFlowTests(LightningRegtestNetworkFixture fixture, ITestOutputHelper output) + { + _lightningRegtestNetworkFixture = fixture; + _output = output; + Console.SetOut(new TestOutputWriter(output)); + + _port = PortPoolUtil.GetAvailablePortAsync().GetAwaiter().GetResult(); + Assert.True(_port > 0); + ISecureKeyManager secureKeyManager = new FakeSecureKeyManager(); + + // Get Bitcoin network info + Assert.NotNull(_lightningRegtestNetworkFixture.Builder); + var bitcoinConfiguration = _lightningRegtestNetworkFixture.Builder.Configuration.BTCNodes[0]; + var zmqRawBlockPort = + bitcoinConfiguration.Cmd.First(c => c.Contains("-zmqpubrawblock")).Split(':')[2]; + var zmqRawTxPort = + bitcoinConfiguration.Cmd.First(c => c.Contains("-zmqpubrawtx")).Split(':')[2]; + var bitcoin = _lightningRegtestNetworkFixture.Builder.BitcoinRpcClient; + Assert.NotNull(bitcoin); + var bitcoinEndpoint = bitcoin.Address.ToString(); + + // Mock HttpClient for FeeService + var httpMessageHandlerMock = new Mock(MockBehavior.Strict); + httpMessageHandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"fastestFee\": 2}") + }); + + // Build configuration + List> inMemoryConfiguration = + [ + new("Serilog:MinimumLevel:NLightning", "Verbose"), + new("Node:Network", "regtest"), + new("Node:Daemon", "false"), + new("Database:Provider", "Sqlite"), + new("Database:ConnectionString", $"Data Source={_databaseFilePath}"), + new("Bitcoin:RpcEndpoint", bitcoinEndpoint), + new("Bitcoin:RpcUser", bitcoin.CredentialString.UserPassword.UserName), + new("Bitcoin:RpcPassword", bitcoin.CredentialString.UserPassword.Password), + new("Bitcoin:ZmqHost", bitcoin.Address.Host), + new("Bitcoin:ZmqBlockPort", zmqRawBlockPort), + new("Bitcoin:ZmqTxPort", zmqRawTxPort) + ]; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(inMemoryConfiguration).Build(); + + // Create a service collection + var services = new ServiceCollection(); + services.AddSingleton(configuration); + services.AddHttpClient(client => + { + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + }); + services.AddSingleton(secureKeyManager); + services.AddSingleton(sp => + { + var nodeOptions = sp.GetRequiredService>().Value; + return new ChannelOpenValidator(nodeOptions); + }); + services.AddSingleton(sp => + { + var channelIdFactory = sp.GetRequiredService(); + var channelOpenValidator = sp.GetRequiredService(); + var feeService = sp.GetRequiredService(); + var lightningSigner = sp.GetRequiredService(); + var nodeOptions = sp.GetRequiredService>().Value; + var sha256 = sp.GetRequiredService(); + return new ChannelFactory(channelIdFactory, channelOpenValidator, feeService, lightningSigner, nodeOptions, + sha256); + }); + services.AddSingleton(); + services.AddSingleton(serviceProvider => + { + var fundingOutputBuilder = serviceProvider.GetRequiredService(); + var keyDerivationService = serviceProvider.GetRequiredService(); + var logger = serviceProvider.GetRequiredService>(); + var nodeOptions = serviceProvider.GetRequiredService>().Value; + var utxoMemoryRepository = serviceProvider.GetRequiredService(); + + return new LocalLightningSigner(fundingOutputBuilder, keyDerivationService, logger, nodeOptions, + secureKeyManager, utxoMemoryRepository); + }); + services.AddApplicationServices(); + services.AddInfrastructureServices(); + services.AddPersistenceInfrastructureServices(configuration); + services.AddRepositoriesInfrastructureServices(); + services.AddSerializationInfrastructureServices(); + services.AddBitcoinInfrastructure(); + services + .AddScoped, + OpenChannelClientHandler>(); + services.AddSingleton(); + services.AddOptions().BindConfiguration("Bitcoin").ValidateOnStart(); + services.AddOptions().BindConfiguration("FeeEstimation").ValidateOnStart(); + services.AddOptions() + .BindConfiguration("Node") + .PostConfigure(options => + { + options.Features = new FeatureOptions + { + ChainHashes = [ChainConstants.Regtest] + }; + options.ListenAddresses = [$"{IPAddress.Loopback}:{_port}"]; + options.BitcoinNetwork = BitcoinNetwork.Regtest; + options.Features.ChainHashes = [options.BitcoinNetwork.ChainHash]; + }) + .ValidateOnStart(); + + // Set up factories + _serviceProvider = services.BuildServiceProvider(); + + // Set up the database migration + var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var pendingMigrations = context.Database.GetPendingMigrationsAsync().GetAwaiter().GetResult().ToList(); + if (pendingMigrations.Count > 0) + context.Database.Migrate(); + + // Get services + _peerManager = _serviceProvider.GetRequiredService(); + _channelMemoryRepository = _serviceProvider.GetRequiredService(); + _blockchainMonitor = _serviceProvider.GetRequiredService(); + } + + [Fact] + public async Task OpenChannel_WaitForConfirmations_ChannelBecomesOpen() + { + // Arrange + var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; + var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes + .First(x => x.LocalAlias == "alice"); + Assert.NotNull(alice); + + await _peerManager.StartAsync(CancellationToken.None); + + // Get the current block height + var currentHeight = (uint)await bitcoin.GetBlockCountAsync(); + + // Start the blockchain monitor at the current height + await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); + + // Wait a bit for initialization + // await Task.Delay(1000); + + // Fund our wallet + using (var scope = _serviceProvider.CreateScope()) + { + var walletService = scope.ServiceProvider + .GetRequiredService(); + var address = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, false); + + // Subscribe to blockchain monitor events + TaskCompletionSource tsc = new(); + uint txFirstSeenInBlock = int.MaxValue; + + void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) + { + if (e.Amount == LightningMoney.FromUnit(1, LightningMoneyUnit.Btc) + && e.WalletAddress == address.Address) + { + txFirstSeenInBlock = e.BlockHeight; + } + else + { + Assert.Fail("Unexpected wallet movement detected: " + + $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); + } + } + + void OnNewBlockDetected(object? _, NewBlockEventArgs e) + { + if (e.Height >= txFirstSeenInBlock + 5) + { + tsc.TrySetResult(true); + } + } + + _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; + + // Send funds to our wallet + await bitcoin.SendToAddressAsync( + BitcoinAddress.Create(address.Address, NBitcoin.Network.RegTest), + new Money(1, MoneyUnit.BTC)); + + // Mine blocks to confirm + await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); + + // wait for funding transaction to be confirmed + Assert.True(await tsc.Task); + _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; + } + + // Verify we have balance + var utxoRepository = _serviceProvider.GetRequiredService(); + var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); + Assert.True(balance > LightningMoney.Zero); + + // Connect to Alice + var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host + .SplitOnFirst("//")[1] + .SplitOnFirst(":")[0])).First(), 9735); + var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; + + await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); + + Task openChannelTask; + // Open channel - using the client handler + using (var scope = _serviceProvider.CreateScope()) + { + var clientHandler = scope.ServiceProvider.GetService( + typeof(IClientCommandHandler)) as + OpenChannelClientHandler ?? + throw new InvalidOperationException( + $"Unable to get service {nameof(OpenChannelClientHandler)}"); + var request = new OpenChannelClientRequest( + aliceAddress, + LightningMoney.Satoshis(1000000) // 0.01 BTC + ); + + // Act - Open the channel (this should send open_channel and wait for the flow to complete) + openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); + } + + // Mine blocks to confirm + await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); + + var channelResponse = await openChannelTask; + Assert.NotNull(channelResponse); + + // Check if the channel exists (temporary or permanent) + var allChannels = _channelMemoryRepository.FindChannels(_ => true); + + // TODO: Complete the test by mining blocks and verifying state transitions + // For now, verify the flow started + Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); + } + + public void Dispose() + { + _blockchainMonitor.StopAsync().GetAwaiter().GetResult(); + PortPoolUtil.ReleasePort(_port); + if (File.Exists(_databaseFilePath)) + { + try + { + File.Delete(_databaseFilePath); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to delete database file: {ex.Message}"); + } + } + } +} \ No newline at end of file From 4837f3aa84cf2737f0312d862a4c2cfe7c79842e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADckolas=20Goline?= Date: Sat, 14 Mar 2026 00:57:06 -0300 Subject: [PATCH 11/20] update features to reflect latest [changes](https://github.com/lightning/bolts/pull/1310); refactor all usages of changed features; improve OpenChannelClientHandler; refine channel opening validation; --- .../Handlers/OpenChannel1MessageHandler.cs | 22 +- .../Protocol/Factories/MessageFactory.cs | 10 +- .../Handlers/OpenChannelClientHandler.cs | 106 +++++++--- .../Channels/Factories/ChannelFactory.cs | 30 +-- .../Validators/ChannelOpenValidator.cs | 66 +++--- src/NLightning.Domain/Enums/Feature.cs | 54 +++-- src/NLightning.Domain/Node/FeatureSet.cs | 51 +++-- .../Node/Options/FeatureOptions.cs | 199 +++++++++--------- .../Protocol/Interfaces/IMessageFactory.cs | 6 +- .../Messages/AcceptChannel1Message.cs | 14 +- .../Protocol/Messages/OpenChannel1Message.cs | 13 +- .../AcceptChannel1MessageTypeSerializer.cs | 28 ++- .../OpenChannel1MessageTypeSerializer.cs | 28 ++- .../OpenChannel1MessageHandlerTests.cs | 7 +- .../Node/FeatureSetTests.cs | 24 ++- .../Node/FeatureSetSerializerTests.cs | 28 ++- .../Docker/AbcNetworkTests.cs | 12 +- 17 files changed, 403 insertions(+), 295 deletions(-) diff --git a/src/NLightning.Application/Channels/Handlers/OpenChannel1MessageHandler.cs b/src/NLightning.Application/Channels/Handlers/OpenChannel1MessageHandler.cs index 70074c57..966da029 100644 --- a/src/NLightning.Application/Channels/Handlers/OpenChannel1MessageHandler.cs +++ b/src/NLightning.Application/Channels/Handlers/OpenChannel1MessageHandler.cs @@ -5,7 +5,9 @@ namespace NLightning.Application.Channels.Handlers; using Domain.Channels.Enums; using Domain.Channels.Interfaces; using Domain.Crypto.ValueObjects; +using Domain.Enums; using Domain.Exceptions; +using Domain.Node; using Domain.Node.Options; using Domain.Protocol.Interfaces; using Domain.Protocol.Messages; @@ -62,12 +64,28 @@ public OpenChannel1MessageHandler(IChannelFactory channelFactory, IChannelMemory UpfrontShutdownScriptTlv? upfrontShutdownScriptTlv = null; if (channel.LocalUpfrontShutdownScript is not null) upfrontShutdownScriptTlv = new UpfrontShutdownScriptTlv(channel.LocalUpfrontShutdownScript.Value); + else + upfrontShutdownScriptTlv = new UpfrontShutdownScriptTlv(Array.Empty()); - // TODO: Create the ChannelTypeTlv + var channelTypeFeatureSet = FeatureSet.NewBasicChannelType(); + if (negotiatedFeatures.OptionAnchors >= FeatureSupport.Optional) + channelTypeFeatureSet.SetFeature(Feature.OptionAnchors, + negotiatedFeatures.OptionAnchors == FeatureSupport.Compulsory); + + if (channel.ChannelConfig.UseScidAlias >= FeatureSupport.Optional) + channelTypeFeatureSet.SetFeature(Feature.OptionScidAlias, + channel.ChannelConfig.UseScidAlias == FeatureSupport.Compulsory); + + if (channel.ChannelConfig.MinimumDepth == 0) + channelTypeFeatureSet.SetFeature(Feature.OptionZeroconf, true); + + var featureSetBytes = channelTypeFeatureSet.GetBytes() ?? throw new ChannelErrorException("The channel type is not supported", payload.ChannelId, + "Sorry, we had an internal error"); + var channelTypeTlv = new ChannelTypeTlv(featureSetBytes); // Create the reply message var acceptChannel1ReplyMessage = _messageFactory - .CreateAcceptChannel1Message(channel.ChannelConfig.ChannelReserveAmount!, null, + .CreateAcceptChannel1Message(channel.ChannelConfig.ChannelReserveAmount, channelTypeTlv, channel.LocalKeySet.DelayedPaymentCompactBasepoint, channel.LocalKeySet.CurrentPerCommitmentCompactPoint, channel.LocalKeySet.FundingCompactPubKey, diff --git a/src/NLightning.Application/Protocol/Factories/MessageFactory.cs b/src/NLightning.Application/Protocol/Factories/MessageFactory.cs index da49c13c..eb2c61f8 100644 --- a/src/NLightning.Application/Protocol/Factories/MessageFactory.cs +++ b/src/NLightning.Application/Protocol/Factories/MessageFactory.cs @@ -442,8 +442,8 @@ public OpenChannel1Message CreateOpenChannel1Message(ChannelId temporaryChannelI CompactPubKey htlcBasepoint, CompactPubKey firstPerCommitmentPoint, ChannelFlags channelFlags, - UpfrontShutdownScriptTlv? upfrontShutdownScriptTlv, - ChannelTypeTlv? channelTypeTlv) + ChannelTypeTlv channelTypeTlv, + UpfrontShutdownScriptTlv? upfrontShutdownScriptTlv) { var maxHtlcValueInFlight = LightningMoney.Satoshis(_nodeOptions.AllowUpToPercentageOfChannelFundsInFlight * fundingAmount.Satoshi / @@ -456,7 +456,7 @@ public OpenChannel1Message CreateOpenChannel1Message(ChannelId temporaryChannelI paymentBasepoint, pushAmount, revocationBasepoint, _nodeOptions.ToSelfDelay); - return new OpenChannel1Message(payload, upfrontShutdownScriptTlv, channelTypeTlv); + return new OpenChannel1Message(payload, channelTypeTlv, upfrontShutdownScriptTlv); } /// @@ -545,7 +545,7 @@ channelType is null /// /// public AcceptChannel1Message CreateAcceptChannel1Message(LightningMoney channelReserveAmount, - ChannelTypeTlv? channelTypeTlv, + ChannelTypeTlv channelTypeTlv, CompactPubKey delayedPaymentBasepoint, CompactPubKey firstPerCommitmentPoint, CompactPubKey fundingPubKey, CompactPubKey htlcBasepoint, @@ -562,7 +562,7 @@ public AcceptChannel1Message CreateAcceptChannel1Message(LightningMoney channelR maxHtlcValueInFlight, minimumDepth, paymentBasepoint, revocationBasepoint, toSelfDelay); - return new AcceptChannel1Message(payload, upfrontShutdownScriptTlv, channelTypeTlv); + return new AcceptChannel1Message(payload, channelTypeTlv, upfrontShutdownScriptTlv); } /// diff --git a/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs b/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs index 6cb9a13e..ac15d793 100644 --- a/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs +++ b/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs @@ -12,6 +12,7 @@ namespace NLightning.Daemon.Handlers; using Domain.Client.Responses; using Domain.Crypto.ValueObjects; using Domain.Enums; +using Domain.Exceptions; using Domain.Node; using Domain.Node.Events; using Domain.Node.Interfaces; @@ -63,8 +64,9 @@ public async Task HandleAsync(OpenChannelClientReques var isPeerAddressInfo = request.NodeInfo.Contains('@') && request.NodeInfo.Contains(':'); CompactPubKey peerId; - if (isPeerAddressInfo) - peerId = new PeerAddress(request.NodeInfo).PubKey; + peerId = isPeerAddressInfo + ? new PeerAddress(request.NodeInfo).PubKey + : new CompactPubKey(Convert.FromHexString(request.NodeInfo)); // Parse as a hex public key // Check if we're connected to the peer var peer = _peerManager.GetPeer(peerId) @@ -84,53 +86,48 @@ public async Task HandleAsync(OpenChannelClientReques try { - // TODO: Set the channel reserve as 1% of the channel or at least 354 sats + // Select UTXOs and mark them as toSpend for this channel + _ = _utxoMemoryRepository.LockUtxosToSpendOnChannel(request.FundingAmount, channel.ChannelId); + // Add the channel to dictionaries _channelMemoryRepository.AddTemporaryChannel(peerId, channel); - // Select UTXOs and mark them as toSpend for this channel - var utxos = _utxoMemoryRepository.LockUtxosToSpendOnChannel(request.FundingAmount, channel.ChannelId); + // Create the channel type Tlv + var channelTypeFeatureSet = FeatureSet.NewBasicChannelType(); + if (peer.NegotiatedFeatures.OptionAnchors >= FeatureSupport.Optional) + channelTypeFeatureSet.SetFeature(Feature.OptionAnchors, true); - // Create a FeatureSet for the ChannelTypeTlv - var featureSet = new FeatureSet(); - featureSet.SetFeature(Feature.VarOnionOptin, false, false); + if (channel.ChannelConfig.UseScidAlias >= FeatureSupport.Optional) + channelTypeFeatureSet.SetFeature(Feature.OptionScidAlias, true); - // Set StaticRemoteKey if needed - if (peer.NegotiatedFeatures.StaticRemoteKey == FeatureSupport.Compulsory) - featureSet.SetFeature(Feature.OptionStaticRemoteKey, true); + if (channel.ChannelConfig.MinimumDepth == 0) + channelTypeFeatureSet.SetFeature(Feature.OptionZeroconf, true); - // Set OptionAnchorOutputs if needed - if (peer.NegotiatedFeatures.AnchorOutputs == FeatureSupport.Compulsory) - featureSet.SetFeature(Feature.OptionAnchorOutputs, true); + var featureSetBytes = channelTypeFeatureSet.GetBytes() ?? throw new ClientException(ErrorCodes.InvalidOperation, + $"Error creating {nameof(ChannelTypeTlv)}. This should never happen."); + var channelTypeTlv = new ChannelTypeTlv(featureSetBytes); // Create UpfrontShutdownScriptTlv if needed UpfrontShutdownScriptTlv? upfrontShutdownScriptTlv = null; if (channel.LocalUpfrontShutdownScript is not null) upfrontShutdownScriptTlv = new UpfrontShutdownScriptTlv(channel.LocalUpfrontShutdownScript.Value); + else + upfrontShutdownScriptTlv = new UpfrontShutdownScriptTlv(Array.Empty()); // Create the ChannelFlags var channelFlags = new ChannelFlags(ChannelFlag.None); - if (peer.Features.IsFeatureSet(Feature.OptionScidAlias, true)) - { - featureSet.SetFeature(Feature.OptionScidAlias, true); + if (peer.NegotiatedFeatures.ScidAlias == FeatureSupport.Compulsory) channelFlags = new ChannelFlags(ChannelFlag.AnnounceChannel); - } - - // Create the ChannelTypeTlv - ChannelTypeTlv? channelTypeTlv = null; - var featureSetBytes = featureSet.GetBytes(); - if (featureSetBytes is not null) - channelTypeTlv = new ChannelTypeTlv(featureSetBytes); // Create the openChannel message var openChannel1Message = _messageFactory.CreateOpenChannel1Message( channel.ChannelId, channel.LocalBalance, channel.LocalKeySet.FundingCompactPubKey, - channel.RemoteBalance, channel.ChannelConfig.ChannelReserveAmount!, + channel.RemoteBalance, channel.ChannelConfig.ChannelReserveAmount, channel.ChannelConfig.FeeRateAmountPerKw, channel.ChannelConfig.MaxAcceptedHtlcs, channel.LocalKeySet.RevocationCompactBasepoint, channel.LocalKeySet.PaymentCompactBasepoint, channel.LocalKeySet.DelayedPaymentCompactBasepoint, channel.LocalKeySet.HtlcCompactBasepoint, channel.LocalKeySet.CurrentPerCommitmentCompactPoint, - channelFlags, upfrontShutdownScriptTlv, channelTypeTlv); + channelFlags, channelTypeTlv, upfrontShutdownScriptTlv); if (!peer.TryGetPeerService(out var peerService)) throw new ClientException(ErrorCodes.InvalidOperation, "Error getting peerService from peer"); @@ -138,6 +135,9 @@ public async Task HandleAsync(OpenChannelClientReques var tsc = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); peerService.OnChannelMessageReceived += ChannelMessageHandlerEnvelope; + peerService.OnAttentionMessageReceived += AttentionMessageHandlerEnvelope; + peerService.OnDisconnect += PeerDisconnectionEnvelope; + peerService.OnExceptionRaised += ExceptionRaisedEnvelope; try { @@ -147,28 +147,43 @@ public async Task HandleAsync(OpenChannelClientReques { //Unsubscribe from the event so we don't have dangling memory peerService.OnChannelMessageReceived -= ChannelMessageHandlerEnvelope; + peerService.OnAttentionMessageReceived -= AttentionMessageHandlerEnvelope; + peerService.OnDisconnect -= PeerDisconnectionEnvelope; + peerService.OnExceptionRaised -= ExceptionRaisedEnvelope; throw; } - // Since everything went ok so far, let's update the locked utxos on the database - foreach (var utxo in utxos) - _unitOfWork.UtxoDbRepository.Update(utxo); - - await _unitOfWork.SaveChangesAsync(); - var response = await tsc.Task; // Unsubscribe from the event peerService.OnChannelMessageReceived -= ChannelMessageHandlerEnvelope; + peerService.OnAttentionMessageReceived -= AttentionMessageHandlerEnvelope; + peerService.OnDisconnect -= PeerDisconnectionEnvelope; + peerService.OnExceptionRaised -= ExceptionRaisedEnvelope; return response; - // + // Envelopes for the events void ChannelMessageHandlerEnvelope(object? _, ChannelMessageEventArgs args) { HandleChannelMessage(args, channel.ChannelId, tsc); } + + void AttentionMessageHandlerEnvelope(object? _, AttentionMessageEventArgs args) + { + HandleAttentionMessage(args, channel.ChannelId, tsc); + } + + void PeerDisconnectionEnvelope(object? _, PeerDisconnectedEventArgs args) + { + HandlePeerDisconnection(args, channel.ChannelId, tsc); + } + + void ExceptionRaisedEnvelope(object? _, Exception e) + { + HandleExceptionRaised(e, channel.ChannelId, tsc); + } } catch { @@ -184,8 +199,8 @@ void ChannelMessageHandlerEnvelope(object? _, ChannelMessageEventArgs args) } } - private void HandleChannelMessage(ChannelMessageEventArgs args, ChannelId _, - TaskCompletionSource __) + private static void HandleChannelMessage(ChannelMessageEventArgs args, ChannelId _, + TaskCompletionSource __) { if (args.Message.Type == MessageTypes.AcceptChannel) { @@ -200,4 +215,25 @@ private void HandleChannelMessage(ChannelMessageEventArgs args, ChannelId _, Console.WriteLine("Unknown message type: {0}", Enum.GetName(args.Message.Type)); } } + + private static void HandleAttentionMessage(AttentionMessageEventArgs args, ChannelId _, + TaskCompletionSource tsc) + { + Console.Error.WriteLine($"Error opening channel: {args.Message}"); + tsc.TrySetException(new ChannelErrorException($"Error opening channel: {args.Message}")); + } + + private static void HandlePeerDisconnection(PeerDisconnectedEventArgs args, ChannelId _, + TaskCompletionSource tsc) + { + Console.Error.WriteLine("Peer disconnected"); + tsc.TrySetException(new ChannelErrorException("Error opening channel: Peer disconnected")); + } + + private static void HandleExceptionRaised(Exception e, ChannelId _, + TaskCompletionSource tsc) + { + Console.Error.WriteLine(e.ToString()); + tsc.TrySetException(e); + } } \ No newline at end of file diff --git a/src/NLightning.Domain/Channels/Factories/ChannelFactory.cs b/src/NLightning.Domain/Channels/Factories/ChannelFactory.cs index 902bb9d0..8eb86515 100644 --- a/src/NLightning.Domain/Channels/Factories/ChannelFactory.cs +++ b/src/NLightning.Domain/Channels/Factories/ChannelFactory.cs @@ -52,10 +52,6 @@ public async Task CreateChannelV1AsNonInitiatorAsync(OpenChannel1M if (negotiatedFeatures.DualFund == FeatureSupport.Compulsory) throw new ChannelErrorException("We can only accept dual fund channels"); - // Check if the channel type was negotiated and the channel type is present - if (message.ChannelTypeTlv is not null && negotiatedFeatures.ChannelType == FeatureSupport.Compulsory) - throw new ChannelErrorException("Channel type was negotiated but not provided"); - // Perform optional checks for the channel var ourChannelReserveAmount = GetOurChannelReserveFromFundingAmount(payload.FundingAmount); _channelOpenValidator.PerformOptionalChecks( @@ -119,7 +115,7 @@ public async Task CreateChannelV1AsNonInitiatorAsync(OpenChannel1M var channelConfig = new ChannelConfig(payload.ChannelReserveAmount, payload.FeeRatePerKw, payload.HtlcMinimumAmount, _nodeOptions.DustLimitAmount, payload.MaxAcceptedHtlcs, payload.MaxHtlcValueInFlight, minimumDepth, - negotiatedFeatures.AnchorOutputs != FeatureSupport.No, + negotiatedFeatures.OptionAnchors != FeatureSupport.No, payload.DustLimitAmount, payload.ToSelfDelay, useScidAlias, localUpfrontShutdownScript, remoteUpfrontShutdownScript); @@ -160,25 +156,31 @@ public async Task CreateChannelV1AsInitiatorAsync(OpenChannelClien if (request.FeeRatePerKw is not null && request.FeeRatePerKw > ChannelConstants.MaxFeePerKw) throw new ChannelErrorException($"Fee rate per kw is too large: {request.FeeRatePerKw}"); + // Check if our fee is too big + if (request.FeeRatePerKw is not null && request.FeeRatePerKw < ChannelConstants.MinFeePerKw) + throw new ChannelErrorException($"Fee rate per kw is too small: {request.FeeRatePerKw}"); + // Check if the dust limit is greater than the channel reserve amount var channelReserveAmount = GetOurChannelReserveFromFundingAmount(request.FundingAmount); if (request.ChannelReserveAmount is not null && request.ChannelReserveAmount > channelReserveAmount) channelReserveAmount = request.ChannelReserveAmount; + var dustLimitAmount = ChannelConstants.MinDustLimitAmount; if (request.DustLimitAmount is not null) { - if (request.DustLimitAmount > channelReserveAmount) - throw new ChannelErrorException( - $"Dust limit({request.DustLimitAmount}) is greater than channel reserve({channelReserveAmount})"); - // Check if dust_limit_satoshis is too small if (request.DustLimitAmount < ChannelConstants.MinDustLimitAmount) throw new ChannelErrorException($"Dust limit amount is too small: {request.DustLimitAmount}"); + + dustLimitAmount = request.DustLimitAmount; } + if (dustLimitAmount > channelReserveAmount) + channelReserveAmount = dustLimitAmount; + // Check if there are enough funds to pay for fees - var currentFeeRatePerKw = await _feeService.GetFeeRatePerKwAsync(); - var expectedWeight = negotiatedFeatures.AnchorOutputs > FeatureSupport.No + var currentFeeRatePerKw = request.FeeRatePerKw ?? await _feeService.GetFeeRatePerKwAsync(); + var expectedWeight = negotiatedFeatures.OptionAnchors > FeatureSupport.No ? TransactionConstants.InitialCommitmentTransactionWeightNoAnchor : TransactionConstants.InitialCommitmentTransactionWeightWithAnchor; var expectedFee = LightningMoney.Satoshis(expectedWeight * currentFeeRatePerKw.Satoshi / 1000); @@ -188,7 +190,7 @@ public async Task CreateChannelV1AsInitiatorAsync(OpenChannelClien // Check if this is a large channel and if we support it if (request.FundingAmount >= ChannelConstants.LargeChannelAmount && negotiatedFeatures.LargeChannels == FeatureSupport.No) - throw new ChannelErrorException("The peer don't support large channels"); + throw new ChannelErrorException("The peer doesn't support large channels"); // Check if we want zeroconf and if it's negotiated var minimumDepth = _nodeOptions.MinimumDepth; @@ -237,10 +239,10 @@ public async Task CreateChannelV1AsInitiatorAsync(OpenChannelClien // Generate the channel configuration var channelConfig = new ChannelConfig(channelReserveAmount, request.FeeRatePerKw ?? currentFeeRatePerKw, request.HtlcMinimumAmount ?? _nodeOptions.HtlcMinimumAmount, - request.DustLimitAmount ?? _nodeOptions.DustLimitAmount, + dustLimitAmount, request.MaxAcceptedHtlcs ?? _nodeOptions.MaxAcceptedHtlcs, maxHtlcValueInFlight, minimumDepth, - negotiatedFeatures.AnchorOutputs != FeatureSupport.No, + negotiatedFeatures.OptionAnchors != FeatureSupport.No, LightningMoney.Zero, request.ToSelfDelay ?? _nodeOptions.ToSelfDelay, negotiatedFeatures.ScidAlias, localUpfrontShutdownScript); diff --git a/src/NLightning.Domain/Channels/Validators/ChannelOpenValidator.cs b/src/NLightning.Domain/Channels/Validators/ChannelOpenValidator.cs index 8debaa13..d724478f 100644 --- a/src/NLightning.Domain/Channels/Validators/ChannelOpenValidator.cs +++ b/src/NLightning.Domain/Channels/Validators/ChannelOpenValidator.cs @@ -41,8 +41,13 @@ public void PerformOptionalChecks(ChannelOpenOptionalValidationParameters parame $"Max htlc value in flight is too small: {parameters.MaxHtlcValueInFlight}"); } + // If the channel amount is too small, we can have the channelReserve smaller than our dust + var ourChannelReserveAmount = parameters.OurChannelReserveAmount; + if (ourChannelReserveAmount < parameters.DustLimitAmount) + ourChannelReserveAmount = parameters.DustLimitAmount; + // Check if we consider channel_reserve_satoshis too large. IE. 20% bigger than our 1% channel reserve - if (parameters.ChannelReserveAmount > parameters.OurChannelReserveAmount * 1.2M) + if (parameters.ChannelReserveAmount > ourChannelReserveAmount * 1.2M) throw new ChannelErrorException($"Channel reserve amount is too large: {parameters.ChannelReserveAmount}"); // Check if we consider max_accepted_htlcs too small. IE. 20% smaller than our max-accepted htlcs @@ -83,7 +88,7 @@ public void PerformMandatoryChecks(ChannelOpenMandatoryValidationParameters para $"Fee rate per kw is too small: {parameters.FeeRatePerKw}, currentFee{parameters.CurrentFeeRatePerKw}"); } - // Check if the dust limit is greater than the channel reserve amount + // Check if the dust limit is greater than the channel reserve amount if (parameters.DustLimitAmount > parameters.ChannelReserveAmount) throw new ChannelErrorException( $"Dust limit({parameters.DustLimitAmount}) is greater than channel reserve({parameters.ChannelReserveAmount})"); @@ -100,7 +105,7 @@ public void PerformMandatoryChecks(ChannelOpenMandatoryValidationParameters para throw new ChannelErrorException($"Push amount is too large: {parameters.PushAmount}"); // Check if there are enough funds to pay for fees - var expectedWeight = parameters.NegotiatedFeatures.AnchorOutputs > FeatureSupport.No + var expectedWeight = parameters.NegotiatedFeatures.OptionAnchors > FeatureSupport.No ? TransactionConstants.InitialCommitmentTransactionWeightNoAnchor : TransactionConstants.InitialCommitmentTransactionWeightWithAnchor; var expectedFee = LightningMoney.Satoshis(expectedWeight * parameters.CurrentFeeRatePerKw.Satoshi / 1000); @@ -114,35 +119,36 @@ public void PerformMandatoryChecks(ChannelOpenMandatoryValidationParameters para throw new ChannelErrorException("We don't support large channels"); } - // Check ChannelType against negotiated options + // Check if ChannelType exists minimumDepth = _nodeOptions.MinimumDepth; - if (parameters.ChannelTypeTlv is not null) + if (parameters.ChannelTypeTlv is null) + throw new ChannelErrorException("ChannelTypeTlv is not present"); + + // Check if OptionStaticRemoteKey is Compulsory + if (!parameters.ChannelTypeTlv.Features.IsFeatureSet(Feature.OptionStaticRemoteKey, true)) + throw new ChannelErrorException("Static remote key feature is compulsory but not set by peer", + "ChannelTypeTlv: Static remote key is compulsory"); + + if (parameters.ChannelTypeTlv.Features.IsFeatureSet(Feature.OptionAnchors, true) + && parameters.NegotiatedFeatures.OptionAnchors == FeatureSupport.No) + throw new ChannelErrorException("Anchor outputs feature is not supported but requested by peer", + "ChannelTypeTlv: We don't support anchor outputs"); + + if (parameters.ChannelTypeTlv.Features.IsFeatureSet(Feature.OptionScidAlias, true)) { - // Check if it set any non-negotiated features - if (parameters.ChannelTypeTlv.Features.IsFeatureSet(Feature.OptionStaticRemoteKey, true)) - { - if (parameters.NegotiatedFeatures.StaticRemoteKey == FeatureSupport.No) - throw new ChannelErrorException("Static remote key feature is not supported but requested by peer"); - - if (parameters.ChannelTypeTlv.Features.IsFeatureSet(Feature.OptionAnchorOutputs, true) - && parameters.NegotiatedFeatures.AnchorOutputs == FeatureSupport.No) - throw new ChannelErrorException("Anchor outputs feature is not supported but requested by peer"); - - if (parameters.ChannelTypeTlv.Features.IsFeatureSet(Feature.OptionScidAlias, true)) - { - if (parameters.ChannelFlags is not null && parameters.ChannelFlags.Value.AnnounceChannel) - throw new ChannelErrorException("Invalid channel flags for OPTION_SCID_ALIAS"); - } - - // Check for ZeroConf feature - if (parameters.ChannelTypeTlv.Features.IsFeatureSet(Feature.OptionZeroconf, true)) - { - if (_nodeOptions.Features.ZeroConf == FeatureSupport.No) - throw new ChannelErrorException("ZeroConf feature not supported but requested by peer"); - - minimumDepth = 0U; - } - } + if (parameters.ChannelFlags is not null && parameters.ChannelFlags.Value.AnnounceChannel) + throw new ChannelErrorException("Invalid channel flags for OPTION_SCID_ALIAS", + "ChannelTypeTlv: We want to announce this channel"); + } + + // Check for ZeroConf feature + if (parameters.ChannelTypeTlv.Features.IsFeatureSet(Feature.OptionZeroconf, true)) + { + if (_nodeOptions.Features.ZeroConf == FeatureSupport.No) + throw new ChannelErrorException("ZeroConf feature not supported but requested by peer", + "ChannelTypeTlv: We don't support ZeroConf with you"); + + minimumDepth = 0U; } } } \ No newline at end of file diff --git a/src/NLightning.Domain/Enums/Feature.cs b/src/NLightning.Domain/Enums/Feature.cs index 59c052c6..7bc8f48f 100644 --- a/src/NLightning.Domain/Enums/Feature.cs +++ b/src/NLightning.Domain/Enums/Feature.cs @@ -16,14 +16,6 @@ public enum Feature /// OptionDataLossProtect = 1, - /// - /// 3 is for the optional bit. - /// - /// - /// This feature is optional and is used to indicate that the node supports the initial_routing_sync field in the channel_reestablish message. - /// - InitialRoutingSync = 3, - /// /// 4 is for the compulsory bit, 5 is for the optional bit. /// @@ -88,21 +80,13 @@ public enum Feature /// OptionSupportLargeChannel = 19, - /// - /// 20 is for the compulsory bit, 21 is for the optional bit. - /// - /// - /// This feature is optional and is used to indicate that the node supports anchor outputs. - /// - OptionAnchorOutputs = 21, - /// /// 22 is for the compulsory bit, 23 is for the optional bit. /// /// /// This feature is optional and is used to indicate that the node supports anchor outputs with zero fee htlc transactions. /// - OptionAnchorsZeroFeeHtlcTx = 23, + OptionAnchors = 23, /// /// 24 is for the compulsory bit, 25 is for the optional bit. @@ -124,10 +108,26 @@ public enum Feature /// 28 is for the compulsory bit, 29 is for the optional bit. /// /// - /// This feature is optional and is used to indicate that the node supports dual funded channels (v2). + /// This feature is optional and is used to indicate that the node supports dual-funded channels (v2). /// OptionDualFund = 29, + /// + /// 34 is for the compulsory bit, 35 is for the optional bit. + /// + /// + /// This feature is optional and is used to indicate that the node supports quiesce. + /// + OptionQuiesce = 35, + + /// + /// 36 is for the compulsory bit, 37 is for the optional bit. + /// + /// + /// This feature is optional and is used to indicate that the node can generate/relay attribution data. + /// + OptionAttributionData = 37, + /// /// 38 is for the compulsory bit, 39 is for the optional bit. /// @@ -136,6 +136,14 @@ public enum Feature /// OptionOnionMessages = 39, + /// + /// 42 is for the compulsory bit, 43 is for the optional bit. + /// + /// + /// This feature is optional and is used to indicate that the node supports providing storage for other nodes' encrypted backup data. + /// + OptionProvideStorage = 43, + /// /// 44 is for the compulsory bit, 45 is for the optional bit. /// @@ -166,5 +174,13 @@ public enum Feature /// /// This feature is optional and is used to indicate that the node supports zeroconf channels. /// - OptionZeroconf = 51 + OptionZeroconf = 51, + + /// + /// 60 is for the compulsory bit, 61 is for the optional bit. + /// + /// + /// This feature is optional and is used to indicate that the node supports simple close. + /// + OptionSimpleClose = 61 } \ No newline at end of file diff --git a/src/NLightning.Domain/Node/FeatureSet.cs b/src/NLightning.Domain/Node/FeatureSet.cs index c3975645..d9586f58 100644 --- a/src/NLightning.Domain/Node/FeatureSet.cs +++ b/src/NLightning.Domain/Node/FeatureSet.cs @@ -21,8 +21,7 @@ public class FeatureSet { Feature.GossipQueriesEx, [Feature.GossipQueries] }, { Feature.PaymentSecret, [Feature.VarOnionOptin] }, { Feature.BasicMpp, [Feature.PaymentSecret] }, - { Feature.OptionAnchorOutputs, [Feature.OptionStaticRemoteKey] }, - { Feature.OptionAnchorsZeroFeeHtlcTx, [Feature.OptionStaticRemoteKey] }, + { Feature.OptionAnchors, [Feature.OptionStaticRemoteKey] }, { Feature.OptionRouteBlinding, [Feature.VarOnionOptin] }, { Feature.OptionZeroconf, [Feature.OptionScidAlias] }, }; @@ -38,8 +37,23 @@ public class FeatureSet public FeatureSet() { FeatureFlags = new BitArray(128); + // Always set the compulsory bit of option_data_loss_protect + SetFeature(Feature.OptionDataLossProtect, true); // Always set the compulsory bit of var_onion_optin - SetFeature(Feature.VarOnionOptin, false); + SetFeature(Feature.VarOnionOptin, true); + // Always set the compulsory bit of option_static_remote_key + SetFeature(Feature.OptionStaticRemoteKey, true); + // Always set the compulsory bit of payment_secret + SetFeature(Feature.PaymentSecret, true); + // Always set the compulsory bit for option_channel_type + SetFeature(Feature.OptionChannelType, true); + } + + public static FeatureSet NewBasicChannelType() + { + // Initialize a new FeatureSet with only OptionStaticRemoteKey set as compulsory + var featureFlagsForChannelType = DeserializeFromBytes([0b0001_0000, 0b0000_0000]); + return featureFlagsForChannelType; } public event EventHandler? Changed; @@ -164,13 +178,12 @@ private bool IsFeatureSet(int bitPosition) } /// - /// Checks if the option_anchor_outputs or option_anchors_zero_fee_htlc_tx feature is set. + /// Checks if the option_anchors feature is set. /// /// true if one of the features is set, false otherwise. public bool IsOptionAnchorsSet() { - return IsFeatureSet(Feature.OptionAnchorOutputs, false) || - IsFeatureSet(Feature.OptionAnchorsZeroFeeHtlcTx, false); + return IsFeatureSet(Feature.OptionAnchors, false) || IsFeatureSet(Feature.OptionAnchors, true); } /// @@ -276,15 +289,24 @@ public void WriteToBitWriter(IBitWriter bitWriter, int length, bool shouldPad) /// public bool HasFeature(Feature feature) => IsFeatureSet(feature, false) || IsFeatureSet(feature, true); - public byte[]? GetBytes() + public byte[]? GetBytes(bool asGlobal = false) { - var lastIndexOfOne = GetLastIndexOfOne(FeatureFlags); + // Get the last valid bit + var lastIndexOfOne = GetLastIndexOfOne(FeatureFlags, asGlobal); if (lastIndexOfOne == -1) return null; - var bytes = new byte[lastIndexOfOne]; + // Calculate total bytes needed + var totalBytes = (FeatureFlags.Length + 7) / 8; + var bytes = new byte[totalBytes]; + + // Copy bits as bytes FeatureFlags.CopyTo(bytes, 0); - return bytes; + + // Calculate last valid byte + var lastValidByte = (lastIndexOfOne + 7) / 8; + + return bytes[..lastValidByte]; } /// @@ -360,10 +382,12 @@ public static FeatureSet Combine(FeatureSet first, FeatureSet second) public override string ToString() { var sb = new StringBuilder(); - for (var i = 0; i < FeatureFlags.Length; i++) + for (var i = 1; i < FeatureFlags.Length; i += 2) { if (IsFeatureSet(i)) sb.Append($"{(Feature)i}, "); + else if (IsFeatureSet(i - 1)) + sb.Append($"{(Feature)i}, "); } return sb.ToString().TrimEnd(' ', ','); @@ -399,9 +423,10 @@ private void OnChanged() Changed?.Invoke(this, EventArgs.Empty); } - private static int GetLastIndexOfOne(BitArray bitArray) + private static int GetLastIndexOfOne(BitArray bitArray, bool asGlobal = false) { - for (var i = bitArray.Length - 1; i >= 0; i--) + var maxLength = asGlobal ? 13 : bitArray.Length; + for (var i = maxLength - 1; i >= 0; i--) { if (bitArray[i]) return i; diff --git a/src/NLightning.Domain/Node/Options/FeatureOptions.cs b/src/NLightning.Domain/Node/Options/FeatureOptions.cs index 55de2e95..d4172fc9 100644 --- a/src/NLightning.Domain/Node/Options/FeatureOptions.cs +++ b/src/NLightning.Domain/Node/Options/FeatureOptions.cs @@ -11,18 +11,10 @@ namespace NLightning.Domain.Node.Options; public class FeatureOptions { - /// - /// Enable data loss protection. - /// - public FeatureSupport DataLossProtect { get; set; } = FeatureSupport.Compulsory; - - /// - /// Enable initial routing sync. - /// - public FeatureSupport InitialRoutingSync { get; set; } = FeatureSupport.No; + public FeatureSupport OptionDataLossProtect { get; private set; } = FeatureSupport.Compulsory; /// - /// Enable upfront shutdown script. + /// Enable an upfront shutdown script. /// public FeatureSupport UpfrontShutdownScript { get; set; } = FeatureSupport.Optional; @@ -31,20 +23,16 @@ public class FeatureOptions /// public FeatureSupport GossipQueries { get; set; } = FeatureSupport.Optional; + public FeatureSupport VarOnionOptIn { get; private set; } = FeatureSupport.Compulsory; + /// /// Enable expanded gossip queries. /// public FeatureSupport ExpandedGossipQueries { get; set; } = FeatureSupport.Optional; - /// - /// Enable static remote key. - /// - public FeatureSupport StaticRemoteKey { get; set; } = FeatureSupport.Compulsory; + public FeatureSupport OptionStaticRemoteKey { get; private set; } = FeatureSupport.Compulsory; - /// - /// Enable payment secret. - /// - public FeatureSupport PaymentSecret { get; set; } = FeatureSupport.Compulsory; + public FeatureSupport PaymentSecret { get; private set; } = FeatureSupport.Compulsory; /// /// Enable basic MPP. @@ -56,20 +44,15 @@ public class FeatureOptions /// public FeatureSupport LargeChannels { get; set; } = FeatureSupport.Optional; - /// - /// Enable anchor outputs. - /// - public FeatureSupport AnchorOutputs { get; set; } = FeatureSupport.Optional; - /// /// Enable zero fee anchor tx. /// - public FeatureSupport ZeroFeeAnchorTx { get; set; } = FeatureSupport.No; + public FeatureSupport OptionAnchors { get; set; } = FeatureSupport.No; /// /// Enable route blinding. /// - public FeatureSupport RouteBlinding { get; set; } = FeatureSupport.Optional; + public FeatureSupport OptionRouteBlinding { get; set; } = FeatureSupport.Optional; /// /// Enable beyond segwit shutdown. @@ -81,15 +64,18 @@ public class FeatureOptions /// public FeatureSupport DualFund { get; set; } = FeatureSupport.Optional; + public FeatureSupport OptionQuiesce { get; set; } = FeatureSupport.Optional; + + public FeatureSupport OptionAttributionData { get; set; } = FeatureSupport.Optional; + /// /// Enable onion messages. /// - public FeatureSupport OnionMessages { get; set; } = FeatureSupport.No; + public FeatureSupport OptionOnionMessages { get; set; } = FeatureSupport.No; - /// - /// Enable channel type. - /// - public FeatureSupport ChannelType { get; set; } = FeatureSupport.Optional; + public FeatureSupport OptionProvideStorage { get; set; } = FeatureSupport.Optional; + + public FeatureSupport OptionChannelType { get; private set; } = FeatureSupport.Compulsory; /// /// Enable scid alias. @@ -106,6 +92,14 @@ public class FeatureOptions /// public FeatureSupport ZeroConf { get; set; } = FeatureSupport.No; + public FeatureSupport OptionSimpleClose { get; set; } = FeatureSupport.No; + + /// + /// Enable initial routing sync. + /// + /// [Deprecated] + public FeatureSupport InitialRoutingSync { get; set; } = FeatureSupport.No; + /// /// The chain hashes of the node. /// @@ -133,17 +127,6 @@ public FeatureSet GetNodeFeatures() { var features = new FeatureSet(); - // Set default features - if (DataLossProtect != FeatureSupport.No) - { - features.SetFeature(Feature.OptionDataLossProtect, DataLossProtect == FeatureSupport.Compulsory); - } - - if (InitialRoutingSync != FeatureSupport.No) - { - features.SetFeature(Feature.InitialRoutingSync, InitialRoutingSync == FeatureSupport.Compulsory); - } - if (UpfrontShutdownScript != FeatureSupport.No) { features.SetFeature(Feature.OptionUpfrontShutdownScript, @@ -160,59 +143,59 @@ public FeatureSet GetNodeFeatures() features.SetFeature(Feature.GossipQueriesEx, ExpandedGossipQueries == FeatureSupport.Compulsory); } - if (StaticRemoteKey != FeatureSupport.No) + if (BasicMpp != FeatureSupport.No) { - features.SetFeature(Feature.OptionStaticRemoteKey, StaticRemoteKey == FeatureSupport.Compulsory); + features.SetFeature(Feature.BasicMpp, BasicMpp == FeatureSupport.Compulsory); } - if (PaymentSecret != FeatureSupport.No) + if (LargeChannels != FeatureSupport.No) { - features.SetFeature(Feature.PaymentSecret, PaymentSecret == FeatureSupport.Compulsory); + features.SetFeature(Feature.OptionSupportLargeChannel, LargeChannels == FeatureSupport.Compulsory); } - if (BasicMpp != FeatureSupport.No) + if (OptionAnchors != FeatureSupport.No) { - features.SetFeature(Feature.BasicMpp, BasicMpp == FeatureSupport.Compulsory); + features.SetFeature(Feature.OptionAnchors, OptionAnchors == FeatureSupport.Compulsory); } - if (LargeChannels != FeatureSupport.No) + if (OptionRouteBlinding != FeatureSupport.No) { - features.SetFeature(Feature.OptionSupportLargeChannel, LargeChannels == FeatureSupport.Compulsory); + features.SetFeature(Feature.OptionRouteBlinding, OptionRouteBlinding == FeatureSupport.Compulsory); } - if (AnchorOutputs != FeatureSupport.No) + if (BeyondSegwitShutdown != FeatureSupport.No) { - features.SetFeature(Feature.OptionAnchorOutputs, AnchorOutputs == FeatureSupport.Compulsory); + features.SetFeature(Feature.OptionShutdownAnySegwit, BeyondSegwitShutdown == FeatureSupport.Compulsory); } - if (ZeroFeeAnchorTx != FeatureSupport.No) + if (DualFund != FeatureSupport.No) { - features.SetFeature(Feature.OptionAnchorsZeroFeeHtlcTx, ZeroFeeAnchorTx == FeatureSupport.Compulsory); + features.SetFeature(Feature.OptionDualFund, DualFund == FeatureSupport.Compulsory); } - if (RouteBlinding != FeatureSupport.No) + if (OptionQuiesce != FeatureSupport.No) { - features.SetFeature(Feature.OptionRouteBlinding, RouteBlinding == FeatureSupport.Compulsory); + features.SetFeature(Feature.OptionQuiesce, OptionQuiesce == FeatureSupport.Compulsory); } - if (BeyondSegwitShutdown != FeatureSupport.No) + if (OptionAttributionData != FeatureSupport.No) { - features.SetFeature(Feature.OptionShutdownAnySegwit, BeyondSegwitShutdown == FeatureSupport.Compulsory); + features.SetFeature(Feature.OptionAttributionData, OptionAttributionData == FeatureSupport.Compulsory); } - if (DualFund != FeatureSupport.No) + if (OptionOnionMessages != FeatureSupport.No) { - features.SetFeature(Feature.OptionDualFund, DualFund == FeatureSupport.Compulsory); + features.SetFeature(Feature.OptionOnionMessages, OptionOnionMessages == FeatureSupport.Compulsory); } - if (OnionMessages != FeatureSupport.No) + if (OptionProvideStorage != FeatureSupport.No) { - features.SetFeature(Feature.OptionOnionMessages, OnionMessages == FeatureSupport.Compulsory); + features.SetFeature(Feature.OptionProvideStorage, OptionProvideStorage == FeatureSupport.Compulsory); } - if (ChannelType != FeatureSupport.No) + if (OptionChannelType != FeatureSupport.No) { - features.SetFeature(Feature.OptionChannelType, ChannelType == FeatureSupport.Compulsory); + features.SetFeature(Feature.OptionChannelType, OptionChannelType == FeatureSupport.Compulsory); } if (ScidAlias != FeatureSupport.No) @@ -230,6 +213,11 @@ public FeatureSet GetNodeFeatures() features.SetFeature(Feature.OptionZeroconf, ZeroConf == FeatureSupport.Compulsory); } + if (OptionSimpleClose != FeatureSupport.No) + { + features.SetFeature(Feature.OptionSimpleClose, OptionSimpleClose == FeatureSupport.Compulsory); + } + return features; } @@ -268,16 +256,11 @@ public static FeatureOptions GetNodeOptions(FeatureSet featureSet, TlvStream? ex { var options = new FeatureOptions { - DataLossProtect = featureSet.IsFeatureSet(Feature.OptionDataLossProtect, true) - ? FeatureSupport.Compulsory - : featureSet.IsFeatureSet(Feature.OptionDataLossProtect, false) - ? FeatureSupport.Optional - : FeatureSupport.No, - InitialRoutingSync = featureSet.IsFeatureSet(Feature.InitialRoutingSync, true) - ? FeatureSupport.Compulsory - : featureSet.IsFeatureSet(Feature.InitialRoutingSync, false) - ? FeatureSupport.Optional - : FeatureSupport.No, + OptionDataLossProtect = featureSet.IsFeatureSet(Feature.OptionDataLossProtect, true) + ? FeatureSupport.Compulsory + : featureSet.IsFeatureSet(Feature.OptionDataLossProtect, false) + ? FeatureSupport.Optional + : FeatureSupport.No, UpfrontShutdownScript = featureSet.IsFeatureSet(Feature.OptionUpfrontShutdownScript, true) ? FeatureSupport.Compulsory : featureSet.IsFeatureSet(Feature.OptionUpfrontShutdownScript, false) @@ -288,16 +271,21 @@ public static FeatureOptions GetNodeOptions(FeatureSet featureSet, TlvStream? ex : featureSet.IsFeatureSet(Feature.GossipQueries, false) ? FeatureSupport.Optional : FeatureSupport.No, + VarOnionOptIn = featureSet.IsFeatureSet(Feature.VarOnionOptin, true) + ? FeatureSupport.Compulsory + : featureSet.IsFeatureSet(Feature.VarOnionOptin, false) + ? FeatureSupport.Optional + : FeatureSupport.No, ExpandedGossipQueries = featureSet.IsFeatureSet(Feature.GossipQueriesEx, true) ? FeatureSupport.Compulsory : featureSet.IsFeatureSet(Feature.GossipQueriesEx, false) ? FeatureSupport.Optional : FeatureSupport.No, - StaticRemoteKey = featureSet.IsFeatureSet(Feature.OptionStaticRemoteKey, true) - ? FeatureSupport.Compulsory - : featureSet.IsFeatureSet(Feature.OptionStaticRemoteKey, false) - ? FeatureSupport.Optional - : FeatureSupport.No, + OptionStaticRemoteKey = featureSet.IsFeatureSet(Feature.OptionStaticRemoteKey, true) + ? FeatureSupport.Compulsory + : featureSet.IsFeatureSet(Feature.OptionStaticRemoteKey, false) + ? FeatureSupport.Optional + : FeatureSupport.No, PaymentSecret = featureSet.IsFeatureSet(Feature.PaymentSecret, true) ? FeatureSupport.Compulsory : featureSet.IsFeatureSet(Feature.PaymentSecret, false) @@ -313,21 +301,16 @@ public static FeatureOptions GetNodeOptions(FeatureSet featureSet, TlvStream? ex : featureSet.IsFeatureSet(Feature.OptionSupportLargeChannel, false) ? FeatureSupport.Optional : FeatureSupport.No, - AnchorOutputs = featureSet.IsFeatureSet(Feature.OptionAnchorOutputs, true) + OptionAnchors = featureSet.IsFeatureSet(Feature.OptionAnchors, true) ? FeatureSupport.Compulsory - : featureSet.IsFeatureSet(Feature.OptionAnchorOutputs, false) - ? FeatureSupport.Optional - : FeatureSupport.No, - ZeroFeeAnchorTx = featureSet.IsFeatureSet(Feature.OptionAnchorsZeroFeeHtlcTx, true) - ? FeatureSupport.Compulsory - : featureSet.IsFeatureSet(Feature.OptionAnchorsZeroFeeHtlcTx, false) - ? FeatureSupport.Optional - : FeatureSupport.No, - RouteBlinding = featureSet.IsFeatureSet(Feature.OptionRouteBlinding, true) - ? FeatureSupport.Compulsory - : featureSet.IsFeatureSet(Feature.OptionRouteBlinding, false) + : featureSet.IsFeatureSet(Feature.OptionAnchors, false) ? FeatureSupport.Optional : FeatureSupport.No, + OptionRouteBlinding = featureSet.IsFeatureSet(Feature.OptionRouteBlinding, true) + ? FeatureSupport.Compulsory + : featureSet.IsFeatureSet(Feature.OptionRouteBlinding, false) + ? FeatureSupport.Optional + : FeatureSupport.No, BeyondSegwitShutdown = featureSet.IsFeatureSet(Feature.OptionShutdownAnySegwit, true) ? FeatureSupport.Compulsory : featureSet.IsFeatureSet(Feature.OptionShutdownAnySegwit, false) @@ -338,16 +321,31 @@ public static FeatureOptions GetNodeOptions(FeatureSet featureSet, TlvStream? ex : featureSet.IsFeatureSet(Feature.OptionDualFund, false) ? FeatureSupport.Optional : FeatureSupport.No, - OnionMessages = featureSet.IsFeatureSet(Feature.OptionOnionMessages, true) + OptionQuiesce = featureSet.IsFeatureSet(Feature.OptionQuiesce, true) ? FeatureSupport.Compulsory - : featureSet.IsFeatureSet(Feature.OptionOnionMessages, false) + : featureSet.IsFeatureSet(Feature.OptionQuiesce, false) ? FeatureSupport.Optional : FeatureSupport.No, - ChannelType = featureSet.IsFeatureSet(Feature.OptionChannelType, true) - ? FeatureSupport.Compulsory - : featureSet.IsFeatureSet(Feature.OptionChannelType, false) - ? FeatureSupport.Optional - : FeatureSupport.No, + OptionAttributionData = featureSet.IsFeatureSet(Feature.OptionAttributionData, true) + ? FeatureSupport.Compulsory + : featureSet.IsFeatureSet(Feature.OptionAttributionData, false) + ? FeatureSupport.Optional + : FeatureSupport.No, + OptionOnionMessages = featureSet.IsFeatureSet(Feature.OptionOnionMessages, true) + ? FeatureSupport.Compulsory + : featureSet.IsFeatureSet(Feature.OptionOnionMessages, false) + ? FeatureSupport.Optional + : FeatureSupport.No, + OptionProvideStorage = featureSet.IsFeatureSet(Feature.OptionProvideStorage, true) + ? FeatureSupport.Compulsory + : featureSet.IsFeatureSet(Feature.OptionProvideStorage, false) + ? FeatureSupport.Optional + : FeatureSupport.No, + OptionChannelType = featureSet.IsFeatureSet(Feature.OptionChannelType, true) + ? FeatureSupport.Compulsory + : featureSet.IsFeatureSet(Feature.OptionChannelType, false) + ? FeatureSupport.Optional + : FeatureSupport.No, ScidAlias = featureSet.IsFeatureSet(Feature.OptionScidAlias, true) ? FeatureSupport.Compulsory : featureSet.IsFeatureSet(Feature.OptionScidAlias, false) @@ -362,7 +360,12 @@ public static FeatureOptions GetNodeOptions(FeatureSet featureSet, TlvStream? ex ? FeatureSupport.Compulsory : featureSet.IsFeatureSet(Feature.OptionZeroconf, false) ? FeatureSupport.Optional - : FeatureSupport.No + : FeatureSupport.No, + OptionSimpleClose = featureSet.IsFeatureSet(Feature.OptionSimpleClose, true) + ? FeatureSupport.Compulsory + : featureSet.IsFeatureSet(Feature.OptionSimpleClose, false) + ? FeatureSupport.Optional + : FeatureSupport.No, }; if (extension?.TryGetTlv(new BigSize(1), out var chainHashes) ?? false) diff --git a/src/NLightning.Domain/Protocol/Interfaces/IMessageFactory.cs b/src/NLightning.Domain/Protocol/Interfaces/IMessageFactory.cs index f24dcda9..58791d7b 100644 --- a/src/NLightning.Domain/Protocol/Interfaces/IMessageFactory.cs +++ b/src/NLightning.Domain/Protocol/Interfaces/IMessageFactory.cs @@ -53,8 +53,8 @@ OpenChannel1Message CreateOpenChannel1Message(ChannelId temporaryChannelId, Ligh CompactPubKey paymentBasepoint, CompactPubKey delayedPaymentBasepoint, CompactPubKey htlcBasepoint, CompactPubKey firstPerCommitmentPoint, ChannelFlags channelFlags, - UpfrontShutdownScriptTlv? upfrontShutdownScriptTlv, - ChannelTypeTlv? channelTypeTlv); + ChannelTypeTlv channelTypeTlv, + UpfrontShutdownScriptTlv? upfrontShutdownScriptTlv); OpenChannel2Message CreateOpenChannel2Message(ChannelId temporaryChannelId, uint fundingFeeRatePerKw, uint commitmentFeeRatePerKw, ulong fundingSatoshis, @@ -67,7 +67,7 @@ OpenChannel2Message CreateOpenChannel2Message(ChannelId temporaryChannelId, uint byte[]? channelType = null, bool requireConfirmedInputs = false); AcceptChannel1Message CreateAcceptChannel1Message(LightningMoney channelReserveAmount, - ChannelTypeTlv? channelTypeTlv, + ChannelTypeTlv channelTypeTlv, CompactPubKey delayedPaymentBasepoint, CompactPubKey firstPerCommitmentPoint, CompactPubKey fundingPubKey, CompactPubKey htlcBasepoint, diff --git a/src/NLightning.Domain/Protocol/Messages/AcceptChannel1Message.cs b/src/NLightning.Domain/Protocol/Messages/AcceptChannel1Message.cs index c2972901..3b3b98a5 100644 --- a/src/NLightning.Domain/Protocol/Messages/AcceptChannel1Message.cs +++ b/src/NLightning.Domain/Protocol/Messages/AcceptChannel1Message.cs @@ -9,7 +9,7 @@ namespace NLightning.Domain.Protocol.Messages; /// Represents an open_channel message. /// /// -/// The accept_channel message is sent to the initiator in order to accept the channel opening. +/// The accept_channel message is sent to the initiator to accept the channel opening. /// The message type is 33. /// public sealed class AcceptChannel1Message : BaseChannelMessage @@ -29,18 +29,14 @@ public sealed class AcceptChannel1Message : BaseChannelMessage /// public ChannelTypeTlv? ChannelTypeTlv { get; } - public AcceptChannel1Message(AcceptChannel1Payload payload, - UpfrontShutdownScriptTlv? upfrontShutdownScriptTlv = null, - ChannelTypeTlv? channelTypeTlv = null) + public AcceptChannel1Message(AcceptChannel1Payload payload, ChannelTypeTlv channelTypeTlv, + UpfrontShutdownScriptTlv? upfrontShutdownScriptTlv = null) : base(MessageTypes.AcceptChannel, payload) { UpfrontShutdownScriptTlv = upfrontShutdownScriptTlv; ChannelTypeTlv = channelTypeTlv; - if (UpfrontShutdownScriptTlv is not null || ChannelTypeTlv is not null) - { - Extension = new TlvStream(); - Extension.Add(UpfrontShutdownScriptTlv, ChannelTypeTlv); - } + Extension = new TlvStream(); + Extension.Add(UpfrontShutdownScriptTlv, ChannelTypeTlv); } } \ No newline at end of file diff --git a/src/NLightning.Domain/Protocol/Messages/OpenChannel1Message.cs b/src/NLightning.Domain/Protocol/Messages/OpenChannel1Message.cs index df3b84aa..d6137431 100644 --- a/src/NLightning.Domain/Protocol/Messages/OpenChannel1Message.cs +++ b/src/NLightning.Domain/Protocol/Messages/OpenChannel1Message.cs @@ -20,19 +20,16 @@ public sealed class OpenChannel1Message : BaseChannelMessage public new OpenChannel1Payload Payload { get => (OpenChannel1Payload)base.Payload; } public UpfrontShutdownScriptTlv? UpfrontShutdownScriptTlv { get; } - public ChannelTypeTlv? ChannelTypeTlv { get; } + public ChannelTypeTlv ChannelTypeTlv { get; } - public OpenChannel1Message(OpenChannel1Payload payload, UpfrontShutdownScriptTlv? upfrontShutdownScriptTlv = null, - ChannelTypeTlv? channelTypeTlv = null) + public OpenChannel1Message(OpenChannel1Payload payload, ChannelTypeTlv channelTypeTlv, + UpfrontShutdownScriptTlv? upfrontShutdownScriptTlv = null) : base(MessageTypes.OpenChannel, payload) { UpfrontShutdownScriptTlv = upfrontShutdownScriptTlv; ChannelTypeTlv = channelTypeTlv; - if (UpfrontShutdownScriptTlv is not null || ChannelTypeTlv is not null) - { - Extension = new TlvStream(); - Extension.Add(UpfrontShutdownScriptTlv, ChannelTypeTlv); - } + Extension = new TlvStream(); + Extension.Add(UpfrontShutdownScriptTlv, ChannelTypeTlv); } } \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Serialization/Messages/Types/AcceptChannel1MessageTypeSerializer.cs b/src/NLightning.Infrastructure.Serialization/Messages/Types/AcceptChannel1MessageTypeSerializer.cs index 3ba8d2d9..ae88e39a 100644 --- a/src/NLightning.Infrastructure.Serialization/Messages/Types/AcceptChannel1MessageTypeSerializer.cs +++ b/src/NLightning.Infrastructure.Serialization/Messages/Types/AcceptChannel1MessageTypeSerializer.cs @@ -1,13 +1,13 @@ using System.Runtime.Serialization; -using NLightning.Domain.Protocol.Interfaces; -using NLightning.Domain.Serialization.Interfaces; namespace NLightning.Infrastructure.Serialization.Messages.Types; using Domain.Protocol.Constants; +using Domain.Protocol.Interfaces; using Domain.Protocol.Messages; using Domain.Protocol.Payloads; using Domain.Protocol.Tlv; +using Domain.Serialization.Interfaces; using Exceptions; using Interfaces; @@ -58,12 +58,9 @@ public async Task DeserializeAsync(Stream stream) // Deserialize extension if (stream.Position >= stream.Length) - return new AcceptChannel1Message(payload); - - var extension = await _tlvStreamSerializer.DeserializeAsync(stream); - if (extension is null) - return new AcceptChannel1Message(payload); + throw new SerializationException("Required extension is missing"); + var extension = await _tlvStreamSerializer.DeserializeAsync(stream) ?? throw new SerializationException("Required extension is missing"); UpfrontShutdownScriptTlv? upfrontShutdownScriptTlv = null; if (extension.TryGetTlv(TlvConstants.UpfrontShutdownScript, out var baseUpfrontShutdownTlv)) { @@ -73,16 +70,15 @@ public async Task DeserializeAsync(Stream stream) upfrontShutdownScriptTlv = tlvConverter.ConvertFromBase(baseUpfrontShutdownTlv!); } - ChannelTypeTlv? channelTypeTlv = null; - if (extension.TryGetTlv(TlvConstants.ChannelType, out var baseChannelTypeTlv)) - { - var tlvConverter = - _tlvConverterFactory.GetConverter() - ?? throw new SerializationException($"No serializer found for tlv type {nameof(ChannelTypeTlv)}"); - channelTypeTlv = tlvConverter.ConvertFromBase(baseChannelTypeTlv!); - } + if (!extension.TryGetTlv(TlvConstants.ChannelType, out var baseChannelTypeTlv)) + throw new SerializationException("Required extension is missing"); + + var channelTypeTlvConverter = + _tlvConverterFactory.GetConverter() + ?? throw new SerializationException($"No serializer found for tlv type {nameof(ChannelTypeTlv)}"); + var channelTypeTlv = channelTypeTlvConverter.ConvertFromBase(baseChannelTypeTlv!); - return new AcceptChannel1Message(payload, upfrontShutdownScriptTlv, channelTypeTlv); + return new AcceptChannel1Message(payload, channelTypeTlv, upfrontShutdownScriptTlv); } catch (SerializationException e) { diff --git a/src/NLightning.Infrastructure.Serialization/Messages/Types/OpenChannel1MessageTypeSerializer.cs b/src/NLightning.Infrastructure.Serialization/Messages/Types/OpenChannel1MessageTypeSerializer.cs index 3f940893..e523f5f1 100644 --- a/src/NLightning.Infrastructure.Serialization/Messages/Types/OpenChannel1MessageTypeSerializer.cs +++ b/src/NLightning.Infrastructure.Serialization/Messages/Types/OpenChannel1MessageTypeSerializer.cs @@ -1,13 +1,13 @@ using System.Runtime.Serialization; -using NLightning.Domain.Protocol.Interfaces; -using NLightning.Domain.Serialization.Interfaces; namespace NLightning.Infrastructure.Serialization.Messages.Types; using Domain.Protocol.Constants; +using Domain.Protocol.Interfaces; using Domain.Protocol.Messages; using Domain.Protocol.Payloads; using Domain.Protocol.Tlv; +using Domain.Serialization.Interfaces; using Exceptions; using Interfaces; @@ -58,12 +58,9 @@ public async Task DeserializeAsync(Stream stream) // Deserialize extension if (stream.Position >= stream.Length) - return new OpenChannel1Message(payload); - - var extension = await _tlvStreamSerializer.DeserializeAsync(stream); - if (extension is null) - return new OpenChannel1Message(payload); + throw new SerializationException("Required extension is missing"); + var extension = await _tlvStreamSerializer.DeserializeAsync(stream) ?? throw new SerializationException("Required extension is missing"); UpfrontShutdownScriptTlv? upfrontShutdownScriptTlv = null; if (extension.TryGetTlv(TlvConstants.UpfrontShutdownScript, out var baseUpfrontShutdownTlv)) { @@ -73,16 +70,15 @@ public async Task DeserializeAsync(Stream stream) upfrontShutdownScriptTlv = tlvConverter.ConvertFromBase(baseUpfrontShutdownTlv!); } - ChannelTypeTlv? channelTypeTlv = null; - if (extension.TryGetTlv(TlvConstants.ChannelType, out var baseChannelTypeTlv)) - { - var tlvConverter = - _tlvConverterFactory.GetConverter() - ?? throw new SerializationException($"No serializer found for tlv type {nameof(ChannelTypeTlv)}"); - channelTypeTlv = tlvConverter.ConvertFromBase(baseChannelTypeTlv!); - } + if (!extension.TryGetTlv(TlvConstants.ChannelType, out var baseChannelTypeTlv)) + throw new SerializationException("Required extension is missing"); + + var channelTypeTlvConverter = + _tlvConverterFactory.GetConverter() + ?? throw new SerializationException($"No serializer found for tlv type {nameof(ChannelTypeTlv)}"); + var channelTypeTlv = channelTypeTlvConverter.ConvertFromBase(baseChannelTypeTlv!); - return new OpenChannel1Message(payload, upfrontShutdownScriptTlv, channelTypeTlv); + return new OpenChannel1Message(payload, channelTypeTlv, upfrontShutdownScriptTlv); } catch (SerializationException e) { diff --git a/test/NLightning.Application.Tests/Channels/Handlers/OpenChannel1MessageHandlerTests.cs b/test/NLightning.Application.Tests/Channels/Handlers/OpenChannel1MessageHandlerTests.cs index f984d44c..ee61be00 100644 --- a/test/NLightning.Application.Tests/Channels/Handlers/OpenChannel1MessageHandlerTests.cs +++ b/test/NLightning.Application.Tests/Channels/Handlers/OpenChannel1MessageHandlerTests.cs @@ -10,6 +10,7 @@ using NLightning.Domain.Enums; using NLightning.Domain.Exceptions; using NLightning.Domain.Money; +using NLightning.Domain.Node; using NLightning.Domain.Node.Options; using NLightning.Domain.Protocol.Interfaces; using NLightning.Domain.Protocol.Messages; @@ -71,7 +72,8 @@ public OpenChannel1MessageHandlerTests() emptyPubKey, fundingAmount, emptyPubKey, emptyPubKey, htlcMinimumAmount, maxAcceptedHtlcs, maxHtlcAmountInFlight, emptyPubKey, LightningMoney.Zero, emptyPubKey, toSelfDelay); - _validMessage = new OpenChannel1Message(payload); + _validMessage = + new OpenChannel1Message(payload, new ChannelTypeTlv(FeatureSet.NewBasicChannelType().GetBytes()!)); // Setup ChannelConfig var channelConfig = new ChannelConfig(channelReserveAmount, feeRateAmountPerKw, htlcMinimumAmount, @@ -108,7 +110,8 @@ public OpenChannel1MessageHandlerTests() new AcceptChannel1Payload(channelId, channelReserveAmount, emptyPubKey, dustLimitAmount, emptyPubKey, emptyPubKey, emptyPubKey, htlcMinimumAmount, maxAcceptedHtlcs, maxHtlcAmountInFlight, 3, emptyPubKey, - emptyPubKey, toSelfDelay))); + emptyPubKey, toSelfDelay), + new ChannelTypeTlv(FeatureSet.NewBasicChannelType().GetBytes()!))); } [Fact] diff --git a/test/NLightning.Domain.Tests/Node/FeatureSetTests.cs b/test/NLightning.Domain.Tests/Node/FeatureSetTests.cs index 0ab6baad..24de9ae1 100644 --- a/test/NLightning.Domain.Tests/Node/FeatureSetTests.cs +++ b/test/NLightning.Domain.Tests/Node/FeatureSetTests.cs @@ -6,6 +6,7 @@ namespace NLightning.Domain.Tests.Node; public class FeatureSetTests { #region SetFeature IsFeatureSet + [Theory] [InlineData(Feature.OptionDataLossProtect, false)] [InlineData(Feature.OptionDataLossProtect, true)] @@ -63,9 +64,10 @@ public void Given_Features_When_UnsetFeatureA_Then_FeatureBIsSet(Feature feature [InlineData(Feature.GossipQueriesEx, Feature.GossipQueries, true)] [InlineData(Feature.PaymentSecret, Feature.VarOnionOptin, false)] [InlineData(Feature.PaymentSecret, Feature.VarOnionOptin, true)] - [InlineData(Feature.OptionAnchorOutputs, Feature.OptionStaticRemoteKey, false)] - [InlineData(Feature.OptionAnchorOutputs, Feature.OptionStaticRemoteKey, true)] - public void Given_Features_When_SetFeatureADependsOnFeatureB_Then_FeatureBIsSet(Feature feature, Feature dependsOn, bool isCompulsory) + [InlineData(Feature.OptionAnchors, Feature.OptionStaticRemoteKey, false)] + [InlineData(Feature.OptionAnchors, Feature.OptionStaticRemoteKey, true)] + public void Given_Features_When_SetFeatureADependsOnFeatureB_Then_FeatureBIsSet( + Feature feature, Feature dependsOn, bool isCompulsory) { // Arrange var features = new FeatureSet(); @@ -86,9 +88,10 @@ public void Given_Features_When_SetFeatureADependsOnFeatureB_Then_FeatureBIsSet( [InlineData(Feature.GossipQueries, Feature.GossipQueriesEx, true)] [InlineData(Feature.VarOnionOptin, Feature.PaymentSecret, false)] [InlineData(Feature.VarOnionOptin, Feature.PaymentSecret, true)] - [InlineData(Feature.OptionStaticRemoteKey, Feature.OptionAnchorOutputs, false)] - [InlineData(Feature.OptionStaticRemoteKey, Feature.OptionAnchorOutputs, true)] - public void Given_Features_When_UnsetFeatureA_Then_FeatureBIsUnset(Feature feature, Feature dependent, bool isCompulsory) + [InlineData(Feature.OptionStaticRemoteKey, Feature.OptionAnchors, false)] + [InlineData(Feature.OptionStaticRemoteKey, Feature.OptionAnchors, true)] + public void Given_Features_When_UnsetFeatureA_Then_FeatureBIsUnset(Feature feature, Feature dependent, + bool isCompulsory) { // Arrange var features = new FeatureSet(); @@ -120,9 +123,11 @@ public void Given_Features_When_SetUnknownFeature_Then_UnknownFeatureIsSet() Assert.True(features.IsFeatureSet(42, false)); Assert.True(eventRaised); } + #endregion #region IsCompatible + [Theory] [InlineData(Feature.OptionDataLossProtect, false, false, false, false, true)] [InlineData(Feature.OptionDataLossProtect, false, true, false, false, true)] @@ -133,7 +138,9 @@ public void Given_Features_When_SetUnknownFeature_Then_UnknownFeatureIsSet() [InlineData(Feature.OptionDataLossProtect, true, false, true, false, true)] [InlineData(Feature.OptionDataLossProtect, false, true, true, false, false)] [InlineData(Feature.OptionDataLossProtect, true, false, false, true, false)] - public void Given_Features_When_IsCompatible_Then_ReturnIsKnown(Feature feature, bool unsetLocal, bool isLocalCompulsorySet, bool unsetOther, bool isOtherCompulsorySet, bool expected) + public void Given_Features_When_IsCompatible_Then_ReturnIsKnown(Feature feature, bool unsetLocal, + bool isLocalCompulsorySet, bool unsetOther, + bool isOtherCompulsorySet, bool expected) { // Arrange var features = new FeatureSet(); @@ -228,9 +235,11 @@ public void Given_Features_When_OtherFeatureDontSetDependency_Then_ReturnFalse() // Assert Assert.False(result); } + #endregion #region Combine + [Fact] public void Given_Features_When_Combine_Then_FeaturesAreCombined() { @@ -254,5 +263,6 @@ public void Given_Features_When_Combine_Then_FeaturesAreCombined() Assert.True(combined.IsFeatureSet(Feature.OptionSupportLargeChannel, true)); Assert.True(combined.IsFeatureSet(Feature.GossipQueries, true)); } + #endregion } \ No newline at end of file diff --git a/test/NLightning.Infrastructure.Serialization.Tests/Node/FeatureSetSerializerTests.cs b/test/NLightning.Infrastructure.Serialization.Tests/Node/FeatureSetSerializerTests.cs index 7286e600..c6f870c6 100644 --- a/test/NLightning.Infrastructure.Serialization.Tests/Node/FeatureSetSerializerTests.cs +++ b/test/NLightning.Infrastructure.Serialization.Tests/Node/FeatureSetSerializerTests.cs @@ -15,6 +15,7 @@ public FeatureSetSerializerTests() } #region Serialization + [Theory] [InlineData(Feature.OptionZeroconf, false, 7)] [InlineData(Feature.OptionZeroconf, true, 7)] @@ -24,13 +25,14 @@ public FeatureSetSerializerTests() [InlineData(Feature.OptionOnionMessages, true, 5)] [InlineData(Feature.OptionDualFund, false, 4)] [InlineData(Feature.OptionDualFund, true, 4)] - [InlineData(Feature.OptionAnchorsZeroFeeHtlcTx, false, 3)] - [InlineData(Feature.OptionAnchorsZeroFeeHtlcTx, true, 3)] + [InlineData(Feature.OptionAnchors, false, 3)] + [InlineData(Feature.OptionAnchors, true, 3)] [InlineData(Feature.OptionStaticRemoteKey, false, 2)] [InlineData(Feature.OptionStaticRemoteKey, true, 2)] [InlineData(Feature.GossipQueries, false, 1)] [InlineData(Feature.GossipQueries, true, 1)] - public async Task Given_Features_When_Serialize_Then_BytesAreTrimmed(Feature feature, bool isCompulsory, int expectedLength) + public async Task Given_Features_When_Serialize_Then_BytesAreTrimmed( + Feature feature, bool isCompulsory, int expectedLength) { // Arrange var features = new FeatureSet(); @@ -58,13 +60,14 @@ public async Task Given_Features_When_Serialize_Then_BytesAreTrimmed(Feature fea [InlineData(Feature.OptionOnionMessages, true, 5)] [InlineData(Feature.OptionDualFund, false, 4)] [InlineData(Feature.OptionDualFund, true, 4)] - [InlineData(Feature.OptionAnchorsZeroFeeHtlcTx, false, 3)] - [InlineData(Feature.OptionAnchorsZeroFeeHtlcTx, true, 3)] + [InlineData(Feature.OptionAnchors, false, 3)] + [InlineData(Feature.OptionAnchors, true, 3)] [InlineData(Feature.OptionStaticRemoteKey, false, 2)] [InlineData(Feature.OptionStaticRemoteKey, true, 2)] [InlineData(Feature.GossipQueries, false, 1)] [InlineData(Feature.GossipQueries, true, 1)] - public async Task Given_Features_When_SerializeWithoutLength_Then_LengthIsKnown(Feature feature, bool isCompulsory, int expectedLength) + public async Task Given_Features_When_SerializeWithoutLength_Then_LengthIsKnown( + Feature feature, bool isCompulsory, int expectedLength) { // Arrange var features = new FeatureSet(); @@ -91,13 +94,14 @@ public async Task Given_Features_When_SerializeWithoutLength_Then_LengthIsKnown( [InlineData(Feature.OptionOnionMessages, true, new byte[] { 64, 0, 0, 0, 0 })] [InlineData(Feature.OptionDualFund, false, new byte[] { 32, 0, 0, 0 })] [InlineData(Feature.OptionDualFund, true, new byte[] { 16, 0, 0, 0 })] - [InlineData(Feature.OptionAnchorsZeroFeeHtlcTx, false, new byte[] { 128, 32, 0 })] - [InlineData(Feature.OptionAnchorsZeroFeeHtlcTx, true, new byte[] { 64, 16, 0 })] + [InlineData(Feature.OptionAnchors, false, new byte[] { 128, 32, 0 })] + [InlineData(Feature.OptionAnchors, true, new byte[] { 64, 16, 0 })] [InlineData(Feature.OptionStaticRemoteKey, false, new byte[] { 32, 0 })] [InlineData(Feature.OptionStaticRemoteKey, true, new byte[] { 16, 0 })] [InlineData(Feature.GossipQueries, false, new byte[] { 128 })] [InlineData(Feature.GossipQueries, true, new byte[] { 64 })] - public async Task Given_Features_When_Serialize_Then_BytesAreKnown(Feature feature, bool isCompulsory, byte[] expected) + public async Task Given_Features_When_Serialize_Then_BytesAreKnown(Feature feature, bool isCompulsory, + byte[] expected) { // Arrange var features = new FeatureSet(); @@ -154,9 +158,11 @@ public async Task Given_Features_When_SerializeAsGlobal_Then_NoFeaturesGreaterTh // Assert Assert.Equal(2, bytes.Length); } + #endregion #region Deserialization + [Theory] [InlineData(new byte[] { 0, 7, 8, 128, 0, 0, 0, 0, 0 }, false, Feature.OptionZeroconf)] [InlineData(new byte[] { 0, 7, 4, 64, 0, 0, 0, 0, 0 }, true, Feature.OptionZeroconf)] @@ -172,7 +178,8 @@ public async Task Given_Features_When_SerializeAsGlobal_Then_NoFeaturesGreaterTh [InlineData(new byte[] { 0, 2, 16, 0 }, true, Feature.OptionStaticRemoteKey)] [InlineData(new byte[] { 0, 1, 128 }, false, Feature.GossipQueries)] [InlineData(new byte[] { 0, 1, 64 }, true, Feature.GossipQueries)] - public async Task Given_Buffer_When_Deserialize_Then_FeatureIsSet(byte[] buffer, bool isCompulsory, Feature expected) + public async Task Given_Buffer_When_Deserialize_Then_FeatureIsSet(byte[] buffer, bool isCompulsory, + Feature expected) { // Arrange using var stream = new MemoryStream(buffer); @@ -212,5 +219,6 @@ public async Task Given_WrongLengthBuffer_When_Deserialize_Then_FeatureIsNotSet( Assert.False(features.IsFeatureSet(Feature.OptionZeroconf, false)); Assert.False(features.IsFeatureSet(Feature.OptionZeroconf, true)); } + #endregion } \ No newline at end of file diff --git a/test/NLightning.Integration.Tests/Docker/AbcNetworkTests.cs b/test/NLightning.Integration.Tests/Docker/AbcNetworkTests.cs index 78a32506..1d606d57 100644 --- a/test/NLightning.Integration.Tests/Docker/AbcNetworkTests.cs +++ b/test/NLightning.Integration.Tests/Docker/AbcNetworkTests.cs @@ -8,9 +8,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq.Protected; -using NLightning.Domain.Crypto.ValueObjects; -using NLightning.Domain.Node.Models; -using NLightning.Infrastructure.Persistence.Contexts; using NLightning.Tests.Utils; using ServiceStack; using ServiceStack.Text; @@ -24,8 +21,9 @@ namespace NLightning.Integration.Tests.Docker; using Domain.Channels.Factories; using Domain.Channels.Interfaces; using Domain.Crypto.Hashes; -using Domain.Enums; +using Domain.Crypto.ValueObjects; using Domain.Node.Interfaces; +using Domain.Node.Models; using Domain.Node.Options; using Domain.Node.ValueObjects; using Domain.Protocol.Constants; @@ -38,6 +36,7 @@ namespace NLightning.Integration.Tests.Docker; using Infrastructure.Bitcoin.Options; using Infrastructure.Bitcoin.Signers; using Infrastructure.Persistence; +using Infrastructure.Persistence.Contexts; using Infrastructure.Repositories; using Infrastructure.Serialization; using Mock; @@ -145,10 +144,7 @@ public AbcNetworkTests(LightningRegtestNetworkFixture fixture, ITestOutputHelper { options.Features = new FeatureOptions { - ChainHashes = [ChainConstants.Regtest], - DataLossProtect = FeatureSupport.Optional, - StaticRemoteKey = FeatureSupport.Optional, - PaymentSecret = FeatureSupport.Optional + ChainHashes = [ChainConstants.Regtest] }; options.ListenAddresses = [$"{IPAddress.Loopback}:{_port}"]; options.BitcoinNetwork = BitcoinNetwork.Regtest; From fa27dbe61bf2768e1160428a7b013b066dc9d801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADckolas=20Goline?= Date: Sat, 14 Mar 2026 00:58:44 -0300 Subject: [PATCH 12/20] enhance `BitcoinWalletService` address watchin capabilities --- .../Wallet/BitcoinWalletService.cs | 14 ++++++++++++-- .../Wallet/Interfaces/IBlockchainMonitor.cs | 1 + 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinWalletService.cs b/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinWalletService.cs index e636e670..831be63c 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinWalletService.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinWalletService.cs @@ -14,19 +14,23 @@ namespace NLightning.Infrastructure.Bitcoin.Wallet; public class BitcoinWalletService : IBitcoinWalletService { + private readonly IBlockchainMonitor _blockchainMonitor; private readonly ILogger _logger; private readonly Network _network; private readonly ISecureKeyManager _secureKeyManager; private readonly IUnitOfWork _uow; - public BitcoinWalletService(ILogger logger, IOptions nodeOptions, - ISecureKeyManager secureKeyManager, IUnitOfWork uow) + public BitcoinWalletService(IBlockchainMonitor blockchainMonitor, ILogger logger, + IOptions nodeOptions, ISecureKeyManager secureKeyManager, + IUnitOfWork uow) { + _blockchainMonitor = blockchainMonitor; _logger = logger; _secureKeyManager = secureKeyManager; _uow = uow; _network = Network.GetNetwork(nodeOptions.Value.BitcoinNetwork) ?? Network.Main; + _logger.LogInformation("BitcoinWalletService network: {Network} (config: {ConfigNetwork})", _network, nodeOptions.Value.BitcoinNetwork); } public async Task GetUnusedAddressAsync(AddressType addressType, bool isChange) @@ -73,6 +77,12 @@ public async Task GetUnusedAddressAsync(AddressType addressT _uow.WalletAddressesDbRepository.AddRange(addressList); await _uow.SaveChangesAsync(); + // Register all newly generated addresses with blockchain monitor + foreach (var address in addressList) + { + _blockchainMonitor.WatchBitcoinAddress(address); + } + return addressList[0]; } } \ No newline at end of file diff --git a/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBlockchainMonitor.cs b/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBlockchainMonitor.cs index 47817b7c..08dfcf54 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBlockchainMonitor.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBlockchainMonitor.cs @@ -10,6 +10,7 @@ public interface IBlockchainMonitor uint LastProcessedBlockHeight { get; } event EventHandler OnNewBlockDetected; event EventHandler OnTransactionConfirmed; + event EventHandler? OnWalletMovementDetected; Task PublishAndWatchTransactionAsync(ChannelId channelId, SignedTransaction signedTransaction, uint requiredDepth); Task WatchTransactionAsync(ChannelId channelId, TxId txId, uint requiredDepth); From 43f66c0cc70d1e3a57bf503a6bdec2e3f6121f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADckolas=20Goline?= Date: Sat, 14 Mar 2026 01:01:45 -0300 Subject: [PATCH 13/20] improve peer exception handling and messaging; refactor `AcceptChannel1Handler` to comply with ChannelTypeTlv; implement `FundingSigned` on `ChannelManager`; --- .../Handlers/AcceptChannel1MessageHandler.cs | 4 +- .../Channels/Managers/ChannelManager.cs | 10 ++ .../Node/Managers/PeerManager.cs | 32 +++-- .../Interfaces/IPeerCommunicationService.cs | 20 ++- .../Node/Interfaces/IPeerManager.cs | 3 +- .../Node/Interfaces/IPeerService.cs | 23 ++- .../Node/Services/PeerCommunicationService.cs | 136 ++++++++++++------ .../Node/Services/PeerService.cs | 85 ++++++++--- .../Node/Managers/PeerManagerTests.cs | 10 +- .../Node/FeatureSetSerializerTests.cs | 4 +- 10 files changed, 239 insertions(+), 88 deletions(-) diff --git a/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs b/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs index 0733e57e..f16fe831 100644 --- a/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs +++ b/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs @@ -96,8 +96,8 @@ public AcceptChannel1MessageHandler(IBitcoinWalletService bitcoinWalletService, throw new ChannelErrorException("Temporary channel not found", payload.ChannelId); // Check if the channel type was negotiated and the channel type is present - if (message.ChannelTypeTlv is not null && negotiatedFeatures.ChannelType == FeatureSupport.Compulsory) - throw new ChannelErrorException("Channel type was negotiated but not provided"); + if (message.ChannelTypeTlv is null) + throw new ChannelErrorException("Channel type was not provided"); // Perform optional checks for the channel _channelOpenValidator.PerformOptionalChecks( diff --git a/src/NLightning.Application/Channels/Managers/ChannelManager.cs b/src/NLightning.Application/Channels/Managers/ChannelManager.cs index d8ae069b..71d2998a 100644 --- a/src/NLightning.Application/Channels/Managers/ChannelManager.cs +++ b/src/NLightning.Application/Channels/Managers/ChannelManager.cs @@ -120,6 +120,16 @@ public Task RegisterExistingChannelAsync(ChannelModel channel) "Sorry, we had an internal error"); return await GetChannelMessageHandler(scope) .HandleAsync(channelReadyMessage, currentState, negotiatedFeatures, peerPubKey); + + case MessageTypes.FundingSigned: + // Handle funding signed message + var fundingSignedMessage = message as FundingSignedMessage + ?? throw new ChannelErrorException( + "Error boxing message to FundingSignedMessage", + "Sorry, we had an internal error"); + return await GetChannelMessageHandler(scope) + .HandleAsync(fundingSignedMessage, currentState, negotiatedFeatures, peerPubKey); + default: throw new ChannelErrorException("Unknown message type", "Sorry, we had an internal error"); } diff --git a/src/NLightning.Application/Node/Managers/PeerManager.cs b/src/NLightning.Application/Node/Managers/PeerManager.cs index 368885d8..5b641da9 100644 --- a/src/NLightning.Application/Node/Managers/PeerManager.cs +++ b/src/NLightning.Application/Node/Managers/PeerManager.cs @@ -143,18 +143,17 @@ public async Task ConnectToPeerAsync(PeerAddressInfo peerAddressInfo) } /// - public void DisconnectPeer(CompactPubKey pubKey) + public void DisconnectPeer(CompactPubKey pubKey, Exception? exception = null) { if (_peers.TryGetValue(pubKey, out var peer)) { - if (peer.TryGetPeerService(out var peerService)) - { - peerService.Disconnect(); - } - else + if (!peer.TryGetPeerService(out var peerService)) { _logger.LogWarning("PeerService not found for {Peer}", pubKey); + return; } + + DisconnectPeer(peerService, exception); } else { @@ -172,6 +171,11 @@ public List ListPeers() return _peers.GetValueOrDefault(peerId); } + private static void DisconnectPeer(IPeerService peerService, Exception? exception = null) + { + peerService.Disconnect(exception); + } + private async Task ConnectToPeerAsync(PeerAddressInfo peerAddressInfo, IUnitOfWork uow) { // Convert and validate the address @@ -272,6 +276,7 @@ private void HandlePeerDisconnection(object? sender, PeerDisconnectedEventArgs a { peerService.OnDisconnect -= HandlePeerDisconnection; peerService.OnChannelMessageReceived -= HandlePeerChannelMessage; + peerService.Dispose(); } else { @@ -317,7 +322,7 @@ private async Task HandleChannelMessageResponseAsync(Task task ? cee.PeerMessage : cee.Message); - DisconnectPeer(peerService.PeerPubKey); + DisconnectPeer(peerService, cee); return; } @@ -330,6 +335,17 @@ private async Task HandleChannelMessageResponseAsync(Task task ? cwe.PeerMessage : cwe.Message); + _ = peerService.SendWarningAsync(cwe) + .ContinueWith(warningTask => + { + if (warningTask.IsFaulted) + { + _logger.LogError(warningTask.Exception, + "Failed to send warning message to peer {Peer}", + peerService.PeerPubKey); + } + }, TaskContinuationOptions.OnlyOnFaulted); + return; } @@ -337,7 +353,7 @@ private async Task HandleChannelMessageResponseAsync(Task task task.Exception, "Error handling channel message ({messageType}) from peer {peer}", Enum.GetName(messageType), peerService.PeerPubKey); - DisconnectPeer(peerService.PeerPubKey); + DisconnectPeer(peerService); return; } diff --git a/src/NLightning.Domain/Node/Interfaces/IPeerCommunicationService.cs b/src/NLightning.Domain/Node/Interfaces/IPeerCommunicationService.cs index d4adcbef..4afcb5de 100644 --- a/src/NLightning.Domain/Node/Interfaces/IPeerCommunicationService.cs +++ b/src/NLightning.Domain/Node/Interfaces/IPeerCommunicationService.cs @@ -1,12 +1,13 @@ -using NLightning.Domain.Crypto.ValueObjects; -using NLightning.Domain.Protocol.Interfaces; - namespace NLightning.Domain.Node.Interfaces; +using Crypto.ValueObjects; +using Exceptions; +using Protocol.Interfaces; + /// /// Interface for communication with a single peer. /// -public interface IPeerCommunicationService +public interface IPeerCommunicationService : IDisposable { /// /// Gets a value indicating whether the connection is established. @@ -41,6 +42,14 @@ public interface IPeerCommunicationService /// A task that represents the asynchronous operation. Task SendMessageAsync(IMessage message, CancellationToken cancellationToken = default); + /// + /// Sends a warning message to the peer. + /// + /// The warning exception to send. + /// The cancellation token. + /// A task that represents the asynchronous operation. + Task SendWarningAsync(WarningException we, CancellationToken cancellationToken = default); + /// /// Initializes the communication with the peer. /// @@ -51,5 +60,6 @@ public interface IPeerCommunicationService /// /// Disconnects from the peer. /// - void Disconnect(); + /// The exception that caused the disconnection, if any. + void Disconnect(Exception? exception = null); } \ No newline at end of file diff --git a/src/NLightning.Domain/Node/Interfaces/IPeerManager.cs b/src/NLightning.Domain/Node/Interfaces/IPeerManager.cs index aa0d9a6e..3510e804 100644 --- a/src/NLightning.Domain/Node/Interfaces/IPeerManager.cs +++ b/src/NLightning.Domain/Node/Interfaces/IPeerManager.cs @@ -33,7 +33,8 @@ public interface IPeerManager /// Disconnects a peer. /// /// CompactPubKey of the peer - void DisconnectPeer(CompactPubKey compactPubKey); + /// Optional exception that caused the disconnection + void DisconnectPeer(CompactPubKey compactPubKey, Exception? exception = null); List ListPeers(); PeerModel? GetPeer(CompactPubKey peerId); diff --git a/src/NLightning.Domain/Node/Interfaces/IPeerService.cs b/src/NLightning.Domain/Node/Interfaces/IPeerService.cs index b71bc0f6..b290ac57 100644 --- a/src/NLightning.Domain/Node/Interfaces/IPeerService.cs +++ b/src/NLightning.Domain/Node/Interfaces/IPeerService.cs @@ -3,12 +3,13 @@ namespace NLightning.Domain.Node.Interfaces; using Crypto.ValueObjects; using Domain.Protocol.Interfaces; using Events; +using Exceptions; using Options; /// /// Interface for the peer application service. /// -public interface IPeerService +public interface IPeerService : IDisposable { /// /// Gets the peer's public key. @@ -30,13 +31,24 @@ public interface IPeerService /// event EventHandler OnChannelMessageReceived; + /// + /// Occurs when an Error or Warning message is received from the connected peer. + /// + event EventHandler? OnAttentionMessageReceived; + + /// + /// Occurs when an exception is raised during peer communication. + /// + event EventHandler? OnExceptionRaised; + public string? PreferredHost { get; } public ushort? PreferredPort { get; } /// /// Disconnects from the peer. /// - void Disconnect(); + /// The exception that caused the disconnection, if any. + void Disconnect(Exception? exception = null); /// /// Sends an asynchronous message to the peer. @@ -44,4 +56,11 @@ public interface IPeerService /// The message to be sent to the peer. /// A task that represents the asynchronous operation. Task SendMessageAsync(IChannelMessage replyMessage); + + /// + /// Sends a warning message to the peer. + /// + /// The warning exception containing the warning message to be sent to the peer. + /// A task that represents the asynchronous operation. + Task SendWarningAsync(WarningException we); } \ No newline at end of file diff --git a/src/NLightning.Infrastructure/Node/Services/PeerCommunicationService.cs b/src/NLightning.Infrastructure/Node/Services/PeerCommunicationService.cs index 8fbe020a..f6f0afc4 100644 --- a/src/NLightning.Infrastructure/Node/Services/PeerCommunicationService.cs +++ b/src/NLightning.Infrastructure/Node/Services/PeerCommunicationService.cs @@ -121,11 +121,35 @@ public async Task SendMessageAsync(IMessage message, CancellationToken cancellat } } + /// + public async Task SendWarningAsync(WarningException we, CancellationToken cancellationToken = default) + { + try + { + var message = we.Message; + ChannelId? channelId = null; + if (we is ChannelWarningException cwe) + { + message = cwe.PeerMessage ?? we.Message; + channelId = cwe.ChannelId; + } + + var warningMessage = _messageFactory.CreateWarningMessage(message, channelId); + await _messageService.SendMessageAsync(warningMessage, cancellationToken: cancellationToken); + } + catch (Exception ex) + { + RaiseException(new ConnectionException($"Failed to send message to peer {PeerCompactPubKey}", ex)); + } + } + /// - public void Disconnect() + public void Disconnect(Exception? exception = null) { try { + SendExceptionMessage(exception).GetAwaiter().GetResult(); + _ = _cts.CancelAsync(); _logger.LogTrace("Waiting for ping service to stop for peer {peer}", PeerCompactPubKey); _pingPongTcs.Task.Wait(TimeSpan.FromSeconds(5)); @@ -133,7 +157,6 @@ public void Disconnect() } finally { - _messageService.Dispose(); DisconnectEvent?.Invoke(this, EventArgs.Empty); } } @@ -216,51 +239,61 @@ private void HandleExceptionRaised(object? sender, Exception e) RaiseException(e); } - private void RaiseException(Exception exception) + private Task SendExceptionMessage(Exception? exception) { - var mustDisconnect = false; - if (exception is ErrorException errorException) + switch (exception) { - ChannelId? channelId = null; - var message = errorException.Message; - mustDisconnect = true; - - if (errorException is ChannelErrorException channelErrorException) - { - channelId = channelErrorException.ChannelId; - if (!string.IsNullOrWhiteSpace(channelErrorException.PeerMessage)) - message = channelErrorException.PeerMessage; - } - - if (errorException is not ConnectionException) - { - _logger.LogTrace("Sending error message to peer {peer}. ChannelId: {channelId}, Message: {message}", - PeerCompactPubKey, channelId, message); - - _ = Task.Run(() => _messageService.SendMessageAsync( - new ErrorMessage(new ErrorPayload(channelId, message)))); - - return; - } + case ConnectionException: + case null: + return Task.CompletedTask; + case ErrorException errorException: + { + ChannelId? channelId = null; + var message = errorException.Message; + + if (errorException is ChannelErrorException channelErrorException) + { + channelId = channelErrorException.ChannelId; + if (!string.IsNullOrWhiteSpace(channelErrorException.PeerMessage)) + message = channelErrorException.PeerMessage; + } + + _logger.LogTrace("Sending error message to peer {peer}. ChannelId: {channelId}, Message: {message}", + PeerCompactPubKey, channelId, message); + + return _messageService.SendMessageAsync( + new ErrorMessage(new ErrorPayload(channelId, message))); + } + case WarningException warningException: + { + ChannelId? channelId = null; + var message = warningException.Message; + + if (warningException is ChannelWarningException channelWarningException) + { + channelId = channelWarningException.ChannelId; + if (!string.IsNullOrWhiteSpace(channelWarningException.PeerMessage)) + message = channelWarningException.PeerMessage; + } + + _logger.LogTrace("Sending warning message to peer {peer}. ChannelId: {channelId}, Message: {message}", + PeerCompactPubKey, channelId, message); + + return _messageService.SendMessageAsync( + new WarningMessage(new ErrorPayload(channelId, message))); + } + default: + return Task.CompletedTask; } - else if (exception is WarningException warningException) - { - ChannelId? channelId = null; - var message = warningException.Message; - - if (warningException is ChannelWarningException channelWarningException) - { - channelId = channelWarningException.ChannelId; - if (!string.IsNullOrWhiteSpace(channelWarningException.PeerMessage)) - message = channelWarningException.PeerMessage; - } + } - _logger.LogTrace("Sending warning message to peer {peer}. ChannelId: {channelId}, Message: {message}", - PeerCompactPubKey, channelId, message); + private void RaiseException(Exception exception) + { + var mustDisconnect = false; + if (exception is ErrorException) + mustDisconnect = true; - _ = Task.Run(() => _messageService.SendMessageAsync( - new WarningMessage(new ErrorPayload(channelId, message)))); - } + _ = Task.Run(() => SendExceptionMessage(exception)); // Forward the exception to subscribers ExceptionRaised?.Invoke(this, exception); @@ -268,12 +301,21 @@ private void RaiseException(Exception exception) // Disconnect if not already disconnecting if (mustDisconnect && !_cts.IsCancellationRequested) { - _messageService.OnMessageReceived -= HandleMessageReceived; - _messageService.OnExceptionRaised -= HandleExceptionRaised; - _pingPongService.DisconnectEvent -= HandleExceptionRaised; - - _logger.LogWarning(exception, "We're disconnecting peer {peer} because of an exception", PeerCompactPubKey); + _logger.LogWarning(exception, "We're disconnecting peer {peer} because of an exception", + PeerCompactPubKey); Disconnect(); } } + + public void Dispose() + { + // Unsubscribe from events + _messageService.OnMessageReceived -= HandleMessageReceived; + _messageService.OnExceptionRaised -= HandleExceptionRaised; + _pingPongService.DisconnectEvent -= HandleExceptionRaised; + + _cts.Dispose(); + _messageService.Dispose(); + _initWaitCancellationTokenSource?.Dispose(); + } } \ No newline at end of file diff --git a/src/NLightning.Infrastructure/Node/Services/PeerService.cs b/src/NLightning.Infrastructure/Node/Services/PeerService.cs index f6cd6d42..6c38e858 100644 --- a/src/NLightning.Infrastructure/Node/Services/PeerService.cs +++ b/src/NLightning.Infrastructure/Node/Services/PeerService.cs @@ -1,8 +1,10 @@ using System.Net; +using System.Text.Unicode; using Microsoft.Extensions.Logging; namespace NLightning.Infrastructure.Node.Services; +using Domain.Channels.ValueObjects; using Domain.Crypto.ValueObjects; using Domain.Exceptions; using Domain.Node.Events; @@ -29,6 +31,12 @@ public sealed class PeerService : IPeerService /// public event EventHandler? OnChannelMessageReceived; + /// + public event EventHandler? OnAttentionMessageReceived; + + /// + public event EventHandler? OnExceptionRaised; + /// public CompactPubKey PeerPubKey => _peerCommunicationService.PeerCompactPubKey; @@ -63,10 +71,6 @@ public PeerService(IPeerCommunicationService peerCommunicationService, FeatureOp } catch (Exception e) { - _peerCommunicationService.MessageReceived -= HandleMessage; - _peerCommunicationService.ExceptionRaised -= HandleException; - _peerCommunicationService.DisconnectEvent -= HandleDisconnection; - throw new ErrorException("Error initializing peer communication", e); } } @@ -74,10 +78,10 @@ public PeerService(IPeerCommunicationService peerCommunicationService, FeatureOp /// /// Disconnects from the peer. /// - public void Disconnect() + public void Disconnect(Exception? exception = null) { _logger.LogInformation("Disconnecting peer {peer}", PeerPubKey); - _peerCommunicationService.Disconnect(); + _peerCommunicationService.Disconnect(exception); } public Task SendMessageAsync(IChannelMessage replyMessage) @@ -85,6 +89,11 @@ public Task SendMessageAsync(IChannelMessage replyMessage) return _peerCommunicationService.SendMessageAsync(replyMessage); } + public Task SendWarningAsync(WarningException we) + { + return _peerCommunicationService.SendWarningAsync(we); + } + /// /// Handles messages received from the peer. /// @@ -99,8 +108,54 @@ private void HandleMessage(object? sender, IMessage? message) } else if (message is IChannelMessage channelMessage) { - // Handle channel-related messages - HandleChannelMessage(channelMessage); + _logger.LogTrace("Received channel message ({messageType}) from peer {peer}", + Enum.GetName(message.Type), PeerPubKey); + + OnChannelMessageReceived?.Invoke(this, new ChannelMessageEventArgs(channelMessage, PeerPubKey)); + } + else if (message is ErrorMessage errorMessage) + { + var errorMessageString = string.Empty; + ChannelId? channelId = null; + if (errorMessage.Payload.ChannelId != ChannelId.Zero) + channelId = errorMessage.Payload.ChannelId; + + if (errorMessage.Payload.Data is not null) + { + // Try to get utf8 string from error data + errorMessageString = Utf8.IsValid(errorMessage.Payload.Data) + ? System.Text.Encoding.UTF8.GetString(errorMessage.Payload.Data) + : Convert.ToHexStringLower(errorMessage.Payload.Data); + + _logger.LogError( + "Received error message from peer {peer} for channel {channelId}: {errorMessage}", + PeerPubKey, channelId is null ? "" : channelId.ToString(), errorMessageString); + } + + OnAttentionMessageReceived?.Invoke( + this, new AttentionMessageEventArgs(errorMessageString, PeerPubKey, channelId)); + } + else if (message is WarningMessage warningMessage) + { + var warningMessageString = string.Empty; + ChannelId? channelId = null; + if (warningMessage.Payload.ChannelId != ChannelId.Zero) + channelId = warningMessage.Payload.ChannelId; + + if (warningMessage.Payload.Data is not null) + { + // Try to get utf8 string from error data + warningMessageString = Utf8.IsValid(warningMessage.Payload.Data) + ? System.Text.Encoding.UTF8.GetString(warningMessage.Payload.Data) + : Convert.ToHexStringLower(warningMessage.Payload.Data); + + _logger.LogError( + "Received error message from peer {peer} for channel {channelId}: {errorMessage}", + PeerPubKey, channelId is null ? "" : channelId.ToString(), warningMessageString); + } + + OnAttentionMessageReceived?.Invoke( + this, new AttentionMessageEventArgs(warningMessageString, PeerPubKey, channelId)); } } @@ -110,6 +165,7 @@ private void HandleMessage(object? sender, IMessage? message) private void HandleException(object? sender, Exception e) { _logger.LogError(e, "Exception occurred with peer {peer}", PeerPubKey); + OnExceptionRaised?.Invoke(this, e); } private void HandleDisconnection(object? sender, EventArgs e) @@ -185,14 +241,11 @@ private void HandleInitialization(IMessage message) _isInitialized = true; } - /// - /// Handles channel messages. - /// - private void HandleChannelMessage(IChannelMessage message) + public void Dispose() { - _logger.LogTrace("Received channel message ({messageType}) from peer {peer}", - Enum.GetName(message.Type), PeerPubKey); - - OnChannelMessageReceived?.Invoke(this, new ChannelMessageEventArgs(message, PeerPubKey)); + _peerCommunicationService.MessageReceived -= HandleMessage; + _peerCommunicationService.ExceptionRaised -= HandleException; + _peerCommunicationService.DisconnectEvent -= HandleDisconnection; + _peerCommunicationService.Dispose(); } } \ No newline at end of file diff --git a/test/NLightning.Application.Tests/Node/Managers/PeerManagerTests.cs b/test/NLightning.Application.Tests/Node/Managers/PeerManagerTests.cs index 3a799794..7dae0a1e 100644 --- a/test/NLightning.Application.Tests/Node/Managers/PeerManagerTests.cs +++ b/test/NLightning.Application.Tests/Node/Managers/PeerManagerTests.cs @@ -226,7 +226,7 @@ public void Given_ExistingPeer_When_DisconnectPeer_IsCalled_Then_PeerIsDisconnec peerManager.DisconnectPeer(_compactPubKey); // Then - _mockPeerService.Verify(p => p.Disconnect(), Times.Once); + _mockPeerService.Verify(p => p.Disconnect(null), Times.Once); } [Fact] @@ -342,7 +342,7 @@ public async Task Given_ChannelErrorException_When_ProcessingChannelMessage_Then await Task.Delay(100, TestContext.Current.CancellationToken); // Then - _mockPeerService.Verify(p => p.Disconnect(), Times.Once); + _mockPeerService.Verify(p => p.Disconnect(null), Times.Once); _mockLogger.Verify( l => l.Log( LogLevel.Error, @@ -383,7 +383,7 @@ public async Task await Task.Delay(100, TestContext.Current.CancellationToken); // Then - _mockPeerService.Verify(p => p.Disconnect(), Times.Never); + _mockPeerService.Verify(p => p.Disconnect(null), Times.Never); _mockLogger.Verify( l => l.Log( LogLevel.Warning, @@ -436,14 +436,14 @@ public async Task Given_StopAsync_When_Called_Then_AllPeersAreDisconnectedAndSer var peers = GetPeersFromManager(peerManager); peers.Add(_compactPubKey, _mockPeerModel); var taskCompletionSource = new TaskCompletionSource(); - _mockPeerService.Setup(x => x.Disconnect()).Callback(taskCompletionSource.SetResult); + _mockPeerService.Setup(x => x.Disconnect(null)).Callback(taskCompletionSource.SetResult); // When _ = peerManager.StopAsync(); await taskCompletionSource.Task; // Then - _mockPeerService.Verify(p => p.Disconnect(), Times.Once); + _mockPeerService.Verify(p => p.Disconnect(null), Times.Once); } [Fact] diff --git a/test/NLightning.Infrastructure.Serialization.Tests/Node/FeatureSetSerializerTests.cs b/test/NLightning.Infrastructure.Serialization.Tests/Node/FeatureSetSerializerTests.cs index c6f870c6..4e787f4b 100644 --- a/test/NLightning.Infrastructure.Serialization.Tests/Node/FeatureSetSerializerTests.cs +++ b/test/NLightning.Infrastructure.Serialization.Tests/Node/FeatureSetSerializerTests.cs @@ -172,8 +172,8 @@ public async Task Given_Features_When_SerializeAsGlobal_Then_NoFeaturesGreaterTh [InlineData(new byte[] { 0, 5, 64, 0, 0, 0, 0 }, true, Feature.OptionOnionMessages)] [InlineData(new byte[] { 0, 4, 32, 0, 0, 0 }, false, Feature.OptionDualFund)] [InlineData(new byte[] { 0, 4, 16, 0, 0, 0 }, true, Feature.OptionDualFund)] - [InlineData(new byte[] { 0, 3, 128, 32, 0 }, false, Feature.OptionAnchorsZeroFeeHtlcTx)] - [InlineData(new byte[] { 0, 3, 64, 16, 0 }, true, Feature.OptionAnchorsZeroFeeHtlcTx)] + [InlineData(new byte[] { 0, 3, 128, 32, 0 }, false, Feature.OptionAnchors)] + [InlineData(new byte[] { 0, 3, 64, 16, 0 }, true, Feature.OptionAnchors)] [InlineData(new byte[] { 0, 2, 32, 0 }, false, Feature.OptionStaticRemoteKey)] [InlineData(new byte[] { 0, 2, 16, 0 }, true, Feature.OptionStaticRemoteKey)] [InlineData(new byte[] { 0, 1, 128 }, false, Feature.GossipQueries)] From 0849710ce0343b6ef326d471177b5420ba0b3e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADckolas=20Goline?= Date: Sun, 15 Mar 2026 02:11:16 -0300 Subject: [PATCH 14/20] refactor OpenChannel flow; improve UTXO handling; update tests and dependencies; fix LocalLightningSigner signature logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Níckolas Goline --- .../Handlers/AcceptChannel1MessageHandler.cs | 3 + .../Handlers/FundingSignedMessageHandler.cs | 9 +-- .../Channels/Managers/ChannelManager.cs | 2 +- .../Node/Managers/PeerManager.cs | 2 - .../Printers/OpenChannelPrinter.cs | 3 +- src/NLightning.Daemon/AssemblyInfo.cs | 3 +- .../Handlers/OpenChannelClientHandler.cs | 79 +++++++++++-------- .../Interfaces/IUtxoMemoryRepository.cs | 1 + .../Responses/OpenChannelClientResponse.cs | 8 +- .../Signers/LocalLightningSigner.cs | 16 ++-- .../Memory/UtxoMemoryRepository.cs | 26 +++++- .../Responses/OpenChannelIpcResponse.cs | 4 +- ...Bolt3TestCommitmentKeyDerivationService.cs | 5 +- .../Channels/ChannelOpeningFlowTests.cs | 22 +++--- 14 files changed, 110 insertions(+), 73 deletions(-) diff --git a/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs b/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs index f16fe831..eac26410 100644 --- a/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs +++ b/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs @@ -215,6 +215,9 @@ public AcceptChannel1MessageHandler(IBitcoinWalletService bitcoinWalletService, // Remove the temporary channel _channelMemoryRepository.RemoveTemporaryChannel(peerPubKey, oldChannelId); + // Update the locked utxos + _utxoMemoryRepository.UpgradeChannelIdOnLockedUtxos(oldChannelId, tempChannel.ChannelId); + return fundingCreatedMessage; } catch (Exception e) diff --git a/src/NLightning.Application/Channels/Handlers/FundingSignedMessageHandler.cs b/src/NLightning.Application/Channels/Handlers/FundingSignedMessageHandler.cs index 70dbc149..91005eda 100644 --- a/src/NLightning.Application/Channels/Handlers/FundingSignedMessageHandler.cs +++ b/src/NLightning.Application/Channels/Handlers/FundingSignedMessageHandler.cs @@ -113,12 +113,9 @@ private async Task PersistChannelAsync(ChannelModel channel) try { // Check if the channel already exists - var existingChannel = await _unitOfWork.ChannelDbRepository.GetByIdAsync(channel.ChannelId); - if (existingChannel is not null) - throw new ChannelWarningException("Channel already exists", channel.ChannelId, - "This channel is already in our database"); - - await _unitOfWork.ChannelDbRepository.AddAsync(channel); + var existingChannel = await _unitOfWork.ChannelDbRepository.GetByIdAsync(channel.ChannelId) ?? throw new ChannelWarningException("Channel not found", channel.ChannelId, + "This channel is missing in our database"); + await _unitOfWork.ChannelDbRepository.UpdateAsync(channel); await _unitOfWork.SaveChangesAsync(); _logger.LogDebug("Successfully persisted channel {ChannelId} to database", channel.ChannelId); diff --git a/src/NLightning.Application/Channels/Managers/ChannelManager.cs b/src/NLightning.Application/Channels/Managers/ChannelManager.cs index 71d2998a..760db410 100644 --- a/src/NLightning.Application/Channels/Managers/ChannelManager.cs +++ b/src/NLightning.Application/Channels/Managers/ChannelManager.cs @@ -294,7 +294,7 @@ private void HandleFundingConfirmationAsync(object? sender, TransactionConfirmed // Check if the transaction is a funding transaction for any channel if (!_channelMemoryRepository.TryGetChannel(channelId, out var channel)) { - // Channel not found in memory, check the database + // Channel isn't found in memory, check the database var uow = scope.ServiceProvider.GetRequiredService(); channel = uow.ChannelDbRepository.GetByIdAsync(channelId).GetAwaiter().GetResult(); if (channel is null) diff --git a/src/NLightning.Application/Node/Managers/PeerManager.cs b/src/NLightning.Application/Node/Managers/PeerManager.cs index 5b641da9..5da3e0fd 100644 --- a/src/NLightning.Application/Node/Managers/PeerManager.cs +++ b/src/NLightning.Application/Node/Managers/PeerManager.cs @@ -359,9 +359,7 @@ private async Task HandleChannelMessageResponseAsync(Task task var replyMessage = task.Result; if (replyMessage is not null) - { await peerService.SendMessageAsync(replyMessage); - } } private void HandleResponseMessageReady(object? sender, ChannelResponseMessageEventArgs args) diff --git a/src/NLightning.Client/Printers/OpenChannelPrinter.cs b/src/NLightning.Client/Printers/OpenChannelPrinter.cs index f5ea88e5..106f882d 100644 --- a/src/NLightning.Client/Printers/OpenChannelPrinter.cs +++ b/src/NLightning.Client/Printers/OpenChannelPrinter.cs @@ -7,8 +7,7 @@ public sealed class OpenChannelPrinter : IPrinter public void Print(OpenChannelIpcResponse item) { Console.WriteLine("Channel opened:"); - Console.WriteLine(" Tx Bytes: {0}", Convert.ToHexString(item.Transaction.RawTxBytes).ToLowerInvariant()); - Console.WriteLine(" Tx Id: {0}", Convert.ToHexString(item.Transaction.TxId).ToLowerInvariant()); + Console.WriteLine(" Tx Id: {0}", Convert.ToHexString(item.TxId).ToLowerInvariant()); Console.WriteLine(" Index: {0}", item.Index); Console.WriteLine(" ChannelId: {0}", item.ChannelId); } diff --git a/src/NLightning.Daemon/AssemblyInfo.cs b/src/NLightning.Daemon/AssemblyInfo.cs index 7836a882..0fdf52b3 100644 --- a/src/NLightning.Daemon/AssemblyInfo.cs +++ b/src/NLightning.Daemon/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("NLightning.Bolts.Tests")] \ No newline at end of file +[assembly: InternalsVisibleTo("NLightning.Bolts.Tests")] +[assembly: InternalsVisibleTo("NLightning.Integration.Tests")] \ No newline at end of file diff --git a/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs b/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs index ac15d793..b9bf3a71 100644 --- a/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs +++ b/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs @@ -37,17 +37,19 @@ public sealed class OpenChannelClientHandler private readonly IUnitOfWork _unitOfWork; private readonly IUtxoMemoryRepository _utxoMemoryRepository; + internal event EventHandler? OnWaitingConfirmation; + public ClientCommand Command => ClientCommand.OpenChannel; - public OpenChannelClientHandler(IBlockchainMonitor blockchainMonitor, - IChannelMemoryRepository channelMemoryRepository, IChannelFactory channelFactory, + public OpenChannelClientHandler(IBlockchainMonitor blockchainMonitor, IChannelFactory channelFactory, + IChannelMemoryRepository channelMemoryRepository, ILogger logger, IMessageFactory messageFactory, IPeerManager peerManager, IUnitOfWork unitOfWork, IUtxoMemoryRepository utxoMemoryRepository) { _blockchainMonitor = blockchainMonitor; - _channelMemoryRepository = channelMemoryRepository; _channelFactory = channelFactory; + _channelMemoryRepository = channelMemoryRepository; _logger = logger; _messageFactory = messageFactory; _peerManager = peerManager; @@ -87,7 +89,7 @@ public async Task HandleAsync(OpenChannelClientReques try { // Select UTXOs and mark them as toSpend for this channel - _ = _utxoMemoryRepository.LockUtxosToSpendOnChannel(request.FundingAmount, channel.ChannelId); + var utxos = _utxoMemoryRepository.LockUtxosToSpendOnChannel(request.FundingAmount, channel.ChannelId); // Add the channel to dictionaries _channelMemoryRepository.AddTemporaryChannel(peerId, channel); @@ -103,8 +105,9 @@ public async Task HandleAsync(OpenChannelClientReques if (channel.ChannelConfig.MinimumDepth == 0) channelTypeFeatureSet.SetFeature(Feature.OptionZeroconf, true); - var featureSetBytes = channelTypeFeatureSet.GetBytes() ?? throw new ClientException(ErrorCodes.InvalidOperation, - $"Error creating {nameof(ChannelTypeTlv)}. This should never happen."); + var featureSetBytes = channelTypeFeatureSet.GetBytes() ?? throw new ClientException( + ErrorCodes.InvalidOperation, + $"Error creating {nameof(ChannelTypeTlv)}. This should never happen."); var channelTypeTlv = new ChannelTypeTlv(featureSetBytes); // Create UpfrontShutdownScriptTlv if needed @@ -165,25 +168,17 @@ public async Task HandleAsync(OpenChannelClientReques return response; // Envelopes for the events - void ChannelMessageHandlerEnvelope(object? _, ChannelMessageEventArgs args) - { + void ChannelMessageHandlerEnvelope(object? _, ChannelMessageEventArgs args) => HandleChannelMessage(args, channel.ChannelId, tsc); - } - void AttentionMessageHandlerEnvelope(object? _, AttentionMessageEventArgs args) - { + void AttentionMessageHandlerEnvelope(object? _, AttentionMessageEventArgs args) => HandleAttentionMessage(args, channel.ChannelId, tsc); - } - void PeerDisconnectionEnvelope(object? _, PeerDisconnectedEventArgs args) - { + void PeerDisconnectionEnvelope(object? _, PeerDisconnectedEventArgs args) => HandlePeerDisconnection(args, channel.ChannelId, tsc); - } - void ExceptionRaisedEnvelope(object? _, Exception e) - { + void ExceptionRaisedEnvelope(object? _, Exception e) => HandleExceptionRaised(e, channel.ChannelId, tsc); - } } catch { @@ -199,20 +194,40 @@ void ExceptionRaisedEnvelope(object? _, Exception e) } } - private static void HandleChannelMessage(ChannelMessageEventArgs args, ChannelId _, - TaskCompletionSource __) + private void HandleChannelMessage(ChannelMessageEventArgs args, ChannelId _, + TaskCompletionSource tsc) { - if (args.Message.Type == MessageTypes.AcceptChannel) - { - Console.WriteLine("Channel accepted"); - } - else if (args.Message.Type == MessageTypes.FundingSigned) - { - Console.WriteLine("Funding signed"); - } - else + // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault + switch (args.Message.Type) { - Console.WriteLine("Unknown message type: {0}", Enum.GetName(args.Message.Type)); + case MessageTypes.AcceptChannel: + Console.WriteLine("Channel accepted"); + break; + case MessageTypes.FundingSigned: + Console.WriteLine("Funding signed"); + OnWaitingConfirmation?.Invoke(this, EventArgs.Empty); + break; + case MessageTypes.ChannelReady: + { + Console.WriteLine("Channel ready"); + if (_channelMemoryRepository.TryGetChannel(args.Message.Payload.ChannelId, out var channel) + && channel.FundingOutput?.TransactionId is not null + && channel.FundingOutput?.Index is not null) + { + tsc.TrySetResult(new OpenChannelClientResponse(channel.FundingOutput.TransactionId.Value, + channel.FundingOutput.Index.Value, + channel.ChannelId)); + } + else + { + Console.Error.WriteLine("Channel not found in memory repository"); + } + + break; + } + default: + Console.WriteLine("Unknown message type: {0}", Enum.GetName(args.Message.Type)); + break; } } @@ -223,11 +238,11 @@ private static void HandleAttentionMessage(AttentionMessageEventArgs args, Chann tsc.TrySetException(new ChannelErrorException($"Error opening channel: {args.Message}")); } - private static void HandlePeerDisconnection(PeerDisconnectedEventArgs args, ChannelId _, + private static void HandlePeerDisconnection(PeerDisconnectedEventArgs _, ChannelId __, TaskCompletionSource tsc) { Console.Error.WriteLine("Peer disconnected"); - tsc.TrySetException(new ChannelErrorException("Error opening channel: Peer disconnected")); + // tsc.TrySetException(new ChannelErrorException("Error opening channel: Peer disconnected")); } private static void HandleExceptionRaised(Exception e, ChannelId _, diff --git a/src/NLightning.Domain/Bitcoin/Interfaces/IUtxoMemoryRepository.cs b/src/NLightning.Domain/Bitcoin/Interfaces/IUtxoMemoryRepository.cs index bbd9934d..a39f947a 100644 --- a/src/NLightning.Domain/Bitcoin/Interfaces/IUtxoMemoryRepository.cs +++ b/src/NLightning.Domain/Bitcoin/Interfaces/IUtxoMemoryRepository.cs @@ -20,4 +20,5 @@ public interface IUtxoMemoryRepository List GetLockedUtxosForChannel(ChannelId channelId); List ReturnUtxosNotSpentOnChannel(ChannelId channelId); void ConfirmSpendOnChannel(ChannelId channelId); + void UpgradeChannelIdOnLockedUtxos(ChannelId oldChannelId, ChannelId newChannelId); } \ No newline at end of file diff --git a/src/NLightning.Domain/Client/Responses/OpenChannelClientResponse.cs b/src/NLightning.Domain/Client/Responses/OpenChannelClientResponse.cs index 027153e6..363c9e4e 100644 --- a/src/NLightning.Domain/Client/Responses/OpenChannelClientResponse.cs +++ b/src/NLightning.Domain/Client/Responses/OpenChannelClientResponse.cs @@ -5,13 +5,13 @@ namespace NLightning.Domain.Client.Responses; public sealed class OpenChannelClientResponse { - public SignedTransaction Transaction { get; } - public uint Index { get; } + public TxId TxId { get; } + public ushort Index { get; } public ChannelId ChannelId { get; } - public OpenChannelClientResponse(SignedTransaction transaction, uint index, ChannelId channelId) + public OpenChannelClientResponse(TxId txId, ushort index, ChannelId channelId) { - Transaction = transaction; + TxId = txId; Index = index; ChannelId = channelId; } diff --git a/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs b/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs index 8bf8b755..49a5f26c 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs @@ -272,7 +272,7 @@ public bool SignFundingTransaction(ChannelId channelId, SignedTransaction unsign try { - // Create the scriptPubKey and previous output based on address type + // Create the scriptPubKey and previous output based on the address type Script scriptPubKey; ExtPrivKey signingExtKey; Key signingKey; @@ -357,8 +357,7 @@ public bool SignFundingTransaction(ChannelId channelId, SignedTransaction unsign throw new SignerException("No inputs were successfully signed", channelId, "Signing failed"); // Update the transaction bytes in the SignedTransaction - var signedBytes = nBitcoinTx.ToBytes(); - Array.Copy(signedBytes, unsignedTransaction.RawTxBytes, signedBytes.Length); + unsignedTransaction.RawTxBytes = nBitcoinTx.ToBytes(); _logger.LogInformation( "Successfully signed {SignedCount}/{TotalCount} inputs for funding transaction {TxId}", @@ -514,18 +513,21 @@ private static Key GenerateFundingPrivateKey(ExtKey extKey) /// /// Sign a P2WPKH (Pay-to-Witness-PubKey-Hash) input /// - private void SignP2WpkhInput(Transaction tx, int inputIndex, Key signingKey, TxOut prevOut) + private static void SignP2WpkhInput(Transaction tx, int inputIndex, Key signingKey, TxOut prevOut) { + // For P2WPKH, the scriptCode is the P2PKH script: OP_DUP OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG + var scriptCode = signingKey.PubKey.Hash.ScriptPubKey; + // Get the signature hash for SegWit v0 var sigHash = - tx.GetSignatureHash(prevOut.ScriptPubKey, inputIndex, SigHash.All, prevOut, HashVersion.WitnessV0); + tx.GetSignatureHash(scriptCode, inputIndex, SigHash.All, prevOut, HashVersion.WitnessV0); // Sign the hash - var signature = signingKey.Sign(sigHash, new SigningOptions(SigHash.All, false)); + var transactionSignature = signingKey.Sign(sigHash, new SigningOptions(SigHash.All, false)); // For P2WPKH, witness is: var witness = new WitScript( - Op.GetPushOp(signature.Signature.ToDER()), + Op.GetPushOp(transactionSignature.ToBytes()), Op.GetPushOp(signingKey.PubKey.ToBytes())); tx.Inputs[inputIndex].WitScript = witness; diff --git a/src/NLightning.Infrastructure.Repositories/Memory/UtxoMemoryRepository.cs b/src/NLightning.Infrastructure.Repositories/Memory/UtxoMemoryRepository.cs index ea033993..c42bf820 100644 --- a/src/NLightning.Infrastructure.Repositories/Memory/UtxoMemoryRepository.cs +++ b/src/NLightning.Infrastructure.Repositories/Memory/UtxoMemoryRepository.cs @@ -85,12 +85,15 @@ public List LockUtxosToSpendOnChannel(LightningMoney requestFundingAm public List GetLockedUtxosForChannel(ChannelId channelId) { - return _utxoSet.Values.Where(x => x.LockedToChannelId.HasValue && x.LockedToChannelId.Value.Equals(channelId)).ToList(); + return _utxoSet.Values.Where(x => x.LockedToChannelId.HasValue && x.LockedToChannelId.Value.Equals(channelId)) + .ToList(); } public List ReturnUtxosNotSpentOnChannel(ChannelId channelId) { - var utxos = _utxoSet.Values.Where(x => x.LockedToChannelId.HasValue && x.LockedToChannelId.Value.Equals(channelId)).ToList(); + var utxos = _utxoSet.Values + .Where(x => x.LockedToChannelId.HasValue && x.LockedToChannelId.Value.Equals(channelId)) + .ToList(); foreach (var utxo in utxos) { utxo.LockedToChannelId = null; @@ -102,11 +105,28 @@ public List ReturnUtxosNotSpentOnChannel(ChannelId channelId) public void ConfirmSpendOnChannel(ChannelId channelId) { - var utxos = _utxoSet.Values.Where(x => x.LockedToChannelId.HasValue && x.LockedToChannelId.Value.Equals(channelId)); + var utxos = _utxoSet.Values.Where(x => x.LockedToChannelId.HasValue && + x.LockedToChannelId.Value.Equals(channelId)); foreach (var utxo in utxos) _utxoSet.TryRemove((utxo.TxId, utxo.Index), out _); } + public void UpgradeChannelIdOnLockedUtxos(ChannelId oldChannelId, ChannelId newChannelId) + { + var utxos = _utxoSet.Values + .Where(x => x.LockedToChannelId.HasValue && x.LockedToChannelId.Value.Equals(oldChannelId)) + .ToList(); + // If there's no locked utxos, we have a problem + if (utxos.Count == 0) + throw new InvalidOperationException("No available UTXOs"); + + foreach (var utxo in utxos) + { + utxo.LockedToChannelId = newChannelId; + _utxoSet[(utxo.TxId, utxo.Index)] = utxo; + } + } + private static List? BranchAndBound(List utxos, LightningMoney targetAmount) { const int maxTries = 100_000; diff --git a/src/NLightning.Transport.Ipc/Responses/OpenChannelIpcResponse.cs b/src/NLightning.Transport.Ipc/Responses/OpenChannelIpcResponse.cs index fbcb395c..68861ebd 100644 --- a/src/NLightning.Transport.Ipc/Responses/OpenChannelIpcResponse.cs +++ b/src/NLightning.Transport.Ipc/Responses/OpenChannelIpcResponse.cs @@ -12,7 +12,7 @@ namespace NLightning.Transport.Ipc.Responses; [MessagePackObject] public sealed class OpenChannelIpcResponse { - [Key(0)] public required SignedTransaction Transaction { get; init; } + [Key(0)] public required TxId TxId { get; init; } [Key(2)] public uint Index { get; init; } [Key(3)] public ChannelId ChannelId { get; init; } @@ -20,7 +20,7 @@ public static OpenChannelIpcResponse FromClientResponse(OpenChannelClientRespons { return new OpenChannelIpcResponse { - Transaction = clientResponse.Transaction, + TxId = clientResponse.TxId, Index = clientResponse.Index, ChannelId = clientResponse.ChannelId }; diff --git a/test/NLightning.Integration.Tests/BOLT3/Mocks/Bolt3TestCommitmentKeyDerivationService.cs b/test/NLightning.Integration.Tests/BOLT3/Mocks/Bolt3TestCommitmentKeyDerivationService.cs index 2a5f3366..89643abe 100644 --- a/test/NLightning.Integration.Tests/BOLT3/Mocks/Bolt3TestCommitmentKeyDerivationService.cs +++ b/test/NLightning.Integration.Tests/BOLT3/Mocks/Bolt3TestCommitmentKeyDerivationService.cs @@ -27,10 +27,9 @@ public CommitmentKeys DeriveLocalCommitmentKeys(uint localChannelKeyIndex, Chann _emptyCompactPubKey, Secret.Empty); } - public CommitmentKeys DeriveRemoteCommitmentKeys(uint localChannelKeyIndex, ChannelBasepoints localBasepoints, + public CommitmentKeys DeriveRemoteCommitmentKeys(ChannelBasepoints localBasepoints, ChannelBasepoints remoteBasepoints, - CompactPubKey remotePerCommitmentPoint, - ulong commitmentNumber) + CompactPubKey remotePerCommitmentPoint) { throw new NotImplementedException(); } diff --git a/test/NLightning.Integration.Tests/Channels/ChannelOpeningFlowTests.cs b/test/NLightning.Integration.Tests/Channels/ChannelOpeningFlowTests.cs index e7caefa0..a7509977 100644 --- a/test/NLightning.Integration.Tests/Channels/ChannelOpeningFlowTests.cs +++ b/test/NLightning.Integration.Tests/Channels/ChannelOpeningFlowTests.cs @@ -206,9 +206,6 @@ public async Task OpenChannel_WaitForConfirmations_ChannelBecomesOpen() // Start the blockchain monitor at the current height await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); - // Wait a bit for initialization - // await Task.Delay(1000); - // Fund our wallet using (var scope = _serviceProvider.CreateScope()) { @@ -237,9 +234,7 @@ void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) void OnNewBlockDetected(object? _, NewBlockEventArgs e) { if (e.Height >= txFirstSeenInBlock + 5) - { tsc.TrySetResult(true); - } } _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; @@ -284,16 +279,23 @@ await bitcoin.SendToAddressAsync( $"Unable to get service {nameof(OpenChannelClientHandler)}"); var request = new OpenChannelClientRequest( aliceAddress, - LightningMoney.Satoshis(1000000) // 0.01 BTC - ); + LightningMoney.Satoshis(1000000) // 0.01 BTC, + ) + { + FeeRatePerKw = LightningMoney.Satoshis(10000) + }; + + // Subscribe to the event and mine blocks when needed + clientHandler.OnWaitingConfirmation += async (_, _) => + { + // Mine blocks to confirm + await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); + }; // Act - Open the channel (this should send open_channel and wait for the flow to complete) openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); } - // Mine blocks to confirm - await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); - var channelResponse = await openChannelTask; Assert.NotNull(channelResponse); From a562fb7b4ff88e74906fe31f4574b8528ea7c737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADckolas=20Goline?= Date: Sun, 15 Mar 2026 02:16:26 -0300 Subject: [PATCH 15/20] refactor OpenChannel flow; improve UTXO handling; update tests and dependencies; fix LocalLightningSigner signature logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Níckolas Goline --- .../Node/Services/PeerService.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/NLightning.Infrastructure/Node/Services/PeerService.cs b/src/NLightning.Infrastructure/Node/Services/PeerService.cs index 6c38e858..e65ab922 100644 --- a/src/NLightning.Infrastructure/Node/Services/PeerService.cs +++ b/src/NLightning.Infrastructure/Node/Services/PeerService.cs @@ -125,7 +125,11 @@ private void HandleMessage(object? sender, IMessage? message) // Try to get utf8 string from error data errorMessageString = Utf8.IsValid(errorMessage.Payload.Data) ? System.Text.Encoding.UTF8.GetString(errorMessage.Payload.Data) +#if NET9_0_OR_GREATER : Convert.ToHexStringLower(errorMessage.Payload.Data); +#else + : Convert.ToHexString(errorMessage.Payload.Data).ToLowerInvariant(); +#endif _logger.LogError( "Received error message from peer {peer} for channel {channelId}: {errorMessage}", @@ -147,7 +151,11 @@ private void HandleMessage(object? sender, IMessage? message) // Try to get utf8 string from error data warningMessageString = Utf8.IsValid(warningMessage.Payload.Data) ? System.Text.Encoding.UTF8.GetString(warningMessage.Payload.Data) +#if NET9_0_OR_GREATER : Convert.ToHexStringLower(warningMessage.Payload.Data); +#else + : Convert.ToHexString(warningMessage.Payload.Data).ToLowerInvariant(); +#endif _logger.LogError( "Received error message from peer {peer} for channel {channelId}: {errorMessage}", From fad83cc752a0ec88279c8984ab6ec840cc042331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADckolas=20Goline?= Date: Mon, 16 Mar 2026 14:51:19 -0300 Subject: [PATCH 16/20] add more tests and update failing tests to new rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Níckolas Goline --- .../Extensions/NodeConfigurationExtensions.cs | 1 - src/NLightning.Daemon/Program.cs | 6 +- src/NLightning.Domain/Enums/Feature.cs | 8 +- src/NLightning.Domain/Node/FeatureSet.cs | 4 - .../Signers/LocalLightningSigner.cs | 31 +- .../OpenChannel1MessageHandlerTests.cs | 7 +- .../Node/Managers/PeerManagerTests.cs | 2 +- .../Models/TaggedFieldListTests.cs | 6 +- .../Messages/InitMessageTests.cs | 12 +- .../Node/FeatureSetSerializerTests.cs | 77 +- .../Channels/ChannelOpeningFlowTests.cs | 326 ------- .../Docker/ChannelOpeningFlowTests.cs | 801 ++++++++++++++++++ .../Docker/Mock/FakeSecureKeyManager.cs | 8 +- 13 files changed, 885 insertions(+), 404 deletions(-) delete mode 100644 test/NLightning.Integration.Tests/Channels/ChannelOpeningFlowTests.cs create mode 100644 test/NLightning.Integration.Tests/Docker/ChannelOpeningFlowTests.cs diff --git a/src/NLightning.Daemon/Extensions/NodeConfigurationExtensions.cs b/src/NLightning.Daemon/Extensions/NodeConfigurationExtensions.cs index 3a809592..0ebcb719 100644 --- a/src/NLightning.Daemon/Extensions/NodeConfigurationExtensions.cs +++ b/src/NLightning.Daemon/Extensions/NodeConfigurationExtensions.cs @@ -163,7 +163,6 @@ private static string CreateDefaultConfigJson() "0.0.0.0:9735" ], "Features": { - "StaticRemoteKey": "Compulsory" } }, "FeeEstimation": { diff --git a/src/NLightning.Daemon/Program.cs b/src/NLightning.Daemon/Program.cs index 97cf00d9..637353a0 100644 --- a/src/NLightning.Daemon/Program.cs +++ b/src/NLightning.Daemon/Program.cs @@ -96,11 +96,11 @@ ?? throw new InvalidOperationException("Node configuration section is missing or invalid."); // Instantiate the service - var bitcoinWalletService = new BitcoinChainService(Options.Create(bitcoinOptions), walletLogger, - Options.Create(nodeOptions) + var bitcoinChainService = new BitcoinChainService(Options.Create(bitcoinOptions), walletLogger, + Options.Create(nodeOptions) ); - var heightOfBirth = await bitcoinWalletService.GetCurrentBlockHeightAsync(); + var heightOfBirth = await bitcoinChainService.GetCurrentBlockHeightAsync(); // Creates new key var key = new Key(); diff --git a/src/NLightning.Domain/Enums/Feature.cs b/src/NLightning.Domain/Enums/Feature.cs index 7bc8f48f..3e2b1b93 100644 --- a/src/NLightning.Domain/Enums/Feature.cs +++ b/src/NLightning.Domain/Enums/Feature.cs @@ -12,7 +12,7 @@ public enum Feature /// 0 is for the compulsory bit, 1 is for the optional bit. /// /// - /// This feature is optional and is used to indicate that the node supports the data_loss_protect field in the channel_reestablish message. + /// This feature is compulsory and is used to indicate that the node supports the data_loss_protect field in the channel_reestablish message. /// OptionDataLossProtect = 1, @@ -52,7 +52,7 @@ public enum Feature /// 12 is for the compulsory bit, 13 is for the optional bit. /// /// - /// This feature is optional and is used to indicate that the node supports static_remotekey. + /// This feature is compulsory and is used to indicate that the node supports static_remotekey. /// OptionStaticRemoteKey = 13, @@ -60,7 +60,7 @@ public enum Feature /// 14 is for the compulsory bit, 15 is for the optional bit. /// /// - /// This feature is optional and is used to indicate that the node supports payment_secret. + /// This feature is compulsory and is used to indicate that the node supports payment_secret. /// PaymentSecret = 15, @@ -148,7 +148,7 @@ public enum Feature /// 44 is for the compulsory bit, 45 is for the optional bit. /// /// - /// This feature is optional and is used to indicate that the node supports channel type. + /// This feature is compulsory and is used to indicate that the node supports channel type. /// OptionChannelType = 45, diff --git a/src/NLightning.Domain/Node/FeatureSet.cs b/src/NLightning.Domain/Node/FeatureSet.cs index d9586f58..413fe15f 100644 --- a/src/NLightning.Domain/Node/FeatureSet.cs +++ b/src/NLightning.Domain/Node/FeatureSet.cs @@ -19,10 +19,6 @@ public class FeatureSet { // This \/ --- Depends on this \/ { Feature.GossipQueriesEx, [Feature.GossipQueries] }, - { Feature.PaymentSecret, [Feature.VarOnionOptin] }, - { Feature.BasicMpp, [Feature.PaymentSecret] }, - { Feature.OptionAnchors, [Feature.OptionStaticRemoteKey] }, - { Feature.OptionRouteBlinding, [Feature.VarOnionOptin] }, { Feature.OptionZeroconf, [Feature.OptionScidAlias] }, }; diff --git a/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs b/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs index 49a5f26c..887f5f0d 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs @@ -244,7 +244,8 @@ public bool SignFundingTransaction(ChannelId channelId, SignedTransaction unsign var signedInputCount = 0; var prevOuts = new TxOut[nBitcoinTx.Inputs.Count]; - var signingKeys = new Key[nBitcoinTx.Inputs.Count]; + var signingKeys = new Key?[nBitcoinTx.Inputs.Count]; + var taprootKeyPairs = new TaprootKeyPair?[nBitcoinTx.Inputs.Count]; var utxos = new UtxoModel[nBitcoinTx.Inputs.Count]; // Sign each input @@ -275,7 +276,8 @@ public bool SignFundingTransaction(ChannelId channelId, SignedTransaction unsign // Create the scriptPubKey and previous output based on the address type Script scriptPubKey; ExtPrivKey signingExtKey; - Key signingKey; + Key? signingKey = null; + TaprootKeyPair? taprootKeyPair = null; switch (utxo.AddressType) { @@ -294,9 +296,10 @@ public bool SignFundingTransaction(ChannelId channelId, SignedTransaction unsign signingExtKey = _secureKeyManager.GetDepositP2TrKeyAtIndex( utxo.WalletAddress.Index, utxo.WalletAddress.IsChange); - signingKey = ExtKey.CreateFromBytes(signingExtKey).PrivateKey; + var rootKey = ExtKey.CreateFromBytes(signingExtKey).PrivateKey; // For P2TR (Taproot): OP_1 <32-byte-taproot-output> - scriptPubKey = signingKey.PubKey.GetTaprootFullPubKey().ScriptPubKey; + taprootKeyPair = rootKey.CreateTaprootKeyPair(); + scriptPubKey = taprootKeyPair.PubKey.ScriptPubKey; break; default: @@ -305,6 +308,7 @@ public bool SignFundingTransaction(ChannelId channelId, SignedTransaction unsign } signingKeys[i] = signingKey; + taprootKeyPairs[i] = taprootKeyPair; prevOuts[i] = new TxOut(new Money(utxo.Amount.Satoshi), scriptPubKey); } catch (Exception ex) @@ -322,18 +326,25 @@ public bool SignFundingTransaction(ChannelId channelId, SignedTransaction unsign { var utxo = utxos[i]; var signingKey = signingKeys[i]; + var taprootKeyPair = taprootKeyPairs[i]; var prevOut = prevOuts[i]; switch (utxo.AddressType) { // Sign based on the address type case AddressType.P2Wpkh: + if (signingKey is null) + throw new SignerException($"Missing signing key for P2WPKH input {i}", channelId); + // Sign P2WPKH input SignP2WpkhInput(nBitcoinTx, i, signingKey, prevOut); break; case AddressType.P2Tr: + if (taprootKeyPair is null) + throw new SignerException($"Missing taproot key pair for P2TR input {i}", channelId); + // Sign P2TR (Taproot) input - key path spend - SignP2TrInput(nBitcoinTx, i, signingKey, prevOuts); + SignP2TrInput(nBitcoinTx, i, taprootKeyPair, prevOuts); break; default: throw new SignerException($"Unsupported address type {utxo.AddressType} for input {i}", @@ -537,17 +548,19 @@ private static void SignP2WpkhInput(Transaction tx, int inputIndex, Key signingK /// Sign a P2TR (Pay-to-Taproot) input using the key path spend /// /// For Taproot, we use BIP341 signing - private static void SignP2TrInput(Transaction tx, int inputIndex, Key signingKey, TxOut[] prevOuts) + private static void SignP2TrInput(Transaction tx, int inputIndex, TaprootKeyPair taprootKeyPair, TxOut[] prevOuts) { // Create the TaprootExecutionData - // var taprootPubKey = signingKey.PubKey.GetTaprootFullPubKey(); - var taprootExecutionData = new TaprootExecutionData(inputIndex); + var taprootExecutionData = new TaprootExecutionData(inputIndex) + { + SigHash = TaprootSigHash.All + }; // Calculate the signature hash using Taproot rules (BIP341) var sigHash = tx.GetSignatureHashTaproot(prevOuts.ToArray(), taprootExecutionData); // Sign with Schnorr signature (BIP340) - var taprootSignature = signingKey.SignTaprootKeySpend(sigHash, TaprootSigHash.All); + var taprootSignature = taprootKeyPair.SignTaprootKeySpend(sigHash, TaprootSigHash.All); // For key path spend, witness is just: tx.Inputs[inputIndex].WitScript = new WitScript(Op.GetPushOp(taprootSignature.ToBytes())); diff --git a/test/NLightning.Application.Tests/Channels/Handlers/OpenChannel1MessageHandlerTests.cs b/test/NLightning.Application.Tests/Channels/Handlers/OpenChannel1MessageHandlerTests.cs index ee61be00..4a770339 100644 --- a/test/NLightning.Application.Tests/Channels/Handlers/OpenChannel1MessageHandlerTests.cs +++ b/test/NLightning.Application.Tests/Channels/Handlers/OpenChannel1MessageHandlerTests.cs @@ -59,9 +59,9 @@ public OpenChannel1MessageHandlerTests() var dustLimitAmount = LightningMoney.Satoshis(354); var feeRateAmountPerKw = LightningMoney.Zero; var htlcMinimumAmount = LightningMoney.Satoshis(1); - var maxAcceptedHtlcs = (ushort)10; + const ushort maxAcceptedHtlcs = 10; var maxHtlcAmountInFlight = LightningMoney.Satoshis(10_000); - var toSelfDelay = (ushort)144; + const ushort toSelfDelay = 144; var fundingAmount = LightningMoney.Satoshis(10_000); // Create a valid OpenChannel1Message @@ -138,7 +138,8 @@ public async Task HandleAsync_ValidMessage_CreatesChannelAndReturnsAcceptChannel _mockMessageFactory.Verify( x => x.CreateAcceptChannel1Message( - _channel.ChannelConfig.ChannelReserveAmount!, null, + _channel.ChannelConfig.ChannelReserveAmount, + It.IsAny(), _channel.LocalKeySet.DelayedPaymentCompactBasepoint, _channel.LocalKeySet.CurrentPerCommitmentCompactPoint, _channel.LocalKeySet.FundingCompactPubKey, _channel.LocalKeySet.HtlcCompactBasepoint, diff --git a/test/NLightning.Application.Tests/Node/Managers/PeerManagerTests.cs b/test/NLightning.Application.Tests/Node/Managers/PeerManagerTests.cs index 7dae0a1e..51f30459 100644 --- a/test/NLightning.Application.Tests/Node/Managers/PeerManagerTests.cs +++ b/test/NLightning.Application.Tests/Node/Managers/PeerManagerTests.cs @@ -342,7 +342,7 @@ public async Task Given_ChannelErrorException_When_ProcessingChannelMessage_Then await Task.Delay(100, TestContext.Current.CancellationToken); // Then - _mockPeerService.Verify(p => p.Disconnect(null), Times.Once); + _mockPeerService.Verify(p => p.Disconnect(channelError), Times.Once); _mockLogger.Verify( l => l.Log( LogLevel.Error, diff --git a/test/NLightning.Bolt11.Tests/Models/TaggedFieldListTests.cs b/test/NLightning.Bolt11.Tests/Models/TaggedFieldListTests.cs index c3c35930..e93461b5 100644 --- a/test/NLightning.Bolt11.Tests/Models/TaggedFieldListTests.cs +++ b/test/NLightning.Bolt11.Tests/Models/TaggedFieldListTests.cs @@ -41,7 +41,7 @@ public void Given_TaggedFieldListWithExistingField_When_AddSameType_Then_Argumen // When / Then var ex = Assert.Throws(() => list.Add(new MockTaggedField - { Type = TaggedFieldTypes.Description }) + { Type = TaggedFieldTypes.Description }) ); Assert.Contains("already contains a tagged field of type Description", ex.Message); @@ -56,7 +56,7 @@ public void Given_TaggedFieldListWithDescription_When_AddDescriptionHash_Then_Ar // When / Then var ex = Assert.Throws(() => list.Add(new MockTaggedField - { Type = TaggedFieldTypes.DescriptionHash }) + { Type = TaggedFieldTypes.DescriptionHash }) ); Assert.Contains("already contains a tagged field of type DescriptionHash", ex.Message); @@ -71,7 +71,7 @@ public void Given_TaggedFieldListWithDescriptionHash_When_AddDescription_Then_Ar // When / Then var ex = Assert.Throws(() => list.Add(new MockTaggedField - { Type = TaggedFieldTypes.Description }) + { Type = TaggedFieldTypes.Description }) ); Assert.Contains("already contains a tagged field of type Description", ex.Message); diff --git a/test/NLightning.Infrastructure.Serialization.Tests/Messages/InitMessageTests.cs b/test/NLightning.Infrastructure.Serialization.Tests/Messages/InitMessageTests.cs index e9c5805e..d3a8adf2 100644 --- a/test/NLightning.Infrastructure.Serialization.Tests/Messages/InitMessageTests.cs +++ b/test/NLightning.Infrastructure.Serialization.Tests/Messages/InitMessageTests.cs @@ -34,7 +34,11 @@ public async Task var stream = new MemoryStream( Convert.FromHexString( +<<<<<<< HEAD "000202000002020001206FE28C0AB6F1B372C1A6A246AE63F74F931E8365E15A089C68D6190000000000")); +======= + "00025101000610000000510101206fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000")); +>>>>>>> ea0794c (add more tests and update failing tests to new rules) // Act var initMessage = await _initMessageTypeSerializer.DeserializeAsync(stream); @@ -53,7 +57,7 @@ public async Task Given_ValidStreamWithOnlyPayload_When_DeserializeAsync_Then_Re { // Arrange var expectedPayload = new InitPayload(new FeatureSet()); - var stream = new MemoryStream(Convert.FromHexString("0002020000020200")); + var stream = new MemoryStream(Convert.FromHexString("000251010006100000005101")); // Act var initMessage = await _initMessageTypeSerializer.DeserializeAsync(stream); @@ -83,7 +87,11 @@ public async Task Given_ValidPayloadAndExtension_When_SerializeAsync_Then_Writes var stream = new MemoryStream(); var expectedBytes = Convert.FromHexString( +<<<<<<< HEAD "000202000002020001206FE28C0AB6F1B372C1A6A246AE63F74F931E8365E15A089C68D6190000000000"); +======= + "00025101000610000000510101206fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000"); +>>>>>>> ea0794c (add more tests and update failing tests to new rules) // Act await _initMessageTypeSerializer.SerializeAsync(message, stream); @@ -101,7 +109,7 @@ public async Task Given_ValidPayloadOnly_When_SerializeAsync_Then_WritesCorrectD // Arrange var message = new InitMessage(new InitPayload(new FeatureSet())); var stream = new MemoryStream(); - var expectedBytes = Convert.FromHexString("0002020000020200"); + var expectedBytes = Convert.FromHexString("000251010006100000005101"); // Act await _initMessageTypeSerializer.SerializeAsync(message, stream); diff --git a/test/NLightning.Infrastructure.Serialization.Tests/Node/FeatureSetSerializerTests.cs b/test/NLightning.Infrastructure.Serialization.Tests/Node/FeatureSetSerializerTests.cs index 4e787f4b..64ec1624 100644 --- a/test/NLightning.Infrastructure.Serialization.Tests/Node/FeatureSetSerializerTests.cs +++ b/test/NLightning.Infrastructure.Serialization.Tests/Node/FeatureSetSerializerTests.cs @@ -17,20 +17,12 @@ public FeatureSetSerializerTests() #region Serialization [Theory] + [InlineData(Feature.OptionSimpleClose, false, 8)] + [InlineData(Feature.OptionSimpleClose, true, 8)] [InlineData(Feature.OptionZeroconf, false, 7)] [InlineData(Feature.OptionZeroconf, true, 7)] [InlineData(Feature.OptionScidAlias, false, 6)] [InlineData(Feature.OptionScidAlias, true, 6)] - [InlineData(Feature.OptionOnionMessages, false, 5)] - [InlineData(Feature.OptionOnionMessages, true, 5)] - [InlineData(Feature.OptionDualFund, false, 4)] - [InlineData(Feature.OptionDualFund, true, 4)] - [InlineData(Feature.OptionAnchors, false, 3)] - [InlineData(Feature.OptionAnchors, true, 3)] - [InlineData(Feature.OptionStaticRemoteKey, false, 2)] - [InlineData(Feature.OptionStaticRemoteKey, true, 2)] - [InlineData(Feature.GossipQueries, false, 1)] - [InlineData(Feature.GossipQueries, true, 1)] public async Task Given_Features_When_Serialize_Then_BytesAreTrimmed( Feature feature, bool isCompulsory, int expectedLength) { @@ -52,20 +44,12 @@ public async Task Given_Features_When_Serialize_Then_BytesAreTrimmed( } [Theory] - [InlineData(Feature.OptionZeroconf, false, 7)] - [InlineData(Feature.OptionZeroconf, true, 7)] + [InlineData(Feature.OptionSimpleClose, false, 8)] + [InlineData(Feature.OptionSimpleClose, true, 8)] + [InlineData(Feature.OptionPaymentMetadata, false, 7)] + [InlineData(Feature.OptionPaymentMetadata, true, 6)] [InlineData(Feature.OptionScidAlias, false, 6)] [InlineData(Feature.OptionScidAlias, true, 6)] - [InlineData(Feature.OptionOnionMessages, false, 5)] - [InlineData(Feature.OptionOnionMessages, true, 5)] - [InlineData(Feature.OptionDualFund, false, 4)] - [InlineData(Feature.OptionDualFund, true, 4)] - [InlineData(Feature.OptionAnchors, false, 3)] - [InlineData(Feature.OptionAnchors, true, 3)] - [InlineData(Feature.OptionStaticRemoteKey, false, 2)] - [InlineData(Feature.OptionStaticRemoteKey, true, 2)] - [InlineData(Feature.GossipQueries, false, 1)] - [InlineData(Feature.GossipQueries, true, 1)] public async Task Given_Features_When_SerializeWithoutLength_Then_LengthIsKnown( Feature feature, bool isCompulsory, int expectedLength) { @@ -86,28 +70,25 @@ public async Task Given_Features_When_SerializeWithoutLength_Then_LengthIsKnown( } [Theory] - [InlineData(Feature.OptionZeroconf, false, new byte[] { 8, 128, 0, 0, 0, 0, 0 })] - [InlineData(Feature.OptionZeroconf, true, new byte[] { 4, 64, 0, 0, 0, 0, 0 })] - [InlineData(Feature.OptionScidAlias, false, new byte[] { 128, 0, 0, 0, 0, 0 })] - [InlineData(Feature.OptionScidAlias, true, new byte[] { 64, 0, 0, 0, 0, 0 })] - [InlineData(Feature.OptionOnionMessages, false, new byte[] { 128, 0, 0, 0, 0 })] - [InlineData(Feature.OptionOnionMessages, true, new byte[] { 64, 0, 0, 0, 0 })] - [InlineData(Feature.OptionDualFund, false, new byte[] { 32, 0, 0, 0 })] - [InlineData(Feature.OptionDualFund, true, new byte[] { 16, 0, 0, 0 })] - [InlineData(Feature.OptionAnchors, false, new byte[] { 128, 32, 0 })] - [InlineData(Feature.OptionAnchors, true, new byte[] { 64, 16, 0 })] - [InlineData(Feature.OptionStaticRemoteKey, false, new byte[] { 32, 0 })] - [InlineData(Feature.OptionStaticRemoteKey, true, new byte[] { 16, 0 })] - [InlineData(Feature.GossipQueries, false, new byte[] { 128 })] - [InlineData(Feature.GossipQueries, true, new byte[] { 64 })] + [InlineData(Feature.OptionZeroconf, false, new byte[] { 8, 144, 0, 0, 0, 81, 1 })] + [InlineData(Feature.OptionZeroconf, true, new byte[] { 4, 80, 0, 0, 0, 81, 1 })] + [InlineData(Feature.OptionScidAlias, false, new byte[] { 144, 0, 0, 0, 81, 1 })] + [InlineData(Feature.OptionScidAlias, true, new byte[] { 80, 0, 0, 0, 81, 1 })] + [InlineData(Feature.OptionOnionMessages, false, new byte[] { 16, 128, 0, 0, 81, 1 })] + [InlineData(Feature.OptionOnionMessages, true, new byte[] { 16, 64, 0, 0, 81, 1 })] + [InlineData(Feature.OptionDualFund, false, new byte[] { 16, 0, 32, 0, 81, 1 })] + [InlineData(Feature.OptionDualFund, true, new byte[] { 16, 0, 16, 0, 81, 1 })] + [InlineData(Feature.OptionAnchors, false, new byte[] { 16, 0, 0, 128, 81, 1 })] + [InlineData(Feature.OptionAnchors, true, new byte[] { 16, 0, 0, 64, 81, 1 })] + [InlineData(Feature.GossipQueries, false, new byte[] { 16, 0, 0, 0, 81, 129 })] + [InlineData(Feature.GossipQueries, true, new byte[] { 16, 0, 0, 0, 81, 65 })] public async Task Given_Features_When_Serialize_Then_BytesAreKnown(Feature feature, bool isCompulsory, byte[] expected) { // Arrange var features = new FeatureSet(); + // Set tested feature features.SetFeature(feature, isCompulsory); - // Clean default features - features.SetFeature(Feature.VarOnionOptin, false, false); using var stream = new MemoryStream(); @@ -124,10 +105,13 @@ public async Task Given_Features_When_SerializeWithoutLength_Then_BytesAreKnown( { // Arrange var features = new FeatureSet(); - // Sets bit 0 - features.SetFeature(Feature.OptionDataLossProtect, true); + // Set bit 1 + features.SetFeature(Feature.OptionDataLossProtect, false); // Clean default features - features.SetFeature(Feature.VarOnionOptin, false, false); + features.SetFeature(Feature.VarOnionOptin, true, false); + features.SetFeature(Feature.OptionStaticRemoteKey, true, false); + features.SetFeature(Feature.PaymentSecret, true, false); + features.SetFeature(Feature.OptionChannelType, true, false); using var stream = new MemoryStream(); @@ -136,7 +120,7 @@ public async Task Given_Features_When_SerializeWithoutLength_Then_BytesAreKnown( var bytes = stream.ToArray(); // Assert - Assert.Equal([1], bytes); + Assert.Equal([2], bytes); } [Fact] @@ -144,10 +128,11 @@ public async Task Given_Features_When_SerializeAsGlobal_Then_NoFeaturesGreaterTh { // Arrange var features = new FeatureSet(); - // Sets bit 0 - features.SetFeature(Feature.OptionSupportLargeChannel, true); - // Clean default features - features.SetFeature(Feature.VarOnionOptin, false, false); + // Clean default features except for bit 0 + features.SetFeature(Feature.VarOnionOptin, true, false); + features.SetFeature(Feature.OptionStaticRemoteKey, true, false); + features.SetFeature(Feature.PaymentSecret, true, false); + features.SetFeature(Feature.OptionChannelType, true, false); using var stream = new MemoryStream(); diff --git a/test/NLightning.Integration.Tests/Channels/ChannelOpeningFlowTests.cs b/test/NLightning.Integration.Tests/Channels/ChannelOpeningFlowTests.cs deleted file mode 100644 index a7509977..00000000 --- a/test/NLightning.Integration.Tests/Channels/ChannelOpeningFlowTests.cs +++ /dev/null @@ -1,326 +0,0 @@ -using System.Net; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Moq.Protected; -using NBitcoin; -using NLightning.Daemon.Handlers; -using NLightning.Daemon.Interfaces; -using NLightning.Domain.Bitcoin.Events; -using NLightning.Domain.Bitcoin.Interfaces; -using NLightning.Domain.Bitcoin.Transactions.Factories; -using NLightning.Domain.Bitcoin.Transactions.Interfaces; -using NLightning.Domain.Channels.Factories; -using NLightning.Domain.Channels.Interfaces; -using NLightning.Domain.Channels.Validators; -using NLightning.Domain.Client.Requests; -using NLightning.Domain.Client.Responses; -using NLightning.Domain.Crypto.Hashes; -using NLightning.Domain.Enums; -using NLightning.Domain.Money; -using NLightning.Domain.Node.Interfaces; -using NLightning.Domain.Node.Options; -using NLightning.Domain.Node.ValueObjects; -using NLightning.Domain.Protocol.Constants; -using NLightning.Domain.Protocol.Interfaces; -using NLightning.Domain.Protocol.ValueObjects; -using NLightning.Infrastructure.Bitcoin.Builders; -using NLightning.Infrastructure.Bitcoin.Options; -using NLightning.Infrastructure.Bitcoin.Services; -using NLightning.Infrastructure.Bitcoin.Signers; -using NLightning.Infrastructure.Persistence.Contexts; -using NLightning.Integration.Tests.Docker.Mock; -using NLightning.Integration.Tests.Docker.Utils; -using NLightning.Tests.Utils; -using ServiceStack; -using Xunit.Abstractions; - -namespace NLightning.Integration.Tests.Channels; - -using Application; -using Fixtures; -using Infrastructure; -using Infrastructure.Bitcoin; -using Infrastructure.Bitcoin.Wallet.Interfaces; -using Infrastructure.Persistence; -using Infrastructure.Repositories; -using Infrastructure.Serialization; -using TestCollections; - -[Collection(LightningRegtestNetworkFixtureCollection.Name)] -public class ChannelOpeningFlowTests : IDisposable -{ - private readonly LightningRegtestNetworkFixture _lightningRegtestNetworkFixture; - private readonly IPeerManager _peerManager; - private readonly IChannelMemoryRepository _channelMemoryRepository; - private readonly IBlockchainMonitor _blockchainMonitor; - private readonly int _port; - private readonly string _databaseFilePath = $"nlightning_channel_test_{Guid.NewGuid()}.db"; - private readonly IServiceProvider _serviceProvider; - private readonly ITestOutputHelper _output; - - public ChannelOpeningFlowTests(LightningRegtestNetworkFixture fixture, ITestOutputHelper output) - { - _lightningRegtestNetworkFixture = fixture; - _output = output; - Console.SetOut(new TestOutputWriter(output)); - - _port = PortPoolUtil.GetAvailablePortAsync().GetAwaiter().GetResult(); - Assert.True(_port > 0); - ISecureKeyManager secureKeyManager = new FakeSecureKeyManager(); - - // Get Bitcoin network info - Assert.NotNull(_lightningRegtestNetworkFixture.Builder); - var bitcoinConfiguration = _lightningRegtestNetworkFixture.Builder.Configuration.BTCNodes[0]; - var zmqRawBlockPort = - bitcoinConfiguration.Cmd.First(c => c.Contains("-zmqpubrawblock")).Split(':')[2]; - var zmqRawTxPort = - bitcoinConfiguration.Cmd.First(c => c.Contains("-zmqpubrawtx")).Split(':')[2]; - var bitcoin = _lightningRegtestNetworkFixture.Builder.BitcoinRpcClient; - Assert.NotNull(bitcoin); - var bitcoinEndpoint = bitcoin.Address.ToString(); - - // Mock HttpClient for FeeService - var httpMessageHandlerMock = new Mock(MockBehavior.Strict); - httpMessageHandlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(() => new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent("{\"fastestFee\": 2}") - }); - - // Build configuration - List> inMemoryConfiguration = - [ - new("Serilog:MinimumLevel:NLightning", "Verbose"), - new("Node:Network", "regtest"), - new("Node:Daemon", "false"), - new("Database:Provider", "Sqlite"), - new("Database:ConnectionString", $"Data Source={_databaseFilePath}"), - new("Bitcoin:RpcEndpoint", bitcoinEndpoint), - new("Bitcoin:RpcUser", bitcoin.CredentialString.UserPassword.UserName), - new("Bitcoin:RpcPassword", bitcoin.CredentialString.UserPassword.Password), - new("Bitcoin:ZmqHost", bitcoin.Address.Host), - new("Bitcoin:ZmqBlockPort", zmqRawBlockPort), - new("Bitcoin:ZmqTxPort", zmqRawTxPort) - ]; - var configuration = new ConfigurationBuilder().AddInMemoryCollection(inMemoryConfiguration).Build(); - - // Create a service collection - var services = new ServiceCollection(); - services.AddSingleton(configuration); - services.AddHttpClient(client => - { - client.Timeout = TimeSpan.FromSeconds(30); - client.DefaultRequestHeaders.Add("Accept", "application/json"); - }); - services.AddSingleton(secureKeyManager); - services.AddSingleton(sp => - { - var nodeOptions = sp.GetRequiredService>().Value; - return new ChannelOpenValidator(nodeOptions); - }); - services.AddSingleton(sp => - { - var channelIdFactory = sp.GetRequiredService(); - var channelOpenValidator = sp.GetRequiredService(); - var feeService = sp.GetRequiredService(); - var lightningSigner = sp.GetRequiredService(); - var nodeOptions = sp.GetRequiredService>().Value; - var sha256 = sp.GetRequiredService(); - return new ChannelFactory(channelIdFactory, channelOpenValidator, feeService, lightningSigner, nodeOptions, - sha256); - }); - services.AddSingleton(); - services.AddSingleton(serviceProvider => - { - var fundingOutputBuilder = serviceProvider.GetRequiredService(); - var keyDerivationService = serviceProvider.GetRequiredService(); - var logger = serviceProvider.GetRequiredService>(); - var nodeOptions = serviceProvider.GetRequiredService>().Value; - var utxoMemoryRepository = serviceProvider.GetRequiredService(); - - return new LocalLightningSigner(fundingOutputBuilder, keyDerivationService, logger, nodeOptions, - secureKeyManager, utxoMemoryRepository); - }); - services.AddApplicationServices(); - services.AddInfrastructureServices(); - services.AddPersistenceInfrastructureServices(configuration); - services.AddRepositoriesInfrastructureServices(); - services.AddSerializationInfrastructureServices(); - services.AddBitcoinInfrastructure(); - services - .AddScoped, - OpenChannelClientHandler>(); - services.AddSingleton(); - services.AddOptions().BindConfiguration("Bitcoin").ValidateOnStart(); - services.AddOptions().BindConfiguration("FeeEstimation").ValidateOnStart(); - services.AddOptions() - .BindConfiguration("Node") - .PostConfigure(options => - { - options.Features = new FeatureOptions - { - ChainHashes = [ChainConstants.Regtest] - }; - options.ListenAddresses = [$"{IPAddress.Loopback}:{_port}"]; - options.BitcoinNetwork = BitcoinNetwork.Regtest; - options.Features.ChainHashes = [options.BitcoinNetwork.ChainHash]; - }) - .ValidateOnStart(); - - // Set up factories - _serviceProvider = services.BuildServiceProvider(); - - // Set up the database migration - var scope = _serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - var pendingMigrations = context.Database.GetPendingMigrationsAsync().GetAwaiter().GetResult().ToList(); - if (pendingMigrations.Count > 0) - context.Database.Migrate(); - - // Get services - _peerManager = _serviceProvider.GetRequiredService(); - _channelMemoryRepository = _serviceProvider.GetRequiredService(); - _blockchainMonitor = _serviceProvider.GetRequiredService(); - } - - [Fact] - public async Task OpenChannel_WaitForConfirmations_ChannelBecomesOpen() - { - // Arrange - var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; - var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes - .First(x => x.LocalAlias == "alice"); - Assert.NotNull(alice); - - await _peerManager.StartAsync(CancellationToken.None); - - // Get the current block height - var currentHeight = (uint)await bitcoin.GetBlockCountAsync(); - - // Start the blockchain monitor at the current height - await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); - - // Fund our wallet - using (var scope = _serviceProvider.CreateScope()) - { - var walletService = scope.ServiceProvider - .GetRequiredService(); - var address = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, false); - - // Subscribe to blockchain monitor events - TaskCompletionSource tsc = new(); - uint txFirstSeenInBlock = int.MaxValue; - - void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) - { - if (e.Amount == LightningMoney.FromUnit(1, LightningMoneyUnit.Btc) - && e.WalletAddress == address.Address) - { - txFirstSeenInBlock = e.BlockHeight; - } - else - { - Assert.Fail("Unexpected wallet movement detected: " + - $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); - } - } - - void OnNewBlockDetected(object? _, NewBlockEventArgs e) - { - if (e.Height >= txFirstSeenInBlock + 5) - tsc.TrySetResult(true); - } - - _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; - _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; - - // Send funds to our wallet - await bitcoin.SendToAddressAsync( - BitcoinAddress.Create(address.Address, NBitcoin.Network.RegTest), - new Money(1, MoneyUnit.BTC)); - - // Mine blocks to confirm - await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); - - // wait for funding transaction to be confirmed - Assert.True(await tsc.Task); - _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; - _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; - } - - // Verify we have balance - var utxoRepository = _serviceProvider.GetRequiredService(); - var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); - Assert.True(balance > LightningMoney.Zero); - - // Connect to Alice - var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host - .SplitOnFirst("//")[1] - .SplitOnFirst(":")[0])).First(), 9735); - var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; - - await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); - - Task openChannelTask; - // Open channel - using the client handler - using (var scope = _serviceProvider.CreateScope()) - { - var clientHandler = scope.ServiceProvider.GetService( - typeof(IClientCommandHandler)) as - OpenChannelClientHandler ?? - throw new InvalidOperationException( - $"Unable to get service {nameof(OpenChannelClientHandler)}"); - var request = new OpenChannelClientRequest( - aliceAddress, - LightningMoney.Satoshis(1000000) // 0.01 BTC, - ) - { - FeeRatePerKw = LightningMoney.Satoshis(10000) - }; - - // Subscribe to the event and mine blocks when needed - clientHandler.OnWaitingConfirmation += async (_, _) => - { - // Mine blocks to confirm - await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); - }; - - // Act - Open the channel (this should send open_channel and wait for the flow to complete) - openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); - } - - var channelResponse = await openChannelTask; - Assert.NotNull(channelResponse); - - // Check if the channel exists (temporary or permanent) - var allChannels = _channelMemoryRepository.FindChannels(_ => true); - - // TODO: Complete the test by mining blocks and verifying state transitions - // For now, verify the flow started - Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); - } - - public void Dispose() - { - _blockchainMonitor.StopAsync().GetAwaiter().GetResult(); - PortPoolUtil.ReleasePort(_port); - if (File.Exists(_databaseFilePath)) - { - try - { - File.Delete(_databaseFilePath); - } - catch (Exception ex) - { - Console.WriteLine($"Failed to delete database file: {ex.Message}"); - } - } - } -} \ No newline at end of file diff --git a/test/NLightning.Integration.Tests/Docker/ChannelOpeningFlowTests.cs b/test/NLightning.Integration.Tests/Docker/ChannelOpeningFlowTests.cs new file mode 100644 index 00000000..c0703111 --- /dev/null +++ b/test/NLightning.Integration.Tests/Docker/ChannelOpeningFlowTests.cs @@ -0,0 +1,801 @@ +using System.Net; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq.Protected; +using NBitcoin; +using NLightning.Daemon.Handlers; +using NLightning.Daemon.Interfaces; +using NLightning.Domain.Bitcoin.Events; +using NLightning.Domain.Bitcoin.Interfaces; +using NLightning.Domain.Bitcoin.Transactions.Factories; +using NLightning.Domain.Bitcoin.Transactions.Interfaces; +using NLightning.Domain.Channels.Factories; +using NLightning.Domain.Channels.Interfaces; +using NLightning.Domain.Channels.Validators; +using NLightning.Domain.Client.Requests; +using NLightning.Domain.Client.Responses; +using NLightning.Domain.Crypto.Hashes; +using NLightning.Domain.Enums; +using NLightning.Domain.Money; +using NLightning.Domain.Node.Interfaces; +using NLightning.Domain.Node.Options; +using NLightning.Domain.Node.ValueObjects; +using NLightning.Domain.Protocol.Constants; +using NLightning.Domain.Protocol.Interfaces; +using NLightning.Domain.Protocol.ValueObjects; +using NLightning.Infrastructure.Bitcoin.Builders; +using NLightning.Infrastructure.Bitcoin.Options; +using NLightning.Infrastructure.Bitcoin.Services; +using NLightning.Infrastructure.Bitcoin.Signers; +using NLightning.Infrastructure.Persistence.Contexts; +using NLightning.Integration.Tests.Docker.Mock; +using NLightning.Integration.Tests.Docker.Utils; +using NLightning.Tests.Utils; +using ServiceStack; +using Xunit.Abstractions; + +namespace NLightning.Integration.Tests.Channels; + +using Application; +using Fixtures; +using Infrastructure; +using Infrastructure.Bitcoin; +using Infrastructure.Bitcoin.Wallet.Interfaces; +using Infrastructure.Persistence; +using Infrastructure.Repositories; +using Infrastructure.Serialization; +using TestCollections; + +[Collection(LightningRegtestNetworkFixtureCollection.Name)] +public class ChannelOpeningFlowTests : IDisposable +{ + private readonly LightningRegtestNetworkFixture _lightningRegtestNetworkFixture; + private readonly IPeerManager _peerManager; + private readonly IChannelMemoryRepository _channelMemoryRepository; + private readonly IBlockchainMonitor _blockchainMonitor; + private readonly int _port; + private readonly string _databaseFilePath = $"nlightning_channel_test_{Guid.NewGuid()}.db"; + private readonly IServiceProvider _serviceProvider; + private readonly ITestOutputHelper _output; + + public ChannelOpeningFlowTests(LightningRegtestNetworkFixture fixture, ITestOutputHelper output) + { + _lightningRegtestNetworkFixture = fixture; + _output = output; + Console.SetOut(new TestOutputWriter(output)); + + _port = PortPoolUtil.GetAvailablePortAsync().GetAwaiter().GetResult(); + Assert.True(_port > 0); + ISecureKeyManager secureKeyManager = new FakeSecureKeyManager(); + + // Get Bitcoin network info + Assert.NotNull(_lightningRegtestNetworkFixture.Builder); + var bitcoinConfiguration = _lightningRegtestNetworkFixture.Builder.Configuration.BTCNodes[0]; + var zmqRawBlockPort = + bitcoinConfiguration.Cmd.First(c => c.Contains("-zmqpubrawblock")).Split(':')[2]; + var zmqRawTxPort = + bitcoinConfiguration.Cmd.First(c => c.Contains("-zmqpubrawtx")).Split(':')[2]; + var bitcoin = _lightningRegtestNetworkFixture.Builder.BitcoinRpcClient; + Assert.NotNull(bitcoin); + var bitcoinEndpoint = bitcoin.Address.ToString(); + + // Mock HttpClient for FeeService + var httpMessageHandlerMock = new Mock(MockBehavior.Strict); + httpMessageHandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"fastestFee\": 2}") + }); + + // Build configuration + List> inMemoryConfiguration = + [ + new("Serilog:MinimumLevel:NLightning", "Verbose"), + new("Node:Network", "regtest"), + new("Node:Daemon", "false"), + new("Database:Provider", "Sqlite"), + new("Database:ConnectionString", $"Data Source={_databaseFilePath}"), + new("Bitcoin:RpcEndpoint", bitcoinEndpoint), + new("Bitcoin:RpcUser", bitcoin.CredentialString.UserPassword.UserName), + new("Bitcoin:RpcPassword", bitcoin.CredentialString.UserPassword.Password), + new("Bitcoin:ZmqHost", bitcoin.Address.Host), + new("Bitcoin:ZmqBlockPort", zmqRawBlockPort), + new("Bitcoin:ZmqTxPort", zmqRawTxPort) + ]; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(inMemoryConfiguration).Build(); + + // Create a service collection + var services = new ServiceCollection(); + services.AddSingleton(configuration); + services.AddHttpClient(client => + { + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + }); + services.AddSingleton(secureKeyManager); + services.AddSingleton(sp => + { + var nodeOptions = sp.GetRequiredService>().Value; + return new ChannelOpenValidator(nodeOptions); + }); + services.AddSingleton(sp => + { + var channelIdFactory = sp.GetRequiredService(); + var channelOpenValidator = sp.GetRequiredService(); + var feeService = sp.GetRequiredService(); + var lightningSigner = sp.GetRequiredService(); + var nodeOptions = sp.GetRequiredService>().Value; + var sha256 = sp.GetRequiredService(); + return new ChannelFactory(channelIdFactory, channelOpenValidator, feeService, lightningSigner, nodeOptions, + sha256); + }); + services.AddSingleton(); + services.AddSingleton(serviceProvider => + { + var fundingOutputBuilder = serviceProvider.GetRequiredService(); + var keyDerivationService = serviceProvider.GetRequiredService(); + var logger = serviceProvider.GetRequiredService>(); + var nodeOptions = serviceProvider.GetRequiredService>().Value; + var utxoMemoryRepository = serviceProvider.GetRequiredService(); + + return new LocalLightningSigner(fundingOutputBuilder, keyDerivationService, logger, nodeOptions, + secureKeyManager, utxoMemoryRepository); + }); + services.AddApplicationServices(); + services.AddInfrastructureServices(); + services.AddPersistenceInfrastructureServices(configuration); + services.AddRepositoriesInfrastructureServices(); + services.AddSerializationInfrastructureServices(); + services.AddBitcoinInfrastructure(); + services + .AddScoped, + OpenChannelClientHandler>(); + services.AddSingleton(); + services.AddOptions().BindConfiguration("Bitcoin").ValidateOnStart(); + services.AddOptions().BindConfiguration("FeeEstimation").ValidateOnStart(); + services.AddOptions() + .BindConfiguration("Node") + .PostConfigure(options => + { + options.Features = new FeatureOptions + { + ChainHashes = [ChainConstants.Regtest] + }; + options.ListenAddresses = [$"{IPAddress.Loopback}:{_port}"]; + options.BitcoinNetwork = BitcoinNetwork.Regtest; + options.Features.ChainHashes = [options.BitcoinNetwork.ChainHash]; + options.ToSelfDelay = 240; + }) + .ValidateOnStart(); + + // Set up factories + _serviceProvider = services.BuildServiceProvider(); + + // Set up the database migration + var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var pendingMigrations = context.Database.GetPendingMigrationsAsync().GetAwaiter().GetResult().ToList(); + if (pendingMigrations.Count > 0) + context.Database.Migrate(); + + // Get services + _peerManager = _serviceProvider.GetRequiredService(); + _channelMemoryRepository = _serviceProvider.GetRequiredService(); + _blockchainMonitor = _serviceProvider.GetRequiredService(); + } + + [Fact] + public async Task GivenSingleP2WPKHInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() + { + // Arrange + var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; + var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes + .First(x => x.LocalAlias == "alice"); + Assert.NotNull(alice); + + await _peerManager.StartAsync(CancellationToken.None); + + // Get the current block height + var currentHeight = (uint)await bitcoin.GetBlockCountAsync(); + + // Start the blockchain monitor at the current height + await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); + + // Fund our wallet + using (var scope = _serviceProvider.CreateScope()) + { + var walletService = scope.ServiceProvider + .GetRequiredService(); + var address = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, false); + + // Subscribe to blockchain monitor events + TaskCompletionSource tsc = new(); + uint txFirstSeenInBlock = int.MaxValue; + + void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) + { + if (e.Amount == LightningMoney.FromUnit(1, LightningMoneyUnit.Btc) + && e.WalletAddress == address.Address) + { + txFirstSeenInBlock = e.BlockHeight; + } + else + { + Assert.Fail("Unexpected wallet movement detected: " + + $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); + } + } + + void OnNewBlockDetected(object? _, NewBlockEventArgs e) + { + if (e.Height >= txFirstSeenInBlock + 5) + tsc.TrySetResult(true); + } + + _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; + + // Send funds to our wallet + await bitcoin.SendToAddressAsync( + BitcoinAddress.Create(address.Address, NBitcoin.Network.RegTest), + new Money(1, MoneyUnit.BTC)); + + // Mine blocks to confirm + await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); + + // wait for funding transaction to be confirmed + Assert.True(await tsc.Task); + _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; + } + + // Verify we have balance + var utxoRepository = _serviceProvider.GetRequiredService(); + var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); + Assert.True(balance > LightningMoney.Zero); + + // Connect to Alice + var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host + .SplitOnFirst("//")[1] + .SplitOnFirst(":")[0])).First(), 9735); + var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; + + await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); + + Task openChannelTask; + // Open channel - using the client handler + using (var scope = _serviceProvider.CreateScope()) + { + var clientHandler = scope.ServiceProvider.GetService( + typeof(IClientCommandHandler)) as + OpenChannelClientHandler ?? + throw new InvalidOperationException( + $"Unable to get service {nameof(OpenChannelClientHandler)}"); + var request = new OpenChannelClientRequest( + aliceAddress, + LightningMoney.Satoshis(1000000) // 0.01 BTC, + ) + { + FeeRatePerKw = LightningMoney.Satoshis(10000) + }; + + // Subscribe to the event and mine blocks when needed + clientHandler.OnWaitingConfirmation += async (_, _) => + { + // Mine blocks to confirm + await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); + }; + + // Act - Open the channel (this should send open_channel and wait for the flow to complete) + openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); + } + + var channelResponse = await openChannelTask; + Assert.NotNull(channelResponse); + + // Check if the channel exists (temporary or permanent) + var allChannels = _channelMemoryRepository.FindChannels(_ => true); + + Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); + } + + [Fact] + public async Task GivenMultipleP2WPKHInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() + { + // Arrange + var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; + var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes + .First(x => x.LocalAlias == "alice"); + Assert.NotNull(alice); + + await _peerManager.StartAsync(CancellationToken.None); + + // Get the current block height + var currentHeight = (uint)await bitcoin.GetBlockCountAsync(); + + // Start the blockchain monitor at the current height + await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); + + // Fund our wallet + using (var scope = _serviceProvider.CreateScope()) + { + var walletService = scope.ServiceProvider + .GetRequiredService(); + + // Subscribe to blockchain monitor events + TaskCompletionSource tsc = new(); + uint txFirstSeenInBlock = int.MaxValue; + + var address1 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, false); + var address2 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, true); + + void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) + { + if (e.Amount == LightningMoney.Satoshis(1100000) && e.WalletAddress == address1.Address) + { + txFirstSeenInBlock = e.BlockHeight; + } + else + { + Assert.Fail("Unexpected wallet movement detected: " + + $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); + } + } + + void OnNewBlockDetected(object? _, NewBlockEventArgs e) + { + if (e.Height >= txFirstSeenInBlock + 5) + tsc.TrySetResult(true); + } + + _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; + + // Send funds to our wallet + await bitcoin.SendToAddressAsync( + BitcoinAddress.Create(address1.Address, NBitcoin.Network.RegTest), + new Money(1100000, MoneyUnit.Satoshi)); // 0.011 + await bitcoin.SendToAddressAsync( + BitcoinAddress.Create(address2.Address, NBitcoin.Network.RegTest), + new Money(1100000, MoneyUnit.Satoshi)); // 0.011 + + // Mine blocks to confirm + await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); + + // wait for funding transaction to be confirmed + Assert.True(await tsc.Task); + _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; + } + + // Verify we have balance + var utxoRepository = _serviceProvider.GetRequiredService(); + var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); + Assert.True(balance > LightningMoney.Zero); + + // Connect to Alice + var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host + .SplitOnFirst("//")[1] + .SplitOnFirst(":")[0])).First(), 9735); + var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; + + await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); + + Task openChannelTask; + // Open channel - using the client handler + using (var scope = _serviceProvider.CreateScope()) + { + var clientHandler = scope.ServiceProvider.GetService( + typeof(IClientCommandHandler)) as + OpenChannelClientHandler ?? + throw new InvalidOperationException( + $"Unable to get service {nameof(OpenChannelClientHandler)}"); + var request = new OpenChannelClientRequest( + aliceAddress, + LightningMoney.Satoshis(2000000) // 0.02 BTC, + ) + { + FeeRatePerKw = LightningMoney.Satoshis(10000) + }; + + // Subscribe to the event and mine blocks when needed + clientHandler.OnWaitingConfirmation += async (_, _) => + { + // Mine blocks to confirm + await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); + }; + + // Act - Open the channel (this should send open_channel and wait for the flow to complete) + openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); + } + + var channelResponse = await openChannelTask; + Assert.NotNull(channelResponse); + + // Check if the channel exists (temporary or permanent) + var allChannels = _channelMemoryRepository.FindChannels(_ => true); + + Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); + } + + [Fact] + public async Task GivenSingleP2TRInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() + { + // Arrange + var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; + var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes + .First(x => x.LocalAlias == "alice"); + Assert.NotNull(alice); + + await _peerManager.StartAsync(CancellationToken.None); + + // Get the current block height + var currentHeight = (uint)await bitcoin.GetBlockCountAsync(); + + // Start the blockchain monitor at the current height + await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); + + // Fund our wallet + using (var scope = _serviceProvider.CreateScope()) + { + var walletService = scope.ServiceProvider + .GetRequiredService(); + var address = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Tr, false); + + // Subscribe to blockchain monitor events + TaskCompletionSource tsc = new(); + uint txFirstSeenInBlock = int.MaxValue; + + void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) + { + if (e.Amount == LightningMoney.FromUnit(1, LightningMoneyUnit.Btc) + && e.WalletAddress == address.Address) + { + txFirstSeenInBlock = e.BlockHeight; + } + else + { + Assert.Fail("Unexpected wallet movement detected: " + + $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); + } + } + + void OnNewBlockDetected(object? _, NewBlockEventArgs e) + { + if (e.Height >= txFirstSeenInBlock + 5) + tsc.TrySetResult(true); + } + + _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; + + // Send funds to our wallet + await bitcoin.SendToAddressAsync( + BitcoinAddress.Create(address.Address, NBitcoin.Network.RegTest), + new Money(1, MoneyUnit.BTC)); + + // Mine blocks to confirm + await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); + + // wait for funding transaction to be confirmed + Assert.True(await tsc.Task); + _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; + } + + // Verify we have balance + var utxoRepository = _serviceProvider.GetRequiredService(); + var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); + Assert.True(balance > LightningMoney.Zero); + + // Connect to Alice + var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host + .SplitOnFirst("//")[1] + .SplitOnFirst(":")[0])).First(), 9735); + var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; + + await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); + + Task openChannelTask; + // Open channel - using the client handler + using (var scope = _serviceProvider.CreateScope()) + { + var clientHandler = scope.ServiceProvider.GetService( + typeof(IClientCommandHandler)) as + OpenChannelClientHandler ?? + throw new InvalidOperationException( + $"Unable to get service {nameof(OpenChannelClientHandler)}"); + var request = new OpenChannelClientRequest( + aliceAddress, + LightningMoney.Satoshis(1000000) // 0.01 BTC, + ) + { + FeeRatePerKw = LightningMoney.Satoshis(10000) + }; + + // Subscribe to the event and mine blocks when needed + clientHandler.OnWaitingConfirmation += async (_, _) => + { + // Mine blocks to confirm + await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); + }; + + // Act - Open the channel (this should send open_channel and wait for the flow to complete) + openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); + } + + var channelResponse = await openChannelTask; + Assert.NotNull(channelResponse); + + // Check if the channel exists (temporary or permanent) + var allChannels = _channelMemoryRepository.FindChannels(_ => true); + + Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); + } + + [Fact] + public async Task GivenMultipleP2TRInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() + { + // Arrange + var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; + var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes + .First(x => x.LocalAlias == "alice"); + Assert.NotNull(alice); + + await _peerManager.StartAsync(CancellationToken.None); + + // Get the current block height + var currentHeight = (uint)await bitcoin.GetBlockCountAsync(); + + // Start the blockchain monitor at the current height + await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); + + // Fund our wallet + using (var scope = _serviceProvider.CreateScope()) + { + var walletService = scope.ServiceProvider + .GetRequiredService(); + + // Subscribe to blockchain monitor events + TaskCompletionSource tsc = new(); + uint txFirstSeenInBlock = int.MaxValue; + + var address1 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Tr, false); + var address2 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Tr, true); + + void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) + { + if (e.Amount == LightningMoney.Satoshis(1100000) && e.WalletAddress == address1.Address) + { + txFirstSeenInBlock = e.BlockHeight; + } + else + { + Assert.Fail("Unexpected wallet movement detected: " + + $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); + } + } + + void OnNewBlockDetected(object? _, NewBlockEventArgs e) + { + if (e.Height >= txFirstSeenInBlock + 5) + tsc.TrySetResult(true); + } + + _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; + + // Send funds to our wallet + await bitcoin.SendToAddressAsync( + BitcoinAddress.Create(address1.Address, NBitcoin.Network.RegTest), + new Money(1100000, MoneyUnit.Satoshi)); // 0.011 + await bitcoin.SendToAddressAsync( + BitcoinAddress.Create(address2.Address, NBitcoin.Network.RegTest), + new Money(1100000, MoneyUnit.Satoshi)); // 0.011 + + // Mine blocks to confirm + await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); + + // wait for funding transaction to be confirmed + Assert.True(await tsc.Task); + _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; + } + + // Verify we have balance + var utxoRepository = _serviceProvider.GetRequiredService(); + var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); + Assert.True(balance > LightningMoney.Zero); + + // Connect to Alice + var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host + .SplitOnFirst("//")[1] + .SplitOnFirst(":")[0])).First(), 9735); + var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; + + await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); + + Task openChannelTask; + // Open channel - using the client handler + using (var scope = _serviceProvider.CreateScope()) + { + var clientHandler = scope.ServiceProvider.GetService( + typeof(IClientCommandHandler)) as + OpenChannelClientHandler ?? + throw new InvalidOperationException( + $"Unable to get service {nameof(OpenChannelClientHandler)}"); + var request = new OpenChannelClientRequest( + aliceAddress, + LightningMoney.Satoshis(2000000) // 0.02 BTC, + ) + { + FeeRatePerKw = LightningMoney.Satoshis(10000) + }; + + // Subscribe to the event and mine blocks when needed + clientHandler.OnWaitingConfirmation += async (_, _) => + { + // Mine blocks to confirm + await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); + }; + + // Act - Open the channel (this should send open_channel and wait for the flow to complete) + openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); + } + + var channelResponse = await openChannelTask; + Assert.NotNull(channelResponse); + + // Check if the channel exists (temporary or permanent) + var allChannels = _channelMemoryRepository.FindChannels(_ => true); + + Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); + } + + [Fact] + public async Task GivenMixedInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() + { + // Arrange + var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; + var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes + .First(x => x.LocalAlias == "alice"); + Assert.NotNull(alice); + + await _peerManager.StartAsync(CancellationToken.None); + + // Get the current block height + var currentHeight = (uint)await bitcoin.GetBlockCountAsync(); + + // Start the blockchain monitor at the current height + await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); + + // Fund our wallet + using (var scope = _serviceProvider.CreateScope()) + { + var walletService = scope.ServiceProvider + .GetRequiredService(); + + // Subscribe to blockchain monitor events + TaskCompletionSource tsc = new(); + uint txFirstSeenInBlock = int.MaxValue; + + var address1 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, false); + var address2 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Tr, false); + + void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) + { + if (e.Amount == LightningMoney.Satoshis(1100000) && e.WalletAddress == address1.Address) + { + txFirstSeenInBlock = e.BlockHeight; + } + else + { + Assert.Fail("Unexpected wallet movement detected: " + + $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); + } + } + + void OnNewBlockDetected(object? _, NewBlockEventArgs e) + { + if (e.Height >= txFirstSeenInBlock + 5) + tsc.TrySetResult(true); + } + + _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; + + // Send funds to our wallet + await bitcoin.SendToAddressAsync( + BitcoinAddress.Create(address1.Address, NBitcoin.Network.RegTest), + new Money(1100000, MoneyUnit.Satoshi)); // 0.011 + await bitcoin.SendToAddressAsync( + BitcoinAddress.Create(address2.Address, NBitcoin.Network.RegTest), + new Money(1100000, MoneyUnit.Satoshi)); // 0.011 + + // Mine blocks to confirm + await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); + + // wait for funding transaction to be confirmed + Assert.True(await tsc.Task); + _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; + } + + // Verify we have balance + var utxoRepository = _serviceProvider.GetRequiredService(); + var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); + Assert.True(balance > LightningMoney.Zero); + + // Connect to Alice + var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host + .SplitOnFirst("//")[1] + .SplitOnFirst(":")[0])).First(), 9735); + var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; + + await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); + + Task openChannelTask; + // Open channel - using the client handler + using (var scope = _serviceProvider.CreateScope()) + { + var clientHandler = scope.ServiceProvider.GetService( + typeof(IClientCommandHandler)) as + OpenChannelClientHandler ?? + throw new InvalidOperationException( + $"Unable to get service {nameof(OpenChannelClientHandler)}"); + var request = new OpenChannelClientRequest( + aliceAddress, + LightningMoney.Satoshis(2000000) // 0.02 BTC, + ) + { + FeeRatePerKw = LightningMoney.Satoshis(10000) + }; + + // Subscribe to the event and mine blocks when needed + clientHandler.OnWaitingConfirmation += async (_, _) => + { + // Mine blocks to confirm + await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); + }; + + // Act - Open the channel (this should send open_channel and wait for the flow to complete) + openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); + } + + var channelResponse = await openChannelTask; + Assert.NotNull(channelResponse); + + // Check if the channel exists (temporary or permanent) + var allChannels = _channelMemoryRepository.FindChannels(_ => true); + + Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); + } + + public void Dispose() + { + _blockchainMonitor.StopAsync().GetAwaiter().GetResult(); + PortPoolUtil.ReleasePort(_port); + if (File.Exists(_databaseFilePath)) + { + try + { + File.Delete(_databaseFilePath); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to delete database file: {ex.Message}"); + } + } + } +} \ No newline at end of file diff --git a/test/NLightning.Integration.Tests/Docker/Mock/FakeSecureKeyManager.cs b/test/NLightning.Integration.Tests/Docker/Mock/FakeSecureKeyManager.cs index 6752a37c..5cc6a2b0 100644 --- a/test/NLightning.Integration.Tests/Docker/Mock/FakeSecureKeyManager.cs +++ b/test/NLightning.Integration.Tests/Docker/Mock/FakeSecureKeyManager.cs @@ -2,6 +2,7 @@ namespace NLightning.Integration.Tests.Docker.Mock; +using Domain.Bitcoin.Constants; using Domain.Bitcoin.ValueObjects; using Domain.Crypto.ValueObjects; using Domain.Protocol.Interfaces; @@ -12,6 +13,9 @@ public class FakeSecureKeyManager : ISecureKeyManager private readonly ExtKey _p2TrKey; private readonly ExtKey _p2WpkhKey; + private readonly KeyPath _depositP2TrKeyPath = new(KeyConstants.P2TrKeyPathString); + private readonly KeyPath _depositP2WpkhKeyPath = new(KeyConstants.P2WpkhKeyPathString); + public BitcoinKeyPath KeyPath => new BitcoinKeyPath([]); // ReSharper disable once UnassignedGetOnlyAutoProperty @@ -40,12 +44,12 @@ public ExtPrivKey GetChannelKeyAtIndex(uint index) public ExtPrivKey GetDepositP2TrKeyAtIndex(uint index, bool isChange) { - return _p2TrKey.ToBytes(); + return _p2TrKey.Derive(_depositP2TrKeyPath.Derive(isChange ? "1" : "0")).Derive(index).ToBytes(); } public ExtPrivKey GetDepositP2WpkhKeyAtIndex(uint index, bool isChange) { - return _p2WpkhKey.ToBytes(); + return _p2WpkhKey.Derive(_depositP2WpkhKeyPath.Derive(isChange ? "1" : "0")).Derive(index).ToBytes(); } public CryptoKeyPair GetNodeKeyPair() From 9a13d5996288216d5bcf9d12263592623aa7f3ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADckolas=20Goline?= Date: Mon, 16 Mar 2026 15:10:09 -0300 Subject: [PATCH 17/20] fix formatting --- src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs b/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs index b9bf3a71..d83e357b 100644 --- a/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs +++ b/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs @@ -242,7 +242,7 @@ private static void HandlePeerDisconnection(PeerDisconnectedEventArgs _, Channel TaskCompletionSource tsc) { Console.Error.WriteLine("Peer disconnected"); - // tsc.TrySetException(new ChannelErrorException("Error opening channel: Peer disconnected")); + tsc.TrySetException(new ChannelErrorException("Error opening channel: Peer disconnected")); } private static void HandleExceptionRaised(Exception e, ChannelId _, From a61f2fbd92b03c269a118cf5990cb1ccd5dc014e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADckolas=20Goline?= Date: Wed, 18 Mar 2026 19:40:13 -0300 Subject: [PATCH 18/20] commit after rebase from main using dotnet10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Níckolas Goline --- .../Interfaces/INodeInfoQueryService.cs | 1 + .../CommitmentTransactionModelFactory.cs | 2 +- .../Database/Bitcoin/UtxoDbRepository.cs | 12 +- .../Node/FeatureSetTests.cs | 12 +- .../Messages/InitMessageTests.cs | 8 -- .../Docker/ChannelOpeningFlowTests.cs | 119 ++++++++++-------- .../Docker/Mock/FakeSecureKeyManager.cs | 19 ++- .../NLightning.Integration.Tests.csproj | 1 + 8 files changed, 93 insertions(+), 81 deletions(-) diff --git a/src/NLightning.Daemon/Interfaces/INodeInfoQueryService.cs b/src/NLightning.Daemon/Interfaces/INodeInfoQueryService.cs index 88b5ab4c..4eea1a56 100644 --- a/src/NLightning.Daemon/Interfaces/INodeInfoQueryService.cs +++ b/src/NLightning.Daemon/Interfaces/INodeInfoQueryService.cs @@ -1,6 +1,7 @@ using NLightning.Daemon.Contracts.Control; namespace NLightning.Daemon.Interfaces; + public interface INodeInfoQueryService { Task QueryAsync(CancellationToken ct); diff --git a/src/NLightning.Domain/Bitcoin/Transactions/Factories/CommitmentTransactionModelFactory.cs b/src/NLightning.Domain/Bitcoin/Transactions/Factories/CommitmentTransactionModelFactory.cs index 3e90e185..8dd6d33f 100644 --- a/src/NLightning.Domain/Bitcoin/Transactions/Factories/CommitmentTransactionModelFactory.cs +++ b/src/NLightning.Domain/Bitcoin/Transactions/Factories/CommitmentTransactionModelFactory.cs @@ -80,7 +80,7 @@ public CommitmentTransactionModel CreateCommitmentTransactionModel(ChannelModel // Calculate base weight var weight = WeightConstants.TransactionBaseWeight + TransactionConstants.CommitmentTransactionInputWeight - // + htlcs.Count * WeightConstants.HtlcOutputWeight + // + htlcs.Count * WeightConstants.HtlcOutputWeight + WeightConstants.P2WshOutputWeight; // To Local Output // Set initial amounts for to_local and to_remote outputs diff --git a/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/UtxoDbRepository.cs b/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/UtxoDbRepository.cs index 4a67661e..a440c6db 100644 --- a/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/UtxoDbRepository.cs +++ b/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/UtxoDbRepository.cs @@ -35,7 +35,7 @@ public async Task> GetUnspentAsync(bool includeWalletAddr { var query = Get(asNoTracking: true).AsQueryable(); if (includeWalletAddress) - query.Include(x => x.WalletAddress); + query = query.Include(x => x.WalletAddress); var utxoSet = await query.ToListAsync(); @@ -73,11 +73,11 @@ private UtxoModel MapEntityToModel(UtxoEntity entity) entity.BlockHeight, entity.AddressIndex, entity.IsAddressChange, entity.AddressType); - if (entity.WalletAddress is not null) - { - var walletAddressModel = WalletAddressesDbRepository.MapEntityToModel(entity.WalletAddress); - utxoModel.SetWalletAddress(walletAddressModel); - } + if (entity.WalletAddress is null) + return utxoModel; + + var walletAddressModel = WalletAddressesDbRepository.MapEntityToModel(entity.WalletAddress); + utxoModel.SetWalletAddress(walletAddressModel); return utxoModel; } diff --git a/test/NLightning.Domain.Tests/Node/FeatureSetTests.cs b/test/NLightning.Domain.Tests/Node/FeatureSetTests.cs index 24de9ae1..ad943d9d 100644 --- a/test/NLightning.Domain.Tests/Node/FeatureSetTests.cs +++ b/test/NLightning.Domain.Tests/Node/FeatureSetTests.cs @@ -62,10 +62,6 @@ public void Given_Features_When_UnsetFeatureA_Then_FeatureBIsSet(Feature feature [Theory] [InlineData(Feature.GossipQueriesEx, Feature.GossipQueries, false)] [InlineData(Feature.GossipQueriesEx, Feature.GossipQueries, true)] - [InlineData(Feature.PaymentSecret, Feature.VarOnionOptin, false)] - [InlineData(Feature.PaymentSecret, Feature.VarOnionOptin, true)] - [InlineData(Feature.OptionAnchors, Feature.OptionStaticRemoteKey, false)] - [InlineData(Feature.OptionAnchors, Feature.OptionStaticRemoteKey, true)] public void Given_Features_When_SetFeatureADependsOnFeatureB_Then_FeatureBIsSet( Feature feature, Feature dependsOn, bool isCompulsory) { @@ -86,10 +82,6 @@ public void Given_Features_When_SetFeatureADependsOnFeatureB_Then_FeatureBIsSet( [Theory] [InlineData(Feature.GossipQueries, Feature.GossipQueriesEx, false)] [InlineData(Feature.GossipQueries, Feature.GossipQueriesEx, true)] - [InlineData(Feature.VarOnionOptin, Feature.PaymentSecret, false)] - [InlineData(Feature.VarOnionOptin, Feature.PaymentSecret, true)] - [InlineData(Feature.OptionStaticRemoteKey, Feature.OptionAnchors, false)] - [InlineData(Feature.OptionStaticRemoteKey, Feature.OptionAnchors, true)] public void Given_Features_When_UnsetFeatureA_Then_FeatureBIsUnset(Feature feature, Feature dependent, bool isCompulsory) { @@ -117,10 +109,10 @@ public void Given_Features_When_SetUnknownFeature_Then_UnknownFeatureIsSet() features.Changed += (_, _) => eventRaised = true; // Act - features.SetFeature(42, true); + features.SetFeature(134, true); // Assert - Assert.True(features.IsFeatureSet(42, false)); + Assert.True(features.IsFeatureSet(134, false)); Assert.True(eventRaised); } diff --git a/test/NLightning.Infrastructure.Serialization.Tests/Messages/InitMessageTests.cs b/test/NLightning.Infrastructure.Serialization.Tests/Messages/InitMessageTests.cs index d3a8adf2..4ab98a42 100644 --- a/test/NLightning.Infrastructure.Serialization.Tests/Messages/InitMessageTests.cs +++ b/test/NLightning.Infrastructure.Serialization.Tests/Messages/InitMessageTests.cs @@ -34,11 +34,7 @@ public async Task var stream = new MemoryStream( Convert.FromHexString( -<<<<<<< HEAD - "000202000002020001206FE28C0AB6F1B372C1A6A246AE63F74F931E8365E15A089C68D6190000000000")); -======= "00025101000610000000510101206fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000")); ->>>>>>> ea0794c (add more tests and update failing tests to new rules) // Act var initMessage = await _initMessageTypeSerializer.DeserializeAsync(stream); @@ -87,11 +83,7 @@ public async Task Given_ValidPayloadAndExtension_When_SerializeAsync_Then_Writes var stream = new MemoryStream(); var expectedBytes = Convert.FromHexString( -<<<<<<< HEAD - "000202000002020001206FE28C0AB6F1B372C1A6A246AE63F74F931E8365E15A089C68D6190000000000"); -======= "00025101000610000000510101206fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000"); ->>>>>>> ea0794c (add more tests and update failing tests to new rules) // Act await _initMessageTypeSerializer.SerializeAsync(message, stream); diff --git a/test/NLightning.Integration.Tests/Docker/ChannelOpeningFlowTests.cs b/test/NLightning.Integration.Tests/Docker/ChannelOpeningFlowTests.cs index c0703111..fe009fd0 100644 --- a/test/NLightning.Integration.Tests/Docker/ChannelOpeningFlowTests.cs +++ b/test/NLightning.Integration.Tests/Docker/ChannelOpeningFlowTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Options; using Moq.Protected; using NBitcoin; +using NLightning.Application; using NLightning.Daemon.Handlers; using NLightning.Daemon.Interfaces; using NLightning.Domain.Bitcoin.Events; @@ -26,28 +27,26 @@ using NLightning.Domain.Protocol.Constants; using NLightning.Domain.Protocol.Interfaces; using NLightning.Domain.Protocol.ValueObjects; +using NLightning.Infrastructure; +using NLightning.Infrastructure.Bitcoin; using NLightning.Infrastructure.Bitcoin.Builders; using NLightning.Infrastructure.Bitcoin.Options; using NLightning.Infrastructure.Bitcoin.Services; using NLightning.Infrastructure.Bitcoin.Signers; +using NLightning.Infrastructure.Bitcoin.Wallet.Interfaces; +using NLightning.Infrastructure.Persistence; using NLightning.Infrastructure.Persistence.Contexts; -using NLightning.Integration.Tests.Docker.Mock; -using NLightning.Integration.Tests.Docker.Utils; +using NLightning.Infrastructure.Repositories; +using NLightning.Infrastructure.Serialization; using NLightning.Tests.Utils; using ServiceStack; -using Xunit.Abstractions; -namespace NLightning.Integration.Tests.Channels; +namespace NLightning.Integration.Tests.Docker; -using Application; using Fixtures; -using Infrastructure; -using Infrastructure.Bitcoin; -using Infrastructure.Bitcoin.Wallet.Interfaces; -using Infrastructure.Persistence; -using Infrastructure.Repositories; -using Infrastructure.Serialization; +using Mock; using TestCollections; +using Utils; [Collection(LightningRegtestNetworkFixtureCollection.Name)] public class ChannelOpeningFlowTests : IDisposable @@ -59,12 +58,10 @@ public class ChannelOpeningFlowTests : IDisposable private readonly int _port; private readonly string _databaseFilePath = $"nlightning_channel_test_{Guid.NewGuid()}.db"; private readonly IServiceProvider _serviceProvider; - private readonly ITestOutputHelper _output; public ChannelOpeningFlowTests(LightningRegtestNetworkFixture fixture, ITestOutputHelper output) { _lightningRegtestNetworkFixture = fixture; - _output = output; Console.SetOut(new TestOutputWriter(output)); _port = PortPoolUtil.GetAvailablePortAsync().GetAwaiter().GetResult(); @@ -202,7 +199,7 @@ public async Task GivenSingleP2WPKHInput_WhenHandleAsyncIsCalled_ChannelOpensCor await _peerManager.StartAsync(CancellationToken.None); // Get the current block height - var currentHeight = (uint)await bitcoin.GetBlockCountAsync(); + var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); // Start the blockchain monitor at the current height await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); @@ -242,12 +239,13 @@ void OnNewBlockDetected(object? _, NewBlockEventArgs e) _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; // Send funds to our wallet - await bitcoin.SendToAddressAsync( - BitcoinAddress.Create(address.Address, NBitcoin.Network.RegTest), - new Money(1, MoneyUnit.BTC)); + await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address.Address, NBitcoin.Network.RegTest), + new Money(1, MoneyUnit.BTC), TestContext.Current.CancellationToken); // Mine blocks to confirm - await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); + await bitcoin.GenerateToAddressAsync( + 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), + TestContext.Current.CancellationToken); // wait for funding transaction to be confirmed Assert.True(await tsc.Task); @@ -263,7 +261,9 @@ await bitcoin.SendToAddressAsync( // Connect to Alice var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host .SplitOnFirst("//")[1] - .SplitOnFirst(":")[0])).First(), 9735); + .SplitOnFirst(":")[0], + TestContext.Current.CancellationToken)).First(), + 9735); var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); @@ -318,7 +318,7 @@ public async Task GivenMultipleP2WPKHInput_WhenHandleAsyncIsCalled_ChannelOpensC await _peerManager.StartAsync(CancellationToken.None); // Get the current block height - var currentHeight = (uint)await bitcoin.GetBlockCountAsync(); + var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); // Start the blockchain monitor at the current height await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); @@ -359,15 +359,17 @@ void OnNewBlockDetected(object? _, NewBlockEventArgs e) _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; // Send funds to our wallet - await bitcoin.SendToAddressAsync( - BitcoinAddress.Create(address1.Address, NBitcoin.Network.RegTest), - new Money(1100000, MoneyUnit.Satoshi)); // 0.011 - await bitcoin.SendToAddressAsync( - BitcoinAddress.Create(address2.Address, NBitcoin.Network.RegTest), - new Money(1100000, MoneyUnit.Satoshi)); // 0.011 + await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address1.Address, NBitcoin.Network.RegTest), + new Money(1100000, MoneyUnit.Satoshi), + TestContext.Current.CancellationToken); // 0.011 + await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address2.Address, NBitcoin.Network.RegTest), + new Money(1100000, MoneyUnit.Satoshi), + TestContext.Current.CancellationToken); // 0.011 // Mine blocks to confirm - await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); + await bitcoin.GenerateToAddressAsync( + 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), + TestContext.Current.CancellationToken); // wait for funding transaction to be confirmed Assert.True(await tsc.Task); @@ -383,7 +385,9 @@ await bitcoin.SendToAddressAsync( // Connect to Alice var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host .SplitOnFirst("//")[1] - .SplitOnFirst(":")[0])).First(), 9735); + .SplitOnFirst(":")[0], + TestContext.Current.CancellationToken)).First(), + 9735); var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); @@ -438,7 +442,7 @@ public async Task GivenSingleP2TRInput_WhenHandleAsyncIsCalled_ChannelOpensCorre await _peerManager.StartAsync(CancellationToken.None); // Get the current block height - var currentHeight = (uint)await bitcoin.GetBlockCountAsync(); + var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); // Start the blockchain monitor at the current height await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); @@ -478,12 +482,13 @@ void OnNewBlockDetected(object? _, NewBlockEventArgs e) _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; // Send funds to our wallet - await bitcoin.SendToAddressAsync( - BitcoinAddress.Create(address.Address, NBitcoin.Network.RegTest), - new Money(1, MoneyUnit.BTC)); + await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address.Address, NBitcoin.Network.RegTest), + new Money(1, MoneyUnit.BTC), TestContext.Current.CancellationToken); // Mine blocks to confirm - await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); + await bitcoin.GenerateToAddressAsync( + 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), + TestContext.Current.CancellationToken); // wait for funding transaction to be confirmed Assert.True(await tsc.Task); @@ -499,7 +504,9 @@ await bitcoin.SendToAddressAsync( // Connect to Alice var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host .SplitOnFirst("//")[1] - .SplitOnFirst(":")[0])).First(), 9735); + .SplitOnFirst(":")[0], + TestContext.Current.CancellationToken)).First(), + 9735); var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); @@ -554,7 +561,7 @@ public async Task GivenMultipleP2TRInput_WhenHandleAsyncIsCalled_ChannelOpensCor await _peerManager.StartAsync(CancellationToken.None); // Get the current block height - var currentHeight = (uint)await bitcoin.GetBlockCountAsync(); + var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); // Start the blockchain monitor at the current height await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); @@ -595,15 +602,17 @@ void OnNewBlockDetected(object? _, NewBlockEventArgs e) _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; // Send funds to our wallet - await bitcoin.SendToAddressAsync( - BitcoinAddress.Create(address1.Address, NBitcoin.Network.RegTest), - new Money(1100000, MoneyUnit.Satoshi)); // 0.011 - await bitcoin.SendToAddressAsync( - BitcoinAddress.Create(address2.Address, NBitcoin.Network.RegTest), - new Money(1100000, MoneyUnit.Satoshi)); // 0.011 + await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address1.Address, NBitcoin.Network.RegTest), + new Money(1100000, MoneyUnit.Satoshi), + TestContext.Current.CancellationToken); // 0.011 + await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address2.Address, NBitcoin.Network.RegTest), + new Money(1100000, MoneyUnit.Satoshi), + TestContext.Current.CancellationToken); // 0.011 // Mine blocks to confirm - await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); + await bitcoin.GenerateToAddressAsync( + 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), + TestContext.Current.CancellationToken); // wait for funding transaction to be confirmed Assert.True(await tsc.Task); @@ -619,7 +628,9 @@ await bitcoin.SendToAddressAsync( // Connect to Alice var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host .SplitOnFirst("//")[1] - .SplitOnFirst(":")[0])).First(), 9735); + .SplitOnFirst(":")[0], + TestContext.Current.CancellationToken)).First(), + 9735); var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); @@ -674,7 +685,7 @@ public async Task GivenMixedInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly( await _peerManager.StartAsync(CancellationToken.None); // Get the current block height - var currentHeight = (uint)await bitcoin.GetBlockCountAsync(); + var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); // Start the blockchain monitor at the current height await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); @@ -715,15 +726,17 @@ void OnNewBlockDetected(object? _, NewBlockEventArgs e) _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; // Send funds to our wallet - await bitcoin.SendToAddressAsync( - BitcoinAddress.Create(address1.Address, NBitcoin.Network.RegTest), - new Money(1100000, MoneyUnit.Satoshi)); // 0.011 - await bitcoin.SendToAddressAsync( - BitcoinAddress.Create(address2.Address, NBitcoin.Network.RegTest), - new Money(1100000, MoneyUnit.Satoshi)); // 0.011 + await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address1.Address, NBitcoin.Network.RegTest), + new Money(1100000, MoneyUnit.Satoshi), + TestContext.Current.CancellationToken); // 0.011 + await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address2.Address, NBitcoin.Network.RegTest), + new Money(1100000, MoneyUnit.Satoshi), + TestContext.Current.CancellationToken); // 0.011 // Mine blocks to confirm - await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); + await bitcoin.GenerateToAddressAsync( + 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), + TestContext.Current.CancellationToken); // wait for funding transaction to be confirmed Assert.True(await tsc.Task); @@ -739,7 +752,9 @@ await bitcoin.SendToAddressAsync( // Connect to Alice var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host .SplitOnFirst("//")[1] - .SplitOnFirst(":")[0])).First(), 9735); + .SplitOnFirst(":")[0], + TestContext.Current.CancellationToken)).First(), + 9735); var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); diff --git a/test/NLightning.Integration.Tests/Docker/Mock/FakeSecureKeyManager.cs b/test/NLightning.Integration.Tests/Docker/Mock/FakeSecureKeyManager.cs index 5cc6a2b0..12e0c14b 100644 --- a/test/NLightning.Integration.Tests/Docker/Mock/FakeSecureKeyManager.cs +++ b/test/NLightning.Integration.Tests/Docker/Mock/FakeSecureKeyManager.cs @@ -13,10 +13,14 @@ public class FakeSecureKeyManager : ISecureKeyManager private readonly ExtKey _p2TrKey; private readonly ExtKey _p2WpkhKey; + private readonly KeyPath _channelKeyPath = new(KeyConstants.ChannelKeyPathString); private readonly KeyPath _depositP2TrKeyPath = new(KeyConstants.P2TrKeyPathString); private readonly KeyPath _depositP2WpkhKeyPath = new(KeyConstants.P2WpkhKeyPathString); - public BitcoinKeyPath KeyPath => new BitcoinKeyPath([]); + private readonly object _lastUsedIndexLock = new(); + private uint _lastUsedIndex; + + public BitcoinKeyPath KeyPath => new([]); // ReSharper disable once UnassignedGetOnlyAutoProperty public BitcoinKeyPath ChannelKeyPath { get; } @@ -33,13 +37,20 @@ public FakeSecureKeyManager() public ExtPrivKey GetNextChannelKey(out uint index) { - index = 0; - return _nodeKey.ToBytes(); + lock (_lastUsedIndexLock) + { + _lastUsedIndex++; + index = _lastUsedIndex; + } + + var derivedKey = _nodeKey.Derive(_channelKeyPath.Derive(index)); + return derivedKey.ToBytes(); } public ExtPrivKey GetChannelKeyAtIndex(uint index) { - return _nodeKey.ToBytes(); + var derivedKey = _nodeKey.Derive(_channelKeyPath.Derive(index)); + return derivedKey.ToBytes(); } public ExtPrivKey GetDepositP2TrKeyAtIndex(uint index, bool isChange) diff --git a/test/NLightning.Integration.Tests/NLightning.Integration.Tests.csproj b/test/NLightning.Integration.Tests/NLightning.Integration.Tests.csproj index bcf81edc..e5bc561e 100644 --- a/test/NLightning.Integration.Tests/NLightning.Integration.Tests.csproj +++ b/test/NLightning.Integration.Tests/NLightning.Integration.Tests.csproj @@ -37,6 +37,7 @@ + From 0d57b5a3fb7116a0fde3cdb1a4201a90b2ffa210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADckolas=20Goline?= Date: Thu, 19 Mar 2026 18:29:30 -0300 Subject: [PATCH 19/20] add OpenChannelSubscription command and related handlers; update channel management and logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Níckolas Goline --- .../Handlers/AcceptChannel1MessageHandler.cs | 63 +- .../Handlers/ChannelReadyMessageHandler.cs | 134 +- .../Handlers/FundingCreatedMessageHandler.cs | 3 +- .../Handlers/FundingSignedMessageHandler.cs | 44 +- .../Channels/Managers/ChannelManager.cs | 2 +- .../Handlers/OpenChannelMessageHandler.cs | 26 + .../Ipc/NamedPipeIpcClient.cs | 28 + .../Printers/NodeInfoPrinter.cs | 9 + .../Printers/OpenChannelPrinter.cs | 6 +- .../OpenChannelSubscriptionPrinter.cs | 28 + src/NLightning.Client/Program.cs | 4 +- .../Control/NodeInfoResponse.cs | 2 + .../Extensions/NodeServiceExtensions.cs | 9 +- .../Handlers/OpenChannelClientHandler.cs | 202 +- .../OpenChannelClientSubscriptionHandler.cs | 184 ++ .../Interfaces/IClientCommandHandler.cs | 14 + .../Ipc/Handlers/NodeInfoIpcHandler.cs | 2 + .../OpenChannelSubscriptionIpcHandler.cs | 98 + .../Services/Ipc/NamedPipeIpcService.cs | 7 +- .../Services/NltgDaemonService.cs | 13 +- .../Services/NodeInfoQueryService.cs | 18 +- .../Events/ChannelUpdatedEventArgs.cs | 13 + .../Events/ChannelUpgradedEventArgs.cs | 15 + .../Interfaces/IChannelMemoryRepository.cs | 103 +- .../Client/Constants/ErrorCodes.cs | 1 + .../Client/Enums/ClientCommand.cs | 3 +- .../OpenChannelClientSubscriptionRequest.cs | 13 + .../Responses/OpenChannelClientResponse.cs | 7 +- .../OpenChannelClientSubscriptionResponse.cs | 18 + .../Crypto/ValueObjects/CompactPubKey.cs | 2 +- .../Node/Events/PeerDisconnectedEventArgs.cs | 4 +- .../Interfaces/IPeerCommunicationService.cs | 2 +- .../Wallet/BitcoinChainService.cs | 9 +- .../Wallet/BlockchainMonitorService.cs | 63 +- .../Database/BaseDbRepository.cs | 24 +- .../Channel/ChannelConfigDbRepository.cs | 3 +- .../Database/Channel/ChannelDbRepository.cs | 1 + .../Memory/ChannelMemoryRepository.cs | 54 +- .../Node/Services/PeerCommunicationService.cs | 4 +- .../Node/Services/PeerService.cs | 6 +- .../Transport/Interfaces/ITcpService.cs | 11 + .../Transport/Services/TcpService.cs | 11 +- .../OpenChannelSubscriptionIpcRequest.cs | 20 + .../Responses/NodeInfoIpcResponse.cs | 14 +- .../Responses/OpenChannelIpcResponse.cs | 7 +- .../OpenChannelSubscriptionIpcResponse.cs | 32 + .../FundingCreatedMessageHandlerTests.cs | 54 +- .../Handlers/OpenChannelClientHandlerTests.cs | 387 ++++ ...enChannelClientSubscriptionHandlerTests.cs | 307 +++ .../Docker/ChannelOpeningFlowTests.cs | 1695 +++++++++-------- 50 files changed, 2598 insertions(+), 1181 deletions(-) create mode 100644 src/NLightning.Client/Handlers/OpenChannelMessageHandler.cs create mode 100644 src/NLightning.Client/Printers/OpenChannelSubscriptionPrinter.cs create mode 100644 src/NLightning.Daemon/Handlers/OpenChannelClientSubscriptionHandler.cs create mode 100644 src/NLightning.Daemon/Ipc/Handlers/OpenChannelSubscriptionIpcHandler.cs create mode 100644 src/NLightning.Domain/Channels/Events/ChannelUpdatedEventArgs.cs create mode 100644 src/NLightning.Domain/Channels/Events/ChannelUpgradedEventArgs.cs create mode 100644 src/NLightning.Domain/Client/Requests/OpenChannelClientSubscriptionRequest.cs create mode 100644 src/NLightning.Domain/Client/Responses/OpenChannelClientSubscriptionResponse.cs create mode 100644 src/NLightning.Transport.Ipc/Requests/OpenChannelSubscriptionIpcRequest.cs create mode 100644 src/NLightning.Transport.Ipc/Responses/OpenChannelSubscriptionIpcResponse.cs create mode 100644 test/NLightning.Daemon.Tests/Handlers/OpenChannelClientHandlerTests.cs create mode 100644 test/NLightning.Daemon.Tests/Handlers/OpenChannelClientSubscriptionHandlerTests.cs diff --git a/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs b/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs index eac26410..ece5ff96 100644 --- a/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs +++ b/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using NLightning.Domain.Persistence.Interfaces; namespace NLightning.Application.Channels.Handlers; @@ -18,7 +19,6 @@ namespace NLightning.Application.Channels.Handlers; using Domain.Enums; using Domain.Exceptions; using Domain.Node.Options; -using Domain.Persistence.Interfaces; using Domain.Protocol.Interfaces; using Domain.Protocol.Messages; using Domain.Protocol.Models; @@ -73,8 +73,9 @@ public AcceptChannel1MessageHandler(IBitcoinWalletService bitcoinWalletService, public async Task HandleAsync(AcceptChannel1Message message, ChannelState currentState, FeatureOptions negotiatedFeatures, CompactPubKey peerPubKey) { - _logger.LogTrace("Processing AcceptChannel1Message with ChannelId: {ChannelId} from Peer: {PeerPubKey}", - message.Payload.ChannelId, peerPubKey); + if (_logger.IsEnabled(LogLevel.Trace)) + _logger.LogTrace("Processing AcceptChannel1Message with ChannelId: {ChannelId} from Peer: {PeerPubKey}", + message.Payload.ChannelId, peerPubKey); var payload = message.Payload; @@ -154,6 +155,9 @@ public AcceptChannel1MessageHandler(IBitcoinWalletService bitcoinWalletService, tempChannel.AddCommitmentNumber(commitmentNumber); + // Keep the oldChannelId for later + var oldChannelId = tempChannel.ChannelId; + try { var fundingAmount = tempChannel.LocalBalance + tempChannel.RemoteBalance; @@ -179,10 +183,15 @@ public AcceptChannel1MessageHandler(IBitcoinWalletService bitcoinWalletService, tempChannel.ChangeAddress = fundingTransactionModel.ChangeAddress; // Create a new channelId - var oldChannelId = tempChannel.ChannelId; tempChannel.UpdateChannelId( _channelIdFactory.CreateV1(fundingOutput.TransactionId.Value, fundingOutput.Index.Value)); + // Check if the channel already exists in the database (it never should) + var existingChannel = await _unitOfWork.ChannelDbRepository.GetByIdAsync(tempChannel.ChannelId); + if (existingChannel is not null) + throw new ChannelErrorException("Channel already exists in the database", tempChannel.ChannelId, + "Sorry, we had an internal error"); + // Register the channel with the signer _lightningSigner.RegisterChannel(tempChannel.ChannelId, tempChannel.GetSigningInfo()); @@ -201,19 +210,13 @@ public AcceptChannel1MessageHandler(IBitcoinWalletService bitcoinWalletService, tempChannel.UpdateLastSentSignature(ourSignature); tempChannel.UpdateState(ChannelState.V1FundingCreated); - // Save to the database - await PersistChannelAsync(tempChannel); - // Create the funding created message var fundingCreatedMessage = _messageFactory.CreateFundingCreatedMessage(oldChannelId, fundingOutput.TransactionId.Value, fundingOutput.Index.Value, ourSignature); - // Add the channel to the dictionary - _channelMemoryRepository.AddChannel(tempChannel); - - // Remove the temporary channel - _channelMemoryRepository.RemoveTemporaryChannel(peerPubKey, oldChannelId); + // Upgrade the channel in the dictionary + _channelMemoryRepository.UpgradeChannel(oldChannelId, tempChannel); // Update the locked utxos _utxoMemoryRepository.UpgradeChannelIdOnLockedUtxos(oldChannelId, tempChannel.ChannelId); @@ -222,32 +225,20 @@ public AcceptChannel1MessageHandler(IBitcoinWalletService bitcoinWalletService, } catch (Exception e) { - throw new ChannelErrorException("Error creating commitment transaction", e); - } - } - - /// - /// Persists a channel to the database using a scoped Unit of Work - /// - private async Task PersistChannelAsync(ChannelModel channel) - { - try - { - // Check if the channel already exists - var existingChannel = await _unitOfWork.ChannelDbRepository.GetByIdAsync(channel.ChannelId); - if (existingChannel is not null) - throw new ChannelWarningException("Channel already exists", channel.ChannelId, - "This channel is already in our database"); + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation("Forgetting channel {channelId}", tempChannel.ChannelId); - await _unitOfWork.ChannelDbRepository.AddAsync(channel); - await _unitOfWork.SaveChangesAsync(); + if (tempChannel.ChannelId != oldChannelId) + { + if (!_channelMemoryRepository.TryRemoveTemporaryChannel(tempChannel.RemoteNodeId, + tempChannel.ChannelId)) + _logger.LogWarning("Unable to remove temporary channel with id {channelId} for peer {peerPubKey}", + tempChannel.ChannelId, peerPubKey); + else if (!_channelMemoryRepository.TryRemoveChannel(tempChannel.ChannelId)) + _logger.LogWarning("Unable to remove channel with id {channelId}", tempChannel.ChannelId); + } - _logger.LogDebug("Successfully persisted channel {ChannelId} to database", channel.ChannelId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to persist channel {ChannelId} to database", channel.ChannelId); - throw; + throw new ChannelErrorException("Error creating commitment transaction", e); } } } \ No newline at end of file diff --git a/src/NLightning.Application/Channels/Handlers/ChannelReadyMessageHandler.cs b/src/NLightning.Application/Channels/Handlers/ChannelReadyMessageHandler.cs index 7112a3c6..6bb86c26 100644 --- a/src/NLightning.Application/Channels/Handlers/ChannelReadyMessageHandler.cs +++ b/src/NLightning.Application/Channels/Handlers/ChannelReadyMessageHandler.cs @@ -32,8 +32,9 @@ public ChannelReadyMessageHandler(IChannelMemoryRepository channelMemoryReposito public async Task HandleAsync(ChannelReadyMessage message, ChannelState currentState, FeatureOptions negotiatedFeatures, CompactPubKey peerPubKey) { - _logger.LogTrace("Processing ChannelReadyMessage with ChannelId: {ChannelId} from Peer: {PeerPubKey}", - message.Payload.ChannelId, peerPubKey); + if (_logger.IsEnabled(LogLevel.Trace)) + _logger.LogTrace("Processing ChannelReadyMessage with ChannelId: {ChannelId} from Peer: {PeerPubKey}", + message.Payload.ChannelId, peerPubKey); var payload = message.Payload; @@ -41,8 +42,10 @@ public ChannelReadyMessageHandler(IChannelMemoryRepository channelMemoryReposito or ChannelState.ReadyForThem or ChannelState.ReadyForUs or ChannelState.Open)) - throw new ChannelErrorException("Channel had the wrong state", payload.ChannelId, - "This channel is not ready to be opened"); + throw new ChannelErrorException( + $"Unexpected ChannelReady message in state {Enum.GetName(currentState)}", + payload.ChannelId, + "Protocol violation: unexpected ChannelReady message"); // Check if there's a channel for this peer if (!_channelMemoryRepository.TryGetChannel(payload.ChannelId, out var channel)) @@ -56,87 +59,71 @@ or ChannelState.ReadyForUs "This channel requires a ShortChannelIdTlv to be provided"); // Store their new per-commitment point - if (channel.RemoteKeySet.CurrentPerCommitmentIndex == 0) + if (channel.RemoteKeySet!.CurrentPerCommitmentIndex == 0) channel.RemoteKeySet.UpdatePerCommitmentPoint(payload.SecondPerCommitmentPoint); - // Handle ScidAlias - if (currentState is ChannelState.Open or ChannelState.ReadyForThem) + switch (currentState) { - if (mustUseScidAlias) - { - if (ShouldReplaceAlias()) + case ChannelState.Open or ChannelState.ReadyForThem: // Handle ScidAlias { - var oldAlias = channel.RemoteAlias; - channel.RemoteAlias = message.ShortChannelIdTlv!.ShortChannelId; - - _logger.LogDebug("Updated remote alias for channel {ChannelId} from {OldAlias} to {NewAlias}", - payload.ChannelId, oldAlias, channel.RemoteAlias); - - await PersistChannelAsync(channel); + if (mustUseScidAlias) + { + if (ShouldReplaceAlias()) + { + var oldAlias = channel.RemoteAlias; + channel.RemoteAlias = message.ShortChannelIdTlv!.ShortChannelId; + + if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug( + "Updated remote alias for channel {ChannelId} from {OldAlias} to {NewAlias}", + payload.ChannelId, oldAlias, channel.RemoteAlias); + + await PersistChannelAsync(channel); + } + else if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Keeping existing remote alias {ExistingAlias} for channel {ChannelId}", + channel.RemoteAlias, + payload.ChannelId); + } + } + else if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug("Received duplicate ChannelReady message for channel {ChannelId} in Open state", + payload.ChannelId); + + break; } - else + case ChannelState.ReadyForUs: // We already sent our ChannelReady, now they sent theirs { - _logger.LogDebug( - "Keeping existing remote alias {ExistingAlias} for channel {ChannelId}", channel.RemoteAlias, - payload.ChannelId); - } - } - else - _logger.LogDebug("Received duplicate ChannelReady message for channel {ChannelId} in Open state", - payload.ChannelId); - - return null; // No further action needed, we are already open - } - - if (channel.IsInitiator) // Handle state transitions based on whether we are the initiator - { - // We already sent our ChannelReady, now they sent theirs - if (currentState == ChannelState.ReadyForUs) - { - // Valid transition: ReadyForUs -> Open - channel.UpdateState(ChannelState.Open); - await PersistChannelAsync(channel); - - _logger.LogInformation("Channel {ChannelId} is now open (we are initiator)", payload.ChannelId); - - // TODO: Notify application layer that channel is fully open - // TODO: Update routing tables + // Valid transition: ReadyForUs -> Open + channel.UpdateState(ChannelState.Open); + await PersistChannelAsync(channel); - return null; - } + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation("Channel {ChannelId} is now open", payload.ChannelId); - // Invalid state for initiator receiving ChannelReady - _logger.LogError( - "Received ChannelReady message for channel {ChannelId} in invalid state {CurrentState} (we are initiator). Expected: ReadyForUs", - payload.ChannelId, currentState); + // TODO: Notify application layer that channel is fully open + // TODO: Update routing tables - throw new ChannelErrorException($"Unexpected ChannelReady message in state {Enum.GetName(currentState)}", - payload.ChannelId, - "Protocol violation: unexpected ChannelReady message"); - } - - if (currentState == ChannelState.V1FundingSigned) // We are not the initiator - { - // First ChannelReady from initiator - // Valid transition: V1FundingSigned -> ReadyForThem - channel.UpdateState(ChannelState.ReadyForThem); - await PersistChannelAsync(channel); + break; + } + case ChannelState.V1FundingSigned: // First ChannelReady + { + // Valid transition: V1FundingSigned -> ReadyForThem + channel.UpdateState(ChannelState.ReadyForThem); + await PersistChannelAsync(channel); - _logger.LogInformation( - "Received ChannelReady from initiator for channel {ChannelId}, waiting for funding confirmation", - payload.ChannelId); + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation( + "Received ChannelReady from peer for channel {ChannelId}, waiting for funding confirmation", + payload.ChannelId); - return null; + break; + } } - // Invalid state for non-initiator receiving ChannelReady - _logger.LogError( - "Received ChannelReady message for channel {ChannelId} in invalid state {CurrentState} (we are not initiator). Expected: V1FundingSigned or ReadyForThem", - payload.ChannelId, currentState); - - throw new ChannelErrorException($"Unexpected ChannelReady message in state {Enum.GetName(currentState)}", - payload.ChannelId, - "Protocol violation: unexpected ChannelReady message"); + return null; // No further action needed } /// @@ -155,7 +142,8 @@ private async Task PersistChannelAsync(ChannelModel channel) _channelMemoryRepository.UpdateChannel(channel); - _logger.LogDebug("Successfully persisted channel {ChannelId} to database", channel.ChannelId); + if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug("Successfully persisted channel {ChannelId} to database", channel.ChannelId); } catch (Exception ex) { diff --git a/src/NLightning.Application/Channels/Handlers/FundingCreatedMessageHandler.cs b/src/NLightning.Application/Channels/Handlers/FundingCreatedMessageHandler.cs index 7dd13c4f..0745fcc7 100644 --- a/src/NLightning.Application/Channels/Handlers/FundingCreatedMessageHandler.cs +++ b/src/NLightning.Application/Channels/Handlers/FundingCreatedMessageHandler.cs @@ -114,7 +114,7 @@ public FundingCreatedMessageHandler(IBlockchainMonitor blockchainMonitor, IChann _channelMemoryRepository.AddChannel(channel); // Remove the temporary channel - _channelMemoryRepository.RemoveTemporaryChannel(peerPubKey, oldChannelId); + _channelMemoryRepository.TryRemoveTemporaryChannel(peerPubKey, oldChannelId); await _blockchainMonitor.WatchTransactionAsync(channel.ChannelId, payload.FundingTxId, channel.ChannelConfig.MinimumDepth); @@ -129,6 +129,7 @@ private async Task PersistChannelAsync(ChannelModel channel) { try { + // TODO: REVIEW FULL FLOW // Check if the channel already exists var existingChannel = await _unitOfWork.ChannelDbRepository.GetByIdAsync(channel.ChannelId); if (existingChannel is not null) diff --git a/src/NLightning.Application/Channels/Handlers/FundingSignedMessageHandler.cs b/src/NLightning.Application/Channels/Handlers/FundingSignedMessageHandler.cs index 91005eda..030dc372 100644 --- a/src/NLightning.Application/Channels/Handlers/FundingSignedMessageHandler.cs +++ b/src/NLightning.Application/Channels/Handlers/FundingSignedMessageHandler.cs @@ -21,7 +21,6 @@ namespace NLightning.Application.Channels.Handlers; public class FundingSignedMessageHandler : IChannelMessageHandler { private readonly IBlockchainMonitor _blockchainMonitor; - private readonly IBitcoinWalletService _bitcoinWalletService; private readonly IChannelMemoryRepository _channelMemoryRepository; private readonly ICommitmentTransactionBuilder _commitmentTransactionBuilder; private readonly ICommitmentTransactionModelFactory _commitmentTransactionModelFactory; @@ -32,7 +31,7 @@ public class FundingSignedMessageHandler : IChannelMessageHandler HandleAsync(FundingSignedMessage message, ChannelState currentState, FeatureOptions negotiatedFeatures, CompactPubKey peerPubKey) { - _logger.LogTrace("Processing FundingCreatedMessage with ChannelId: {ChannelId} from Peer: {PeerPubKey}", - message.Payload.ChannelId, peerPubKey); + if (_logger.IsEnabled(LogLevel.Trace)) + _logger.LogTrace("Processing FundingCreatedMessage with ChannelId: {ChannelId} from Peer: {PeerPubKey}", + message.Payload.ChannelId, peerPubKey); var payload = message.Payload; @@ -80,12 +79,8 @@ public FundingSignedMessageHandler(IBlockchainMonitor blockchainMonitor, IBitcoi // Validate remote signature for our local commitment transaction _lightningSigner.ValidateSignature(channel.ChannelId, payload.Signature, localUnsignedCommitmentTransaction); - // Update the channel with the new signatures and the new state + // Update the channel with the new signature channel.UpdateLastReceivedSignature(payload.Signature); - channel.UpdateState(ChannelState.V1FundingSigned); - - // Save to the database - await PersistChannelAsync(channel); // Get the locked utxos to create the funding transaction var utxos = _utxoMemoryRepository.GetLockedUtxosForChannel(channel.ChannelId); @@ -99,26 +94,43 @@ public FundingSignedMessageHandler(IBlockchainMonitor blockchainMonitor, IBitcoi if (!allSigned) throw new ChannelErrorException("Unable to sign all inputs for the funding transaction"); + // Persist the channel to the database before publishing the transaction, so the watched transaction can point + // to the channel + await PersistChannelAsync(channel); + await _blockchainMonitor.PublishAndWatchTransactionAsync(channel.ChannelId, unsignedFundingTransaction, channel.ChannelConfig.MinimumDepth); + // Now that we should remember the channel, we update its state + channel.UpdateState(ChannelState.V1FundingSigned); + + // Save to the database + await PersistChannelAsync(channel); + return null; } /// - /// Persists a channel to the database using the scoped Unit of Work + /// Persists a channel to the database using a scoped Unit of Work /// private async Task PersistChannelAsync(ChannelModel channel) { try { - // Check if the channel already exists - var existingChannel = await _unitOfWork.ChannelDbRepository.GetByIdAsync(channel.ChannelId) ?? throw new ChannelWarningException("Channel not found", channel.ChannelId, - "This channel is missing in our database"); - await _unitOfWork.ChannelDbRepository.UpdateAsync(channel); + // Update the channel in memory first + _channelMemoryRepository.UpdateChannel(channel); + + // Check if we are adding or if we need to update the channel + var existingChannel = await _unitOfWork.ChannelDbRepository.GetByIdAsync(channel.ChannelId); + if (existingChannel is not null) + await _unitOfWork.ChannelDbRepository.UpdateAsync(channel); + else + await _unitOfWork.ChannelDbRepository.AddAsync(channel); + await _unitOfWork.SaveChangesAsync(); - _logger.LogDebug("Successfully persisted channel {ChannelId} to database", channel.ChannelId); + if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug("Successfully persisted channel {ChannelId} to database", channel.ChannelId); } catch (Exception ex) { diff --git a/src/NLightning.Application/Channels/Managers/ChannelManager.cs b/src/NLightning.Application/Channels/Managers/ChannelManager.cs index 760db410..b9fb8456 100644 --- a/src/NLightning.Application/Channels/Managers/ChannelManager.cs +++ b/src/NLightning.Application/Channels/Managers/ChannelManager.cs @@ -162,7 +162,7 @@ private async Task PersistChannelAsync(ChannelModel channel) await unitOfWork.SaveChangesAsync(); // Remove from dictionaries - _channelMemoryRepository.RemoveChannel(channel.ChannelId); + _channelMemoryRepository.TryRemoveChannel(channel.ChannelId); _logger.LogDebug("Successfully persisted channel {ChannelId} to database", channel.ChannelId); } diff --git a/src/NLightning.Client/Handlers/OpenChannelMessageHandler.cs b/src/NLightning.Client/Handlers/OpenChannelMessageHandler.cs new file mode 100644 index 00000000..b4a2d6ca --- /dev/null +++ b/src/NLightning.Client/Handlers/OpenChannelMessageHandler.cs @@ -0,0 +1,26 @@ +namespace NLightning.Client.Handlers; + +using Domain.Channels.Enums; +using Ipc; +using Printers; + +internal class OpenChannelMessageHandler +{ + internal static async Task HandleAsync(string[] commandArgs, NamedPipeIpcClient client, + CancellationToken cancellationToken) + { + var channelResponse = await client.OpenChannelAsync(commandArgs[0], commandArgs[1], cancellationToken); + new OpenChannelPrinter().Print(channelResponse); + + while (!cancellationToken.IsCancellationRequested) + { + var subscriptionResponse = + await client.OpenChannelSubscriptionAsync(channelResponse.ChannelId, cancellationToken); + + new OpenChannelSubscriptionPrinter().Print(subscriptionResponse); + + if (subscriptionResponse.ChannelState is ChannelState.ReadyForUs or ChannelState.ReadyForThem) + break; + } + } +} \ No newline at end of file diff --git a/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs b/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs index 8841c421..e3c0917f 100644 --- a/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs +++ b/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs @@ -1,6 +1,7 @@ using System.Buffers; using System.IO.Pipes; using MessagePack; +using NLightning.Domain.Channels.ValueObjects; namespace NLightning.Client.Ipc; @@ -178,6 +179,33 @@ public async Task OpenChannelAsync(string nodeInfo, stri throw new InvalidOperationException($"IPC error {err.Code}: {err.Message}"); } + public async Task OpenChannelSubscriptionAsync( + ChannelId channelId, CancellationToken ct = default) + { + var req = new OpenChannelSubscriptionIpcRequest + { + ChannelId = channelId + }; + var payload = MessagePackSerializer.Serialize(req, cancellationToken: ct); + var env = new IpcEnvelope + { + Version = 1, + Command = ClientCommand.OpenChannelSubscription, + CorrelationId = Guid.NewGuid(), + AuthToken = await GetAuthTokenAsync(ct), + Payload = payload, + Kind = 0 + }; + + var respEnv = await SendAsync(env, ct); + if (respEnv.Kind != IpcEnvelopeKind.Error) + return MessagePackSerializer.Deserialize( + respEnv.Payload, cancellationToken: ct); + + var err = MessagePackSerializer.Deserialize(respEnv.Payload, cancellationToken: ct); + throw new InvalidOperationException($"IPC error {err.Code}: {err.Message}"); + } + private async Task SendAsync(IpcEnvelope envelope, CancellationToken ct) { await using var client = diff --git a/src/NLightning.Client/Printers/NodeInfoPrinter.cs b/src/NLightning.Client/Printers/NodeInfoPrinter.cs index 9151d4f7..9ba9ddca 100644 --- a/src/NLightning.Client/Printers/NodeInfoPrinter.cs +++ b/src/NLightning.Client/Printers/NodeInfoPrinter.cs @@ -7,6 +7,15 @@ public sealed class NodeInfoPrinter : IPrinter public void Print(NodeInfoIpcResponse item) { Console.WriteLine("Node Information:"); + Console.WriteLine(" Pubkey: {0}", item.PubKey); + Console.WriteLine(" Listening to:"); + foreach (var t in item.ListeningTo) + { + Console.WriteLine(" {0}", t); + } + + Console.WriteLine(); + Console.WriteLine("Network Information:"); Console.WriteLine(" Network: {0}", item.Network); Console.WriteLine(" Best Block Height: {0}", item.BestBlockHeight); Console.WriteLine(" Best Block Hash: {0}", item.BestBlockHash); diff --git a/src/NLightning.Client/Printers/OpenChannelPrinter.cs b/src/NLightning.Client/Printers/OpenChannelPrinter.cs index 106f882d..1c75a23c 100644 --- a/src/NLightning.Client/Printers/OpenChannelPrinter.cs +++ b/src/NLightning.Client/Printers/OpenChannelPrinter.cs @@ -6,9 +6,7 @@ public sealed class OpenChannelPrinter : IPrinter { public void Print(OpenChannelIpcResponse item) { - Console.WriteLine("Channel opened:"); - Console.WriteLine(" Tx Id: {0}", Convert.ToHexString(item.TxId).ToLowerInvariant()); - Console.WriteLine(" Index: {0}", item.Index); - Console.WriteLine(" ChannelId: {0}", item.ChannelId); + Console.WriteLine("Opening Channel: {0}", item.ChannelId); + Console.WriteLine("Peer accepted our Channel. Sending funding data to Peer."); } } \ No newline at end of file diff --git a/src/NLightning.Client/Printers/OpenChannelSubscriptionPrinter.cs b/src/NLightning.Client/Printers/OpenChannelSubscriptionPrinter.cs new file mode 100644 index 00000000..4536e43b --- /dev/null +++ b/src/NLightning.Client/Printers/OpenChannelSubscriptionPrinter.cs @@ -0,0 +1,28 @@ +using NLightning.Domain.Channels.Enums; + +namespace NLightning.Client.Printers; + +using Transport.Ipc.Responses; + +public sealed class OpenChannelSubscriptionPrinter : IPrinter +{ + public void Print(OpenChannelSubscriptionIpcResponse item) + { + switch (item.ChannelState) + { + case ChannelState.V1FundingSigned: + Console.WriteLine("Peer sent their signature. Sending ours."); + Console.WriteLine("Funding transaction published. TxId: {0}, Index: {1}", item.TxId, item.Index); + Console.WriteLine("Waiting for confirmations."); + Console.WriteLine("You can either wait for the full confirmation or press CTRL+C to quit."); + break; + case ChannelState.ReadyForThem or ChannelState.ReadyForUs: + Console.WriteLine("Channel is now open!"); + break; + default: + Console.WriteLine("We've got an unexpected Channel state update: {0}", + Enum.GetName(typeof(ChannelState), item.ChannelState)); + break; + } + } +} \ No newline at end of file diff --git a/src/NLightning.Client/Program.cs b/src/NLightning.Client/Program.cs index 41546ced..307fe3e9 100644 --- a/src/NLightning.Client/Program.cs +++ b/src/NLightning.Client/Program.cs @@ -1,4 +1,5 @@ using MessagePack; +using NLightning.Client.Handlers; using NLightning.Client.Ipc; using NLightning.Client.Printers; using NLightning.Client.Utils; @@ -66,8 +67,7 @@ break; case "openchannel": case "open-channel": - var channel = await client.OpenChannelAsync(commandArgs[0], commandArgs[1], cts.Token); - new OpenChannelPrinter().Print(channel); + OpenChannelMessageHandler.HandleAsync(commandArgs, client, cts.Token).GetAwaiter().GetResult(); break; default: Console.Error.WriteLine($"Unknown command: {cmd}"); diff --git a/src/NLightning.Daemon.Contracts/Control/NodeInfoResponse.cs b/src/NLightning.Daemon.Contracts/Control/NodeInfoResponse.cs index dbe506c8..4908e071 100644 --- a/src/NLightning.Daemon.Contracts/Control/NodeInfoResponse.cs +++ b/src/NLightning.Daemon.Contracts/Control/NodeInfoResponse.cs @@ -5,6 +5,8 @@ namespace NLightning.Daemon.Contracts.Control; /// public sealed class NodeInfoResponse { + public required string PubKey { get; init; } + public required string ListeningTo { get; init; } public string Network { get; init; } = string.Empty; public string BestBlockHash { get; init; } = string.Empty; public long BestBlockHeight { get; init; } diff --git a/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs b/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs index f8f77540..ad4c49e2 100644 --- a/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs +++ b/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs @@ -61,6 +61,10 @@ public static IHostBuilder ConfigureNltgServices(this IHostBuilder hostBuilder, services .AddScoped, OpenChannelClientHandler>(); + services + .AddScoped, + OpenChannelClientSubscriptionHandler>(); // Register IPC server and handlers services.AddSingleton(sp => @@ -68,10 +72,8 @@ public static IHostBuilder ConfigureNltgServices(this IHostBuilder hostBuilder, var ipcAuthenticator = sp.GetRequiredService(); var ipcFraming = sp.GetRequiredService(); var logger = sp.GetRequiredService>(); - var nodeOptions = sp.GetRequiredService>(); var ipcRequestRouter = sp.GetRequiredService(); - return new NamedPipeIpcService(ipcAuthenticator, configPath, ipcFraming, logger, nodeOptions, - ipcRequestRouter); + return new NamedPipeIpcService(ipcAuthenticator, configPath, ipcFraming, logger, ipcRequestRouter); }); services.AddSingleton(); services.AddSingleton(); @@ -82,6 +84,7 @@ public static IHostBuilder ConfigureNltgServices(this IHostBuilder hostBuilder, services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => { var cookiePath = NodeUtils.GetCookieFilePath(configPath); diff --git a/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs b/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs index d83e357b..48074607 100644 --- a/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs +++ b/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs @@ -3,6 +3,7 @@ namespace NLightning.Daemon.Handlers; using Domain.Bitcoin.Interfaces; +using Domain.Channels.Events; using Domain.Channels.Interfaces; using Domain.Channels.ValueObjects; using Domain.Client.Constants; @@ -17,8 +18,6 @@ namespace NLightning.Daemon.Handlers; using Domain.Node.Events; using Domain.Node.Interfaces; using Domain.Node.ValueObjects; -using Domain.Persistence.Interfaces; -using Domain.Protocol.Constants; using Domain.Protocol.Interfaces; using Domain.Protocol.Tlv; using Infrastructure.Bitcoin.Wallet.Interfaces; @@ -34,18 +33,18 @@ public sealed class OpenChannelClientHandler private readonly ILogger _logger; private readonly IMessageFactory _messageFactory; private readonly IPeerManager _peerManager; - private readonly IUnitOfWork _unitOfWork; private readonly IUtxoMemoryRepository _utxoMemoryRepository; - internal event EventHandler? OnWaitingConfirmation; + private ChannelId _channelId = ChannelId.Zero; + private IPeerService? _peerService; + /// public ClientCommand Command => ClientCommand.OpenChannel; public OpenChannelClientHandler(IBlockchainMonitor blockchainMonitor, IChannelFactory channelFactory, IChannelMemoryRepository channelMemoryRepository, ILogger logger, IMessageFactory messageFactory, - IPeerManager peerManager, IUnitOfWork unitOfWork, - IUtxoMemoryRepository utxoMemoryRepository) + IPeerManager peerManager, IUtxoMemoryRepository utxoMemoryRepository) { _blockchainMonitor = blockchainMonitor; _channelFactory = channelFactory; @@ -53,10 +52,10 @@ public OpenChannelClientHandler(IBlockchainMonitor blockchainMonitor, IChannelFa _logger = logger; _messageFactory = messageFactory; _peerManager = peerManager; - _unitOfWork = unitOfWork; _utxoMemoryRepository = utxoMemoryRepository; } + /// public async Task HandleAsync(OpenChannelClientRequest request, CancellationToken ct) { if (string.IsNullOrWhiteSpace(request.NodeInfo)) @@ -83,14 +82,22 @@ public async Task HandleAsync(OpenChannelClientReques var channel = await _channelFactory.CreateChannelV1AsInitiatorAsync(request, peer.NegotiatedFeatures, peerId); - _logger.LogTrace("Created Channel {id} with fundingPubKey: {fundingPubKey}", channel.ChannelId, - channel.LocalKeySet.FundingCompactPubKey); + // Save the channelId for later + _channelId = channel.ChannelId; + + if (_logger.IsEnabled(LogLevel.Trace)) + _logger.LogTrace("Created Temporary Channel {id} with fundingPubKey: {fundingPubKey}", channel.ChannelId, + channel.LocalKeySet.FundingCompactPubKey); + + // Select UTXOs and mark them as toSpend for this channel + _utxoMemoryRepository.LockUtxosToSpendOnChannel(request.FundingAmount, channel.ChannelId); + + // Create a task completion source for the response + var tsc = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); try { - // Select UTXOs and mark them as toSpend for this channel - var utxos = _utxoMemoryRepository.LockUtxosToSpendOnChannel(request.FundingAmount, channel.ChannelId); - // Add the channel to dictionaries _channelMemoryRepository.AddTemporaryChannel(peerId, channel); @@ -111,11 +118,9 @@ public async Task HandleAsync(OpenChannelClientReques var channelTypeTlv = new ChannelTypeTlv(featureSetBytes); // Create UpfrontShutdownScriptTlv if needed - UpfrontShutdownScriptTlv? upfrontShutdownScriptTlv = null; - if (channel.LocalUpfrontShutdownScript is not null) - upfrontShutdownScriptTlv = new UpfrontShutdownScriptTlv(channel.LocalUpfrontShutdownScript.Value); - else - upfrontShutdownScriptTlv = new UpfrontShutdownScriptTlv(Array.Empty()); + var upfrontShutdownScriptTlv = channel.LocalUpfrontShutdownScript is not null + ? new UpfrontShutdownScriptTlv(channel.LocalUpfrontShutdownScript.Value) + : new UpfrontShutdownScriptTlv(Array.Empty()); // Create the ChannelFlags var channelFlags = new ChannelFlags(ChannelFlag.None); @@ -132,123 +137,94 @@ public async Task HandleAsync(OpenChannelClientReques channel.LocalKeySet.HtlcCompactBasepoint, channel.LocalKeySet.CurrentPerCommitmentCompactPoint, channelFlags, channelTypeTlv, upfrontShutdownScriptTlv); - if (!peer.TryGetPeerService(out var peerService)) + if (!peer.TryGetPeerService(out _peerService)) throw new ClientException(ErrorCodes.InvalidOperation, "Error getting peerService from peer"); - var tsc = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - peerService.OnChannelMessageReceived += ChannelMessageHandlerEnvelope; - peerService.OnAttentionMessageReceived += AttentionMessageHandlerEnvelope; - peerService.OnDisconnect += PeerDisconnectionEnvelope; - peerService.OnExceptionRaised += ExceptionRaisedEnvelope; - - try - { - await peerService.SendMessageAsync(openChannel1Message); - } - catch - { - //Unsubscribe from the event so we don't have dangling memory - peerService.OnChannelMessageReceived -= ChannelMessageHandlerEnvelope; - peerService.OnAttentionMessageReceived -= AttentionMessageHandlerEnvelope; - peerService.OnDisconnect -= PeerDisconnectionEnvelope; - peerService.OnExceptionRaised -= ExceptionRaisedEnvelope; - - throw; - } - - var response = await tsc.Task; - - // Unsubscribe from the event - peerService.OnChannelMessageReceived -= ChannelMessageHandlerEnvelope; - peerService.OnAttentionMessageReceived -= AttentionMessageHandlerEnvelope; - peerService.OnDisconnect -= PeerDisconnectionEnvelope; - peerService.OnExceptionRaised -= ExceptionRaisedEnvelope; - - return response; - - // Envelopes for the events - void ChannelMessageHandlerEnvelope(object? _, ChannelMessageEventArgs args) => - HandleChannelMessage(args, channel.ChannelId, tsc); - - void AttentionMessageHandlerEnvelope(object? _, AttentionMessageEventArgs args) => - HandleAttentionMessage(args, channel.ChannelId, tsc); - - void PeerDisconnectionEnvelope(object? _, PeerDisconnectedEventArgs args) => - HandlePeerDisconnection(args, channel.ChannelId, tsc); - - void ExceptionRaisedEnvelope(object? _, Exception e) => - HandleExceptionRaised(e, channel.ChannelId, tsc); + // Subscribe to the events before sending the message + _peerService.OnAttentionMessageReceived += AttentionMessageHandlerEnvelope; + _peerService.OnDisconnect += PeerDisconnectionEnvelope; + _peerService.OnExceptionRaised += ExceptionRaisedEnvelope; + _channelMemoryRepository.OnChannelUpgraded += ChannelUpgradedHandlerEnvelope; + + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation("Sending OpenChannel message to peer {peerId} for channel {channelId}", + peerId, + channel.ChannelId); + await _peerService.SendMessageAsync(openChannel1Message); + + return await tsc.Task; } catch { - var utxos = _utxoMemoryRepository.ReturnUtxosNotSpentOnChannel(channel.ChannelId); - - // Since something went wrong, let's unlock the utxos on the database - foreach (var utxo in utxos) - _unitOfWork.UtxoDbRepository.Update(utxo); - - await _unitOfWork.SaveChangesAsync(); + _utxoMemoryRepository.ReturnUtxosNotSpentOnChannel(_channelId); throw; } + finally + { + //Unsubscribe from the events so we don't have dangling memory + _peerService?.OnAttentionMessageReceived -= AttentionMessageHandlerEnvelope; + _peerService?.OnDisconnect -= PeerDisconnectionEnvelope; + _peerService?.OnExceptionRaised -= ExceptionRaisedEnvelope; + _channelMemoryRepository.OnChannelUpgraded -= ChannelUpgradedHandlerEnvelope; + } + + // Envelopes for the events + void AttentionMessageHandlerEnvelope(object? _, AttentionMessageEventArgs args) => + HandleAttentionMessage(args, tsc); + + void PeerDisconnectionEnvelope(object? _, PeerDisconnectedEventArgs args) => + HandlePeerDisconnection(args, channel.RemoteNodeId, tsc); + + void ExceptionRaisedEnvelope(object? _, Exception e) => + HandleExceptionRaised(e, tsc); + + void ChannelUpgradedHandlerEnvelope(object? _, ChannelUpgradedEventArgs args) => + HandleChannelUpgraded(args, tsc); } - private void HandleChannelMessage(ChannelMessageEventArgs args, ChannelId _, - TaskCompletionSource tsc) + private void HandleChannelUpgraded(ChannelUpgradedEventArgs args, + TaskCompletionSource tsc) { - // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault - switch (args.Message.Type) - { - case MessageTypes.AcceptChannel: - Console.WriteLine("Channel accepted"); - break; - case MessageTypes.FundingSigned: - Console.WriteLine("Funding signed"); - OnWaitingConfirmation?.Invoke(this, EventArgs.Empty); - break; - case MessageTypes.ChannelReady: - { - Console.WriteLine("Channel ready"); - if (_channelMemoryRepository.TryGetChannel(args.Message.Payload.ChannelId, out var channel) - && channel.FundingOutput?.TransactionId is not null - && channel.FundingOutput?.Index is not null) - { - tsc.TrySetResult(new OpenChannelClientResponse(channel.FundingOutput.TransactionId.Value, - channel.FundingOutput.Index.Value, - channel.ChannelId)); - } - else - { - Console.Error.WriteLine("Channel not found in memory repository"); - } - - break; - } - default: - Console.WriteLine("Unknown message type: {0}", Enum.GetName(args.Message.Type)); - break; - } + if (args.OldChannelId != _channelId) + return; + + tsc.TrySetResult(new OpenChannelClientResponse(args.NewChannelId)); + + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation("Channel {oldChannelId} has been upgraded to {channelId}", args.OldChannelId, + args.NewChannelId); } - private static void HandleAttentionMessage(AttentionMessageEventArgs args, ChannelId _, - TaskCompletionSource tsc) + private void HandleAttentionMessage(AttentionMessageEventArgs args, + TaskCompletionSource tsc) { - Console.Error.WriteLine($"Error opening channel: {args.Message}"); + if (args.ChannelId != _channelId) + return; + + _logger.LogError( + "Received attention message from peer {peerId} for channel {channelId}: {message}", + args.PeerPubKey, args.ChannelId, args.Message); + tsc.TrySetException(new ChannelErrorException($"Error opening channel: {args.Message}")); } - private static void HandlePeerDisconnection(PeerDisconnectedEventArgs _, ChannelId __, - TaskCompletionSource tsc) + private void HandlePeerDisconnection(PeerDisconnectedEventArgs args, CompactPubKey peerPubKey, + TaskCompletionSource tsc) { - Console.Error.WriteLine("Peer disconnected"); - tsc.TrySetException(new ChannelErrorException("Error opening channel: Peer disconnected")); + if (args.PeerPubKey != peerPubKey) + return; + + _logger.LogError("Peer disconnected without notice"); + tsc.TrySetException(new ConnectionException("Error opening channel: Peer disconnected")); } - private static void HandleExceptionRaised(Exception e, ChannelId _, - TaskCompletionSource tsc) + private void HandleExceptionRaised(Exception e, TaskCompletionSource tsc) { - Console.Error.WriteLine(e.ToString()); + if (e is not ChannelErrorException ce || ce.ChannelId != _channelId) + return; + + _logger.LogError("Exception raised while opening channel: {message}", e.Message); tsc.TrySetException(e); } } \ No newline at end of file diff --git a/src/NLightning.Daemon/Handlers/OpenChannelClientSubscriptionHandler.cs b/src/NLightning.Daemon/Handlers/OpenChannelClientSubscriptionHandler.cs new file mode 100644 index 00000000..5ced9539 --- /dev/null +++ b/src/NLightning.Daemon/Handlers/OpenChannelClientSubscriptionHandler.cs @@ -0,0 +1,184 @@ +using Microsoft.Extensions.Logging; + +namespace NLightning.Daemon.Handlers; + +using Domain.Bitcoin.Interfaces; +using Domain.Channels.Enums; +using Domain.Channels.Events; +using Domain.Channels.Interfaces; +using Domain.Channels.ValueObjects; +using Domain.Client.Constants; +using Domain.Client.Enums; +using Domain.Client.Exceptions; +using Domain.Client.Requests; +using Domain.Client.Responses; +using Domain.Crypto.ValueObjects; +using Domain.Exceptions; +using Domain.Node.Events; +using Domain.Node.Interfaces; +using Interfaces; + +public class OpenChannelClientSubscriptionHandler : + IClientCommandHandler +{ + private readonly IChannelMemoryRepository _channelMemoryRepository; + private readonly ILogger _logger; + private readonly IPeerManager _peerManager; + private readonly IUtxoMemoryRepository _utxoMemoryRepository; + + private ChannelId _channelId; + private IPeerService? _peerService; + + /// + public ClientCommand Command => ClientCommand.OpenChannelSubscription; + + public OpenChannelClientSubscriptionHandler(IChannelMemoryRepository channelMemoryRepository, + ILogger logger, + IPeerManager peerManager, IUtxoMemoryRepository utxoMemoryRepository) + { + _channelMemoryRepository = channelMemoryRepository; + _logger = logger; + _peerManager = peerManager; + _utxoMemoryRepository = utxoMemoryRepository; + } + + /// + public async Task HandleAsync(OpenChannelClientSubscriptionRequest request, + CancellationToken ct) + { + if (request.ChannelId == ChannelId.Zero) + throw new ClientException(ErrorCodes.InvalidChannel, "ChannelId cannot be empty"); + + _channelId = request.ChannelId; + + if (!_channelMemoryRepository.TryGetChannel(_channelId, out var channel)) + { + if (!_channelMemoryRepository.TryGetChannel(_channelId, out channel)) + throw new ClientException(ErrorCodes.InvalidChannel, $"Channel with Id {_channelId} not found"); + } + + var peer = _peerManager.GetPeer(channel.RemoteNodeId) ?? throw new ClientException(ErrorCodes.InvalidOperation, + $"Peer with NodeId {channel.RemoteNodeId} is not connected"); + var lockedUtxos = _utxoMemoryRepository.GetLockedUtxosForChannel(_channelId); + if (lockedUtxos.Count == 0) + throw new ClientException(ErrorCodes.InvalidOperation, + $"No locked UTXOs found for channel {_channelId}"); + + // Create a task completion source for the response + var tsc = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + // Check if the channel is already in a state we care about + var shouldPersistChannel = channel.State is ChannelState.V1FundingSigned + or ChannelState.ReadyForUs + or ChannelState.ReadyForThem; + + try + { + if (!peer.TryGetPeerService(out _peerService)) + throw new ClientException(ErrorCodes.InvalidOperation, "Error getting peerService from peer"); + + // Subscribe to the events + _peerService.OnAttentionMessageReceived += AttentionMessageHandlerEnvelope; + _peerService.OnDisconnect += PeerDisconnectionEnvelope; + _peerService.OnExceptionRaised += ExceptionRaisedEnvelope; + _channelMemoryRepository.OnChannelUpdated += ChannelUpdatedHandlerEnvelope; + + return await tsc.Task; + } + catch + { + if (!shouldPersistChannel) + _utxoMemoryRepository.ReturnUtxosNotSpentOnChannel(request.ChannelId); + + throw; + } + finally + { + //Unsubscribe from the events so we don't have dangling memory + _peerService?.OnAttentionMessageReceived -= AttentionMessageHandlerEnvelope; + _peerService?.OnDisconnect -= PeerDisconnectionEnvelope; + _peerService?.OnExceptionRaised -= ExceptionRaisedEnvelope; + _channelMemoryRepository.OnChannelUpdated -= ChannelUpdatedHandlerEnvelope; + } + + // Envelopes for the events + void AttentionMessageHandlerEnvelope(object? _, AttentionMessageEventArgs args) => + HandleAttentionMessage(args, tsc); + + void PeerDisconnectionEnvelope(object? _, PeerDisconnectedEventArgs args) => + HandlePeerDisconnection(args, channel.RemoteNodeId, tsc); + + void ExceptionRaisedEnvelope(object? _, Exception e) => + HandleExceptionRaised(e, tsc); + + void ChannelUpdatedHandlerEnvelope(object? _, ChannelUpdatedEventArgs args) => + HandleChannelUpdated(args, tsc); + } + + private void HandleAttentionMessage(AttentionMessageEventArgs args, + TaskCompletionSource tsc) + { + if (args.ChannelId != _channelId) + return; + + _logger.LogError( + "Received attention message from peer {peerId} for channel {channelId}: {message}", + args.PeerPubKey, args.ChannelId, args.Message); + + tsc.TrySetException(new ChannelErrorException($"Error opening channel: {args.Message}")); + } + + private void HandlePeerDisconnection(PeerDisconnectedEventArgs args, CompactPubKey peerPubKey, + TaskCompletionSource tsc) + { + if (args.PeerPubKey != peerPubKey) + return; + + if (args.Exception is null) + { + _logger.LogError("Peer disconnected without notice"); + tsc.TrySetException(new ConnectionException("Error opening channel: Peer disconnected")); + } + else + { + _logger.LogError(args.Exception, "Peer disconnected. Error: {message}", args.Exception.Message); + tsc.TrySetException(new ConnectionException("Error opening channel: Peer disconnected", args.Exception)); + } + } + + private void HandleExceptionRaised(Exception e, TaskCompletionSource tsc) + { + if (e is not ChannelErrorException ce || ce.ChannelId != _channelId) + return; + + _logger.LogError("Exception raised while opening channel: {message}", e.Message); + tsc.TrySetException(e); + } + + private void HandleChannelUpdated(ChannelUpdatedEventArgs args, + TaskCompletionSource tsc) + { + if (args.Channel.ChannelId != _channelId) + return; + + if (args.Channel.State == ChannelState.V1FundingSigned) + { + tsc.TrySetResult(new OpenChannelClientSubscriptionResponse(args.Channel.ChannelId) + { + ChannelState = ChannelState.V1FundingSigned, + TxId = args.Channel.FundingOutput?.TransactionId, + Index = args.Channel.FundingOutput?.Index + }); + } + else if (args.Channel.State is ChannelState.ReadyForUs or ChannelState.ReadyForThem) + { + tsc.TrySetResult(new OpenChannelClientSubscriptionResponse(args.Channel.ChannelId) + { + ChannelState = ChannelState.ReadyForUs, + TxId = args.Channel.FundingOutput?.TransactionId, + Index = args.Channel.FundingOutput?.Index + }); + } + } +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Interfaces/IClientCommandHandler.cs b/src/NLightning.Daemon/Interfaces/IClientCommandHandler.cs index f1c4ca4a..796ba41f 100644 --- a/src/NLightning.Daemon/Interfaces/IClientCommandHandler.cs +++ b/src/NLightning.Daemon/Interfaces/IClientCommandHandler.cs @@ -4,6 +4,20 @@ namespace NLightning.Daemon.Interfaces; public interface IClientCommandHandler { + /// + /// Gets the client command associated with the handler. + /// + /// + /// This property returns a value from the ClientCommand enumeration, + /// representing the specific command handled by the implementing class. + /// ClientCommand Command { get; } + + /// + /// Handles the execution of a client command asynchronously. + /// + /// The request object containing the necessary data to handle the command. + /// A cancellation token that can be used to cancel the operation. + /// A task representing the asynchronous operation, containing the response of the command execution. Task HandleAsync(TRequest request, CancellationToken ct); } \ No newline at end of file diff --git a/src/NLightning.Daemon/Ipc/Handlers/NodeInfoIpcHandler.cs b/src/NLightning.Daemon/Ipc/Handlers/NodeInfoIpcHandler.cs index 332804f0..169f66ed 100644 --- a/src/NLightning.Daemon/Ipc/Handlers/NodeInfoIpcHandler.cs +++ b/src/NLightning.Daemon/Ipc/Handlers/NodeInfoIpcHandler.cs @@ -32,6 +32,8 @@ public async Task HandleAsync(IpcEnvelope envelope, CancellationTok var resp = await _query.QueryAsync(ct); var ipcResp = new NodeInfoIpcResponse { + PubKey = new CompactPubKey(Convert.FromHexString(resp.PubKey)), + ListeningTo = resp.ListeningTo.Split(',').ToList(), Network = resp.Network, BestBlockHash = new Hash(Convert.FromHexString(resp.BestBlockHash)), BestBlockHeight = resp.BestBlockHeight, diff --git a/src/NLightning.Daemon/Ipc/Handlers/OpenChannelSubscriptionIpcHandler.cs b/src/NLightning.Daemon/Ipc/Handlers/OpenChannelSubscriptionIpcHandler.cs new file mode 100644 index 00000000..70696094 --- /dev/null +++ b/src/NLightning.Daemon/Ipc/Handlers/OpenChannelSubscriptionIpcHandler.cs @@ -0,0 +1,98 @@ +using MessagePack; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace NLightning.Daemon.Ipc.Handlers; + +using Daemon.Handlers; +using Daemon.Interfaces; +using Domain.Client.Constants; +using Domain.Client.Enums; +using Domain.Client.Exceptions; +using Domain.Client.Requests; +using Domain.Client.Responses; +using Domain.Exceptions; +using Interfaces; +using Services.Ipc.Factories; +using Transport.Ipc; +using Transport.Ipc.Requests; +using Transport.Ipc.Responses; + +public class OpenChannelSubscriptionIpcHandler : IIpcCommandHandler +{ + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + + public ClientCommand Command => ClientCommand.OpenChannelSubscription; + + public OpenChannelSubscriptionIpcHandler(ILogger logger, + IServiceProvider serviceProvider) + { + _logger = logger; + _serviceProvider = serviceProvider; + } + + public async Task HandleAsync(IpcEnvelope envelope, CancellationToken ct) + { + try + { + // Deserialize the request + var request = + MessagePackSerializer.Deserialize( + envelope.Payload, cancellationToken: ct); + + // Get the client handler + using var scope = _serviceProvider.CreateScope(); + var openChannelClientSubscriptionHandler = + scope.ServiceProvider.GetService( + typeof(IClientCommandHandler)) as + OpenChannelClientSubscriptionHandler ?? + throw new InvalidOperationException( + $"Unable to get service {nameof(OpenChannelClientSubscriptionHandler)}"); + + var clientResponse = await openChannelClientSubscriptionHandler.HandleAsync(request.ToClientRequest(), ct); + + var payload = MessagePackSerializer.Serialize( + OpenChannelSubscriptionIpcResponse.FromClientResponse(clientResponse), + cancellationToken: ct); + return new IpcEnvelope + { + Version = envelope.Version, + Command = envelope.Command, + CorrelationId = envelope.CorrelationId, + Kind = IpcEnvelopeKind.Response, + Payload = payload + }; + } + catch (ClientException ce) + { + _logger.LogError(ce, "Error while handling OpenChannelSubscription"); + return IpcErrorFactory.CreateErrorEnvelope(envelope, ce.Message, ce.Message); + } + catch (InvalidOperationException oe) + { + _logger.LogError(oe, "The operation could not be completed"); + return IpcErrorFactory.CreateErrorEnvelope(envelope, ErrorCodes.InvalidOperation, + $"The operation could not be completed: {oe.Message}"); + } + catch (ConnectionException ce) + { + _logger.LogError(ce, "Failed to connect to peer"); + return IpcErrorFactory.CreateErrorEnvelope(envelope, ErrorCodes.ConnectionError, + $"Connection failed: {ce.Message}"); + } + catch (ChannelErrorException cee) + { + _logger.LogError(cee, "Error opening Channel"); + return IpcErrorFactory.CreateErrorEnvelope(envelope, ErrorCodes.ConnectionError, + $"Channel Error: {cee.Message}"); + } + catch (Exception e) + { + _logger.LogError(e, "Error opening channel"); + return IpcErrorFactory.CreateErrorEnvelope(envelope, ErrorCodes.ServerError, + $"Error opening channel: {e.Message}"); + } + } +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcService.cs b/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcService.cs index 401f3945..d1f17159 100644 --- a/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcService.cs +++ b/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcService.cs @@ -1,6 +1,5 @@ using System.IO.Pipes; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace NLightning.Daemon.Services.Ipc; @@ -8,7 +7,6 @@ namespace NLightning.Daemon.Services.Ipc; using Daemon.Ipc.Interfaces; using Domain.Client.Constants; using Domain.Client.Interfaces; -using Domain.Node.Options; using Factories; using Transport.Ipc; @@ -28,8 +26,7 @@ internal sealed class NamedPipeIpcService : INamedPipeIpcService private Task? _listenerTask; public NamedPipeIpcService(IIpcAuthenticator authenticator, string configPath, IIpcFraming framing, - ILogger logger, IOptions _, - IIpcRequestRouter router) + ILogger logger, IIpcRequestRouter router) { _logger = logger; _authenticator = authenticator; @@ -140,7 +137,7 @@ private async Task HandleClientAsync(NamedPipeServerStream stream, CancellationT try { await stream.DisposeAsync(); } catch { - /* ignore */ + //ignore } } } diff --git a/src/NLightning.Daemon/Services/NltgDaemonService.cs b/src/NLightning.Daemon/Services/NltgDaemonService.cs index efd15159..5a416bd1 100644 --- a/src/NLightning.Daemon/Services/NltgDaemonService.cs +++ b/src/NLightning.Daemon/Services/NltgDaemonService.cs @@ -45,11 +45,16 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) ?? _configuration.GetValue("daemon-child") ?? _nodeOptions.Daemon; - _logger.LogInformation("NLTG Daemon started on {Network} network", network); - _logger.LogDebug("Running in daemon mode: {IsDaemon}", isDaemon); + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation("NLTG Daemon started on {Network} network", network); - var pubKey = _secureKeyManager.GetNodePubKey(); - _logger.LogDebug("lightning-cli connect {pubKey}@docker.for.mac.host.internal:9735", pubKey.ToString()); + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Running in daemon mode: {IsDaemon}", isDaemon); + + var pubKey = _secureKeyManager.GetNodePubKey(); + _logger.LogDebug("Our PubKey is {pubKey}", pubKey.ToString()); + } try { diff --git a/src/NLightning.Daemon/Services/NodeInfoQueryService.cs b/src/NLightning.Daemon/Services/NodeInfoQueryService.cs index 8669a131..d6bb0879 100644 --- a/src/NLightning.Daemon/Services/NodeInfoQueryService.cs +++ b/src/NLightning.Daemon/Services/NodeInfoQueryService.cs @@ -6,17 +6,24 @@ namespace NLightning.Daemon.Services; using Contracts.Control; using Domain.Node.Options; using Domain.Persistence.Interfaces; +using Domain.Protocol.Interfaces; +using Infrastructure.Transport.Interfaces; using Interfaces; public sealed class NodeInfoQueryService : INodeInfoQueryService { - private readonly IServiceProvider _services; private readonly NodeOptions _nodeOptions; + private readonly ISecureKeyManager _secureKeyManager; + private readonly IServiceProvider _services; + private readonly ITcpService _tcpService; - public NodeInfoQueryService(IServiceProvider services, IOptions nodeOptions) + public NodeInfoQueryService(IOptions nodeOptions, ISecureKeyManager secureKeyManager, + IServiceProvider services, ITcpService tcpService) { - _services = services; _nodeOptions = nodeOptions.Value; + _secureKeyManager = secureKeyManager; + _services = services; + _tcpService = tcpService; } public async Task QueryAsync(CancellationToken ct) @@ -47,8 +54,13 @@ public async Task QueryAsync(CancellationToken ct) } } + var pubKeyString = _secureKeyManager.GetNodePubKey().ToString(); + var listeningToString = string.Join(',', _tcpService.ListeningTo.Select(e => e.ToString()).ToList()); + return new NodeInfoResponse { + PubKey = pubKeyString, + ListeningTo = listeningToString, Network = _nodeOptions.BitcoinNetwork, BestBlockHash = bestHashHex, BestBlockHeight = bestHeight, diff --git a/src/NLightning.Domain/Channels/Events/ChannelUpdatedEventArgs.cs b/src/NLightning.Domain/Channels/Events/ChannelUpdatedEventArgs.cs new file mode 100644 index 00000000..0bf94063 --- /dev/null +++ b/src/NLightning.Domain/Channels/Events/ChannelUpdatedEventArgs.cs @@ -0,0 +1,13 @@ +namespace NLightning.Domain.Channels.Events; + +using Models; + +public class ChannelUpdatedEventArgs +{ + public ChannelModel Channel { get; } + + public ChannelUpdatedEventArgs(ChannelModel channel) + { + Channel = channel; + } +} \ No newline at end of file diff --git a/src/NLightning.Domain/Channels/Events/ChannelUpgradedEventArgs.cs b/src/NLightning.Domain/Channels/Events/ChannelUpgradedEventArgs.cs new file mode 100644 index 00000000..1e90365d --- /dev/null +++ b/src/NLightning.Domain/Channels/Events/ChannelUpgradedEventArgs.cs @@ -0,0 +1,15 @@ +namespace NLightning.Domain.Channels.Events; + +using ValueObjects; + +public class ChannelUpgradedEventArgs : EventArgs +{ + public ChannelId OldChannelId { get; } + public ChannelId NewChannelId { get; } + + public ChannelUpgradedEventArgs(ChannelId oldChannelId, ChannelId newChannelId) + { + OldChannelId = oldChannelId; + NewChannelId = newChannelId; + } +} \ No newline at end of file diff --git a/src/NLightning.Domain/Channels/Interfaces/IChannelMemoryRepository.cs b/src/NLightning.Domain/Channels/Interfaces/IChannelMemoryRepository.cs index cf7692e3..10168738 100644 --- a/src/NLightning.Domain/Channels/Interfaces/IChannelMemoryRepository.cs +++ b/src/NLightning.Domain/Channels/Interfaces/IChannelMemoryRepository.cs @@ -4,25 +4,124 @@ namespace NLightning.Domain.Channels.Interfaces; using Crypto.ValueObjects; using Enums; +using Events; using Models; using ValueObjects; public interface IChannelMemoryRepository { + /// + /// Event triggered when a channel has been successfully upgraded. + /// + /// + /// This event is raised after the process of upgrading a channel, transitioning from a temporary or transitional state + /// to its final state within the system. Subscribing to this event enables handlers to respond to the completion of the + /// channel upgrade process. + /// + event EventHandler? OnChannelUpgraded; + + /// + /// Event triggered when a channel's data or state has been updated. + /// + /// + /// This event is raised whenever changes are made to a channel's information or status within the system, + /// providing subscribers the opportunity to take actions or synchronize with the updated channel data. + /// + event EventHandler? OnChannelUpdated; + + /// + /// Attempts to retrieve a channel that matches the specified channel ID. + /// + /// The unique identifier of the channel to retrieve. + /// When this method returns, contains the channel associated with the specified ID, if found; otherwise, null. + /// true if a channel with the specified ID was found; otherwise, false. bool TryGetChannel(ChannelId channelId, [MaybeNullWhen(false)] out ChannelModel channel); + /// + /// Retrieves a list of channels that match the specified predicate. + /// + /// A function that defines the criteria to filter channels. + /// A list of channels that match the provided predicate. List FindChannels(Func predicate); + /// + /// Attempts to retrieve the state of a channel that matches the specified channel ID. + /// + /// The unique identifier of the channel whose state is to be retrieved. + /// When this method returns, contains the state of the channel associated with the specified ID, if found; otherwise, ChannelState.None. + /// true if a channel with the specified ID was found, allowing its state to be retrieved; otherwise, false. bool TryGetChannelState(ChannelId channelId, out ChannelState channelState); + + /// + /// Adds the specified channel to the in-memory channel repository. + /// + /// The channel to be added to the repository. void AddChannel(ChannelModel channel); + + /// + /// Updates the specified channel in the memory repository. + /// + /// The channel model to update. The channel must already exist in the repository. void UpdateChannel(ChannelModel channel); - void RemoveChannel(ChannelId channelId); + /// + /// Attempts to remove a channel that matches the specified channel ID. + /// + /// The unique identifier of the channel to be removed. + /// true if a channel with the specified ID was successfully removed; otherwise, false. + bool TryRemoveChannel(ChannelId channelId); + + /// + /// Attempts to retrieve a temporary channel that matches the specified public key and channel ID. + /// + /// The compact public key associated with the target channel. + /// The unique identifier of the channel to locate. + /// When this method returns, contains the temporary channel associated with the specified public key and channel ID, if found; otherwise, null. + /// true if a temporary channel matching the specified public key and channel ID was found; otherwise, false. bool TryGetTemporaryChannel(CompactPubKey compactPubKey, ChannelId channelId, [MaybeNullWhen(false)] out ChannelModel channel); + /// + /// Attempts to retrieve the temporary state of a channel that matches the specified public key and channel ID. + /// + /// The compact public key associated with the channel. + /// The unique identifier of the channel to retrieve. + /// When this method returns, contains the state of the channel if found; otherwise, ChannelState.None. + /// true if a temporary channel state matching the specified public key and channel ID was found; otherwise, false. bool TryGetTemporaryChannelState(CompactPubKey compactPubKey, ChannelId channelId, out ChannelState channelState); + + /// + /// Adds a temporary channel associated with the specified public key. + /// + /// The public key associated with the channel to add, in compact format. + /// The channel information to store temporarily. void AddTemporaryChannel(CompactPubKey compactPubKey, ChannelModel channel); + + /// + /// Updates the temporary channel associated with the specified compact public key. + /// + /// The compact public key identifying the temporary channel to update. + /// The updated temporary channel model containing new state or configuration. void UpdateTemporaryChannel(CompactPubKey compactPubKey, ChannelModel channel); - void RemoveTemporaryChannel(CompactPubKey compactPubKey, ChannelId channelId); + + /// + /// Attempts to remove a temporary channel associated with the specified public key and channel ID. + /// + /// The public key of the channel's peer used to identify the temporary channel. + /// The unique identifier of the channel to be removed. + /// true if the temporary channel was successfully removed; otherwise, false. + bool TryRemoveTemporaryChannel(CompactPubKey compactPubKey, ChannelId channelId); + + /// + /// Upgrades an existing channel by removing it from the temporary channel list and adding it to the channel list. + /// + /// + /// This method is typically used when a channel transitions from a temporary state + /// (e.g., during the opening process) to a fully established state. It ensures that the channel is properly moved + /// from the temporary storage to the main channel repository, allowing it to be managed as a regular channel + /// going forward. + /// + /// The unique identifier of the existing channel to be upgraded. + /// The temporary channel model that will replace the existing channel. + void UpgradeChannel(ChannelId oldChannelId, ChannelModel tempChannel); } \ No newline at end of file diff --git a/src/NLightning.Domain/Client/Constants/ErrorCodes.cs b/src/NLightning.Domain/Client/Constants/ErrorCodes.cs index a23dd5d3..c1a4ad04 100644 --- a/src/NLightning.Domain/Client/Constants/ErrorCodes.cs +++ b/src/NLightning.Domain/Client/Constants/ErrorCodes.cs @@ -8,4 +8,5 @@ public static class ErrorCodes public const string ConnectionError = "connection_error"; public const string ServerError = "server_error"; public const string NotEnoughBalance = "not_enough_balance"; + public const string InvalidChannel = "invalid_channel"; } \ No newline at end of file diff --git a/src/NLightning.Domain/Client/Enums/ClientCommand.cs b/src/NLightning.Domain/Client/Enums/ClientCommand.cs index 14315466..51562838 100644 --- a/src/NLightning.Domain/Client/Enums/ClientCommand.cs +++ b/src/NLightning.Domain/Client/Enums/ClientCommand.cs @@ -12,5 +12,6 @@ public enum ClientCommand ListPeers = 3, GetAddress = 4, WalletBalance = 5, - OpenChannel = 6 + OpenChannel = 6, + OpenChannelSubscription = 7 } \ No newline at end of file diff --git a/src/NLightning.Domain/Client/Requests/OpenChannelClientSubscriptionRequest.cs b/src/NLightning.Domain/Client/Requests/OpenChannelClientSubscriptionRequest.cs new file mode 100644 index 00000000..3239fceb --- /dev/null +++ b/src/NLightning.Domain/Client/Requests/OpenChannelClientSubscriptionRequest.cs @@ -0,0 +1,13 @@ +namespace NLightning.Domain.Client.Requests; + +using Channels.ValueObjects; + +public class OpenChannelClientSubscriptionRequest +{ + public ChannelId ChannelId { get; } + + public OpenChannelClientSubscriptionRequest(ChannelId channelId) + { + ChannelId = channelId; + } +} \ No newline at end of file diff --git a/src/NLightning.Domain/Client/Responses/OpenChannelClientResponse.cs b/src/NLightning.Domain/Client/Responses/OpenChannelClientResponse.cs index 363c9e4e..a5cbb5bd 100644 --- a/src/NLightning.Domain/Client/Responses/OpenChannelClientResponse.cs +++ b/src/NLightning.Domain/Client/Responses/OpenChannelClientResponse.cs @@ -1,18 +1,13 @@ namespace NLightning.Domain.Client.Responses; -using Bitcoin.ValueObjects; using Channels.ValueObjects; public sealed class OpenChannelClientResponse { - public TxId TxId { get; } - public ushort Index { get; } public ChannelId ChannelId { get; } - public OpenChannelClientResponse(TxId txId, ushort index, ChannelId channelId) + public OpenChannelClientResponse(ChannelId channelId) { - TxId = txId; - Index = index; ChannelId = channelId; } } \ No newline at end of file diff --git a/src/NLightning.Domain/Client/Responses/OpenChannelClientSubscriptionResponse.cs b/src/NLightning.Domain/Client/Responses/OpenChannelClientSubscriptionResponse.cs new file mode 100644 index 00000000..61149ec1 --- /dev/null +++ b/src/NLightning.Domain/Client/Responses/OpenChannelClientSubscriptionResponse.cs @@ -0,0 +1,18 @@ +namespace NLightning.Domain.Client.Responses; + +using Bitcoin.ValueObjects; +using Channels.Enums; +using Channels.ValueObjects; + +public class OpenChannelClientSubscriptionResponse +{ + public ChannelId ChannelId { get; } + public ChannelState ChannelState { get; init; } + public TxId? TxId { get; init; } + public uint? Index { get; init; } + + public OpenChannelClientSubscriptionResponse(ChannelId channelId) + { + ChannelId = channelId; + } +} \ No newline at end of file diff --git a/src/NLightning.Domain/Crypto/ValueObjects/CompactPubKey.cs b/src/NLightning.Domain/Crypto/ValueObjects/CompactPubKey.cs index e527eca3..17773374 100644 --- a/src/NLightning.Domain/Crypto/ValueObjects/CompactPubKey.cs +++ b/src/NLightning.Domain/Crypto/ValueObjects/CompactPubKey.cs @@ -36,7 +36,7 @@ public CompactPubKey(byte[] value) return left.Equals(right); } - public override string ToString() => Convert.ToHexString(_value).ToLowerInvariant(); + public override string ToString() => Convert.ToHexStringLower(_value); public bool Equals(CompactPubKey other) { diff --git a/src/NLightning.Domain/Node/Events/PeerDisconnectedEventArgs.cs b/src/NLightning.Domain/Node/Events/PeerDisconnectedEventArgs.cs index 7e05bf66..0e444434 100644 --- a/src/NLightning.Domain/Node/Events/PeerDisconnectedEventArgs.cs +++ b/src/NLightning.Domain/Node/Events/PeerDisconnectedEventArgs.cs @@ -5,9 +5,11 @@ namespace NLightning.Domain.Node.Events; public class PeerDisconnectedEventArgs : EventArgs { public CompactPubKey PeerPubKey { get; } + public Exception? Exception { get; } - public PeerDisconnectedEventArgs(CompactPubKey peerPubKey) + public PeerDisconnectedEventArgs(CompactPubKey peerPubKey, Exception? exception = null) { PeerPubKey = peerPubKey; + Exception = exception; } } \ No newline at end of file diff --git a/src/NLightning.Domain/Node/Interfaces/IPeerCommunicationService.cs b/src/NLightning.Domain/Node/Interfaces/IPeerCommunicationService.cs index 4afcb5de..df18ab3e 100644 --- a/src/NLightning.Domain/Node/Interfaces/IPeerCommunicationService.cs +++ b/src/NLightning.Domain/Node/Interfaces/IPeerCommunicationService.cs @@ -27,7 +27,7 @@ public interface IPeerCommunicationService : IDisposable /// /// Event raised when the peer is disconnected. /// - event EventHandler? DisconnectEvent; + event EventHandler? DisconnectEvent; /// /// Event raised when an exception occurs. diff --git a/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinChainService.cs b/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinChainService.cs index 44cbf638..ce47516b 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinChainService.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinChainService.cs @@ -34,9 +34,14 @@ public async Task SendTransactionAsync(Transaction transaction) { try { - _logger.LogInformation("Broadcasting transaction {TxId}", transaction.GetHash()); + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation("Broadcasting transaction {TxId}", transaction.GetHash()); + var result = await _rpcClient.SendRawTransactionAsync(transaction); - _logger.LogInformation("Successfully broadcast transaction {TxId}", result); + + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation("Successfully broadcast transaction {TxId}", result); + return result; } catch (Exception ex) diff --git a/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs b/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs index 412a7c04..79c44ad8 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs @@ -143,9 +143,10 @@ public async Task StopAsync() public async Task PublishAndWatchTransactionAsync(ChannelId channelId, SignedTransaction signedTransaction, uint requiredDepth) { - _logger.LogInformation( - "Publishing transaction {TxId} for {RequiredDepth} confirmations for channel {channelId}", - signedTransaction.TxId, requiredDepth, channelId); + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation( + "Publishing transaction {TxId} for {RequiredDepth} confirmations for channel {channelId}", + signedTransaction.TxId, requiredDepth, channelId); // Convert the tx var transaction = Transaction.Load(signedTransaction.RawTxBytes, _network); @@ -159,8 +160,10 @@ public async Task PublishAndWatchTransactionAsync(ChannelId channelId, SignedTra public async Task WatchTransactionAsync(ChannelId channelId, TxId txId, uint requiredDepth) { - _logger.LogInformation("Watching transaction {TxId} for {RequiredDepth} confirmations for channel {channelId}", - txId, requiredDepth, channelId); + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation( + "Watching transaction {TxId} for {RequiredDepth} confirmations for channel {channelId}", + txId, requiredDepth, channelId); using var scope = _serviceProvider.CreateScope(); using var uow = scope.ServiceProvider.GetRequiredService(); @@ -177,7 +180,8 @@ public async Task WatchTransactionAsync(ChannelId channelId, TxId txId, uint req public void WatchBitcoinAddress(WalletAddressModel walletAddress) { - _logger.LogInformation("Watching bitcoin address {walletAddress} for deposits", walletAddress); + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation("Watching bitcoin address {walletAddress} for deposits", walletAddress); _watchedAddresses[walletAddress.Address] = walletAddress; } @@ -195,7 +199,8 @@ public void WatchBitcoinAddress(WalletAddressModel walletAddress) private async Task MonitorBlockchainAsync(CancellationToken cancellationToken) { - _logger.LogInformation("Starting blockchain monitoring loop"); + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation("Starting blockchain monitoring loop"); try { @@ -262,7 +267,8 @@ private async Task MonitorBlockchainAsync(CancellationToken cancellationToken) } catch (OperationCanceledException) { - _logger.LogInformation("Blockchain monitoring loop cancelled"); + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation("Blockchain monitoring loop cancelled"); } catch (Exception ex) { @@ -284,8 +290,9 @@ private void InitializeZmqSockets() // _transactionSocket.Connect($"tcp://{_bitcoinOptions.ZmqHost}:{_bitcoinOptions.ZmqTxPort}"); // _transactionSocket.Subscribe("rawtx"); - _logger.LogInformation("ZMQ sockets initialized - Block: {BlockPort}, Tx: {TxPort}", - _bitcoinOptions.ZmqBlockPort, _bitcoinOptions.ZmqTxPort); + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation("ZMQ sockets initialized - Block: {BlockPort}, Tx: {TxPort}", + _bitcoinOptions.ZmqBlockPort, _bitcoinOptions.ZmqTxPort); } catch (Exception ex) { @@ -370,7 +377,8 @@ private async Task ProcessNewBlock(Block block, uint currentHeight) try { - _logger.LogDebug("Processing block at height {blockHeight}: {BlockHash}", currentHeight, blockHash); + if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug("Processing block at height {blockHeight}: {BlockHash}", currentHeight, blockHash); // Check for missed blocks first await AddMissingBlocksToProcessAsync(currentHeight); @@ -408,7 +416,9 @@ private void ProcessBlock(Block block, uint height, IUnitOfWork uow) { var blockHash = block.GetHash(); - _logger.LogDebug("Processing block {Height} with {TxCount} transactions", height, block.Transactions.Count); + if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug("Processing block {Height} with {TxCount} transactions", height, + block.Transactions.Count); // Notify listeners of the new block OnNewBlockDetected?.Invoke(this, new NewBlockEventArgs(height, blockHash.ToBytes())); @@ -439,9 +449,10 @@ private void ProcessBlock(Block block, uint height, IUnitOfWork uow) private void ConfirmTransaction(uint blockHeight, IUnitOfWork uow, WatchedTransactionModel watchedTransaction) { - _logger.LogInformation( - "Transaction {TxId} reached required depth of {depth} confirmations at block {blockHeight}", - watchedTransaction.TransactionId, watchedTransaction.RequiredDepth, blockHeight); + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation( + "Transaction {TxId} reached required depth of {depth} confirmations at block {blockHeight}", + watchedTransaction.TransactionId, watchedTransaction.RequiredDepth, blockHeight); watchedTransaction.MarkAsCompleted(); uow.WatchedTransactionDbRepository.Update(watchedTransaction); @@ -454,9 +465,10 @@ private void ConfirmTransaction(uint blockHeight, IUnitOfWork uow, WatchedTransa private void CheckBlockForWatchedTransactions(List blockTransactions, uint blockHeight, IUnitOfWork uow) { - _logger.LogDebug( - "Checking {watchedTransactionCount} watched transactions for block {height} with {TxCount} transactions", - _watchedTransactions.Count, blockHeight, blockTransactions.Count); + if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug( + "Checking {watchedTransactionCount} watched transactions for block {height} with {TxCount} transactions", + _watchedTransactions.Count, blockHeight, blockTransactions.Count); ushort index = 0; foreach (var transaction in blockTransactions) @@ -493,8 +505,9 @@ private void CheckBlockForWalletMovement(List transactions, uint bl if (_watchedAddresses.IsEmpty) return; - _logger.LogDebug("Checking {AddressCount} watched addresses for deposits/spends in block {Height}", - _watchedAddresses.Count, blockHeight); + if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug("Checking {AddressCount} watched addresses for deposits/spends in block {Height}", + _watchedAddresses.Count, blockHeight); foreach (var transaction in transactions) { @@ -511,9 +524,10 @@ private void CheckBlockForWalletMovement(List transactions, uint bl if (!_watchedAddresses.TryGetValue(destinationAddress.ToString(), out var watchedAddress)) continue; - _logger.LogInformation( - "Deposit detected: {amount} to address {destinationAddress} in tx {txId} at block {height}", - output.Value, destinationAddress, txId, blockHeight); + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation( + "Deposit detected: {amount} to address {destinationAddress} in tx {txId} at block {height}", + output.Value, destinationAddress, txId, blockHeight); // Save Utxo to the database var utxo = new UtxoModel(txId.ToBytes(), (uint)i, LightningMoney.Satoshis(output.Value.Satoshi), @@ -543,7 +557,8 @@ private void CheckWatchedTransactionsDepth(IUnitOfWork uow) { try { - var confirmations = _lastProcessedBlockHeight - watchedTransaction.FirstSeenAtHeight; + // The FirstSeenAtHeight represents 1 confirmation, so we have to add 1 + var confirmations = _lastProcessedBlockHeight - watchedTransaction.FirstSeenAtHeight + 1; if (confirmations >= watchedTransaction.RequiredDepth) ConfirmTransaction(_lastProcessedBlockHeight, uow, watchedTransaction); } diff --git a/src/NLightning.Infrastructure.Repositories/Database/BaseDbRepository.cs b/src/NLightning.Infrastructure.Repositories/Database/BaseDbRepository.cs index c084fe0b..724701b8 100644 --- a/src/NLightning.Infrastructure.Repositories/Database/BaseDbRepository.cs +++ b/src/NLightning.Infrastructure.Repositories/Database/BaseDbRepository.cs @@ -102,12 +102,28 @@ protected void DeleteWhere(Expression> predicate) protected void Update(TEntity entityToUpdate) { - // Get the current state of the entity - var trackedEntity = DbSet.Local.FirstOrDefault(e => e.Equals(entityToUpdate)); + // Get the primary key value + var keyValues = _context.Entry(entityToUpdate).Metadata.FindPrimaryKey()?.Properties + .Select(p => _context.Entry(entityToUpdate).Property(p.Name).CurrentValue).ToArray(); + + if (keyValues == null || keyValues.Length == 0) + { + DbSet.Update(entityToUpdate); + return; + } + + // Find tracked entity by primary key + var trackedEntity = DbSet.Local.FirstOrDefault(e => + { + var trackedKeyValues = _context.Entry(e).Metadata.FindPrimaryKey()?.Properties + .Select(p => _context.Entry(e).Property(p.Name).CurrentValue).ToArray(); + return trackedKeyValues != null && keyValues.SequenceEqual(trackedKeyValues); + }); + if (trackedEntity is not null) { - // If the entity is already tracked, update its state - var entry = DbSet.Entry(trackedEntity); + // If the entity is already tracked, update its values + var entry = _context.Entry(trackedEntity); entry.CurrentValues.SetValues(entityToUpdate); } else diff --git a/src/NLightning.Infrastructure.Repositories/Database/Channel/ChannelConfigDbRepository.cs b/src/NLightning.Infrastructure.Repositories/Database/Channel/ChannelConfigDbRepository.cs index b87780da..ce7a4d63 100644 --- a/src/NLightning.Infrastructure.Repositories/Database/Channel/ChannelConfigDbRepository.cs +++ b/src/NLightning.Infrastructure.Repositories/Database/Channel/ChannelConfigDbRepository.cs @@ -70,7 +70,8 @@ internal static ChannelConfig MapEntityToDomain(ChannelConfigEntity entity) if (entity.RemoteUpfrontShutdownScript is not null) remoteUpfrontShutdownScript = entity.RemoteUpfrontShutdownScript; - return new ChannelConfig(channelReserveAmount, LightningMoney.Satoshis(entity.FeeRatePerKwSatoshis), + return new ChannelConfig(channelReserveAmount ?? LightningMoney.Zero, + LightningMoney.Satoshis(entity.FeeRatePerKwSatoshis), LightningMoney.MilliSatoshis(entity.HtlcMinimumMsat), LightningMoney.Satoshis(entity.LocalDustLimitAmountSats), entity.MaxAcceptedHtlcs, LightningMoney.MilliSatoshis(entity.MaxHtlcAmountInFlight), entity.MinimumDepth, diff --git a/src/NLightning.Infrastructure.Repositories/Database/Channel/ChannelDbRepository.cs b/src/NLightning.Infrastructure.Repositories/Database/Channel/ChannelDbRepository.cs index dba962b9..1a2ae879 100644 --- a/src/NLightning.Infrastructure.Repositories/Database/Channel/ChannelDbRepository.cs +++ b/src/NLightning.Infrastructure.Repositories/Database/Channel/ChannelDbRepository.cs @@ -32,6 +32,7 @@ public ChannelDbRepository(NLightningDbContext context, IMessageSerializer messa public async Task AddAsync(ChannelModel channelModel) { var channelEntity = await MapDomainToEntity(channelModel, _messageSerializer); + Insert(channelEntity); } diff --git a/src/NLightning.Infrastructure.Repositories/Memory/ChannelMemoryRepository.cs b/src/NLightning.Infrastructure.Repositories/Memory/ChannelMemoryRepository.cs index 43c3a024..77a49d96 100644 --- a/src/NLightning.Infrastructure.Repositories/Memory/ChannelMemoryRepository.cs +++ b/src/NLightning.Infrastructure.Repositories/Memory/ChannelMemoryRepository.cs @@ -1,9 +1,11 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; namespace NLightning.Infrastructure.Repositories.Memory; using Domain.Channels.Enums; +using Domain.Channels.Events; using Domain.Channels.Interfaces; using Domain.Channels.Models; using Domain.Channels.ValueObjects; @@ -11,16 +13,30 @@ namespace NLightning.Infrastructure.Repositories.Memory; public class ChannelMemoryRepository : IChannelMemoryRepository { + private readonly ILogger _logger; private readonly ConcurrentDictionary _channels = []; private readonly ConcurrentDictionary _channelStates = []; private readonly ConcurrentDictionary<(CompactPubKey, ChannelId), ChannelModel> _temporaryChannels = []; private readonly ConcurrentDictionary<(CompactPubKey, ChannelId), ChannelState> _temporaryChannelStates = []; + /// + public event EventHandler? OnChannelUpgraded; + + /// + public event EventHandler? OnChannelUpdated; + + public ChannelMemoryRepository(ILogger logger) + { + _logger = logger; + } + + /// public bool TryGetChannel(ChannelId channelId, [MaybeNullWhen(false)] out ChannelModel channel) { return _channels.TryGetValue(channelId, out channel); } + /// public List FindChannels(Func predicate) { return _channels @@ -29,11 +45,13 @@ public List FindChannels(Func predicate) .ToList(); } + /// public bool TryGetChannelState(ChannelId channelId, out ChannelState channelState) { return _channelStates.TryGetValue(channelId, out channelState); } + /// public void AddChannel(ChannelModel channel) { ArgumentNullException.ThrowIfNull(channel); @@ -44,6 +62,7 @@ public void AddChannel(ChannelModel channel) _channelStates[channel.ChannelId] = channel.State; } + /// public void UpdateChannel(ChannelModel channel) { ArgumentNullException.ThrowIfNull(channel); @@ -53,28 +72,32 @@ public void UpdateChannel(ChannelModel channel) _channels[channel.ChannelId] = channel; _channelStates[channel.ChannelId] = channel.State; + + OnChannelUpdated?.Invoke(this, new ChannelUpdatedEventArgs(channel)); } - public void RemoveChannel(ChannelId channelId) + /// + public bool TryRemoveChannel(ChannelId channelId) { - if (!_channels.TryRemove(channelId, out _)) - throw new KeyNotFoundException($"Channel with Id {channelId} does not exist."); - - _channelStates.TryRemove(channelId, out _); + var removed = _channels.TryRemove(channelId, out _); + return removed && _channelStates.TryRemove(channelId, out _); } + /// public bool TryGetTemporaryChannel(CompactPubKey compactPubKey, ChannelId channelId, [MaybeNullWhen(false)] out ChannelModel channel) { return _temporaryChannels.TryGetValue((compactPubKey, channelId), out channel); } + /// public bool TryGetTemporaryChannelState(CompactPubKey compactPubKey, ChannelId channelId, out ChannelState channelState) { return _temporaryChannelStates.TryGetValue((compactPubKey, channelId), out channelState); } + /// public void AddTemporaryChannel(CompactPubKey compactPubKey, ChannelModel channel) { if (!_temporaryChannels.TryAdd((compactPubKey, channel.ChannelId), channel)) @@ -84,6 +107,7 @@ public void AddTemporaryChannel(CompactPubKey compactPubKey, ChannelModel channe _temporaryChannelStates[(compactPubKey, channel.ChannelId)] = channel.State; } + /// public void UpdateTemporaryChannel(CompactPubKey compactPubKey, ChannelModel channel) { if (!_temporaryChannels.ContainsKey((compactPubKey, channel.ChannelId))) @@ -94,12 +118,22 @@ public void UpdateTemporaryChannel(CompactPubKey compactPubKey, ChannelModel cha _temporaryChannelStates[(compactPubKey, channel.ChannelId)] = channel.State; } - public void RemoveTemporaryChannel(CompactPubKey compactPubKey, ChannelId channelId) + /// + public bool TryRemoveTemporaryChannel(CompactPubKey compactPubKey, ChannelId channelId) { - if (!_temporaryChannels.TryRemove((compactPubKey, channelId), out _)) - throw new KeyNotFoundException( - $"Temporary channel with Id {channelId} for CompactPubKey {compactPubKey} does not exist."); + var removed = _temporaryChannels.TryRemove((compactPubKey, channelId), out _); + return removed && _temporaryChannelStates.TryRemove((compactPubKey, channelId), out _); + } + + /// + public void UpgradeChannel(ChannelId oldChannelId, ChannelModel tempChannel) + { + AddChannel(tempChannel); + if (!TryRemoveTemporaryChannel(tempChannel.RemoteNodeId, oldChannelId)) + _logger.LogWarning( + "Unable to remove Temporary Channel with Id {oldChannelId} while upgrading Channel {channelId}.", + oldChannelId, tempChannel.ChannelId); - _temporaryChannelStates.TryRemove((compactPubKey, channelId), out _); + OnChannelUpgraded?.Invoke(this, new ChannelUpgradedEventArgs(oldChannelId, tempChannel.ChannelId)); } } \ No newline at end of file diff --git a/src/NLightning.Infrastructure/Node/Services/PeerCommunicationService.cs b/src/NLightning.Infrastructure/Node/Services/PeerCommunicationService.cs index f6f0afc4..5915435e 100644 --- a/src/NLightning.Infrastructure/Node/Services/PeerCommunicationService.cs +++ b/src/NLightning.Infrastructure/Node/Services/PeerCommunicationService.cs @@ -33,7 +33,7 @@ public class PeerCommunicationService : IPeerCommunicationService public event EventHandler? MessageReceived; /// - public event EventHandler? DisconnectEvent; + public event EventHandler? DisconnectEvent; /// public event EventHandler? ExceptionRaised; @@ -157,7 +157,7 @@ public void Disconnect(Exception? exception = null) } finally { - DisconnectEvent?.Invoke(this, EventArgs.Empty); + DisconnectEvent?.Invoke(this, exception); } } diff --git a/src/NLightning.Infrastructure/Node/Services/PeerService.cs b/src/NLightning.Infrastructure/Node/Services/PeerService.cs index e65ab922..c5b60770 100644 --- a/src/NLightning.Infrastructure/Node/Services/PeerService.cs +++ b/src/NLightning.Infrastructure/Node/Services/PeerService.cs @@ -176,10 +176,10 @@ private void HandleException(object? sender, Exception e) OnExceptionRaised?.Invoke(this, e); } - private void HandleDisconnection(object? sender, EventArgs e) + private void HandleDisconnection(object? sender, Exception e) { - _logger.LogTrace("Handling disconnection for peer {Peer}", PeerPubKey); - OnDisconnect?.Invoke(this, new PeerDisconnectedEventArgs(PeerPubKey)); + _logger.LogTrace(e, "Handling disconnection for peer {Peer}", PeerPubKey); + OnDisconnect?.Invoke(this, new PeerDisconnectedEventArgs(PeerPubKey, e)); } /// diff --git a/src/NLightning.Infrastructure/Transport/Interfaces/ITcpService.cs b/src/NLightning.Infrastructure/Transport/Interfaces/ITcpService.cs index 6c79a582..1eeb2ed3 100644 --- a/src/NLightning.Infrastructure/Transport/Interfaces/ITcpService.cs +++ b/src/NLightning.Infrastructure/Transport/Interfaces/ITcpService.cs @@ -1,3 +1,5 @@ +using System.Net; + namespace NLightning.Infrastructure.Transport.Interfaces; using Events; @@ -6,6 +8,15 @@ namespace NLightning.Infrastructure.Transport.Interfaces; public interface ITcpService { + /// + /// Gets the list of IP endpoints that the service is currently listening to for incoming connections. + /// + /// + /// This property provides the collection of addresses and ports actively used by the TCP listener + /// to accept connections. The list remains updated as the service starts and stops listening to various addresses. + /// + List ListeningTo { get; } + /// /// Event triggered when a new peer successfully establishes a connection. /// diff --git a/src/NLightning.Infrastructure/Transport/Services/TcpService.cs b/src/NLightning.Infrastructure/Transport/Services/TcpService.cs index 047de458..715d0c19 100644 --- a/src/NLightning.Infrastructure/Transport/Services/TcpService.cs +++ b/src/NLightning.Infrastructure/Transport/Services/TcpService.cs @@ -2,15 +2,15 @@ using System.Net.Sockets; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using NLightning.Domain.Exceptions; -using NLightning.Infrastructure.Node.ValueObjects; -using NLightning.Infrastructure.Protocol.Models; namespace NLightning.Infrastructure.Transport.Services; +using Domain.Exceptions; using Domain.Node.Options; using Events; using Interfaces; +using Node.ValueObjects; +using Protocol.Models; public class TcpService : ITcpService { @@ -21,6 +21,8 @@ public class TcpService : ITcpService private CancellationTokenSource? _cts; private Task? _listeningTask; + public List ListeningTo => _listeners.Select(l => l.LocalEndpoint).ToList(); + /// public event EventHandler? OnNewPeerConnected; @@ -49,7 +51,8 @@ public Task StartListeningAsync(CancellationToken cancellationToken) listener.Start(); _listeners.Add(listener); - _logger.LogInformation("Listening for connections on {Address}:{Port}", ipAddress, port); + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation("Listening for connections on {Address}:{Port}", ipAddress, port); } _listeningTask = ListenForConnectionsAsync(_cts.Token); diff --git a/src/NLightning.Transport.Ipc/Requests/OpenChannelSubscriptionIpcRequest.cs b/src/NLightning.Transport.Ipc/Requests/OpenChannelSubscriptionIpcRequest.cs new file mode 100644 index 00000000..db5bf34d --- /dev/null +++ b/src/NLightning.Transport.Ipc/Requests/OpenChannelSubscriptionIpcRequest.cs @@ -0,0 +1,20 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc.Requests; + +using Domain.Channels.ValueObjects; +using Domain.Client.Requests; + +/// +/// Empty request for OpenChannelSubscription. +/// +[MessagePackObject] +public sealed class OpenChannelSubscriptionIpcRequest +{ + [Key(0)] public required ChannelId ChannelId { get; init; } + + public OpenChannelClientSubscriptionRequest ToClientRequest() + { + return new OpenChannelClientSubscriptionRequest(ChannelId); + } +} \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/Responses/NodeInfoIpcResponse.cs b/src/NLightning.Transport.Ipc/Responses/NodeInfoIpcResponse.cs index 37257309..405c07ef 100644 --- a/src/NLightning.Transport.Ipc/Responses/NodeInfoIpcResponse.cs +++ b/src/NLightning.Transport.Ipc/Responses/NodeInfoIpcResponse.cs @@ -11,10 +11,12 @@ namespace NLightning.Transport.Ipc.Responses; [MessagePackObject] public sealed class NodeInfoIpcResponse { - [Key(0)] public BitcoinNetwork Network { get; init; } - [Key(1)] public Hash BestBlockHash { get; init; } - [Key(2)] public long BestBlockHeight { get; init; } - [Key(3)] public DateTimeOffset? BestBlockTime { get; init; } - [Key(4)] public string? Implementation { get; set; } = "NLightning"; - [Key(5)] public string? Version { get; init; } + [Key(0)] public required CompactPubKey PubKey { get; init; } + [Key(1)] public required List ListeningTo { get; init; } + [Key(2)] public BitcoinNetwork Network { get; init; } + [Key(3)] public Hash BestBlockHash { get; init; } + [Key(4)] public long BestBlockHeight { get; init; } + [Key(5)] public DateTimeOffset? BestBlockTime { get; init; } + [Key(6)] public string? Implementation { get; set; } = "NLightning"; + [Key(7)] public string? Version { get; init; } } \ No newline at end of file diff --git a/src/NLightning.Transport.Ipc/Responses/OpenChannelIpcResponse.cs b/src/NLightning.Transport.Ipc/Responses/OpenChannelIpcResponse.cs index 68861ebd..65331dc8 100644 --- a/src/NLightning.Transport.Ipc/Responses/OpenChannelIpcResponse.cs +++ b/src/NLightning.Transport.Ipc/Responses/OpenChannelIpcResponse.cs @@ -2,7 +2,6 @@ namespace NLightning.Transport.Ipc.Responses; -using Domain.Bitcoin.ValueObjects; using Domain.Channels.ValueObjects; using Domain.Client.Responses; @@ -12,16 +11,12 @@ namespace NLightning.Transport.Ipc.Responses; [MessagePackObject] public sealed class OpenChannelIpcResponse { - [Key(0)] public required TxId TxId { get; init; } - [Key(2)] public uint Index { get; init; } - [Key(3)] public ChannelId ChannelId { get; init; } + [Key(0)] public required ChannelId ChannelId { get; init; } public static OpenChannelIpcResponse FromClientResponse(OpenChannelClientResponse clientResponse) { return new OpenChannelIpcResponse { - TxId = clientResponse.TxId, - Index = clientResponse.Index, ChannelId = clientResponse.ChannelId }; } diff --git a/src/NLightning.Transport.Ipc/Responses/OpenChannelSubscriptionIpcResponse.cs b/src/NLightning.Transport.Ipc/Responses/OpenChannelSubscriptionIpcResponse.cs new file mode 100644 index 00000000..4f0f2e5c --- /dev/null +++ b/src/NLightning.Transport.Ipc/Responses/OpenChannelSubscriptionIpcResponse.cs @@ -0,0 +1,32 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc.Responses; + +using Domain.Bitcoin.ValueObjects; +using Domain.Channels.Enums; +using Domain.Channels.ValueObjects; +using Domain.Client.Responses; + +/// +/// Response for OpenChannelSubscription command +/// +[MessagePackObject] +public sealed class OpenChannelSubscriptionIpcResponse +{ + [Key(0)] public required ChannelId ChannelId { get; init; } + [Key(1)] public required ChannelState ChannelState { get; init; } + [Key(2)] public TxId? TxId { get; init; } + [Key(3)] public uint? Index { get; init; } + + public static OpenChannelSubscriptionIpcResponse FromClientResponse( + OpenChannelClientSubscriptionResponse clientResponse) + { + return new OpenChannelSubscriptionIpcResponse + { + ChannelId = clientResponse.ChannelId, + ChannelState = clientResponse.ChannelState, + TxId = clientResponse.TxId, + Index = clientResponse.Index + }; + } +} \ No newline at end of file diff --git a/test/NLightning.Application.Tests/Channels/Handlers/FundingCreatedMessageHandlerTests.cs b/test/NLightning.Application.Tests/Channels/Handlers/FundingCreatedMessageHandlerTests.cs index fbc596c7..899ce1bd 100644 --- a/test/NLightning.Application.Tests/Channels/Handlers/FundingCreatedMessageHandlerTests.cs +++ b/test/NLightning.Application.Tests/Channels/Handlers/FundingCreatedMessageHandlerTests.cs @@ -30,13 +30,8 @@ namespace NLightning.Application.Tests.Channels.Handlers; public class FundingCreatedMessageHandlerTests { private readonly Mock _mockBlockchainMonitor; - private readonly Mock _mockChannelIdFactory; private readonly Mock _mockChannelMemoryRepository; - private readonly Mock _mockCommitmentTransactionBuilder; - private readonly Mock _mockCommitmentTransactionModelFactory; private readonly Mock _mockLightningSigner; - private readonly Mock> _mockLogger; - private readonly Mock _mockMessageFactory; private readonly Mock _mockUnitOfWork; private readonly Mock _mockChannelDbRepository; private readonly FundingCreatedMessageHandler _handler; @@ -49,29 +44,28 @@ public class FundingCreatedMessageHandlerTests private readonly TxId _fundingTxId; private readonly ushort _fundingOutputIndex; private readonly CompactSignature _remoteSignature; - private readonly CompactSignature _localSignature; public FundingCreatedMessageHandlerTests() { _mockBlockchainMonitor = new Mock(); - _mockChannelIdFactory = new Mock(); + var mockChannelIdFactory = new Mock(); _mockChannelMemoryRepository = new Mock(); - _mockCommitmentTransactionBuilder = new Mock(); - _mockCommitmentTransactionModelFactory = new Mock(); + var mockCommitmentTransactionBuilder = new Mock(); + var mockCommitmentTransactionModelFactory = new Mock(); _mockLightningSigner = new Mock(); - _mockLogger = new Mock>(); - _mockMessageFactory = new Mock(); + var mockLogger = new Mock>(); + var mockMessageFactory = new Mock(); _mockUnitOfWork = new Mock(); _mockChannelDbRepository = new Mock(); _mockUnitOfWork.Setup(x => x.ChannelDbRepository).Returns(_mockChannelDbRepository.Object); - _handler = new FundingCreatedMessageHandler(_mockBlockchainMonitor.Object, _mockChannelIdFactory.Object, + _handler = new FundingCreatedMessageHandler(_mockBlockchainMonitor.Object, mockChannelIdFactory.Object, _mockChannelMemoryRepository.Object, - _mockCommitmentTransactionBuilder.Object, - _mockCommitmentTransactionModelFactory.Object, - _mockLightningSigner.Object, _mockLogger.Object, - _mockMessageFactory.Object, _mockUnitOfWork.Object); + mockCommitmentTransactionBuilder.Object, + mockCommitmentTransactionModelFactory.Object, + _mockLightningSigner.Object, mockLogger.Object, + mockMessageFactory.Object, _mockUnitOfWork.Object); // Setup test data CompactPubKey emptyPubKey = new byte[] { @@ -95,7 +89,7 @@ public FundingCreatedMessageHandlerTests() _remoteSignature = new CompactSignature(new byte[64]); byte[] localSignatureBytes = _remoteSignature; localSignatureBytes[0] = 1; - _localSignature = new CompactSignature(localSignatureBytes); + var localSignature = new CompactSignature(localSignatureBytes); var fundingAmount = LightningMoney.Satoshis(10_000); var commitmentNumber = new CommitmentNumber(emptyPubKey, emptyPubKey, new FakeSha256()); var fundingOutputInfo = new FundingOutputInfo(fundingAmount, emptyPubKey, emptyPubKey) @@ -119,8 +113,8 @@ public FundingCreatedMessageHandlerTests() _peerPubKey, 0, ChannelState.V1Opening, ChannelVersion.V1); // Setup ChannelIdFactory - _mockChannelIdFactory.Setup(x => x.CreateV1(It.IsAny(), It.IsAny())) - .Returns(_newChannelId); + mockChannelIdFactory.Setup(x => x.CreateV1(It.IsAny(), It.IsAny())) + .Returns(_newChannelId); // Setup mock commitment transactions var mockLocalCommitmentTx = @@ -128,11 +122,11 @@ public FundingCreatedMessageHandlerTests() var mockRemoteCommitmentTx = new CommitmentTransactionModel(commitmentNumber, LightningMoney.Zero, fundingOutputInfo); - _mockCommitmentTransactionModelFactory + mockCommitmentTransactionModelFactory .Setup(x => x.CreateCommitmentTransactionModel(It.IsAny(), CommitmentSide.Local)) .Returns(mockLocalCommitmentTx); - _mockCommitmentTransactionModelFactory + mockCommitmentTransactionModelFactory .Setup(x => x.CreateCommitmentTransactionModel(It.IsAny(), CommitmentSide.Remote)) .Returns(mockRemoteCommitmentTx); @@ -140,23 +134,23 @@ public FundingCreatedMessageHandlerTests() var mockLocalUnsignedTx = new SignedTransaction(TxId.Zero, [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]); var mockRemoteUnsignedTx = new SignedTransaction(TxId.One, [0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]); - _mockCommitmentTransactionBuilder + mockCommitmentTransactionBuilder .Setup(x => x.Build(mockLocalCommitmentTx)) .Returns(mockLocalUnsignedTx); - _mockCommitmentTransactionBuilder + mockCommitmentTransactionBuilder .Setup(x => x.Build(mockRemoteCommitmentTx)) .Returns(mockRemoteUnsignedTx); // Setup LightningSigner _mockLightningSigner .Setup(x => x.SignChannelTransaction(It.IsAny(), It.IsAny())) - .Returns(_localSignature); + .Returns(localSignature); // Setup MessageFactory - _mockMessageFactory + mockMessageFactory .Setup(x => x.CreateFundingSignedMessage(It.IsAny(), It.IsAny())) - .Returns(new FundingSignedMessage(new FundingSignedPayload(_newChannelId, _localSignature))); + .Returns(new FundingSignedMessage(new FundingSignedPayload(_newChannelId, localSignature))); // Setup ChannelDbRepository _mockChannelDbRepository @@ -196,8 +190,8 @@ public async Task HandleAsync_ValidMessage_ProcessesChannelAndReturnsFundingSign Assert.IsType(result); // Verify transaction ID and output index were set on the channel - Assert.Equal(_fundingTxId, _channel.FundingOutput.TransactionId); - Assert.Equal(_fundingOutputIndex, _channel.FundingOutput.Index); + Assert.Equal(_fundingTxId, _channel.FundingOutput?.TransactionId); + Assert.Equal(_fundingOutputIndex, _channel.FundingOutput?.Index); // Verify channel ID was updated Assert.Equal(_channel.ChannelId, _newChannelId); @@ -220,7 +214,7 @@ public async Task HandleAsync_ValidMessage_ProcessesChannelAndReturnsFundingSign // Verify channel state was updated Assert.Equal(ChannelState.V1FundingSigned, _channel.State); - // Verify channel was persisted + // Verify if the channel was persisted _mockChannelDbRepository.Verify(x => x.AddAsync(_channel), Times.Once); _mockUnitOfWork.Verify(x => x.SaveChangesAsync(), Times.Once); @@ -231,7 +225,7 @@ public async Task HandleAsync_ValidMessage_ProcessesChannelAndReturnsFundingSign // Verify channel management operations _mockChannelMemoryRepository.Verify(x => x.AddChannel(_channel), Times.Once); - _mockChannelMemoryRepository.Verify(x => x.RemoveTemporaryChannel(_peerPubKey, _tempChannelId), Times.Once); + _mockChannelMemoryRepository.Verify(x => x.TryRemoveTemporaryChannel(_peerPubKey, _tempChannelId), Times.Once); } [Fact] diff --git a/test/NLightning.Daemon.Tests/Handlers/OpenChannelClientHandlerTests.cs b/test/NLightning.Daemon.Tests/Handlers/OpenChannelClientHandlerTests.cs new file mode 100644 index 00000000..c075fef8 --- /dev/null +++ b/test/NLightning.Daemon.Tests/Handlers/OpenChannelClientHandlerTests.cs @@ -0,0 +1,387 @@ +using Microsoft.Extensions.Logging; + +namespace NLightning.Daemon.Tests.Handlers; + +using Daemon.Handlers; +using Domain.Bitcoin.Interfaces; +using Domain.Channels.Enums; +using Domain.Channels.Events; +using Domain.Channels.Interfaces; +using Domain.Channels.Models; +using Domain.Channels.ValueObjects; +using Domain.Client.Constants; +using Domain.Client.Exceptions; +using Domain.Client.Requests; +using Domain.Crypto.ValueObjects; +using Domain.Enums; +using Domain.Exceptions; +using Domain.Money; +using Domain.Node.Events; +using Domain.Node.Interfaces; +using Domain.Node.Models; +using Domain.Node.Options; +using Domain.Node.ValueObjects; +using Domain.Protocol.Interfaces; +using Domain.Protocol.Messages; +using Domain.Protocol.Payloads; +using Domain.Protocol.Tlv; +using Domain.Protocol.ValueObjects; +using Infrastructure.Bitcoin.Wallet.Interfaces; + +public class OpenChannelClientHandlerTests +{ + private readonly Mock _blockchainMonitorMock; + private readonly Mock _channelFactoryMock; + private readonly Mock _channelMemoryRepositoryMock; + private readonly Mock _messageFactoryMock; + private readonly Mock _peerManagerMock; + private readonly Mock _utxoMemoryRepositoryMock; + private readonly OpenChannelClientHandler _handler; + + public OpenChannelClientHandlerTests() + { + _blockchainMonitorMock = new Mock(); + _channelFactoryMock = new Mock(); + _channelMemoryRepositoryMock = new Mock(); + var loggerMock = new Mock>(); + _messageFactoryMock = new Mock(); + _peerManagerMock = new Mock(); + _utxoMemoryRepositoryMock = new Mock(); + + _handler = new OpenChannelClientHandler( + _blockchainMonitorMock.Object, + _channelFactoryMock.Object, + _channelMemoryRepositoryMock.Object, + loggerMock.Object, + _messageFactoryMock.Object, + _peerManagerMock.Object, + _utxoMemoryRepositoryMock.Object + ); + } + + [Fact] + public async Task GivenValidRequest_WhenHandleAsync_ThenFollowsCompleteFlow() + { + // Arrange + var peerId = CreateDummyPubKey(); + var nodeInfo = $"{peerId}@127.0.0.1:9735"; + var fundingAmount = LightningMoney.Satoshis(100000); + var request = new OpenChannelClientRequest(nodeInfo, fundingAmount); + + var peerModel = new PeerModel(peerId, "127.0.0.1", 9735, "ipv4"); + var peerServiceMock = new Mock(); + peerServiceMock.Setup(x => x.Features).Returns(new FeatureOptions()); + peerModel.SetPeerService(peerServiceMock.Object); + + _peerManagerMock.Setup(x => x.GetPeer(peerId)).Returns(peerModel); + _blockchainMonitorMock.Setup(x => x.LastProcessedBlockHeight).Returns(100u); + _utxoMemoryRepositoryMock.Setup(x => x.GetConfirmedBalance(100u)).Returns(LightningMoney.Satoshis(200000)); + + var channelConfig = new ChannelConfig(); + var localKeySet = new ChannelKeySetModel(0, peerId, peerId, peerId, peerId, peerId, peerId); + var channelModel = new ChannelModel(channelConfig, CreateRandomChannelId(), null, null, true, null, null, + fundingAmount, localKeySet, 0, 0, LightningMoney.Zero, null, 0, peerId, 0, + ChannelState.V1Opening, ChannelVersion.V1); + var tempChannelId = channelModel.ChannelId; + + _channelFactoryMock.Setup(x => x.CreateChannelV1AsInitiatorAsync(request, It.IsAny(), peerId)) + .ReturnsAsync(channelModel); + + var openChannel1Message = CreateDummyOpenChannel1Message(tempChannelId, fundingAmount, peerId); + _messageFactoryMock.Setup(x => x.CreateOpenChannel1Message(It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(openChannel1Message); + + peerServiceMock.Setup(x => x.SendMessageAsync(It.IsAny())).Returns(Task.CompletedTask); + + var finalChannelId = CreateRandomChannelId(); + + // Act + var handleTask = _handler.HandleAsync(request, CancellationToken.None); + + // Wait a bit to ensure it reached the `await tsc.Task` + await Task.Delay(100, TestContext.Current.CancellationToken); + + _channelMemoryRepositoryMock.Raise(x => x.OnChannelUpgraded += null, null!, + new ChannelUpgradedEventArgs(tempChannelId, finalChannelId)); + + var response = await handleTask; + + // Assert + Assert.Equal(finalChannelId, response.ChannelId); + _peerManagerMock.Verify(x => x.GetPeer(peerId), Times.Once); + _utxoMemoryRepositoryMock.Verify(x => x.LockUtxosToSpendOnChannel(fundingAmount, tempChannelId), Times.Once); + _channelMemoryRepositoryMock.Verify(x => x.AddTemporaryChannel(peerId, channelModel), Times.Once); + peerServiceMock.Verify(x => x.SendMessageAsync(openChannel1Message), Times.Once); + } + + [Fact] + public async Task GivenPeerNotConnected_WhenHandleAsync_ThenConnectsToPeer() + { + // Arrange + var peerId = CreateDummyPubKey(); + var nodeInfo = $"{peerId}@127.0.0.1:9735"; + var fundingAmount = LightningMoney.Satoshis(100000); + var request = new OpenChannelClientRequest(nodeInfo, fundingAmount); + + var peerModel = new PeerModel(peerId, "127.0.0.1", 9735, "ipv4"); + var peerServiceMock = new Mock(); + peerServiceMock.Setup(x => x.Features).Returns(new FeatureOptions()); + peerModel.SetPeerService(peerServiceMock.Object); + + // Peer is not found initially + _peerManagerMock.Setup(x => x.GetPeer(peerId)).Returns((PeerModel?)null); + _peerManagerMock.Setup(x => x.ConnectToPeerAsync(It.IsAny())).ReturnsAsync(peerModel); + + _blockchainMonitorMock.Setup(x => x.LastProcessedBlockHeight).Returns(100u); + _utxoMemoryRepositoryMock.Setup(x => x.GetConfirmedBalance(100u)).Returns(LightningMoney.Satoshis(200000)); + + var channelModel = new ChannelModel(new ChannelConfig(), CreateRandomChannelId(), null, null, true, null, null, + fundingAmount, + new ChannelKeySetModel(0, peerId, peerId, peerId, peerId, peerId, peerId), + 0, 0, LightningMoney.Zero, null, 0, peerId, 0, ChannelState.V1Opening, + ChannelVersion.V1); + var tempChannelId = channelModel.ChannelId; + _channelFactoryMock.Setup(x => x.CreateChannelV1AsInitiatorAsync(request, It.IsAny(), peerId)) + .ReturnsAsync(channelModel); + _messageFactoryMock.Setup(x => x.CreateOpenChannel1Message(It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(CreateDummyOpenChannel1Message(tempChannelId, fundingAmount, peerId)); + peerServiceMock.Setup(x => x.SendMessageAsync(It.IsAny())).Returns(Task.CompletedTask); + + var finalChannelId = CreateRandomChannelId(); + + // Act + var handleTask = _handler.HandleAsync(request, CancellationToken.None); + await Task.Delay(100, TestContext.Current.CancellationToken); + + _channelMemoryRepositoryMock.Raise(x => x.OnChannelUpgraded += null, null!, + new ChannelUpgradedEventArgs(tempChannelId, finalChannelId)); + await handleTask; + + // Assert + _peerManagerMock.Verify(x => x.ConnectToPeerAsync(It.Is(p => p.Address == nodeInfo)), + Times.Once); + } + + [Fact] + public async Task GivenInsufficientBalance_WhenHandleAsync_ThenThrowsClientException() + { + // Arrange + var peerId = CreateDummyPubKey(); + var nodeInfo = $"{peerId}@127.0.0.1:9735"; + var fundingAmount = LightningMoney.Satoshis(100000); + var request = new OpenChannelClientRequest(nodeInfo, fundingAmount); + + var peerModel = new PeerModel(peerId, "127.0.0.1", 9735, "ipv4"); + _peerManagerMock.Setup(x => x.GetPeer(peerId)).Returns(peerModel); + _blockchainMonitorMock.Setup(x => x.LastProcessedBlockHeight).Returns(100u); + _utxoMemoryRepositoryMock.Setup(x => x.GetConfirmedBalance(100u)).Returns(LightningMoney.Satoshis(50000)); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => _handler.HandleAsync(request, CancellationToken.None)); + Assert.Equal(ErrorCodes.NotEnoughBalance, ex.ErrorCode); + } + + [Fact] + public async Task GivenInvalidNodeInfo_WhenHandleAsync_ThenThrowsClientException() + { + // Arrange + var request = new OpenChannelClientRequest("", LightningMoney.Satoshis(100000)); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => _handler.HandleAsync(request, CancellationToken.None)); + Assert.Equal(ErrorCodes.InvalidAddress, ex.ErrorCode); + } + + [Fact] + public async Task GivenPeerDisconnection_WhenHandleAsync_ThenThrowsConnectionException() + { + // Arrange + var peerId = CreateDummyPubKey(); + var nodeInfo = $"{peerId}@127.0.0.1:9735"; + var fundingAmount = LightningMoney.Satoshis(100000); + var request = new OpenChannelClientRequest(nodeInfo, fundingAmount); + + var peerModel = new PeerModel(peerId, "127.0.0.1", 9735, "ipv4"); + var peerServiceMock = new Mock(); + peerServiceMock.Setup(x => x.Features).Returns(new FeatureOptions()); + peerModel.SetPeerService(peerServiceMock.Object); + + _peerManagerMock.Setup(x => x.GetPeer(peerId)).Returns(peerModel); + _blockchainMonitorMock.Setup(x => x.LastProcessedBlockHeight).Returns(100u); + _utxoMemoryRepositoryMock.Setup(x => x.GetConfirmedBalance(100u)).Returns(LightningMoney.Satoshis(200000)); + + var channelModel = new ChannelModel(new ChannelConfig(), CreateRandomChannelId(), null, null, true, null, null, + fundingAmount, + new ChannelKeySetModel(0, peerId, peerId, peerId, peerId, peerId, peerId), + 0, 0, LightningMoney.Zero, null, 0, peerId, 0, ChannelState.V1Opening, + ChannelVersion.V1); + _channelFactoryMock.Setup(x => x.CreateChannelV1AsInitiatorAsync(request, It.IsAny(), peerId)) + .ReturnsAsync(channelModel); + _messageFactoryMock.Setup(x => x.CreateOpenChannel1Message(It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(CreateDummyOpenChannel1Message(channelModel.ChannelId, fundingAmount, peerId)); + + // Act + var handleTask = _handler.HandleAsync(request, CancellationToken.None); + await Task.Delay(100, TestContext.Current.CancellationToken); + + peerServiceMock.Raise(x => x.OnDisconnect += null, null!, new PeerDisconnectedEventArgs(peerId)); + + // Assert + await Assert.ThrowsAsync(() => handleTask); + _utxoMemoryRepositoryMock.Verify(x => x.ReturnUtxosNotSpentOnChannel(channelModel.ChannelId), Times.Once); + } + + [Fact] + public async Task GivenAttentionMessage_WhenHandleAsync_ThenThrowsChannelErrorException() + { + // Arrange + var peerId = CreateDummyPubKey(); + var nodeInfo = $"{peerId}@127.0.0.1:9735"; + var fundingAmount = LightningMoney.Satoshis(100000); + var request = new OpenChannelClientRequest(nodeInfo, fundingAmount); + + var peerModel = new PeerModel(peerId, "127.0.0.1", 9735, "ipv4"); + var peerServiceMock = new Mock(); + peerServiceMock.Setup(x => x.Features).Returns(new FeatureOptions()); + peerModel.SetPeerService(peerServiceMock.Object); + + _peerManagerMock.Setup(x => x.GetPeer(peerId)).Returns(peerModel); + _blockchainMonitorMock.Setup(x => x.LastProcessedBlockHeight).Returns(100u); + _utxoMemoryRepositoryMock.Setup(x => x.GetConfirmedBalance(100u)).Returns(LightningMoney.Satoshis(200000)); + + var channelModel = new ChannelModel(new ChannelConfig(), CreateRandomChannelId(), null, null, true, null, null, + fundingAmount, + new ChannelKeySetModel(0, peerId, peerId, peerId, peerId, peerId, peerId), + 0, 0, LightningMoney.Zero, null, 0, peerId, 0, ChannelState.V1Opening, + ChannelVersion.V1); + _channelFactoryMock.Setup(x => x.CreateChannelV1AsInitiatorAsync(request, It.IsAny(), peerId)) + .ReturnsAsync(channelModel); + _messageFactoryMock.Setup(x => x.CreateOpenChannel1Message(It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(CreateDummyOpenChannel1Message(channelModel.ChannelId, fundingAmount, peerId)); + + // Act + var handleTask = _handler.HandleAsync(request, CancellationToken.None); + await Task.Delay(100, TestContext.Current.CancellationToken); + + peerServiceMock.Raise(x => x.OnAttentionMessageReceived += null, null!, + new AttentionMessageEventArgs("Error Message", peerId, + channelModel.ChannelId)); + + // Assert + await Assert.ThrowsAsync(() => handleTask); + _utxoMemoryRepositoryMock.Verify(x => x.ReturnUtxosNotSpentOnChannel(channelModel.ChannelId), Times.Once); + } + + [Fact] + public async Task GivenExceptionRaised_WhenHandleAsync_ThenThrowsException() + { + // Arrange + var peerId = CreateDummyPubKey(); + var nodeInfo = $"{peerId}@127.0.0.1:9735"; + var fundingAmount = LightningMoney.Satoshis(100000); + var request = new OpenChannelClientRequest(nodeInfo, fundingAmount); + + var peerModel = new PeerModel(peerId, "127.0.0.1", 9735, "ipv4"); + var peerServiceMock = new Mock(); + peerServiceMock.Setup(x => x.Features).Returns(new FeatureOptions()); + peerModel.SetPeerService(peerServiceMock.Object); + + _peerManagerMock.Setup(x => x.GetPeer(peerId)).Returns(peerModel); + _blockchainMonitorMock.Setup(x => x.LastProcessedBlockHeight).Returns(100u); + _utxoMemoryRepositoryMock.Setup(x => x.GetConfirmedBalance(100u)).Returns(LightningMoney.Satoshis(200000)); + + var channelModel = new ChannelModel(new ChannelConfig(), CreateRandomChannelId(), null, null, true, null, null, + fundingAmount, + new ChannelKeySetModel(0, peerId, peerId, peerId, peerId, peerId, peerId), + 0, 0, LightningMoney.Zero, null, 0, peerId, 0, ChannelState.V1Opening, + ChannelVersion.V1); + _channelFactoryMock.Setup(x => x.CreateChannelV1AsInitiatorAsync(request, It.IsAny(), peerId)) + .ReturnsAsync(channelModel); + _messageFactoryMock.Setup(x => x.CreateOpenChannel1Message(It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(CreateDummyOpenChannel1Message(channelModel.ChannelId, fundingAmount, peerId)); + + // Act + var handleTask = _handler.HandleAsync(request, CancellationToken.None); + await Task.Delay(100, TestContext.Current.CancellationToken); + + var expectedException = new ChannelErrorException("Critical error", channelModel.ChannelId); + peerServiceMock.Raise(x => x.OnExceptionRaised += null, null!, expectedException); + + // Assert + var ex = await Assert.ThrowsAsync(() => handleTask); + Assert.Same(expectedException, ex); + _utxoMemoryRepositoryMock.Verify(x => x.ReturnUtxosNotSpentOnChannel(channelModel.ChannelId), Times.Once); + } + + private static ChannelId CreateRandomChannelId() + { + var bytes = new byte[32]; + Random.Shared.NextBytes(bytes); + return new ChannelId(bytes); + } + + private static CompactPubKey CreateDummyPubKey() + { + var bytes = new byte[33]; + bytes[0] = 0x02; + return new CompactPubKey(bytes); + } + + private static OpenChannel1Message CreateDummyOpenChannel1Message(ChannelId tempChannelId, + LightningMoney fundingAmount, + CompactPubKey peerId) + { + var payload = new OpenChannel1Payload(new ChainHash(new byte[32]), new ChannelFlags(ChannelFlag.None), + tempChannelId, LightningMoney.Zero, peerId, LightningMoney.Zero, + LightningMoney.Zero, peerId, fundingAmount, peerId, peerId, + LightningMoney.Zero, 483, LightningMoney.Zero, peerId, + LightningMoney.Zero, peerId, 144); + var channelTypeTlv = new ChannelTypeTlv([]); + return new OpenChannel1Message(payload, channelTypeTlv); + } +} \ No newline at end of file diff --git a/test/NLightning.Daemon.Tests/Handlers/OpenChannelClientSubscriptionHandlerTests.cs b/test/NLightning.Daemon.Tests/Handlers/OpenChannelClientSubscriptionHandlerTests.cs new file mode 100644 index 00000000..97967a29 --- /dev/null +++ b/test/NLightning.Daemon.Tests/Handlers/OpenChannelClientSubscriptionHandlerTests.cs @@ -0,0 +1,307 @@ +using Microsoft.Extensions.Logging; + +namespace NLightning.Daemon.Tests.Handlers; + +using Daemon.Handlers; +using Domain.Bitcoin.Interfaces; +using Domain.Bitcoin.ValueObjects; +using Domain.Bitcoin.Wallet.Models; +using Domain.Channels.Enums; +using Domain.Channels.Events; +using Domain.Channels.Interfaces; +using Domain.Channels.Models; +using Domain.Channels.ValueObjects; +using Domain.Client.Constants; +using Domain.Client.Exceptions; +using Domain.Client.Requests; +using Domain.Crypto.ValueObjects; +using Domain.Exceptions; +using Domain.Money; +using Domain.Node.Events; +using Domain.Node.Interfaces; +using Domain.Node.Models; +using Domain.Node.Options; + +public class OpenChannelClientSubscriptionHandlerTests +{ + private readonly Mock _channelMemoryRepositoryMock; + private readonly Mock _peerManagerMock; + private readonly Mock _utxoMemoryRepositoryMock; + private readonly OpenChannelClientSubscriptionHandler _handler; + + public OpenChannelClientSubscriptionHandlerTests() + { + _channelMemoryRepositoryMock = new Mock(); + _peerManagerMock = new Mock(); + _utxoMemoryRepositoryMock = new Mock(); + var loggerMock = new Mock>(); + + _handler = new OpenChannelClientSubscriptionHandler( + _channelMemoryRepositoryMock.Object, + loggerMock.Object, + _peerManagerMock.Object, + _utxoMemoryRepositoryMock.Object + ); + } + + [Fact] + public async Task GivenChannelDoesNotExist_WhenHandleAsync_ThenThrowsClientException() + { + // Arrange + var channelId = CreateRandomChannelId(); + var request = new OpenChannelClientSubscriptionRequest(channelId); + ChannelModel? channel = null; + _channelMemoryRepositoryMock.Setup(x => x.TryGetChannel(channelId, out channel)).Returns(false); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => _handler.HandleAsync(request, CancellationToken.None)); + Assert.Equal(ErrorCodes.InvalidChannel, ex.ErrorCode); + } + + [Fact] + public async Task GivenPeerNotFound_WhenHandleAsync_ThenThrowsClientException() + { + // Arrange + var channelId = CreateRandomChannelId(); + var request = new OpenChannelClientSubscriptionRequest(channelId); + var peerId = CreateDummyPubKey(); + var channel = CreateDummyChannel(channelId, peerId); + _channelMemoryRepositoryMock.Setup(x => x.TryGetChannel(channelId, out channel)).Returns(true); + _peerManagerMock.Setup(x => x.GetPeer(peerId)).Returns((PeerModel?)null); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => _handler.HandleAsync(request, CancellationToken.None)); + Assert.Equal(ErrorCodes.InvalidOperation, ex.ErrorCode); + } + + [Fact] + public async Task GivenNoLockedUtxos_WhenHandleAsync_ThenThrowsClientException() + { + // Arrange + var channelId = CreateRandomChannelId(); + var request = new OpenChannelClientSubscriptionRequest(channelId); + var peerId = CreateDummyPubKey(); + var channel = CreateDummyChannel(channelId, peerId); + var peer = new PeerModel(peerId, "localhost", 9735, "ipv4"); + + _channelMemoryRepositoryMock.Setup(x => x.TryGetChannel(channelId, out channel)).Returns(true); + _peerManagerMock.Setup(x => x.GetPeer(peerId)).Returns(peer); + _utxoMemoryRepositoryMock.Setup(x => x.GetLockedUtxosForChannel(channelId)).Returns(new List()); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => _handler.HandleAsync(request, CancellationToken.None)); + Assert.Equal(ErrorCodes.InvalidOperation, ex.ErrorCode); + } + + [Fact] + public async Task GivenValidRequest_WhenChannelUpdatedToFundingSigned_ThenReturnsResponse() + { + // Arrange + var channelId = CreateRandomChannelId(); + var request = new OpenChannelClientSubscriptionRequest(channelId); + var peerId = CreateDummyPubKey(); + var channel = CreateDummyChannel(channelId, peerId); + var peer = new PeerModel(peerId, "localhost", 9735, "ipv4"); + var peerServiceMock = new Mock(); + peerServiceMock.Setup(x => x.Features).Returns(new FeatureOptions()); + peer.SetPeerService(peerServiceMock.Object); + + _channelMemoryRepositoryMock.Setup(x => x.TryGetChannel(channelId, out channel)).Returns(true); + _peerManagerMock.Setup(x => x.GetPeer(peerId)).Returns(peer); + _utxoMemoryRepositoryMock.Setup(x => x.GetLockedUtxosForChannel(channelId)) + .Returns([CreateDummyUtxo()]); + + // Act + var handleTask = _handler.HandleAsync(request, CancellationToken.None); + + // Trigger Event + channel.UpdateState(ChannelState.V1FundingSigned); + _channelMemoryRepositoryMock.Raise(x => x.OnChannelUpdated += null, this, new ChannelUpdatedEventArgs(channel)); + + var response = await handleTask; + + // Assert + Assert.Equal(channelId, response.ChannelId); + Assert.Equal(ChannelState.V1FundingSigned, response.ChannelState); + } + + [Fact] + public async Task GivenValidRequest_WhenChannelUpdatedToReadyForUs_ThenReturnsResponse() + { + // Arrange + var channelId = CreateRandomChannelId(); + var request = new OpenChannelClientSubscriptionRequest(channelId); + var peerId = CreateDummyPubKey(); + var channel = CreateDummyChannel(channelId, peerId); + var peer = new PeerModel(peerId, "localhost", 9735, "ipv4"); + var peerServiceMock = new Mock(); + peerServiceMock.Setup(x => x.Features).Returns(new FeatureOptions()); + peer.SetPeerService(peerServiceMock.Object); + + _channelMemoryRepositoryMock.Setup(x => x.TryGetChannel(channelId, out channel)).Returns(true); + _peerManagerMock.Setup(x => x.GetPeer(peerId)).Returns(peer); + _utxoMemoryRepositoryMock.Setup(x => x.GetLockedUtxosForChannel(channelId)) + .Returns([CreateDummyUtxo()]); + + // Act + var handleTask = _handler.HandleAsync(request, CancellationToken.None); + + // Trigger Event + channel.UpdateState(ChannelState.ReadyForUs); + _channelMemoryRepositoryMock.Raise(x => x.OnChannelUpdated += null, this, new ChannelUpdatedEventArgs(channel)); + + var response = await handleTask; + + // Assert + Assert.Equal(channelId, response.ChannelId); + Assert.Equal(ChannelState.ReadyForUs, response.ChannelState); + } + + [Fact] + public async Task GivenValidRequest_WhenChannelUpdatedToReadyForThem_ThenReturnsResponse() + { + // Arrange + var channelId = CreateRandomChannelId(); + var request = new OpenChannelClientSubscriptionRequest(channelId); + var peerId = CreateDummyPubKey(); + var channel = CreateDummyChannel(channelId, peerId); + var peer = new PeerModel(peerId, "localhost", 9735, "ipv4"); + var peerServiceMock = new Mock(); + peerServiceMock.Setup(x => x.Features).Returns(new FeatureOptions()); + peer.SetPeerService(peerServiceMock.Object); + + _channelMemoryRepositoryMock.Setup(x => x.TryGetChannel(channelId, out channel)).Returns(true); + _peerManagerMock.Setup(x => x.GetPeer(peerId)).Returns(peer); + _utxoMemoryRepositoryMock.Setup(x => x.GetLockedUtxosForChannel(channelId)) + .Returns([CreateDummyUtxo()]); + + // Act + var handleTask = _handler.HandleAsync(request, CancellationToken.None); + + // Trigger Event + channel.UpdateState(ChannelState.ReadyForThem); + _channelMemoryRepositoryMock.Raise(x => x.OnChannelUpdated += null, this, new ChannelUpdatedEventArgs(channel)); + + var response = await handleTask; + + // Assert + Assert.Equal(channelId, response.ChannelId); + Assert.Equal(ChannelState.ReadyForUs, + response.ChannelState); // Note: Handler sets response.ChannelState to ReadyForUs in both cases + } + + [Fact] + public async Task GivenValidRequest_WhenAttentionMessageReceived_ThenThrowsChannelErrorException() + { + // Arrange + var channelId = CreateRandomChannelId(); + var request = new OpenChannelClientSubscriptionRequest(channelId); + var peerId = CreateDummyPubKey(); + var channel = CreateDummyChannel(channelId, peerId); + var peer = new PeerModel(peerId, "localhost", 9735, "ipv4"); + var peerServiceMock = new Mock(); + peerServiceMock.Setup(x => x.Features).Returns(new FeatureOptions()); + peer.SetPeerService(peerServiceMock.Object); + + _channelMemoryRepositoryMock.Setup(x => x.TryGetChannel(channelId, out channel)).Returns(true); + _peerManagerMock.Setup(x => x.GetPeer(peerId)).Returns(peer); + _utxoMemoryRepositoryMock.Setup(x => x.GetLockedUtxosForChannel(channelId)) + .Returns([CreateDummyUtxo()]); + + // Act + var handleTask = _handler.HandleAsync(request, CancellationToken.None); + + // Trigger Event + peerServiceMock.Raise(x => x.OnAttentionMessageReceived += null, this, + new AttentionMessageEventArgs("Test error", peerId, channelId)); + + // Assert + await Assert.ThrowsAsync(() => handleTask); + } + + [Fact] + public async Task GivenValidRequest_WhenPeerDisconnected_ThenThrowsConnectionException() + { + // Arrange + var channelId = CreateRandomChannelId(); + var request = new OpenChannelClientSubscriptionRequest(channelId); + var peerId = CreateDummyPubKey(); + var channel = CreateDummyChannel(channelId, peerId); + var peer = new PeerModel(peerId, "localhost", 9735, "ipv4"); + var peerServiceMock = new Mock(); + peerServiceMock.Setup(x => x.Features).Returns(new FeatureOptions()); + peer.SetPeerService(peerServiceMock.Object); + + _channelMemoryRepositoryMock.Setup(x => x.TryGetChannel(channelId, out channel)).Returns(true); + _peerManagerMock.Setup(x => x.GetPeer(peerId)).Returns(peer); + _utxoMemoryRepositoryMock.Setup(x => x.GetLockedUtxosForChannel(channelId)) + .Returns([CreateDummyUtxo()]); + + // Act + var handleTask = _handler.HandleAsync(request, CancellationToken.None); + + // Trigger Event + peerServiceMock.Raise(x => x.OnDisconnect += null, this, new PeerDisconnectedEventArgs(peerId)); + + // Assert + await Assert.ThrowsAsync(() => handleTask); + } + + [Fact] + public async Task GivenValidRequest_WhenExceptionRaised_ThenThrowsException() + { + // Arrange + var channelId = CreateRandomChannelId(); + var request = new OpenChannelClientSubscriptionRequest(channelId); + var peerId = CreateDummyPubKey(); + var channel = CreateDummyChannel(channelId, peerId); + var peer = new PeerModel(peerId, "localhost", 9735, "ipv4"); + var peerServiceMock = new Mock(); + peerServiceMock.Setup(x => x.Features).Returns(new FeatureOptions()); + peer.SetPeerService(peerServiceMock.Object); + + _channelMemoryRepositoryMock.Setup(x => x.TryGetChannel(channelId, out channel)).Returns(true); + _peerManagerMock.Setup(x => x.GetPeer(peerId)).Returns(peer); + _utxoMemoryRepositoryMock.Setup(x => x.GetLockedUtxosForChannel(channelId)) + .Returns([CreateDummyUtxo()]); + + // Act + var handleTask = _handler.HandleAsync(request, CancellationToken.None); + + // Trigger Event + peerServiceMock.Raise(x => x.OnExceptionRaised += null, this, + new ChannelErrorException("Test exception", channelId)); + + // Assert + await Assert.ThrowsAsync(() => handleTask); + } + + private static ChannelId CreateRandomChannelId() + { + var bytes = new byte[32]; + Random.Shared.NextBytes(bytes); + return new ChannelId(bytes); + } + + private static CompactPubKey CreateDummyPubKey() + { + var bytes = new byte[33]; + bytes[0] = 0x02; + for (var i = 1; i < 33; i++) bytes[i] = (byte)i; + return new CompactPubKey(bytes); + } + + private static UtxoModel CreateDummyUtxo() + { + return new UtxoModel(new TxId(new byte[32]), 0, LightningMoney.Satoshis(1000000), 100, 0, false, + Domain.Bitcoin.Enums.AddressType.P2Wpkh); + } + + private static ChannelModel CreateDummyChannel(ChannelId channelId, CompactPubKey peerId) + { + return new ChannelModel(new ChannelConfig(), channelId, null, null, true, null, null, + LightningMoney.Satoshis(100000), + new ChannelKeySetModel(0, peerId, peerId, peerId, peerId, peerId, peerId), 0, 0, + LightningMoney.Zero, null, 0, peerId, 0, ChannelState.V1Opening, ChannelVersion.V1); + } +} \ No newline at end of file diff --git a/test/NLightning.Integration.Tests/Docker/ChannelOpeningFlowTests.cs b/test/NLightning.Integration.Tests/Docker/ChannelOpeningFlowTests.cs index fe009fd0..1fe56e53 100644 --- a/test/NLightning.Integration.Tests/Docker/ChannelOpeningFlowTests.cs +++ b/test/NLightning.Integration.Tests/Docker/ChannelOpeningFlowTests.cs @@ -1,816 +1,879 @@ -using System.Net; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Moq.Protected; -using NBitcoin; -using NLightning.Application; -using NLightning.Daemon.Handlers; -using NLightning.Daemon.Interfaces; -using NLightning.Domain.Bitcoin.Events; -using NLightning.Domain.Bitcoin.Interfaces; -using NLightning.Domain.Bitcoin.Transactions.Factories; -using NLightning.Domain.Bitcoin.Transactions.Interfaces; -using NLightning.Domain.Channels.Factories; -using NLightning.Domain.Channels.Interfaces; -using NLightning.Domain.Channels.Validators; -using NLightning.Domain.Client.Requests; -using NLightning.Domain.Client.Responses; -using NLightning.Domain.Crypto.Hashes; -using NLightning.Domain.Enums; -using NLightning.Domain.Money; -using NLightning.Domain.Node.Interfaces; -using NLightning.Domain.Node.Options; -using NLightning.Domain.Node.ValueObjects; -using NLightning.Domain.Protocol.Constants; -using NLightning.Domain.Protocol.Interfaces; -using NLightning.Domain.Protocol.ValueObjects; -using NLightning.Infrastructure; -using NLightning.Infrastructure.Bitcoin; -using NLightning.Infrastructure.Bitcoin.Builders; -using NLightning.Infrastructure.Bitcoin.Options; -using NLightning.Infrastructure.Bitcoin.Services; -using NLightning.Infrastructure.Bitcoin.Signers; -using NLightning.Infrastructure.Bitcoin.Wallet.Interfaces; -using NLightning.Infrastructure.Persistence; -using NLightning.Infrastructure.Persistence.Contexts; -using NLightning.Infrastructure.Repositories; -using NLightning.Infrastructure.Serialization; -using NLightning.Tests.Utils; -using ServiceStack; - -namespace NLightning.Integration.Tests.Docker; - -using Fixtures; -using Mock; -using TestCollections; -using Utils; - -[Collection(LightningRegtestNetworkFixtureCollection.Name)] -public class ChannelOpeningFlowTests : IDisposable -{ - private readonly LightningRegtestNetworkFixture _lightningRegtestNetworkFixture; - private readonly IPeerManager _peerManager; - private readonly IChannelMemoryRepository _channelMemoryRepository; - private readonly IBlockchainMonitor _blockchainMonitor; - private readonly int _port; - private readonly string _databaseFilePath = $"nlightning_channel_test_{Guid.NewGuid()}.db"; - private readonly IServiceProvider _serviceProvider; - - public ChannelOpeningFlowTests(LightningRegtestNetworkFixture fixture, ITestOutputHelper output) - { - _lightningRegtestNetworkFixture = fixture; - Console.SetOut(new TestOutputWriter(output)); - - _port = PortPoolUtil.GetAvailablePortAsync().GetAwaiter().GetResult(); - Assert.True(_port > 0); - ISecureKeyManager secureKeyManager = new FakeSecureKeyManager(); - - // Get Bitcoin network info - Assert.NotNull(_lightningRegtestNetworkFixture.Builder); - var bitcoinConfiguration = _lightningRegtestNetworkFixture.Builder.Configuration.BTCNodes[0]; - var zmqRawBlockPort = - bitcoinConfiguration.Cmd.First(c => c.Contains("-zmqpubrawblock")).Split(':')[2]; - var zmqRawTxPort = - bitcoinConfiguration.Cmd.First(c => c.Contains("-zmqpubrawtx")).Split(':')[2]; - var bitcoin = _lightningRegtestNetworkFixture.Builder.BitcoinRpcClient; - Assert.NotNull(bitcoin); - var bitcoinEndpoint = bitcoin.Address.ToString(); - - // Mock HttpClient for FeeService - var httpMessageHandlerMock = new Mock(MockBehavior.Strict); - httpMessageHandlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(() => new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent("{\"fastestFee\": 2}") - }); - - // Build configuration - List> inMemoryConfiguration = - [ - new("Serilog:MinimumLevel:NLightning", "Verbose"), - new("Node:Network", "regtest"), - new("Node:Daemon", "false"), - new("Database:Provider", "Sqlite"), - new("Database:ConnectionString", $"Data Source={_databaseFilePath}"), - new("Bitcoin:RpcEndpoint", bitcoinEndpoint), - new("Bitcoin:RpcUser", bitcoin.CredentialString.UserPassword.UserName), - new("Bitcoin:RpcPassword", bitcoin.CredentialString.UserPassword.Password), - new("Bitcoin:ZmqHost", bitcoin.Address.Host), - new("Bitcoin:ZmqBlockPort", zmqRawBlockPort), - new("Bitcoin:ZmqTxPort", zmqRawTxPort) - ]; - var configuration = new ConfigurationBuilder().AddInMemoryCollection(inMemoryConfiguration).Build(); - - // Create a service collection - var services = new ServiceCollection(); - services.AddSingleton(configuration); - services.AddHttpClient(client => - { - client.Timeout = TimeSpan.FromSeconds(30); - client.DefaultRequestHeaders.Add("Accept", "application/json"); - }); - services.AddSingleton(secureKeyManager); - services.AddSingleton(sp => - { - var nodeOptions = sp.GetRequiredService>().Value; - return new ChannelOpenValidator(nodeOptions); - }); - services.AddSingleton(sp => - { - var channelIdFactory = sp.GetRequiredService(); - var channelOpenValidator = sp.GetRequiredService(); - var feeService = sp.GetRequiredService(); - var lightningSigner = sp.GetRequiredService(); - var nodeOptions = sp.GetRequiredService>().Value; - var sha256 = sp.GetRequiredService(); - return new ChannelFactory(channelIdFactory, channelOpenValidator, feeService, lightningSigner, nodeOptions, - sha256); - }); - services.AddSingleton(); - services.AddSingleton(serviceProvider => - { - var fundingOutputBuilder = serviceProvider.GetRequiredService(); - var keyDerivationService = serviceProvider.GetRequiredService(); - var logger = serviceProvider.GetRequiredService>(); - var nodeOptions = serviceProvider.GetRequiredService>().Value; - var utxoMemoryRepository = serviceProvider.GetRequiredService(); - - return new LocalLightningSigner(fundingOutputBuilder, keyDerivationService, logger, nodeOptions, - secureKeyManager, utxoMemoryRepository); - }); - services.AddApplicationServices(); - services.AddInfrastructureServices(); - services.AddPersistenceInfrastructureServices(configuration); - services.AddRepositoriesInfrastructureServices(); - services.AddSerializationInfrastructureServices(); - services.AddBitcoinInfrastructure(); - services - .AddScoped, - OpenChannelClientHandler>(); - services.AddSingleton(); - services.AddOptions().BindConfiguration("Bitcoin").ValidateOnStart(); - services.AddOptions().BindConfiguration("FeeEstimation").ValidateOnStart(); - services.AddOptions() - .BindConfiguration("Node") - .PostConfigure(options => - { - options.Features = new FeatureOptions - { - ChainHashes = [ChainConstants.Regtest] - }; - options.ListenAddresses = [$"{IPAddress.Loopback}:{_port}"]; - options.BitcoinNetwork = BitcoinNetwork.Regtest; - options.Features.ChainHashes = [options.BitcoinNetwork.ChainHash]; - options.ToSelfDelay = 240; - }) - .ValidateOnStart(); - - // Set up factories - _serviceProvider = services.BuildServiceProvider(); - - // Set up the database migration - var scope = _serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - var pendingMigrations = context.Database.GetPendingMigrationsAsync().GetAwaiter().GetResult().ToList(); - if (pendingMigrations.Count > 0) - context.Database.Migrate(); - - // Get services - _peerManager = _serviceProvider.GetRequiredService(); - _channelMemoryRepository = _serviceProvider.GetRequiredService(); - _blockchainMonitor = _serviceProvider.GetRequiredService(); - } - - [Fact] - public async Task GivenSingleP2WPKHInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() - { - // Arrange - var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; - var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes - .First(x => x.LocalAlias == "alice"); - Assert.NotNull(alice); - - await _peerManager.StartAsync(CancellationToken.None); - - // Get the current block height - var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); - - // Start the blockchain monitor at the current height - await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); - - // Fund our wallet - using (var scope = _serviceProvider.CreateScope()) - { - var walletService = scope.ServiceProvider - .GetRequiredService(); - var address = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, false); - - // Subscribe to blockchain monitor events - TaskCompletionSource tsc = new(); - uint txFirstSeenInBlock = int.MaxValue; - - void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) - { - if (e.Amount == LightningMoney.FromUnit(1, LightningMoneyUnit.Btc) - && e.WalletAddress == address.Address) - { - txFirstSeenInBlock = e.BlockHeight; - } - else - { - Assert.Fail("Unexpected wallet movement detected: " + - $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); - } - } - - void OnNewBlockDetected(object? _, NewBlockEventArgs e) - { - if (e.Height >= txFirstSeenInBlock + 5) - tsc.TrySetResult(true); - } - - _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; - _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; - - // Send funds to our wallet - await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address.Address, NBitcoin.Network.RegTest), - new Money(1, MoneyUnit.BTC), TestContext.Current.CancellationToken); - - // Mine blocks to confirm - await bitcoin.GenerateToAddressAsync( - 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), - TestContext.Current.CancellationToken); - - // wait for funding transaction to be confirmed - Assert.True(await tsc.Task); - _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; - _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; - } - - // Verify we have balance - var utxoRepository = _serviceProvider.GetRequiredService(); - var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); - Assert.True(balance > LightningMoney.Zero); - - // Connect to Alice - var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host - .SplitOnFirst("//")[1] - .SplitOnFirst(":")[0], - TestContext.Current.CancellationToken)).First(), - 9735); - var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; - - await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); - - Task openChannelTask; - // Open channel - using the client handler - using (var scope = _serviceProvider.CreateScope()) - { - var clientHandler = scope.ServiceProvider.GetService( - typeof(IClientCommandHandler)) as - OpenChannelClientHandler ?? - throw new InvalidOperationException( - $"Unable to get service {nameof(OpenChannelClientHandler)}"); - var request = new OpenChannelClientRequest( - aliceAddress, - LightningMoney.Satoshis(1000000) // 0.01 BTC, - ) - { - FeeRatePerKw = LightningMoney.Satoshis(10000) - }; - - // Subscribe to the event and mine blocks when needed - clientHandler.OnWaitingConfirmation += async (_, _) => - { - // Mine blocks to confirm - await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); - }; - - // Act - Open the channel (this should send open_channel and wait for the flow to complete) - openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); - } - - var channelResponse = await openChannelTask; - Assert.NotNull(channelResponse); - - // Check if the channel exists (temporary or permanent) - var allChannels = _channelMemoryRepository.FindChannels(_ => true); - - Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); - } - - [Fact] - public async Task GivenMultipleP2WPKHInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() - { - // Arrange - var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; - var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes - .First(x => x.LocalAlias == "alice"); - Assert.NotNull(alice); - - await _peerManager.StartAsync(CancellationToken.None); - - // Get the current block height - var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); - - // Start the blockchain monitor at the current height - await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); - - // Fund our wallet - using (var scope = _serviceProvider.CreateScope()) - { - var walletService = scope.ServiceProvider - .GetRequiredService(); - - // Subscribe to blockchain monitor events - TaskCompletionSource tsc = new(); - uint txFirstSeenInBlock = int.MaxValue; - - var address1 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, false); - var address2 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, true); - - void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) - { - if (e.Amount == LightningMoney.Satoshis(1100000) && e.WalletAddress == address1.Address) - { - txFirstSeenInBlock = e.BlockHeight; - } - else - { - Assert.Fail("Unexpected wallet movement detected: " + - $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); - } - } - - void OnNewBlockDetected(object? _, NewBlockEventArgs e) - { - if (e.Height >= txFirstSeenInBlock + 5) - tsc.TrySetResult(true); - } - - _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; - _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; - - // Send funds to our wallet - await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address1.Address, NBitcoin.Network.RegTest), - new Money(1100000, MoneyUnit.Satoshi), - TestContext.Current.CancellationToken); // 0.011 - await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address2.Address, NBitcoin.Network.RegTest), - new Money(1100000, MoneyUnit.Satoshi), - TestContext.Current.CancellationToken); // 0.011 - - // Mine blocks to confirm - await bitcoin.GenerateToAddressAsync( - 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), - TestContext.Current.CancellationToken); - - // wait for funding transaction to be confirmed - Assert.True(await tsc.Task); - _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; - _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; - } - - // Verify we have balance - var utxoRepository = _serviceProvider.GetRequiredService(); - var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); - Assert.True(balance > LightningMoney.Zero); - - // Connect to Alice - var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host - .SplitOnFirst("//")[1] - .SplitOnFirst(":")[0], - TestContext.Current.CancellationToken)).First(), - 9735); - var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; - - await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); - - Task openChannelTask; - // Open channel - using the client handler - using (var scope = _serviceProvider.CreateScope()) - { - var clientHandler = scope.ServiceProvider.GetService( - typeof(IClientCommandHandler)) as - OpenChannelClientHandler ?? - throw new InvalidOperationException( - $"Unable to get service {nameof(OpenChannelClientHandler)}"); - var request = new OpenChannelClientRequest( - aliceAddress, - LightningMoney.Satoshis(2000000) // 0.02 BTC, - ) - { - FeeRatePerKw = LightningMoney.Satoshis(10000) - }; - - // Subscribe to the event and mine blocks when needed - clientHandler.OnWaitingConfirmation += async (_, _) => - { - // Mine blocks to confirm - await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); - }; - - // Act - Open the channel (this should send open_channel and wait for the flow to complete) - openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); - } - - var channelResponse = await openChannelTask; - Assert.NotNull(channelResponse); - - // Check if the channel exists (temporary or permanent) - var allChannels = _channelMemoryRepository.FindChannels(_ => true); - - Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); - } - - [Fact] - public async Task GivenSingleP2TRInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() - { - // Arrange - var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; - var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes - .First(x => x.LocalAlias == "alice"); - Assert.NotNull(alice); - - await _peerManager.StartAsync(CancellationToken.None); - - // Get the current block height - var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); - - // Start the blockchain monitor at the current height - await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); - - // Fund our wallet - using (var scope = _serviceProvider.CreateScope()) - { - var walletService = scope.ServiceProvider - .GetRequiredService(); - var address = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Tr, false); - - // Subscribe to blockchain monitor events - TaskCompletionSource tsc = new(); - uint txFirstSeenInBlock = int.MaxValue; - - void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) - { - if (e.Amount == LightningMoney.FromUnit(1, LightningMoneyUnit.Btc) - && e.WalletAddress == address.Address) - { - txFirstSeenInBlock = e.BlockHeight; - } - else - { - Assert.Fail("Unexpected wallet movement detected: " + - $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); - } - } - - void OnNewBlockDetected(object? _, NewBlockEventArgs e) - { - if (e.Height >= txFirstSeenInBlock + 5) - tsc.TrySetResult(true); - } - - _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; - _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; - - // Send funds to our wallet - await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address.Address, NBitcoin.Network.RegTest), - new Money(1, MoneyUnit.BTC), TestContext.Current.CancellationToken); - - // Mine blocks to confirm - await bitcoin.GenerateToAddressAsync( - 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), - TestContext.Current.CancellationToken); - - // wait for funding transaction to be confirmed - Assert.True(await tsc.Task); - _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; - _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; - } - - // Verify we have balance - var utxoRepository = _serviceProvider.GetRequiredService(); - var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); - Assert.True(balance > LightningMoney.Zero); - - // Connect to Alice - var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host - .SplitOnFirst("//")[1] - .SplitOnFirst(":")[0], - TestContext.Current.CancellationToken)).First(), - 9735); - var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; - - await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); - - Task openChannelTask; - // Open channel - using the client handler - using (var scope = _serviceProvider.CreateScope()) - { - var clientHandler = scope.ServiceProvider.GetService( - typeof(IClientCommandHandler)) as - OpenChannelClientHandler ?? - throw new InvalidOperationException( - $"Unable to get service {nameof(OpenChannelClientHandler)}"); - var request = new OpenChannelClientRequest( - aliceAddress, - LightningMoney.Satoshis(1000000) // 0.01 BTC, - ) - { - FeeRatePerKw = LightningMoney.Satoshis(10000) - }; - - // Subscribe to the event and mine blocks when needed - clientHandler.OnWaitingConfirmation += async (_, _) => - { - // Mine blocks to confirm - await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); - }; - - // Act - Open the channel (this should send open_channel and wait for the flow to complete) - openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); - } - - var channelResponse = await openChannelTask; - Assert.NotNull(channelResponse); - - // Check if the channel exists (temporary or permanent) - var allChannels = _channelMemoryRepository.FindChannels(_ => true); - - Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); - } - - [Fact] - public async Task GivenMultipleP2TRInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() - { - // Arrange - var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; - var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes - .First(x => x.LocalAlias == "alice"); - Assert.NotNull(alice); - - await _peerManager.StartAsync(CancellationToken.None); - - // Get the current block height - var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); - - // Start the blockchain monitor at the current height - await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); - - // Fund our wallet - using (var scope = _serviceProvider.CreateScope()) - { - var walletService = scope.ServiceProvider - .GetRequiredService(); - - // Subscribe to blockchain monitor events - TaskCompletionSource tsc = new(); - uint txFirstSeenInBlock = int.MaxValue; - - var address1 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Tr, false); - var address2 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Tr, true); - - void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) - { - if (e.Amount == LightningMoney.Satoshis(1100000) && e.WalletAddress == address1.Address) - { - txFirstSeenInBlock = e.BlockHeight; - } - else - { - Assert.Fail("Unexpected wallet movement detected: " + - $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); - } - } - - void OnNewBlockDetected(object? _, NewBlockEventArgs e) - { - if (e.Height >= txFirstSeenInBlock + 5) - tsc.TrySetResult(true); - } - - _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; - _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; - - // Send funds to our wallet - await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address1.Address, NBitcoin.Network.RegTest), - new Money(1100000, MoneyUnit.Satoshi), - TestContext.Current.CancellationToken); // 0.011 - await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address2.Address, NBitcoin.Network.RegTest), - new Money(1100000, MoneyUnit.Satoshi), - TestContext.Current.CancellationToken); // 0.011 - - // Mine blocks to confirm - await bitcoin.GenerateToAddressAsync( - 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), - TestContext.Current.CancellationToken); - - // wait for funding transaction to be confirmed - Assert.True(await tsc.Task); - _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; - _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; - } - - // Verify we have balance - var utxoRepository = _serviceProvider.GetRequiredService(); - var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); - Assert.True(balance > LightningMoney.Zero); - - // Connect to Alice - var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host - .SplitOnFirst("//")[1] - .SplitOnFirst(":")[0], - TestContext.Current.CancellationToken)).First(), - 9735); - var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; - - await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); - - Task openChannelTask; - // Open channel - using the client handler - using (var scope = _serviceProvider.CreateScope()) - { - var clientHandler = scope.ServiceProvider.GetService( - typeof(IClientCommandHandler)) as - OpenChannelClientHandler ?? - throw new InvalidOperationException( - $"Unable to get service {nameof(OpenChannelClientHandler)}"); - var request = new OpenChannelClientRequest( - aliceAddress, - LightningMoney.Satoshis(2000000) // 0.02 BTC, - ) - { - FeeRatePerKw = LightningMoney.Satoshis(10000) - }; - - // Subscribe to the event and mine blocks when needed - clientHandler.OnWaitingConfirmation += async (_, _) => - { - // Mine blocks to confirm - await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); - }; - - // Act - Open the channel (this should send open_channel and wait for the flow to complete) - openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); - } - - var channelResponse = await openChannelTask; - Assert.NotNull(channelResponse); - - // Check if the channel exists (temporary or permanent) - var allChannels = _channelMemoryRepository.FindChannels(_ => true); - - Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); - } - - [Fact] - public async Task GivenMixedInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() - { - // Arrange - var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; - var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes - .First(x => x.LocalAlias == "alice"); - Assert.NotNull(alice); - - await _peerManager.StartAsync(CancellationToken.None); - - // Get the current block height - var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); - - // Start the blockchain monitor at the current height - await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); - - // Fund our wallet - using (var scope = _serviceProvider.CreateScope()) - { - var walletService = scope.ServiceProvider - .GetRequiredService(); - - // Subscribe to blockchain monitor events - TaskCompletionSource tsc = new(); - uint txFirstSeenInBlock = int.MaxValue; - - var address1 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, false); - var address2 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Tr, false); - - void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) - { - if (e.Amount == LightningMoney.Satoshis(1100000) && e.WalletAddress == address1.Address) - { - txFirstSeenInBlock = e.BlockHeight; - } - else - { - Assert.Fail("Unexpected wallet movement detected: " + - $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); - } - } - - void OnNewBlockDetected(object? _, NewBlockEventArgs e) - { - if (e.Height >= txFirstSeenInBlock + 5) - tsc.TrySetResult(true); - } - - _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; - _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; - - // Send funds to our wallet - await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address1.Address, NBitcoin.Network.RegTest), - new Money(1100000, MoneyUnit.Satoshi), - TestContext.Current.CancellationToken); // 0.011 - await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address2.Address, NBitcoin.Network.RegTest), - new Money(1100000, MoneyUnit.Satoshi), - TestContext.Current.CancellationToken); // 0.011 - - // Mine blocks to confirm - await bitcoin.GenerateToAddressAsync( - 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), - TestContext.Current.CancellationToken); - - // wait for funding transaction to be confirmed - Assert.True(await tsc.Task); - _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; - _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; - } - - // Verify we have balance - var utxoRepository = _serviceProvider.GetRequiredService(); - var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); - Assert.True(balance > LightningMoney.Zero); - - // Connect to Alice - var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host - .SplitOnFirst("//")[1] - .SplitOnFirst(":")[0], - TestContext.Current.CancellationToken)).First(), - 9735); - var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; - - await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); - - Task openChannelTask; - // Open channel - using the client handler - using (var scope = _serviceProvider.CreateScope()) - { - var clientHandler = scope.ServiceProvider.GetService( - typeof(IClientCommandHandler)) as - OpenChannelClientHandler ?? - throw new InvalidOperationException( - $"Unable to get service {nameof(OpenChannelClientHandler)}"); - var request = new OpenChannelClientRequest( - aliceAddress, - LightningMoney.Satoshis(2000000) // 0.02 BTC, - ) - { - FeeRatePerKw = LightningMoney.Satoshis(10000) - }; - - // Subscribe to the event and mine blocks when needed - clientHandler.OnWaitingConfirmation += async (_, _) => - { - // Mine blocks to confirm - await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); - }; - - // Act - Open the channel (this should send open_channel and wait for the flow to complete) - openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); - } - - var channelResponse = await openChannelTask; - Assert.NotNull(channelResponse); - - // Check if the channel exists (temporary or permanent) - var allChannels = _channelMemoryRepository.FindChannels(_ => true); - - Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); - } - - public void Dispose() - { - _blockchainMonitor.StopAsync().GetAwaiter().GetResult(); - PortPoolUtil.ReleasePort(_port); - if (File.Exists(_databaseFilePath)) - { - try - { - File.Delete(_databaseFilePath); - } - catch (Exception ex) - { - Console.WriteLine($"Failed to delete database file: {ex.Message}"); - } - } - } -} \ No newline at end of file +// using System.Net; +// using Microsoft.EntityFrameworkCore; +// using Microsoft.Extensions.Configuration; +// using Microsoft.Extensions.DependencyInjection; +// using Microsoft.Extensions.Logging; +// using Microsoft.Extensions.Options; +// using Moq.Protected; +// using NBitcoin; +// using NLightning.Application; +// using NLightning.Daemon.Handlers; +// using NLightning.Daemon.Interfaces; +// using NLightning.Domain.Bitcoin.Events; +// using NLightning.Domain.Bitcoin.Interfaces; +// using NLightning.Domain.Bitcoin.Transactions.Factories; +// using NLightning.Domain.Bitcoin.Transactions.Interfaces; +// using NLightning.Domain.Channels.Enums; +// using NLightning.Domain.Channels.Factories; +// using NLightning.Domain.Channels.Interfaces; +// using NLightning.Domain.Channels.Validators; +// using NLightning.Domain.Client.Requests; +// using NLightning.Domain.Client.Responses; +// using NLightning.Domain.Crypto.Hashes; +// using NLightning.Domain.Enums; +// using NLightning.Domain.Money; +// using NLightning.Domain.Node.Interfaces; +// using NLightning.Domain.Node.Options; +// using NLightning.Domain.Node.ValueObjects; +// using NLightning.Domain.Protocol.Constants; +// using NLightning.Domain.Protocol.Interfaces; +// using NLightning.Domain.Protocol.ValueObjects; +// using NLightning.Infrastructure; +// using NLightning.Infrastructure.Bitcoin; +// using NLightning.Infrastructure.Bitcoin.Builders; +// using NLightning.Infrastructure.Bitcoin.Options; +// using NLightning.Infrastructure.Bitcoin.Services; +// using NLightning.Infrastructure.Bitcoin.Signers; +// using NLightning.Infrastructure.Bitcoin.Wallet.Interfaces; +// using NLightning.Infrastructure.Persistence; +// using NLightning.Infrastructure.Persistence.Contexts; +// using NLightning.Infrastructure.Repositories; +// using NLightning.Infrastructure.Serialization; +// using NLightning.Tests.Utils; +// using ServiceStack; +// +// namespace NLightning.Integration.Tests.Docker; +// +// using Fixtures; +// using Mock; +// using TestCollections; +// using Utils; +// +// [Collection(LightningRegtestNetworkFixtureCollection.Name)] +// public class ChannelOpeningFlowTests : IDisposable +// { +// private readonly LightningRegtestNetworkFixture _lightningRegtestNetworkFixture; +// private readonly IPeerManager _peerManager; +// private readonly IChannelMemoryRepository _channelMemoryRepository; +// private readonly IBlockchainMonitor _blockchainMonitor; +// private readonly int _port; +// private readonly string _databaseFilePath = $"nlightning_channel_test_{Guid.NewGuid()}.db"; +// private readonly IServiceProvider _serviceProvider; +// +// public ChannelOpeningFlowTests(LightningRegtestNetworkFixture fixture, ITestOutputHelper output) +// { +// _lightningRegtestNetworkFixture = fixture; +// Console.SetOut(new TestOutputWriter(output)); +// +// _port = PortPoolUtil.GetAvailablePortAsync().GetAwaiter().GetResult(); +// Assert.True(_port > 0); +// ISecureKeyManager secureKeyManager = new FakeSecureKeyManager(); +// +// // Get Bitcoin network info +// Assert.NotNull(_lightningRegtestNetworkFixture.Builder); +// var bitcoinConfiguration = _lightningRegtestNetworkFixture.Builder.Configuration.BTCNodes[0]; +// var zmqRawBlockPort = +// bitcoinConfiguration.Cmd.First(c => c.Contains("-zmqpubrawblock")).Split(':')[2]; +// var zmqRawTxPort = +// bitcoinConfiguration.Cmd.First(c => c.Contains("-zmqpubrawtx")).Split(':')[2]; +// var bitcoin = _lightningRegtestNetworkFixture.Builder.BitcoinRpcClient; +// Assert.NotNull(bitcoin); +// var bitcoinEndpoint = bitcoin.Address.ToString(); +// +// // Mock HttpClient for FeeService +// var httpMessageHandlerMock = new Mock(MockBehavior.Strict); +// httpMessageHandlerMock.Protected() +// .Setup>("SendAsync", ItExpr.IsAny(), +// ItExpr.IsAny()) +// .ReturnsAsync(() => new HttpResponseMessage +// { +// StatusCode = HttpStatusCode.OK, +// Content = new StringContent("{\"fastestFee\": 2}") +// }); +// +// // Build configuration +// List> inMemoryConfiguration = +// [ +// new("Serilog:MinimumLevel:NLightning", "Verbose"), +// new("Node:Network", "regtest"), +// new("Node:Daemon", "false"), +// new("Database:Provider", "Sqlite"), +// new("Database:ConnectionString", $"Data Source={_databaseFilePath}"), +// new("Bitcoin:RpcEndpoint", bitcoinEndpoint), +// new("Bitcoin:RpcUser", bitcoin.CredentialString.UserPassword.UserName), +// new("Bitcoin:RpcPassword", bitcoin.CredentialString.UserPassword.Password), +// new("Bitcoin:ZmqHost", bitcoin.Address.Host), +// new("Bitcoin:ZmqBlockPort", zmqRawBlockPort), +// new("Bitcoin:ZmqTxPort", zmqRawTxPort) +// ]; +// var configuration = new ConfigurationBuilder().AddInMemoryCollection(inMemoryConfiguration).Build(); +// +// // Create a service collection +// var services = new ServiceCollection(); +// services.AddSingleton(configuration); +// services.AddHttpClient(client => +// { +// client.Timeout = TimeSpan.FromSeconds(30); +// client.DefaultRequestHeaders.Add("Accept", "application/json"); +// }); +// services.AddSingleton(secureKeyManager); +// services.AddSingleton(sp => +// { +// var nodeOptions = sp.GetRequiredService>().Value; +// return new ChannelOpenValidator(nodeOptions); +// }); +// services.AddSingleton(sp => +// { +// var channelIdFactory = sp.GetRequiredService(); +// var channelOpenValidator = sp.GetRequiredService(); +// var feeService = sp.GetRequiredService(); +// var lightningSigner = sp.GetRequiredService(); +// var nodeOptions = sp.GetRequiredService>().Value; +// var sha256 = sp.GetRequiredService(); +// return new ChannelFactory(channelIdFactory, channelOpenValidator, feeService, lightningSigner, nodeOptions, +// sha256); +// }); +// services.AddSingleton(); +// services.AddSingleton(serviceProvider => +// { +// var fundingOutputBuilder = serviceProvider.GetRequiredService(); +// var keyDerivationService = serviceProvider.GetRequiredService(); +// var logger = serviceProvider.GetRequiredService>(); +// var nodeOptions = serviceProvider.GetRequiredService>().Value; +// var utxoMemoryRepository = serviceProvider.GetRequiredService(); +// +// return new LocalLightningSigner(fundingOutputBuilder, keyDerivationService, logger, nodeOptions, +// secureKeyManager, utxoMemoryRepository); +// }); +// services.AddApplicationServices(); +// services.AddInfrastructureServices(); +// services.AddPersistenceInfrastructureServices(configuration); +// services.AddRepositoriesInfrastructureServices(); +// services.AddSerializationInfrastructureServices(); +// services.AddBitcoinInfrastructure(); +// services +// .AddScoped, +// OpenChannelClientHandler>(); +// services +// .AddScoped +// , +// OpenChannelClientSubscriptionHandler>(); +// services.AddSingleton(); +// services.AddOptions().BindConfiguration("Bitcoin").ValidateOnStart(); +// services.AddOptions().BindConfiguration("FeeEstimation").ValidateOnStart(); +// services.AddOptions() +// .BindConfiguration("Node") +// .PostConfigure(options => +// { +// options.Features = new FeatureOptions +// { +// ChainHashes = [ChainConstants.Regtest] +// }; +// options.ListenAddresses = [$"{IPAddress.Loopback}:{_port}"]; +// options.BitcoinNetwork = BitcoinNetwork.Regtest; +// options.Features.ChainHashes = [options.BitcoinNetwork.ChainHash]; +// options.ToSelfDelay = 240; +// }) +// .ValidateOnStart(); +// +// // Set up factories +// _serviceProvider = services.BuildServiceProvider(); +// +// // Set up the database migration +// var scope = _serviceProvider.CreateScope(); +// var context = scope.ServiceProvider.GetRequiredService(); +// var pendingMigrations = context.Database.GetPendingMigrationsAsync().GetAwaiter().GetResult().ToList(); +// if (pendingMigrations.Count > 0) +// context.Database.Migrate(); +// +// // Get services +// _peerManager = _serviceProvider.GetRequiredService(); +// _channelMemoryRepository = _serviceProvider.GetRequiredService(); +// _blockchainMonitor = _serviceProvider.GetRequiredService(); +// } +// +// [Fact] +// public async Task GivenSingleP2WPKHInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() +// { +// // Arrange +// var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; +// var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes +// .First(x => x.LocalAlias == "alice"); +// Assert.NotNull(alice); +// +// await _peerManager.StartAsync(CancellationToken.None); +// +// // Get the current block height +// var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); +// +// // Start the blockchain monitor at the current height +// await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); +// +// // Fund our wallet +// using (var scope = _serviceProvider.CreateScope()) +// { +// var walletService = scope.ServiceProvider +// .GetRequiredService(); +// var address = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, false); +// +// // Subscribe to blockchain monitor events +// TaskCompletionSource tsc = new(); +// uint txFirstSeenInBlock = int.MaxValue; +// +// void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) +// { +// if (e.Amount == LightningMoney.FromUnit(1, LightningMoneyUnit.Btc) +// && e.WalletAddress == address.Address) +// { +// txFirstSeenInBlock = e.BlockHeight; +// } +// else +// { +// Assert.Fail("Unexpected wallet movement detected: " + +// $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); +// } +// } +// +// void OnNewBlockDetected(object? _, NewBlockEventArgs e) +// { +// if (e.Height >= txFirstSeenInBlock + 5) +// tsc.TrySetResult(true); +// } +// +// _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; +// _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; +// +// // Send funds to our wallet +// await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address.Address, NBitcoin.Network.RegTest), +// new Money(1, MoneyUnit.BTC), TestContext.Current.CancellationToken); +// +// // Mine blocks to confirm +// await bitcoin.GenerateToAddressAsync( +// 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), +// TestContext.Current.CancellationToken); +// +// // wait for funding transaction to be confirmed +// Assert.True(await tsc.Task); +// _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; +// _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; +// } +// +// // Verify we have balance +// var utxoRepository = _serviceProvider.GetRequiredService(); +// var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); +// Assert.True(balance > LightningMoney.Zero); +// +// // Connect to Alice +// var aliceHost = new IPEndPoint( +// (await Dns.GetHostAddressesAsync(alice.Host.SplitOnFirst("//")[1].SplitOnFirst(":")[0], +// TestContext.Current.CancellationToken)).First(), 9735); +// var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; +// +// await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); +// +// Task openChannelTask; +// // Open channel - using the client handler +// using (var scope = _serviceProvider.CreateScope()) +// { +// var clientHandler = scope.ServiceProvider.GetService( +// typeof(IClientCommandHandler)) as +// OpenChannelClientHandler ?? +// throw new InvalidOperationException( +// $"Unable to get service {nameof(OpenChannelClientHandler)}"); +// var request = new OpenChannelClientRequest( +// aliceAddress, +// LightningMoney.Satoshis(1000000) // 0.01 BTC, +// ) +// { +// FeeRatePerKw = LightningMoney.Satoshis(10000) +// }; +// +// // Act - Open the channel (this should send open_channel and wait for the first response) +// openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); +// } +// +// var channelResponse = await openChannelTask; +// Assert.NotNull(channelResponse); +// +// var channelOpen = false; +// while (!channelOpen) +// { +// Task openChannelSubscriptionTask; +// // Open channel subscription - using the client handler +// using (var scope = _serviceProvider.CreateScope()) +// { +// var subscriptionClientHandler = scope.ServiceProvider.GetService( +// typeof(IClientCommandHandler< +// OpenChannelClientSubscriptionRequest, +// OpenChannelClientSubscriptionResponse>)) as +// OpenChannelClientSubscriptionHandler ?? +// throw new InvalidOperationException( +// $"Unable to get service {nameof(OpenChannelClientSubscriptionHandler)}"); +// var request = new OpenChannelClientSubscriptionRequest(channelResponse.ChannelId); +// +// // Act - Open the channel (this should send open_channel and wait for the first response) +// openChannelSubscriptionTask = subscriptionClientHandler.HandleAsync(request, CancellationToken.None); +// } +// +// var channelSubscriptionResponse = await openChannelSubscriptionTask; +// Assert.NotNull(channelSubscriptionResponse); +// +// if (channelSubscriptionResponse.ChannelState == ChannelState.V1FundingSigned) +// { +// // Mine blocks to confirm +// await bitcoin.GenerateToAddressAsync( +// 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), +// TestContext.Current.CancellationToken); +// } +// else if (channelSubscriptionResponse.ChannelState is ChannelState.ReadyForThem or ChannelState.ReadyForUs +// or ChannelState.Open) +// { +// channelOpen = true; +// } +// } +// +// // Check if the channel exists (temporary or permanent) +// var allChannels = _channelMemoryRepository.FindChannels(_ => true); +// +// Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); +// } +// +// [Fact] +// public async Task GivenMultipleP2WPKHInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() +// { +// // Arrange +// var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; +// var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes +// .First(x => x.LocalAlias == "alice"); +// Assert.NotNull(alice); +// +// await _peerManager.StartAsync(CancellationToken.None); +// +// // Get the current block height +// var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); +// +// // Start the blockchain monitor at the current height +// await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); +// +// // Fund our wallet +// using (var scope = _serviceProvider.CreateScope()) +// { +// var walletService = scope.ServiceProvider +// .GetRequiredService(); +// +// // Subscribe to blockchain monitor events +// TaskCompletionSource tsc = new(); +// uint txFirstSeenInBlock = int.MaxValue; +// +// var address1 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, false); +// var address2 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, true); +// +// void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) +// { +// if (e.Amount == LightningMoney.Satoshis(1100000) && e.WalletAddress == address1.Address) +// { +// txFirstSeenInBlock = e.BlockHeight; +// } +// else +// { +// Assert.Fail("Unexpected wallet movement detected: " + +// $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); +// } +// } +// +// void OnNewBlockDetected(object? _, NewBlockEventArgs e) +// { +// if (e.Height >= txFirstSeenInBlock + 5) +// tsc.TrySetResult(true); +// } +// +// _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; +// _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; +// +// // Send funds to our wallet +// await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address1.Address, NBitcoin.Network.RegTest), +// new Money(1100000, MoneyUnit.Satoshi), +// TestContext.Current.CancellationToken); // 0.011 +// await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address2.Address, NBitcoin.Network.RegTest), +// new Money(1100000, MoneyUnit.Satoshi), +// TestContext.Current.CancellationToken); // 0.011 +// +// // Mine blocks to confirm +// await bitcoin.GenerateToAddressAsync( +// 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), +// TestContext.Current.CancellationToken); +// +// // wait for funding transaction to be confirmed +// Assert.True(await tsc.Task); +// _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; +// _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; +// } +// +// // Verify we have balance +// var utxoRepository = _serviceProvider.GetRequiredService(); +// var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); +// Assert.True(balance > LightningMoney.Zero); +// +// // Connect to Alice +// var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host +// .SplitOnFirst("//")[1] +// .SplitOnFirst(":")[0], +// TestContext.Current.CancellationToken)).First(), +// 9735); +// var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; +// +// await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); +// +// Task openChannelTask; +// // Open channel - using the client handler +// using (var scope = _serviceProvider.CreateScope()) +// { +// var clientHandler = scope.ServiceProvider.GetService( +// typeof(IClientCommandHandler)) as +// OpenChannelClientHandler ?? +// throw new InvalidOperationException( +// $"Unable to get service {nameof(OpenChannelClientHandler)}"); +// var request = new OpenChannelClientRequest( +// aliceAddress, +// LightningMoney.Satoshis(2000000) // 0.02 BTC, +// ) +// { +// FeeRatePerKw = LightningMoney.Satoshis(10000) +// }; +// +// // Act - Open the channel (this should send open_channel and wait for the flow to complete) +// openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); +// } +// +// var channelResponse = await openChannelTask; +// Assert.NotNull(channelResponse); +// +// var channelOpen = false; +// while (!channelOpen) +// { +// Task openChannelSubscriptionTask; +// // Open channel subscription - using the client handler +// using (var scope = _serviceProvider.CreateScope()) +// { +// var subscriptionClientHandler = scope.ServiceProvider.GetService( +// typeof(IClientCommandHandler< +// OpenChannelClientSubscriptionRequest, +// OpenChannelClientSubscriptionResponse>)) as +// OpenChannelClientSubscriptionHandler ?? +// throw new InvalidOperationException( +// $"Unable to get service {nameof(OpenChannelClientSubscriptionHandler)}"); +// var request = new OpenChannelClientSubscriptionRequest(channelResponse.ChannelId); +// +// // Act - Open the channel (this should send open_channel and wait for the first response) +// openChannelSubscriptionTask = subscriptionClientHandler.HandleAsync(request, CancellationToken.None); +// } +// +// var channelSubscriptionResponse = await openChannelSubscriptionTask; +// Assert.NotNull(channelSubscriptionResponse); +// +// if (channelSubscriptionResponse.ChannelState == ChannelState.V1FundingSigned) +// { +// // Mine blocks to confirm +// await bitcoin.GenerateToAddressAsync( +// 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), +// TestContext.Current.CancellationToken); +// } +// else if (channelSubscriptionResponse.ChannelState is ChannelState.ReadyForThem or ChannelState.ReadyForUs +// or ChannelState.Open) +// { +// channelOpen = true; +// } +// } +// +// // Check if the channel exists (temporary or permanent) +// var allChannels = _channelMemoryRepository.FindChannels(_ => true); +// +// Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); +// } +// +// // [Fact] +// // public async Task GivenSingleP2TRInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() +// // { +// // // Arrange +// // var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; +// // var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes +// // .First(x => x.LocalAlias == "alice"); +// // Assert.NotNull(alice); +// // +// // await _peerManager.StartAsync(CancellationToken.None); +// // +// // // Get the current block height +// // var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); +// // +// // // Start the blockchain monitor at the current height +// // await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); +// // +// // // Fund our wallet +// // using (var scope = _serviceProvider.CreateScope()) +// // { +// // var walletService = scope.ServiceProvider +// // .GetRequiredService(); +// // var address = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Tr, false); +// // +// // // Subscribe to blockchain monitor events +// // TaskCompletionSource tsc = new(); +// // uint txFirstSeenInBlock = int.MaxValue; +// // +// // void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) +// // { +// // if (e.Amount == LightningMoney.FromUnit(1, LightningMoneyUnit.Btc) +// // && e.WalletAddress == address.Address) +// // { +// // txFirstSeenInBlock = e.BlockHeight; +// // } +// // else +// // { +// // Assert.Fail("Unexpected wallet movement detected: " + +// // $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); +// // } +// // } +// // +// // void OnNewBlockDetected(object? _, NewBlockEventArgs e) +// // { +// // if (e.Height >= txFirstSeenInBlock + 5) +// // tsc.TrySetResult(true); +// // } +// // +// // _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; +// // _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; +// // +// // // Send funds to our wallet +// // await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address.Address, NBitcoin.Network.RegTest), +// // new Money(1, MoneyUnit.BTC), TestContext.Current.CancellationToken); +// // +// // // Mine blocks to confirm +// // await bitcoin.GenerateToAddressAsync( +// // 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), +// // TestContext.Current.CancellationToken); +// // +// // // wait for funding transaction to be confirmed +// // Assert.True(await tsc.Task); +// // _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; +// // _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; +// // } +// // +// // // Verify we have balance +// // var utxoRepository = _serviceProvider.GetRequiredService(); +// // var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); +// // Assert.True(balance > LightningMoney.Zero); +// // +// // // Connect to Alice +// // var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host +// // .SplitOnFirst("//")[1] +// // .SplitOnFirst(":")[0], +// // TestContext.Current.CancellationToken)).First(), +// // 9735); +// // var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; +// // +// // await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); +// // +// // Task openChannelTask; +// // // Open channel - using the client handler +// // using (var scope = _serviceProvider.CreateScope()) +// // { +// // var clientHandler = scope.ServiceProvider.GetService( +// // typeof(IClientCommandHandler)) as +// // OpenChannelClientHandler ?? +// // throw new InvalidOperationException( +// // $"Unable to get service {nameof(OpenChannelClientHandler)}"); +// // var request = new OpenChannelClientRequest( +// // aliceAddress, +// // LightningMoney.Satoshis(1000000) // 0.01 BTC, +// // ) +// // { +// // FeeRatePerKw = LightningMoney.Satoshis(10000) +// // }; +// // +// // // Subscribe to the event and mine blocks when needed +// // clientHandler.OnWaitingConfirmation += async (_, _) => +// // { +// // // Mine blocks to confirm +// // await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); +// // }; +// // +// // // Act - Open the channel (this should send open_channel and wait for the flow to complete) +// // openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); +// // } +// // +// // var channelResponse = await openChannelTask; +// // Assert.NotNull(channelResponse); +// // +// // // Check if the channel exists (temporary or permanent) +// // var allChannels = _channelMemoryRepository.FindChannels(_ => true); +// // +// // Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); +// // } +// // +// // [Fact] +// // public async Task GivenMultipleP2TRInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() +// // { +// // // Arrange +// // var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; +// // var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes +// // .First(x => x.LocalAlias == "alice"); +// // Assert.NotNull(alice); +// // +// // await _peerManager.StartAsync(CancellationToken.None); +// // +// // // Get the current block height +// // var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); +// // +// // // Start the blockchain monitor at the current height +// // await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); +// // +// // // Fund our wallet +// // using (var scope = _serviceProvider.CreateScope()) +// // { +// // var walletService = scope.ServiceProvider +// // .GetRequiredService(); +// // +// // // Subscribe to blockchain monitor events +// // TaskCompletionSource tsc = new(); +// // uint txFirstSeenInBlock = int.MaxValue; +// // +// // var address1 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Tr, false); +// // var address2 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Tr, true); +// // +// // void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) +// // { +// // if (e.Amount == LightningMoney.Satoshis(1100000) && e.WalletAddress == address1.Address) +// // { +// // txFirstSeenInBlock = e.BlockHeight; +// // } +// // else +// // { +// // Assert.Fail("Unexpected wallet movement detected: " + +// // $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); +// // } +// // } +// // +// // void OnNewBlockDetected(object? _, NewBlockEventArgs e) +// // { +// // if (e.Height >= txFirstSeenInBlock + 5) +// // tsc.TrySetResult(true); +// // } +// // +// // _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; +// // _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; +// // +// // // Send funds to our wallet +// // await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address1.Address, NBitcoin.Network.RegTest), +// // new Money(1100000, MoneyUnit.Satoshi), +// // TestContext.Current.CancellationToken); // 0.011 +// // await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address2.Address, NBitcoin.Network.RegTest), +// // new Money(1100000, MoneyUnit.Satoshi), +// // TestContext.Current.CancellationToken); // 0.011 +// // +// // // Mine blocks to confirm +// // await bitcoin.GenerateToAddressAsync( +// // 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), +// // TestContext.Current.CancellationToken); +// // +// // // wait for funding transaction to be confirmed +// // Assert.True(await tsc.Task); +// // _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; +// // _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; +// // } +// // +// // // Verify we have balance +// // var utxoRepository = _serviceProvider.GetRequiredService(); +// // var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); +// // Assert.True(balance > LightningMoney.Zero); +// // +// // // Connect to Alice +// // var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host +// // .SplitOnFirst("//")[1] +// // .SplitOnFirst(":")[0], +// // TestContext.Current.CancellationToken)).First(), +// // 9735); +// // var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; +// // +// // await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); +// // +// // Task openChannelTask; +// // // Open channel - using the client handler +// // using (var scope = _serviceProvider.CreateScope()) +// // { +// // var clientHandler = scope.ServiceProvider.GetService( +// // typeof(IClientCommandHandler)) as +// // OpenChannelClientHandler ?? +// // throw new InvalidOperationException( +// // $"Unable to get service {nameof(OpenChannelClientHandler)}"); +// // var request = new OpenChannelClientRequest( +// // aliceAddress, +// // LightningMoney.Satoshis(2000000) // 0.02 BTC, +// // ) +// // { +// // FeeRatePerKw = LightningMoney.Satoshis(10000) +// // }; +// // +// // // Subscribe to the event and mine blocks when needed +// // clientHandler.OnWaitingConfirmation += async (_, _) => +// // { +// // // Mine blocks to confirm +// // await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); +// // }; +// // +// // // Act - Open the channel (this should send open_channel and wait for the flow to complete) +// // openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); +// // } +// // +// // var channelResponse = await openChannelTask; +// // Assert.NotNull(channelResponse); +// // +// // // Check if the channel exists (temporary or permanent) +// // var allChannels = _channelMemoryRepository.FindChannels(_ => true); +// // +// // Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); +// // } +// // +// // [Fact] +// // public async Task GivenMixedInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() +// // { +// // // Arrange +// // var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; +// // var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes +// // .First(x => x.LocalAlias == "alice"); +// // Assert.NotNull(alice); +// // +// // await _peerManager.StartAsync(CancellationToken.None); +// // +// // // Get the current block height +// // var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); +// // +// // // Start the blockchain monitor at the current height +// // await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); +// // +// // // Fund our wallet +// // using (var scope = _serviceProvider.CreateScope()) +// // { +// // var walletService = scope.ServiceProvider +// // .GetRequiredService(); +// // +// // // Subscribe to blockchain monitor events +// // TaskCompletionSource tsc = new(); +// // uint txFirstSeenInBlock = int.MaxValue; +// // +// // var address1 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, false); +// // var address2 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Tr, false); +// // +// // void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) +// // { +// // if (e.Amount == LightningMoney.Satoshis(1100000) && e.WalletAddress == address1.Address) +// // { +// // txFirstSeenInBlock = e.BlockHeight; +// // } +// // else +// // { +// // Assert.Fail("Unexpected wallet movement detected: " + +// // $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); +// // } +// // } +// // +// // void OnNewBlockDetected(object? _, NewBlockEventArgs e) +// // { +// // if (e.Height >= txFirstSeenInBlock + 5) +// // tsc.TrySetResult(true); +// // } +// // +// // _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; +// // _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; +// // +// // // Send funds to our wallet +// // await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address1.Address, NBitcoin.Network.RegTest), +// // new Money(1100000, MoneyUnit.Satoshi), +// // TestContext.Current.CancellationToken); // 0.011 +// // await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address2.Address, NBitcoin.Network.RegTest), +// // new Money(1100000, MoneyUnit.Satoshi), +// // TestContext.Current.CancellationToken); // 0.011 +// // +// // // Mine blocks to confirm +// // await bitcoin.GenerateToAddressAsync( +// // 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), +// // TestContext.Current.CancellationToken); +// // +// // // wait for funding transaction to be confirmed +// // Assert.True(await tsc.Task); +// // _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; +// // _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; +// // } +// // +// // // Verify we have balance +// // var utxoRepository = _serviceProvider.GetRequiredService(); +// // var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); +// // Assert.True(balance > LightningMoney.Zero); +// // +// // // Connect to Alice +// // var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host +// // .SplitOnFirst("//")[1] +// // .SplitOnFirst(":")[0], +// // TestContext.Current.CancellationToken)).First(), +// // 9735); +// // var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; +// // +// // await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); +// // +// // Task openChannelTask; +// // // Open channel - using the client handler +// // using (var scope = _serviceProvider.CreateScope()) +// // { +// // var clientHandler = scope.ServiceProvider.GetService( +// // typeof(IClientCommandHandler)) as +// // OpenChannelClientHandler ?? +// // throw new InvalidOperationException( +// // $"Unable to get service {nameof(OpenChannelClientHandler)}"); +// // var request = new OpenChannelClientRequest( +// // aliceAddress, +// // LightningMoney.Satoshis(2000000) // 0.02 BTC, +// // ) +// // { +// // FeeRatePerKw = LightningMoney.Satoshis(10000) +// // }; +// // +// // // Subscribe to the event and mine blocks when needed +// // clientHandler.OnWaitingConfirmation += async (_, _) => +// // { +// // // Mine blocks to confirm +// // await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); +// // }; +// // +// // // Act - Open the channel (this should send open_channel and wait for the flow to complete) +// // openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); +// // } +// // +// // var channelResponse = await openChannelTask; +// // Assert.NotNull(channelResponse); +// // +// // // Check if the channel exists (temporary or permanent) +// // var allChannels = _channelMemoryRepository.FindChannels(_ => true); +// // +// // Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); +// // } +// +// public void Dispose() +// { +// _blockchainMonitor.StopAsync().GetAwaiter().GetResult(); +// PortPoolUtil.ReleasePort(_port); +// if (File.Exists(_databaseFilePath)) +// { +// try +// { +// File.Delete(_databaseFilePath); +// } +// catch (Exception ex) +// { +// Console.WriteLine($"Failed to delete database file: {ex.Message}"); +// } +// } +// } +// } \ No newline at end of file From 8a1e1f857873bf7262c79f064e48c977c05b8932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADckolas=20Goline?= Date: Wed, 1 Apr 2026 14:38:32 -0300 Subject: [PATCH 20/20] fix channelConfig creation when initiating a channel to use `payload.ToSelfDelay` instead of `tempChannel.ChannelConfig.ToSelfDelay` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: jaonoctus Signed-off-by: Níckolas Goline --- .../Handlers/AcceptChannel1MessageHandler.cs | 6 +- .../OpenChannelClientSubscriptionHandler.cs | 47 +- .../Payloads/AcceptChannel1Payload.cs | 4 +- .../Builders/FundingTransactionBuilder.cs | 8 +- .../Signers/LocalLightningSigner.cs | 27 +- .../Docker/ChannelOpeningFlowTests.cs | 1853 +++++++++-------- 6 files changed, 1028 insertions(+), 917 deletions(-) diff --git a/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs b/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs index ece5ff96..dd21fd67 100644 --- a/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs +++ b/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs @@ -133,7 +133,7 @@ public AcceptChannel1MessageHandler(IBitcoinWalletService bitcoinWalletService, tempChannel.AddRemoteKeySet(remoteKeySet); - // Create a new ChannelConfig with the remote provided values + // Create a new ChannelConfig with the remote-provided values var channelConfig = new ChannelConfig(tempChannel.ChannelConfig.ChannelReserveAmount, tempChannel.ChannelConfig.FeeRateAmountPerKw, tempChannel.ChannelConfig.HtlcMinimumAmount, @@ -142,7 +142,7 @@ public AcceptChannel1MessageHandler(IBitcoinWalletService bitcoinWalletService, tempChannel.ChannelConfig.MaxHtlcAmountInFlight, tempChannel.ChannelConfig.MinimumDepth, tempChannel.ChannelConfig.OptionAnchorOutputs, - payload.DustLimitAmount, tempChannel.ChannelConfig.ToSelfDelay, + payload.DustLimitAmount, payload.ToSelfDelay, tempChannel.ChannelConfig.UseScidAlias, tempChannel.ChannelConfig.LocalUpfrontShutdownScript, remoteUpfrontShutdownScript); @@ -202,7 +202,7 @@ public AcceptChannel1MessageHandler(IBitcoinWalletService bitcoinWalletService, // Build the output and the transactions var remoteUnsignedCommitmentTransaction = _commitmentTransactionBuilder.Build(remoteCommitmentTransaction); - // Sign our remote commitment transaction + // Sign their remote commitment transaction var ourSignature = _lightningSigner.SignChannelTransaction(tempChannel.ChannelId, remoteUnsignedCommitmentTransaction); diff --git a/src/NLightning.Daemon/Handlers/OpenChannelClientSubscriptionHandler.cs b/src/NLightning.Daemon/Handlers/OpenChannelClientSubscriptionHandler.cs index 5ced9539..38cae613 100644 --- a/src/NLightning.Daemon/Handlers/OpenChannelClientSubscriptionHandler.cs +++ b/src/NLightning.Daemon/Handlers/OpenChannelClientSubscriptionHandler.cs @@ -52,26 +52,30 @@ public async Task HandleAsync(OpenChannel _channelId = request.ChannelId; if (!_channelMemoryRepository.TryGetChannel(_channelId, out var channel)) - { - if (!_channelMemoryRepository.TryGetChannel(_channelId, out channel)) - throw new ClientException(ErrorCodes.InvalidChannel, $"Channel with Id {_channelId} not found"); - } + throw new ClientException(ErrorCodes.InvalidChannel, $"Channel with Id {_channelId} not found"); var peer = _peerManager.GetPeer(channel.RemoteNodeId) ?? throw new ClientException(ErrorCodes.InvalidOperation, - $"Peer with NodeId {channel.RemoteNodeId} is not connected"); - var lockedUtxos = _utxoMemoryRepository.GetLockedUtxosForChannel(_channelId); - if (lockedUtxos.Count == 0) - throw new ClientException(ErrorCodes.InvalidOperation, - $"No locked UTXOs found for channel {_channelId}"); + $"Peer with NodeId {channel.RemoteNodeId} is not connected"); // Create a task completion source for the response var tsc = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); + // If it's in a state we consider Open, return immediately + if (channel.State is ChannelState.ReadyForUs or ChannelState.ReadyForThem or ChannelState.Open) + { + return new OpenChannelClientSubscriptionResponse(channel.ChannelId) + { + ChannelState = ChannelState.ReadyForUs, + TxId = channel.FundingOutput?.TransactionId, + Index = channel.FundingOutput?.Index + }; + } + // Check if the channel is already in a state we care about - var shouldPersistChannel = channel.State is ChannelState.V1FundingSigned - or ChannelState.ReadyForUs - or ChannelState.ReadyForThem; + var lockedUtxos = _utxoMemoryRepository.GetLockedUtxosForChannel(_channelId); + if (channel.State is not ChannelState.V1FundingSigned && lockedUtxos.Count == 0) + throw new ClientException(ErrorCodes.InvalidOperation, $"No locked UTXOs found for channel {_channelId}"); try { @@ -88,8 +92,14 @@ or ChannelState.ReadyForUs } catch { - if (!shouldPersistChannel) - _utxoMemoryRepository.ReturnUtxosNotSpentOnChannel(request.ChannelId); + if (!_channelMemoryRepository.TryGetChannel(_channelId, out channel) + || channel.State is ChannelState.ReadyForUs + or ChannelState.ReadyForThem + or ChannelState.Open + or ChannelState.V1FundingSigned) + throw; + + _utxoMemoryRepository.ReturnUtxosNotSpentOnChannel(request.ChannelId); throw; } @@ -142,8 +152,13 @@ private void HandlePeerDisconnection(PeerDisconnectedEventArgs args, CompactPubK } else { - _logger.LogError(args.Exception, "Peer disconnected. Error: {message}", args.Exception.Message); - tsc.TrySetException(new ConnectionException("Error opening channel: Peer disconnected", args.Exception)); + // Get to the bottom of the inner exceptions to fetch the real reason for the disconnection + var exception = args.Exception; + while (exception.InnerException is not null) + exception = exception.InnerException; + + _logger.LogError(args.Exception, "Error opening channel. Error: {message}", exception.Message); + tsc.TrySetException(new ChannelErrorException($"Error opening channel: {exception.Message}", exception)); } } diff --git a/src/NLightning.Domain/Protocol/Payloads/AcceptChannel1Payload.cs b/src/NLightning.Domain/Protocol/Payloads/AcceptChannel1Payload.cs index f6ee68bd..5de35c9d 100644 --- a/src/NLightning.Domain/Protocol/Payloads/AcceptChannel1Payload.cs +++ b/src/NLightning.Domain/Protocol/Payloads/AcceptChannel1Payload.cs @@ -26,7 +26,7 @@ public class AcceptChannel1Payload : IChannelMessagePayload public LightningMoney DustLimitAmount { get; } /// - /// max_htlc_value_in_flight_msat is a cap on total value of outstanding HTLCs offered by the remote node, which + /// max_htlc_value_in_flight_msat is a cap on the total value of outstanding HTLCs offered by the remote node, which /// allows the local node to limit its exposure to HTLCs /// public LightningMoney MaxHtlcValueInFlightAmount { get; } @@ -44,7 +44,7 @@ public class AcceptChannel1Payload : IChannelMessagePayload /// /// minimum_depth is the number of blocks we consider reasonable to avoid double-spending of the funding transaction. - /// In case channel_type includes option_zeroconf this MUST be 0 + /// In case channel_type includes option_zeroconf, this MUST be 0 /// public uint MinimumDepth { get; set; } diff --git a/src/NLightning.Infrastructure.Bitcoin/Builders/FundingTransactionBuilder.cs b/src/NLightning.Infrastructure.Bitcoin/Builders/FundingTransactionBuilder.cs index e9f58780..2b5c5520 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Builders/FundingTransactionBuilder.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Builders/FundingTransactionBuilder.cs @@ -34,8 +34,9 @@ public SignedTransaction Build(FundingTransactionModel transaction) var totalInputAmount = coins.Sum(x => x.Amount); - _logger.LogTrace("Building funding transaction with {UtxoCount} UTXOs for amount {FundingAmount}", - coins.Length, transaction.FundingOutput.Amount); + if (_logger.IsEnabled(LogLevel.Trace)) + _logger.LogTrace("Building funding transaction with {UtxoCount} UTXOs for amount {FundingAmount}", + coins.Length, transaction.FundingOutput.Amount); // Create a new Bitcoin transaction var tx = Transaction.Create(_network); @@ -65,7 +66,8 @@ public SignedTransaction Build(FundingTransactionModel transaction) transaction.FundingOutput.TransactionId = tx.GetHash().ToBytes(); transaction.FundingOutput.Index = 0; - _logger.LogInformation("Built funding transaction {TxId} with funding output at index 0", tx.GetHash()); + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation("Built funding transaction {TxId} with funding output at index 0", tx.GetHash()); // Return as SignedTransaction (note: needs to be signed by the signer afterwards) return new SignedTransaction(tx.GetHash().ToBytes(), tx.ToBytes()); diff --git a/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs b/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs index 887f5f0d..1ad194da 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Signers/LocalLightningSigner.cs @@ -390,8 +390,9 @@ public bool SignFundingTransaction(ChannelId channelId, SignedTransaction unsign /// public CompactSignature SignChannelTransaction(ChannelId channelId, SignedTransaction unsignedTransaction) { - _logger.LogTrace("Signing transaction for channel {ChannelId} with TxId {TxId}", channelId, - unsignedTransaction.TxId); + if (_logger.IsEnabled(LogLevel.Trace)) + _logger.LogTrace("Signing transaction for channel {ChannelId} with TxId {TxId}", channelId, + unsignedTransaction.TxId); if (!_channelSigningInfo.TryGetValue(channelId, out var signingInfo)) throw new InvalidOperationException($"Channel {channelId} not registered with signer"); @@ -418,8 +419,8 @@ public CompactSignature SignChannelTransaction(ChannelId channelId, SignedTransa var spentOutput = fundingOutput.ToTxOut(); // Get the signature hash for SegWit - var signatureHash = nBitcoinTx.GetSignatureHash(fundingOutput.RedeemScript, signingInfo.FundingOutputIndex, - SigHash.All, spentOutput, HashVersion.WitnessV0); + var signatureHash = nBitcoinTx.GetSignatureHash(fundingOutput.RedeemScript, 0, SigHash.All, spentOutput, + HashVersion.WitnessV0); // Get the funding private key using var fundingPrivateKey = GenerateFundingPrivateKey(signingInfo.ChannelKeyIndex); @@ -439,8 +440,9 @@ public CompactSignature SignChannelTransaction(ChannelId channelId, SignedTransa public void ValidateSignature(ChannelId channelId, CompactSignature signature, SignedTransaction unsignedTransaction) { - _logger.LogTrace("Validating signature for channel {ChannelId} with TxId {TxId}", channelId, - unsignedTransaction.TxId); + if (_logger.IsEnabled(LogLevel.Trace)) + _logger.LogTrace("Validating signature for channel {ChannelId} with TxId {TxId}", channelId, + unsignedTransaction.TxId); if (!_channelSigningInfo.TryGetValue(channelId, out var signingInfo)) throw new SignerException("Channel not registered with signer", channelId, "Internal error"); @@ -485,18 +487,15 @@ public void ValidateSignature(ChannelId channelId, CompactSignature signature, { // Build the funding output using the channel's signing info var fundingOutputInfo = new FundingOutputInfo(signingInfo.FundingSatoshis, signingInfo.LocalFundingPubKey, - signingInfo.RemoteFundingPubKey) - { - TransactionId = signingInfo.FundingTxId, - Index = signingInfo.FundingOutputIndex - }; + signingInfo.RemoteFundingPubKey, signingInfo.FundingTxId, + signingInfo.FundingOutputIndex); var fundingOutput = _fundingOutputBuilder.Build(fundingOutputInfo); var spentOutput = fundingOutput.ToTxOut(); - var signatureHash = nBitcoinTx.GetSignatureHash(fundingOutput.RedeemScript, - signingInfo.FundingOutputIndex, SigHash.All, - spentOutput, HashVersion.WitnessV0); + var signatureHash = + nBitcoinTx.GetSignatureHash(fundingOutput.RedeemScript, 0, SigHash.All, spentOutput, + HashVersion.WitnessV0); if (!pubKey.Verify(signatureHash, txSignature)) throw new SignerException("Peer signature is invalid", channelId, "Invalid signature provided"); diff --git a/test/NLightning.Integration.Tests/Docker/ChannelOpeningFlowTests.cs b/test/NLightning.Integration.Tests/Docker/ChannelOpeningFlowTests.cs index 1fe56e53..c34cba2f 100644 --- a/test/NLightning.Integration.Tests/Docker/ChannelOpeningFlowTests.cs +++ b/test/NLightning.Integration.Tests/Docker/ChannelOpeningFlowTests.cs @@ -1,879 +1,974 @@ -// using System.Net; -// using Microsoft.EntityFrameworkCore; -// using Microsoft.Extensions.Configuration; -// using Microsoft.Extensions.DependencyInjection; -// using Microsoft.Extensions.Logging; -// using Microsoft.Extensions.Options; -// using Moq.Protected; -// using NBitcoin; -// using NLightning.Application; -// using NLightning.Daemon.Handlers; -// using NLightning.Daemon.Interfaces; -// using NLightning.Domain.Bitcoin.Events; -// using NLightning.Domain.Bitcoin.Interfaces; -// using NLightning.Domain.Bitcoin.Transactions.Factories; -// using NLightning.Domain.Bitcoin.Transactions.Interfaces; -// using NLightning.Domain.Channels.Enums; -// using NLightning.Domain.Channels.Factories; -// using NLightning.Domain.Channels.Interfaces; -// using NLightning.Domain.Channels.Validators; -// using NLightning.Domain.Client.Requests; -// using NLightning.Domain.Client.Responses; -// using NLightning.Domain.Crypto.Hashes; -// using NLightning.Domain.Enums; -// using NLightning.Domain.Money; -// using NLightning.Domain.Node.Interfaces; -// using NLightning.Domain.Node.Options; -// using NLightning.Domain.Node.ValueObjects; -// using NLightning.Domain.Protocol.Constants; -// using NLightning.Domain.Protocol.Interfaces; -// using NLightning.Domain.Protocol.ValueObjects; -// using NLightning.Infrastructure; -// using NLightning.Infrastructure.Bitcoin; -// using NLightning.Infrastructure.Bitcoin.Builders; -// using NLightning.Infrastructure.Bitcoin.Options; -// using NLightning.Infrastructure.Bitcoin.Services; -// using NLightning.Infrastructure.Bitcoin.Signers; -// using NLightning.Infrastructure.Bitcoin.Wallet.Interfaces; -// using NLightning.Infrastructure.Persistence; -// using NLightning.Infrastructure.Persistence.Contexts; -// using NLightning.Infrastructure.Repositories; -// using NLightning.Infrastructure.Serialization; -// using NLightning.Tests.Utils; -// using ServiceStack; -// -// namespace NLightning.Integration.Tests.Docker; -// -// using Fixtures; -// using Mock; -// using TestCollections; -// using Utils; -// -// [Collection(LightningRegtestNetworkFixtureCollection.Name)] -// public class ChannelOpeningFlowTests : IDisposable -// { -// private readonly LightningRegtestNetworkFixture _lightningRegtestNetworkFixture; -// private readonly IPeerManager _peerManager; -// private readonly IChannelMemoryRepository _channelMemoryRepository; -// private readonly IBlockchainMonitor _blockchainMonitor; -// private readonly int _port; -// private readonly string _databaseFilePath = $"nlightning_channel_test_{Guid.NewGuid()}.db"; -// private readonly IServiceProvider _serviceProvider; -// -// public ChannelOpeningFlowTests(LightningRegtestNetworkFixture fixture, ITestOutputHelper output) -// { -// _lightningRegtestNetworkFixture = fixture; -// Console.SetOut(new TestOutputWriter(output)); -// -// _port = PortPoolUtil.GetAvailablePortAsync().GetAwaiter().GetResult(); -// Assert.True(_port > 0); -// ISecureKeyManager secureKeyManager = new FakeSecureKeyManager(); -// -// // Get Bitcoin network info -// Assert.NotNull(_lightningRegtestNetworkFixture.Builder); -// var bitcoinConfiguration = _lightningRegtestNetworkFixture.Builder.Configuration.BTCNodes[0]; -// var zmqRawBlockPort = -// bitcoinConfiguration.Cmd.First(c => c.Contains("-zmqpubrawblock")).Split(':')[2]; -// var zmqRawTxPort = -// bitcoinConfiguration.Cmd.First(c => c.Contains("-zmqpubrawtx")).Split(':')[2]; -// var bitcoin = _lightningRegtestNetworkFixture.Builder.BitcoinRpcClient; -// Assert.NotNull(bitcoin); -// var bitcoinEndpoint = bitcoin.Address.ToString(); -// -// // Mock HttpClient for FeeService -// var httpMessageHandlerMock = new Mock(MockBehavior.Strict); -// httpMessageHandlerMock.Protected() -// .Setup>("SendAsync", ItExpr.IsAny(), -// ItExpr.IsAny()) -// .ReturnsAsync(() => new HttpResponseMessage -// { -// StatusCode = HttpStatusCode.OK, -// Content = new StringContent("{\"fastestFee\": 2}") -// }); -// -// // Build configuration -// List> inMemoryConfiguration = -// [ -// new("Serilog:MinimumLevel:NLightning", "Verbose"), -// new("Node:Network", "regtest"), -// new("Node:Daemon", "false"), -// new("Database:Provider", "Sqlite"), -// new("Database:ConnectionString", $"Data Source={_databaseFilePath}"), -// new("Bitcoin:RpcEndpoint", bitcoinEndpoint), -// new("Bitcoin:RpcUser", bitcoin.CredentialString.UserPassword.UserName), -// new("Bitcoin:RpcPassword", bitcoin.CredentialString.UserPassword.Password), -// new("Bitcoin:ZmqHost", bitcoin.Address.Host), -// new("Bitcoin:ZmqBlockPort", zmqRawBlockPort), -// new("Bitcoin:ZmqTxPort", zmqRawTxPort) -// ]; -// var configuration = new ConfigurationBuilder().AddInMemoryCollection(inMemoryConfiguration).Build(); -// -// // Create a service collection -// var services = new ServiceCollection(); -// services.AddSingleton(configuration); -// services.AddHttpClient(client => -// { -// client.Timeout = TimeSpan.FromSeconds(30); -// client.DefaultRequestHeaders.Add("Accept", "application/json"); -// }); -// services.AddSingleton(secureKeyManager); -// services.AddSingleton(sp => -// { -// var nodeOptions = sp.GetRequiredService>().Value; -// return new ChannelOpenValidator(nodeOptions); -// }); -// services.AddSingleton(sp => -// { -// var channelIdFactory = sp.GetRequiredService(); -// var channelOpenValidator = sp.GetRequiredService(); -// var feeService = sp.GetRequiredService(); -// var lightningSigner = sp.GetRequiredService(); -// var nodeOptions = sp.GetRequiredService>().Value; -// var sha256 = sp.GetRequiredService(); -// return new ChannelFactory(channelIdFactory, channelOpenValidator, feeService, lightningSigner, nodeOptions, -// sha256); -// }); -// services.AddSingleton(); -// services.AddSingleton(serviceProvider => -// { -// var fundingOutputBuilder = serviceProvider.GetRequiredService(); -// var keyDerivationService = serviceProvider.GetRequiredService(); -// var logger = serviceProvider.GetRequiredService>(); -// var nodeOptions = serviceProvider.GetRequiredService>().Value; -// var utxoMemoryRepository = serviceProvider.GetRequiredService(); -// -// return new LocalLightningSigner(fundingOutputBuilder, keyDerivationService, logger, nodeOptions, -// secureKeyManager, utxoMemoryRepository); -// }); -// services.AddApplicationServices(); -// services.AddInfrastructureServices(); -// services.AddPersistenceInfrastructureServices(configuration); -// services.AddRepositoriesInfrastructureServices(); -// services.AddSerializationInfrastructureServices(); -// services.AddBitcoinInfrastructure(); -// services -// .AddScoped, -// OpenChannelClientHandler>(); -// services -// .AddScoped -// , -// OpenChannelClientSubscriptionHandler>(); -// services.AddSingleton(); -// services.AddOptions().BindConfiguration("Bitcoin").ValidateOnStart(); -// services.AddOptions().BindConfiguration("FeeEstimation").ValidateOnStart(); -// services.AddOptions() -// .BindConfiguration("Node") -// .PostConfigure(options => -// { -// options.Features = new FeatureOptions -// { -// ChainHashes = [ChainConstants.Regtest] -// }; -// options.ListenAddresses = [$"{IPAddress.Loopback}:{_port}"]; -// options.BitcoinNetwork = BitcoinNetwork.Regtest; -// options.Features.ChainHashes = [options.BitcoinNetwork.ChainHash]; -// options.ToSelfDelay = 240; -// }) -// .ValidateOnStart(); -// -// // Set up factories -// _serviceProvider = services.BuildServiceProvider(); -// -// // Set up the database migration -// var scope = _serviceProvider.CreateScope(); -// var context = scope.ServiceProvider.GetRequiredService(); -// var pendingMigrations = context.Database.GetPendingMigrationsAsync().GetAwaiter().GetResult().ToList(); -// if (pendingMigrations.Count > 0) -// context.Database.Migrate(); -// -// // Get services -// _peerManager = _serviceProvider.GetRequiredService(); -// _channelMemoryRepository = _serviceProvider.GetRequiredService(); -// _blockchainMonitor = _serviceProvider.GetRequiredService(); -// } -// -// [Fact] -// public async Task GivenSingleP2WPKHInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() -// { -// // Arrange -// var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; -// var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes -// .First(x => x.LocalAlias == "alice"); -// Assert.NotNull(alice); -// -// await _peerManager.StartAsync(CancellationToken.None); -// -// // Get the current block height -// var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); -// -// // Start the blockchain monitor at the current height -// await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); -// -// // Fund our wallet -// using (var scope = _serviceProvider.CreateScope()) -// { -// var walletService = scope.ServiceProvider -// .GetRequiredService(); -// var address = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, false); -// -// // Subscribe to blockchain monitor events -// TaskCompletionSource tsc = new(); -// uint txFirstSeenInBlock = int.MaxValue; -// -// void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) -// { -// if (e.Amount == LightningMoney.FromUnit(1, LightningMoneyUnit.Btc) -// && e.WalletAddress == address.Address) -// { -// txFirstSeenInBlock = e.BlockHeight; -// } -// else -// { -// Assert.Fail("Unexpected wallet movement detected: " + -// $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); -// } -// } -// -// void OnNewBlockDetected(object? _, NewBlockEventArgs e) -// { -// if (e.Height >= txFirstSeenInBlock + 5) -// tsc.TrySetResult(true); -// } -// -// _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; -// _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; -// -// // Send funds to our wallet -// await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address.Address, NBitcoin.Network.RegTest), -// new Money(1, MoneyUnit.BTC), TestContext.Current.CancellationToken); -// -// // Mine blocks to confirm -// await bitcoin.GenerateToAddressAsync( -// 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), -// TestContext.Current.CancellationToken); -// -// // wait for funding transaction to be confirmed -// Assert.True(await tsc.Task); -// _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; -// _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; -// } -// -// // Verify we have balance -// var utxoRepository = _serviceProvider.GetRequiredService(); -// var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); -// Assert.True(balance > LightningMoney.Zero); -// -// // Connect to Alice -// var aliceHost = new IPEndPoint( -// (await Dns.GetHostAddressesAsync(alice.Host.SplitOnFirst("//")[1].SplitOnFirst(":")[0], -// TestContext.Current.CancellationToken)).First(), 9735); -// var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; -// -// await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); -// -// Task openChannelTask; -// // Open channel - using the client handler -// using (var scope = _serviceProvider.CreateScope()) -// { -// var clientHandler = scope.ServiceProvider.GetService( -// typeof(IClientCommandHandler)) as -// OpenChannelClientHandler ?? -// throw new InvalidOperationException( -// $"Unable to get service {nameof(OpenChannelClientHandler)}"); -// var request = new OpenChannelClientRequest( -// aliceAddress, -// LightningMoney.Satoshis(1000000) // 0.01 BTC, -// ) -// { -// FeeRatePerKw = LightningMoney.Satoshis(10000) -// }; -// -// // Act - Open the channel (this should send open_channel and wait for the first response) -// openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); -// } -// -// var channelResponse = await openChannelTask; -// Assert.NotNull(channelResponse); -// -// var channelOpen = false; -// while (!channelOpen) -// { -// Task openChannelSubscriptionTask; -// // Open channel subscription - using the client handler -// using (var scope = _serviceProvider.CreateScope()) -// { -// var subscriptionClientHandler = scope.ServiceProvider.GetService( -// typeof(IClientCommandHandler< -// OpenChannelClientSubscriptionRequest, -// OpenChannelClientSubscriptionResponse>)) as -// OpenChannelClientSubscriptionHandler ?? -// throw new InvalidOperationException( -// $"Unable to get service {nameof(OpenChannelClientSubscriptionHandler)}"); -// var request = new OpenChannelClientSubscriptionRequest(channelResponse.ChannelId); -// -// // Act - Open the channel (this should send open_channel and wait for the first response) -// openChannelSubscriptionTask = subscriptionClientHandler.HandleAsync(request, CancellationToken.None); -// } -// -// var channelSubscriptionResponse = await openChannelSubscriptionTask; -// Assert.NotNull(channelSubscriptionResponse); -// -// if (channelSubscriptionResponse.ChannelState == ChannelState.V1FundingSigned) -// { -// // Mine blocks to confirm -// await bitcoin.GenerateToAddressAsync( -// 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), -// TestContext.Current.CancellationToken); -// } -// else if (channelSubscriptionResponse.ChannelState is ChannelState.ReadyForThem or ChannelState.ReadyForUs -// or ChannelState.Open) -// { -// channelOpen = true; -// } -// } -// -// // Check if the channel exists (temporary or permanent) -// var allChannels = _channelMemoryRepository.FindChannels(_ => true); -// -// Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); -// } -// -// [Fact] -// public async Task GivenMultipleP2WPKHInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() -// { -// // Arrange -// var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; -// var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes -// .First(x => x.LocalAlias == "alice"); -// Assert.NotNull(alice); -// -// await _peerManager.StartAsync(CancellationToken.None); -// -// // Get the current block height -// var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); -// -// // Start the blockchain monitor at the current height -// await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); -// -// // Fund our wallet -// using (var scope = _serviceProvider.CreateScope()) -// { -// var walletService = scope.ServiceProvider -// .GetRequiredService(); -// -// // Subscribe to blockchain monitor events -// TaskCompletionSource tsc = new(); -// uint txFirstSeenInBlock = int.MaxValue; -// -// var address1 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, false); -// var address2 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, true); -// -// void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) -// { -// if (e.Amount == LightningMoney.Satoshis(1100000) && e.WalletAddress == address1.Address) -// { -// txFirstSeenInBlock = e.BlockHeight; -// } -// else -// { -// Assert.Fail("Unexpected wallet movement detected: " + -// $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); -// } -// } -// -// void OnNewBlockDetected(object? _, NewBlockEventArgs e) -// { -// if (e.Height >= txFirstSeenInBlock + 5) -// tsc.TrySetResult(true); -// } -// -// _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; -// _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; -// -// // Send funds to our wallet -// await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address1.Address, NBitcoin.Network.RegTest), -// new Money(1100000, MoneyUnit.Satoshi), -// TestContext.Current.CancellationToken); // 0.011 -// await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address2.Address, NBitcoin.Network.RegTest), -// new Money(1100000, MoneyUnit.Satoshi), -// TestContext.Current.CancellationToken); // 0.011 -// -// // Mine blocks to confirm -// await bitcoin.GenerateToAddressAsync( -// 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), -// TestContext.Current.CancellationToken); -// -// // wait for funding transaction to be confirmed -// Assert.True(await tsc.Task); -// _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; -// _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; -// } -// -// // Verify we have balance -// var utxoRepository = _serviceProvider.GetRequiredService(); -// var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); -// Assert.True(balance > LightningMoney.Zero); -// -// // Connect to Alice -// var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host -// .SplitOnFirst("//")[1] -// .SplitOnFirst(":")[0], -// TestContext.Current.CancellationToken)).First(), -// 9735); -// var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; -// -// await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); -// -// Task openChannelTask; -// // Open channel - using the client handler -// using (var scope = _serviceProvider.CreateScope()) -// { -// var clientHandler = scope.ServiceProvider.GetService( -// typeof(IClientCommandHandler)) as -// OpenChannelClientHandler ?? -// throw new InvalidOperationException( -// $"Unable to get service {nameof(OpenChannelClientHandler)}"); -// var request = new OpenChannelClientRequest( -// aliceAddress, -// LightningMoney.Satoshis(2000000) // 0.02 BTC, -// ) -// { -// FeeRatePerKw = LightningMoney.Satoshis(10000) -// }; -// -// // Act - Open the channel (this should send open_channel and wait for the flow to complete) -// openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); -// } -// -// var channelResponse = await openChannelTask; -// Assert.NotNull(channelResponse); -// -// var channelOpen = false; -// while (!channelOpen) -// { -// Task openChannelSubscriptionTask; -// // Open channel subscription - using the client handler -// using (var scope = _serviceProvider.CreateScope()) -// { -// var subscriptionClientHandler = scope.ServiceProvider.GetService( -// typeof(IClientCommandHandler< -// OpenChannelClientSubscriptionRequest, -// OpenChannelClientSubscriptionResponse>)) as -// OpenChannelClientSubscriptionHandler ?? -// throw new InvalidOperationException( -// $"Unable to get service {nameof(OpenChannelClientSubscriptionHandler)}"); -// var request = new OpenChannelClientSubscriptionRequest(channelResponse.ChannelId); -// -// // Act - Open the channel (this should send open_channel and wait for the first response) -// openChannelSubscriptionTask = subscriptionClientHandler.HandleAsync(request, CancellationToken.None); -// } -// -// var channelSubscriptionResponse = await openChannelSubscriptionTask; -// Assert.NotNull(channelSubscriptionResponse); -// -// if (channelSubscriptionResponse.ChannelState == ChannelState.V1FundingSigned) -// { -// // Mine blocks to confirm -// await bitcoin.GenerateToAddressAsync( -// 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), -// TestContext.Current.CancellationToken); -// } -// else if (channelSubscriptionResponse.ChannelState is ChannelState.ReadyForThem or ChannelState.ReadyForUs -// or ChannelState.Open) -// { -// channelOpen = true; -// } -// } -// -// // Check if the channel exists (temporary or permanent) -// var allChannels = _channelMemoryRepository.FindChannels(_ => true); -// -// Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); -// } -// -// // [Fact] -// // public async Task GivenSingleP2TRInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() -// // { -// // // Arrange -// // var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; -// // var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes -// // .First(x => x.LocalAlias == "alice"); -// // Assert.NotNull(alice); -// // -// // await _peerManager.StartAsync(CancellationToken.None); -// // -// // // Get the current block height -// // var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); -// // -// // // Start the blockchain monitor at the current height -// // await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); -// // -// // // Fund our wallet -// // using (var scope = _serviceProvider.CreateScope()) -// // { -// // var walletService = scope.ServiceProvider -// // .GetRequiredService(); -// // var address = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Tr, false); -// // -// // // Subscribe to blockchain monitor events -// // TaskCompletionSource tsc = new(); -// // uint txFirstSeenInBlock = int.MaxValue; -// // -// // void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) -// // { -// // if (e.Amount == LightningMoney.FromUnit(1, LightningMoneyUnit.Btc) -// // && e.WalletAddress == address.Address) -// // { -// // txFirstSeenInBlock = e.BlockHeight; -// // } -// // else -// // { -// // Assert.Fail("Unexpected wallet movement detected: " + -// // $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); -// // } -// // } -// // -// // void OnNewBlockDetected(object? _, NewBlockEventArgs e) -// // { -// // if (e.Height >= txFirstSeenInBlock + 5) -// // tsc.TrySetResult(true); -// // } -// // -// // _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; -// // _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; -// // -// // // Send funds to our wallet -// // await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address.Address, NBitcoin.Network.RegTest), -// // new Money(1, MoneyUnit.BTC), TestContext.Current.CancellationToken); -// // -// // // Mine blocks to confirm -// // await bitcoin.GenerateToAddressAsync( -// // 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), -// // TestContext.Current.CancellationToken); -// // -// // // wait for funding transaction to be confirmed -// // Assert.True(await tsc.Task); -// // _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; -// // _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; -// // } -// // -// // // Verify we have balance -// // var utxoRepository = _serviceProvider.GetRequiredService(); -// // var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); -// // Assert.True(balance > LightningMoney.Zero); -// // -// // // Connect to Alice -// // var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host -// // .SplitOnFirst("//")[1] -// // .SplitOnFirst(":")[0], -// // TestContext.Current.CancellationToken)).First(), -// // 9735); -// // var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; -// // -// // await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); -// // -// // Task openChannelTask; -// // // Open channel - using the client handler -// // using (var scope = _serviceProvider.CreateScope()) -// // { -// // var clientHandler = scope.ServiceProvider.GetService( -// // typeof(IClientCommandHandler)) as -// // OpenChannelClientHandler ?? -// // throw new InvalidOperationException( -// // $"Unable to get service {nameof(OpenChannelClientHandler)}"); -// // var request = new OpenChannelClientRequest( -// // aliceAddress, -// // LightningMoney.Satoshis(1000000) // 0.01 BTC, -// // ) -// // { -// // FeeRatePerKw = LightningMoney.Satoshis(10000) -// // }; -// // -// // // Subscribe to the event and mine blocks when needed -// // clientHandler.OnWaitingConfirmation += async (_, _) => -// // { -// // // Mine blocks to confirm -// // await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); -// // }; -// // -// // // Act - Open the channel (this should send open_channel and wait for the flow to complete) -// // openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); -// // } -// // -// // var channelResponse = await openChannelTask; -// // Assert.NotNull(channelResponse); -// // -// // // Check if the channel exists (temporary or permanent) -// // var allChannels = _channelMemoryRepository.FindChannels(_ => true); -// // -// // Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); -// // } -// // -// // [Fact] -// // public async Task GivenMultipleP2TRInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() -// // { -// // // Arrange -// // var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; -// // var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes -// // .First(x => x.LocalAlias == "alice"); -// // Assert.NotNull(alice); -// // -// // await _peerManager.StartAsync(CancellationToken.None); -// // -// // // Get the current block height -// // var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); -// // -// // // Start the blockchain monitor at the current height -// // await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); -// // -// // // Fund our wallet -// // using (var scope = _serviceProvider.CreateScope()) -// // { -// // var walletService = scope.ServiceProvider -// // .GetRequiredService(); -// // -// // // Subscribe to blockchain monitor events -// // TaskCompletionSource tsc = new(); -// // uint txFirstSeenInBlock = int.MaxValue; -// // -// // var address1 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Tr, false); -// // var address2 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Tr, true); -// // -// // void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) -// // { -// // if (e.Amount == LightningMoney.Satoshis(1100000) && e.WalletAddress == address1.Address) -// // { -// // txFirstSeenInBlock = e.BlockHeight; -// // } -// // else -// // { -// // Assert.Fail("Unexpected wallet movement detected: " + -// // $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); -// // } -// // } -// // -// // void OnNewBlockDetected(object? _, NewBlockEventArgs e) -// // { -// // if (e.Height >= txFirstSeenInBlock + 5) -// // tsc.TrySetResult(true); -// // } -// // -// // _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; -// // _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; -// // -// // // Send funds to our wallet -// // await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address1.Address, NBitcoin.Network.RegTest), -// // new Money(1100000, MoneyUnit.Satoshi), -// // TestContext.Current.CancellationToken); // 0.011 -// // await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address2.Address, NBitcoin.Network.RegTest), -// // new Money(1100000, MoneyUnit.Satoshi), -// // TestContext.Current.CancellationToken); // 0.011 -// // -// // // Mine blocks to confirm -// // await bitcoin.GenerateToAddressAsync( -// // 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), -// // TestContext.Current.CancellationToken); -// // -// // // wait for funding transaction to be confirmed -// // Assert.True(await tsc.Task); -// // _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; -// // _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; -// // } -// // -// // // Verify we have balance -// // var utxoRepository = _serviceProvider.GetRequiredService(); -// // var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); -// // Assert.True(balance > LightningMoney.Zero); -// // -// // // Connect to Alice -// // var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host -// // .SplitOnFirst("//")[1] -// // .SplitOnFirst(":")[0], -// // TestContext.Current.CancellationToken)).First(), -// // 9735); -// // var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; -// // -// // await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); -// // -// // Task openChannelTask; -// // // Open channel - using the client handler -// // using (var scope = _serviceProvider.CreateScope()) -// // { -// // var clientHandler = scope.ServiceProvider.GetService( -// // typeof(IClientCommandHandler)) as -// // OpenChannelClientHandler ?? -// // throw new InvalidOperationException( -// // $"Unable to get service {nameof(OpenChannelClientHandler)}"); -// // var request = new OpenChannelClientRequest( -// // aliceAddress, -// // LightningMoney.Satoshis(2000000) // 0.02 BTC, -// // ) -// // { -// // FeeRatePerKw = LightningMoney.Satoshis(10000) -// // }; -// // -// // // Subscribe to the event and mine blocks when needed -// // clientHandler.OnWaitingConfirmation += async (_, _) => -// // { -// // // Mine blocks to confirm -// // await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); -// // }; -// // -// // // Act - Open the channel (this should send open_channel and wait for the flow to complete) -// // openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); -// // } -// // -// // var channelResponse = await openChannelTask; -// // Assert.NotNull(channelResponse); -// // -// // // Check if the channel exists (temporary or permanent) -// // var allChannels = _channelMemoryRepository.FindChannels(_ => true); -// // -// // Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); -// // } -// // -// // [Fact] -// // public async Task GivenMixedInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() -// // { -// // // Arrange -// // var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; -// // var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes -// // .First(x => x.LocalAlias == "alice"); -// // Assert.NotNull(alice); -// // -// // await _peerManager.StartAsync(CancellationToken.None); -// // -// // // Get the current block height -// // var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); -// // -// // // Start the blockchain monitor at the current height -// // await _blockchainMonitor.StartAsync(currentHeight, CancellationToken.None); -// // -// // // Fund our wallet -// // using (var scope = _serviceProvider.CreateScope()) -// // { -// // var walletService = scope.ServiceProvider -// // .GetRequiredService(); -// // -// // // Subscribe to blockchain monitor events -// // TaskCompletionSource tsc = new(); -// // uint txFirstSeenInBlock = int.MaxValue; -// // -// // var address1 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, false); -// // var address2 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Tr, false); -// // -// // void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) -// // { -// // if (e.Amount == LightningMoney.Satoshis(1100000) && e.WalletAddress == address1.Address) -// // { -// // txFirstSeenInBlock = e.BlockHeight; -// // } -// // else -// // { -// // Assert.Fail("Unexpected wallet movement detected: " + -// // $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); -// // } -// // } -// // -// // void OnNewBlockDetected(object? _, NewBlockEventArgs e) -// // { -// // if (e.Height >= txFirstSeenInBlock + 5) -// // tsc.TrySetResult(true); -// // } -// // -// // _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; -// // _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; -// // -// // // Send funds to our wallet -// // await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address1.Address, NBitcoin.Network.RegTest), -// // new Money(1100000, MoneyUnit.Satoshi), -// // TestContext.Current.CancellationToken); // 0.011 -// // await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address2.Address, NBitcoin.Network.RegTest), -// // new Money(1100000, MoneyUnit.Satoshi), -// // TestContext.Current.CancellationToken); // 0.011 -// // -// // // Mine blocks to confirm -// // await bitcoin.GenerateToAddressAsync( -// // 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), -// // TestContext.Current.CancellationToken); -// // -// // // wait for funding transaction to be confirmed -// // Assert.True(await tsc.Task); -// // _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; -// // _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; -// // } -// // -// // // Verify we have balance -// // var utxoRepository = _serviceProvider.GetRequiredService(); -// // var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); -// // Assert.True(balance > LightningMoney.Zero); -// // -// // // Connect to Alice -// // var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host -// // .SplitOnFirst("//")[1] -// // .SplitOnFirst(":")[0], -// // TestContext.Current.CancellationToken)).First(), -// // 9735); -// // var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; -// // -// // await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); -// // -// // Task openChannelTask; -// // // Open channel - using the client handler -// // using (var scope = _serviceProvider.CreateScope()) -// // { -// // var clientHandler = scope.ServiceProvider.GetService( -// // typeof(IClientCommandHandler)) as -// // OpenChannelClientHandler ?? -// // throw new InvalidOperationException( -// // $"Unable to get service {nameof(OpenChannelClientHandler)}"); -// // var request = new OpenChannelClientRequest( -// // aliceAddress, -// // LightningMoney.Satoshis(2000000) // 0.02 BTC, -// // ) -// // { -// // FeeRatePerKw = LightningMoney.Satoshis(10000) -// // }; -// // -// // // Subscribe to the event and mine blocks when needed -// // clientHandler.OnWaitingConfirmation += async (_, _) => -// // { -// // // Mine blocks to confirm -// // await bitcoin.GenerateToAddressAsync(6, await bitcoin.GetNewAddressAsync()); -// // }; -// // -// // // Act - Open the channel (this should send open_channel and wait for the flow to complete) -// // openChannelTask = clientHandler.HandleAsync(request, CancellationToken.None); -// // } -// // -// // var channelResponse = await openChannelTask; -// // Assert.NotNull(channelResponse); -// // -// // // Check if the channel exists (temporary or permanent) -// // var allChannels = _channelMemoryRepository.FindChannels(_ => true); -// // -// // Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); -// // } -// -// public void Dispose() -// { -// _blockchainMonitor.StopAsync().GetAwaiter().GetResult(); -// PortPoolUtil.ReleasePort(_port); -// if (File.Exists(_databaseFilePath)) -// { -// try -// { -// File.Delete(_databaseFilePath); -// } -// catch (Exception ex) -// { -// Console.WriteLine($"Failed to delete database file: {ex.Message}"); -// } -// } -// } -// } \ No newline at end of file +using System.Net; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq.Protected; +using NBitcoin; +using NLightning.Application; +using NLightning.Daemon.Handlers; +using NLightning.Daemon.Interfaces; +using NLightning.Domain.Bitcoin.Events; +using NLightning.Domain.Bitcoin.Interfaces; +using NLightning.Domain.Bitcoin.Transactions.Factories; +using NLightning.Domain.Bitcoin.Transactions.Interfaces; +using NLightning.Domain.Channels.Enums; +using NLightning.Domain.Channels.Factories; +using NLightning.Domain.Channels.Interfaces; +using NLightning.Domain.Channels.Validators; +using NLightning.Domain.Client.Requests; +using NLightning.Domain.Client.Responses; +using NLightning.Domain.Crypto.Hashes; +using NLightning.Domain.Enums; +using NLightning.Domain.Money; +using NLightning.Domain.Node.Interfaces; +using NLightning.Domain.Node.Options; +using NLightning.Domain.Node.ValueObjects; +using NLightning.Domain.Protocol.Constants; +using NLightning.Domain.Protocol.Interfaces; +using NLightning.Domain.Protocol.ValueObjects; +using NLightning.Infrastructure; +using NLightning.Infrastructure.Bitcoin; +using NLightning.Infrastructure.Bitcoin.Builders; +using NLightning.Infrastructure.Bitcoin.Options; +using NLightning.Infrastructure.Bitcoin.Services; +using NLightning.Infrastructure.Bitcoin.Signers; +using NLightning.Infrastructure.Bitcoin.Wallet.Interfaces; +using NLightning.Infrastructure.Persistence; +using NLightning.Infrastructure.Persistence.Contexts; +using NLightning.Infrastructure.Repositories; +using NLightning.Infrastructure.Serialization; +using NLightning.Tests.Utils; +using ServiceStack; + +namespace NLightning.Integration.Tests.Docker; + +using Fixtures; +using Mock; +using TestCollections; +using Utils; + +[Collection(LightningRegtestNetworkFixtureCollection.Name)] +public class ChannelOpeningFlowTests : IDisposable +{ + private readonly LightningRegtestNetworkFixture _lightningRegtestNetworkFixture; + private readonly IPeerManager _peerManager; + private readonly IChannelMemoryRepository _channelMemoryRepository; + private readonly IBlockchainMonitor _blockchainMonitor; + private readonly int _port; + private readonly string _databaseFilePath = $"nlightning_channel_test_{Guid.NewGuid()}.db"; + private readonly IServiceProvider _serviceProvider; + + public ChannelOpeningFlowTests(LightningRegtestNetworkFixture fixture, ITestOutputHelper output) + { + _lightningRegtestNetworkFixture = fixture; + Console.SetOut(new TestOutputWriter(output)); + + _port = PortPoolUtil.GetAvailablePortAsync().GetAwaiter().GetResult(); + Assert.True(_port > 0); + ISecureKeyManager secureKeyManager = new FakeSecureKeyManager(); + + // Get Bitcoin network info + Assert.NotNull(_lightningRegtestNetworkFixture.Builder); + var bitcoinConfiguration = _lightningRegtestNetworkFixture.Builder.Configuration.BTCNodes[0]; + var zmqRawBlockPort = + bitcoinConfiguration.Cmd.First(c => c.Contains("-zmqpubrawblock")).Split(':')[2]; + var zmqRawTxPort = + bitcoinConfiguration.Cmd.First(c => c.Contains("-zmqpubrawtx")).Split(':')[2]; + var bitcoin = _lightningRegtestNetworkFixture.Builder.BitcoinRpcClient; + Assert.NotNull(bitcoin); + var bitcoinEndpoint = bitcoin.Address.ToString(); + + // Mock HttpClient for FeeService + var httpMessageHandlerMock = new Mock(MockBehavior.Strict); + httpMessageHandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"fastestFee\": 2}") + }); + + // Build configuration + List> inMemoryConfiguration = + [ + new("Serilog:MinimumLevel:NLightning", "Verbose"), + new("Node:Network", "regtest"), + new("Node:Daemon", "false"), + new("Database:Provider", "Sqlite"), + new("Database:ConnectionString", $"Data Source={_databaseFilePath}"), + new("Bitcoin:RpcEndpoint", bitcoinEndpoint), + new("Bitcoin:RpcUser", bitcoin.CredentialString.UserPassword.UserName), + new("Bitcoin:RpcPassword", bitcoin.CredentialString.UserPassword.Password), + new("Bitcoin:ZmqHost", bitcoin.Address.Host), + new("Bitcoin:ZmqBlockPort", zmqRawBlockPort), + new("Bitcoin:ZmqTxPort", zmqRawTxPort) + ]; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(inMemoryConfiguration).Build(); + + // Create a service collection + var services = new ServiceCollection(); + services.AddSingleton(configuration); + services.AddHttpClient(client => + { + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + }); + services.AddSingleton(secureKeyManager); + services.AddSingleton(sp => + { + var nodeOptions = sp.GetRequiredService>().Value; + return new ChannelOpenValidator(nodeOptions); + }); + services.AddSingleton(sp => + { + var channelIdFactory = sp.GetRequiredService(); + var channelOpenValidator = sp.GetRequiredService(); + var feeService = sp.GetRequiredService(); + var lightningSigner = sp.GetRequiredService(); + var nodeOptions = sp.GetRequiredService>().Value; + var sha256 = sp.GetRequiredService(); + return new ChannelFactory(channelIdFactory, channelOpenValidator, feeService, lightningSigner, nodeOptions, + sha256); + }); + services.AddSingleton(); + services.AddSingleton(serviceProvider => + { + var fundingOutputBuilder = serviceProvider.GetRequiredService(); + var keyDerivationService = serviceProvider.GetRequiredService(); + var logger = serviceProvider.GetRequiredService>(); + var nodeOptions = serviceProvider.GetRequiredService>().Value; + var utxoMemoryRepository = serviceProvider.GetRequiredService(); + + return new LocalLightningSigner(fundingOutputBuilder, keyDerivationService, logger, nodeOptions, + secureKeyManager, utxoMemoryRepository); + }); + services.AddApplicationServices(); + services.AddInfrastructureServices(); + services.AddPersistenceInfrastructureServices(configuration); + services.AddRepositoriesInfrastructureServices(); + services.AddSerializationInfrastructureServices(); + services.AddBitcoinInfrastructure(); + services + .AddScoped, + OpenChannelClientHandler>(); + services + .AddScoped + , + OpenChannelClientSubscriptionHandler>(); + services.AddSingleton(); + services.AddOptions().BindConfiguration("Bitcoin").ValidateOnStart(); + services.AddOptions().BindConfiguration("FeeEstimation").ValidateOnStart(); + services.AddOptions() + .BindConfiguration("Node") + .PostConfigure(options => + { + options.Features = new FeatureOptions + { + ChainHashes = [ChainConstants.Regtest] + }; + options.ListenAddresses = [$"{IPAddress.Loopback}:{_port}"]; + options.BitcoinNetwork = BitcoinNetwork.Regtest; + options.Features.ChainHashes = [options.BitcoinNetwork.ChainHash]; + options.ToSelfDelay = 240; + }) + .ValidateOnStart(); + + // Set up factories + _serviceProvider = services.BuildServiceProvider(); + + // Set up the database migration + var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var pendingMigrations = context.Database.GetPendingMigrationsAsync().GetAwaiter().GetResult().ToList(); + if (pendingMigrations.Count > 0) + context.Database.Migrate(); + + // Get services + _peerManager = _serviceProvider.GetRequiredService(); + _channelMemoryRepository = _serviceProvider.GetRequiredService(); + _blockchainMonitor = _serviceProvider.GetRequiredService(); + } + + [Fact] + public async Task GivenSingleP2WPKHInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() + { + // Arrange + var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; + var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes + .First(x => x.LocalAlias == "alice"); + Assert.NotNull(alice); + + await _peerManager.StartAsync(TestContext.Current.CancellationToken); + + // Get the current block height + var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); + + // Start the blockchain monitor at the current height + await _blockchainMonitor.StartAsync(currentHeight, TestContext.Current.CancellationToken); + + // Fund our wallet + using (var scope = _serviceProvider.CreateScope()) + { + var walletService = scope.ServiceProvider + .GetRequiredService(); + var address = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, false); + + // Subscribe to blockchain monitor events + TaskCompletionSource tsc = new(); + uint txFirstSeenInBlock = int.MaxValue; + + void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) + { + if (e.Amount == LightningMoney.FromUnit(1, LightningMoneyUnit.Btc) + && e.WalletAddress == address.Address) + { + txFirstSeenInBlock = e.BlockHeight; + } + else + { + Assert.Fail("Unexpected wallet movement detected: " + + $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); + } + } + + void OnNewBlockDetected(object? _, NewBlockEventArgs e) + { + if (e.Height >= txFirstSeenInBlock + 5) + tsc.TrySetResult(true); + } + + _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; + + // Send funds to our wallet + await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address.Address, NBitcoin.Network.RegTest), + new Money(1, MoneyUnit.BTC), TestContext.Current.CancellationToken); + + // Mine blocks to confirm + await bitcoin.GenerateToAddressAsync( + 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), + TestContext.Current.CancellationToken); + + // wait for funding transaction to be confirmed + Assert.True(await tsc.Task); + _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; + } + + // Verify we have balance + var utxoRepository = _serviceProvider.GetRequiredService(); + var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); + Assert.True(balance > LightningMoney.Zero); + + // Connect to Alice + var aliceHost = new IPEndPoint( + (await Dns.GetHostAddressesAsync(alice.Host.SplitOnFirst("//")[1].SplitOnFirst(":")[0], + TestContext.Current.CancellationToken)).First(), 9735); + var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; + + await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); + + Task openChannelTask; + // Open channel - using the client handler + using (var scope = _serviceProvider.CreateScope()) + { + var clientHandler = scope.ServiceProvider.GetService( + typeof(IClientCommandHandler)) as + OpenChannelClientHandler ?? + throw new InvalidOperationException( + $"Unable to get service {nameof(OpenChannelClientHandler)}"); + var request = new OpenChannelClientRequest( + aliceAddress, + LightningMoney.Satoshis(1000000) // 0.01 BTC, + ) + { + FeeRatePerKw = LightningMoney.Satoshis(10000) + }; + + // Act - Open the channel (this should send open_channel and wait for the first response) + openChannelTask = clientHandler.HandleAsync(request, TestContext.Current.CancellationToken); + } + + var channelResponse = await openChannelTask; + Assert.NotNull(channelResponse); + + var channelOpen = false; + while (!channelOpen) + { + Task openChannelSubscriptionTask; + // Open channel subscription - using the client handler + using (var scope = _serviceProvider.CreateScope()) + { + var subscriptionClientHandler = scope.ServiceProvider.GetService( + typeof(IClientCommandHandler< + OpenChannelClientSubscriptionRequest, + OpenChannelClientSubscriptionResponse>)) as + OpenChannelClientSubscriptionHandler ?? + throw new InvalidOperationException( + $"Unable to get service {nameof(OpenChannelClientSubscriptionHandler)}"); + var request = new OpenChannelClientSubscriptionRequest(channelResponse.ChannelId); + + // Act - Open the channel (this should send open_channel and wait for the first response) + openChannelSubscriptionTask = + subscriptionClientHandler.HandleAsync(request, TestContext.Current.CancellationToken); + } + + var channelSubscriptionResponse = await openChannelSubscriptionTask; + Assert.NotNull(channelSubscriptionResponse); + + if (channelSubscriptionResponse.ChannelState == ChannelState.V1FundingSigned) + { + // Mine blocks to confirm + _ = bitcoin.GenerateToAddressAsync( + 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), + TestContext.Current.CancellationToken); + } + else if (channelSubscriptionResponse.ChannelState is ChannelState.ReadyForThem or ChannelState.ReadyForUs + or ChannelState.Open) + { + channelOpen = true; + } + } + + // Check if the channel exists (temporary or permanent) + var allChannels = _channelMemoryRepository.FindChannels(_ => true); + + Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); + } + + [Fact] + public async Task GivenMultipleP2WPKHInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() + { + // Arrange + var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; + var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes + .First(x => x.LocalAlias == "alice"); + Assert.NotNull(alice); + + await _peerManager.StartAsync(TestContext.Current.CancellationToken); + + // Get the current block height + var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); + + // Start the blockchain monitor at the current height + await _blockchainMonitor.StartAsync(currentHeight, TestContext.Current.CancellationToken); + + // Fund our wallet + using (var scope = _serviceProvider.CreateScope()) + { + var walletService = scope.ServiceProvider + .GetRequiredService(); + + // Subscribe to blockchain monitor events + TaskCompletionSource tsc = new(); + uint txFirstSeenInBlock = int.MaxValue; + + var address1 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, false); + var address2 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, true); + + void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) + { + if (e.Amount == LightningMoney.Satoshis(1100000) && e.WalletAddress == address1.Address) + { + txFirstSeenInBlock = e.BlockHeight; + } + else + { + Assert.Fail("Unexpected wallet movement detected: " + + $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); + } + } + + void OnNewBlockDetected(object? _, NewBlockEventArgs e) + { + if (e.Height >= txFirstSeenInBlock + 5) + tsc.TrySetResult(true); + } + + _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; + + // Send funds to our wallet + await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address1.Address, NBitcoin.Network.RegTest), + new Money(1100000, MoneyUnit.Satoshi), + TestContext.Current.CancellationToken); // 0.0055 + await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address2.Address, NBitcoin.Network.RegTest), + new Money(1100000, MoneyUnit.Satoshi), + TestContext.Current.CancellationToken); // 0.0055 + + // Mine blocks to confirm + await bitcoin.GenerateToAddressAsync( + 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), + TestContext.Current.CancellationToken); + + // wait for funding transaction to be confirmed + Assert.True(await tsc.Task); + _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; + } + + // Verify we have balance + var utxoRepository = _serviceProvider.GetRequiredService(); + var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); + Assert.True(balance > LightningMoney.Zero); + + // Connect to Alice + var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host + .SplitOnFirst("//")[1] + .SplitOnFirst(":")[0], + TestContext.Current.CancellationToken)).First(), + 9735); + var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; + + await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); + + Task openChannelTask; + // Open channel - using the client handler + using (var scope = _serviceProvider.CreateScope()) + { + var clientHandler = scope.ServiceProvider.GetService( + typeof(IClientCommandHandler)) as + OpenChannelClientHandler ?? + throw new InvalidOperationException( + $"Unable to get service {nameof(OpenChannelClientHandler)}"); + var request = new OpenChannelClientRequest( + aliceAddress, + LightningMoney.Satoshis(2100000) // 0.01 BTC, + ) + { + FeeRatePerKw = LightningMoney.Satoshis(10000) + }; + + // Act - Open the channel (this should send open_channel and wait for the flow to complete) + openChannelTask = clientHandler.HandleAsync(request, TestContext.Current.CancellationToken); + } + + var channelResponse = await openChannelTask; + Assert.NotNull(channelResponse); + + var channelOpen = false; + while (!channelOpen) + { + Task openChannelSubscriptionTask; + // Open channel subscription - using the client handler + using (var scope = _serviceProvider.CreateScope()) + { + var subscriptionClientHandler = scope.ServiceProvider.GetService( + typeof(IClientCommandHandler< + OpenChannelClientSubscriptionRequest, + OpenChannelClientSubscriptionResponse>)) as + OpenChannelClientSubscriptionHandler ?? + throw new InvalidOperationException( + $"Unable to get service {nameof(OpenChannelClientSubscriptionHandler)}"); + var request = new OpenChannelClientSubscriptionRequest(channelResponse.ChannelId); + + // Act - Open the channel (this should send open_channel and wait for the first response) + openChannelSubscriptionTask = + subscriptionClientHandler.HandleAsync(request, TestContext.Current.CancellationToken); + } + + var channelSubscriptionResponse = await openChannelSubscriptionTask; + Assert.NotNull(channelSubscriptionResponse); + + if (channelSubscriptionResponse.ChannelState == ChannelState.V1FundingSigned) + { + // Mine blocks to confirm + _ = bitcoin.GenerateToAddressAsync( + 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), + TestContext.Current.CancellationToken); + } + else if (channelSubscriptionResponse.ChannelState is ChannelState.ReadyForThem or ChannelState.ReadyForUs + or ChannelState.Open) + { + channelOpen = true; + } + } + + // Check if the channel exists (temporary or permanent) + var allChannels = _channelMemoryRepository.FindChannels(_ => true); + + Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); + } + + [Fact] + public async Task GivenSingleP2TRInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() + { + // Arrange + var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; + var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes + .First(x => x.LocalAlias == "alice"); + Assert.NotNull(alice); + + await _peerManager.StartAsync(TestContext.Current.CancellationToken); + + // Get the current block height + var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); + + // Start the blockchain monitor at the current height + await _blockchainMonitor.StartAsync(currentHeight, TestContext.Current.CancellationToken); + + // Fund our wallet + using (var scope = _serviceProvider.CreateScope()) + { + var walletService = scope.ServiceProvider + .GetRequiredService(); + var address = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Tr, false); + + // Subscribe to blockchain monitor events + TaskCompletionSource tsc = new(); + uint txFirstSeenInBlock = int.MaxValue; + + void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) + { + if (e.Amount == LightningMoney.FromUnit(1, LightningMoneyUnit.Btc) + && e.WalletAddress == address.Address) + { + txFirstSeenInBlock = e.BlockHeight; + } + else + { + Assert.Fail("Unexpected wallet movement detected: " + + $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); + } + } + + void OnNewBlockDetected(object? _, NewBlockEventArgs e) + { + if (e.Height >= txFirstSeenInBlock + 5) + tsc.TrySetResult(true); + } + + _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; + + // Send funds to our wallet + await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address.Address, NBitcoin.Network.RegTest), + new Money(1, MoneyUnit.BTC), TestContext.Current.CancellationToken); + + // Mine blocks to confirm + await bitcoin.GenerateToAddressAsync( + 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), + TestContext.Current.CancellationToken); + + // wait for funding transaction to be confirmed + Assert.True(await tsc.Task); + _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; + } + + // Verify we have balance + var utxoRepository = _serviceProvider.GetRequiredService(); + var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); + Assert.True(balance > LightningMoney.Zero); + + // Connect to Alice + var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host + .SplitOnFirst("//")[1] + .SplitOnFirst(":")[0], + TestContext.Current.CancellationToken)).First(), + 9735); + var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; + + await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); + + Task openChannelTask; + // Open channel - using the client handler + using (var scope = _serviceProvider.CreateScope()) + { + var clientHandler = scope.ServiceProvider.GetService( + typeof(IClientCommandHandler)) as + OpenChannelClientHandler ?? + throw new InvalidOperationException( + $"Unable to get service {nameof(OpenChannelClientHandler)}"); + var request = new OpenChannelClientRequest( + aliceAddress, + LightningMoney.Satoshis(1000000) // 0.01 BTC, + ) + { + FeeRatePerKw = LightningMoney.Satoshis(10000) + }; + + // Act - Open the channel (this should send open_channel and wait for the flow to complete) + openChannelTask = clientHandler.HandleAsync(request, TestContext.Current.CancellationToken); + } + + var channelResponse = await openChannelTask; + Assert.NotNull(channelResponse); + + var channelOpen = false; + while (!channelOpen) + { + Task openChannelSubscriptionTask; + // Open channel subscription - using the client handler + using (var scope = _serviceProvider.CreateScope()) + { + var subscriptionClientHandler = scope.ServiceProvider.GetService( + typeof(IClientCommandHandler< + OpenChannelClientSubscriptionRequest, + OpenChannelClientSubscriptionResponse>)) as + OpenChannelClientSubscriptionHandler ?? + throw new InvalidOperationException( + $"Unable to get service {nameof(OpenChannelClientSubscriptionHandler)}"); + var request = new OpenChannelClientSubscriptionRequest(channelResponse.ChannelId); + + // Act - Open the channel (this should send open_channel and wait for the first response) + openChannelSubscriptionTask = + subscriptionClientHandler.HandleAsync(request, TestContext.Current.CancellationToken); + } + + var channelSubscriptionResponse = await openChannelSubscriptionTask; + Assert.NotNull(channelSubscriptionResponse); + + if (channelSubscriptionResponse.ChannelState == ChannelState.V1FundingSigned) + { + // Mine blocks to confirm + _ = bitcoin.GenerateToAddressAsync( + 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), + TestContext.Current.CancellationToken); + } + else if (channelSubscriptionResponse.ChannelState is ChannelState.ReadyForThem or ChannelState.ReadyForUs + or ChannelState.Open) + { + channelOpen = true; + } + } + + // Check if the channel exists (temporary or permanent) + var allChannels = _channelMemoryRepository.FindChannels(_ => true); + + Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); + } + + [Fact] + public async Task GivenMultipleP2TRInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() + { + // Arrange + var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; + var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes + .First(x => x.LocalAlias == "alice"); + Assert.NotNull(alice); + + await _peerManager.StartAsync(TestContext.Current.CancellationToken); + + // Get the current block height + var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); + + // Start the blockchain monitor at the current height + await _blockchainMonitor.StartAsync(currentHeight, TestContext.Current.CancellationToken); + + // Fund our wallet + using (var scope = _serviceProvider.CreateScope()) + { + var walletService = scope.ServiceProvider + .GetRequiredService(); + + // Subscribe to blockchain monitor events + TaskCompletionSource tsc = new(); + uint txFirstSeenInBlock = int.MaxValue; + + var address1 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Tr, false); + var address2 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Tr, true); + + void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) + { + if (e.Amount == LightningMoney.Satoshis(1100000) && e.WalletAddress == address1.Address) + { + txFirstSeenInBlock = e.BlockHeight; + } + else + { + Assert.Fail("Unexpected wallet movement detected: " + + $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); + } + } + + void OnNewBlockDetected(object? _, NewBlockEventArgs e) + { + if (e.Height >= txFirstSeenInBlock + 5) + tsc.TrySetResult(true); + } + + _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; + + // Send funds to our wallet + await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address1.Address, NBitcoin.Network.RegTest), + new Money(1100000, MoneyUnit.Satoshi), + TestContext.Current.CancellationToken); // 0.011 + await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address2.Address, NBitcoin.Network.RegTest), + new Money(1100000, MoneyUnit.Satoshi), + TestContext.Current.CancellationToken); // 0.011 + + // Mine blocks to confirm + await bitcoin.GenerateToAddressAsync( + 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), + TestContext.Current.CancellationToken); + + // wait for funding transaction to be confirmed + Assert.True(await tsc.Task); + _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; + } + + // Verify we have balance + var utxoRepository = _serviceProvider.GetRequiredService(); + var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); + Assert.True(balance > LightningMoney.Zero); + + // Connect to Alice + var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host + .SplitOnFirst("//")[1] + .SplitOnFirst(":")[0], + TestContext.Current.CancellationToken)).First(), + 9735); + var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; + + await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); + + Task openChannelTask; + // Open channel - using the client handler + using (var scope = _serviceProvider.CreateScope()) + { + var clientHandler = scope.ServiceProvider.GetService( + typeof(IClientCommandHandler)) as + OpenChannelClientHandler ?? + throw new InvalidOperationException( + $"Unable to get service {nameof(OpenChannelClientHandler)}"); + var request = new OpenChannelClientRequest( + aliceAddress, + LightningMoney.Satoshis(2000000) // 0.02 BTC, + ) + { + FeeRatePerKw = LightningMoney.Satoshis(10000) + }; + + // Act - Open the channel (this should send open_channel and wait for the flow to complete) + openChannelTask = clientHandler.HandleAsync(request, TestContext.Current.CancellationToken); + } + + var channelResponse = await openChannelTask; + Assert.NotNull(channelResponse); + + var channelOpen = false; + while (!channelOpen) + { + Task openChannelSubscriptionTask; + // Open channel subscription - using the client handler + using (var scope = _serviceProvider.CreateScope()) + { + var subscriptionClientHandler = scope.ServiceProvider.GetService( + typeof(IClientCommandHandler< + OpenChannelClientSubscriptionRequest, + OpenChannelClientSubscriptionResponse>)) as + OpenChannelClientSubscriptionHandler ?? + throw new InvalidOperationException( + $"Unable to get service {nameof(OpenChannelClientSubscriptionHandler)}"); + var request = new OpenChannelClientSubscriptionRequest(channelResponse.ChannelId); + + // Act - Open the channel (this should send open_channel and wait for the first response) + openChannelSubscriptionTask = + subscriptionClientHandler.HandleAsync(request, TestContext.Current.CancellationToken); + } + + var channelSubscriptionResponse = await openChannelSubscriptionTask; + Assert.NotNull(channelSubscriptionResponse); + + if (channelSubscriptionResponse.ChannelState == ChannelState.V1FundingSigned) + { + // Mine blocks to confirm + _ = bitcoin.GenerateToAddressAsync( + 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), + TestContext.Current.CancellationToken); + } + else if (channelSubscriptionResponse.ChannelState is ChannelState.ReadyForThem or ChannelState.ReadyForUs + or ChannelState.Open) + { + channelOpen = true; + } + } + + // Check if the channel exists (temporary or permanent) + var allChannels = _channelMemoryRepository.FindChannels(_ => true); + + Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); + } + + [Fact] + public async Task GivenMixedInput_WhenHandleAsyncIsCalled_ChannelOpensCorrectly() + { + // Arrange + var bitcoin = _lightningRegtestNetworkFixture.Builder!.BitcoinRpcClient!; + var alice = _lightningRegtestNetworkFixture.Builder.LNDNodePool!.ReadyNodes + .First(x => x.LocalAlias == "alice"); + Assert.NotNull(alice); + + await _peerManager.StartAsync(TestContext.Current.CancellationToken); + + // Get the current block height + var currentHeight = (uint)await bitcoin.GetBlockCountAsync(TestContext.Current.CancellationToken); + + // Start the blockchain monitor at the current height + await _blockchainMonitor.StartAsync(currentHeight, TestContext.Current.CancellationToken); + + // Fund our wallet + using (var scope = _serviceProvider.CreateScope()) + { + var walletService = scope.ServiceProvider + .GetRequiredService(); + + // Subscribe to blockchain monitor events + TaskCompletionSource tsc = new(); + uint txFirstSeenInBlock = int.MaxValue; + + var address1 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Wpkh, false); + var address2 = await walletService.GetUnusedAddressAsync(Domain.Bitcoin.Enums.AddressType.P2Tr, false); + + void OnWalletMovementDetected(object? _, WalletMovementEventArgs e) + { + if (e.Amount == LightningMoney.Satoshis(1100000) && e.WalletAddress == address1.Address) + { + txFirstSeenInBlock = e.BlockHeight; + } + else + { + Assert.Fail("Unexpected wallet movement detected: " + + $"Address={e.WalletAddress}, Amount={e.Amount}, TxId={e.TxId}, BlockHeight={e.BlockHeight}"); + } + } + + void OnNewBlockDetected(object? _, NewBlockEventArgs e) + { + if (e.Height >= txFirstSeenInBlock + 5) + tsc.TrySetResult(true); + } + + _blockchainMonitor.OnWalletMovementDetected += OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected += OnNewBlockDetected; + + // Send funds to our wallet + await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address1.Address, NBitcoin.Network.RegTest), + new Money(1100000, MoneyUnit.Satoshi), + TestContext.Current.CancellationToken); // 0.011 + await bitcoin.SendToAddressAsync(BitcoinAddress.Create(address2.Address, NBitcoin.Network.RegTest), + new Money(1100000, MoneyUnit.Satoshi), + TestContext.Current.CancellationToken); // 0.011 + + // Mine blocks to confirm + await bitcoin.GenerateToAddressAsync( + 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), + TestContext.Current.CancellationToken); + + // wait for funding transaction to be confirmed + Assert.True(await tsc.Task); + _blockchainMonitor.OnWalletMovementDetected -= OnWalletMovementDetected; + _blockchainMonitor.OnNewBlockDetected -= OnNewBlockDetected; + } + + // Verify we have balance + var utxoRepository = _serviceProvider.GetRequiredService(); + var balance = utxoRepository.GetConfirmedBalance(_blockchainMonitor.LastProcessedBlockHeight); + Assert.True(balance > LightningMoney.Zero); + + // Connect to Alice + var aliceHost = new IPEndPoint((await Dns.GetHostAddressesAsync(alice.Host + .SplitOnFirst("//")[1] + .SplitOnFirst(":")[0], + TestContext.Current.CancellationToken)).First(), + 9735); + var aliceAddress = $"{Convert.ToHexString(alice.LocalNodePubKeyBytes)}@{aliceHost}"; + + await _peerManager.ConnectToPeerAsync(new PeerAddressInfo(aliceAddress)); + + Task openChannelTask; + // Open channel - using the client handler + using (var scope = _serviceProvider.CreateScope()) + { + var clientHandler = scope.ServiceProvider.GetService( + typeof(IClientCommandHandler)) as + OpenChannelClientHandler ?? + throw new InvalidOperationException( + $"Unable to get service {nameof(OpenChannelClientHandler)}"); + var request = new OpenChannelClientRequest( + aliceAddress, + LightningMoney.Satoshis(2000000) // 0.02 BTC, + ) + { + FeeRatePerKw = LightningMoney.Satoshis(10000) + }; + + // Act - Open the channel (this should send open_channel and wait for the flow to complete) + openChannelTask = clientHandler.HandleAsync(request, TestContext.Current.CancellationToken); + } + + var channelResponse = await openChannelTask; + Assert.NotNull(channelResponse); + + var channelOpen = false; + while (!channelOpen) + { + Task openChannelSubscriptionTask; + // Open channel subscription - using the client handler + using (var scope = _serviceProvider.CreateScope()) + { + var subscriptionClientHandler = scope.ServiceProvider.GetService( + typeof(IClientCommandHandler< + OpenChannelClientSubscriptionRequest, + OpenChannelClientSubscriptionResponse>)) as + OpenChannelClientSubscriptionHandler ?? + throw new InvalidOperationException( + $"Unable to get service {nameof(OpenChannelClientSubscriptionHandler)}"); + var request = new OpenChannelClientSubscriptionRequest(channelResponse.ChannelId); + + // Act - Open the channel (this should send open_channel and wait for the first response) + openChannelSubscriptionTask = + subscriptionClientHandler.HandleAsync(request, TestContext.Current.CancellationToken); + } + + var channelSubscriptionResponse = await openChannelSubscriptionTask; + Assert.NotNull(channelSubscriptionResponse); + + if (channelSubscriptionResponse.ChannelState == ChannelState.V1FundingSigned) + { + // Mine blocks to confirm + _ = bitcoin.GenerateToAddressAsync( + 6, await bitcoin.GetNewAddressAsync(TestContext.Current.CancellationToken), + TestContext.Current.CancellationToken); + } + else if (channelSubscriptionResponse.ChannelState is ChannelState.ReadyForThem or ChannelState.ReadyForUs + or ChannelState.Open) + { + channelOpen = true; + } + } + + // Check if the channel exists (temporary or permanent) + var allChannels = _channelMemoryRepository.FindChannels(_ => true); + + Assert.True(allChannels.Count > 0, "Expected at least one channel to be created"); + } + + public void Dispose() + { + _blockchainMonitor.StopAsync().GetAwaiter().GetResult(); + PortPoolUtil.ReleasePort(_port); + if (File.Exists(_databaseFilePath)) + { + try + { + File.Delete(_databaseFilePath); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to delete database file: {ex.Message}"); + } + } + } +} \ No newline at end of file