#nullable enable using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; using BTCPayServer.Client.Models; using BTCPayServer.Configuration; using BTCPayServer.Data; using BTCPayServer.Events; using BTCPayServer.Lightning; using BTCPayServer.Logging; using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Services; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Stores; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NBitcoin; using NBitcoin.Crypto; using NBitcoin.DataEncoders; using NBXplorer; using Newtonsoft.Json.Linq; namespace BTCPayServer.Payments.Lightning { public class LightningListener : IHostedService { public Logs Logs { get; } readonly EventAggregator _Aggregator; readonly InvoiceRepository _InvoiceRepository; private readonly IMemoryCache _memoryCache; readonly BTCPayNetworkProvider _NetworkProvider; private readonly LightningClientFactoryService lightningClientFactory; private readonly StoreRepository _storeRepository; private readonly PaymentService _paymentService; private readonly PaymentMethodHandlerDictionary _handlers; readonly Channel _CheckInvoices = Channel.CreateUnbounded(); Task? _CheckingInvoice; readonly Dictionary<(string, string), LightningInstanceListener> _InstanceListeners = new(); public LightningListener(EventAggregator aggregator, InvoiceRepository invoiceRepository, IMemoryCache memoryCache, BTCPayNetworkProvider networkProvider, LightningClientFactoryService lightningClientFactory, StoreRepository storeRepository, IOptions options, PaymentService paymentService, PaymentMethodHandlerDictionary paymentMethodHandlerDictionary, Logs logs) { Logs = logs; _Aggregator = aggregator; _InvoiceRepository = invoiceRepository; _memoryCache = memoryCache; _NetworkProvider = networkProvider; this.lightningClientFactory = lightningClientFactory; _storeRepository = storeRepository; _paymentService = paymentService; _handlers = paymentMethodHandlerDictionary; Options = options; } bool needCheckOfflinePayments = true; async Task CheckingInvoice(CancellationToken cancellation) { var pmis = _handlers.Where(h => h is LightningLikePaymentHandler).Select(handler => handler.PaymentMethodId).ToArray(); foreach (var pmi in pmis) { retry: try { Logs.PayServer.LogInformation("Checking if any payment arrived on lightning while the server was offline..."); foreach (var invoice in await _InvoiceRepository.GetMonitoredInvoices(pmi, cancellation)) { if (GetListenedInvoices(invoice).Count > 0) { _CheckInvoices.Writer.TryWrite(invoice.Id); _memoryCache.Set(GetInvoiceCacheKey(invoice.Id), invoice, GetExpiration(invoice)); } } needCheckOfflinePayments = false; Logs.PayServer.LogInformation("Processing lightning payments..."); while (await _CheckInvoices.Reader.WaitToReadAsync(cancellation) && _CheckInvoices.Reader.TryRead(out var invoiceId)) { var invoice = await GetInvoice(invoiceId); foreach (var listenedInvoice in GetListenedInvoices(invoice)) { var store = await GetStore(invoice.StoreId); var lnConfig = _handlers.GetLightningConfig(store, listenedInvoice.Network); if (lnConfig is null) continue; var connStr = GetLightningUrl(listenedInvoice.Network.CryptoCode, lnConfig); if (connStr is null) continue; var instanceListenerKey = (listenedInvoice.Network.CryptoCode, connStr); lock (_InstanceListeners) { if (!_InstanceListeners.TryGetValue(instanceListenerKey, out var instanceListener)) { instanceListener ??= new LightningInstanceListener(_InvoiceRepository, _Aggregator, lightningClientFactory, listenedInvoice.Network, _handlers, connStr, _paymentService, Logs); _InstanceListeners.TryAdd(instanceListenerKey, instanceListener); } instanceListener.AddListenedInvoice(listenedInvoice); _ = instanceListener.PollPayment(listenedInvoice, cancellation); } } if (_CheckInvoices.Reader.Count is 0) this.CheckConnections(); } } catch when (cancellation.IsCancellationRequested) { } catch (Exception ex) { await Task.Delay(1000, cancellation); Logs.PayServer.LogWarning(ex, "Unhandled error in the LightningListener"); goto retry; } } } private string GetInvoiceCacheKey(string invoiceId) => $"{nameof(GetListenedInvoices)}-{invoiceId}"; private string GetStoreCacheKey(string storeId) => $"{nameof(GetListenedInvoices)}-store-{storeId}"; private Task GetInvoice(string invoiceId) { return _memoryCache.GetOrCreateAsync(GetInvoiceCacheKey(invoiceId), async (cacheEntry) => { var invoice = await _InvoiceRepository.GetInvoice(invoiceId); if (invoice is null) return null; cacheEntry.AbsoluteExpiration = GetExpiration(invoice); return invoice; })!; } private Task GetStore(string storeId) { return _memoryCache.GetOrCreateAsync(GetStoreCacheKey(storeId), async (cacheEntry) => { var store = await _storeRepository.FindStore(storeId); cacheEntry.AbsoluteExpiration = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(1.0); return store; })!; } private static DateTimeOffset GetExpiration(InvoiceEntity invoice) { var expiredIn = DateTimeOffset.UtcNow - invoice.ExpirationTime; return DateTimeOffset.UtcNow + (expiredIn >= TimeSpan.FromMinutes(5.0) ? expiredIn : TimeSpan.FromMinutes(5.0)); } IEnumerable<(IPaymentMethodHandler Handler, PaymentPrompt PaymentPrompt, object? Details)> GetLightningPrompts(InvoiceEntity invoice) { foreach (var prompt in invoice.GetPaymentPrompts()) { if (!prompt.Activated) continue; if (!_handlers.TryGetValue(prompt.PaymentMethodId, out var handler)) continue; if (handler is ILightningPaymentHandler) yield return (handler, prompt, handler.ParsePaymentPromptDetails(prompt.Details)); } } private List GetListenedInvoices(InvoiceEntity invoice) { var listenedInvoices = new List(); foreach (var o in GetLightningPrompts(invoice)) { if (o.Details is not LigthningPaymentPromptDetails { InvoiceId: not null } ligthningDetails) continue; listenedInvoices.Add(new ListenedInvoice( invoice.ExpirationTime, ligthningDetails, o.PaymentPrompt, ((IHasNetwork)o.Handler).Network, invoice.Id)); } return listenedInvoices; } readonly ConcurrentDictionary _ListeningInstances = new ConcurrentDictionary(); readonly CompositeDisposable leases = new CompositeDisposable(); public Task StartAsync(CancellationToken cancellationToken) { leases.Add(_Aggregator.SubscribeAsync(async inv => { if (inv.Name == InvoiceEvent.Created) { _CheckInvoices.Writer.TryWrite(inv.Invoice.Id); } if (inv.Name == InvoiceEvent.ReceivedPayment && inv.Invoice.Status == InvoiceStatus.New && inv.Invoice.ExceptionStatus == InvoiceExceptionStatus.PaidPartial) { var pm = inv.Invoice.GetPaymentPrompts().First(); if (pm.Calculate().Due > 0m) { await CreateNewLNInvoiceForBTCPayInvoice(inv.Invoice); } } })); leases.Add(_Aggregator.SubscribeAsync(async inv => { if (inv.State.Status == InvoiceStatus.New && inv.State.ExceptionStatus == InvoiceExceptionStatus.PaidPartial) { var invoice = await _InvoiceRepository.GetInvoice(inv.InvoiceId); await CreateNewLNInvoiceForBTCPayInvoice(invoice); } })); leases.Add(_Aggregator.Subscribe(inv => { if (_handlers.TryGet(inv.PaymentMethodId) is LightningLikePaymentHandler) { _memoryCache.Remove(GetInvoiceCacheKey(inv.InvoiceId)); _CheckInvoices.Writer.TryWrite(inv.InvoiceId); } })); leases.Add(_Aggregator.Subscribe(ev => { _memoryCache.Remove(GetStoreCacheKey(ev.StoreId)); })); leases.Add(_Aggregator.Subscribe(inv => { if (_handlers.TryGet(inv.PaymentMethodId) is LNURLPayPaymentHandler && !string.IsNullOrEmpty(inv.InvoiceId)) { _memoryCache.Remove(GetInvoiceCacheKey(inv.InvoiceId)); _CheckInvoices.Writer.TryWrite(inv.InvoiceId); } })); _CheckingInvoice = CheckingInvoice(_Cts.Token); _ListenPoller = new Timer(s => { if (needCheckOfflinePayments) return; try { CheckConnections(); } catch { } }, null, 0, (int)PollInterval.TotalMilliseconds); leases.Add(_ListenPoller); return Task.CompletedTask; } private void CheckConnections() { lock (_InstanceListeners) { foreach (var key in _InstanceListeners.Keys) { CheckConnection(key.Item1, key.Item2); } } } public void CheckConnection(string cryptoCode, string connStr) { if (_InstanceListeners.TryGetValue((cryptoCode, connStr), out var instance)) { instance.RemoveExpiredInvoices(); if (!instance.Empty) instance.EnsureListening(_Cts.Token); } } private async Task CreateNewLNInvoiceForBTCPayInvoice(InvoiceEntity invoice) { var paymentMethods = GetLightningPrompts(invoice).ToArray(); var store = await _storeRepository.FindStore(invoice.StoreId); if (store is null) return; if (paymentMethods.Any()) { var logs = new InvoiceLogs(); logs.Write( "Partial payment detected, attempting to update all lightning payment methods with new bolt11 with correct due amount.", InvoiceEventData.EventSeverity.Info); foreach (var o in paymentMethods) { var network = ((IHasNetwork)o.Handler).Network; if (o.Details is not LigthningPaymentPromptDetails oldDetails) continue; var lnConfig = _handlers.GetLightningConfig(store, network); if (lnConfig is null) continue; var connStr = GetLightningUrl(network.CryptoCode, lnConfig); var lightningHandler = _handlers.GetLightningHandler(network); if (connStr is null) continue; try { if (oldDetails is LNURLPayPaymentMethodDetails lnurlPayPaymentMethodDetails) { // LNUrlPay doesn't create a BOLT11 until it's actually scanned. // So if no BOLT11 already created, which is likely the case, do nothing if (string.IsNullOrEmpty(o.PaymentPrompt.Destination)) continue; try { var client = lightningHandler.CreateLightningClient(lnConfig); await client.CancelInvoice(oldDetails.InvoiceId); } catch { //not a fully supported option } lnurlPayPaymentMethodDetails = new LNURLPayPaymentMethodDetails() { Bech32Mode = lnurlPayPaymentMethodDetails.Bech32Mode, NodeInfo = lnurlPayPaymentMethodDetails.NodeInfo, }; o.PaymentPrompt.Destination = null; o.PaymentPrompt.Details = JToken.FromObject(lnurlPayPaymentMethodDetails, o.Handler.Serializer); await _InvoiceRepository.UpdatePrompt(invoice.Id, o.PaymentPrompt); _Aggregator.Publish(new Events.InvoiceNewPaymentDetailsEvent(invoice.Id, lnurlPayPaymentMethodDetails, o.Handler.PaymentMethodId)); continue; } try { var client = lightningHandler.CreateLightningClient(lnConfig); await client.CancelInvoice(oldDetails.InvoiceId); } catch { //not a fully supported option } var paymentContext = new PaymentMethodContext(store, store.GetStoreBlob(), JToken.FromObject(lnConfig, _handlers.GetLightningHandler(network).Serializer), lightningHandler, invoice, logs); var paymentPrompt = paymentContext.Prompt; await paymentContext.BeforeFetchingRates(); await paymentContext.CreatePaymentPrompt(); if (paymentContext.Status != PaymentMethodContext.ContextStatus.Created) continue; var instanceListenerKey = (paymentPrompt.Currency, connStr); LightningInstanceListener? instanceListener; lock (_InstanceListeners) { _InstanceListeners.TryGetValue(instanceListenerKey, out instanceListener); } if (instanceListener is not null) { await _InvoiceRepository.NewPaymentPrompt(invoice.Id, paymentContext); await paymentContext.ActivatingPaymentPrompt(); var details = lightningHandler.ParsePaymentPromptDetails(paymentPrompt.Details); instanceListener.AddListenedInvoice(new ListenedInvoice( invoice.ExpirationTime, details, paymentPrompt, network, invoice.Id)); _Aggregator.Publish(new Events.InvoiceNewPaymentDetailsEvent(invoice.Id, details, paymentPrompt.PaymentMethodId)); } } catch (Exception e) { logs.Write($"Could not update {o.Handler.PaymentMethodId}: {e.Message}", InvoiceEventData.EventSeverity.Error); } } await _InvoiceRepository.AddInvoiceLogs(invoice.Id, logs); _CheckInvoices.Writer.TryWrite(invoice.Id); } } private string? GetLightningUrl(string cryptoCode, LightningPaymentMethodConfig supportedMethod) { var url = supportedMethod.GetExternalLightningUrl(); if (url != null) return url; return Options.Value.InternalLightningByCryptoCode.TryGetValue(cryptoCode, out var conn) ? conn.ToString() : null; } TimeSpan _PollInterval = TimeSpan.FromMinutes(1.0); public TimeSpan PollInterval { get { return _PollInterval; } set { _PollInterval = value; if (_ListenPoller != null) { _ListenPoller.Change(0, (int)value.TotalMilliseconds); } } } private Timer? _ListenPoller; public IOptions Options { get; } readonly CancellationTokenSource _Cts = new CancellationTokenSource(); public async Task StopAsync(CancellationToken cancellationToken) { leases.Dispose(); _Cts.Cancel(); try { if (_CheckingInvoice != null) await _CheckingInvoice; } catch (OperationCanceledException) { } try { await Task.WhenAll(_ListeningInstances.Select(c => c.Value.Listening).Where(c => c != null).ToArray()!); } catch (OperationCanceledException) { } Logs.PayServer.LogInformation($"{this.GetType().Name} successfully exited..."); } } public class LightningInstanceListener { public Logs Logs { get; } private readonly InvoiceRepository _invoiceRepository; private readonly EventAggregator _eventAggregator; private readonly BTCPayNetwork _network; private readonly PaymentMethodHandlerDictionary _handlers; private readonly PaymentService _paymentService; private readonly LightningClientFactoryService _lightningClientFactory; public string ConnectionString { get; } public LightningInstanceListener(InvoiceRepository invoiceRepository, EventAggregator eventAggregator, LightningClientFactoryService lightningClientFactory, BTCPayNetwork network, PaymentMethodHandlerDictionary handlers, string connectionString, PaymentService paymentService, Logs logs) { ArgumentNullException.ThrowIfNull(connectionString); Logs = logs; this._invoiceRepository = invoiceRepository; _eventAggregator = eventAggregator; _handlers = handlers; this._network = network; _paymentService = paymentService; _lightningClientFactory = lightningClientFactory; ConnectionString = connectionString; } internal bool AddListenedInvoice(ListenedInvoice invoice) { return _ListenedInvoices.TryAdd(invoice.PaymentMethodDetails.InvoiceId, invoice); } internal async Task PollPayment(ListenedInvoice listenedInvoice, CancellationToken cancellation) { var client = _lightningClientFactory.Create(ConnectionString, _network); var lightningInvoice = await client.GetInvoice(listenedInvoice.PaymentMethodDetails.InvoiceId, cancellation); if (lightningInvoice is null) { _ListenedInvoices.TryRemove(listenedInvoice.PaymentMethodDetails.InvoiceId, out _); return; } if (await AddPayment(lightningInvoice, listenedInvoice.InvoiceId, listenedInvoice.PaymentMethod.PaymentMethodId)) Logs.PayServer.LogInformation($"{_network.CryptoCode} (Lightning): Payment detected via polling on {listenedInvoice.InvoiceId}"); } public bool Empty => _ListenedInvoices.IsEmpty; public bool IsListening => Listening?.Status is TaskStatus.Running || Listening?.Status is TaskStatus.WaitingForActivation; public Task? Listening { get; set; } public void EnsureListening(CancellationToken cancellation) { if (!IsListening) { if (StopListeningCancellationTokenSource != null) StopListeningCancellationTokenSource.Dispose(); StopListeningCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellation); Listening = Listen(StopListeningCancellationTokenSource.Token); } } public CancellationTokenSource? StopListeningCancellationTokenSource; async Task Listen(CancellationToken cancellation) { string? uri = null; try { var lightningClient = _lightningClientFactory.Create(ConnectionString, _network); if (lightningClient is null) return; ListenLogs(lightningClient); uri = lightningClient.GetServerUri(ConnectionString)?.RemoveUserInfo() ?? ""; Logs.PayServer.LogInformation("{CryptoCode} (Lightning): Start listening {Uri}", _network.CryptoCode, uri); using var session = await lightningClient.Listen(cancellation); // Just in case the payment arrived after our last poll but before we listened. await PollAllListenedInvoices(cancellation); if (_ErrorAlreadyLogged) { Logs.PayServer.LogInformation("{CryptoCode} (Lightning): Could reconnect successfully to {Uri}", _network.CryptoCode, uri); } _ErrorAlreadyLogged = false; while (!_ListenedInvoices.IsEmpty) { var notification = await session.WaitInvoice(cancellation); if (!_ListenedInvoices.TryGetValue(notification.Id, out var listenedInvoice)) continue; if (await AddPayment(notification, listenedInvoice.InvoiceId, listenedInvoice.PaymentMethod.PaymentMethodId)) { Logs.PayServer.LogInformation("{CryptoCode} (Lightning): Payment detected via notification ({InvoiceId})", _network.CryptoCode, listenedInvoice.InvoiceId); } } } catch (Exception ex) when (!cancellation.IsCancellationRequested && !_ErrorAlreadyLogged) { _ErrorAlreadyLogged = true; Logs.PayServer.LogError(ex, "{CryptoCode} (Lightning): Error while contacting {Uri}", _network.CryptoCode, uri); Logs.PayServer.LogInformation("{CryptoCode} (Lightning): Stop listening {Uri}", _network.CryptoCode, uri); } catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { } if (_ListenedInvoices.IsEmpty) Logs.PayServer.LogInformation("{CryptoCode} (Lightning): No more invoice to listen on {Uri}, releasing the connection", _network.CryptoCode, uri); } private void ListenLogs(ILightningClient lightningClient) { if (lightningClient is BTCPayServer.Lightning.LND.LndClient lnd) { lnd.Log = msg => Logs.PayServer.LogWarning(msg); } } public DateTimeOffset? LastFullPoll { get; set; } internal async Task PollAllListenedInvoices(CancellationToken cancellation) { foreach (var invoice in _ListenedInvoices.Values) { await PollPayment(invoice, cancellation); } LastFullPoll = DateTimeOffset.UtcNow; if (_ListenedInvoices.IsEmpty) { StopListeningCancellationTokenSource?.Cancel(); } } bool _ErrorAlreadyLogged = false; readonly ConcurrentDictionary _ListenedInvoices = new ConcurrentDictionary(); internal async Task AddPayment(LightningInvoice notification, string invoiceId, PaymentMethodId paymentMethodId) { var state = await AddPaymentCore(notification, invoiceId, paymentMethodId); if (state is not RecordedState.RetryLater) _ListenedInvoices.TryRemove(notification.Id, out _); return state is RecordedState.RecordedNow; } enum RecordedState { RecordedNow, AlreadyRecorded, RetryLater, Expired } async Task AddPaymentCore(LightningInvoice notification, string invoiceId, PaymentMethodId paymentMethodId) { if (notification.Status is LightningInvoiceStatus.Expired) return RecordedState.Expired; if (notification.Status is LightningInvoiceStatus.Unpaid) return RecordedState.RetryLater; var invoiceEntity = await _invoiceRepository.GetInvoice(invoiceId); if (invoiceEntity is null) return RecordedState.AlreadyRecorded; var paidAt = notification.PaidAt ?? DateTimeOffset.UtcNow; var paidAmount = notification.AmountReceived ?? notification.Amount; if (paidAmount is null) { Logs.PayServer.LogWarning( "{CryptoCode} (Lightning): Invoice {InvoiceId} is paid according to the node but no amount was returned; cannot record the payment yet.", _network.CryptoCode, invoiceId); return RecordedState.RetryLater; } var handler = _handlers[paymentMethodId]; var paymentHash = notification.GetPaymentHash(_network.NBitcoinNetwork); var preimage = GetValidPreimage(notification, paymentHash); var paymentData = new PaymentData() { Id = paymentHash?.ToString() ?? notification.BOLT11, Created = paidAt, Status = PaymentStatus.Settled, Currency = _network.CryptoCode, InvoiceDataId = invoiceId, Amount = paidAmount.ToDecimal(LightMoneyUnit.BTC), }.Set(invoiceEntity, handler, new LightningLikePaymentData() { PaymentHash = paymentHash, Preimage = preimage, }); var payment = await _paymentService.AddPayment(paymentData, [notification.BOLT11]); if (payment is null) return RecordedState.AlreadyRecorded; if (preimage is not null) { var details = (LigthningPaymentPromptDetails)handler.ParsePaymentPromptDetails(invoiceEntity.GetPaymentPrompt(handler.PaymentMethodId)! .Details); if (details.Preimage is null) { details.Preimage = preimage; await _invoiceRepository.UpdatePaymentDetails(invoiceId, handler, details); } } var invoice = await _invoiceRepository.GetInvoice(invoiceId); if (invoice != null) _eventAggregator.Publish(new InvoiceEvent(invoice, InvoiceEvent.ReceivedPayment) { Payment = payment }); return RecordedState.RecordedNow; } private uint256? GetValidPreimage(LightningInvoice notification, uint256? paymentHash) { uint256? preimage = null; if (!string.IsNullOrEmpty(notification.Preimage) && HexEncoder.IsWellFormed(notification.Preimage) && notification.Preimage.Length == 64 && paymentHash is not null) { var candidatePreimage = Encoders.Hex.DecodeData(notification.Preimage); if (Hashes.SHA256(candidatePreimage).AsSpan().SequenceEqual(paymentHash.ToBytes(false))) { Array.Reverse(candidatePreimage); preimage = new uint256(candidatePreimage); } else Logs.PayServer.LogWarning( "{CryptoCode} (Lightning): Invoice {InvoiceId} has a preimage but it doesn't match the payment hash.", _network.CryptoCode, notification.Id); } return preimage; } internal void RemoveExpiredInvoices() { foreach (var invoice in _ListenedInvoices) { if (invoice.Value.IsExpired()) _ListenedInvoices.TryRemove(invoice.Key, out var _); } if (_ListenedInvoices.IsEmpty) StopListeningCancellationTokenSource?.Cancel(); } } public record ListenedInvoice( DateTimeOffset Expiration, LigthningPaymentPromptDetails PaymentMethodDetails, PaymentPrompt PaymentMethod, BTCPayNetwork Network, string InvoiceId) { public bool IsExpired() { return DateTimeOffset.UtcNow > Expiration; } } }