Files
btcpayserver/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs

337 lines
16 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#nullable enable
using System;
using System.ComponentModel.DataAnnotations;
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, IPaymentMethodHandler
{
new 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<PoliciesSettings> _policies;
private readonly IOptions<LightningNetworkOptions> _lightningNetworkOptions;
public LightningLikePaymentHandler(
PaymentMethodId paymentMethodId,
NBXplorerDashboard dashboard,
LightningClientFactoryService lightningClientFactory,
BTCPayNetwork network,
SocketFactory socketFactory,
IOptions<LightningNetworkOptions> options,
ISettingsAccessor<PoliciesSettings> policies,
IOptions<LightningNetworkOptions> 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;
context.Prompt.RateDivisibility = 8;
return Task.CompletedTask;
}
public PaymentMethodId PaymentMethodId { get; private set; }
public IOptions<LightningNetworkOptions> Options { get; }
public BTCPayNetwork Network => _Network;
static LightMoney OneSat = LightMoney.FromUnit(1.0m, LightMoneyUnit.Satoshi);
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 = paymentPrompt.Calculate().Due;
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);
var diff = request.Amount - lightningInvoice.Amount;
if (diff != LightMoney.Zero)
{
// Some providers doesn't round up to msat. So we tweak the fees so the due match the BOLT11's amount.
paymentPrompt.AddTweakFee(-diff.ToUnit(LightMoneyUnit.BTC));
}
}
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 = lightningInvoice.GetPaymentHash(_Network.NBitcoinNetwork),
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<NodeInfo[]> GetNodeInfo(LightningPaymentMethodConfig supportedPaymentMethod, PrefixedInvoiceLogs? invoiceLogs,
bool? preferOnion = null, bool throws = false)
{
var synced = _Dashboard.IsFullySynched(_Network.CryptoCode, out var summary);
if (supportedPaymentMethod.IsInternalNode && !synced)
invoiceLogs?.Write("The full node isnt synced yet. Lightning payments may not function correctly.", InvoiceEventData.EventSeverity.Warning);
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<NodeInfo>();
}
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<LightningPaymentMethodConfig>(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<LigthningPaymentPromptDetails>(Serializer) ?? throw new FormatException($"Invalid {nameof(LigthningPaymentPromptDetails)}");
}
public LightningPaymentData ParsePaymentDetails(JToken details)
{
return details.ToObject<LightningPaymentData>(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<string>()! };
#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 is IExtendedLightningClient vlc)
{
var result = await vlc.Validate();
if (result != ValidationResult.Success)
{
validationContext.ModelState.AddModelError(nameof(config.ConnectionString), result?.ErrorMessage ?? "Invalid connection string");
return;
}
}
if (!client.IsSafe(config.ConnectionString))
{
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 (FormatException ex)
{
validationContext.ModelState.AddModelError(nameof(config.ConnectionString), ex.Message);
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
}
}
}