diff --git a/src/Proto/nodeguard.proto b/src/Proto/nodeguard.proto index f5b3654d..ec3e6192 100644 --- a/src/Proto/nodeguard.proto +++ b/src/Proto/nodeguard.proto @@ -6,7 +6,7 @@ option go_package = "./nodeguard"; service NodeGuardService { /* - + Returns the liquidity rules associated to a node and its channels */ rpc GetLiquidityRules(GetLiquidityRulesRequest) returns (GetLiquidityRulesResponse); @@ -162,7 +162,10 @@ message OpenChannelRequest { int64 sats_amount = 3; // Whether the channel should be private bool private = 4; - + // Whether the channel should be created in a changeless way + bool changeless = 6; + // Outpoints for the UTXOs to use for the channel + repeated string utxos_outpoints = 7; } // A successful response is an empty message and does NOT indicate that the channel has been open, external monitoring is required diff --git a/src/Rpc/NodeGuardService.cs b/src/Rpc/NodeGuardService.cs index bf7816b4..ceea97f6 100644 --- a/src/Rpc/NodeGuardService.cs +++ b/src/Rpc/NodeGuardService.cs @@ -8,6 +8,7 @@ using Grpc.Core; using NBitcoin; using NBXplorer.DerivationStrategy; +using NBXplorer.Models; using Nodeguard; using Quartz; using LiquidityRule = Nodeguard.LiquidityRule; @@ -54,6 +55,7 @@ public class NodeGuardService : Nodeguard.NodeGuardService.NodeGuardServiceBase, private readonly INodeRepository _nodeRepository; private readonly IChannelOperationRequestRepository _channelOperationRequestRepository; private readonly IChannelRepository _channelRepository; + private readonly ICoinSelectionService _coinSelectionService; private readonly IScheduler _scheduler; public NodeGuardService(ILogger logger, @@ -66,7 +68,8 @@ public NodeGuardService(ILogger logger, ISchedulerFactory schedulerFactory, INodeRepository nodeRepository, IChannelOperationRequestRepository channelOperationRequestRepository, - IChannelRepository channelRepository + IChannelRepository channelRepository, + ICoinSelectionService coinSelectionService ) { _logger = logger; @@ -80,6 +83,7 @@ IChannelRepository channelRepository _nodeRepository = nodeRepository; _channelOperationRequestRepository = channelOperationRequestRepository; _channelRepository = channelRepository; + _coinSelectionService = coinSelectionService; _scheduler = Task.Run(() => _schedulerFactory.GetScheduler()).Result; } @@ -348,8 +352,33 @@ public override async Task OpenChannel(OpenChannelRequest r throw new RpcException(new Status(StatusCode.NotFound, "Destination node not found")); } + var wallet = await _walletRepository.GetById(request.WalletId); + if (wallet == null) + { + throw new RpcException(new Status(StatusCode.NotFound, "Wallet not found")); + } + + if (request.Changeless && request.UtxosOutpoints.Count == 0) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, "Changeless channel open requires utxos")); + } + try { + var outpoints = new List(); + var utxos = new List(); + + if (request.Changeless) + { + foreach (var outpoint in request.UtxosOutpoints) + { + outpoints.Add(OutPoint.Parse(outpoint)); + } + + // Search the utxos and lock them + utxos = await _coinSelectionService.GetUTXOsByOutpointAsync(wallet.GetDerivationStrategy(), outpoints); + } + var channelOperationRequest = new ChannelOperationRequest { SatsAmount = request.SatsAmount, @@ -360,9 +389,10 @@ public override async Task OpenChannel(OpenChannelRequest r WalletId = request.WalletId, SourceNodeId = sourceNode.Id, DestNodeId = destNode.Id, - /*UserId = null, //TODO User & Auth - User = null,*/ + /*UserId = null, //TODO User & Auth + User = null,*/ IsChannelPrivate = request.Private, + Changeless = request.Changeless, }; //Persist request @@ -373,6 +403,13 @@ public override async Task OpenChannel(OpenChannelRequest r throw new RpcException(new Status(StatusCode.Internal, "Error adding channel operation request")); } + if (request.Changeless) + { + // Lock the utxos + await _coinSelectionService.LockUTXOs(utxos, channelOperationRequest, + BitcoinRequestType.ChannelOperation); + } + //Fire Open Channel Job var scheduler = await _schedulerFactory.GetScheduler(); @@ -407,7 +444,7 @@ public override async Task CloseChannel(CloseChannelReques { //Get channel by its chan_id (id of the ln implementation) var channel = await _channelRepository.GetByChanId(request.ChannelId); - + if (channel == null) { throw new RpcException(new Status(StatusCode.NotFound, "Channel not found")); @@ -416,10 +453,10 @@ public override async Task CloseChannel(CloseChannelReques try { //Create channel operation request - + var channelOperationRequest = new ChannelOperationRequest { - Description = "Channel close (API)", + Description = "Channel close (API)", Status = ChannelOperationRequestStatus.Pending, RequestType = OperationRequestType.Close, SourceNodeId = channel.SourceNodeId, @@ -428,32 +465,32 @@ public override async Task CloseChannel(CloseChannelReques /*UserId = null, //TODO User & Auth */ }; - + //Persist request - + var result = await _channelOperationRequestRepository.AddAsync(channelOperationRequest); - + if (!result.Item1) { _logger?.LogError("Error adding channel operation request, error: {error}", result.Item2); throw new RpcException(new Status(StatusCode.Internal, "Error adding channel operation request")); } - + //Fire Close Channel Job var scheduler = await _schedulerFactory.GetScheduler(); - + var map = new JobDataMap(); map.Put("closeRequestId", channelOperationRequest.Id); map.Put("forceClose", request.Force); - + var retryList = RetriableJob.ParseRetryListFromString(Constants.JOB_RETRY_INTERVAL_LIST_IN_MINUTES); var job = RetriableJob.Create(map, channelOperationRequest.Id.ToString(), retryList); await scheduler.ScheduleJob(job.Job, job.Trigger); - + channelOperationRequest.JobId = job.Job.Key.ToString(); - + var jobUpdateResult = _channelOperationRequestRepository.Update(channelOperationRequest); - + if (!jobUpdateResult.Item1) { _logger?.LogError("Error updating channel operation request, error: {error}", jobUpdateResult.Item2); diff --git a/src/Services/CoinSelectionService.cs b/src/Services/CoinSelectionService.cs index 5a70719f..22818a90 100644 --- a/src/Services/CoinSelectionService.cs +++ b/src/Services/CoinSelectionService.cs @@ -47,6 +47,13 @@ public interface ICoinSelectionService /// public Task> GetAvailableUTXOsAsync(DerivationStrategyBase derivationStrategy, CoinSelectionStrategy strategy, int limit, long amount, long closestTo); + /// + /// Gets the UTXOs that are not locked in other transactions related to the outpoints + /// + /// + /// + public Task> GetUTXOsByOutpointAsync(DerivationStrategyBase derivationStrategy, List outPoints); + /// /// Locks the UTXOs for using in a specific transaction /// @@ -194,4 +201,10 @@ public async Task> GetAvailableUTXOsAsync(DerivationStrategyBase deri return (coins, selectedUTXOs); } + + public async Task> GetUTXOsByOutpointAsync(DerivationStrategyBase derivationStrategy, List outPoints) + { + var utxos = await _nbXplorerService.GetUTXOsAsync(derivationStrategy); + return utxos.Confirmed.UTXOs.Where(utxo => outPoints.Contains(utxo.Outpoint)).ToList(); + } } \ No newline at end of file diff --git a/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs b/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs index 4bae5b61..442361d0 100644 --- a/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs +++ b/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs @@ -77,7 +77,7 @@ public async Task OpenChannel_SourceNodeNotFound_ReturnsNotFoundError() new Mock().Object, new Mock().Object, schedulerFactoryMock, nodeRepositoryMock.Object, - channelOperationRequestRepositoryMock.Object, null); + channelOperationRequestRepositoryMock.Object, null, null); var request = new OpenChannelRequest { @@ -118,7 +118,7 @@ public async Task OpenChannel_DestinationNodeNotFound_ReturnsNotFoundError() new Mock().Object, new Mock().Object, schedulerFactoryMock, nodeRepositoryMock.Object, - channelOperationRequestRepositoryMock.Object, null); + channelOperationRequestRepositoryMock.Object, null, null); var request = new OpenChannelRequest { @@ -144,6 +144,8 @@ public async Task OpenChannel_ValidRequest_OpensChannelAndReturnsResponse() var nodeRepositoryMock = new Mock(); var channelOperationRequestRepositoryMock = new Mock(); var schedulerFactoryMock = GetSchedulerFactoryMock(); + var coinSelectionServiceMock = new Mock(); + var walletRepositoryMock = new Mock(); var sourcePubKey = "sourcePubKey"; var destPubKey = "destPubKey"; @@ -153,6 +155,8 @@ public async Task OpenChannel_ValidRequest_OpensChannelAndReturnsResponse() .ReturnsAsync(new Node {Id = 1, PubKey = sourcePubKey}); nodeRepositoryMock.Setup(repo => repo.GetByPubkey(destPubKey)) .ReturnsAsync(new Node {Id = 2, PubKey = destPubKey}); + walletRepositoryMock.Setup(repo => repo.GetById(walletId)) + .ReturnsAsync(new Wallet {Id = walletId}); channelOperationRequestRepositoryMock.Setup(repo => repo.AddAsync(It.IsAny())) .ReturnsAsync((true, null)); channelOperationRequestRepositoryMock.Setup(repo => repo.Update(It.IsAny())) @@ -160,12 +164,79 @@ public async Task OpenChannel_ValidRequest_OpensChannelAndReturnsResponse() var service = new NodeGuardService(_logger.Object, new Mock().Object, - new Mock().Object, + walletRepositoryMock.Object, + _mockMapper, new Mock().Object, + new Mock().Object, new Mock().Object, + schedulerFactoryMock, + nodeRepositoryMock.Object, + channelOperationRequestRepositoryMock.Object, null, coinSelectionServiceMock.Object); + + var request = new OpenChannelRequest + { + SourcePubKey = sourcePubKey, + DestinationPubKey = destPubKey, + SatsAmount = 10000, + Private = false, + WalletId = walletId, + }; + var context = new Mock().Object; + + // Act + Func act = async () => await service.OpenChannel(request, context); + + // Clear Jobs + var scheduler = await schedulerFactoryMock.GetScheduler(); + await scheduler.Clear(); + + // Assert + await act.Should().NotThrowAsync(); + nodeRepositoryMock.Verify(repo => repo.GetByPubkey(sourcePubKey), Times.Once); + nodeRepositoryMock.Verify(repo => repo.GetByPubkey(destPubKey), Times.Once); + channelOperationRequestRepositoryMock.Verify(repo => repo.AddAsync(It.IsAny()), + Times.Once); + channelOperationRequestRepositoryMock.Verify(repo => repo.Update(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task OpenChannel_ValidRequest_OpensChangelessChannelAndReturnsResponse() + { + // Arrange + var nodeRepositoryMock = new Mock(); + var channelOperationRequestRepositoryMock = new Mock(); + var schedulerFactoryMock = GetSchedulerFactoryMock(); + var coinSelectionServiceMock = new Mock(); + var walletRepositoryMock = new Mock(); + + var sourcePubKey = "sourcePubKey"; + var destPubKey = "destPubKey"; + var walletId = 1; + var wallet = new Wallet() {Id = walletId}; + + nodeRepositoryMock.Setup(repo => repo.GetByPubkey(sourcePubKey)) + .ReturnsAsync(new Node {Id = 1, PubKey = sourcePubKey}); + nodeRepositoryMock.Setup(repo => repo.GetByPubkey(destPubKey)) + .ReturnsAsync(new Node {Id = 2, PubKey = destPubKey}); + walletRepositoryMock.Setup(repo => repo.GetById(walletId)) + .ReturnsAsync(wallet); + coinSelectionServiceMock.Setup(service => service.GetUTXOsByOutpointAsync(wallet.GetDerivationStrategy(), + new List { new OutPoint(), new OutPoint() })) + .ReturnsAsync(new List { new UTXO() }); + channelOperationRequestRepositoryMock.Setup(repo => repo.AddAsync(It.IsAny())) + .ReturnsAsync((true, null)); + coinSelectionServiceMock.Setup(service => service.LockUTXOs(new List { new UTXO() }, + new ChannelOperationRequest(), BitcoinRequestType.ChannelOperation)); + channelOperationRequestRepositoryMock.Setup(repo => repo.Update(It.IsAny())) + .Returns((true, null)); + + + var service = new NodeGuardService(_logger.Object, new Mock().Object, + walletRepositoryMock.Object, _mockMapper, new Mock().Object, new Mock().Object, new Mock().Object, schedulerFactoryMock, nodeRepositoryMock.Object, - channelOperationRequestRepositoryMock.Object, null); + channelOperationRequestRepositoryMock.Object, null, coinSelectionServiceMock.Object); var request = new OpenChannelRequest { @@ -174,14 +245,23 @@ public async Task OpenChannel_ValidRequest_OpensChannelAndReturnsResponse() SatsAmount = 10000, Private = false, WalletId = walletId, + Changeless = true, + UtxosOutpoints = { + "6b0d07129a492c287d6fdd34c7b19f0b0136901db6c1a95e0d46e0ecde9db1c3:0", + "6b0d07129a492c287d6fdd34c7b19f0b0136901db6c1a95e0d46e0ecde9db1c3:1" + } }; var context = new Mock().Object; // Act Func act = async () => await service.OpenChannel(request, context); + // Clear Jobs + var scheduler = await schedulerFactoryMock.GetScheduler(); + await scheduler.Clear(); + // Assert - act.Should().NotThrowAsync(); + await act.Should().NotThrowAsync(); nodeRepositoryMock.Verify(repo => repo.GetByPubkey(sourcePubKey), Times.Once); nodeRepositoryMock.Verify(repo => repo.GetByPubkey(destPubKey), Times.Once); channelOperationRequestRepositoryMock.Verify(repo => repo.AddAsync(It.IsAny()), @@ -190,6 +270,55 @@ public async Task OpenChannel_ValidRequest_OpensChannelAndReturnsResponse() Times.Once); } + [Fact] + public async Task OpenChannel_FailedRequest_NoOutPoints() + { + // Arrange + var nodeRepositoryMock = new Mock(); + var channelOperationRequestRepositoryMock = new Mock(); + var schedulerFactoryMock = GetSchedulerFactoryMock(); + var coinSelectionServiceMock = new Mock(); + var walletRepositoryMock = new Mock(); + + var sourcePubKey = "sourcePubKey"; + var destPubKey = "destPubKey"; + var walletId = 1; + var wallet = new Wallet() {Id = walletId}; + + nodeRepositoryMock.Setup(repo => repo.GetByPubkey(sourcePubKey)) + .ReturnsAsync(new Node {Id = 1, PubKey = sourcePubKey}); + nodeRepositoryMock.Setup(repo => repo.GetByPubkey(destPubKey)) + .ReturnsAsync(new Node {Id = 2, PubKey = destPubKey}); + walletRepositoryMock.Setup(repo => repo.GetById(walletId)) + .ReturnsAsync(wallet); + + + var service = new NodeGuardService(_logger.Object, new Mock().Object, + walletRepositoryMock.Object, + _mockMapper, new Mock().Object, + new Mock().Object, new Mock().Object, + schedulerFactoryMock, + nodeRepositoryMock.Object, + channelOperationRequestRepositoryMock.Object, null, coinSelectionServiceMock.Object); + + var request = new OpenChannelRequest + { + SourcePubKey = sourcePubKey, + DestinationPubKey = destPubKey, + SatsAmount = 10000, + Private = false, + WalletId = walletId, + Changeless = true, + }; + var context = new Mock().Object; + + // Act + Func act = async () => await service.OpenChannel(request, context); + + // Assert + (await act.Should().ThrowAsync()).Which.Status.StatusCode.Should().Be(StatusCode.InvalidArgument); + } + [Fact] public async Task CloseChannel_ValidRequest_ClosesChannelAndReturnsResponse() { @@ -217,7 +346,7 @@ public async Task CloseChannel_ValidRequest_ClosesChannelAndReturnsResponse() new Mock().Object, new Mock().Object, schedulerFactoryMock, null, - channelOperationRequestRepositoryMock.Object, channelRepositoryMock.Object); + channelOperationRequestRepositoryMock.Object, channelRepositoryMock.Object, null); var request = new CloseChannelRequest @@ -257,7 +386,8 @@ public async Task CloseChannel_ChannelNotFound_ReturnsNotFoundError() new Mock().Object, new Mock().Object, schedulerFactoryMock, null, - channelOperationRequestRepositoryMock.Object, channelRepositoryMock.Object); + channelOperationRequestRepositoryMock.Object, channelRepositoryMock.Object, + null); var request = new CloseChannelRequest { @@ -295,7 +425,8 @@ public async Task GetLiquidityRules_NoPubkey() new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, - new Mock().Object, null); + new Mock().Object, null, + null); var getLiquidityRulesRequest = new GetLiquidityRulesRequest { NodePubkey = string.Empty @@ -343,7 +474,8 @@ public async Task GetLiquidityRules_Success() _mockMapper, new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, - new Mock().Object, null); + new Mock().Object, null, + null); var getLiquidityRulesRequest = new GetLiquidityRulesRequest { NodePubkey = "0101010011" @@ -382,7 +514,7 @@ public async Task GetLiquidityRules_Exception() _mockMapper, new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, - new Mock().Object, null); + new Mock().Object, null,null); var getLiquidityRulesRequest = new GetLiquidityRulesRequest { NodePubkey = "101001010101" @@ -427,7 +559,7 @@ public async Task GetNewWalletAddress_Success() _mockMapper, new Mock().Object, new Mock().Object, mock.Object, new Mock().Object, new Mock().Object, - new Mock().Object, null); + new Mock().Object, null, null); var newWalletAddressRequest = new GetNewWalletAddressRequest { @@ -464,7 +596,7 @@ public async Task RequestWithdrawal_Success() _mockMapper, walletWithdrawalRequestRepository.Object, bitcoinService.Object, nbxplorerService.Object, mockScheduler, new Mock().Object, - new Mock().Object, null); + new Mock().Object, null, null); var requestWithdrawalRequest = new RequestWithdrawalRequest { @@ -514,7 +646,7 @@ public async Task RequestWithdrawal_NoWallet() _mockMapper, walletWithdrawalRequestRepository.Object, bitcoinService.Object, nbxplorerService.Object, new Mock().Object, new Mock().Object, - new Mock().Object, null); + new Mock().Object, null, null); //Act var resp = async () => await mockNodeGuardService.RequestWithdrawal(requestWithdrawalRequest, @@ -555,7 +687,7 @@ public async Task RequestWithdrawal_NoAvailableUTXOs() _mockMapper, walletWithdrawalRequestRepository.Object, bitcoinService.Object, nbxplorerService.Object, new Mock().Object, new Mock().Object, - new Mock().Object, null); + new Mock().Object, null, null); //Act var resp = async () => await mockNodeGuardService.RequestWithdrawal(requestWithdrawalRequest, @@ -596,7 +728,7 @@ public async Task RequestWithdrawal_FailureRepoSave() _mockMapper, walletWithdrawalRequestRepository.Object, bitcoinService.Object, nbxplorerService.Object, new Mock().Object, new Mock().Object, - new Mock().Object, null); + new Mock().Object, null, null); //Act var resp = async () => await mockNodeGuardService.RequestWithdrawal(requestWithdrawalRequest, @@ -637,7 +769,7 @@ public async Task RequestWithdrawal_NullTemplatePSBT() _mockMapper, walletWithdrawalRequestRepository.Object, bitcoinService.Object, nbxplorerService.Object, new Mock().Object, new Mock().Object, - new Mock().Object, null); + new Mock().Object, null, null); //Act var resp = async () => await mockNodeGuardService.RequestWithdrawal(requestWithdrawalRequest, @@ -793,7 +925,7 @@ public async Task GetAvailableWallets_ReturnsAllWallets() var request = new GetAvailableWalletsRequest(); var nodeGuardService = new NodeGuardService(null, null, walletRepository, null, null, null, null, scheduleFactory.Object, new Mock().Object, - new Mock().Object, null); + new Mock().Object, null, null); var result = await nodeGuardService.GetAvailableWallets(request, null); result.Wallets.ToList().Count().Should().Be(2); @@ -835,7 +967,7 @@ public async Task GetAvailableWallets_ReturnsTypeHot() }; var nodeGuardService = new NodeGuardService(null, null, walletRepository, null, null, null, null, scheduleFactory.Object, new Mock().Object, - new Mock().Object, null); + new Mock().Object, null, null); var result = await nodeGuardService.GetAvailableWallets(request, null); result.Wallets.ToList().Count().Should().Be(1); @@ -857,7 +989,8 @@ public async Task AddNode_ValidRequest_AddsNodeAndReturnsResponse() new Mock().Object, nodeRepositoryMock.Object, new Mock().Object, - new Mock().Object); + new Mock().Object, + null); var request = new AddNodeRequest { @@ -895,7 +1028,8 @@ public async Task AddNode_FailedToAddNode_ReturnsInternalError() new Mock().Object, nodeRepositoryMock.Object, new Mock().Object, - new Mock().Object); + new Mock().Object, + null); var request = new AddNodeRequest { @@ -937,7 +1071,8 @@ public async Task GetNodes_RequestIncludeUnmanaged_ReturnsUnmanagedNodes() new Mock().Object, nodeRepositoryMock.Object, new Mock().Object, - new Mock().Object); + new Mock().Object, + null); var request = new GetNodesRequest { @@ -975,7 +1110,8 @@ public async Task GetNodes_RequestNotIncludeUnmanaged_ReturnsManagedNodes() new Mock().Object, nodeRepositoryMock.Object, new Mock().Object, - new Mock().Object); + new Mock().Object, + null); var request = new GetNodesRequest { @@ -1029,7 +1165,7 @@ public async Task GetAvailableWallets_ReturnsTypeCold() }; var nodeGuardService = new NodeGuardService(null, null, walletRepository, null, null, null, null, scheduleFactory.Object, new Mock().Object, - new Mock().Object, null); + new Mock().Object, null, null); var result = await nodeGuardService.GetAvailableWallets(request, null); result.Wallets.ToList().Count().Should().Be(1); @@ -1083,7 +1219,7 @@ public async Task GetAvailableWallets_ReturnsIds() }; var nodeGuardService = new NodeGuardService(null, null, walletRepository, null, null, null, null, scheduleFactory.Object, new Mock().Object, - new Mock().Object, null); + new Mock().Object, null, null); var result = await nodeGuardService.GetAvailableWallets(request, null); result.Wallets.ToList().Count().Should().Be(2); @@ -1105,7 +1241,7 @@ public async Task GetAvailableWallets_CantPassTwoFilters() }; var nodeGuardService = new NodeGuardService(null, null, null, null, null, null, null, scheduleFactory.Object, null, - null, null); + null, null, null); var act = () => nodeGuardService.GetAvailableWallets(request, null); await act