diff --git a/src/Services/LightningService.cs b/src/Services/LightningService.cs index 972ecd2f..a581d07d 100644 --- a/src/Services/LightningService.cs +++ b/src/Services/LightningService.cs @@ -176,7 +176,6 @@ public async Task OpenChannel(ChannelOperationRequest channelOperationRequest) var network = CurrentNetworkHelper.GetCurrentNetwork(); - var closeAddress = await GetCloseAddress(channelOperationRequest, derivationStrategyBase, _nbXplorerService, _logger); _logger.LogInformation("Channel open request for request id: {RequestId} from node: {SourceNodeName} to node: {DestinationNodeName}", channelOperationRequest.Id, @@ -193,54 +192,24 @@ public async Task OpenChannel(ChannelOperationRequest channelOperationRequest) try { var humanSignaturesCount = channelOperationRequest.ChannelOperationRequestPsbts.Count( - x => channelOperationRequest.Wallet != null && - !x.IsFinalisedPSBT && - !x.IsInternalWalletPSBT && + x => channelOperationRequest.Wallet != null && + !x.IsFinalisedPSBT && + !x.IsInternalWalletPSBT && !x.IsTemplatePSBT); - + //If it is a hot wallet, we dont check the number of (human) signatures - if (channelOperationRequest.Wallet != null && !channelOperationRequest.Wallet.IsHotWallet && channelOperationRequest.Wallet != null && humanSignaturesCount != channelOperationRequest.Wallet.MofN -1) + if (channelOperationRequest.Wallet != null && !channelOperationRequest.Wallet.IsHotWallet && channelOperationRequest.Wallet != null && humanSignaturesCount != channelOperationRequest.Wallet.MofN - 1) { - _logger.LogError("The number of human signatures does not match the number of signatures required for this wallet, expected {MofN} but got {HumanSignaturesCount}", channelOperationRequest.Wallet.MofN-1, humanSignaturesCount); + _logger.LogError("The number of human signatures does not match the number of signatures required for this wallet, expected {MofN} but got {HumanSignaturesCount}", channelOperationRequest.Wallet.MofN - 1, humanSignaturesCount); throw new InvalidOperationException("The number of human signatures does not match the number of signatures required for this wallet"); } - if (!combinedPSBT.TryGetVirtualSize(out var estimatedVsize)) - { - _logger.LogError("Could not estimate virtual size of the PSBT"); - throw new InvalidOperationException("Could not estimate virtual size of the PSBT"); - } - if(channelOperationRequest.Changeless && combinedPSBT.Outputs.Any()) + if (channelOperationRequest.Changeless && combinedPSBT.Outputs.Any()) { _logger.LogError("Changeless channel operation request cannot have outputs at this stage"); throw new InvalidOperationException("Changeless channel operation request cannot have outputs at this stage"); } - var changelessVSize = channelOperationRequest.Changeless ? 43 : 0; // 8 value + 1 script pub key size + 34 script pub key hash (Segwit output 2-0f-2 multisig) - var outputVirtualSize = estimatedVsize + changelessVSize; // We add the change output if needed - var initialFeeRate = channelOperationRequest.FeeRate ?? (await LightningHelper.GetFeeRateResult(network, _nbXplorerService)).FeeRate.SatoshiPerByte;; - - var totalFees = new Money(outputVirtualSize * initialFeeRate, MoneyUnit.Satoshi); - - long fundingAmount = channelOperationRequest.Changeless ? channelOperationRequest.SatsAmount - totalFees : channelOperationRequest.SatsAmount; - //We prepare the request (shim) with the base PSBT we had presigned with the UTXOs to fund the channel - var openChannelRequest = new OpenChannelRequest - { - FundingShim = new FundingShim - { - PsbtShim = new PsbtShim - { - BasePsbt = ByteString.FromBase64(combinedPSBT.ToBase64()), - NoPublish = false, - PendingChanId = ByteString.CopyFrom(pendingChannelId) - } - }, - LocalFundingAmount = fundingAmount, - CloseAddress = closeAddress.Address.ToString(), - Private = channelOperationRequest.IsChannelPrivate, - NodePubkey = ByteString.CopyFrom(Convert.FromHexString(destination.PubKey)), - }; - //Prior to opening the channel, we add the remote node as a peer var remoteNodeInfo = await GetNodeInfo(channelOperationRequest.DestNode?.PubKey); if (remoteNodeInfo == null) @@ -250,8 +219,15 @@ public async Task OpenChannel(ChannelOperationRequest channelOperationRequest) throw new InvalidOperationException(); } + var initialFeeRate = channelOperationRequest.FeeRate ?? (await LightningHelper.GetFeeRateResult(network, _nbXplorerService)).FeeRate.SatoshiPerByte; + ; + + var fundingAmount = GetFundingAmount(channelOperationRequest, combinedPSBT, initialFeeRate); + + var openChannelRequest = await CreateOpenChannelRequest(channelOperationRequest, combinedPSBT, remoteNodeInfo, fundingAmount, pendingChannelId, derivationStrategyBase); + //For now, we only rely on pure tcp IPV4 connections - var addr = remoteNodeInfo.Addresses.FirstOrDefault(x => x.Network == "tcp").Addr; + var addr = remoteNodeInfo.Addresses.FirstOrDefault(x => x.Network == "tcp")?.Addr; if (addr == null) { @@ -281,6 +257,7 @@ public async Task OpenChannel(ChannelOperationRequest channelOperationRequest) { throw new PeerNotOnlineException($"$peer {destination.PubKey} is not online"); } + if (!e.Message.Contains("already connected to peer")) { throw; @@ -346,6 +323,7 @@ public async Task OpenChannel(ChannelOperationRequest channelOperationRequest) { channelOperationRequest.StatusLogs.Add(ChannelStatusLog.Info($"Channel opened successfully 🎉")); } + _channelOperationRequestRepository.Update(channelOperationRequest); var fundingTx = LightningHelper.DecodeTxId(response.ChanOpen.ChannelPoint.FundingTxidBytes); @@ -368,7 +346,7 @@ public async Task OpenChannel(ChannelOperationRequest channelOperationRequest) CreationDatetime = DateTimeOffset.Now, FundingTx = fundingTx, FundingTxOutputIndex = response.ChanOpen.ChannelPoint.OutputIndex, - BtcCloseAddress = closeAddress?.Address.ToString(), + BtcCloseAddress = openChannelRequest.CloseAddress, SatsAmount = channelOperationRequest.SatsAmount, UpdateDatetime = DateTimeOffset.Now, Status = Channel.ChannelStatus.Open, @@ -526,7 +504,7 @@ public async Task OpenChannel(ChannelOperationRequest channelOperationRequest) } //if the fee is too high, we throw an exception - var finalizedTotalIn = finalizedPSBT.Inputs.Sum(x => (long) x.GetCoin()?.Amount); + var finalizedTotalIn = finalizedPSBT.Inputs.Sum(x => (long)x.GetCoin()?.Amount); if (finalizedPSBT.GetFee().Satoshi >= finalizedTotalIn * Constants.MAX_TX_FEE_RATIO) { @@ -626,10 +604,12 @@ public async Task OpenChannel(ChannelOperationRequest channelOperationRequest) // TODO: Make exception message pretty throw new RemoteCanceledFundingException(e.Message); } + if (e.Message.Contains("is not online")) { throw new PeerNotOnlineException($"$peer {destination.PubKey} is not online"); } + throw; } } @@ -655,6 +635,58 @@ public async Task OpenChannel(ChannelOperationRequest channelOperationRequest) return new Lightning.LightningClient(grpcChannel).Wrap(); }; + public long GetFundingAmount(ChannelOperationRequest channelOperationRequest, PSBT combinedPSBT, decimal initialFeeRate) + { + if (!combinedPSBT.TryGetVirtualSize(out var estimatedVsize)) + { + _logger.LogError("Could not estimate virtual size of the PSBT"); + throw new InvalidOperationException("Could not estimate virtual size of the PSBT"); + } + + var changelessVSize = channelOperationRequest.Changeless ? 43 : 0; // 8 value + 1 script pub key size + 34 script pub key hash (Segwit output 2-0f-2 multisig) + var outputVirtualSize = estimatedVsize + changelessVSize; // We add the change output if needed + + var totalFees = new Money(outputVirtualSize * initialFeeRate, MoneyUnit.Satoshi); + return channelOperationRequest.Changeless ? channelOperationRequest.SatsAmount - totalFees : channelOperationRequest.SatsAmount; + } + + public async Task CreateOpenChannelRequest(ChannelOperationRequest channelOperationRequest, PSBT? combinedPSBT, LightningNode? remoteNodeInfo, long fundingAmount, byte[] pendingChannelId, DerivationStrategyBase? derivationStrategyBase) + { + if (combinedPSBT == null) throw new ArgumentNullException(nameof(combinedPSBT)); + if (remoteNodeInfo == null) throw new ArgumentNullException(nameof(remoteNodeInfo)); + if (derivationStrategyBase == null) throw new ArgumentNullException(nameof(derivationStrategyBase)); + + //We prepare the request (shim) with the base PSBT we had presigned with the UTXOs to fund the channel + var openChannelRequest = new OpenChannelRequest + { + FundingShim = new FundingShim + { + PsbtShim = new PsbtShim + { + BasePsbt = ByteString.FromBase64(combinedPSBT.ToBase64()), + NoPublish = false, + PendingChanId = ByteString.CopyFrom(pendingChannelId) + } + }, + LocalFundingAmount = fundingAmount, + Private = channelOperationRequest.IsChannelPrivate, + NodePubkey = ByteString.CopyFrom(Convert.FromHexString(remoteNodeInfo.PubKey)), + }; + + // Check features to see if we need or is allowed to add a close address + var upfrontShutdownScriptOpt = remoteNodeInfo.Features.ContainsKey((uint)FeatureBit.UpfrontShutdownScriptOpt); + var upfrontShutdownScriptReq = remoteNodeInfo.Features.ContainsKey((uint)FeatureBit.UpfrontShutdownScriptReq); + if (upfrontShutdownScriptOpt && remoteNodeInfo.Features[(uint)FeatureBit.UpfrontShutdownScriptOpt] is { IsKnown: true } || + upfrontShutdownScriptReq && remoteNodeInfo.Features[(uint)FeatureBit.UpfrontShutdownScriptReq] is { IsKnown: true }) + { + var address = await GetCloseAddress(channelOperationRequest, derivationStrategyBase, _nbXplorerService, _logger); + openChannelRequest.CloseAddress = address.Address.ToString(); + ; + } + + return openChannelRequest; + } + public static PSBT GetCombinedPsbt(ChannelOperationRequest channelOperationRequest, ILogger? _logger = null) { //PSBT Combine @@ -672,7 +704,7 @@ public static PSBT GetCombinedPsbt(ChannelOperationRequest channelOperationReque throw new ArgumentException(invalidPsbtNullToBeUsedForTheRequest, nameof(combinedPSBT)); } - public static async Task GetCloseAddress(ChannelOperationRequest channelOperationRequest, + public static async Task GetCloseAddress(ChannelOperationRequest channelOperationRequest, DerivationStrategyBase derivationStrategyBase, INBXplorerService nbXplorerService, ILogger? _logger = null) { var closeAddress = await diff --git a/test/FundsManager.Tests/Services/LightningServiceTests.cs b/test/FundsManager.Tests/Services/LightningServiceTests.cs index ac9d813f..24f26da2 100644 --- a/test/FundsManager.Tests/Services/LightningServiceTests.cs +++ b/test/FundsManager.Tests/Services/LightningServiceTests.cs @@ -17,6 +17,7 @@ * */ +using System.Security.Cryptography; using Microsoft.Extensions.Logging; using FundsManager.Data; using FundsManager.Data.Models; @@ -297,7 +298,7 @@ private static Mock GetNBXplorerServiceFullyMocked(UTXOChange var nbXplorerMock = new Mock(); //Mock to return a wallet address var keyPathInformation = new KeyPathInformation() - {Address = BitcoinAddress.Create("bcrt1q590shaxaf5u08ml8jwlzghz99dup3z9592vxal", Network.RegTest)}; + { Address = BitcoinAddress.Create("bcrt1q590shaxaf5u08ml8jwlzghz99dup3z9592vxal", Network.RegTest) }; nbXplorerMock .Setup(x => x.GetUnusedAsync(It.IsAny(), It.IsAny(), @@ -426,7 +427,7 @@ public async Task OpenChannel_SuccessLegacyMultiSig() .Setup(x => x.GetById(It.IsAny())) .ReturnsAsync(operationRequest); - var nodes = new List {destinationNode}; + var nodes = new List { destinationNode }; nodeRepository .Setup(x => x.GetAllManagedByNodeGuard()) @@ -527,7 +528,7 @@ public async Task OpenChannel_SuccessLegacyMultiSig() }, }; - utxoChanges.Confirmed = new UTXOChange() {UTXOs = utxoList}; + utxoChanges.Confirmed = new UTXOChange() { UTXOs = utxoList }; var channelOperationRequestPsbtRepository = new Mock(); channelOperationRequestPsbtRepository @@ -654,7 +655,7 @@ public async Task OpenChannel_SuccessMultiSig() .Setup(x => x.GetById(It.IsAny())) .ReturnsAsync(operationRequest); - var nodes = new List {destinationNode}; + var nodes = new List { destinationNode }; nodeRepository .Setup(x => x.GetAllManagedByNodeGuard()) @@ -755,7 +756,7 @@ public async Task OpenChannel_SuccessMultiSig() }, }; - utxoChanges.Confirmed = new UTXOChange() {UTXOs = utxoList}; + utxoChanges.Confirmed = new UTXOChange() { UTXOs = utxoList }; var channelOperationRequestPsbtRepository = new Mock(); channelOperationRequestPsbtRepository @@ -882,7 +883,7 @@ public async Task OpenChannel_SuccessSingleSigBip39() .Setup(x => x.GetById(It.IsAny())) .ReturnsAsync(operationRequest); - var nodes = new List {destinationNode}; + var nodes = new List { destinationNode }; nodeRepository .Setup(x => x.GetAllManagedByNodeGuard()) @@ -983,7 +984,7 @@ public async Task OpenChannel_SuccessSingleSigBip39() }, }; - utxoChanges.Confirmed = new UTXOChange() {UTXOs = utxoList}; + utxoChanges.Confirmed = new UTXOChange() { UTXOs = utxoList }; var channelOperationRequestPsbtRepository = new Mock(); channelOperationRequestPsbtRepository @@ -1059,7 +1060,7 @@ public async Task OpenChannel_SuccessSingleSigBip39() //TODO Remove hack LightningService.CreateLightningClient = originalCreateLightningClient; } - + /// /// This tests makes sure that if a multisig wallet is used, the number of signatures is correct. /// This means that we need in in a m-of-n multisig, m-1 signatures so nodeguard is that last one to sign to avoid leaking signatures with SIGHASH_NONE @@ -1086,9 +1087,9 @@ public async Task OpenChannel_FailedIncorrectNumberOfHumanSigs() { PSBT = userSignedPSBT, }); - + //Lets add a second signed "human" PSBT - + channelOpReqPsbts.Add(new ChannelOperationRequestPSBT() { PSBT = userSignedPSBT, @@ -1120,7 +1121,7 @@ public async Task OpenChannel_FailedIncorrectNumberOfHumanSigs() .Setup(x => x.GetById(It.IsAny())) .ReturnsAsync(operationRequest); - var nodes = new List {destinationNode}; + var nodes = new List { destinationNode }; nodeRepository .Setup(x => x.GetAllManagedByNodeGuard()) @@ -1221,7 +1222,7 @@ public async Task OpenChannel_FailedIncorrectNumberOfHumanSigs() }, }; - utxoChanges.Confirmed = new UTXOChange() {UTXOs = utxoList}; + utxoChanges.Confirmed = new UTXOChange() { UTXOs = utxoList }; var channelOperationRequestPsbtRepository = new Mock(); channelOperationRequestPsbtRepository @@ -1347,7 +1348,7 @@ public async Task OpenChannel_SuccessSingleSig() .Setup(x => x.GetById(It.IsAny())) .ReturnsAsync(operationRequest); - var nodes = new List {destinationNode}; + var nodes = new List { destinationNode }; nodeRepository .Setup(x => x.GetAllManagedByNodeGuard()) @@ -1448,7 +1449,7 @@ public async Task OpenChannel_SuccessSingleSig() }, }; - utxoChanges.Confirmed = new UTXOChange() {UTXOs = utxoList}; + utxoChanges.Confirmed = new UTXOChange() { UTXOs = utxoList }; var channelOperationRequestPsbtRepository = new Mock(); channelOperationRequestPsbtRepository @@ -1612,5 +1613,103 @@ public async Task CloseChannel_Succeeds() //TODO Remove hack LightningService.CreateLightningClient = originalCreateLightningClient; } + + [Fact] + public async Task? CreateOpenChannelRequest_CreatesRequestWithoutClosingAddress() + { + // Arrange + var wallet = CreateWallet.SingleSig(_internalWallet); + var channelOperationRequest = new ChannelOperationRequest + { + Wallet = wallet + }; + var psbt = + "cHNidP8BAFIBAAAAAeh7YDXyZE11vXb0yRqCkrxY7VpHH1WVMHwaCWYMv/pCAQAAAAD/////AUjf9QUAAAAAFgAULTCtUNMojFQZ8oa6fpbXbDhK2EYAAAAATwEENYfPA325Ro0AAAABg9H86IDUttPPFss+9te+0DByQgbeD7RPXNuVH9mh1qIDnMEWyKA+kvyG038on8+HxI+9AD8r6ZI1dNIDSGC8824Q7QIQyDAAAIABAACAAQAAAAABAR8A4fUFAAAAABYAFOk69QEyo0x+Xs/zV62OLrHh9eszAQMEAgAAAAAA"; + + var combinedPsbt = LightningHelper.CombinePSBTs(new[] { psbt }); + var lightningService = new LightningService(_logger, null, null, null, null, null, null, null, null); + var pendingChannelId = RandomNumberGenerator.GetBytes(32); + var derivationStrategyBase = LightningService.GetDerivationStrategyBase(channelOperationRequest); + var node = new LightningNode() + { + PubKey = "03650f49929d84d9a6d9b5a66235c603a1a0597dd609f7cd3b15052382cf9bb1b4" + }; + + // Act + var openChannelRequest = await lightningService.CreateOpenChannelRequest(channelOperationRequest, combinedPsbt, node, 1000, pendingChannelId, derivationStrategyBase); + + // Assert + openChannelRequest.Should().Be(new OpenChannelRequest() + { + FundingShim = new FundingShim + { + PsbtShim = new PsbtShim + { + BasePsbt = ByteString.FromBase64(combinedPsbt.ToBase64()), + NoPublish = false, + PendingChanId = ByteString.CopyFrom(pendingChannelId) + } + }, + LocalFundingAmount = 1000, + Private = false, + NodePubkey = ByteString.CopyFrom(Convert.FromHexString("03650f49929d84d9a6d9b5a66235c603a1a0597dd609f7cd3b15052382cf9bb1b4")), + CloseAddress = "" + }); + } + + [Fact] + public async Task? CreateOpenChannelRequest_CreatesRequestWithClosingAddress() + { + // Arrange + var nbXplorerMock = new Mock(); + + nbXplorerMock.Setup(x => x.GetUnusedAsync(It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.FromResult(new KeyPathInformation() { Address = BitcoinAddress.Create("bcrt1q590shaxaf5u08ml8jwlzghz99dup3z9592vxal", Network.RegTest) })); + + + var wallet = CreateWallet.SingleSig(_internalWallet); + var channelOperationRequest = new ChannelOperationRequest + { + Wallet = wallet + }; + var psbt = + "cHNidP8BAFIBAAAAAeh7YDXyZE11vXb0yRqCkrxY7VpHH1WVMHwaCWYMv/pCAQAAAAD/////AUjf9QUAAAAAFgAULTCtUNMojFQZ8oa6fpbXbDhK2EYAAAAATwEENYfPA325Ro0AAAABg9H86IDUttPPFss+9te+0DByQgbeD7RPXNuVH9mh1qIDnMEWyKA+kvyG038on8+HxI+9AD8r6ZI1dNIDSGC8824Q7QIQyDAAAIABAACAAQAAAAABAR8A4fUFAAAAABYAFOk69QEyo0x+Xs/zV62OLrHh9eszAQMEAgAAAAAA"; + + var combinedPsbt = LightningHelper.CombinePSBTs(new[] { psbt }); + var lightningService = new LightningService(_logger, null, null, null, null, null, null, nbXplorerMock.Object, null); + var pendingChannelId = RandomNumberGenerator.GetBytes(32); + var derivationStrategyBase = LightningService.GetDerivationStrategyBase(channelOperationRequest); + + var node = new LightningNode() + { + PubKey = "03650f49929d84d9a6d9b5a66235c603a1a0597dd609f7cd3b15052382cf9bb1b4", + }; + node.Features.Add((uint)FeatureBit.UpfrontShutdownScriptOpt, new Feature() { Name = "upfront-shutdown-script", IsKnown = true, IsRequired = false }); + + // Act + var openChannelRequest = await lightningService.CreateOpenChannelRequest(channelOperationRequest, combinedPsbt, node, 1000, pendingChannelId, derivationStrategyBase); + + // Assert + openChannelRequest.Should().Be(new OpenChannelRequest() + { + FundingShim = new FundingShim + { + PsbtShim = new PsbtShim + { + BasePsbt = ByteString.FromBase64(combinedPsbt.ToBase64()), + NoPublish = false, + PendingChanId = ByteString.CopyFrom(pendingChannelId) + } + }, + LocalFundingAmount = 1000, + Private = false, + NodePubkey = ByteString.CopyFrom(Convert.FromHexString("03650f49929d84d9a6d9b5a66235c603a1a0597dd609f7cd3b15052382cf9bb1b4")), + CloseAddress = "bcrt1q590shaxaf5u08ml8jwlzghz99dup3z9592vxal" + }); + } } } \ No newline at end of file