diff --git a/BTCPayServer.Tests/BTCPayServerTester.cs b/BTCPayServer.Tests/BTCPayServerTester.cs index 1e2d4079a..4456db733 100644 --- a/BTCPayServer.Tests/BTCPayServerTester.cs +++ b/BTCPayServer.Tests/BTCPayServerTester.cs @@ -1,5 +1,7 @@ using BTCPayServer.Configuration; using BTCPayServer.Hosting; +using BTCPayServer.Payments; +using BTCPayServer.Payments.Lightning; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Rates; using BTCPayServer.Tests.Logging; @@ -118,6 +120,7 @@ namespace BTCPayServer.Tests .Build(); _Host.Start(); InvoiceRepository = (InvoiceRepository)_Host.Services.GetService(typeof(InvoiceRepository)); + ((LightningLikePaymentHandler)_Host.Services.GetService(typeof(IPaymentMethodHandler))).SkipP2PTest = !InContainer; } public string HostName @@ -127,6 +130,7 @@ namespace BTCPayServer.Tests } public InvoiceRepository InvoiceRepository { get; private set; } public Uri IntegratedLightning { get; internal set; } + public bool InContainer { get; internal set; } public T GetService() { diff --git a/BTCPayServer.Tests/ServerTester.cs b/BTCPayServer.Tests/ServerTester.cs index 4fe6d989c..73fc2e425 100644 --- a/BTCPayServer.Tests/ServerTester.cs +++ b/BTCPayServer.Tests/ServerTester.cs @@ -57,7 +57,9 @@ namespace BTCPayServer.Tests LTCExplorerClient = new ExplorerClient(NetworkProvider.GetNetwork("LTC").NBXplorerNetwork, new Uri(GetEnvironment("TESTS_LTCNBXPLORERURL", "http://127.0.0.1:32838/"))); var btc = NetworkProvider.GetNetwork("BTC").NBitcoinNetwork; - CustomerLightningD = new CLightningRPCClient(new Uri(GetEnvironment("TEST_CUSTOMERLIGHTNINGD", "http://127.0.0.1:30992/")), btc); + CustomerLightningD = new CLightningRPCClient(new Uri(GetEnvironment("TEST_CUSTOMERLIGHTNINGD", "tcp://127.0.0.1:30992/")), btc); + MerchantLightningD = new CLightningRPCClient(new Uri(GetEnvironment("TEST_MERCHANTLIGHTNINGD", "tcp://127.0.0.1:30993/")), btc); + MerchantCharge = new ChargeTester(this, "TEST_MERCHANTCHARGE", "http://api-token:foiewnccewuify@127.0.0.1:54938/", "merchant_lightningd", btc); PayTester = new BTCPayServerTester(Path.Combine(_Directory, "pay")) @@ -69,6 +71,7 @@ namespace BTCPayServer.Tests }; PayTester.Port = int.Parse(GetEnvironment("TESTS_PORT", Utils.FreeTcpPort().ToString(CultureInfo.InvariantCulture)), CultureInfo.InvariantCulture); PayTester.HostName = GetEnvironment("TESTS_HOSTNAME", "127.0.0.1"); + PayTester.InContainer = bool.Parse(GetEnvironment("TESTS_INCONTAINER", "false")); PayTester.Start(); } @@ -90,8 +93,10 @@ namespace BTCPayServer.Tests { while (true) { + var skippedStates = new[] { "ONCHAIN", "CHANNELD_SHUTTING_DOWN", "CLOSINGD_SIGEXCHANGE", "CLOSINGD_COMPLETE", "FUNDING_SPEND_SEEN" }; var channel = (await CustomerLightningD.ListPeersAsync()) .SelectMany(p => p.Channels) + .Where(c => !skippedStates.Contains(c.State ?? "")) .FirstOrDefault(); switch (channel?.State) { @@ -148,6 +153,7 @@ namespace BTCPayServer.Tests } public CLightningRPCClient CustomerLightningD { get; set; } + public CLightningRPCClient MerchantLightningD { get; private set; } public ChargeTester MerchantCharge { get; private set; } internal string GetEnvironment(string variable, string defaultValue) diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index 1763687e1..4956257e2 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -1,4 +1,5 @@ using BTCPayServer.Controllers; +using System.Linq; using BTCPayServer.Models.AccountViewModels; using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Services.Invoices; @@ -11,6 +12,8 @@ using System.Text; using System.Threading.Tasks; using Xunit; using NBXplorer.DerivationStrategy; +using BTCPayServer.Payments; +using BTCPayServer.Payments.Lightning; namespace BTCPayServer.Tests { @@ -111,20 +114,24 @@ namespace BTCPayServer.Tests { get; set; } - - public void RegisterLightningNode(string cryptoCode) + + public void RegisterLightningNode(string cryptoCode, LightningConnectionType connectionType) { - RegisterLightningNodeAsync(cryptoCode).GetAwaiter().GetResult(); + RegisterLightningNodeAsync(cryptoCode, connectionType).GetAwaiter().GetResult(); } - public async Task RegisterLightningNodeAsync(string cryptoCode) + public async Task RegisterLightningNodeAsync(string cryptoCode, LightningConnectionType connectionType) { var storeController = parent.PayTester.GetController(UserId); await storeController.AddLightningNode(StoreId, new LightningNodeViewModel() { CryptoCurrency = "BTC", - Url = parent.MerchantCharge.Client.Uri.AbsoluteUri + Url = connectionType == LightningConnectionType.Charge ? parent.MerchantCharge.Client.Uri.AbsoluteUri : + connectionType == LightningConnectionType.CLightning ? parent.MerchantLightningD.Address.AbsoluteUri + : throw new NotSupportedException(connectionType.ToString()) }, "save"); + if (storeController.ModelState.ErrorCount != 0) + Assert.False(true, storeController.ModelState.FirstOrDefault().Value.Errors[0].ErrorMessage); } - } +} } diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 39d0edbdf..04b8aea4a 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -324,6 +324,79 @@ namespace BTCPayServer.Tests } } + [Fact] + public void CanParseLightningURL() + { + LightningConnectionString conn = null; + Assert.True(LightningConnectionString.TryParse("/test/a", out conn)); + Assert.Equal("unix://test/a", conn.ToString()); + Assert.Equal("unix://test/a", conn.ToUri(true).AbsoluteUri); + Assert.Equal("unix://test/a", conn.ToUri(false).AbsoluteUri); + Assert.Equal(LightningConnectionType.CLightning, conn.ConnectionType); + + Assert.True(LightningConnectionString.TryParse("unix://test/a", out conn)); + Assert.Equal("unix://test/a", conn.ToString()); + Assert.Equal("unix://test/a", conn.ToUri(true).AbsoluteUri); + Assert.Equal("unix://test/a", conn.ToUri(false).AbsoluteUri); + Assert.Equal(LightningConnectionType.CLightning, conn.ConnectionType); + + Assert.True(LightningConnectionString.TryParse("unix://test/a", out conn)); + Assert.Equal("unix://test/a", conn.ToString()); + Assert.Equal("unix://test/a", conn.ToUri(true).AbsoluteUri); + Assert.Equal("unix://test/a", conn.ToUri(false).AbsoluteUri); + Assert.Equal(LightningConnectionType.CLightning, conn.ConnectionType); + + Assert.True(LightningConnectionString.TryParse("tcp://test/a", out conn)); + Assert.Equal("tcp://test/a", conn.ToString()); + Assert.Equal("tcp://test/a", conn.ToUri(true).AbsoluteUri); + Assert.Equal("tcp://test/a", conn.ToUri(false).AbsoluteUri); + Assert.Equal(LightningConnectionType.CLightning, conn.ConnectionType); + + Assert.True(LightningConnectionString.TryParse("http://aaa:bbb@test/a", out conn)); + Assert.Equal("http://aaa:bbb@test/a", conn.ToString()); + Assert.Equal("http://aaa:bbb@test/a", conn.ToUri(true).AbsoluteUri); + Assert.Equal("http://test/a", conn.ToUri(false).AbsoluteUri); + Assert.Equal(LightningConnectionType.Charge, conn.ConnectionType); + Assert.Equal("aaa", conn.Username); + Assert.Equal("bbb", conn.Password); + + Assert.False(LightningConnectionString.TryParse("lol://aaa:bbb@test/a", out conn)); + Assert.False(LightningConnectionString.TryParse("https://test/a", out conn)); + Assert.False(LightningConnectionString.TryParse("unix://dwewoi:dwdwqd@test/a", out conn)); + } + + [Fact] + public void CanSendLightningPayment2() + { + using (var tester = ServerTester.Create()) + { + tester.Start(); + tester.PrepareLightning(); + var user = tester.NewAccount(); + user.GrantAccess(); + user.RegisterLightningNode("BTC", LightningConnectionType.CLightning); + user.RegisterDerivationScheme("BTC"); + + var invoice = user.BitPay.CreateInvoice(new Invoice() + { + Price = 0.01, + Currency = "USD", + PosData = "posData", + OrderId = "orderId", + ItemDesc = "Some description" + }); + + tester.SendLightningPayment(invoice); + + Eventually(() => + { + var localInvoice = user.BitPay.GetInvoice(invoice.Id); + Assert.Equal("complete", localInvoice.Status); + Assert.Equal("False", localInvoice.ExceptionStatus.ToString()); + }); + } + } + [Fact] public void CanSendLightningPayment() { @@ -334,7 +407,7 @@ namespace BTCPayServer.Tests tester.PrepareLightning(); var user = tester.NewAccount(); user.GrantAccess(); - user.RegisterLightningNode("BTC"); + user.RegisterLightningNode("BTC", LightningConnectionType.Charge); user.RegisterDerivationScheme("BTC"); var invoice = user.BitPay.CreateInvoice(new Invoice() diff --git a/BTCPayServer.Tests/docker-compose.yml b/BTCPayServer.Tests/docker-compose.yml index 810bf3c86..a7c37a875 100644 --- a/BTCPayServer.Tests/docker-compose.yml +++ b/BTCPayServer.Tests/docker-compose.yml @@ -17,14 +17,19 @@ services: TESTS_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Database=btcpayserver TESTS_PORT: 80 TESTS_HOSTNAME: tests - TEST_CUSTOMERLIGHTNINGD: http://customer_lightningd:9835/ + TEST_MERCHANTLIGHTNINGD: "/etc/merchant_lightningd_datadir/lightning-rpc" + TEST_CUSTOMERLIGHTNINGD: "/etc/customer_lightningd_datadir/lightning-rpc" TEST_MERCHANTCHARGE: http://api-token:foiewnccewuify@lightning-charged:9112/ + TESTS_INCONTAINER: "true" expose: - "80" links: - dev extra_hosts: - "tests:127.0.0.1" + volumes: + - "customer_lightningd_datadir:/etc/customer_lightningd_datadir" + - "merchant_lightningd_datadir:/etc/merchant_lightningd_datadir" # The dev container is not actually used, it is just handy to run `docker-compose up dev` to start all services dev: @@ -90,6 +95,7 @@ services: bitcoin-datadir=/etc/bitcoin bitcoin-rpcconnect=bitcoind network=regtest + ipaddr=customer_lightningd log-level=debug ports: - "30992:9835" # api port @@ -128,6 +134,7 @@ services: LIGHTNINGD_OPT: | bitcoin-datadir=/etc/bitcoin bitcoin-rpcconnect=bitcoind + ipaddr=merchant_lightningd network=regtest log-level=debug ports: diff --git a/BTCPayServer.Tests/docker-merchant-lightning-cli.ps1 b/BTCPayServer.Tests/docker-merchant-lightning-cli.ps1 new file mode 100755 index 000000000..576cf31d0 --- /dev/null +++ b/BTCPayServer.Tests/docker-merchant-lightning-cli.ps1 @@ -0,0 +1 @@ +docker exec -ti btcpayservertests_merchant_lightningd_1 lightning-cli $args diff --git a/BTCPayServer.Tests/docker-merchant-lightning-cli.sh b/BTCPayServer.Tests/docker-merchant-lightning-cli.sh new file mode 100755 index 000000000..56dbdf91d --- /dev/null +++ b/BTCPayServer.Tests/docker-merchant-lightning-cli.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker exec -ti btcpayservertests_merchant_lightningd_1 lightning-cli "$@" diff --git a/BTCPayServer/Controllers/StoresController.LightningLike.cs b/BTCPayServer/Controllers/StoresController.LightningLike.cs index e3d05862c..5c6a12c76 100644 --- a/BTCPayServer/Controllers/StoresController.LightningLike.cs +++ b/BTCPayServer/Controllers/StoresController.LightningLike.cs @@ -8,6 +8,7 @@ using BTCPayServer.Payments; using BTCPayServer.Payments.Lightning.CLightning; using Microsoft.AspNetCore.Mvc; using BTCPayServer.Payments.Lightning; +using System.Net; namespace BTCPayServer.Controllers { @@ -23,7 +24,7 @@ namespace BTCPayServer.Controllers return NotFound(); LightningNodeViewModel vm = new LightningNodeViewModel(); vm.SetCryptoCurrencies(_NetworkProvider, selectedCrypto); - vm.InternalLightningNode = GetInternalLightningNodeIfAuthorized(); + vm.InternalLightningNode = CanUseInternalLightning() ? _BtcpayServerOptions.InternalLightningNode.AbsoluteUri : null; return View(vm); } @@ -36,7 +37,7 @@ namespace BTCPayServer.Controllers return NotFound(); var network = vm.CryptoCurrency == null ? null : _ExplorerProvider.GetNetwork(vm.CryptoCurrency); vm.SetCryptoCurrencies(_NetworkProvider, vm.CryptoCurrency); - vm.InternalLightningNode = GetInternalLightningNodeIfAuthorized(); + vm.InternalLightningNode = CanUseInternalLightning() ? _BtcpayServerOptions.InternalLightningNode.AbsoluteUri : null; if (network == null) { ModelState.AddModelError(nameof(vm.CryptoCurrency), "Invalid network"); @@ -47,41 +48,39 @@ namespace BTCPayServer.Controllers Payments.Lightning.LightningSupportedPaymentMethod paymentMethod = null; if (!string.IsNullOrEmpty(vm.Url)) { - Uri uri; - if (!Uri.TryCreate(vm.Url, UriKind.Absolute, out uri)) + if(!LightningConnectionString.TryParse(vm.Url, out var connectionString, out var error)) { - ModelState.AddModelError(nameof(vm.Url), "Invalid URL"); + ModelState.AddModelError(nameof(vm.Url), $"Invalid URL ({error})"); return View(vm); } - var domain = GetDomain(uri.AbsoluteUri); - if (uri.Scheme != "https" && domain != "127.0.0.1" && domain != "localhost") + var internalDomain = _BtcpayServerOptions.InternalLightningNode.DnsSafeHost; + bool isLocal = (internalDomain == "127.0.0.1" || internalDomain == "localhost"); + + bool isInternalNode = connectionString.ConnectionType == LightningConnectionType.CLightning || + connectionString.BaseUri.DnsSafeHost == internalDomain || + isLocal; + + if (connectionString.BaseUri.Scheme == "http" && !isLocal) { - var internalNode = GetInternalLightningNodeIfAuthorized(); - if (internalNode == null || GetDomain(internalNode) != domain) + if (!isInternalNode || (isInternalNode && !CanUseInternalLightning())) { ModelState.AddModelError(nameof(vm.Url), "The url must be HTTPS"); return View(vm); } } - if (!CanUseInternalLightning() && GetDomain(_BtcpayServerOptions.InternalLightningNode.AbsoluteUri) == GetDomain(uri.AbsoluteUri)) + if (isInternalNode && !CanUseInternalLightning()) { ModelState.AddModelError(nameof(vm.Url), "Unauthorized url"); return View(vm); } - if (string.IsNullOrEmpty(uri.UserInfo) || uri.UserInfo.Split(':').Length != 2) - { - ModelState.AddModelError(nameof(vm.Url), "The url is missing user and password"); - return View(vm); - } - paymentMethod = new Payments.Lightning.LightningSupportedPaymentMethod() { CryptoCode = paymentMethodId.CryptoCode }; - paymentMethod.SetLightningChargeUrl(uri); + paymentMethod.SetLightningUrl(connectionString); } if (command == "save") { @@ -112,24 +111,9 @@ namespace BTCPayServer.Controllers } } - private string GetInternalLightningNodeIfAuthorized() - { - if (_BtcpayServerOptions.InternalLightningNode != null && - CanUseInternalLightning()) - { - return _BtcpayServerOptions.InternalLightningNode.AbsoluteUri; - } - return null; - } - private bool CanUseInternalLightning() { return (_BTCPayEnv.IsDevelopping || User.IsInRole(Roles.ServerAdmin)); } - - string GetDomain(string uri) - { - return new UriBuilder(uri).Host; - } } } diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index 9331dfab1..13b29032d 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -246,7 +246,7 @@ namespace BTCPayServer.Controllers vm.LightningNodes.Add(new StoreViewModel.LightningNode() { CryptoCode = lightning.CryptoCode, - Address = lightning.GetLightningChargeUrl(false).AbsoluteUri + Address = lightning.GetLightningUrl().BaseUri.AbsoluteUri }); } } diff --git a/BTCPayServer/Payments/Lightning/CLightning/CLightningRPCClient.cs b/BTCPayServer/Payments/Lightning/CLightning/CLightningRPCClient.cs index eeeda6a1a..44e0b52a2 100644 --- a/BTCPayServer/Payments/Lightning/CLightning/CLightningRPCClient.cs +++ b/BTCPayServer/Payments/Lightning/CLightning/CLightningRPCClient.cs @@ -5,8 +5,10 @@ using System.Linq; using System.Net; using System.Net.Sockets; using System.Text; +using System.Threading; using System.Threading.Tasks; using BTCPayServer.Payments.Lightning.Charge; +using Mono.Unix; using NBitcoin; using NBitcoin.RPC; using Newtonsoft.Json; @@ -14,7 +16,14 @@ using Newtonsoft.Json.Linq; namespace BTCPayServer.Payments.Lightning.CLightning { - public class CLightningRPCClient + public class LightningRPCException : Exception + { + public LightningRPCException(string message) : base(message) + { + + } + } + public class CLightningRPCClient : ILightningInvoiceClient, ILightningListenInvoiceSession { public Network Network { get; private set; } public Uri Address { get; private set; } @@ -25,13 +34,17 @@ namespace BTCPayServer.Payments.Lightning.CLightning throw new ArgumentNullException(nameof(address)); if (network == null) throw new ArgumentNullException(nameof(network)); + if(address.Scheme == "file") + { + address = new UriBuilder(address) { Scheme = "unix" }.Uri; + } Address = address; Network = network; } - public Task GetInfoAsync() + public Task GetInfoAsync(CancellationToken cancellation = default(CancellationToken)) { - return SendCommandAsync("getinfo"); + return SendCommandAsync("getinfo", cancellation: cancellation); } public Task SendAsync(string bolt11) @@ -42,7 +55,7 @@ namespace BTCPayServer.Payments.Lightning.CLightning public async Task ListPeersAsync() { var peers = await SendCommandAsync("listpeers", isArray: true); - foreach(var peer in peers) + foreach (var peer in peers) { peer.Channels = peer.Channels ?? Array.Empty(); } @@ -60,60 +73,158 @@ namespace BTCPayServer.Payments.Lightning.CLightning } static Encoding UTF8 = new UTF8Encoding(false); - private async Task SendCommandAsync(string command, object[] parameters = null, bool noReturn = false, bool isArray = false) + private async Task SendCommandAsync(string command, object[] parameters = null, bool noReturn = false, bool isArray = false, CancellationToken cancellation = default(CancellationToken)) { parameters = parameters ?? Array.Empty(); - var domain = Address.DnsSafeHost; - if (!IPAddress.TryParse(domain, out IPAddress address)) + using (Socket socket = await Connect()) { - address = (await Dns.GetHostAddressesAsync(domain)).FirstOrDefault(); - if (address == null) - throw new Exception("Host not found"); - } - Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); - await socket.ConnectAsync(new IPEndPoint(address, Address.Port)); - using (var networkStream = new NetworkStream(socket)) - { - using (var textWriter = new StreamWriter(networkStream, UTF8, 1024 * 10, true)) + using (var networkStream = new NetworkStream(socket)) { - using (var jsonWriter = new JsonTextWriter(textWriter)) + using (var textWriter = new StreamWriter(networkStream, UTF8, 1024 * 10, true)) { - var req = new JObject(); - req.Add("id", 0); - req.Add("method", command); - req.Add("params", new JArray(parameters)); - await req.WriteToAsync(jsonWriter); - await jsonWriter.FlushAsync(); + using (var jsonWriter = new JsonTextWriter(textWriter)) + { + var req = new JObject(); + req.Add("id", 0); + req.Add("method", command); + req.Add("params", new JArray(parameters)); + await req.WriteToAsync(jsonWriter, cancellation); + await jsonWriter.FlushAsync(cancellation); + } + await textWriter.FlushAsync(); } - await textWriter.FlushAsync(); - } - await networkStream.FlushAsync(); - using (var textReader = new StreamReader(networkStream, UTF8, false, 1024 * 10, true)) - { - using (var jsonReader = new JsonTextReader(textReader)) + await networkStream.FlushAsync(cancellation); + using (var textReader = new StreamReader(networkStream, UTF8, false, 1024 * 10, true)) { - var result = await JObject.LoadAsync(jsonReader); - var error = result.Property("error"); - if(error != null) + using (var jsonReader = new JsonTextReader(textReader)) { - throw new Exception(error.Value.ToString()); + var resultAsync = JObject.LoadAsync(jsonReader, cancellation); + + // without this hack resultAsync is blocking even if cancellation happen + using (cancellation.Register(() => { socket.Dispose(); })) + { + var result = await resultAsync; + var error = result.Property("error"); + if (error != null) + { + throw new LightningRPCException(error.Value["message"].Value()); + } + if (noReturn) + return default(T); + if (isArray) + { + return result["result"].Children().First().Children().First().ToObject(); + } + return result["result"].ToObject(); + } } - if (noReturn) - return default(T); - if (isArray) - { - return result["result"].Children().First().Children().First().ToObject(); - } - return result["result"].ToObject(); } } } } + private async Task Connect() + { + Socket socket = null; + EndPoint endpoint = null; + if (Address.Scheme == "tcp" || Address.Scheme == "tcp") + { + var domain = Address.DnsSafeHost; + if (!IPAddress.TryParse(domain, out IPAddress address)) + { + address = (await Dns.GetHostAddressesAsync(domain)).FirstOrDefault(); + if (address == null) + throw new Exception("Host not found"); + } + socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + endpoint = new IPEndPoint(address, Address.Port); + } + else if (Address.Scheme == "unix") + { + var path = Address.AbsoluteUri.Remove(0, "unix:".Length); + if (!path.StartsWith('/')) + path = "/" + path; + while (path.Length >= 2 && (path[0] != '/' || path[1] == '/')) + { + path = path.Remove(0, 1); + } + if (path.Length < 2) + throw new FormatException("Invalid unix url"); + socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.IP); + endpoint = new UnixEndPoint(path); + } + else + throw new NotSupportedException($"Protocol {Address.Scheme} for clightning not supported"); + + await socket.ConnectAsync(endpoint); + return socket; + } + public async Task NewAddressAsync() { var obj = await SendCommandAsync("newaddr"); return BitcoinAddress.Create(obj.Property("address").Value.Value(), Network); } + + async Task ILightningInvoiceClient.GetInvoice(string invoiceId, CancellationToken cancellation) + { + var invoices = await SendCommandAsync("listinvoices", new[] { invoiceId }, false, true, cancellation); + if (invoices.Length == 0) + return null; + return ChargeClient.ToLightningInvoice(invoices[0]); + } + + static NBitcoin.DataEncoders.DataEncoder InvoiceIdEncoder = NBitcoin.DataEncoders.Encoders.Base58; + async Task ILightningInvoiceClient.CreateInvoice(LightMoney amount, TimeSpan expiry, CancellationToken cancellation) + { + var id = InvoiceIdEncoder.EncodeData(RandomUtils.GetBytes(20)); + var invoice = await SendCommandAsync("invoice", new object[] { amount.MilliSatoshi, id, "" }, cancellation: cancellation); + invoice.Label = id; + invoice.MilliSatoshi = amount; + invoice.Status = "unpaid"; + return ToLightningInvoice(invoice); + } + + private static LightningInvoice ToLightningInvoice(CreateInvoiceResponse invoice) + { + return new LightningInvoice() + { + Id = invoice.Label, + Amount = invoice.MilliSatoshi, + BOLT11 = invoice.BOLT11, + Status = invoice.Status, + PaidAt = invoice.PaidAt + }; + } + + Task ILightningInvoiceClient.Listen(CancellationToken cancellation) + { + return Task.FromResult(this); + } + long lastInvoiceIndex = 99999999999; + async Task ILightningListenInvoiceSession.WaitInvoice(CancellationToken cancellation) + { + var chargeInvoice = await SendCommandAsync("waitanyinvoice", new object[] { lastInvoiceIndex }, cancellation: cancellation); + lastInvoiceIndex = chargeInvoice.PayIndex.Value; + return ToLightningInvoice(chargeInvoice); + } + + async Task ILightningInvoiceClient.GetInfo(CancellationToken cancellation) + { + var info = await GetInfoAsync(cancellation); + var address = info.Address.Select(a => a.Address).FirstOrDefault(); + var port = info.Port; + return new LightningNodeInformation() + { + P2PPort = port, + Address = address, + BlockHeight = info.BlockHeight + }; + } + + void IDisposable.Dispose() + { + + } } } diff --git a/BTCPayServer/Payments/Lightning/CLightning/CreateInvoiceResponse.cs b/BTCPayServer/Payments/Lightning/CLightning/CreateInvoiceResponse.cs new file mode 100644 index 000000000..2dfe75795 --- /dev/null +++ b/BTCPayServer/Payments/Lightning/CLightning/CreateInvoiceResponse.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using NBitcoin; +using Newtonsoft.Json; + +namespace BTCPayServer.Payments.Lightning.CLightning +{ + public class CreateInvoiceResponse + { + [JsonConverter(typeof(NBitcoin.JsonConverters.UInt256JsonConverter))] + [JsonProperty("payment_hash")] + public uint256 PaymentHash { get; set; } + + [JsonProperty("msatoshi")] + [JsonConverter(typeof(JsonConverters.LightMoneyJsonConverter))] + public LightMoney MilliSatoshi { get; set; } + [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] + [JsonProperty("expiry_time")] + public DateTimeOffset ExpiryTime { get; set; } + [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] + [JsonProperty("expires_at")] + public DateTimeOffset ExpiryAt { get; set; } + [JsonProperty("bolt11")] + public string BOLT11 { get; set; } + [JsonProperty("pay_index")] + public int? PayIndex { get; set; } + public string Label { get; set; } + public string Status { get; set; } + [JsonProperty("paid_at")] + [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] + public DateTimeOffset? PaidAt { get; set; } + } +} diff --git a/BTCPayServer/Payments/Lightning/CLightning/UnixEndPoint.cs b/BTCPayServer/Payments/Lightning/CLightning/UnixEndPoint.cs new file mode 100644 index 000000000..35e85fd0b --- /dev/null +++ b/BTCPayServer/Payments/Lightning/CLightning/UnixEndPoint.cs @@ -0,0 +1,140 @@ +// +// Mono.Unix.UnixEndPoint: EndPoint derived class for AF_UNIX family sockets. +// +// Authors: +// Gonzalo Paniagua Javier (gonzalo@ximian.com) +// +// (C) 2003 Ximian, Inc (http://www.ximian.com) +// + +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +using System; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace Mono.Unix +{ + [Serializable] + public class UnixEndPoint : EndPoint + { + string filename; + + public UnixEndPoint(string filename) + { + if (filename == null) + throw new ArgumentNullException("filename"); + + if (filename.Length == 0) + throw new ArgumentException("Cannot be empty.", "filename"); + this.filename = filename; + } + + public string Filename + { + get + { + return (filename); + } + set + { + filename = value; + } + } + + public override AddressFamily AddressFamily + { + get { return AddressFamily.Unix; } + } + + public override EndPoint Create(SocketAddress socketAddress) + { + /* + * Should also check this + * + int addr = (int) AddressFamily.Unix; + if (socketAddress [0] != (addr & 0xFF)) + throw new ArgumentException ("socketAddress is not a unix socket address."); + + if (socketAddress [1] != ((addr & 0xFF00) >> 8)) + throw new ArgumentException ("socketAddress is not a unix socket address."); + */ + + if (socketAddress.Size == 2) + { + // Empty filename. + // Probably from RemoteEndPoint which on linux does not return the file name. + UnixEndPoint uep = new UnixEndPoint("a"); + uep.filename = ""; + return uep; + } + int size = socketAddress.Size - 2; + byte[] bytes = new byte[size]; + for (int i = 0; i < bytes.Length; i++) + { + bytes[i] = socketAddress[i + 2]; + // There may be junk after the null terminator, so ignore it all. + if (bytes[i] == 0) + { + size = i; + break; + } + } + + string name = Encoding.Default.GetString(bytes, 0, size); + return new UnixEndPoint(name); + } + + public override SocketAddress Serialize() + { + byte[] bytes = Encoding.Default.GetBytes(filename); + SocketAddress sa = new SocketAddress(AddressFamily, 2 + bytes.Length + 1); + // sa [0] -> family low byte, sa [1] -> family high byte + for (int i = 0; i < bytes.Length; i++) + sa[2 + i] = bytes[i]; + + //NULL suffix for non-abstract path + sa[2 + bytes.Length] = 0; + + return sa; + } + + public override string ToString() + { + return (filename); + } + + public override int GetHashCode() + { + return filename.GetHashCode(StringComparison.Ordinal); + } + + public override bool Equals(object o) + { + UnixEndPoint other = o as UnixEndPoint; + if (other == null) + return false; + + return (other.filename == filename); + } + } +} diff --git a/BTCPayServer/Payments/Lightning/Charge/ChargeClient.cs b/BTCPayServer/Payments/Lightning/Charge/ChargeClient.cs index b29aa88f3..0c1062736 100644 --- a/BTCPayServer/Payments/Lightning/Charge/ChargeClient.cs +++ b/BTCPayServer/Payments/Lightning/Charge/ChargeClient.cs @@ -142,7 +142,7 @@ namespace BTCPayServer.Payments.Lightning.Charge { return new LightningInvoice() { - Id = invoice.Id, + Id = invoice.Id ?? invoice.Label, Amount = invoice.MilliSatoshi, BOLT11 = invoice.PaymentRequest, PaidAt = invoice.PaidAt, @@ -161,7 +161,6 @@ namespace BTCPayServer.Payments.Lightning.Charge var info = await GetInfoAsync(cancellation); var address = info.Address.Select(a => a.Address).FirstOrDefault(); var port = info.Port; - address = address ?? Uri.DnsSafeHost; return new LightningNodeInformation() { P2PPort = port, diff --git a/BTCPayServer/Payments/Lightning/Charge/ChargeSession.cs b/BTCPayServer/Payments/Lightning/Charge/ChargeSession.cs index b80328df1..d7cd5652e 100644 --- a/BTCPayServer/Payments/Lightning/Charge/ChargeSession.cs +++ b/BTCPayServer/Payments/Lightning/Charge/ChargeSession.cs @@ -27,6 +27,7 @@ namespace BTCPayServer.Payments.Lightning.Charge [JsonProperty("payreq")] public string PaymentRequest { get; set; } + public string Label { get; set; } } public class ChargeSession : ILightningListenInvoiceSession { diff --git a/BTCPayServer/Payments/Lightning/ILightningInvoiceClient.cs b/BTCPayServer/Payments/Lightning/ILightningInvoiceClient.cs index 5111b901e..b95769d99 100644 --- a/BTCPayServer/Payments/Lightning/ILightningInvoiceClient.cs +++ b/BTCPayServer/Payments/Lightning/ILightningInvoiceClient.cs @@ -35,6 +35,6 @@ namespace BTCPayServer.Payments.Lightning public interface ILightningListenInvoiceSession : IDisposable { - Task WaitInvoice(CancellationToken token); + Task WaitInvoice(CancellationToken cancellation); } } diff --git a/BTCPayServer/Payments/Lightning/LightningClientFactory.cs b/BTCPayServer/Payments/Lightning/LightningClientFactory.cs index d31947d68..7cb26db7e 100644 --- a/BTCPayServer/Payments/Lightning/LightningClientFactory.cs +++ b/BTCPayServer/Payments/Lightning/LightningClientFactory.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using BTCPayServer.Payments.Lightning.Charge; +using BTCPayServer.Payments.Lightning.CLightning; namespace BTCPayServer.Payments.Lightning { @@ -10,7 +11,17 @@ namespace BTCPayServer.Payments.Lightning { public ILightningInvoiceClient CreateClient(LightningSupportedPaymentMethod supportedPaymentMethod, BTCPayNetwork network) { - return new ChargeClient(supportedPaymentMethod.GetLightningChargeUrl(true), network.NBitcoinNetwork); + var uri = supportedPaymentMethod.GetLightningUrl(); + if (uri.ConnectionType == LightningConnectionType.Charge) + { + return new ChargeClient(uri.ToUri(true), network.NBitcoinNetwork); + } + else if (uri.ConnectionType == LightningConnectionType.CLightning) + { + return new CLightningRPCClient(uri.ToUri(false), network.NBitcoinNetwork); + } + else + throw new NotSupportedException($"Unsupported connection string for lightning server ({uri.ConnectionType})"); } } } diff --git a/BTCPayServer/Payments/Lightning/LightningConnectionString.cs b/BTCPayServer/Payments/Lightning/LightningConnectionString.cs new file mode 100644 index 000000000..fe7fc8a6a --- /dev/null +++ b/BTCPayServer/Payments/Lightning/LightningConnectionString.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BTCPayServer.Payments.Lightning +{ + public enum LightningConnectionType + { + Charge, + CLightning + } + public class LightningConnectionString + { + public static bool TryParse(string str, out LightningConnectionString connectionString) + { + return TryParse(str, out connectionString, out var error); + } + public static bool TryParse(string str, out LightningConnectionString connectionString, out string error) + { + if (str == null) + throw new ArgumentNullException(nameof(str)); + if (str.StartsWith('/')) + str = "unix:" + str; + var result = new LightningConnectionString(); + connectionString = null; + error = null; + + Uri uri; + if (!System.Uri.TryCreate(str, UriKind.Absolute, out uri)) + { + error = "Invalid URL"; + return false; + } + + var supportedDomains = new string[] { "unix", "tcp", "http", "https" }; + if (!supportedDomains.Contains(uri.Scheme)) + { + var protocols = String.Join(",", supportedDomains); + error = $"The url support the following protocols {protocols}"; + return false; + } + + if (uri.Scheme == "unix") + { + str = uri.AbsoluteUri.Substring("unix:".Length); + while (str.Length >= 1 && str[0] == '/') + { + str = str.Substring(1); + } + uri = new Uri("unix://" + str, UriKind.Absolute); + } + + if (uri.Scheme == "http" || uri.Scheme == "https") + { + var parts = uri.UserInfo.Split(':'); + if (string.IsNullOrEmpty(uri.UserInfo) || parts.Length != 2) + { + error = "The url is missing user and password"; + return false; + } + result.Username = parts[0]; + result.Password = parts[1]; + } + else if (!string.IsNullOrEmpty(uri.UserInfo)) + { + error = "The url should not have user information"; + return false; + } + result.BaseUri = new UriBuilder(uri) { UserName = "", Password = "" }.Uri; + connectionString = result; + return true; + } + + public LightningConnectionString() + { + + } + + public string Username { get; set; } + public string Password { get; set; } + public Uri BaseUri { get; set; } + + public LightningConnectionType ConnectionType + { + get + { + return BaseUri.Scheme == "http" || BaseUri.Scheme == "https" ? LightningConnectionType.Charge + : LightningConnectionType.CLightning; + } + } + + public Uri ToUri(bool withCredentials) + { + if (withCredentials) + { + return new UriBuilder(BaseUri) { UserName = Username ?? "", Password = Password ?? "" }.Uri; + } + else + { + return BaseUri; + } + } + + public override string ToString() + { + return ToUri(true).AbsoluteUri; + } + } +} diff --git a/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs b/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs index dd8b49be1..a73e75d5a 100644 --- a/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs +++ b/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs @@ -49,18 +49,21 @@ namespace BTCPayServer.Payments.Lightning catch { return false; } } + /// + /// Used for testing + /// + public bool SkipP2PTest { get; set; } + public async Task Test(LightningSupportedPaymentMethod supportedPaymentMethod, BTCPayNetwork network) { if (!_Dashboard.IsFullySynched(network.CryptoCode, out var summary)) throw new Exception($"Full node not available"); - var cts = new CancellationTokenSource(5000); var client = _LightningClientFactory.CreateClient(supportedPaymentMethod, network); LightningNodeInformation info = null; try { - info = await client.GetInfo(cts.Token); } catch (Exception ex) @@ -68,6 +71,11 @@ namespace BTCPayServer.Payments.Lightning throw new Exception($"Error while connecting to the API ({ex.Message})"); } + if(info.Address == null) + { + throw new Exception($"No lightning node public address has been configured"); + } + var blocksGap = Math.Abs(info.BlockHeight - summary.Status.ChainHeight); if (blocksGap > 10) { @@ -76,7 +84,8 @@ namespace BTCPayServer.Payments.Lightning try { - await TestConnection(info.Address, info.P2PPort, cts.Token); + if(!SkipP2PTest) + await TestConnection(info.Address, info.P2PPort, cts.Token); } catch (Exception ex) { diff --git a/BTCPayServer/Payments/Lightning/LightningListener.cs b/BTCPayServer/Payments/Lightning/LightningListener.cs index a6c0ed1d4..645f22a73 100644 --- a/BTCPayServer/Payments/Lightning/LightningListener.cs +++ b/BTCPayServer/Payments/Lightning/LightningListener.cs @@ -79,7 +79,7 @@ namespace BTCPayServer.Payments.Lightning var listenedInvoice = new ListenedInvoice() { - Uri = lightningSupportedMethod.GetLightningChargeUrl(false).AbsoluteUri, + Uri = lightningSupportedMethod.GetLightningUrl().BaseUri.AbsoluteUri, PaymentMethodDetails = lightningMethod, SupportedPaymentMethod = lightningSupportedMethod, PaymentMethod = paymentMethod, @@ -125,7 +125,7 @@ namespace BTCPayServer.Payments.Lightning { try { - Logs.PayServer.LogInformation($"{supportedPaymentMethod.CryptoCode} (Lightning): Start listening {supportedPaymentMethod.GetLightningChargeUrl(false)}"); + Logs.PayServer.LogInformation($"{supportedPaymentMethod.CryptoCode} (Lightning): Start listening {supportedPaymentMethod.GetLightningUrl().BaseUri}"); var charge = _LightningClientFactory.CreateClient(supportedPaymentMethod, network); var session = await charge.Listen(_Cts.Token); while (true) @@ -134,7 +134,6 @@ namespace BTCPayServer.Payments.Lightning ListenedInvoice listenedInvoice = GetListenedInvoice(notification.Id); if (listenedInvoice == null) continue; - if (notification.Id == listenedInvoice.PaymentMethodDetails.InvoiceId && notification.BOLT11 == listenedInvoice.PaymentMethodDetails.BOLT11) { @@ -157,10 +156,10 @@ namespace BTCPayServer.Payments.Lightning } catch (Exception ex) { - Logs.PayServer.LogError(ex, $"{supportedPaymentMethod.CryptoCode} (Lightning): Error while contacting {supportedPaymentMethod.GetLightningChargeUrl(false)}"); - DoneListening(supportedPaymentMethod.GetLightningChargeUrl(false)); + Logs.PayServer.LogError(ex, $"{supportedPaymentMethod.CryptoCode} (Lightning): Error while contacting {supportedPaymentMethod.GetLightningUrl().BaseUri}"); + DoneListening(supportedPaymentMethod.GetLightningUrl()); } - Logs.PayServer.LogInformation($"{supportedPaymentMethod.CryptoCode} (Lightning): Stop listening {supportedPaymentMethod.GetLightningChargeUrl(false)}"); + Logs.PayServer.LogInformation($"{supportedPaymentMethod.CryptoCode} (Lightning): Stop listening {supportedPaymentMethod.GetLightningUrl().BaseUri}"); } private async Task AddPayment(BTCPayNetwork network, LightningInvoice notification, ListenedInvoice listenedInvoice) @@ -204,8 +203,9 @@ namespace BTCPayServer.Payments.Lightning /// Stop listening all invoices on this server /// /// - private void DoneListening(Uri uri) + private void DoneListening(LightningConnectionString connectionString) { + var uri = connectionString.BaseUri; lock (_ListenedInvoiceByChargeInvoiceId) { foreach (var listenedInvoice in _ListenedInvoiceByLightningUrl[uri.AbsoluteUri]) diff --git a/BTCPayServer/Payments/Lightning/LightningSupportedPaymentMethod.cs b/BTCPayServer/Payments/Lightning/LightningSupportedPaymentMethod.cs index 58420e297..e1b100e9e 100644 --- a/BTCPayServer/Payments/Lightning/LightningSupportedPaymentMethod.cs +++ b/BTCPayServer/Payments/Lightning/LightningSupportedPaymentMethod.cs @@ -8,41 +8,36 @@ namespace BTCPayServer.Payments.Lightning public class LightningSupportedPaymentMethod : ISupportedPaymentMethod { public string CryptoCode { get; set; } - [Obsolete("Use Get/SetLightningChargeUrl")] + [Obsolete("Use Get/SetLightningUrl")] public string LightningChargeUrl { get; set; } - public Uri GetLightningChargeUrl(bool withCredentials) + public LightningConnectionString GetLightningUrl() { #pragma warning disable CS0618 // Type or member is obsolete - UriBuilder uri = new UriBuilder(LightningChargeUrl); - if (withCredentials) + var fullUri = new UriBuilder(LightningChargeUrl) { UserName = Username, Password = Password }.Uri.AbsoluteUri; + if(!LightningConnectionString.TryParse(fullUri, out var connectionString, out var error)) { - uri.UserName = Username; - uri.Password = Password; + throw new FormatException(error); } + return connectionString; #pragma warning restore CS0618 // Type or member is obsolete - return uri.Uri; } - public void SetLightningChargeUrl(Uri uri) + public void SetLightningUrl(LightningConnectionString connectionString) { - if (uri == null) - throw new ArgumentNullException(nameof(uri)); - if (string.IsNullOrEmpty(uri.UserInfo)) - throw new ArgumentException(paramName: nameof(uri), message: "Uri should have credential information"); - var splitted = uri.UserInfo.Split(':'); - if (splitted.Length != 2) - throw new ArgumentException(paramName: nameof(uri), message: "Uri should have credential information"); + if (connectionString == null) + throw new ArgumentNullException(nameof(connectionString)); + #pragma warning disable CS0618 // Type or member is obsolete - Username = splitted[0]; - Password = splitted[1]; - LightningChargeUrl = new UriBuilder(uri) { UserName = "", Password = "" }.Uri.AbsoluteUri; + Username = connectionString.Username; + Password = connectionString.Password; + LightningChargeUrl = connectionString.BaseUri.AbsoluteUri; #pragma warning restore CS0618 // Type or member is obsolete } - [Obsolete("Use Get/SetLightningChargeUrl")] + [Obsolete("Use Get/SetLightningUrl")] public string Username { get; set; } - [Obsolete("Use Get/SetLightningChargeUrl")] + [Obsolete("Use Get/SetLightningUrl")] public string Password { get; set; } public PaymentMethodId PaymentId => new PaymentMethodId(CryptoCode, PaymentTypes.LightningLike); }