#nullable enable using System; using System.Linq; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Client; using BTCPayServer.Client.Models; using BTCPayServer.Configuration; using BTCPayServer.Data; using BTCPayServer.HostedServices; using BTCPayServer.Lightning; using BTCPayServer.Lightning.LndHub; using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Security; using BTCPayServer.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Options; using NBitcoin; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace BTCPayServer.Payments.Lightning { public interface ILightningPaymentHandler : IHasNetwork { LightningPaymentData ParsePaymentDetails(JToken details); } public class LightningLikePaymentHandler : IPaymentMethodHandler, ILightningPaymentHandler { public JsonSerializer Serializer { get; } public static readonly int LightningTimeout = 5000; readonly NBXplorerDashboard _Dashboard; private readonly LightningClientFactoryService _lightningClientFactory; private readonly BTCPayNetwork _Network; private readonly SocketFactory _socketFactory; private readonly ISettingsAccessor _policies; private readonly IOptions _lightningNetworkOptions; public LightningLikePaymentHandler( PaymentMethodId paymentMethodId, NBXplorerDashboard dashboard, LightningClientFactoryService lightningClientFactory, BTCPayNetwork network, SocketFactory socketFactory, IOptions options, ISettingsAccessor policies, IOptions lightningNetworkOptions) { Serializer = BlobSerializer.CreateSerializer(network.NBitcoinNetwork).Serializer; _Dashboard = dashboard; _lightningClientFactory = lightningClientFactory; _Network = network; _socketFactory = socketFactory; Options = options; _policies = policies; _lightningNetworkOptions = lightningNetworkOptions; PaymentMethodId = paymentMethodId; } public Task BeforeFetchingRates(PaymentMethodContext context) { context.Prompt.Currency = _Network.CryptoCode; context.Prompt.PaymentMethodFee = 0m; context.Prompt.Divisibility = 11; return Task.CompletedTask; } public PaymentMethodId PaymentMethodId { get; private set; } public IOptions Options { get; } public BTCPayNetwork Network => _Network; public async Task ConfigurePrompt(PaymentMethodContext context) { if (context.InvoiceEntity.Type == InvoiceType.TopUp) { throw new PaymentMethodUnavailableException("Lightning Network payment method is not available for top-up invoices"); } var paymentPrompt = context.Prompt; var preferOnion = Uri.TryCreate(context.InvoiceEntity.ServerUrl, UriKind.Absolute, out var u) && u.IsOnion(); var storeBlob = context.StoreBlob; var store = context.Store; var config = ParsePaymentMethodConfig(context.PaymentMethodConfig); var nodeInfo = GetNodeInfo(config, context.Logs, preferOnion); var invoice = context.InvoiceEntity; decimal due = Extensions.RoundUp(invoice.Price / paymentPrompt.Rate, _Network.Divisibility); try { due = paymentPrompt.Calculate().Due; } catch (Exception) { // ignored } var client = config.CreateLightningClient(_Network, Options.Value, _lightningClientFactory); var expiry = invoice.ExpirationTime - DateTimeOffset.UtcNow; if (expiry < TimeSpan.Zero) expiry = TimeSpan.FromSeconds(1); LightningInvoice? lightningInvoice; string description = storeBlob.LightningDescriptionTemplate; description = description.Replace("{StoreName}", store.StoreName ?? "", StringComparison.OrdinalIgnoreCase) .Replace("{ItemDescription}", invoice.Metadata.ItemDesc ?? "", StringComparison.OrdinalIgnoreCase) .Replace("{OrderId}", invoice.Metadata.OrderId ?? "", StringComparison.OrdinalIgnoreCase); using (var cts = new CancellationTokenSource(LightningTimeout)) { try { var request = new CreateInvoiceParams(new LightMoney(due, LightMoneyUnit.BTC), description, expiry); request.PrivateRouteHints = storeBlob.LightningPrivateRouteHints; lightningInvoice = await client.CreateInvoice(request, cts.Token); } catch (OperationCanceledException) when (cts.IsCancellationRequested) { throw new PaymentMethodUnavailableException("The lightning node did not reply in a timely manner"); } catch (Exception ex) { throw new PaymentMethodUnavailableException($"Impossible to create lightning invoice ({ex.Message})", ex); } } paymentPrompt.Destination = lightningInvoice.BOLT11; var details = new LigthningPaymentPromptDetails { PaymentHash = BOLT11PaymentRequest.Parse(lightningInvoice.BOLT11, _Network.NBitcoinNetwork).PaymentHash, Preimage = string.IsNullOrEmpty(lightningInvoice.Preimage) ? null : uint256.Parse(lightningInvoice.Preimage), InvoiceId = lightningInvoice.Id, NodeInfo = (await nodeInfo).FirstOrDefault()?.ToString() }; paymentPrompt.Details = JObject.FromObject(details, Serializer); } public async Task GetNodeInfo(LightningPaymentMethodConfig supportedPaymentMethod, PrefixedInvoiceLogs? invoiceLogs, bool? preferOnion = null, bool throws = false) { var synced = _Dashboard.IsFullySynched(_Network.CryptoCode, out var summary); if (supportedPaymentMethod.IsInternalNode && !synced) throw new PaymentMethodUnavailableException("Full node not available"); try { using var cts = new CancellationTokenSource(LightningTimeout); var client = CreateLightningClient(supportedPaymentMethod); // LNDhub-compatible implementations might not offer all of GetInfo data. // Skip checks in those cases, see https://github.com/lnbits/lnbits/issues/1182 var isLndHub = client is LndHubLightningClient; LightningNodeInformation info; try { info = await client.GetInfo(cts.Token); } catch (OperationCanceledException) when (cts.IsCancellationRequested) { throw new PaymentMethodUnavailableException("The lightning node did not reply in a timely manner"); } catch (NotSupportedException) { // LNDhub, LNbits and others might not support this call, yet we can create invoices. return new NodeInfo[] { }; } catch (UnauthorizedAccessException) { // LND might return this with restricted macaroon, support this nevertheless.. return new NodeInfo[] { }; } catch (Exception ex) { throw new PaymentMethodUnavailableException($"Error while connecting to the API: {ex.Message}" + (!string.IsNullOrEmpty(ex.InnerException?.Message) ? $" ({ex.InnerException.Message})" : "")); } // Node info might be empty if there are no public URIs to announce. The UI also supports this. var nodeInfo = preferOnion != null && info.NodeInfoList.Any(i => i.IsTor == preferOnion) ? info.NodeInfoList.Where(i => i.IsTor == preferOnion.Value).ToArray() : info.NodeInfoList.Select(i => i).ToArray(); if (summary.Status is not null) { var blocksGap = summary.Status.ChainHeight - info.BlockHeight; if (blocksGap > 10 && !(isLndHub && info.BlockHeight == 0)) { throw new PaymentMethodUnavailableException( $"The lightning node is not synched ({blocksGap} blocks left)"); } } return nodeInfo; } catch (Exception e) when (!throws) { invoiceLogs?.Write($"NodeInfo failed to be fetched: {e.Message}", InvoiceEventData.EventSeverity.Error); } return Array.Empty(); } public ILightningClient CreateLightningClient(LightningPaymentMethodConfig supportedPaymentMethod) { return supportedPaymentMethod.CreateLightningClient(_Network, Options.Value, _lightningClientFactory); } public async Task TestConnection(NodeInfo nodeInfo, CancellationToken cancellation) { try { if (!Utils.TryParseEndpoint(nodeInfo.Host, nodeInfo.Port, out var endpoint)) throw new PaymentMethodUnavailableException($"Could not parse the endpoint {nodeInfo.Host}"); using var tcp = await _socketFactory.ConnectAsync(endpoint, cancellation); } catch (Exception ex) { throw new PaymentMethodUnavailableException($"Error while connecting to the lightning node via {nodeInfo.Host}:{nodeInfo.Port} ({ex.Message})"); } } public LightningPaymentMethodConfig ParsePaymentMethodConfig(JToken config) { return config.ToObject(Serializer) ?? throw new FormatException($"Invalid {nameof(LightningPaymentMethodConfig)}"); } object IPaymentMethodHandler.ParsePaymentMethodConfig(JToken config) { return ParsePaymentMethodConfig(config); } object IPaymentMethodHandler.ParsePaymentPromptDetails(JToken details) { return ParsePaymentPromptDetails(details); } public LigthningPaymentPromptDetails ParsePaymentPromptDetails(JToken details) { return details.ToObject(Serializer) ?? throw new FormatException($"Invalid {nameof(LigthningPaymentPromptDetails)}"); } public LightningPaymentData ParsePaymentDetails(JToken details) { return details.ToObject(Serializer) ?? throw new FormatException($"Invalid {nameof(LightningPaymentData)}"); } object IPaymentMethodHandler.ParsePaymentDetails(JToken details) { return ParsePaymentDetails(details); } public async Task ValidatePaymentMethodConfig(PaymentMethodConfigValidationContext validationContext) { if (validationContext.Config is JValue { Type: JTokenType.String }) validationContext.Config = new JObject() { ["connectionString"] = validationContext.Config.Value()! }; #pragma warning disable CS0618 // Type or member is obsolete var config = ParsePaymentMethodConfig(validationContext.Config); if (config.ConnectionString == LightningPaymentMethodConfig.InternalNode) config.SetInternalNode(); LightningPaymentMethodConfig? oldConfig = null; if (validationContext.PreviousConfig is not null) oldConfig = ParsePaymentMethodConfig(validationContext.PreviousConfig); var connectionStringChanged = oldConfig?.ConnectionString != config.ConnectionString; if (connectionStringChanged && !string.IsNullOrEmpty(config.ConnectionString)) { // Let's check the connection string can be parsed and is safe to use for non-admin. try { var client = _lightningClientFactory.Create(config.ConnectionString, _Network); if (!client.IsSafe()) { var canManage = (await validationContext.AuthorizationService.AuthorizeAsync(validationContext.User, null, new PolicyRequirement(Policies.CanModifyServerSettings))).Succeeded; if (!canManage) { validationContext.ModelState.AddModelError(nameof(config.ConnectionString), $"You do not have 'btcpay.server.canmodifyserversettings' rights, so the connection string should not contain 'cookiefilepath', 'macaroondirectorypath', 'macaroonfilepath', and should not point to a local ip or to a dns name ending with '.internal', '.local', '.lan' or '.'."); return; } } } catch { validationContext.ModelState.AddModelError(nameof(config.ConnectionString), "Invalid connection string"); return; } } if (oldConfig?.IsInternalNode != config.IsInternalNode && config.IsInternalNode) { var canUseInternalNode = _policies.Settings.AllowLightningInternalNodeForAll || (await validationContext.AuthorizationService.AuthorizeAsync(validationContext.User, null, new PolicyRequirement(Policies.CanUseInternalLightningNode))).Succeeded && _lightningNetworkOptions.Value.InternalLightningByCryptoCode.ContainsKey(_Network.CryptoCode); if (!canUseInternalNode) { validationContext.SetMissingPermission(Policies.CanUseInternalLightningNode, $"You are not authorized to use the internal lightning node. Either add '{Policies.CanUseInternalLightningNode}' to an API Key, or allow non-admin users to use the internal lightning node in the server settings."); return; } } if (!config.IsInternalNode && string.IsNullOrEmpty(config.ConnectionString)) { validationContext.ModelState.AddModelError(nameof(config.ConnectionString), "The connection string or setting the internal node is required"); return; } validationContext.Config = JToken.FromObject(config, Serializer); #pragma warning restore CS0618 // Type or member is obsolete } } }