diff --git a/BTCPayServer.Tests/Lnd/UnitTests.cs b/BTCPayServer.Tests/Lnd/UnitTests.cs index 0ba85c652..b932e4f21 100644 --- a/BTCPayServer.Tests/Lnd/UnitTests.cs +++ b/BTCPayServer.Tests/Lnd/UnitTests.cs @@ -64,6 +64,7 @@ namespace BTCPayServer.Tests.Lnd public async Task TestWaitListenInvoice() { var merchantInvoice = await InvoiceClient.CreateInvoice(10000, "Hello world", TimeSpan.FromSeconds(3600)); + var merchantInvoice2 = await InvoiceClient.CreateInvoice(10000, "Hello world", TimeSpan.FromSeconds(3600)); var waitToken = default(CancellationToken); var listener = await InvoiceClient.Listen(waitToken); @@ -76,8 +77,22 @@ namespace BTCPayServer.Tests.Lnd }); var invoice = await waitTask; - Assert.True(invoice.PaidAt.HasValue); + + var waitTask2 = listener.WaitInvoice(waitToken); + + payResponse = await CustomerLnd.SendPaymentSyncAsync(new LnrpcSendRequest + { + Payment_request = merchantInvoice2.BOLT11 + }); + + invoice = await waitTask2; + Assert.True(invoice.PaidAt.HasValue); + + var waitTask3 = listener.WaitInvoice(waitToken); + await Task.Delay(100); + listener.Dispose(); + Assert.Throws(()=> waitTask3.GetAwaiter().GetResult()); } [Fact] diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index 151042737..8943b32de 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -109,7 +109,7 @@ namespace BTCPayServer public static string GetAbsoluteUri(this HttpRequest request, string redirectUrl) { - bool isRelative = + bool isRelative = (redirectUrl.Length > 0 && redirectUrl[0] == '/') || !new Uri(redirectUrl, UriKind.RelativeOrAbsolute).IsAbsoluteUri; return isRelative ? request.GetAbsoluteRoot() + redirectUrl : redirectUrl; @@ -141,7 +141,7 @@ namespace BTCPayServer public static void AddRange(this HashSet hashSet, IEnumerable items) { - foreach(var item in items) + foreach (var item in items) { hashSet.Add(item); } @@ -157,6 +157,15 @@ namespace BTCPayServer NBitcoin.Extensions.TryAdd(ctx.Items, "BitpayAuth", value); } + public static async Task WithCancellation(this Task task, CancellationToken cancellationToken) + { + var waiting = Task.Delay(-1, cancellationToken); + var doing = task; + await Task.WhenAny(waiting, doing); + cancellationToken.ThrowIfCancellationRequested(); + return await doing; + } + public static (string Signature, String Id, String Authorization) GetBitpayAuth(this HttpContext ctx) { ctx.Items.TryGetValue("BitpayAuth", out object obj); diff --git a/BTCPayServer/Payments/Lightning/LightningClientFactory.cs b/BTCPayServer/Payments/Lightning/LightningClientFactory.cs index fccf801f2..eb17a4110 100644 --- a/BTCPayServer/Payments/Lightning/LightningClientFactory.cs +++ b/BTCPayServer/Payments/Lightning/LightningClientFactory.cs @@ -26,7 +26,6 @@ namespace BTCPayServer.Payments.Lightning else if (connString.ConnectionType == LightningConnectionType.CLightning) { return new CLightningRPCClient(connString.ToUri(false), network); - } else if (connString.ConnectionType == LightningConnectionType.LndREST) { diff --git a/BTCPayServer/Payments/Lightning/Lnd/LndInvoiceClient.cs b/BTCPayServer/Payments/Lightning/Lnd/LndInvoiceClient.cs index 2a1b1e4da..1a931d98b 100644 --- a/BTCPayServer/Payments/Lightning/Lnd/LndInvoiceClient.cs +++ b/BTCPayServer/Payments/Lightning/Lnd/LndInvoiceClient.cs @@ -10,13 +10,91 @@ using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Threading; +using System.Threading.Channels; using System.Threading.Tasks; using NBitcoin; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace BTCPayServer.Payments.Lightning.Lnd { - public class LndInvoiceClient : ILightningInvoiceClient, ILightningListenInvoiceSession + public class LndInvoiceClient : ILightningInvoiceClient { + class LndInvoiceClientSession : ILightningListenInvoiceSession + { + private LndSwaggerClient _Parent; + Channel _Invoices = Channel.CreateBounded(50); + CancellationTokenSource _Cts = new CancellationTokenSource(); + ManualResetEventSlim _Stopped = new ManualResetEventSlim(false); + + public LndInvoiceClientSession(LndSwaggerClient parent) + { + _Parent = parent; + } + + public async void StartListening() + { + var urlBuilder = new StringBuilder(); + urlBuilder.Append(_Parent.BaseUrl).Append("/v1/invoices/subscribe"); + try + { + while (!_Cts.IsCancellationRequested) + { + using (var client = _Parent.CreateHttpClient()) + { + client.Timeout = TimeSpan.FromMilliseconds(Timeout.Infinite); + + var request = new HttpRequestMessage(HttpMethod.Get, urlBuilder.ToString()); + + using (var response = await client.SendAsync( + request, HttpCompletionOption.ResponseHeadersRead, _Cts.Token)) + { + using (var body = await response.Content.ReadAsStreamAsync()) + using (var reader = new StreamReader(body)) + { + string line = await reader.ReadLineAsync().WithCancellation(_Cts.Token); + if (line != null && line.StartsWith("{\"result\":", StringComparison.OrdinalIgnoreCase)) + { + var invoiceString = JObject.Parse(line)["result"].ToString(); + LnrpcInvoice parsedInvoice = _Parent.Deserialize(invoiceString); + await _Invoices.Writer.WriteAsync(ConvertLndInvoice(parsedInvoice)); + } + } + } + } + } + } + catch when (_Cts.IsCancellationRequested) + { + + } + finally + { + _Stopped.Set(); + } + } + + public async Task WaitInvoice(CancellationToken cancellation) + { + try + { + return await _Invoices.Reader.ReadAsync(cancellation); + } + catch (ChannelClosedException) + { + throw new TaskCanceledException(); + } + } + + public void Dispose() + { + _Cts.Cancel(); + _Stopped.Wait(); + _Invoices.Writer.Complete(); + } + } + + public LndSwaggerClient _rpcClient; public LndInvoiceClient(LndSwaggerClient swaggerClient) @@ -78,31 +156,15 @@ namespace BTCPayServer.Payments.Lightning.Lnd var resp = await _rpcClient.LookupInvoiceAsync(invoiceId, null, cancellation); return ConvertLndInvoice(resp); } - + public Task Listen(CancellationToken cancellation = default(CancellationToken)) { -#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed - _rpcClient.StartSubscribeInvoiceThread(cancellation); -#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed - return Task.FromResult(this); + var session = new LndInvoiceClientSession(this._rpcClient); + session.StartListening(); + return Task.FromResult(session); } - async Task ILightningListenInvoiceSession.WaitInvoice(CancellationToken cancellation) - { - var resp = await _rpcClient.InvoiceResponse.Task; - return ConvertLndInvoice(resp); - } - - - // utility static methods... maybe move to separate class - private static string BitString(byte[] bytes) - { - return BitConverter.ToString(bytes) - .Replace("-", "", StringComparison.InvariantCulture) - .ToLower(CultureInfo.InvariantCulture); - } - - private static LightningInvoice ConvertLndInvoice(LnrpcInvoice resp) + internal static LightningInvoice ConvertLndInvoice(LnrpcInvoice resp) { var invoice = new LightningInvoice { @@ -129,9 +191,13 @@ namespace BTCPayServer.Payments.Lightning.Lnd return invoice; } - public void Dispose() + + // utility static methods... maybe move to separate class + private static string BitString(byte[] bytes) { - // + return BitConverter.ToString(bytes) + .Replace("-", "", StringComparison.InvariantCulture) + .ToLower(CultureInfo.InvariantCulture); } // Invariant culture conversion diff --git a/BTCPayServer/Payments/Lightning/Lnd/LndSwaggerClient.partial.cs b/BTCPayServer/Payments/Lightning/Lnd/LndSwaggerClient.partial.cs index cd574f3b2..a12814ec6 100644 --- a/BTCPayServer/Payments/Lightning/Lnd/LndSwaggerClient.partial.cs +++ b/BTCPayServer/Payments/Lightning/Lnd/LndSwaggerClient.partial.cs @@ -42,7 +42,7 @@ namespace BTCPayServer.Payments.Lightning.Lnd _Settings = settings; } LndRestSettings _Settings; - private static HttpClient CreateHttpClient(LndRestSettings settings) + internal static HttpClient CreateHttpClient(LndRestSettings settings) { var handler = new HttpClientHandler { @@ -79,51 +79,14 @@ namespace BTCPayServer.Payments.Lightning.Lnd return httpClient; } - public TaskCompletionSource InvoiceResponse = new TaskCompletionSource(); - public TaskCompletionSource SubscribeLost = new TaskCompletionSource(); - - // TODO: Refactor swagger generated wrapper to include this method directly - public async Task StartSubscribeInvoiceThread(CancellationToken token) + internal HttpClient CreateHttpClient() { - var urlBuilder = new StringBuilder(); - urlBuilder.Append(BaseUrl).Append("/v1/invoices/subscribe"); + return LndSwaggerClient.CreateHttpClient(_Settings); + } - using (var client = CreateHttpClient(_Settings)) - { - client.Timeout = TimeSpan.FromMilliseconds(Timeout.Infinite); - - var request = new HttpRequestMessage(HttpMethod.Get, urlBuilder.ToString()); - - using (var response = await client.SendAsync( - request, HttpCompletionOption.ResponseHeadersRead, token)) - { - using (var body = await response.Content.ReadAsStreamAsync()) - using (var reader = new StreamReader(body)) - { - try - { - while (!reader.EndOfStream) - { - string line = reader.ReadLine(); - if (line != null && line.Contains("\"result\":", StringComparison.OrdinalIgnoreCase)) - { - dynamic parsedJson = JObject.Parse(line); - var result = parsedJson.result; - var invoiceString = result.ToString(); - LnrpcInvoice parsedInvoice = JsonConvert.DeserializeObject(invoiceString, _settings.Value); - InvoiceResponse.SetResult(parsedInvoice); - } - } - } - catch (Exception e) - { - // TODO: check that the exception type is actually from a closed stream. - Debug.WriteLine(e.Message); - SubscribeLost.SetResult(this); - } - } - } - } + internal T Deserialize(string str) + { + return JsonConvert.DeserializeObject(str, _settings.Value); } } }