diff --git a/src/BTCPayServer.Lightning.Common/CloseChannelRequest.cs b/src/BTCPayServer.Lightning.Common/CloseChannelRequest.cs new file mode 100644 index 00000000..b7643c59 --- /dev/null +++ b/src/BTCPayServer.Lightning.Common/CloseChannelRequest.cs @@ -0,0 +1,27 @@ +using System; + +namespace BTCPayServer.Lightning +{ + public class CloseChannelRequest + { + public NodeInfo NodeInfo + { + get; set; + } + public string ChannelPointFundingTxIdStr + { + get; set; + } + public long ChannelPointOutputIndex + { + get; set; + } + public static void AssertIsSane(CloseChannelRequest closeChannelRequest) + { + if (closeChannelRequest == null) + throw new ArgumentNullException(nameof(closeChannelRequest)); + if (closeChannelRequest.ChannelPointFundingTxIdStr == null) + throw new ArgumentNullException(nameof(closeChannelRequest.ChannelPointFundingTxIdStr)); + } + } +} diff --git a/src/BTCPayServer.Lightning.Common/CloseChannelResponse.cs b/src/BTCPayServer.Lightning.Common/CloseChannelResponse.cs new file mode 100644 index 00000000..3a86b15f --- /dev/null +++ b/src/BTCPayServer.Lightning.Common/CloseChannelResponse.cs @@ -0,0 +1,20 @@ +namespace BTCPayServer.Lightning +{ + public enum CloseChannelResult + { + Ok, + Failed, + } + + public class CloseChannelResponse + { + public CloseChannelResponse(CloseChannelResult result) + { + Result = result; + } + public CloseChannelResult Result + { + get; set; + } + } +} diff --git a/src/BTCPayServer.Lightning.Common/OpenChannelResponse.cs b/src/BTCPayServer.Lightning.Common/OpenChannelResponse.cs index 3a61b822..c72913fa 100644 --- a/src/BTCPayServer.Lightning.Common/OpenChannelResponse.cs +++ b/src/BTCPayServer.Lightning.Common/OpenChannelResponse.cs @@ -23,5 +23,9 @@ public OpenChannelResult Result { get; set; } + public string FundingTxIdIfAvailable + { + get; set; + } } } diff --git a/src/BTCPayServer.Lightning.LND/LndClient.cs b/src/BTCPayServer.Lightning.LND/LndClient.cs index e59b2b28..62fbcb17 100644 --- a/src/BTCPayServer.Lightning.LND/LndClient.cs +++ b/src/BTCPayServer.Lightning.LND/LndClient.cs @@ -373,7 +373,9 @@ async Task ILightningClient.OpenChannel(OpenChannelRequest req.Sat_per_byte = ((int)openChannelRequest.FeeRate.SatoshiPerByte).ToString(); } var result = await this.SwaggerClient.OpenChannelSyncAsync(req, cancellation); - return new OpenChannelResponse(OpenChannelResult.Ok); + var res = new OpenChannelResponse(OpenChannelResult.Ok); + res.FundingTxIdIfAvailable = result.Funding_txid_str; + return res; } catch(SwaggerException ex) when (ex.AsLNDError() is LndError2 lndError && @@ -427,6 +429,22 @@ async Task ILightningClient.OpenChannel(OpenChannelRequest } } + public async Task CloseChannel(CloseChannelRequest closeChannelRequest, CancellationToken cancellation) + { + CloseChannelRequest.AssertIsSane(closeChannelRequest); + cancellation.ThrowIfCancellationRequested(); + try + { + var result = await this.SwaggerClient.CloseChannelAsync(closeChannelRequest.ChannelPointFundingTxIdStr, closeChannelRequest.ChannelPointOutputIndex, cancellation); + return new CloseChannelResponse(CloseChannelResult.Ok); + } + catch (Exception) + { + throw; + //return new CloseChannelResponse(CloseChannelResult.Failed); + } + } + async Task ILightningClient.GetDepositAddress() { return BitcoinAddress.Create((await SwaggerClient.NewWitnessAddressAsync()).Address, Network); diff --git a/src/BTCPayServer.Lightning.All/ConnectChannels.cs b/tests/ChannelSetup.cs similarity index 86% rename from src/BTCPayServer.Lightning.All/ConnectChannels.cs rename to tests/ChannelSetup.cs index b287749a..bb911b29 100644 --- a/src/BTCPayServer.Lightning.All/ConnectChannels.cs +++ b/tests/ChannelSetup.cs @@ -14,9 +14,9 @@ namespace BTCPayServer.Lightning.Tests /// /// Utilities to connect channels on regtest /// - public static class ConnectChannels + public static class ChannelSetup { - static ConnectChannels() + static ChannelSetup() { Logs = NullLogger.Instance; } @@ -26,13 +26,13 @@ public static ILogger Logs set; } /// - /// Create channels from all senders to all receivers while mining on cashCow + /// Open channels from all senders to all receivers while mining on cashCow /// /// The miner and liquidity source /// Senders of payment on Lightning Network /// Receivers of payment on Lightning Network /// - public static async Task ConnectAll(RPCClient cashCow, IEnumerable senders, IEnumerable receivers) + public static async Task OpenAll(RPCClient cashCow, IEnumerable senders, IEnumerable receivers) { if(await cashCow.GetBlockCountAsync() <= cashCow.Network.Consensus.CoinbaseMaturity) { @@ -44,11 +44,20 @@ public static async Task ConnectAll(RPCClient cashCow, IEnumerable availableFundingTxIds + = new Dictionary<(ILightningClient, ILightningClient), string>(); + + internal static string GetFundingTxIdForChannel(ILightningClient sender, ILightningClient dest) + { + return availableFundingTxIds.GetValueOrDefault((sender, dest)); + } + + public static async Task OpenChannel(RPCClient cashCow, ILightningClient sender, ILightningClient dest) { var destInfo = await dest.GetInfo(); var destInvoice = await dest.CreateInvoice(1000, "EnsureConnectedToDestination", TimeSpan.FromSeconds(5000)); @@ -115,6 +124,8 @@ public static async Task CreateChannel(RPCClient cashCow, ILightningClient sende } if(openChannel.Result == OpenChannelResult.Ok) { + if (!String.IsNullOrEmpty(openChannel.FundingTxIdIfAvailable)) + availableFundingTxIds[(sender, dest)] = openChannel.FundingTxIdIfAvailable; // generate one block and a bit more time to confirm channel opening await cashCow.GenerateAsync(1); await WaitLNSynched(cashCow, sender); diff --git a/tests/CommonTests.cs b/tests/CommonTests.cs index 1fd6673f..7fd6ff1b 100644 --- a/tests/CommonTests.cs +++ b/tests/CommonTests.cs @@ -15,6 +15,76 @@ namespace BTCPayServer.Lightning.Tests { public class CommonTests { + uint CountJsonObjects(string jsonString) + { + uint countJsonInner(char? head, string tail, uint braceNestingLevel, bool insideSubString, uint acc) + { + if (!head.HasValue) + { + if (braceNestingLevel != 0) + throw new ArgumentException("jsonString seems to be invalid"); + return acc; + } + + char? newHead = null; + if (!String.IsNullOrEmpty(tail)) + newHead = tail.First(); + var newTail = newHead == null ? null : tail.Substring(1); + + if (!insideSubString && head == '"') { + return countJsonInner(newHead, newTail, braceNestingLevel, true, acc); + } + if (insideSubString) + { + if (head == '"') + return countJsonInner(newHead, newTail, braceNestingLevel, false, acc); + return countJsonInner(newHead, newTail, braceNestingLevel, insideSubString, acc); + } + + if (head == '{') + return countJsonInner(newHead, newTail, braceNestingLevel + 1, insideSubString, acc); + else if (head == '}') + { + if (braceNestingLevel == 0) + throw new ArgumentException("jsonString seems to be invalid"); + var newNestingLevel = braceNestingLevel - 1; + var newAcc = (newNestingLevel == 0) ? acc + 1 : acc; + return countJsonInner(newHead, newTail, newNestingLevel, insideSubString, newAcc); + } + else + return countJsonInner(newHead, newTail, braceNestingLevel, insideSubString, acc); + } + + if (jsonString == string.Empty) + return 0; + if (!jsonString.StartsWith("{")) + throw new ArgumentException("jsonString should start with {"); + if (!jsonString.EndsWith("}")) + throw new ArgumentException("jsonString should end with }"); + + return countJsonInner(jsonString.First(), jsonString.Substring(1), 0, false, 0); + } + + [Fact()] + public void JsonCounter() + { + Assert.Equal(0u, CountJsonObjects(String.Empty)); + + Assert.Throws(() => CountJsonObjects("x")); // not startswith { + Assert.Throws(() => CountJsonObjects("{x")); // not endswith } + + Assert.Equal(1u, CountJsonObjects("{ \"foo\": 1 }")); + Assert.Equal(2u, CountJsonObjects("{ \"foo\": 1 }{ \"bar\": 2 }")); + Assert.Equal(3u, CountJsonObjects("{ \"foo\": 1 }{ \"bar\": 2 }{ \"baz\": 3 }")); + + // ends with bad nesting level + Assert.Throws(() => CountJsonObjects("{{x}")); + Assert.Throws(() => CountJsonObjects("{x}}")); + + // edge case + Assert.Equal(1u, CountJsonObjects("{ \"foo\": \"{\" }")); + } + #if DEBUG public const int Timeout = 20 * 60 * 1000; #else @@ -25,7 +95,7 @@ public CommonTests(ITestOutputHelper helper) Docker = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("IN_DOCKER_CONTAINER")); Logs.Tester = new XUnitLog(helper) { Name = "Tests" }; Logs.LogProvider = new XUnitLogProvider(helper); - ConnectChannels.Logs = Logs.LogProvider.CreateLogger("Tests"); + ChannelSetup.Logs = Logs.LogProvider.CreateLogger("Tests"); } public static bool Docker { get; set; } @@ -271,7 +341,7 @@ private async Task EnsureConnectedToDestinations((string Name, ILightningClient { await Task.WhenAll(WaitServersAreUp($"{test.Name} (Customer)", test.Customer), WaitServersAreUp($"{test.Name} (Merchant)", test.Merchant)); Tests.Logs.Tester.LogInformation($"{test.Name}: Connecting channels..."); - await ConnectChannels.ConnectAll(Tester.CreateRPC(), new[] { test.Customer }, new[] { test.Merchant }); + await ChannelSetup.OpenAll(Tester.CreateRPC(), new[] { test.Customer }, new[] { test.Merchant }); Tests.Logs.Tester.LogInformation($"{test.Name}: Channels connected"); } @@ -536,5 +606,30 @@ public void CanParseLightningURL() Assert.True(LightningConnectionString.TryParse("type=charge;server=http://api-token:foiewnccewuify@127.0.0.1:54938/;allowinsecure=true", out conn)); Assert.Equal("type=charge;server=http://127.0.0.1:54938/;api-token=foiewnccewuify;allowinsecure=true", conn.ToString()); } + + [Fact(Timeout = Timeout)] + public async Task CanCloseLndChannel() + { + // TODO: test in all LN implementations, not just LND + //foreach (var test in Tester.GetTestedPairs()) + //{ + var test = Tester.GetTestedLndPair(); + await EnsureConnectedToDestinations(test); + var customer = (ILightningClient)test.Customer; + var senderChannel = (await customer.ListChannels()).First(); + + var senderInfo = await customer.GetInfo(); + var fundingTxIdForChannel = ChannelSetup.GetFundingTxIdForChannel(customer, test.Merchant); + if (String.IsNullOrEmpty(fundingTxIdForChannel)) + throw new InvalidOperationException("Could not find funding Tx ID of channel"); + var closeChannelRequest = new CloseChannelRequest() + { + NodeInfo = senderInfo.NodeInfoList.First(), + ChannelPointOutputIndex = senderChannel.ChannelPoint.N, + ChannelPointFundingTxIdStr = fundingTxIdForChannel + }; + await test.Merchant.CloseChannel(closeChannelRequest, CancellationToken.None); + //} + } } } diff --git a/tests/Tester.cs b/tests/Tester.cs index 8d983523..5ee69295 100644 --- a/tests/Tester.cs +++ b/tests/Tester.cs @@ -87,11 +87,18 @@ public static LndClient CreateLndClientDest() yield return ("Eclair (Client)", CreateEclairClient()); } + private static (string Name, ILightningClient Customer, ILightningClient Merchant) GetTestedCLightningPair() + => ("C-Lightning", CreateCLightningClient(), CreateCLightningClientDest()); + internal static (string Name, LndClient Customer, LndClient Merchant) GetTestedLndPair() + => ("LND", CreateLndClient(), CreateLndClientDest()); + private static (string Name, ILightningClient Customer, ILightningClient Merchant) GetTestedEclairPair() + => ("Eclair", CreateEclairClient(), CreateEclairClientDest()); + public static IEnumerable<(string Name, ILightningClient Customer, ILightningClient Merchant)> GetTestedPairs() { - yield return ("C-Lightning", CreateCLightningClient(), CreateCLightningClientDest()); - yield return ("LND", CreateLndClient(), CreateLndClientDest()); - yield return ("Eclair", CreateEclairClient(), CreateEclairClientDest()); + yield return GetTestedCLightningPair(); + yield return GetTestedLndPair(); + yield return GetTestedEclairPair(); } } }