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 4074123b..a07cd827 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", "{A3C18FCE-8C13-4B2F-BD9E-82131750C716}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Release|Any CPU = Release|Any CPU @@ -360,6 +368,54 @@ 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 + {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 @@ -397,5 +453,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} + {A3C18FCE-8C13-4B2F-BD9E-82131750C716} = {123D0631-533C-447F-B7DA-D1D37E5E64BF} EndGlobalSection EndGlobal diff --git a/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs b/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs new file mode 100644 index 00000000..dd21fd67 --- /dev/null +++ b/src/NLightning.Application/Channels/Handlers/AcceptChannel1MessageHandler.cs @@ -0,0 +1,244 @@ +using Microsoft.Extensions.Logging; +using NLightning.Domain.Persistence.Interfaces; + +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.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) + { + if (_logger.IsEnabled(LogLevel.Trace)) + _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 null) + throw new ChannelErrorException("Channel type was 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, payload.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); + + // Keep the oldChannelId for later + var oldChannelId = tempChannel.ChannelId; + + 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 + 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()); + + // Generate the base commitment transactions + var remoteCommitmentTransaction = + _commitmentTransactionModelFactory.CreateCommitmentTransactionModel(tempChannel, CommitmentSide.Remote); + + // Build the output and the transactions + var remoteUnsignedCommitmentTransaction = _commitmentTransactionBuilder.Build(remoteCommitmentTransaction); + + // Sign their 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); + + // Create the funding created message + var fundingCreatedMessage = + _messageFactory.CreateFundingCreatedMessage(oldChannelId, fundingOutput.TransactionId.Value, + fundingOutput.Index.Value, ourSignature); + + // Upgrade the channel in the dictionary + _channelMemoryRepository.UpgradeChannel(oldChannelId, tempChannel); + + // Update the locked utxos + _utxoMemoryRepository.UpgradeChannelIdOnLockedUtxos(oldChannelId, tempChannel.ChannelId); + + return fundingCreatedMessage; + } + catch (Exception e) + { + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation("Forgetting channel {channelId}", tempChannel.ChannelId); + + 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); + } + + 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/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..0745fcc7 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,21 +95,26 @@ 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); // Remove the temporary channel - _channelMemoryRepository.RemoveTemporaryChannel(peerPubKey, oldChannelId); + _channelMemoryRepository.TryRemoveTemporaryChannel(peerPubKey, oldChannelId); await _blockchainMonitor.WatchTransactionAsync(channel.ChannelId, payload.FundingTxId, channel.ChannelConfig.MinimumDepth); @@ -124,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 new file mode 100644 index 00000000..030dc372 --- /dev/null +++ b/src/NLightning.Application/Channels/Handlers/FundingSignedMessageHandler.cs @@ -0,0 +1,141 @@ +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 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, + IChannelMemoryRepository channelMemoryRepository, + ICommitmentTransactionBuilder commitmentTransactionBuilder, + ICommitmentTransactionModelFactory commitmentTransactionModelFactory, + IFundingTransactionBuilder fundingTransactionBuilder, + IFundingTransactionModelFactory fundingTransactionModelFactory, + ILightningSigner lightningSigner, ILogger logger, + IUnitOfWork unitOfWork, IUtxoMemoryRepository utxoMemoryRepository) + { + _blockchainMonitor = blockchainMonitor; + _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) + { + if (_logger.IsEnabled(LogLevel.Trace)) + _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 signature + channel.UpdateLastReceivedSignature(payload.Signature); + + // 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"); + + // 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 a scoped Unit of Work + /// + private async Task PersistChannelAsync(ChannelModel channel) + { + try + { + // 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(); + + if (_logger.IsEnabled(LogLevel.Debug)) + _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/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/Channels/Managers/ChannelManager.cs b/src/NLightning.Application/Channels/Managers/ChannelManager.cs index f74a88d4..b9fb8456 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 @@ -111,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"); } @@ -143,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); } @@ -275,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) @@ -288,7 +307,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 9dc6cd4b..5da3e0fd 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; @@ -8,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; @@ -15,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; @@ -60,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(); @@ -115,29 +129,31 @@ public async Task StopAsync() /// /// Thrown when the connection to the peer fails. - public async Task ConnectToPeerAsync(PeerAddressInfo peerAddressInfo) + /// Thrown when the connection to the peer already exists. + 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; } /// - 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 { @@ -145,26 +161,52 @@ public void DisconnectPeer(CompactPubKey pubKey) } } - private async Task ConnectToPeerAsync(PeerAddressInfo peerAddressInfo, IUnitOfWork uow) + public List ListPeers() + { + return _peers.Values.ToList(); + } + + public PeerModel? GetPeer(CompactPubKey peerId) { + 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 + 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); 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 }; @@ -172,7 +214,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,12 +230,33 @@ 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); + 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(); + } + _peers.Add(peerService.PeerPubKey, peer); } catch (Exception e) @@ -211,6 +276,7 @@ private void HandlePeerDisconnection(object? sender, PeerDisconnectedEventArgs a { peerService.OnDisconnect -= HandlePeerDisconnection; peerService.OnChannelMessageReceived -= HandlePeerChannelMessage; + peerService.Dispose(); } else { @@ -256,7 +322,7 @@ private async Task HandleChannelMessageResponseAsync(Task task ? cee.PeerMessage : cee.Message); - DisconnectPeer(peerService.PeerPubKey); + DisconnectPeer(peerService, cee); return; } @@ -269,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; } @@ -276,15 +353,13 @@ 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; } 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.Application/Protocol/Factories/MessageFactory.cs b/src/NLightning.Application/Protocol/Factories/MessageFactory.cs index c269e717..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); } /// @@ -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/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 new file mode 100644 index 00000000..e3c0917f --- /dev/null +++ b/src/NLightning.Client/Ipc/NamedPipeIpcClient.cs @@ -0,0 +1,279 @@ +using System.Buffers; +using System.IO.Pipes; +using MessagePack; +using NLightning.Domain.Channels.ValueObjects; + +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; +using Transport.Ipc.Responses; + +public sealed class NamedPipeIpcClient : 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 NodeInfoIpcRequest(); + var payload = MessagePackSerializer.Serialize(req, cancellationToken: ct); + var env = new IpcEnvelope + { + Version = 1, + Command = ClientCommand.NodeInfo, + 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}"); + } + + 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 = ClientCommand.ConnectPeer, + 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}"); + } + + 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 = ClientCommand.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); + 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 = ClientCommand.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}"); + } + + public async Task GetWalletBalance(CancellationToken ct) + { + var req = new WalletBalanceIpcRequest(); + var payload = MessagePackSerializer.Serialize(req, cancellationToken: ct); + var env = new IpcEnvelope + { + Version = 1, + Command = ClientCommand.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}"); + } + + 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}"); + } + + 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 = + 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..fdc59d57 --- /dev/null +++ b/src/NLightning.Client/NLightning.Client.csproj @@ -0,0 +1,17 @@ + + + + Exe + + + + + + + + + + + + + diff --git a/src/NLightning.Client/Printers/ConnectPeerPrinter.cs b/src/NLightning.Client/Printers/ConnectPeerPrinter.cs new file mode 100644 index 00000000..5fb239b0 --- /dev/null +++ b/src/NLightning.Client/Printers/ConnectPeerPrinter.cs @@ -0,0 +1,17 @@ +namespace NLightning.Client.Printers; + +using Transport.Ipc.Responses; + +public sealed class ConnectPeerPrinter : IPrinter +{ + public void Print(ConnectPeerIpcResponse item) + { + Console.WriteLine("Connected to Peer:"); + 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/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/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/ListPeersPrinter.cs b/src/NLightning.Client/Printers/ListPeersPrinter.cs new file mode 100644 index 00000000..447bec78 --- /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: {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("----------------------------------------------------------------------------------"); + } + } + } +} \ 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..9ba9ddca --- /dev/null +++ b/src/NLightning.Client/Printers/NodeInfoPrinter.cs @@ -0,0 +1,27 @@ +namespace NLightning.Client.Printers; + +using NLightning.Transport.Ipc.Responses; + +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); + if (item.BestBlockTime is not null) + Console.WriteLine($" Best Block Time: {item.BestBlockTime:O}"); + 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/OpenChannelPrinter.cs b/src/NLightning.Client/Printers/OpenChannelPrinter.cs new file mode 100644 index 00000000..1c75a23c --- /dev/null +++ b/src/NLightning.Client/Printers/OpenChannelPrinter.cs @@ -0,0 +1,12 @@ +namespace NLightning.Client.Printers; + +using Transport.Ipc.Responses; + +public sealed class OpenChannelPrinter : IPrinter +{ + public void Print(OpenChannelIpcResponse item) + { + 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/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 new file mode 100644 index 00000000..307fe3e9 --- /dev/null +++ b/src/NLightning.Client/Program.cs @@ -0,0 +1,85 @@ +using MessagePack; +using NLightning.Client.Handlers; +using NLightning.Client.Ipc; +using NLightning.Client.Printers; +using NLightning.Client.Utils; +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) => +{ + e.Cancel = true; + cts.Cancel(); +}; + +// Get network for the NamedPipe file path +var cookiePath = CommandLineHelper.GetCookiePath(args); +var namedPipeFilePath = NodeUtils.GetNamedPipeFilePath(cookiePath); +var cookieFilePath = NodeUtils.GetCookieFilePath(cookiePath); + +var cmd = CommandLineHelper.GetCommand(args) ?? "node-info"; + +try +{ + if (CommandLineHelper.IsHelpRequested(args)) + { + ClientUtils.ShowUsage(); + return 0; + } + + await using var client = new NamedPipeIpcClient(namedPipeFilePath, cookieFilePath); + + var commandArgs = CommandLineHelper.GetCommandArguments(cmd, args); + + switch (cmd) + { + case "info": + case "node-info": + var info = await client.GetNodeInfoAsync(cts.Token); + new NodeInfoPrinter().Print(info); + break; + case "connect": + case "connect-peer": + if (commandArgs.Length == 0) + Console.Error.WriteLine("No arguments specified."); + 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; + case "getaddress": + case "get-address": + 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; + case "openchannel": + case "open-channel": + OpenChannelMessageHandler.HandleAsync(commandArgs, client, cts.Token).GetAwaiter().GetResult(); + 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; \ 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..363af26b --- /dev/null +++ b/src/NLightning.Client/Utils/ClientUtils.cs @@ -0,0 +1,30 @@ +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(" --help, -h, -? Show this help message"); + Console.WriteLine(); + Console.WriteLine("Commands:"); + 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"); + 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/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/NodeInfoResponse.cs b/src/NLightning.Daemon.Contracts/Control/NodeInfoResponse.cs new file mode 100644 index 00000000..4908e071 --- /dev/null +++ b/src/NLightning.Daemon.Contracts/Control/NodeInfoResponse.cs @@ -0,0 +1,16 @@ +namespace NLightning.Daemon.Contracts.Control; + +/// +/// Transport-agnostic response for NodeInfo command. +/// +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; } + 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..80ca8161 --- /dev/null +++ b/src/NLightning.Daemon.Contracts/Helpers/CommandLineHelper.cs @@ -0,0 +1,151 @@ +namespace NLightning.Daemon.Contracts.Helpers; + +/// +/// Helper class for displaying command line usage information +/// +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(DashDashHelp, StringComparison.OrdinalIgnoreCase) + || arg.Equals(DashH, StringComparison.OrdinalIgnoreCase)); + } + + public static string? GetCommand(string[] args) + { + for (var i = 0; i < args.Length; i++) + { + if (IsOption(args[i])) + { + i++; + 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 GetCookiePath(string[] args) + { + string? network = null; + string? cookiePath = null; + + // Check command line args + for (var i = 0; i < args.Length; i++) + { + // Check for network + if (args[i].StartsWith(DashN) || args[i].StartsWith(DashDashNetwork, StringComparison.OrdinalIgnoreCase)) + { + 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; + } + } + + // Check the environment if no args provided + if (cookiePath is null && network is null) + { + var envNetwork = Environment.GetEnvironmentVariable("NLTG_NETWORK"); + if (!string.IsNullOrEmpty(envNetwork)) + { + network = envNetwork; + } + else + { + var envCookie = Environment.GetEnvironmentVariable("NLTG_COOKIE"); + if (!string.IsNullOrEmpty(envCookie)) + { + cookiePath = envCookie; + } + } + } + + // 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)) + { + cookiePath = Path.GetDirectoryName(cookiePath) ?? + throw new InvalidOperationException("Cookie not found"); + } + else if (cookiePath.EndsWith(Path.DirectorySeparatorChar)) + cookiePath = cookiePath[..^1]; + + return Directory.Exists(cookiePath) ? cookiePath : throw new InvalidOperationException("Cookie not found"); + } + + 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.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..c21bf538 --- /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..0cc2cfa2 --- /dev/null +++ b/src/NLightning.Daemon.Contracts/Utilities/NodeUtils.cs @@ -0,0 +1,22 @@ +namespace NLightning.Daemon.Contracts.Utilities; + +using Constants; + +public static class NodeUtils +{ + /// + /// Gets the path for the Named-Pipe file + /// + public static string GetNamedPipeFilePath(string cookiePath) + { + return Path.Combine(cookiePath, NodeConstants.NamedPipeFile); + } + + /// + /// Gets the path for the Named-Pipe file + /// + public static string GetCookieFilePath(string cookiePath) + { + return Path.Combine(cookiePath, 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.Daemon/AssemblyInfo.cs b/src/NLightning.Daemon/AssemblyInfo.cs new file mode 100644 index 00000000..0fdf52b3 --- /dev/null +++ b/src/NLightning.Daemon/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("NLightning.Bolts.Tests")] +[assembly: InternalsVisibleTo("NLightning.Integration.Tests")] \ No newline at end of file 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 79% rename from src/NLightning.Node/Extensions/NodeConfigurationExtensions.cs rename to src/NLightning.Daemon/Extensions/NodeConfigurationExtensions.cs index 861e8502..0ebcb719 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; @@ -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!); } /// @@ -151,7 +163,6 @@ private static string CreateDefaultConfigJson() "0.0.0.0:9735" ], "Features": { - "StaticRemoteKey": "Compulsory" } }, "FeeEstimation": { @@ -166,7 +177,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.Node/Extensions/NodeServiceExtensions.cs b/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs similarity index 51% rename from src/NLightning.Node/Extensions/NodeServiceExtensions.cs rename to src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs index 3f9643f1..ad4c49e2 100644 --- a/src/NLightning.Node/Extensions/NodeServiceExtensions.cs +++ b/src/NLightning.Daemon/Extensions/NodeServiceExtensions.cs @@ -3,37 +3,48 @@ 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 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; +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 { /// /// 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) => { @@ -46,6 +57,41 @@ public static IHostBuilder ConfigureNltgServices(this IHostBuilder hostBuilder, // Register the main daemon service services.AddHostedService(); + // Register Client Handlers + services + .AddScoped, + OpenChannelClientHandler>(); + services + .AddScoped, + OpenChannelClientSubscriptionHandler>(); + + // Register IPC server and handlers + services.AddSingleton(sp => + { + var ipcAuthenticator = sp.GetRequiredService(); + var ipcFraming = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + var ipcRequestRouter = sp.GetRequiredService(); + return new NamedPipeIpcService(ipcAuthenticator, configPath, ipcFraming, logger, ipcRequestRouter); + }); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => + { + var cookiePath = NodeUtils.GetCookieFilePath(configPath); + var logger = sp.GetRequiredService>(); + return new CookieFileAuthenticator(cookiePath, logger); + }); + // Add HttpClient for FeeService with configuration services.AddHttpClient(client => { @@ -54,16 +100,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 => @@ -72,10 +127,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.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/OpenChannelClientHandler.cs b/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs new file mode 100644 index 00000000..48074607 --- /dev/null +++ b/src/NLightning.Daemon/Handlers/OpenChannelClientHandler.cs @@ -0,0 +1,230 @@ +using Microsoft.Extensions.Logging; + +namespace NLightning.Daemon.Handlers; + +using Domain.Bitcoin.Interfaces; +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.Enums; +using Domain.Exceptions; +using Domain.Node; +using Domain.Node.Events; +using Domain.Node.Interfaces; +using Domain.Node.ValueObjects; +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 IUtxoMemoryRepository _utxoMemoryRepository; + + 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, IUtxoMemoryRepository utxoMemoryRepository) + { + _blockchainMonitor = blockchainMonitor; + _channelFactory = channelFactory; + _channelMemoryRepository = channelMemoryRepository; + _logger = logger; + _messageFactory = messageFactory; + _peerManager = peerManager; + _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; + + 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) + ?? 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); + + // 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 + { + // Add the channel to dictionaries + _channelMemoryRepository.AddTemporaryChannel(peerId, channel); + + // Create the channel type Tlv + var channelTypeFeatureSet = FeatureSet.NewBasicChannelType(); + if (peer.NegotiatedFeatures.OptionAnchors >= FeatureSupport.Optional) + channelTypeFeatureSet.SetFeature(Feature.OptionAnchors, true); + + if (channel.ChannelConfig.UseScidAlias >= FeatureSupport.Optional) + channelTypeFeatureSet.SetFeature(Feature.OptionScidAlias, true); + + 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 channelTypeTlv = new ChannelTypeTlv(featureSetBytes); + + // Create UpfrontShutdownScriptTlv if needed + 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); + if (peer.NegotiatedFeatures.ScidAlias == FeatureSupport.Compulsory) + channelFlags = new ChannelFlags(ChannelFlag.AnnounceChannel); + + // 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, channelTypeTlv, upfrontShutdownScriptTlv); + + if (!peer.TryGetPeerService(out _peerService)) + throw new ClientException(ErrorCodes.InvalidOperation, "Error getting peerService from peer"); + + // 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 + { + _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 HandleChannelUpgraded(ChannelUpgradedEventArgs args, + TaskCompletionSource tsc) + { + 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 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; + + _logger.LogError("Peer disconnected without notice"); + tsc.TrySetException(new ConnectionException("Error opening channel: Peer disconnected")); + } + + 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); + } +} \ 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..38cae613 --- /dev/null +++ b/src/NLightning.Daemon/Handlers/OpenChannelClientSubscriptionHandler.cs @@ -0,0 +1,199 @@ +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)) + 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"); + + // 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 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 + { + 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 (!_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; + } + 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 + { + // 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)); + } + } + + 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.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/IClientCommandHandler.cs b/src/NLightning.Daemon/Interfaces/IClientCommandHandler.cs new file mode 100644 index 00000000..796ba41f --- /dev/null +++ b/src/NLightning.Daemon/Interfaces/IClientCommandHandler.cs @@ -0,0 +1,23 @@ +namespace NLightning.Daemon.Interfaces; + +using NLightning.Domain.Client.Enums; + +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/Interfaces/INodeInfoQueryService.cs b/src/NLightning.Daemon/Interfaces/INodeInfoQueryService.cs new file mode 100644 index 00000000..4eea1a56 --- /dev/null +++ b/src/NLightning.Daemon/Interfaces/INodeInfoQueryService.cs @@ -0,0 +1,8 @@ +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/Ipc/Handlers/ConnectPeerIpcHandler.cs b/src/NLightning.Daemon/Ipc/Handlers/ConnectPeerIpcHandler.cs new file mode 100644 index 00000000..fc9524cc --- /dev/null +++ b/src/NLightning.Daemon/Ipc/Handlers/ConnectPeerIpcHandler.cs @@ -0,0 +1,93 @@ +using MessagePack; +using Microsoft.Extensions.Logging; + +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.Requests; +using Transport.Ipc.Responses; + +internal sealed class ConnectPeerIpcHandler : IIpcCommandHandler +{ + private readonly ILogger _logger; + private readonly IPeerManager _peerManager; + + public ClientCommand Command => ClientCommand.ConnectPeer; + + public ConnectPeerIpcHandler(ILogger logger, IPeerManager peerManager) + { + _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/Ipc/Handlers/GetAddressIpcHandler.cs b/src/NLightning.Daemon/Ipc/Handlers/GetAddressIpcHandler.cs new file mode 100644 index 00000000..fc09b0c4 --- /dev/null +++ b/src/NLightning.Daemon/Ipc/Handlers/GetAddressIpcHandler.cs @@ -0,0 +1,78 @@ +using MessagePack; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +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.Requests; +using Transport.Ipc.Responses; + +internal class GetAddressIpcHandler : IIpcCommandHandler +{ + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + + public ClientCommand Command => ClientCommand.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)).Address; + + if (request.AddressType.HasFlag(AddressType.P2Wpkh)) + p2Wpkh = (await walletAddressService.GetUnusedAddressAsync(AddressType.P2Wpkh, false)).Address; + + // 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/Ipc/Handlers/GetWalletBalanceIpcHandler.cs b/src/NLightning.Daemon/Ipc/Handlers/GetWalletBalanceIpcHandler.cs new file mode 100644 index 00000000..c3ae9ee3 --- /dev/null +++ b/src/NLightning.Daemon/Ipc/Handlers/GetWalletBalanceIpcHandler.cs @@ -0,0 +1,64 @@ +using MessagePack; +using Microsoft.Extensions.Logging; + +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.Responses; + +internal class GetWalletBalanceIpcHandler : IIpcCommandHandler +{ + private readonly IBlockchainMonitor _blockchainMonitor; + private readonly ILogger _logger; + private readonly IUtxoMemoryRepository _utxoMemoryRepository; + public ClientCommand Command => ClientCommand.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.Daemon/Ipc/Handlers/ListPeersIpcHandler.cs b/src/NLightning.Daemon/Ipc/Handlers/ListPeersIpcHandler.cs new file mode 100644 index 00000000..9a143094 --- /dev/null +++ b/src/NLightning.Daemon/Ipc/Handlers/ListPeersIpcHandler.cs @@ -0,0 +1,67 @@ +using MessagePack; +using Microsoft.Extensions.Logging; + +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.Responses; + +internal class ListPeersIpcHandler : IIpcCommandHandler +{ + private readonly IPeerManager _peerManager; + private readonly ILogger _logger; + + public ClientCommand Command => ClientCommand.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/Ipc/Handlers/NodeInfoIpcHandler.cs b/src/NLightning.Daemon/Ipc/Handlers/NodeInfoIpcHandler.cs new file mode 100644 index 00000000..169f66ed --- /dev/null +++ b/src/NLightning.Daemon/Ipc/Handlers/NodeInfoIpcHandler.cs @@ -0,0 +1,60 @@ +using MessagePack; +using Microsoft.Extensions.Logging; + +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.Responses; + +internal sealed class NodeInfoIpcHandler : IIpcCommandHandler +{ + private readonly INodeInfoQueryService _query; + private readonly ILogger _logger; + + public ClientCommand Command => ClientCommand.NodeInfo; + + public NodeInfoIpcHandler(INodeInfoQueryService query, ILogger logger) + { + _query = query; + _logger = logger; + } + + public async Task HandleAsync(IpcEnvelope envelope, CancellationToken ct) + { + try + { + 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, + 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) + { + _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/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/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/Ipc/Interfaces/IIpcAuthenticator.cs b/src/NLightning.Daemon/Ipc/Interfaces/IIpcAuthenticator.cs new file mode 100644 index 00000000..4e9d7efc --- /dev/null +++ b/src/NLightning.Daemon/Ipc/Interfaces/IIpcAuthenticator.cs @@ -0,0 +1,6 @@ +namespace NLightning.Daemon.Ipc.Interfaces; + +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/Ipc/Interfaces/IIpcFraming.cs b/src/NLightning.Daemon/Ipc/Interfaces/IIpcFraming.cs new file mode 100644 index 00000000..56eaaf2b --- /dev/null +++ b/src/NLightning.Daemon/Ipc/Interfaces/IIpcFraming.cs @@ -0,0 +1,9 @@ +namespace NLightning.Daemon.Ipc.Interfaces; + +using Transport.Ipc; + +internal 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/Ipc/Interfaces/IIpcRequestRouter.cs b/src/NLightning.Daemon/Ipc/Interfaces/IIpcRequestRouter.cs new file mode 100644 index 00000000..c7649724 --- /dev/null +++ b/src/NLightning.Daemon/Ipc/Interfaces/IIpcRequestRouter.cs @@ -0,0 +1,8 @@ +namespace NLightning.Daemon.Ipc.Interfaces; + +using Transport.Ipc; + +internal interface IIpcRequestRouter +{ + Task RouteAsync(IpcEnvelope request, 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 89% rename from src/NLightning.Node/NLightning.Node.csproj rename to src/NLightning.Daemon/NLightning.Daemon.csproj index 05a9c843..0faf968c 100644 --- a/src/NLightning.Node/NLightning.Node.csproj +++ b/src/NLightning.Daemon/NLightning.Daemon.csproj @@ -29,10 +29,13 @@ + + + @@ -40,6 +43,7 @@ + diff --git a/src/NLightning.Node/Program.cs b/src/NLightning.Daemon/Program.cs similarity index 81% rename from src/NLightning.Node/Program.cs rename to src/NLightning.Daemon/Program.cs index 2b6e5034..637353a0 100644 --- a/src/NLightning.Node/Program.cs +++ b/src/NLightning.Daemon/Program.cs @@ -1,16 +1,19 @@ +using MessagePack; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; 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 NLightning.Transport.Ipc.MessagePack; using Serilog; try @@ -26,19 +29,21 @@ 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 (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,13 +52,10 @@ // Check if help is requested if (CommandLineHelper.IsHelpRequested(args)) { - CommandLineHelper.ShowUsage(); + DaemonUtils.ShowUsage(); 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 @@ -76,15 +78,15 @@ } 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(); + var walletLogger = loggerFactory.CreateLogger(); // Bind options from initialConfig var bitcoinOptions = initialConfig.GetSection("Bitcoin").Get() @@ -94,13 +96,11 @@ ?? throw new InvalidOperationException("Node configuration section is missing or invalid."); // Instantiate the service - var bitcoinWalletService = new BitcoinWalletService( - 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(); @@ -128,12 +128,15 @@ return 0; } + // Register the default formatter for MessagePackSerializer + MessagePackSerializer.DefaultOptions = NLightningMessagePackOptions.Options; + Log.Information("Starting NLTG..."); // 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 new file mode 100644 index 00000000..bd0a9395 --- /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 Daemon.Ipc.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/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/IpcFraming.cs b/src/NLightning.Daemon/Services/Ipc/IpcFraming.cs new file mode 100644 index 00000000..596e73f7 --- /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 Daemon.Ipc.Interfaces; +using 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..cba0a28e --- /dev/null +++ b/src/NLightning.Daemon/Services/Ipc/IpcRouting.cs @@ -0,0 +1,54 @@ +using MessagePack; +using Microsoft.Extensions.Logging; + +namespace NLightning.Daemon.Services.Ipc; + +using Daemon.Ipc.Interfaces; +using Domain.Client.Enums; +using Transport.Ipc; + +/// +/// Default router that uses a map of handlers keyed by command. +/// +internal 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 = IpcEnvelopeKind.Error, + Payload = payload + }; + } +} \ No newline at end of file diff --git a/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcService.cs b/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcService.cs new file mode 100644 index 00000000..d1f17159 --- /dev/null +++ b/src/NLightning.Daemon/Services/Ipc/NamedPipeIpcService.cs @@ -0,0 +1,165 @@ +using System.IO.Pipes; +using Microsoft.Extensions.Logging; + +namespace NLightning.Daemon.Services.Ipc; + +using Contracts.Utilities; +using Daemon.Ipc.Interfaces; +using Domain.Client.Constants; +using Domain.Client.Interfaces; +using Factories; +using Transport.Ipc; + +/// +/// Hosted service that listens to on a named pipe and processes IPC requests using injected components. +/// +internal sealed class NamedPipeIpcService : INamedPipeIpcService +{ + private readonly ILogger _logger; + private readonly IIpcAuthenticator _authenticator; + private readonly IIpcFraming _framing; + private readonly IIpcRequestRouter _router; + private readonly string _pipeName; + private readonly string _cookiePath; + + private CancellationTokenSource? _cts; + private Task? _listenerTask; + + public NamedPipeIpcService(IIpcAuthenticator authenticator, string configPath, IIpcFraming framing, + ILogger logger, IIpcRequestRouter router) + { + _logger = logger; + _authenticator = authenticator; + _framing = framing; + _router = router; + + _pipeName = NodeUtils.GetNamedPipeFilePath(configPath); + _cookiePath = NodeUtils.GetCookieFilePath(configPath); + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + EnsureCookieExists(); + + _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 + { + await _listenerTask; + } + catch (OperationCanceledException) + { + // Expected during cancellation + } + } + } + + private async Task ListenToIpcClientAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + 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); + } + } + } + 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) + { + try + { + var request = await _framing.ReadAsync(stream, ct); + + if (!await _authenticator.ValidateAsync(request.AuthToken, ct)) + { + var err = IpcErrorFactory.CreateErrorEnvelope(request, ErrorCodes.AuthenticationFailure, + "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 = IpcEnvelopeKind.Error }; + var err = IpcErrorFactory.CreateErrorEnvelope(env, ErrorCodes.ServerError, 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)) + return; + + 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; + } + } +} \ No newline at end of file diff --git a/src/NLightning.Node/Services/NltgDaemonService.cs b/src/NLightning.Daemon/Services/NltgDaemonService.cs similarity index 71% rename from src/NLightning.Node/Services/NltgDaemonService.cs rename to src/NLightning.Daemon/Services/NltgDaemonService.cs index 72968bfd..5a416bd1 100644 --- a/src/NLightning.Node/Services/NltgDaemonService.cs +++ b/src/NLightning.Daemon/Services/NltgDaemonService.cs @@ -3,9 +3,10 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace NLightning.Node.Services; +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; @@ -41,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 { @@ -58,6 +67,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 +84,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/Services/NodeInfoQueryService.cs b/src/NLightning.Daemon/Services/NodeInfoQueryService.cs new file mode 100644 index 00000000..d6bb0879 --- /dev/null +++ b/src/NLightning.Daemon/Services/NodeInfoQueryService.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +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 NodeOptions _nodeOptions; + private readonly ISecureKeyManager _secureKeyManager; + private readonly IServiceProvider _services; + private readonly ITcpService _tcpService; + + public NodeInfoQueryService(IOptions nodeOptions, ISecureKeyManager secureKeyManager, + IServiceProvider services, ITcpService tcpService) + { + _nodeOptions = nodeOptions.Value; + _secureKeyManager = secureKeyManager; + _services = services; + _tcpService = tcpService; + } + + 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 + } + } + + 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, + 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..46ad063c 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,18 +311,15 @@ 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)); } /// /// 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, DaemonConstants.DaemonFolder, network); - Directory.CreateDirectory(networkDir); // Ensure directory exists - return Path.Combine(networkDir, DaemonConstants.PidFile); + return Path.Combine(configPath, NodeConstants.PidFile); } /// @@ -325,7 +368,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 +414,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.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/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/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 new file mode 100644 index 00000000..daa6f8ce --- /dev/null +++ b/src/NLightning.Domain/Bitcoin/Interfaces/IUtxoDbRepository.cs @@ -0,0 +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); + 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 new file mode 100644 index 00000000..a39f947a --- /dev/null +++ b/src/NLightning.Domain/Bitcoin/Interfaces/IUtxoMemoryRepository.cs @@ -0,0 +1,24 @@ +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); + void UpgradeChannelIdOnLockedUtxos(ChannelId oldChannelId, ChannelId newChannelId); +} \ 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..e90e7285 --- /dev/null +++ b/src/NLightning.Domain/Bitcoin/Interfaces/IWalletAddressesDbRepository.cs @@ -0,0 +1,14 @@ +using NLightning.Domain.Bitcoin.Wallet.Models; + +namespace NLightning.Domain.Bitcoin.Interfaces; + +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/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..8dd6d33f 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; @@ -42,7 +57,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, @@ -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.") @@ -208,7 +222,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..fb1a7e19 --- /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 the 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/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/Bitcoin/Wallet/Models/UtxoModel.cs b/src/NLightning.Domain/Bitcoin/Wallet/Models/UtxoModel.cs new file mode 100644 index 00000000..4c5b769c --- /dev/null +++ b/src/NLightning.Domain/Bitcoin/Wallet/Models/UtxoModel.cs @@ -0,0 +1,50 @@ +namespace NLightning.Domain.Bitcoin.Wallet.Models; + +using Channels.ValueObjects; +using Enums; +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 uint AddressIndex { get; private set; } + public bool IsAddressChange { get; private set; } + public AddressType AddressType { get; private set; } + public ChannelId? LockedToChannelId { get; set; } + + 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 new file mode 100644 index 00000000..f8d07784 --- /dev/null +++ b/src/NLightning.Domain/Bitcoin/Wallet/Models/WalletAddressModel.cs @@ -0,0 +1,24 @@ +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 WalletAddressModel(AddressType addressType, uint index, bool isChange, string address) + { + AddressType = addressType; + Index = index; + IsChange = isChange; + Address = address; + } + + public override string ToString() + { + return Address; + } +} \ No newline at end of file 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/Factories/ChannelFactory.cs b/src/NLightning.Domain/Channels/Factories/ChannelFactory.cs index b6a61d15..8eb86515 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; @@ -47,16 +52,16 @@ 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 - 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 @@ -110,11 +115,11 @@ 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); - // Generate the commitment numbers + // Generate the commitment number var commitmentNumber = new CommitmentNumber(remoteKeySet.PaymentCompactBasepoint, localKeySet.PaymentCompactBasepoint, _sha256); @@ -134,158 +139,128 @@ 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) + public async Task CreateChannelV1AsInitiatorAsync(OpenChannelClientRequest request, + FeatureOptions negotiatedFeatures, + CompactPubKey remoteNodeId) { - // 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) - { - // Check if ChainHash is compatible - if (payload.ChainHash != _nodeOptions.BitcoinNetwork.ChainHash) - throw new ChannelErrorException("ChainHash is not compatible"); + // 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 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 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 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}"); + // 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 max_accepted_htlcs is too large - if (payload.MaxAcceptedHtlcs > ChannelConstants.MaxAcceptedHtlcs) - throw new ChannelErrorException($"Max accepted htlcs is too small: {payload.MaxAcceptedHtlcs}"); + // 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 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 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 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}"); + var dustLimitAmount = ChannelConstants.MinDustLimitAmount; + if (request.DustLimitAmount is not null) + { + // 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 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})"); + dustLimitAmount = request.DustLimitAmount; + } - // Check if dust_limit_satoshis is too small - if (payload.DustLimitAmount < ChannelConstants.MinDustLimitAmount) - throw new ChannelErrorException($"Dust limit amount is too small: {payload.DustLimitAmount}"); + if (dustLimitAmount > channelReserveAmount) + channelReserveAmount = dustLimitAmount; // Check if there are enough funds to pay for fees - 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); - 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 doesn'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, + dustLimitAmount, + request.MaxAcceptedHtlcs ?? _nodeOptions.MaxAcceptedHtlcs, + maxHtlcValueInFlight, minimumDepth, + negotiatedFeatures.OptionAnchors != 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/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/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..d724478f --- /dev/null +++ b/src/NLightning.Domain/Channels/Validators/ChannelOpenValidator.cs @@ -0,0 +1,154 @@ +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}"); + } + + // 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 > 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.OptionAnchors > 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 if ChannelType exists + minimumDepth = _nodeOptions.MinimumDepth; + 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)) + { + 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/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.Domain/Client/Constants/ErrorCodes.cs b/src/NLightning.Domain/Client/Constants/ErrorCodes.cs new file mode 100644 index 00000000..c1a4ad04 --- /dev/null +++ b/src/NLightning.Domain/Client/Constants/ErrorCodes.cs @@ -0,0 +1,12 @@ +namespace NLightning.Domain.Client.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"; + 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 new file mode 100644 index 00000000..51562838 --- /dev/null +++ b/src/NLightning.Domain/Client/Enums/ClientCommand.cs @@ -0,0 +1,17 @@ +namespace NLightning.Domain.Client.Enums; + +/// +/// Commands sent by a client. +/// +public enum ClientCommand +{ + // Reserve 0 for unknown + Unknown = 0, + NodeInfo = 1, + ConnectPeer = 2, + ListPeers = 3, + GetAddress = 4, + WalletBalance = 5, + OpenChannel = 6, + OpenChannelSubscription = 7 +} \ 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/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 new file mode 100644 index 00000000..a5cbb5bd --- /dev/null +++ b/src/NLightning.Domain/Client/Responses/OpenChannelClientResponse.cs @@ -0,0 +1,13 @@ +namespace NLightning.Domain.Client.Responses; + +using Channels.ValueObjects; + +public sealed class OpenChannelClientResponse +{ + public ChannelId ChannelId { get; } + + public OpenChannelClientResponse(ChannelId channelId) + { + 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/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/Enums/Feature.cs b/src/NLightning.Domain/Enums/Feature.cs index 59c052c6..3e2b1b93 100644 --- a/src/NLightning.Domain/Enums/Feature.cs +++ b/src/NLightning.Domain/Enums/Feature.cs @@ -12,18 +12,10 @@ 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, - /// - /// 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. /// @@ -60,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, @@ -68,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, @@ -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,11 +136,19 @@ 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. /// /// - /// 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, @@ -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/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/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 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/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/FeatureSet.cs b/src/NLightning.Domain/Node/FeatureSet.cs index 9cb56933..413fe15f 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; /// @@ -19,11 +19,6 @@ public class FeatureSet { // This \/ --- Depends on this \/ { Feature.GossipQueriesEx, [Feature.GossipQueries] }, - { Feature.PaymentSecret, [Feature.VarOnionOptin] }, - { Feature.BasicMpp, [Feature.PaymentSecret] }, - { Feature.OptionAnchorOutputs, [Feature.OptionStaticRemoteKey] }, - { Feature.OptionAnchorsZeroFeeHtlcTx, [Feature.OptionStaticRemoteKey] }, - { Feature.OptionRouteBlinding, [Feature.VarOnionOptin] }, { Feature.OptionZeroconf, [Feature.OptionScidAlias] }, }; @@ -38,8 +33,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 +174,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,6 +285,26 @@ public void WriteToBitWriter(IBitWriter bitWriter, int length, bool shouldPad) /// public bool HasFeature(Feature feature) => IsFeatureSet(feature, false) || IsFeatureSet(feature, true); + public byte[]? GetBytes(bool asGlobal = false) + { + // Get the last valid bit + var lastIndexOfOne = GetLastIndexOfOne(FeatureFlags, asGlobal); + if (lastIndexOfOne == -1) + return null; + + // Calculate total bytes needed + var totalBytes = (FeatureFlags.Length + 7) / 8; + var bytes = new byte[totalBytes]; + + // Copy bits as bytes + FeatureFlags.CopyTo(bytes, 0); + + // Calculate last valid byte + var lastValidByte = (lastIndexOfOne + 7) / 8; + + return bytes[..lastValidByte]; + } + /// /// Deserializes the features from a byte array. /// @@ -349,10 +378,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(' ', ','); @@ -388,9 +419,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/Interfaces/IPeerCommunicationService.cs b/src/NLightning.Domain/Node/Interfaces/IPeerCommunicationService.cs index d4adcbef..df18ab3e 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. @@ -26,7 +27,7 @@ public interface IPeerCommunicationService /// /// Event raised when the peer is disconnected. /// - event EventHandler? DisconnectEvent; + event EventHandler? DisconnectEvent; /// /// Event raised when an exception occurs. @@ -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 71275bb0..3510e804 100644 --- a/src/NLightning.Domain/Node/Interfaces/IPeerManager.cs +++ b/src/NLightning.Domain/Node/Interfaces/IPeerManager.cs @@ -1,8 +1,9 @@ -using NLightning.Domain.Crypto.ValueObjects; -using NLightning.Domain.Node.ValueObjects; - namespace NLightning.Domain.Node.Interfaces; +using Crypto.ValueObjects; +using Models; +using ValueObjects; + /// /// Interface for the peer manager. /// @@ -26,11 +27,15 @@ 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. /// /// 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); } \ No newline at end of file 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.Domain/Node/Models/PeerModel.cs b/src/NLightning.Domain/Node/Models/PeerModel.cs index 05ba09e3..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 @@ -16,8 +17,29 @@ 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 FeatureOptions NegotiatedFeatures + { + get + { + return _peerService is null + ? throw new NullReferenceException($"{nameof(PeerModel)}.{nameof(Features)} was null") + : _peerService.Features; + } + } + public PeerAddressInfo PeerAddressInfo { get @@ -30,11 +52,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.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/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 2b989b88..686afd34 100644 --- a/src/NLightning.Domain/Persistence/Interfaces/IUnitOfWork.cs +++ b/src/NLightning.Domain/Persistence/Interfaces/IUnitOfWork.cs @@ -1,16 +1,19 @@ -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 { // Bitcoin repositories IBlockchainStateDbRepository BlockchainStateDbRepository { get; } IWatchedTransactionDbRepository WatchedTransactionDbRepository { get; } + IWalletAddressesDbRepository WalletAddressesDbRepository { get; } + IUtxoDbRepository UtxoDbRepository { get; } // Chanel repositories IChannelConfigDbRepository ChannelConfigDbRepository { get; } @@ -22,6 +25,8 @@ public interface IUnitOfWork : IDisposable IPeerDbRepository PeerDbRepository { get; } 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/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.Domain/Protocol/Interfaces/IMessageFactory.cs b/src/NLightning.Domain/Protocol/Interfaces/IMessageFactory.cs index 3b37fba5..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, @@ -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.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.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.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 new file mode 100644 index 00000000..2b5c5520 --- /dev/null +++ b/src/NLightning.Infrastructure.Bitcoin/Builders/FundingTransactionBuilder.cs @@ -0,0 +1,75 @@ +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); + + 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); + + // 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; + 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 + transaction.FundingOutput.TransactionId = tx.GetHash().ToBytes(); + transaction.FundingOutput.Index = 0; + + 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()); + } +} \ 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 907dcdfe..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. @@ -25,15 +25,19 @@ public static class DependencyInjection public static IServiceCollection AddBitcoinInfrastructure(this IServiceCollection services) { // Register Singletons - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); 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..8b289372 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,19 +236,16 @@ 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 }; } /// /// 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/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 fcb008bb..1ad194da 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)); @@ -51,7 +56,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 +91,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; @@ -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); } @@ -126,9 +131,9 @@ 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; + using var perCommitmentSeed = channelKey.Derive(PerCommitmentSeedDerivationIndex, true).PrivateKey; var perCommitmentSecret = _keyDerivationService.GeneratePerCommitmentSecret(perCommitmentSeed.ToBytes(), commitmentNumber); @@ -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); } @@ -162,9 +167,9 @@ 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; + using var perCommitmentSeed = channelKey.Derive(PerCommitmentSeedDerivationIndex, true).PrivateKey; return _keyDerivationService.GeneratePerCommitmentSecret( perCommitmentSeed.ToBytes(), commitmentNumber); @@ -174,17 +179,221 @@ 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 CompactSignature SignTransaction(ChannelId channelId, SignedTransaction unsignedTransaction) + public bool SignWalletTransaction(SignedTransaction unsignedTransaction) { - _logger.LogTrace("Signing transaction for channel {ChannelId} with TxId {TxId}", channelId, + 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 taprootKeyPairs = new TaprootKeyPair?[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 the address type + Script scriptPubKey; + ExtPrivKey signingExtKey; + Key? signingKey = null; + TaprootKeyPair? taprootKeyPair = null; + + 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); + var rootKey = ExtKey.CreateFromBytes(signingExtKey).PrivateKey; + // For P2TR (Taproot): OP_1 <32-byte-taproot-output> + taprootKeyPair = rootKey.CreateTaprootKeyPair(); + scriptPubKey = taprootKeyPair.PubKey.ScriptPubKey; + break; + + default: + throw new SignerException($"Unsupported address type {utxo.AddressType} for input {i}", + channelId); + } + + signingKeys[i] = signingKey; + taprootKeyPairs[i] = taprootKeyPair; + 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 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, taprootKeyPair, 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 + unsignedTransaction.RawTxBytes = nBitcoinTx.ToBytes(); + + _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 SignChannelTransaction(ChannelId channelId, SignedTransaction unsignedTransaction) + { + 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"); @@ -210,9 +419,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, 0, SigHash.All, spentOutput, + HashVersion.WitnessV0); // Get the funding private key using var fundingPrivateKey = GenerateFundingPrivateKey(signingInfo.ChannelKeyIndex); @@ -232,8 +440,9 @@ public CompactSignature SignTransaction(ChannelId channelId, SignedTransaction u 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"); @@ -278,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"); @@ -303,14 +509,59 @@ 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); } - 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 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(scriptCode, inputIndex, SigHash.All, prevOut, HashVersion.WitnessV0); + + // Sign the hash + var transactionSignature = signingKey.Sign(sigHash, new SigningOptions(SigHash.All, false)); + + // For P2WPKH, witness is: + var witness = new WitScript( + Op.GetPushOp(transactionSignature.ToBytes()), + 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, TaprootKeyPair taprootKeyPair, TxOut[] prevOuts) + { + // Create the TaprootExecutionData + 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 = taprootKeyPair.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/BitcoinChainService.cs b/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinChainService.cs new file mode 100644 index 00000000..ce47516b --- /dev/null +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinChainService.cs @@ -0,0 +1,116 @@ +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 + { + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation("Broadcasting transaction {TxId}", transaction.GetHash()); + + var result = await _rpcClient.SendRawTransactionAsync(transaction); + + if (_logger.IsEnabled(LogLevel.Information)) + _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..831be63c 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinWalletService.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/BitcoinWalletService.cs @@ -1,110 +1,88 @@ -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.Enums; +using Domain.Bitcoin.ValueObjects; +using Domain.Bitcoin.Wallet.Models; +using Domain.Node.Options; +using Domain.Persistence.Interfaces; +using Domain.Protocol.Interfaces; +using Interfaces; + +public class BitcoinWalletService : IBitcoinWalletService { - private readonly RPCClient _rpcClient; + private readonly IBlockchainMonitor _blockchainMonitor; 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(IBlockchainMonitor blockchainMonitor, ILogger logger, + IOptions nodeOptions, ISecureKeyManager secureKeyManager, + IUnitOfWork uow) { + _blockchainMonitor = blockchainMonitor; _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; + _logger.LogInformation("BitcoinWalletService network: {Network} (config: {ConfigNetwork})", _network, nodeOptions.Value.BitcoinNetwork); } - 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 (addressType is not (AddressType.P2Wpkh or AddressType.P2Tr)) + 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; - 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) + _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++) { - return 0; // Transaction not found + 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())); + } } - catch (Exception ex) + + _uow.WalletAddressesDbRepository.AddRange(addressList); + await _uow.SaveChangesAsync(); + + // Register all newly generated addresses with blockchain monitor + foreach (var address in addressList) { - _logger.LogError(ex, "Failed to get confirmations for transaction {TxId}", txId); - throw; + _blockchainMonitor.WatchBitcoinAddress(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 7bb0ecb1..79c44ad8 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/BlockchainMonitorService.cs @@ -9,10 +9,13 @@ namespace NLightning.Infrastructure.Bitcoin.Wallet; 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; @@ -21,13 +24,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); @@ -39,13 +43,16 @@ public class BlockchainMonitorService : IBlockchainMonitor public event EventHandler? OnNewBlockDetected; public event EventHandler? OnTransactionConfirmed; + public event EventHandler? OnWalletMovementDetected; - 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 +68,12 @@ public async Task StartAsync(uint heightOfBirth, CancellationToken cancellationT // Load pending transactions await LoadPendingWatchedTransactionsAsync(uow); + // 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) @@ -80,16 +93,19 @@ 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); - 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(); @@ -105,9 +121,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(); @@ -126,10 +140,30 @@ public async Task StopAsync() CleanupZmqSockets(); } + public async Task PublishAndWatchTransactionAsync(ChannelId channelId, SignedTransaction signedTransaction, + uint requiredDepth) + { + 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); + + // 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}", - 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(); @@ -144,6 +178,14 @@ public async Task WatchTransactionAsync(ChannelId channelId, TxId txId, uint req await uow.SaveChangesAsync(); } + public void WatchBitcoinAddress(WalletAddressModel walletAddress) + { + if (_logger.IsEnabled(LogLevel.Information)) + _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); @@ -157,7 +199,8 @@ public async Task WatchTransactionAsync(ChannelId channelId, TxId txId, uint req private async Task MonitorBlockchainAsync(CancellationToken cancellationToken) { - _logger.LogInformation("Starting blockchain monitoring loop"); + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation("Starting blockchain monitoring loop"); try { @@ -180,10 +223,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); @@ -224,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) { @@ -246,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) { @@ -309,8 +354,8 @@ private async Task AddMissingBlocksToProcessAsync(uint currentHeight) if (_blocksToProcess.ContainsKey(height)) continue; - // Add missing block to process queue - var blockAtHeight = await _bitcoinWallet.GetBlockAsync(height); + // Add the missing block to the process queue + var blockAtHeight = await _bitcoinChainService.GetBlockAsync(height); if (blockAtHeight is not null) { _blocksToProcess[height] = blockAtHeight; @@ -332,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); @@ -370,13 +416,18 @@ 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())); // 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 + CheckBlockForWalletMovement(block.Transactions, height, uow); // Update blockchain state _blockchainState.UpdateState(blockHash.ToBytes(), height); @@ -398,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); @@ -410,12 +462,13 @@ 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( - "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) @@ -447,13 +500,65 @@ private void CheckWatchedTransactionsForBlock(List blockTransaction } } + private void CheckBlockForWalletMovement(List transactions, uint blockHeight, IUnitOfWork uow) + { + if (_watchedAddresses.IsEmpty) + return; + + 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) + { + var txId = transaction.GetHash(); + + // Check each output for deposits + 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; + + 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), + blockHeight, watchedAddress); + uow.AddUtxo(utxo); + + 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 + foreach (var input in transaction.Inputs) + uow.TrySpendUtxo(new TxId(input.PrevOut.Hash.ToBytes()), input.PrevOut.N); + } + } + private void CheckWatchedTransactionsDepth(IUnitOfWork uow) { foreach (var (txId, watchedTransaction) in _watchedTransactions) { 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); } @@ -474,4 +579,29 @@ 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; + } + } + + private async Task LoadUtxoSetAsync(IUnitOfWork uow) + { + _logger.LogInformation("Loading Utxo set"); + + var utxoSet = (await uow.UtxoDbRepository.GetUnspentAsync()).ToList(); + if (utxoSet.Count > 0) + { + var utxoMemoryRepository = _serviceProvider.GetService() ?? + throw new InvalidOperationException( + $"Error getting required service {nameof(IUtxoMemoryRepository)}"); + utxoMemoryRepository.Load(utxoSet); + } + } } \ 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..5f3b9590 --- /dev/null +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBitcoinWalletService.cs @@ -0,0 +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); +} \ 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..08dfcf54 100644 --- a/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBlockchainMonitor.cs +++ b/src/NLightning.Infrastructure.Bitcoin/Wallet/Interfaces/IBlockchainMonitor.cs @@ -2,13 +2,19 @@ namespace NLightning.Infrastructure.Bitcoin.Wallet.Interfaces; 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); + 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); + void WatchBitcoinAddress(WalletAddressModel walletAddress); /// /// Starts a background task to periodically refresh the fee rate 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/20251106194247_AddFieldsForChannelOpen.Designer.cs b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251106194247_AddFieldsForChannelOpen.Designer.cs new file mode 100644 index 00000000..cfd02f94 --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence.Postgres/Migrations/20251106194247_AddFieldsForChannelOpen.Designer.cs @@ -0,0 +1,579 @@ +// +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("20251106194247_AddFieldsForChannelOpen")] + partial class AddFieldsForChannelOpen + { + /// + 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.UtxoEntity", b => + { + b.Property("TransactionId") + .HasColumnType("bytea") + .HasColumnName("transaction_id"); + + b.Property("Index") + .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"); + + b.Property("BlockHeight") + .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); + }); + + 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.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") + .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("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"); + + 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.HasIndex("ChangeAddressIndex", "ChangeAddressIsChange", "ChangeAddressAddressType") + .HasDatabaseName("ix_channels_change_address_index_change_address_is_change_chan"); + + 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.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) + .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"); + + 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 => + { + 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.Bitcoin.WalletAddressEntity", b => + { + b.Navigation("Utxos"); + }); + + 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/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 55222efa..2b53704b 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); @@ -48,6 +48,91 @@ 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("AddressIndex") + .HasColumnType("bigint") + .HasColumnName("address_index"); + + b.Property("AddressType") + .HasColumnType("smallint") + .HasColumnName("address_type"); + + b.Property("AmountSats") + .HasColumnType("bigint") + .HasColumnName("amount_sats"); + + b.Property("BlockHeight") + .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); + }); + + 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.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") @@ -158,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"); @@ -234,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); }); @@ -368,12 +472,29 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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.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) @@ -400,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 => @@ -422,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/20251106194300_AddFieldsForChannelOpen.Designer.cs b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251106194300_AddFieldsForChannelOpen.Designer.cs new file mode 100644 index 00000000..f0028595 --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence.SqlServer/Migrations/20251106194300_AddFieldsForChannelOpen.Designer.cs @@ -0,0 +1,469 @@ +// +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("20251106194300_AddFieldsForChannelOpen")] + partial class AddFieldsForChannelOpen + { + /// + 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.UtxoEntity", b => + { + b.Property("TransactionId") + .HasColumnType("varbinary(32)"); + + 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"); + }); + + 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.HasKey("Index", "IsChange", "AddressType"); + + b.ToTable("WalletAddresses"); + }); + + 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("ChangeAddressAddressType") + .HasColumnType("tinyint"); + + b.Property("ChangeAddressIndex") + .HasColumnType("bigint"); + + b.Property("ChangeAddressIsChange") + .HasColumnType("bit"); + + b.Property("ChangeAddressType") + .HasColumnType("tinyint"); + + 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.HasIndex("ChangeAddressIndex", "ChangeAddressIsChange", "ChangeAddressAddressType"); + + 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.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) + .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"); + + 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 => + { + 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.Bitcoin.WalletAddressEntity", b => + { + b.Navigation("Utxos"); + }); + + 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/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 0d323cc9..b62d1d88 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); @@ -43,6 +43,72 @@ 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("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"); + }); + + 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.HasKey("Index", "IsChange", "AddressType"); + + b.ToTable("WalletAddresses"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => { b.Property("TransactionId") @@ -128,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"); @@ -185,6 +263,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("PeerEntityNodeId"); + b.HasIndex("ChangeAddressIndex", "ChangeAddressIsChange", "ChangeAddressAddressType"); + b.ToTable("Channels"); }); @@ -291,11 +371,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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.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) @@ -319,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 => @@ -339,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/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/20251106194254_AddFieldsForChannelOpen.Designer.cs b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251106194254_AddFieldsForChannelOpen.Designer.cs new file mode 100644 index 00000000..093699d4 --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/20251106194254_AddFieldsForChannelOpen.Designer.cs @@ -0,0 +1,460 @@ +// +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("20251106194254_AddFieldsForChannelOpen")] + partial class AddFieldsForChannelOpen + { + /// + 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.UtxoEntity", b => + { + b.Property("TransactionId") + .HasColumnType("BLOB"); + + 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"); + }); + + 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.HasKey("Index", "IsChange", "AddressType"); + + b.ToTable("WalletAddresses"); + }); + + 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("ChangeAddressAddressType") + .HasColumnType("INTEGER"); + + b.Property("ChangeAddressIndex") + .HasColumnType("INTEGER"); + + b.Property("ChangeAddressIsChange") + .HasColumnType("INTEGER"); + + b.Property("ChangeAddressType") + .HasColumnType("INTEGER"); + + 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.HasIndex("ChangeAddressIndex", "ChangeAddressIsChange", "ChangeAddressAddressType"); + + 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.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) + .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"); + + 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 => + { + 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.Bitcoin.WalletAddressEntity", b => + { + b.Navigation("Utxos"); + }); + + 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/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 3eb61e3a..05270ca1 100644 --- a/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/NLightningDbContextModelSnapshot.cs +++ b/src/NLightning.Infrastructure.Persistence.Sqlite/Migrations/NLightningDbContextModelSnapshot.cs @@ -38,6 +38,68 @@ 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("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"); + }); + + 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.HasKey("Index", "IsChange", "AddressType"); + + b.ToTable("WalletAddresses"); + }); + modelBuilder.Entity("NLightning.Infrastructure.Persistence.Entities.Bitcoin.WatchedTransactionEntity", b => { b.Property("TransactionId") @@ -123,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"); @@ -180,6 +254,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("PeerEntityNodeId"); + b.HasIndex("ChangeAddressIndex", "ChangeAddressIsChange", "ChangeAddressAddressType"); + b.ToTable("Channels"); }); @@ -286,11 +362,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Port") .HasColumnType("INTEGER"); + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + b.HasKey("NodeId"); 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) @@ -314,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 => @@ -334,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/Contexts/NLightningDbContext.cs b/src/NLightning.Infrastructure.Persistence/Contexts/NLightningDbContext.cs index 41efaaa5..255d5b32 100644 --- a/src/NLightning.Infrastructure.Persistence/Contexts/NLightningDbContext.cs +++ b/src/NLightning.Infrastructure.Persistence/Contexts/NLightningDbContext.cs @@ -24,6 +24,8 @@ public NLightningDbContext(DbContextOptions options, Databa // Bitcoin DbSets 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; } @@ -41,6 +43,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // Bitcoin entities modelBuilder.ConfigureBlockchainStateEntity(_databaseType); modelBuilder.ConfigureWatchedTransactionEntity(_databaseType); + modelBuilder.ConfigureWalletAddressEntity(_databaseType); + modelBuilder.ConfigureUtxoEntity(_databaseType); // Channel entities modelBuilder.ConfigureChannelEntity(_databaseType); 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/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..ebd327e9 --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/UtxoEntity.cs @@ -0,0 +1,23 @@ +namespace NLightning.Infrastructure.Persistence.Entities.Bitcoin; + +using Domain.Bitcoin.Enums; +using Domain.Bitcoin.ValueObjects; +using Domain.Channels.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; } + 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() { } +} \ 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 new file mode 100644 index 00000000..3014cc07 --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence/Entities/Bitcoin/WalletAddressEntity.cs @@ -0,0 +1,16 @@ +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 virtual IEnumerable? Utxos { 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/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/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/Bitcoin/UtxoEntityConfiguration.cs b/src/NLightning.Infrastructure.Persistence/EntityConfiguration/Bitcoin/UtxoEntityConfiguration.cs new file mode 100644 index 00000000..8a7cc09a --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence/EntityConfiguration/Bitcoin/UtxoEntityConfiguration.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace NLightning.Infrastructure.Persistence.EntityConfiguration.Bitcoin; + +using Domain.Channels.Constants; +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(); + 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(); + + // 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 new file mode 100644 index 00000000..71c5a723 --- /dev/null +++ b/src/NLightning.Infrastructure.Persistence/EntityConfiguration/Bitcoin/WalletAddressEntityConfiguration.cs @@ -0,0 +1,28 @@ +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 _) + { + 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(); + + // 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.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/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/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/UtxoDbRepository.cs b/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/UtxoDbRepository.cs new file mode 100644 index 00000000..a440c6db --- /dev/null +++ b/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/UtxoDbRepository.cs @@ -0,0 +1,84 @@ +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; +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 void Update(UtxoModel utxoModel) + { + var utxoEntity = MapDomainToEntity(utxoModel); + Update(utxoEntity); + } + + public async Task> GetUnspentAsync(bool includeWalletAddress = false) + { + var query = Get(asNoTracking: true).AsQueryable(); + if (includeWalletAddress) + query = 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 + { + TransactionId = model.TxId, + Index = model.Index, + AmountSats = model.Amount.Satoshi, + BlockHeight = model.BlockHeight, + AddressIndex = model.AddressIndex, + IsAddressChange = model.IsAddressChange, + AddressType = model.AddressType + }; + } + + private UtxoModel MapEntityToModel(UtxoEntity entity) + { + var utxoModel = new UtxoModel(entity.TransactionId, entity.Index, LightningMoney.Satoshis(entity.AmountSats), + entity.BlockHeight, entity.AddressIndex, entity.IsAddressChange, + entity.AddressType); + + if (entity.WalletAddress is null) + return utxoModel; + + 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 new file mode 100644 index 00000000..d8116761 --- /dev/null +++ b/src/NLightning.Infrastructure.Repositories/Database/Bitcoin/WalletAddressesDbRepository.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore; + +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; + +public class WalletAddressesDbRepository(NLightningDbContext context) + : BaseDbRepository(context), IWalletAddressesDbRepository +{ + public async Task GetUnusedAddressAsync(AddressType type, bool isChange) + { + 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(); + + 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 + }; + } + + internal static WalletAddressModel MapEntityToModel(WalletAddressEntity entity) + { + return new WalletAddressModel(entity.AddressType, entity.Index, entity.IsChange, entity.Address); + } +} \ No newline at end of file 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/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.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/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.Repositories/Memory/UtxoMemoryRepository.cs b/src/NLightning.Infrastructure.Repositories/Memory/UtxoMemoryRepository.cs new file mode 100644 index 00000000..c42bf820 --- /dev/null +++ b/src/NLightning.Infrastructure.Repositories/Memory/UtxoMemoryRepository.cs @@ -0,0 +1,219 @@ +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 +{ + 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) + { + _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) + { + 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 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.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(); + 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.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; + 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 e3ba74ed..24cce6bc 100644 --- a/src/NLightning.Infrastructure.Repositories/UnitOfWork.cs +++ b/src/NLightning.Infrastructure.Repositories/UnitOfWork.cs @@ -1,9 +1,13 @@ +using Microsoft.Extensions.Logging; + namespace NLightning.Infrastructure.Repositories; using Database.Bitcoin; 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; @@ -16,12 +20,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; @@ -38,6 +46,11 @@ public class UnitOfWork : IUnitOfWork public IWatchedTransactionDbRepository WatchedTransactionDbRepository => _watchedTransactionDbRepository ??= new WatchedTransactionDbRepository(_context); + public IWalletAddressesDbRepository WalletAddressesDbRepository => + _walletAddressesDbRepository ??= new WalletAddressesDbRepository(_context); + + public IUtxoDbRepository UtxoDbRepository => _utxoDbRepository ??= new UtxoDbRepository(_context); + public IChannelConfigDbRepository ChannelConfigDbRepository => _channelConfigDbRepository ??= new ChannelConfigDbRepository(_context); @@ -53,11 +66,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() @@ -75,6 +91,60 @@ 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 TrySpendUtxo(TxId transactionId, uint index) + { + // Check if utxo exists in memory + if (!_utxoMemoryRepository.TryGetUtxo(transactionId, index, out var utxoModel)) + return; + + 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.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/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.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/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.Infrastructure/Node/Services/PeerCommunicationService.cs b/src/NLightning.Infrastructure/Node/Services/PeerCommunicationService.cs index eb16c1dd..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; @@ -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")); } }); @@ -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,8 +157,7 @@ public void Disconnect() } finally { - _messageService.Dispose(); - DisconnectEvent?.Invoke(this, EventArgs.Empty); + DisconnectEvent?.Invoke(this, exception); } } @@ -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..c5b60770 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,62 @@ 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) +#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}", + 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) +#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}", + PeerPubKey, channelId is null ? "" : channelId.ToString(), warningMessageString); + } + + OnAttentionMessageReceived?.Invoke( + this, new AttentionMessageEventArgs(warningMessageString, PeerPubKey, channelId)); } } @@ -110,12 +173,13 @@ 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) + 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)); } /// @@ -185,14 +249,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/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.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..1eeb2ed3 100644 --- a/src/NLightning.Infrastructure/Transport/Interfaces/ITcpService.cs +++ b/src/NLightning.Infrastructure/Transport/Interfaces/ITcpService.cs @@ -1,12 +1,22 @@ -using NLightning.Domain.Node.ValueObjects; -using NLightning.Infrastructure.Node.ValueObjects; +using System.Net; namespace NLightning.Infrastructure.Transport.Interfaces; using Events; +using Node.ValueObjects; +using Protocol.Models; 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. /// @@ -34,5 +44,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..715d0c19 100644 --- a/src/NLightning.Infrastructure/Transport/Services/TcpService.cs +++ b/src/NLightning.Infrastructure/Transport/Services/TcpService.cs @@ -2,16 +2,15 @@ using System.Net.Sockets; 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; 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 { @@ -22,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; @@ -50,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); @@ -95,10 +97,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.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/src/NLightning.Node/AssemblyInfo.cs b/src/NLightning.Node/AssemblyInfo.cs deleted file mode 100644 index 7836a882..00000000 --- a/src/NLightning.Node/AssemblyInfo.cs +++ /dev/null @@ -1,3 +0,0 @@ -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("NLightning.Bolts.Tests")] \ 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/IpcEnvelope.cs b/src/NLightning.Transport.Ipc/IpcEnvelope.cs new file mode 100644 index 00000000..e8fd2ba8 --- /dev/null +++ b/src/NLightning.Transport.Ipc/IpcEnvelope.cs @@ -0,0 +1,26 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc; + +using Domain.Client.Enums; + +/// +/// 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 ClientCommand 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 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/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/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 new file mode 100644 index 00000000..cc9452f2 --- /dev/null +++ b/src/NLightning.Transport.Ipc/MessagePack/Formatters/CompactPubKeyFormatter.cs @@ -0,0 +1,22 @@ +using System.Runtime.Serialization; +using MessagePack; +using MessagePack.Formatters; + +namespace NLightning.Transport.Ipc.MessagePack.Formatters; + +using Domain.Crypto.ValueObjects; + +[ExcludeFormatterFromSourceGeneratedResolver] +public class CompactPubKeyFormatter : IMessagePackFormatter +{ + public void Serialize(ref MessagePackWriter writer, CompactPubKey value, MessagePackSerializerOptions options) + { + writer.Write((byte[])value); + } + + public CompactPubKey Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + 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/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/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/Formatters/PeerAddressInfoFormatter.cs b/src/NLightning.Transport.Ipc/MessagePack/Formatters/PeerAddressInfoFormatter.cs new file mode 100644 index 00000000..9bb542af --- /dev/null +++ b/src/NLightning.Transport.Ipc/MessagePack/Formatters/PeerAddressInfoFormatter.cs @@ -0,0 +1,22 @@ +using System.Runtime.Serialization; +using MessagePack; +using MessagePack.Formatters; + +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) + { + 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/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 new file mode 100644 index 00000000..e96948b0 --- /dev/null +++ b/src/NLightning.Transport.Ipc/MessagePack/NLightningFormatterResolver.cs @@ -0,0 +1,46 @@ +using MessagePack; +using MessagePack.Formatters; +using MessagePack.Resolvers; + +namespace NLightning.Transport.Ipc.MessagePack; + +using Domain.Bitcoin.ValueObjects; +using Domain.Channels.ValueObjects; +using Domain.Crypto.ValueObjects; +using Domain.Money; +using Domain.Node; +using Domain.Node.ValueObjects; +using Domain.Protocol.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 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() + { + 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 new file mode 100644 index 00000000..751fec87 --- /dev/null +++ b/src/NLightning.Transport.Ipc/NLightning.Transport.Ipc.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/NLightning.Transport.Ipc/Requests/ConnectPeerIpcRequest.cs b/src/NLightning.Transport.Ipc/Requests/ConnectPeerIpcRequest.cs new file mode 100644 index 00000000..5a80476f --- /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 Peer command +/// +[MessagePackObject] +public sealed class ConnectPeerIpcRequest +{ + [Key(0)] public required PeerAddressInfo Address { get; init; } +} \ 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..dc901c77 --- /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 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/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/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/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/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/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/ConnectPeerIpcResponse.cs b/src/NLightning.Transport.Ipc/Responses/ConnectPeerIpcResponse.cs new file mode 100644 index 00000000..aa5d2d51 --- /dev/null +++ b/src/NLightning.Transport.Ipc/Responses/ConnectPeerIpcResponse.cs @@ -0,0 +1,20 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc.Responses; + +using Domain.Crypto.ValueObjects; +using Domain.Node; + +/// +/// Response for Connect command +/// +[MessagePackObject] +public sealed class ConnectPeerIpcResponse +{ + [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/GetAddressIpcResponse.cs b/src/NLightning.Transport.Ipc/Responses/GetAddressIpcResponse.cs new file mode 100644 index 00000000..5be954d3 --- /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 sealed 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/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 new file mode 100644 index 00000000..405c07ef --- /dev/null +++ b/src/NLightning.Transport.Ipc/Responses/NodeInfoIpcResponse.cs @@ -0,0 +1,22 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc.Responses; + +using Domain.Crypto.ValueObjects; +using Domain.Protocol.ValueObjects; + +/// +/// Response for NodeInfo command +/// +[MessagePackObject] +public sealed class NodeInfoIpcResponse +{ + [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 new file mode 100644 index 00000000..65331dc8 --- /dev/null +++ b/src/NLightning.Transport.Ipc/Responses/OpenChannelIpcResponse.cs @@ -0,0 +1,23 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc.Responses; + +using Domain.Channels.ValueObjects; +using Domain.Client.Responses; + +/// +/// Response for OpenChannel command +/// +[MessagePackObject] +public sealed class OpenChannelIpcResponse +{ + [Key(0)] public required ChannelId ChannelId { get; init; } + + public static OpenChannelIpcResponse FromClientResponse(OpenChannelClientResponse clientResponse) + { + return new OpenChannelIpcResponse + { + ChannelId = clientResponse.ChannelId + }; + } +} \ No newline at end of file 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/src/NLightning.Transport.Ipc/Responses/PeerInfoIpcResponse.cs b/src/NLightning.Transport.Ipc/Responses/PeerInfoIpcResponse.cs new file mode 100644 index 00000000..90992cfa --- /dev/null +++ b/src/NLightning.Transport.Ipc/Responses/PeerInfoIpcResponse.cs @@ -0,0 +1,19 @@ +using MessagePack; + +namespace NLightning.Transport.Ipc.Responses; + +using Domain.Crypto.ValueObjects; +using Domain.Node; + +/// +/// Response for Peer Info command +/// +[MessagePackObject] +public sealed 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/src/NLightning.Transport.Ipc/Responses/WalletBalanceIpcResponse.cs b/src/NLightning.Transport.Ipc/Responses/WalletBalanceIpcResponse.cs new file mode 100644 index 00000000..f973f941 --- /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 sealed class WalletBalanceIpcResponse +{ + [Key(0)] public required LightningMoney ConfirmedBalance { get; init; } + [Key(1)] public required LightningMoney UnconfirmedBalance { get; init; } +} \ No newline at end of file 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.Application.Tests/Channels/Handlers/FundingCreatedMessageHandlerTests.cs b/test/NLightning.Application.Tests/Channels/Handlers/FundingCreatedMessageHandlerTests.cs index 2a0de274..899ce1bd 100644 --- a/test/NLightning.Application.Tests/Channels/Handlers/FundingCreatedMessageHandlerTests.cs +++ b/test/NLightning.Application.Tests/Channels/Handlers/FundingCreatedMessageHandlerTests.cs @@ -144,12 +144,12 @@ public FundingCreatedMessageHandlerTests() // Setup LightningSigner _mockLightningSigner - .Setup(x => x.SignTransaction(It.IsAny(), It.IsAny())) + .Setup(x => x.SignChannelTransaction(It.IsAny(), It.IsAny())) .Returns(localSignature); // Setup MessageFactory mockMessageFactory - .Setup(x => x.CreatedFundingSignedMessage(It.IsAny(), It.IsAny())) + .Setup(x => x.CreateFundingSignedMessage(It.IsAny(), It.IsAny())) .Returns(new FundingSignedMessage(new FundingSignedPayload(_newChannelId, localSignature))); // Setup ChannelDbRepository @@ -190,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); @@ -208,13 +208,13 @@ 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 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); @@ -225,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.Application.Tests/Channels/Handlers/OpenChannel1MessageHandlerTests.cs b/test/NLightning.Application.Tests/Channels/Handlers/OpenChannel1MessageHandlerTests.cs index f984d44c..4a770339 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; @@ -58,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 @@ -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] @@ -135,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 8492d76f..51f30459 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; @@ -42,6 +43,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 +52,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 }; @@ -93,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 @@ -112,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), @@ -137,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 @@ -222,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] @@ -338,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(channelError), Times.Once); _mockLogger.Verify( l => l.Log( LogLevel.Error, @@ -379,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, @@ -432,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.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)); 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.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.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.Domain.Tests/Node/FeatureSetTests.cs b/test/NLightning.Domain.Tests/Node/FeatureSetTests.cs index 0ab6baad..ad943d9d 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)] @@ -61,11 +62,8 @@ 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.OptionAnchorOutputs, Feature.OptionStaticRemoteKey, false)] - [InlineData(Feature.OptionAnchorOutputs, Feature.OptionStaticRemoteKey, true)] - public void Given_Features_When_SetFeatureADependsOnFeatureB_Then_FeatureBIsSet(Feature feature, Feature dependsOn, bool isCompulsory) + public void Given_Features_When_SetFeatureADependsOnFeatureB_Then_FeatureBIsSet( + Feature feature, Feature dependsOn, bool isCompulsory) { // Arrange var features = new FeatureSet(); @@ -84,11 +82,8 @@ 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.OptionAnchorOutputs, false)] - [InlineData(Feature.OptionStaticRemoteKey, Feature.OptionAnchorOutputs, true)] - public void Given_Features_When_UnsetFeatureA_Then_FeatureBIsUnset(Feature feature, Feature dependent, bool isCompulsory) + public void Given_Features_When_UnsetFeatureA_Then_FeatureBIsUnset(Feature feature, Feature dependent, + bool isCompulsory) { // Arrange var features = new FeatureSet(); @@ -114,15 +109,17 @@ 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); } + #endregion #region IsCompatible + [Theory] [InlineData(Feature.OptionDataLossProtect, false, false, false, false, true)] [InlineData(Feature.OptionDataLossProtect, false, true, false, false, true)] @@ -133,7 +130,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 +227,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 +255,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.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 66% rename from test/NLightning.Infrastructure.Tests/Bitcoin/Wallet/BlockchainMonitorServiceTests.cs rename to test/NLightning.Infrastructure.Bitcoin.Tests/Wallet/BlockchainMonitorServiceTests.cs index 488bdd64..cfb29c9c 100644 --- a/test/NLightning.Infrastructure.Tests/Bitcoin/Wallet/BlockchainMonitorServiceTests.cs +++ b/test/NLightning.Infrastructure.Bitcoin.Tests/Wallet/BlockchainMonitorServiceTests.cs @@ -4,32 +4,37 @@ 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 _mockBitcoinWallet; + private readonly Mock> _mockBitcoinOptions; + 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; 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,32 +44,36 @@ public BlockchainMonitorServiceTests() ZmqTxPort = 28333 }); - _mockBitcoinWallet = new Mock(); - var mockLogger = new Mock>(); + _mockBitcoinChainService = 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(); + _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, - mockLogger.Object, - mockNodeOptions.Object, - fakeServiceProvider); + _mockBitcoinOptions.Object, + _mockBitcoinChainService.Object, + _mockLogger.Object, + _mockNodeOptions.Object, + _fakeServiceProvider); } [Fact] @@ -83,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.Main.ConsensusFactory.CreateBlock()); + _mockBitcoinChainService.Setup(x => x.GetBlockAsync(It.IsAny())) + .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); // Act await _service.StartAsync(0, CancellationToken.None); @@ -95,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] @@ -110,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.Main.ConsensusFactory.CreateBlock()); + _mockBitcoinChainService.Setup(x => x.GetBlockAsync(It.IsAny())) + .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); // Act await _service.StartAsync(0, CancellationToken.None); @@ -137,9 +146,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 +158,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 @@ -161,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); @@ -258,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() { @@ -330,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.Main.ConsensusFactory.CreateBlock()); + _mockBitcoinChainService.Setup(x => x.GetBlockAsync(It.IsAny())) + .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); // Act await _service.StartAsync(heightOfBirth, CancellationToken.None); @@ -360,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.Main.ConsensusFactory.CreateBlock()); + _mockBitcoinChainService.Setup(x => x.GetBlockAsync(It.IsAny())) + .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); // Act await _service.StartAsync(heightOfBirth, CancellationToken.None); @@ -376,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] @@ -393,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.Main.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] @@ -429,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.Main.ConsensusFactory.CreateBlock()); + _mockBitcoinChainService.Setup(x => x.GetBlockAsync(It.IsAny())) + .ReturnsAsync(Consensus.RegTest.ConsensusFactory.CreateBlock()); // Act await _service.StartAsync(heightOfBirth, CancellationToken.None); @@ -443,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.Infrastructure.Serialization.Tests/Messages/InitMessageTests.cs b/test/NLightning.Infrastructure.Serialization.Tests/Messages/InitMessageTests.cs index e9c5805e..4ab98a42 100644 --- a/test/NLightning.Infrastructure.Serialization.Tests/Messages/InitMessageTests.cs +++ b/test/NLightning.Infrastructure.Serialization.Tests/Messages/InitMessageTests.cs @@ -34,7 +34,7 @@ public async Task var stream = new MemoryStream( Convert.FromHexString( - "000202000002020001206FE28C0AB6F1B372C1A6A246AE63F74F931E8365E15A089C68D6190000000000")); + "00025101000610000000510101206fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000")); // Act var initMessage = await _initMessageTypeSerializer.DeserializeAsync(stream); @@ -53,7 +53,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 +83,7 @@ public async Task Given_ValidPayloadAndExtension_When_SerializeAsync_Then_Writes var stream = new MemoryStream(); var expectedBytes = Convert.FromHexString( - "000202000002020001206FE28C0AB6F1B372C1A6A246AE63F74F931E8365E15A089C68D6190000000000"); + "00025101000610000000510101206fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000"); // Act await _initMessageTypeSerializer.SerializeAsync(message, stream); @@ -101,7 +101,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 7286e600..64ec1624 100644 --- a/test/NLightning.Infrastructure.Serialization.Tests/Node/FeatureSetSerializerTests.cs +++ b/test/NLightning.Infrastructure.Serialization.Tests/Node/FeatureSetSerializerTests.cs @@ -15,22 +15,16 @@ 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.OptionAnchorsZeroFeeHtlcTx, false, 3)] - [InlineData(Feature.OptionAnchorsZeroFeeHtlcTx, 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(); @@ -50,21 +44,14 @@ public async Task Given_Features_When_Serialize_Then_BytesAreTrimmed(Feature fea } [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.OptionAnchorsZeroFeeHtlcTx, false, 3)] - [InlineData(Feature.OptionAnchorsZeroFeeHtlcTx, 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(); @@ -83,27 +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.OptionAnchorsZeroFeeHtlcTx, false, new byte[] { 128, 32, 0 })] - [InlineData(Feature.OptionAnchorsZeroFeeHtlcTx, 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) + [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(); @@ -120,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(); @@ -132,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] @@ -140,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(); @@ -154,9 +143,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)] @@ -166,13 +157,14 @@ 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)] [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 +204,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/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/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/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..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; @@ -109,11 +108,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 +124,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(); @@ -141,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; diff --git a/test/NLightning.Integration.Tests/Docker/ChannelOpeningFlowTests.cs b/test/NLightning.Integration.Tests/Docker/ChannelOpeningFlowTests.cs new file mode 100644 index 00000000..c34cba2f --- /dev/null +++ b/test/NLightning.Integration.Tests/Docker/ChannelOpeningFlowTests.cs @@ -0,0 +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(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 diff --git a/test/NLightning.Integration.Tests/Docker/Mock/FakeSecureKeyManager.cs b/test/NLightning.Integration.Tests/Docker/Mock/FakeSecureKeyManager.cs index d611f70c..12e0c14b 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; @@ -9,25 +10,57 @@ namespace NLightning.Integration.Tests.Docker.Mock; public class FakeSecureKeyManager : ISecureKeyManager { private readonly ExtKey _nodeKey; + 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); + + private readonly object _lastUsedIndexLock = new(); + private uint _lastUsedIndex; + + public BitcoinKeyPath KeyPath => new([]); + + // ReSharper disable once UnassignedGetOnlyAutoProperty + public BitcoinKeyPath ChannelKeyPath { get; } - public BitcoinKeyPath KeyPath => new BitcoinKeyPath([]); // 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 GetNextChannelKey(out uint index) + { + lock (_lastUsedIndexLock) + { + _lastUsedIndex++; + index = _lastUsedIndex; + } + + var derivedKey = _nodeKey.Derive(_channelKeyPath.Derive(index)); + return derivedKey.ToBytes(); + } + + public ExtPrivKey GetChannelKeyAtIndex(uint index) + { + var derivedKey = _nodeKey.Derive(_channelKeyPath.Derive(index)); + return derivedKey.ToBytes(); } - public ExtPrivKey GetNextKey(out uint index) + public ExtPrivKey GetDepositP2TrKeyAtIndex(uint index, bool isChange) { - index = 0; - return _nodeKey.ToBytes(); + return _p2TrKey.Derive(_depositP2TrKeyPath.Derive(isChange ? "1" : "0")).Derive(index).ToBytes(); } - public ExtPrivKey GetKeyAtIndex(uint index) + public ExtPrivKey GetDepositP2WpkhKeyAtIndex(uint index, bool isChange) { - return _nodeKey.ToBytes(); + return _p2WpkhKey.Derive(_depositP2WpkhKeyPath.Derive(isChange ? "1" : "0")).Derive(index).ToBytes(); } public CryptoKeyPair GetNodeKeyPair() 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 @@ + 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